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

113
pkg/iam/oauth2/errors.go Normal file
View File

@@ -0,0 +1,113 @@
// 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 (
"errors"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// OAuth2Error represents an OAuth2 protocol error with an associated
// error code per RFC 6749 §5.2 and RFC 8628 §3.5.
type OAuth2Error struct {
code string
description string
}
func (e *OAuth2Error) Error() string {
if e.description != "" {
return e.code + ": " + e.description
}
return e.code
}
func (e *OAuth2Error) ErrorCode() string { return e.code }
func (e *OAuth2Error) Description() string { return e.description }
func (e *OAuth2Error) Is(target error) bool {
t, ok := target.(*OAuth2Error)
if !ok {
return false
}
return e.code == t.code
}
type ErrorOption func(*OAuth2Error)
func WithDescription(description string) ErrorOption {
return func(e *OAuth2Error) {
e.description = description
}
}
func WithError(err error) ErrorOption {
return func(e *OAuth2Error) {
e.description = err.Error()
}
}
// NewError creates a new OAuth2Error derived from a sentinel error code.
func NewError(code *OAuth2Error, opts ...ErrorOption) *OAuth2Error {
e := &OAuth2Error{code: code.code}
for _, opt := range opts {
opt(e)
}
return e
}
var (
// OAuth2 error codes per RFC 6749 §5.2 and RFC 8628 §3.5.
ErrInvalidRequest = &OAuth2Error{code: "invalid_request"}
ErrInvalidClient = &OAuth2Error{code: "invalid_client"}
ErrInvalidGrant = &OAuth2Error{code: "invalid_grant"}
ErrUnauthorizedClient = &OAuth2Error{code: "unauthorized_client"}
ErrUnsupportedGrantType = &OAuth2Error{code: "unsupported_grant_type"}
ErrInvalidScope = &OAuth2Error{code: "invalid_scope"}
ErrAccessDenied = &OAuth2Error{code: "access_denied"}
ErrServerError = &OAuth2Error{code: "server_error"}
ErrInvalidRedirectURI = &OAuth2Error{code: "invalid_redirect_uri"}
// RFC 7009 revocation errors.
ErrUnsupportedTokenType = &OAuth2Error{code: "unsupported_token_type"}
// RFC 8628 device flow errors.
ErrAuthorizationPending = &OAuth2Error{code: "authorization_pending"}
ErrSlowDown = &OAuth2Error{code: "slow_down"}
ErrExpiredToken = &OAuth2Error{code: "expired_token"}
)
var (
ErrClientNotFound = errors.New("client not found")
ErrConsentNotFound = errors.New("consent not found")
ErrDeviceCodeNotPending = errors.New("device code is not pending")
ErrUnauthorizedMember = errors.New("user is not a member of the client organization")
)
// ConsentRequiredError is returned by Authorize when the user must approve
// the authorization request before a code can be issued.
type ConsentRequiredError struct {
ConsentID gid.GID
Client *coredata.OAuth2Client
Scopes coredata.OAuth2Scopes
}
func (e *ConsentRequiredError) Error() string {
return "consent required"
}

130
pkg/iam/oauth2/gc.go Normal file
View File

@@ -0,0 +1,130 @@
// 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"
"fmt"
"sync/atomic"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
"go.probo.inc/probo/pkg/coredata"
)
const (
DefaultGCInterval = 5 * time.Minute
)
type GarbageCollector = worker.Worker[struct{}]
type gcHandler struct {
pg *pg.Client
logger *log.Logger
lastRunAt atomic.Int64
}
func NewGarbageCollector(
pgClient *pg.Client,
logger *log.Logger,
opts ...worker.Option,
) *GarbageCollector {
h := &gcHandler{
pg: pgClient,
logger: logger.Named("oauth.garbage_collector"),
}
return worker.New(
"oauth.garbage_collector",
h,
logger,
append(
[]worker.Option{
worker.WithInterval(DefaultGCInterval),
worker.WithMaxConcurrency(1),
},
opts...,
)...,
)
}
func (h *gcHandler) Claim(_ context.Context) (struct{}, error) {
now := time.Now().UnixNano()
last := h.lastRunAt.Load()
if last > 0 && now-last < int64(DefaultGCInterval) {
return struct{}{}, worker.ErrNoTask
}
if !h.lastRunAt.CompareAndSwap(last, now) {
return struct{}{}, worker.ErrNoTask
}
return struct{}{}, nil
}
func (h *gcHandler) Process(ctx context.Context, _ struct{}) error {
return h.cleanup(ctx)
}
func (h *gcHandler) cleanup(ctx context.Context) error {
now := time.Now()
return h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var authCode coredata.OAuth2AuthorizationCode
authCodesDeleted, err := authCode.DeleteExpired(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired authorization codes: %w", err)
}
var accessToken coredata.OAuth2AccessToken
accessTokensDeleted, err := accessToken.DeleteExpired(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired access tokens: %w", err)
}
var refreshToken coredata.OAuth2RefreshToken
refreshTokensDeleted, err := refreshToken.DeleteExpired(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired refresh tokens: %w", err)
}
var deviceCode coredata.OAuth2DeviceCode
deviceCodesDeleted, err := deviceCode.DeleteExpired(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired device codes: %w", err)
}
h.logger.InfoCtx(
ctx,
"oauth2 server garbage collector cleaned up",
log.Int64("authorization_codes_deleted", authCodesDeleted),
log.Int64("access_tokens_deleted", accessTokensDeleted),
log.Int64("refresh_tokens_deleted", refreshTokensDeleted),
log.Int64("device_codes_deleted", deviceCodesDeleted),
)
return nil
},
)
}

108
pkg/iam/oauth2/id_token.go Normal file
View File

@@ -0,0 +1,108 @@
// 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 (
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/uri"
)
type (
// SigningKey pairs an RSA private key with its key ID. All entries are
// published in the JWKS endpoint. Keys with Active set to true are
// used for signing new tokens; when multiple keys are active, the
// service round-robins between them.
SigningKey struct {
PrivateKey *rsa.PrivateKey
KID string
Active bool
}
IDTokenClaims struct {
Issuer uri.URI `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
AuthTime int64 `json:"auth_time"`
Nonce string `json:"nonce,omitempty"`
AtHash string `json:"at_hash,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
Name string `json:"name,omitempty"`
Scope coredata.OAuth2Scopes `json:"-"`
}
SigningKeys []SigningKey
)
// ComputeAtHash computes the at_hash claim value for an access token.
// Per OIDC Core §3.1.3.6: left half of SHA-256 hash, base64url-encoded.
func ComputeAtHash(accessToken string) string {
h := sha256.Sum256([]byte(accessToken))
return base64.RawURLEncoding.EncodeToString(h[:16])
}
func NewIDTokenClaims(
issuer uri.URI,
identityID gid.GID,
clientID gid.GID,
authTime time.Time,
scopes coredata.OAuth2Scopes,
nonce string,
accessToken string,
email string,
emailVerified bool,
fullName string,
ttl time.Duration,
) *IDTokenClaims {
now := time.Now()
claims := &IDTokenClaims{
Issuer: issuer,
Subject: identityID.String(),
Audience: clientID.String(),
ExpiresAt: now.Add(ttl).Unix(),
IssuedAt: now.Unix(),
AuthTime: authTime.Unix(),
Scope: scopes,
}
if nonce != "" {
claims.Nonce = nonce
}
if accessToken != "" {
claims.AtHash = ComputeAtHash(accessToken)
}
for _, scope := range scopes {
switch scope {
case ScopeEmail:
claims.Email = email
claims.EmailVerified = &emailVerified
case ScopeProfile:
claims.Name = fullName
}
}
return claims
}

View File

@@ -0,0 +1,300 @@
// 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 (
"crypto/sha256"
"encoding/base64"
"testing"
"time"
"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"
"go.probo.inc/probo/pkg/uri"
)
var testIssuer = uri.URI("https://issuer.example.com")
func TestComputeAtHash(t *testing.T) {
t.Parallel()
t.Run(
"returns left half of sha256 base64url encoded",
func(t *testing.T) {
t.Parallel()
accessToken := "ya29.test-access-token"
h := sha256.Sum256([]byte(accessToken))
expected := base64.RawURLEncoding.EncodeToString(h[:16])
result := oauth2.ComputeAtHash(accessToken)
assert.Equal(t, expected, result)
},
)
t.Run(
"different tokens produce different hashes",
func(t *testing.T) {
t.Parallel()
hash1 := oauth2.ComputeAtHash("token-a")
hash2 := oauth2.ComputeAtHash("token-b")
assert.NotEqual(t, hash1, hash2)
},
)
t.Run(
"empty token",
func(t *testing.T) {
t.Parallel()
result := oauth2.ComputeAtHash("")
assert.NotEmpty(t, result)
},
)
t.Run(
"deterministic",
func(t *testing.T) {
t.Parallel()
hash1 := oauth2.ComputeAtHash("same-token")
hash2 := oauth2.ComputeAtHash("same-token")
assert.Equal(t, hash1, hash2)
},
)
}
func TestNewIDTokenClaims(t *testing.T) {
t.Parallel()
identityID := gid.Nil
clientID := gid.Nil
authTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
t.Run(
"basic claims without optional scopes",
func(t *testing.T) {
t.Parallel()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"",
"",
"user@example.com",
true,
"John Doe",
1*time.Hour,
)
assert.Equal(t, testIssuer, claims.Issuer)
assert.Equal(t, identityID.String(), claims.Subject)
assert.Equal(t, clientID.String(), claims.Audience)
assert.Equal(t, authTime.Unix(), claims.AuthTime)
assert.Empty(t, claims.Nonce)
assert.Empty(t, claims.AtHash)
assert.Empty(t, claims.Email)
assert.Nil(t, claims.EmailVerified)
assert.Empty(t, claims.Name)
},
)
t.Run(
"sets nonce when provided",
func(t *testing.T) {
t.Parallel()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"test-nonce",
"",
"",
false,
"",
1*time.Hour,
)
assert.Equal(t, "test-nonce", claims.Nonce)
},
)
t.Run(
"computes at_hash when access token provided",
func(t *testing.T) {
t.Parallel()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"",
"access-token-123",
"",
false,
"",
1*time.Hour,
)
expected := oauth2.ComputeAtHash("access-token-123")
assert.Equal(t, expected, claims.AtHash)
},
)
t.Run(
"includes email claims with email scope",
func(t *testing.T) {
t.Parallel()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail},
"",
"",
"user@example.com",
true,
"",
1*time.Hour,
)
assert.Equal(t, "user@example.com", claims.Email)
require.NotNil(t, claims.EmailVerified)
assert.True(t, *claims.EmailVerified)
},
)
t.Run(
"includes name with profile scope",
func(t *testing.T) {
t.Parallel()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeProfile},
"",
"",
"",
false,
"Jane Doe",
1*time.Hour,
)
assert.Equal(t, "Jane Doe", claims.Name)
},
)
t.Run(
"includes all claims with all scopes",
func(t *testing.T) {
t.Parallel()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{
oauth2.ScopeOpenID,
oauth2.ScopeEmail,
oauth2.ScopeProfile,
},
"nonce-val",
"access-token",
"user@example.com",
false,
"John Doe",
1*time.Hour,
)
assert.Equal(t, "nonce-val", claims.Nonce)
assert.NotEmpty(t, claims.AtHash)
assert.Equal(t, "user@example.com", claims.Email)
require.NotNil(t, claims.EmailVerified)
assert.False(t, *claims.EmailVerified)
assert.Equal(t, "John Doe", claims.Name)
},
)
t.Run(
"sets expiration based on ttl",
func(t *testing.T) {
t.Parallel()
ttl := 2 * time.Hour
before := time.Now()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
"",
"",
"",
false,
"",
ttl,
)
after := time.Now()
assert.GreaterOrEqual(t, claims.ExpiresAt, before.Add(ttl).Unix())
assert.LessOrEqual(t, claims.ExpiresAt, after.Add(ttl).Unix())
assert.GreaterOrEqual(t, claims.IssuedAt, before.Unix())
assert.LessOrEqual(t, claims.IssuedAt, after.Unix())
},
)
t.Run(
"email not verified",
func(t *testing.T) {
t.Parallel()
claims := oauth2.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail},
"",
"",
"user@example.com",
false,
"",
1*time.Hour,
)
require.NotNil(t, claims.EmailVerified)
assert.False(t, *claims.EmailVerified)
},
)
}

130
pkg/iam/oauth2/metadata.go Normal file
View File

@@ -0,0 +1,130 @@
// 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"
)
type (
// ServerMetadata represents the OpenID Connect Discovery 1.0 / RFC 8414
// authorization server metadata document.
ServerMetadata struct {
Issuer uri.URI `json:"issuer"`
AuthorizationEndpoint uri.URI `json:"authorization_endpoint"`
TokenEndpoint uri.URI `json:"token_endpoint"`
UserinfoEndpoint uri.URI `json:"userinfo_endpoint"`
JwksURI uri.URI `json:"jwks_uri"`
RegistrationEndpoint uri.URI `json:"registration_endpoint"`
IntrospectionEndpoint uri.URI `json:"introspection_endpoint"`
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"`
RevocationEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"revocation_endpoint_auth_methods_supported"`
IntrospectionEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"introspection_endpoint_auth_methods_supported"`
SubjectTypesSupported []coredata.OAuth2SubjectType `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []coredata.OAuth2SigningAlgorithm `json:"id_token_signing_alg_values_supported"`
CodeChallengeMethodsSupported []coredata.OAuth2CodeChallengeMethod `json:"code_challenge_methods_supported"`
ClaimsSupported []coredata.OAuth2Claim `json:"claims_supported"`
}
// Endpoints holds the endpoint URLs for the OIDC discovery document.
Endpoints struct {
Authorization uri.URI
Token uri.URI
Userinfo uri.URI
JWKS uri.URI
Registration uri.URI
Introspection uri.URI
Revocation uri.URI
DeviceAuthorization uri.URI
}
)
func NewMetadata(issuer uri.URI, endpoints Endpoints, apiScopes []coredata.OAuth2Scope) *ServerMetadata {
return &ServerMetadata{
Issuer: issuer,
AuthorizationEndpoint: endpoints.Authorization,
TokenEndpoint: endpoints.Token,
UserinfoEndpoint: endpoints.Userinfo,
JwksURI: endpoints.JWKS,
RegistrationEndpoint: endpoints.Registration,
IntrospectionEndpoint: endpoints.Introspection,
RevocationEndpoint: endpoints.Revocation,
DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization,
ScopesSupported: slices.Concat(
[]coredata.OAuth2Scope{
ScopeOpenID,
ScopeProfile,
ScopeEmail,
ScopeOfflineAccess,
},
apiScopes,
),
ProtectedResources: []uri.URI{issuer},
ResponseTypesSupported: []coredata.OAuth2ResponseType{
coredata.OAuth2ResponseTypeCode,
},
GrantTypesSupported: []coredata.OAuth2GrantType{
coredata.OAuth2GrantTypeAuthorizationCode,
coredata.OAuth2GrantTypeRefreshToken,
coredata.OAuth2GrantTypeDeviceCode,
},
TokenEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
},
RevocationEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
},
IntrospectionEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
},
SubjectTypesSupported: []coredata.OAuth2SubjectType{
coredata.OAuth2SubjectTypePublic,
},
IDTokenSigningAlgValuesSupported: []coredata.OAuth2SigningAlgorithm{
coredata.OAuth2SigningAlgorithmRS256,
},
CodeChallengeMethodsSupported: []coredata.OAuth2CodeChallengeMethod{
coredata.OAuth2CodeChallengeMethodS256,
},
ClaimsSupported: []coredata.OAuth2Claim{
coredata.OAuth2ClaimIssuer,
coredata.OAuth2ClaimSubject,
coredata.OAuth2ClaimAudience,
coredata.OAuth2ClaimExpiration,
coredata.OAuth2ClaimIssuedAt,
coredata.OAuth2ClaimAuthTime,
coredata.OAuth2ClaimNonce,
coredata.OAuth2ClaimAtHash,
coredata.OAuth2ClaimEmail,
coredata.OAuth2ClaimEmailVerified,
coredata.OAuth2ClaimName,
},
}
}

View File

@@ -0,0 +1,256 @@
// 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 (
"slices"
"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 TestNewMetadata(t *testing.T) {
t.Parallel()
apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead}
issuer := uri.URI("https://auth.example.com")
endpoints := oauth2.Endpoints{
Authorization: "https://auth.example.com/authorize",
Token: "https://auth.example.com/token",
Userinfo: "https://auth.example.com/userinfo",
JWKS: "https://auth.example.com/.well-known/jwks.json",
Registration: "https://auth.example.com/register",
Introspection: "https://auth.example.com/introspect",
Revocation: "https://auth.example.com/revoke",
DeviceAuthorization: "https://auth.example.com/device",
}
metadata := oauth2.NewMetadata(issuer, endpoints, apiScopes)
require.NotNil(t, metadata)
t.Run(
"issuer",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, issuer, metadata.Issuer)
},
)
t.Run(
"endpoints",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, endpoints.Authorization, metadata.AuthorizationEndpoint)
assert.Equal(t, endpoints.Token, metadata.TokenEndpoint)
assert.Equal(t, endpoints.Userinfo, metadata.UserinfoEndpoint)
assert.Equal(t, endpoints.JWKS, metadata.JwksURI)
assert.Equal(t, endpoints.Registration, metadata.RegistrationEndpoint)
assert.Equal(t, endpoints.Introspection, metadata.IntrospectionEndpoint)
assert.Equal(t, endpoints.Revocation, metadata.RevocationEndpoint)
assert.Equal(t, endpoints.DeviceAuthorization, metadata.DeviceAuthorizationEndpoint)
},
)
t.Run(
"scopes supported",
func(t *testing.T) {
t.Parallel()
expectedScopes := slices.Concat(
[]coredata.OAuth2Scope{
oauth2.ScopeOpenID,
oauth2.ScopeProfile,
oauth2.ScopeEmail,
oauth2.ScopeOfflineAccess,
},
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)
},
)
t.Run(
"response types supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2ResponseType{
coredata.OAuth2ResponseTypeCode,
},
metadata.ResponseTypesSupported,
)
},
)
t.Run(
"grant types supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2GrantType{
coredata.OAuth2GrantTypeAuthorizationCode,
coredata.OAuth2GrantTypeRefreshToken,
coredata.OAuth2GrantTypeDeviceCode,
},
metadata.GrantTypesSupported,
)
},
)
t.Run(
"token endpoint auth methods supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2ClientTokenEndpointAuthMethod{
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
},
metadata.TokenEndpointAuthMethodsSupported,
)
},
)
t.Run(
"revocation endpoint auth methods supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2ClientTokenEndpointAuthMethod{
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
},
metadata.RevocationEndpointAuthMethodsSupported,
)
},
)
t.Run(
"introspection endpoint auth methods supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2ClientTokenEndpointAuthMethod{
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
},
metadata.IntrospectionEndpointAuthMethodsSupported,
)
},
)
t.Run(
"subject types supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2SubjectType{
coredata.OAuth2SubjectTypePublic,
},
metadata.SubjectTypesSupported,
)
},
)
t.Run(
"id token signing algorithms supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2SigningAlgorithm{
coredata.OAuth2SigningAlgorithmRS256,
},
metadata.IDTokenSigningAlgValuesSupported,
)
},
)
t.Run(
"code challenge methods supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2CodeChallengeMethod{
coredata.OAuth2CodeChallengeMethodS256,
},
metadata.CodeChallengeMethodsSupported,
)
},
)
t.Run(
"claims supported",
func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]coredata.OAuth2Claim{
coredata.OAuth2ClaimIssuer,
coredata.OAuth2ClaimSubject,
coredata.OAuth2ClaimAudience,
coredata.OAuth2ClaimExpiration,
coredata.OAuth2ClaimIssuedAt,
coredata.OAuth2ClaimAuthTime,
coredata.OAuth2ClaimNonce,
coredata.OAuth2ClaimAtHash,
coredata.OAuth2ClaimEmail,
coredata.OAuth2ClaimEmailVerified,
coredata.OAuth2ClaimName,
},
metadata.ClaimsSupported,
)
},
)
}

43
pkg/iam/oauth2/pkce.go Normal file
View File

@@ -0,0 +1,43 @@
// 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 (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"go.probo.inc/probo/pkg/coredata"
)
func ValidateCodeChallenge(verifier, challenge string, method coredata.OAuth2CodeChallengeMethod) bool {
if verifier == "" || challenge == "" {
return false
}
switch method {
case coredata.OAuth2CodeChallengeMethodS256:
return validateS256(verifier, challenge)
default:
return false
}
}
func validateS256(verifier, challenge string) bool {
h := sha256.Sum256([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(h[:])
return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
}

158
pkg/iam/oauth2/pkce_test.go Normal file
View File

@@ -0,0 +1,158 @@
// 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 (
"crypto/sha256"
"encoding/base64"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam/oauth2"
)
func computeS256Challenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
func TestValidateCodeChallenge(t *testing.T) {
t.Parallel()
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := computeS256Challenge(verifier)
t.Run(
"valid s256",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethodS256,
)
require.True(t, result)
},
)
t.Run(
"wrong verifier",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
"wrong-verifier",
challenge,
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"wrong challenge",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
verifier,
"wrong-challenge",
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"unsupported method",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethod("plain"),
)
assert.False(t, result)
},
)
t.Run(
"empty method",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethod(""),
)
assert.False(t, result)
},
)
t.Run(
"empty verifier",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
"",
challenge,
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"empty challenge",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
verifier,
"",
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"both empty",
func(t *testing.T) {
t.Parallel()
result := oauth2.ValidateCodeChallenge(
"",
"",
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
}

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)
},
)
}

1769
pkg/iam/oauth2/service.go Normal file

File diff suppressed because it is too large Load Diff