Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions as.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,133 @@ type AuthorizationServerConfig struct {
// non-nil, /oauth/authorize and /oauth/par accept a "request"
// parameter containing a signed JWT. Nil (the default) disables JAR.
JAR *JARConfig

// JWTBearer, when non-nil, enables two RFC 7523 features:
//
// (1) private_key_jwt / client_secret_jwt client authentication on the
// token endpoint (RFC 7523 section 2.2). Clients registered with
// token_endpoint_auth_method = "private_key_jwt" or
// "client_secret_jwt" authenticate by presenting a signed JWT as
// client_assertion instead of a client_secret.
//
// (2) The urn:ietf:params:oauth:grant-type:jwt-bearer grant type (RFC
// 7523 section 2.1). A JWT issued by a configured trusted external
// issuer (e.g. Google, k8s OIDC, AWS IAM Roles Anywhere) is
// exchanged for an AS-issued access token.
//
// When nil both features are disabled and the AS behaves as before
// (client_secret_basic / client_secret_post only).
JWTBearer *JWTBearerConfig
}

// JWTBearerConfig controls RFC 7523 JWT client authentication and the
// JWT Bearer grant type. All fields have safe defaults.
type JWTBearerConfig struct {
// TrustedJWTIssuers is the list of external issuers whose JWTs may be
// exchanged for AS-issued access tokens via the jwt-bearer grant (RFC
// 7523 section 2.1). Each entry must specify Issuer and JWKSURL;
// AllowedAlgorithms defaults to ["ES256","RS256","EdDSA"].
TrustedJWTIssuers []TrustedJWTIssuer

// ClientAssertionMaxAge bounds how far in the past the client assertion
// JWT's iat claim may be. Defaults to 60 seconds (RFC 7523 recommends
// short-lived assertions). Operators may tighten but rarely need to
// widen this.
ClientAssertionMaxAge time.Duration

// AssertionMaxAge bounds how far in the past the bearer grant assertion
// JWT's iat claim may be. Defaults to 300 seconds.
AssertionMaxAge time.Duration

// ReplayCacheTTL is the duration JTIs remain in the replay cache.
// Defaults to 600 seconds (covers the maximum assertion lifetime with
// comfortable margin). Backed by JWTBearerStorage.InsertJTI; backends
// that do not implement JWTBearerStorage fall back to an in-process
// sync.Map and lose replay protection across restarts.
ReplayCacheTTL time.Duration

// MaxActorChainDepth caps on-behalf-of actor chains in the RFC 8693
// token-exchange grant. Defaults to 5. Values above 10 are not
// recommended: each additional link adds a storage round-trip at
// token-exchange time.
MaxActorChainDepth int
}

// TrustedJWTIssuer is one entry in JWTBearerConfig.TrustedJWTIssuers.
type TrustedJWTIssuer struct {
// Issuer is the value the trusted issuer places in the iss claim of
// its JWTs. Must be an exact string match.
Issuer string

// JWKSURL is the URL of the issuer's JSON Web Key Set document.
// Fetched and cached by the AS; rotated on 401 or periodic refresh.
JWKSURL string

// AllowedAlgorithms is the set of JWS algorithms the AS will accept
// for this issuer. Defaults to ES256, RS256, EdDSA.
AllowedAlgorithms []string

// SubjectMapper resolves the "sub" claim (and any other claims) of
// the external JWT to a local user ULID. When nil the built-in
// SubMapper is used: the sub claim is parsed directly as a ULID.
// Use EmailMapper to resolve the email claim to a local user.Email
// row instead.
SubjectMapper SubjectMapper
}

// SubjectMapper resolves claims from a trusted external JWT to a local
// user ULID. Two built-in implementations are provided:
//
// - SubMapper: treats the "sub" claim as a ULID directly.
// - EmailMapper: looks up the user by the "email" claim value.
//
// Custom implementations may consult any claim in the map; the AS passes
// the full decoded payload.
type SubjectMapper interface {
// Resolve returns the local user ULID for the supplied claim map, or
// ErrStorageNotFound when no mapping exists. Any other error is
// treated as a transient failure and returned as server_error.
Resolve(claims map[string]any) (ULID, error)
}

// SubMapper is the built-in SubjectMapper that parses the "sub" claim
// directly as a ULID. Use when the external issuer's subject is the
// theauth user ID (e.g. for internal workload tokens).
type SubMapper struct{}

// Resolve implements SubjectMapper by parsing claims["sub"] as a ULID.
func (SubMapper) Resolve(claims map[string]any) (ULID, error) {
sub, _ := claims["sub"].(string)
if sub == "" {
return ULID{}, ErrStorageNotFound
}
var id ULID
if err := id.UnmarshalText([]byte(sub)); err != nil {
return ULID{}, ErrStorageNotFound
}
return id, nil
}

// EmailMapper is the built-in SubjectMapper that looks up the user by
// email. The Lookup function must be provided (the AS wires in
// Storage.UserByEmail). Returns ErrStorageNotFound when no user with
// that email exists.
type EmailMapper struct {
// Lookup finds a user by their email address. Wire in
// Storage.UserByEmail at startup.
Lookup func(email string) (ULID, error)
}

// Resolve implements SubjectMapper by resolving claims["email"] to a user ID.
func (m EmailMapper) Resolve(claims map[string]any) (ULID, error) {
email, _ := claims["email"].(string)
if email == "" {
return ULID{}, ErrStorageNotFound
}
if m.Lookup == nil {
return ULID{}, ErrStorageNotFound
}
return m.Lookup(email)
}

// PARConfig is the root-package alias for as.PARConfig. Consumers set
Expand Down Expand Up @@ -228,6 +355,36 @@ func dpopConfigFromRoot(c *DPoPConfig) *internaldpop.Config {
}
}

// jwtBearerConfigFromRoot translates the root JWTBearerConfig into the
// internal/as JWTBearerConfig. Nil-safe; returns nil to disable JWT bearer.
func jwtBearerConfigFromRoot(c *JWTBearerConfig) *internalas.JWTBearerConfig {
if c == nil {
return nil
}
issuers := make([]internalas.TrustedJWTIssuer, len(c.TrustedJWTIssuers))
for i, t := range c.TrustedJWTIssuers {
algs := append([]string(nil), t.AllowedAlgorithms...)
var mapper func(map[string]any) (ULID, error)
if t.SubjectMapper != nil {
sm := t.SubjectMapper
mapper = sm.Resolve
}
issuers[i] = internalas.TrustedJWTIssuer{
Issuer: t.Issuer,
JWKSURL: t.JWKSURL,
AllowedAlgorithms: algs,
SubjectMapper: mapper,
}
}
return &internalas.JWTBearerConfig{
TrustedJWTIssuers: issuers,
ClientAssertionMaxAge: c.ClientAssertionMaxAge,
AssertionMaxAge: c.AssertionMaxAge,
ReplayCacheTTL: c.ReplayCacheTTL,
MaxActorChainDepth: c.MaxActorChainDepth,
}
}

// ProtectedResource is defined in internal/models and re-exported from
// models_v20.go as a type alias. The struct definition was relocated as
// part of the arch-A0 models extraction so that every persistent and
Expand Down Expand Up @@ -261,6 +418,7 @@ func validateASConfig(cfg *AuthorizationServerConfig, encryptionKey []byte) erro
RequireState: cfg.RequireState,
PAR: cfg.PAR,
JAR: cfg.JAR,
JWTBearer: jwtBearerConfigFromRoot(cfg.JWTBearer),
}
if err := internalas.Validate(&internal, encryptionKey); err != nil {
return err
Expand Down
21 changes: 21 additions & 0 deletions forwarders_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ func (a *TheAuth) ExchangeToken(ctx context.Context, req TokenExchangeRequest) (
return a.as.ExchangeToken(ctx, req)
}

// JWTBearerGrant implements RFC 7523 section 2.1: an externally-issued JWT
// is exchanged for an AS-issued access token. The assertion parameter must
// be the compact serialized JWT from a configured TrustedJWTIssuer.
//
// Requires Config.AuthorizationServer.JWTBearer to be non-nil; returns
// ErrOAuthUnsupportedGrantType when not configured.
func (a *TheAuth) JWTBearerGrant(ctx context.Context, req TokenRequest, assertion string) (TokenResponse, error) {
if a.as == nil {
return TokenResponse{}, ErrAuthorizationServerNotConfigured
}
return a.as.JWTBearerGrant(ctx, req, assertion)
}

// DCRRegister is a convenience wrapper around RegisterClient that registers
// a new OAuth client with AllowAnonymousRegistration semantics. The returned
// RegisteredClient carries ClientID and ClientSecret. This is the same
// operation as POST /oauth/register but callable from Go code directly.
func (a *TheAuth) DCRRegister(ctx context.Context, req ClientRegistrationRequest) (RegisteredClient, error) {
return a.RegisterClient(ctx, req, true)
}

// Note: the previous unexported helper authenticateClient was removed in
// PR G (2026-06-21). Every in-tree caller now invokes
// a.as.AuthenticateClient directly (the introspect / revoke / token
Expand Down
44 changes: 44 additions & 0 deletions internal/as/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,31 @@ type Config struct {
// non-nil, /oauth/authorize and /oauth/par accept a "request"
// parameter containing a signed JWT. Nil (default) disables JAR.
JAR *JARConfig

// JWTBearer, when non-nil, enables RFC 7523 client authentication and
// the jwt-bearer grant. Mirrored from root JWTBearerConfig.
JWTBearer *JWTBearerConfig
}

// JWTBearerConfig is the internal mirror of the root JWTBearerConfig.
// Populated once at New time; read-only afterwards.
type JWTBearerConfig struct {
TrustedJWTIssuers []TrustedJWTIssuer
ClientAssertionMaxAge time.Duration
AssertionMaxAge time.Duration
ReplayCacheTTL time.Duration
MaxActorChainDepth int
}

// TrustedJWTIssuer is the internal mirror of the root TrustedJWTIssuer.
type TrustedJWTIssuer struct {
Issuer string
JWKSURL string
AllowedAlgorithms []string
// SubjectMapper resolves the "sub" (or other) claim to a local user ULID.
// The internal package holds it as a plain func to avoid a dependency on
// root interfaces.
SubjectMapper func(claims map[string]any) (models.ULID, error)
}

// Validate applies defaults and screens required fields. Mirror of the
Expand Down Expand Up @@ -180,6 +205,25 @@ func Validate(cfg *Config, encryptionKey []byte) error {
if cfg.JAR != nil {
applyJARDefaults(cfg.JAR)
}
if cfg.JWTBearer != nil {
if cfg.JWTBearer.ClientAssertionMaxAge <= 0 {
cfg.JWTBearer.ClientAssertionMaxAge = 60 * time.Second
}
if cfg.JWTBearer.AssertionMaxAge <= 0 {
cfg.JWTBearer.AssertionMaxAge = 300 * time.Second
}
if cfg.JWTBearer.ReplayCacheTTL <= 0 {
cfg.JWTBearer.ReplayCacheTTL = 600 * time.Second
}
if cfg.JWTBearer.MaxActorChainDepth <= 0 {
cfg.JWTBearer.MaxActorChainDepth = 5
}
for i, iss := range cfg.JWTBearer.TrustedJWTIssuers {
if len(iss.AllowedAlgorithms) == 0 {
cfg.JWTBearer.TrustedJWTIssuers[i].AllowedAlgorithms = []string{"ES256", "RS256", "EdDSA"}
}
}
}
return nil
}

Expand Down
Loading
Loading