Add OAuth2/OpenID Connect authorization server

Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization
server with support for authorization code flow (with PKCE),
refresh token rotation, device authorization grant, dynamic
client registration, token introspection, and token revocation.

Includes database schema, coredata layer, service logic, HTTP
handlers, OIDC discovery endpoint, and JWKS publishing.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-30 14:49:18 +02:00
parent e84094e62c
commit 11770b4058
155 changed files with 14483 additions and 223 deletions

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server
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"
}

126
pkg/iam/oauth2server/gc.go Normal file
View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server
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("oauth2server.garbage_collector"),
}
return worker.New(
"oauth2server.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
},
)
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server
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 coredata.OAuth2ScopeEmail:
claims.Email = email
claims.EmailVerified = &emailVerified
case coredata.OAuth2ScopeProfile:
claims.Name = fullName
}
}
return claims
}

View File

@@ -0,0 +1,300 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server_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/oauth2server"
"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 := oauth2server.ComputeAtHash(accessToken)
assert.Equal(t, expected, result)
},
)
t.Run(
"different tokens produce different hashes",
func(t *testing.T) {
t.Parallel()
hash1 := oauth2server.ComputeAtHash("token-a")
hash2 := oauth2server.ComputeAtHash("token-b")
assert.NotEqual(t, hash1, hash2)
},
)
t.Run(
"empty token",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ComputeAtHash("")
assert.NotEmpty(t, result)
},
)
t.Run(
"deterministic",
func(t *testing.T) {
t.Parallel()
hash1 := oauth2server.ComputeAtHash("same-token")
hash2 := oauth2server.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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
"",
"",
"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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
"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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
"",
"access-token-123",
"",
false,
"",
1*time.Hour,
)
expected := oauth2server.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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
"",
"",
"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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeProfile},
"",
"",
"",
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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeEmail,
coredata.OAuth2ScopeProfile,
},
"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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
"",
"",
"",
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 := oauth2server.NewIDTokenClaims(
testIssuer,
identityID,
clientID,
authTime,
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
"",
"",
"user@example.com",
false,
"",
1*time.Hour,
)
require.NotNil(t, claims.EmailVerified)
assert.False(t, *claims.EmailVerified)
},
)
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server
import (
"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"`
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) *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: []coredata.OAuth2Scope{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeProfile,
coredata.OAuth2ScopeEmail,
coredata.OAuth2ScopeOfflineAccess,
},
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,240 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server_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/oauth2server"
"go.probo.inc/probo/pkg/uri"
)
func TestNewMetadata(t *testing.T) {
t.Parallel()
issuer := uri.URI("https://auth.example.com")
endpoints := oauth2server.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 := oauth2server.NewMetadata(issuer, endpoints)
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()
assert.Equal(
t,
[]coredata.OAuth2Scope{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeProfile,
coredata.OAuth2ScopeEmail,
coredata.OAuth2ScopeOfflineAccess,
},
metadata.ScopesSupported,
)
},
)
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,
)
},
)
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server
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
}

View File

@@ -0,0 +1,158 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 oauth2server_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/oauth2server"
)
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 := oauth2server.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethodS256,
)
require.True(t, result)
},
)
t.Run(
"wrong verifier",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
"wrong-verifier",
challenge,
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"wrong challenge",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
verifier,
"wrong-challenge",
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"unsupported method",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethod("plain"),
)
assert.False(t, result)
},
)
t.Run(
"empty method",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
verifier,
challenge,
coredata.OAuth2CodeChallengeMethod(""),
)
assert.False(t, result)
},
)
t.Run(
"empty verifier",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
"",
challenge,
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"empty challenge",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
verifier,
"",
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
t.Run(
"both empty",
func(t *testing.T) {
t.Parallel()
result := oauth2server.ValidateCodeChallenge(
"",
"",
coredata.OAuth2CodeChallengeMethodS256,
)
assert.False(t, result)
},
)
}

File diff suppressed because it is too large Load Diff