Add OAuth2 API scope registration and enforcement

Register v1 API scopes in coredata, advertise them in OIDC discovery
and protected-resource metadata, show them on the consent screen, and
enforce scope-to-action mapping in the IAM Authorizer before policy
evaluation.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-15 17:33:15 +02:00
parent 25151fa089
commit 3ebb221a9b
56 changed files with 1918 additions and 290 deletions

View File

@@ -27,6 +27,7 @@ import (
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/iam/policy"
)
@@ -82,19 +83,21 @@ type AuthorizeMultiParams struct {
// Authorizer evaluates authorization requests against registered policies.
type Authorizer struct {
pg *pg.Client
evaluator *policy.Evaluator
policySet *PolicySet
logger *log.Logger
pg *pg.Client
evaluator *policy.Evaluator
policySet *PolicySet
oauth2ScopeSet *ScopeSet
logger *log.Logger
}
// NewAuthorizer creates a new Authorizer instance.
func NewAuthorizer(pgClient *pg.Client, logger *log.Logger) *Authorizer {
return &Authorizer{
pg: pgClient,
evaluator: policy.NewEvaluator(),
policySet: NewPolicySet(),
logger: logger,
pg: pgClient,
evaluator: policy.NewEvaluator(),
policySet: NewPolicySet(),
oauth2ScopeSet: NewScopeSet(),
logger: logger,
}
}
@@ -103,6 +106,37 @@ func (a *Authorizer) RegisterPolicySet(ps *PolicySet) {
a.policySet.Merge(ps)
}
// RegisterScopes merges OAuth2 scope-to-action mappings into the authorizer.
func (a *Authorizer) RegisterScopes(ss *ScopeSet) {
a.oauth2ScopeSet.Merge(ss)
}
// APIScopes returns OAuth2 API scopes advertised in discovery metadata.
func (a *Authorizer) APIScopes() []coredata.OAuth2Scope {
if a.oauth2ScopeSet == nil {
return []coredata.OAuth2Scope{}
}
return a.oauth2ScopeSet.APIScopes()
}
func (a *Authorizer) checkOAuth2Scope(
ctx context.Context,
principal gid.GID,
action Action,
) error {
accessToken, ok := oauth2.AccessTokenFromContext(ctx)
if !ok {
return nil
}
if a.oauth2ScopeSet == nil || !a.oauth2ScopeSet.Allows(accessToken.Scopes, action) {
return NewInsufficientOAuth2ScopeError(principal, action)
}
return nil
}
// Authorize checks if the principal is allowed to perform the action on the resource.
func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) (*coredata.Scope, error) {
return a.AuthorizeBatch(
@@ -459,6 +493,22 @@ func (a *Authorizer) evaluateMultiInTx(
continue
}
if err := a.checkOAuth2Scope(ctx, params.Principal, item.Action); err != nil {
decisions[i] = err
a.logDecision(
ctx,
DecisionRecord{
Effect: effectError,
Action: item.Action,
ResourceID: item.Resource,
Principal: params.Principal,
Reason: err.Error(),
},
)
continue
}
req := policy.AuthorizationRequest{
Principal: params.Principal,
Resource: item.Resource,

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package iam
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/oauth2"
)
func TestAuthorizer_checkOAuth2Scope(t *testing.T) {
t.Parallel()
const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read")
principal := gid.New(gid.NilTenant, coredata.IdentityEntityType)
action := Action("core:organization:get")
t.Run("skips when access token is absent", func(t *testing.T) {
t.Parallel()
a := &Authorizer{}
err := a.checkOAuth2Scope(context.Background(), principal, action)
require.NoError(t, err)
})
t.Run("denies before IAM when scopes are not registered", func(t *testing.T) {
t.Parallel()
a := &Authorizer{}
ctx := oauth2.ContextWithAccessToken(
context.Background(),
&coredata.OAuth2AccessToken{Scopes: coredata.OAuth2Scopes{scopeV1OrgRead}},
)
err := a.checkOAuth2Scope(ctx, principal, action)
require.Error(t, err)
scopeErr, ok := errors.AsType[*ErrInsufficientOAuth2Scope](err)
require.True(t, ok)
assert.Equal(t, principal, scopeErr.IdentityID)
assert.Equal(t, action, scopeErr.Action)
})
t.Run("allows when registered scopes authorize the action", func(t *testing.T) {
t.Parallel()
a := NewAuthorizer(nil, nil)
a.RegisterScopes(
CreateScopeSet(
map[coredata.OAuth2Scope][]Action{
scopeV1OrgRead: {action},
},
),
)
ctx := oauth2.ContextWithAccessToken(
context.Background(),
&coredata.OAuth2AccessToken{Scopes: coredata.OAuth2Scopes{scopeV1OrgRead}},
)
err := a.checkOAuth2Scope(ctx, principal, action)
require.NoError(t, err)
})
}

View File

@@ -203,6 +203,23 @@ func (e ErrInsufficientPermissions) Error() string {
return fmt.Sprintf("identity %q does not have sufficient permissions to perform action %s on entity %q", e.IdentityID, e.Action, e.EntityID)
}
type ErrInsufficientOAuth2Scope struct {
IdentityID gid.GID
Action Action
}
func NewInsufficientOAuth2ScopeError(identityID gid.GID, action Action) error {
return &ErrInsufficientOAuth2Scope{IdentityID: identityID, Action: action}
}
func (e ErrInsufficientOAuth2Scope) Error() string {
return fmt.Sprintf(
"identity %q does not have an OAuth2 scope granting action %s",
e.IdentityID,
e.Action,
)
}
type ErrMixedOrganizationBatch struct {
Action Action
OrganizationIDs []string

View File

@@ -94,9 +94,6 @@ const (
ActionOAuth2ConsentGet = "iam:oauth2-consent:get"
ActionOAuth2ConsentApprove = "iam:oauth2-consent:approve"
// Connector actions
ActionConnectorGet = "iam:connector:get"
// Audit log entry actions
ActionAuditLogEntryGet = "iam:audit-log-entry:get"
ActionAuditLogEntryList = "iam:audit-log-entry:list"

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server
package oauth2
import (
"errors"

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server
package oauth2
import (
"context"
@@ -45,11 +45,11 @@ func NewGarbageCollector(
) *GarbageCollector {
h := &gcHandler{
pg: pgClient,
logger: logger.Named("oauth2server.garbage_collector"),
logger: logger.Named("oauth.garbage_collector"),
}
return worker.New(
"oauth2server.garbage_collector",
"oauth.garbage_collector",
h,
logger,
append(

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server
package oauth2
import (
"crypto/rsa"
@@ -96,10 +96,10 @@ func NewIDTokenClaims(
for _, scope := range scopes {
switch scope {
case coredata.OAuth2ScopeEmail:
case ScopeEmail:
claims.Email = email
claims.EmailVerified = &emailVerified
case coredata.OAuth2ScopeProfile:
case ScopeProfile:
claims.Name = fullName
}
}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server_test
package oauth2_test
import (
"crypto/sha256"
@@ -24,7 +24,7 @@ import (
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/uri"
)
@@ -42,7 +42,7 @@ func TestComputeAtHash(t *testing.T) {
h := sha256.Sum256([]byte(accessToken))
expected := base64.RawURLEncoding.EncodeToString(h[:16])
result := oauth2server.ComputeAtHash(accessToken)
result := oauth2.ComputeAtHash(accessToken)
assert.Equal(t, expected, result)
},
)
@@ -52,8 +52,8 @@ func TestComputeAtHash(t *testing.T) {
func(t *testing.T) {
t.Parallel()
hash1 := oauth2server.ComputeAtHash("token-a")
hash2 := oauth2server.ComputeAtHash("token-b")
hash1 := oauth2.ComputeAtHash("token-a")
hash2 := oauth2.ComputeAtHash("token-b")
assert.NotEqual(t, hash1, hash2)
},
)
@@ -63,7 +63,7 @@ func TestComputeAtHash(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ComputeAtHash("")
result := oauth2.ComputeAtHash("")
assert.NotEmpty(t, result)
},
)
@@ -73,8 +73,8 @@ func TestComputeAtHash(t *testing.T) {
func(t *testing.T) {
t.Parallel()
hash1 := oauth2server.ComputeAtHash("same-token")
hash2 := oauth2server.ComputeAtHash("same-token")
hash1 := oauth2.ComputeAtHash("same-token")
hash2 := oauth2.ComputeAtHash("same-token")
assert.Equal(t, hash1, hash2)
},
)
@@ -92,12 +92,12 @@ func TestNewIDTokenClaims(t *testing.T) {
func(t *testing.T) {
t.Parallel()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"",
"",
"user@example.com",
@@ -123,12 +123,12 @@ func TestNewIDTokenClaims(t *testing.T) {
func(t *testing.T) {
t.Parallel()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"test-nonce",
"",
"",
@@ -146,12 +146,12 @@ func TestNewIDTokenClaims(t *testing.T) {
func(t *testing.T) {
t.Parallel()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"",
"access-token-123",
"",
@@ -160,7 +160,7 @@ func TestNewIDTokenClaims(t *testing.T) {
1*time.Hour,
)
expected := oauth2server.ComputeAtHash("access-token-123")
expected := oauth2.ComputeAtHash("access-token-123")
assert.Equal(t, expected, claims.AtHash)
},
)
@@ -170,12 +170,12 @@ func TestNewIDTokenClaims(t *testing.T) {
func(t *testing.T) {
t.Parallel()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail},
"",
"",
"user@example.com",
@@ -195,12 +195,12 @@ func TestNewIDTokenClaims(t *testing.T) {
func(t *testing.T) {
t.Parallel()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeProfile},
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeProfile},
"",
"",
"",
@@ -218,15 +218,15 @@ func TestNewIDTokenClaims(t *testing.T) {
func(t *testing.T) {
t.Parallel()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeEmail,
coredata.OAuth2ScopeProfile,
oauth2.ScopeOpenID,
oauth2.ScopeEmail,
oauth2.ScopeProfile,
},
"nonce-val",
"access-token",
@@ -252,12 +252,12 @@ func TestNewIDTokenClaims(t *testing.T) {
ttl := 2 * time.Hour
before := time.Now()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"",
"",
"",
@@ -279,12 +279,12 @@ func TestNewIDTokenClaims(t *testing.T) {
func(t *testing.T) {
t.Parallel()
claims := oauth2server.NewIDTokenClaims(
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail},
"",
"",
"user@example.com",

View File

@@ -12,9 +12,11 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server
package oauth2
import (
"slices"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/uri"
)
@@ -33,6 +35,7 @@ type (
RevocationEndpoint uri.URI `json:"revocation_endpoint"`
DeviceAuthorizationEndpoint uri.URI `json:"device_authorization_endpoint"`
ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"`
ProtectedResources []uri.URI `json:"protected_resources,omitempty"`
ResponseTypesSupported []coredata.OAuth2ResponseType `json:"response_types_supported"`
GrantTypesSupported []coredata.OAuth2GrantType `json:"grant_types_supported"`
TokenEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_methods_supported"`
@@ -57,7 +60,7 @@ type (
}
)
func NewMetadata(issuer uri.URI, endpoints Endpoints) *ServerMetadata {
func NewMetadata(issuer uri.URI, endpoints Endpoints, apiScopes []coredata.OAuth2Scope) *ServerMetadata {
return &ServerMetadata{
Issuer: issuer,
AuthorizationEndpoint: endpoints.Authorization,
@@ -68,12 +71,16 @@ func NewMetadata(issuer uri.URI, endpoints Endpoints) *ServerMetadata {
IntrospectionEndpoint: endpoints.Introspection,
RevocationEndpoint: endpoints.Revocation,
DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization,
ScopesSupported: []coredata.OAuth2Scope{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeProfile,
coredata.OAuth2ScopeEmail,
coredata.OAuth2ScopeOfflineAccess,
},
ScopesSupported: slices.Concat(
[]coredata.OAuth2Scope{
ScopeOpenID,
ScopeProfile,
ScopeEmail,
ScopeOfflineAccess,
},
apiScopes,
),
ProtectedResources: []uri.URI{issuer},
ResponseTypesSupported: []coredata.OAuth2ResponseType{
coredata.OAuth2ResponseTypeCode,
},

View File

@@ -12,23 +12,27 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server_test
package oauth2_test
import (
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/uri"
)
func TestNewMetadata(t *testing.T) {
t.Parallel()
apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead}
issuer := uri.URI("https://auth.example.com")
endpoints := oauth2server.Endpoints{
endpoints := oauth2.Endpoints{
Authorization: "https://auth.example.com/authorize",
Token: "https://auth.example.com/token",
Userinfo: "https://auth.example.com/userinfo",
@@ -39,7 +43,7 @@ func TestNewMetadata(t *testing.T) {
DeviceAuthorization: "https://auth.example.com/device",
}
metadata := oauth2server.NewMetadata(issuer, endpoints)
metadata := oauth2.NewMetadata(issuer, endpoints, apiScopes)
require.NotNil(t, metadata)
t.Run(
@@ -72,16 +76,28 @@ func TestNewMetadata(t *testing.T) {
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
expectedScopes := slices.Concat(
[]coredata.OAuth2Scope{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeProfile,
coredata.OAuth2ScopeEmail,
coredata.OAuth2ScopeOfflineAccess,
oauth2.ScopeOpenID,
oauth2.ScopeProfile,
oauth2.ScopeEmail,
oauth2.ScopeOfflineAccess,
},
metadata.ScopesSupported,
apiScopes,
)
assert.Equal(t, expectedScopes, metadata.ScopesSupported)
assert.Contains(t, metadata.ScopesSupported, oauth2.ScopeOpenID)
assert.Contains(t, metadata.ScopesSupported, probo.ScopeV1DocumentRead)
},
)
t.Run(
"protected resources",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, []uri.URI{issuer}, metadata.ProtectedResources)
},
)

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server
package oauth2
import (
"crypto/sha256"

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server_test
package oauth2_test
import (
"crypto/sha256"
@@ -22,7 +22,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
)
func computeS256Challenge(verifier string) string {
@@ -41,7 +41,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethodS256,
@@ -56,7 +56,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
"wrong-verifier",
challenge,
coredata.OAuth2CodeChallengeMethodS256,
@@ -71,7 +71,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
verifier,
"wrong-challenge",
coredata.OAuth2CodeChallengeMethodS256,
@@ -86,7 +86,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethod("plain"),
@@ -101,7 +101,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethod(""),
@@ -116,7 +116,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
"",
challenge,
coredata.OAuth2CodeChallengeMethodS256,
@@ -131,7 +131,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
verifier,
"",
coredata.OAuth2CodeChallengeMethodS256,
@@ -146,7 +146,7 @@ func TestValidateCodeChallenge(t *testing.T) {
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
result := oauth2.ValidateCodeChallenge(
"",
"",
coredata.OAuth2CodeChallengeMethodS256,

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2
import (
"slices"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/uri"
)
// ProtectedResourceMetadata represents the RFC 9728 protected resource metadata
// document published at /.well-known/oauth-protected-resource.
type ProtectedResourceMetadata struct {
Resource uri.URI `json:"resource"`
AuthorizationServers []uri.URI `json:"authorization_servers"`
BearerMethodsSupported []string `json:"bearer_methods_supported"`
ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"`
}
func NewProtectedResourceMetadata(
resource uri.URI,
authorizationServer uri.URI,
apiScopes []coredata.OAuth2Scope,
) *ProtectedResourceMetadata {
return &ProtectedResourceMetadata{
Resource: resource,
AuthorizationServers: []uri.URI{authorizationServer},
BearerMethodsSupported: []string{
"header",
},
ScopesSupported: slices.Concat(
[]coredata.OAuth2Scope{ScopeOpenID},
apiScopes,
),
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/uri"
)
func TestNewProtectedResourceMetadata(t *testing.T) {
t.Parallel()
apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead}
resource := uri.URI("https://app.example.com")
authorizationServer := uri.URI("https://app.example.com")
metadata := oauth2.NewProtectedResourceMetadata(resource, authorizationServer, apiScopes)
require.NotNil(t, metadata)
assert.Equal(t, resource, metadata.Resource)
assert.Equal(t, []uri.URI{authorizationServer}, metadata.AuthorizationServers)
assert.Equal(t, []string{"header"}, metadata.BearerMethodsSupported)
assert.Contains(t, metadata.ScopesSupported, oauth2.ScopeOpenID)
assert.Contains(t, metadata.ScopesSupported, probo.ScopeV1DocumentRead)
assert.NotContains(t, metadata.ScopesSupported, oauth2.ScopeProfile)
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2
import (
"context"
"go.probo.inc/probo/pkg/coredata"
)
type contextKey struct{ name string }
var (
accessTokenContextKey = &contextKey{name: "oauth2_access_token"}
clientContextKey = &contextKey{name: "oauth2_client"}
)
func ContextWithAccessToken(ctx context.Context, accessToken *coredata.OAuth2AccessToken) context.Context {
return context.WithValue(ctx, accessTokenContextKey, accessToken)
}
func AccessTokenFromContext(ctx context.Context) (*coredata.OAuth2AccessToken, bool) {
accessToken, ok := ctx.Value(accessTokenContextKey).(*coredata.OAuth2AccessToken)
return accessToken, ok
}
func ContextWithClient(ctx context.Context, client *coredata.OAuth2Client) context.Context {
return context.WithValue(ctx, clientContextKey, client)
}
func ClientFromContext(ctx context.Context) (*coredata.OAuth2Client, bool) {
client, ok := ctx.Value(clientContextKey).(*coredata.OAuth2Client)
return client, ok
}

72
pkg/iam/oauth2/scope.go Normal file
View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2
import (
"fmt"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const (
ScopeOpenID coredata.OAuth2Scope = "openid"
ScopeProfile coredata.OAuth2Scope = "profile"
ScopeEmail coredata.OAuth2Scope = "email"
ScopeOfflineAccess coredata.OAuth2Scope = "offline_access"
)
func IsStandardScope(scope coredata.OAuth2Scope) bool {
switch scope {
case ScopeOpenID, ScopeProfile, ScopeEmail, ScopeOfflineAccess:
return true
}
return false
}
func IsValid(scope coredata.OAuth2Scope) bool {
return IsStandardScope(scope)
}
func UnmarshalScope(text []byte) (coredata.OAuth2Scope, error) {
scope := coredata.OAuth2Scope(text)
if !IsValid(scope) {
return "", fmt.Errorf("invalid oauth2 scope value: %q", string(text))
}
return scope, nil
}
func UnmarshalScopes(text []byte) (coredata.OAuth2Scopes, error) {
str := string(text)
if str == "" {
return nil, nil
}
fields := strings.Fields(str)
scopes := make(coredata.OAuth2Scopes, len(fields))
for i, f := range fields {
scope, err := UnmarshalScope([]byte(f))
if err != nil {
return nil, err
}
scopes[i] = scope
}
return scopes, nil
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2_test
import (
"testing"
"github.com/stretchr/testify/assert"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam/oauth2"
)
func TestIsValid(t *testing.T) {
t.Parallel()
t.Run(
"offline_access is valid",
func(t *testing.T) {
t.Parallel()
assert.True(t, oauth2.IsValid(oauth2.ScopeOfflineAccess))
},
)
t.Run(
"unknown scope is invalid",
func(t *testing.T) {
t.Parallel()
assert.False(t, oauth2.IsValid(coredata.OAuth2Scope("admin")))
},
)
}
func TestUnmarshalScope(t *testing.T) {
t.Parallel()
t.Run(
"offline_access unmarshals",
func(t *testing.T) {
t.Parallel()
scope, err := oauth2.UnmarshalScope([]byte("offline_access"))
assert.NoError(t, err)
assert.Equal(t, oauth2.ScopeOfflineAccess, scope)
},
)
t.Run(
"invalid scope returns error",
func(t *testing.T) {
t.Parallel()
_, err := oauth2.UnmarshalScope([]byte("admin"))
assert.Error(t, err)
},
)
}
func TestOAuth2ScopesContains(t *testing.T) {
t.Parallel()
t.Run(
"contains offline_access",
func(t *testing.T) {
t.Parallel()
scopes := coredata.OAuth2Scopes{
oauth2.ScopeOpenID,
oauth2.ScopeOfflineAccess,
}
assert.True(t, scopes.Contains(oauth2.ScopeOfflineAccess))
},
)
t.Run(
"does not contain offline_access",
func(t *testing.T) {
t.Parallel()
scopes := coredata.OAuth2Scopes{
oauth2.ScopeOpenID,
oauth2.ScopeProfile,
}
assert.False(t, scopes.Contains(oauth2.ScopeOfflineAccess))
},
)
}
func TestOAuth2ScopesOrDefault(t *testing.T) {
t.Parallel()
defaultScopes := coredata.OAuth2Scopes{
oauth2.ScopeOpenID,
oauth2.ScopeProfile,
}
t.Run(
"returns default when scopes is nil",
func(t *testing.T) {
t.Parallel()
var scopes coredata.OAuth2Scopes
result := scopes.OrDefault(defaultScopes)
assert.Equal(t, defaultScopes, result)
},
)
t.Run(
"returns default when scopes is empty",
func(t *testing.T) {
t.Parallel()
scopes := coredata.OAuth2Scopes{}
result := scopes.OrDefault(defaultScopes)
assert.Equal(t, defaultScopes, result)
},
)
t.Run(
"returns scopes when non-empty",
func(t *testing.T) {
t.Parallel()
scopes := coredata.OAuth2Scopes{oauth2.ScopeEmail}
result := scopes.OrDefault(defaultScopes)
assert.Equal(t, scopes, result)
},
)
}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package oauth2server
package oauth2
import (
"context"
@@ -194,11 +194,6 @@ func (s *Service) Run(ctx context.Context) error {
return s.gc.Run(ctx)
}
// Metadata returns the OIDC discovery document.
func (s *Service) Metadata(endpoints Endpoints) *ServerMetadata {
return NewMetadata(s.baseURL, endpoints)
}
// JWKS returns the public key set.
func (s *Service) JWKS() *jose.JWKS {
jwks := &jose.JWKS{
@@ -384,7 +379,7 @@ func (s *Service) ExchangeAuthorizationCode(
}
}
if code.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
if code.Scopes.Contains(ScopeOpenID) {
var (
idTokenClaims = NewIDTokenClaims(
s.baseURL,
@@ -426,7 +421,7 @@ func (s *Service) ExchangeAuthorizationCode(
return fmt.Errorf("cannot create access token: %w", err)
}
if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && code.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) {
if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && code.Scopes.Contains(ScopeOfflineAccess) {
refreshTokenValue = rand.MustHexString(refreshTokenByteLength)
refreshToken := &coredata.OAuth2RefreshToken{
@@ -560,7 +555,7 @@ func (s *Service) RefreshToken(
)
}
if previousRefreshToken.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
if previousRefreshToken.Scopes.Contains(ScopeOpenID) {
var (
claims = NewIDTokenClaims(
s.baseURL,
@@ -689,7 +684,7 @@ func (s *Service) CreateDeviceCode(
)
}
if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) {
if requestedScopes.Contains(ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) {
return NewError(
ErrInvalidScope,
WithDescription("offline_access requires the refresh_token grant type"),
@@ -846,7 +841,7 @@ func (s *Service) PollDeviceCode(
idToken string
)
if deviceCode.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
if deviceCode.Scopes.Contains(ScopeOpenID) {
var (
claims = NewIDTokenClaims(
s.baseURL,
@@ -887,7 +882,7 @@ func (s *Service) PollDeviceCode(
return fmt.Errorf("cannot create access token: %w", err)
}
if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && deviceCode.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) {
if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && deviceCode.Scopes.Contains(ScopeOfflineAccess) {
refreshTokenValue = rand.MustHexString(refreshTokenByteLength)
refreshToken := &coredata.OAuth2RefreshToken{
@@ -1287,10 +1282,10 @@ func (s *Service) UserInfo(
for _, scope := range scopes {
switch scope {
case coredata.OAuth2ScopeEmail:
case ScopeEmail:
claims["email"] = identity.EmailAddress.String()
claims["email_verified"] = identity.EmailAddressVerified
case coredata.OAuth2ScopeProfile:
case ScopeProfile:
claims["name"] = identity.FullName
}
}
@@ -1445,7 +1440,7 @@ func (s *Service) Authorize(
return fmt.Errorf("cannot authorize: requested scope exceeds client registration")
}
if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) {
if requestedScopes.Contains(ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) {
return NewError(
ErrInvalidScope,
WithDescription("offline_access requires the refresh_token grant type"),

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package iam_test
import (
"testing"
"github.com/stretchr/testify/assert"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/agentrun"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
)
func allRegisteredOAuth2ScopeSets() *iam.ScopeSet {
return iam.NewScopeSet().
Merge(iam.IAMOAuth2ScopeSet()).
Merge(probo.OAuth2ScopeSet()).
Merge(accessreview.OAuth2ScopeSet()).
Merge(agentrun.OAuth2ScopeSet())
}
func registerAllOAuth2ScopeSets(authorizer *iam.Authorizer) {
authorizer.RegisterScopes(iam.IAMOAuth2ScopeSet())
authorizer.RegisterScopes(probo.OAuth2ScopeSet())
authorizer.RegisterScopes(accessreview.OAuth2ScopeSet())
authorizer.RegisterScopes(agentrun.OAuth2ScopeSet())
}
func TestRegisteredOAuth2ScopeSets_OrganizationRead(t *testing.T) {
t.Parallel()
authorizer := iam.NewAuthorizer(nil, nil)
registerAllOAuth2ScopeSets(authorizer)
scopeSet := allRegisteredOAuth2ScopeSets()
tokenScopes := coredata.OAuth2Scopes{probo.ScopeV1OrgRead}
assert.True(t, scopeSet.Allows(tokenScopes, probo.ActionOrganizationGet))
assert.False(t, scopeSet.Allows(tokenScopes, probo.ActionOrganizationUpdate))
assert.False(t, scopeSet.Allows(tokenScopes, probo.ActionThirdPartyList))
}
func TestRegisteredOAuth2ScopeSets_UnmappedActionDenies(t *testing.T) {
t.Parallel()
authorizer := iam.NewAuthorizer(nil, nil)
registerAllOAuth2ScopeSets(authorizer)
scopeSet := allRegisteredOAuth2ScopeSets()
tokenScopes := coredata.OAuth2Scopes{
probo.ScopeV1OrgRead,
probo.ScopeV1ThirdPartyRead,
}
assert.False(t, scopeSet.Allows(tokenScopes, "core:unmapped:action"))
}

87
pkg/iam/oauth2_scopes.go Normal file
View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package iam
import "go.probo.inc/probo/pkg/coredata"
const (
ScopeV1IAMRead coredata.OAuth2Scope = "v1:iam:read"
ScopeV1IAM coredata.OAuth2Scope = "v1:iam"
)
// IAMOAuth2ScopeSet returns OAuth2 scope mappings for IAM actions.
func IAMOAuth2ScopeSet() *ScopeSet {
return CreateScopeSet(
map[coredata.OAuth2Scope][]Action{
ScopeV1IAMRead: {
ActionOrganizationGet,
ActionOrganizationList,
ActionIdentityGet,
ActionSessionList,
ActionSessionGet,
ActionInvitationList,
ActionInvitationGet,
ActionMembershipGet,
ActionMembershipList,
ActionMembershipProfileGet,
ActionMembershipProfileList,
ActionPersonalAPIKeyGet,
ActionPersonalAPIKeyList,
ActionSAMLConfigurationGet,
ActionSAMLConfigurationList,
ActionSCIMConfigurationGet,
ActionSCIMEventList,
ActionSCIMEventGet,
ActionSCIMBridgeGet,
ActionOAuth2ConsentGet,
ActionAuditLogEntryGet,
ActionAuditLogEntryList,
},
ScopeV1IAM: {
ActionOrganizationCreate,
ActionOrganizationUpdate,
ActionOrganizationDelete,
ActionIdentityUpdate,
ActionIdentityDelete,
ActionSessionRevoke,
ActionSessionRevokeAll,
ActionInvitationCreate,
ActionInvitationAccept,
ActionInvitationDelete,
ActionMembershipUpdate,
ActionMembershipDelete,
ActionMembershipRoleSetOwner,
ActionMembershipProfileCreate,
ActionMembershipProfileUpdate,
ActionMembershipProfileDelete,
ActionMembershipProfileActivate,
ActionMembershipProfileDeactivate,
ActionPersonalAPIKeyCreate,
ActionPersonalAPIKeyUpdate,
ActionPersonalAPIKeyDelete,
ActionSAMLConfigurationCreate,
ActionSAMLConfigurationUpdate,
ActionSAMLConfigurationDelete,
ActionSCIMConfigurationCreate,
ActionSCIMConfigurationUpdate,
ActionSCIMConfigurationDelete,
ActionSCIMBridgeCreate,
ActionSCIMBridgeUpdate,
ActionSCIMBridgeDelete,
ActionOAuth2ConsentApprove,
},
},
)
}

102
pkg/iam/scope_set.go Normal file
View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package iam
import (
"cmp"
"maps"
"slices"
"go.probo.inc/probo/pkg/coredata"
)
// ScopeSet holds OAuth2 scope to IAM action mappings. Services create their
// own ScopeSet and register it on the Authorizer at composition time.
type ScopeSet struct {
scopeActions map[coredata.OAuth2Scope][]Action
actionScopes map[Action][]coredata.OAuth2Scope
}
// NewScopeSet creates an empty ScopeSet.
func NewScopeSet() *ScopeSet {
return &ScopeSet{
scopeActions: make(map[coredata.OAuth2Scope][]Action),
}
}
// CreateScopeSet creates a ScopeSet from scope-to-action mappings. Entries with
// no actions are skipped.
func CreateScopeSet(mappings map[coredata.OAuth2Scope][]Action) *ScopeSet {
s := NewScopeSet()
for scope, actions := range mappings {
if len(actions) == 0 {
continue
}
s.scopeActions[scope] = append(s.scopeActions[scope], actions...)
}
s.rebuildActionScopes()
return s
}
// Merge combines another ScopeSet into this one.
func (s *ScopeSet) Merge(other *ScopeSet) *ScopeSet {
for scope, actions := range other.scopeActions {
s.scopeActions[scope] = append(s.scopeActions[scope], actions...)
}
s.rebuildActionScopes()
return s
}
// APIScopes returns every registered OAuth2 API scope in this set.
func (s *ScopeSet) APIScopes() []coredata.OAuth2Scope {
return sortedScopes(slices.Collect(maps.Keys(s.scopeActions)))
}
// Allows reports whether tokenScopes authorize action.
func (s *ScopeSet) Allows(tokenScopes coredata.OAuth2Scopes, action Action) bool {
grantingScopes, ok := s.actionScopes[action]
if !ok {
return false
}
return slices.ContainsFunc(grantingScopes, tokenScopes.Contains)
}
func (s *ScopeSet) rebuildActionScopes() {
actionScopes := make(map[Action][]coredata.OAuth2Scope, len(s.scopeActions)*4)
for scope, actions := range s.scopeActions {
for _, action := range actions {
actionScopes[action] = append(actionScopes[action], scope)
}
}
s.actionScopes = actionScopes
}
func sortedScopes(scopes []coredata.OAuth2Scope) []coredata.OAuth2Scope {
sorted := slices.Clone(scopes)
slices.SortFunc(sorted, func(a, b coredata.OAuth2Scope) int {
return cmp.Compare(string(a), string(b))
})
return sorted
}

88
pkg/iam/scope_set_test.go Normal file
View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package iam
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestScopeSet_Allows(t *testing.T) {
t.Parallel()
const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read")
scopeSet := CreateScopeSet(
map[coredata.OAuth2Scope][]Action{
scopeV1OrgRead: {"core:organization:get"},
},
)
tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead}
assert.True(t, scopeSet.Allows(tokenScopes, "core:organization:get"))
assert.False(t, scopeSet.Allows(tokenScopes, "core:organization:update"))
}
func TestScopeSet_Merge(t *testing.T) {
t.Parallel()
const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read")
scopeSet := NewScopeSet().
Merge(
CreateScopeSet(
map[coredata.OAuth2Scope][]Action{
scopeV1OrgRead: {"core:organization:get"},
},
),
).
Merge(
CreateScopeSet(
map[coredata.OAuth2Scope][]Action{
scopeV1OrgRead: {"core:organization-context:get"},
},
),
)
tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead}
assert.True(t, scopeSet.Allows(tokenScopes, "core:organization:get"))
assert.True(t, scopeSet.Allows(tokenScopes, "core:organization-context:get"))
}
func TestAuthorizer_RegisterScopes(t *testing.T) {
t.Parallel()
const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read")
authorizer := NewAuthorizer(nil, nil)
authorizer.RegisterScopes(
CreateScopeSet(
map[coredata.OAuth2Scope][]Action{
scopeV1OrgRead: {"core:organization:get"},
},
),
)
require.NotNil(t, authorizer.oauth2ScopeSet)
tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead}
assert.True(t, authorizer.oauth2ScopeSet.Allows(tokenScopes, "core:organization:get"))
assert.False(t, authorizer.oauth2ScopeSet.Allows(tokenScopes, "core:organization:update"))
}

View File

@@ -33,7 +33,7 @@ import (
"go.probo.inc/probo/pkg/crypto/passwdhash"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/iam/oidc"
"go.probo.inc/probo/pkg/iam/saml"
"go.probo.inc/probo/pkg/iam/scim"
@@ -67,7 +67,7 @@ type (
OIDCService *oidc.Service
SCIMService *scim.Service
APIKeyService *APIKeyService
OAuth2ServerService *oauth2server.Service
OAuth2ServerService *oauth2.Service
Authorizer *Authorizer
samlDomainVerifier *SAMLDomainVerifier
@@ -95,8 +95,8 @@ type (
SCIMBridgePollInterval time.Duration
GoogleOIDC oidc.ProviderConfig
MicrosoftOIDC oidc.ProviderConfig
OAuth2ServerSigningKeys oauth2server.SigningKeys
OAuth2ServerOptions []oauth2server.Option
OAuth2ServerSigningKeys oauth2.SigningKeys
OAuth2ServerOptions []oauth2.Option
}
)
@@ -162,6 +162,7 @@ func NewService(
cfg.Logger.Named("authorizer"),
)
svc.Authorizer.RegisterPolicySet(IAMPolicySet())
svc.Authorizer.RegisterScopes(IAMOAuth2ScopeSet())
samlService, err := saml.NewService(svc.pg, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger)
if err != nil {
@@ -194,11 +195,11 @@ func NewService(
},
)
svc.OAuth2ServerService = oauth2server.NewService(
svc.OAuth2ServerService = oauth2.NewService(
pgClient,
cfg.OAuth2ServerSigningKeys,
uri.URI(cfg.BaseURL.String()),
cfg.Logger.Named("oauth2server"),
cfg.Logger.Named("oauth2"),
cfg.OAuth2ServerOptions...,
)
@@ -213,6 +214,16 @@ func NewService(
return svc, nil
}
// OAuth2ServerMetadata returns the OIDC discovery document.
func (s *Service) OAuth2ServerMetadata(endpoints oauth2.Endpoints) *oauth2.ServerMetadata {
return oauth2.NewMetadata(uri.URI(s.baseURL), endpoints, s.Authorizer.APIScopes())
}
// OAuth2ProtectedResourceMetadata returns the RFC 9728 protected resource metadata document.
func (s *Service) OAuth2ProtectedResourceMetadata(resource uri.URI) *oauth2.ProtectedResourceMetadata {
return oauth2.NewProtectedResourceMetadata(resource, resource, s.Authorizer.APIScopes())
}
func (s *Service) IsSignUpEnabled() bool {
return !s.disableSignup
}