Skip to content
Open
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
15 changes: 15 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ type Config struct {
// my-audience: ["nhi:manage"]
AudienceScopeProfiles map[string][]string `koanf:"audience_scope_profiles"`

// AllowedResources is the deployer-configured allowlist of resource URIs
// accepted on POST /oauth2/token (RFC 8707 §2 resource indicator). When
// non-empty (restricted mode), a supplied `resource` parameter MUST match
// one entry exactly; mismatches are rejected with invalid_target. When
// empty (the default, open mode), any syntactically valid absolute URI is
// accepted. Blank entries fail closed at startup.
AllowedResources []string `koanf:"allowed_resources"`

// DefaultAudience is the `aud` value stamped on access tokens when no
// `resource` parameter is supplied. When empty (the default), `aud` falls
// back to the issuer URL (backward-compatible). Set this to the canonical
// URI of your resource server to make tokens immediately acceptable by
// strict resource servers without requiring every caller to pass `resource`.
DefaultAudience string `koanf:"default_audience"`

// ExternalIssuers configures direct OIDC IdP federation (issue #88).
// When grant_type=token-exchange and subject_token_type=id_token, ZeroID
// looks up the upstream iss in this list, fetches the issuer's JWKS, and
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ require (
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mellium.im/sasl v0.3.2 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,16 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
8 changes: 8 additions & 0 deletions internal/handler/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ type TokenInput struct {
// empty value leaves issuance unchanged; a non-empty value that names
// no known profile is rejected with `invalid_target` (RFC 8693).
Audience string `json:"audience,omitempty" doc:"Audience profile name (trusted external-principal exchange only)"`
// Resource is the RFC 8707 resource indicator URI — the target resource
// server this token will be presented to. When supplied, `aud` on the
// issued JWT is set to [resource] instead of the issuer URL. The value
// must be an absolute URI (scheme + host, no fragment). When
// AllowedResources is configured, the URI must match one of the listed
// values; otherwise any syntactically valid URI is accepted.
Resource string `json:"resource,omitempty" doc:"RFC 8707 resource indicator URI — the target resource server"`
// authorization_code grant fields:
Code string `json:"code,omitempty" doc:"Authorization code JWT"`
CodeVerifier string `json:"code_verifier,omitempty" doc:"PKCE S256 code verifier"`
Expand Down Expand Up @@ -397,6 +404,7 @@ func (a *API) tokenOp(ctx context.Context, input *TokenInput) (*TokenOutput, err
Role: input.Body.Role,
PrivilegeScope: input.Body.PrivilegeScope,
Audience: input.Body.Audience,
Resource: input.Body.Resource,
Code: input.Body.Code,
CodeVerifier: input.Body.CodeVerifier,
RedirectURI: input.Body.RedirectURI,
Expand Down
11 changes: 8 additions & 3 deletions internal/service/backchannel.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,10 @@ type RedeemInput struct {
// Non-empty when the polling /oauth2/token call carried a valid DPoP
// proof; the issued credential then carries cnf.jkt + token_type "DPoP".
DPoPKeyThumbprint string
// Audience carries the RFC 8707 pre-resolved audience from Token(). Nil
// means no resource indicator was supplied (IssueCredential uses the
// issuer-URL fallback).
Audience []string
}

// Redeem implements the polling response state machine per CIBA Core §11.
Expand Down Expand Up @@ -774,7 +778,7 @@ func (s *BackchannelService) Redeem(ctx context.Context, in RedeemInput) (*domai
return nil, oauthBadRequest(oautherror.AccessDenied, "auth_req_id has already been redeemed")

case domain.BackchannelStatusApproved:
return s.issueTokenForApprovedRow(ctx, row, in.DPoPKeyThumbprint)
return s.issueTokenForApprovedRow(ctx, row, in.DPoPKeyThumbprint, in.Audience)

default:
return nil, oauthBadRequest(oautherror.InvalidGrant, fmt.Sprintf("unexpected request status %q", row.Status))
Expand All @@ -789,7 +793,7 @@ func (s *BackchannelService) Redeem(ctx context.Context, in RedeemInput) (*domai
// Caller MUST hold the invariant that row.Status == approved. The MarkIssued
// guard provides the actual at-most-once gate; on a lost race the second
// caller gets affected=0 and an *OAuthError signalling the duplication.
func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row *domain.BackchannelAuthRequest, dpopKeyThumbprint string) (*domain.AccessToken, error) {
func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row *domain.BackchannelAuthRequest, dpopKeyThumbprint string, audience []string) (*domain.AccessToken, error) {
// Claim-first: flip approved → issued BEFORE minting the token so only
// one caller can ever reach IssueCredential. The conditional UPDATE in
// MarkIssued (status='approved' guard) is the at-most-once invariant;
Expand Down Expand Up @@ -854,6 +858,7 @@ func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row *
Identity: identity,
Scopes: parseScopeString(row.Scope),
GrantType: domain.GrantTypeCIBA,
Audience: audience,
TTL: 900, // 15 minutes — short-lived; matches ExternalPrincipalExchange
UseRS256: true,
SubjectOverride: row.ApprovedSubjectID,
Expand Down Expand Up @@ -1067,7 +1072,7 @@ func (s *BackchannelService) dispatchPushApproval(ctx context.Context, row *doma
// so there is no DPoP proof — passes empty thumbprint to keep the token
// as Bearer. Resource-server-side DPoP for CIBA-push tokens is a future
// item if it's ever needed.
accessToken, err := s.issueTokenForApprovedRow(ctx, row, "")
accessToken, err := s.issueTokenForApprovedRow(ctx, row, "", nil)
if err != nil {
// Most likely an OAuthError("access_denied") from a lost race against
// a concurrent dispatch. Log and exit — the first dispatcher will
Expand Down
88 changes: 88 additions & 0 deletions internal/service/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ type OAuthService struct {
// and validated at construction — see ResolveAudienceScopeProfiles). Nil is
// treated as the built-in defaults.
audienceScopeProfiles map[string][]string
// allowedResources is the validated RFC 8707 resource allowlist. Nil or
// empty = open mode (any syntactically valid URI accepted).
allowedResources []string
// defaultAudience is used as aud when no resource parameter is supplied.
// Empty = fall back to the issuer URL (existing behavior in IssueCredential).
defaultAudience string
// trustedServiceValidator checks if the caller is a trusted service for external principal exchange.
trustedServiceValidator trustedServiceValidatorFunc
// customGrants holds registered custom grant type handlers.
Expand Down Expand Up @@ -252,6 +258,23 @@ func ResolveAudienceScopeProfiles(configured map[string][]string) (map[string][]
// The public type is zeroid.TrustedServiceValidator (hooks.go).
type trustedServiceValidatorFunc func(ctx context.Context) (serviceName string, err error)

// ValidateAllowedResources validates the deployer-configured allowed_resources
// list at startup. It rejects any blank entry (fail closed — an empty string
// could otherwise accidentally match resource="" in allowlist mode). URI
// syntactic validity is intentionally deferred to request time in
// resolveResourceAudience. Returns a cloned list on success; nil input returns
// an empty list and no error.
func ValidateAllowedResources(configured []string) ([]string, error) {
out := make([]string, 0, len(configured))
for _, r := range configured {
if strings.TrimSpace(r) == "" {
return nil, fmt.Errorf("allowed_resources: entry %q is blank", r)
}
out = append(out, r)
}
return out, nil
}

// OAuthServiceConfig holds configuration for the OAuthService.
type OAuthServiceConfig struct {
Issuer string
Expand All @@ -262,6 +285,12 @@ type OAuthServiceConfig struct {
// external-principal exchange (from ResolveAudienceScopeProfiles). Nil falls
// back to the built-in defaults.
AudienceScopeProfiles map[string][]string
// AllowedResources is the validated list of permitted resource URIs for RFC
// 8707 resource indicators. Empty = open mode (any valid URI accepted).
AllowedResources []string
// DefaultAudience is the aud value used when no resource parameter is
// supplied. Empty = fall back to the issuer URL (existing behavior).
DefaultAudience string
// TrustedServiceValidator is called during external principal token exchange
// to verify the caller is a trusted internal service. If nil, external
// principal exchange is disabled.
Expand Down Expand Up @@ -292,6 +321,8 @@ func NewOAuthService(
hmacSecret: cfg.HMACSecret,
authCodeIssuer: cfg.AuthCodeIssuer,
audienceScopeProfiles: audienceProfilesOrDefault(cfg.AudienceScopeProfiles),
allowedResources: cfg.AllowedResources,
defaultAudience: cfg.DefaultAudience,
trustedServiceValidator: cfg.TrustedServiceValidator,
}
}
Expand All @@ -313,6 +344,31 @@ func audienceProfilesOrDefault(configured map[string][]string) map[string][]stri
return out
}

// resolveResourceAudience resolves the RFC 8707 resource indicator to the aud
// value that will be stamped on the issued JWT.
//
// - resource non-empty → validate URI + allowlist → return []string{resource}
// - resource empty + defaultAudience set → return []string{defaultAudience}
// - resource empty + no defaultAudience → return nil (IssueCredential uses issuer fallback)
func (s *OAuthService) resolveResourceAudience(resource string) ([]string, error) {
if resource == "" {
if s.defaultAudience != "" {
return []string{s.defaultAudience}, nil
}
return nil, nil
}
// RFC 8707 §2: MUST be an absolute URI with no fragment.
u, err := url.Parse(resource)
if err != nil || u.Scheme == "" || u.Host == "" || u.Fragment != "" {
return nil, oauthBadRequest(oautherror.InvalidTarget, "resource URI is malformed (must be an absolute URI with no fragment)")
}
// Allowlist check (restricted mode).
if len(s.allowedResources) > 0 && !slices.Contains(s.allowedResources, resource) {
return nil, oauthBadRequest(oautherror.InvalidTarget, "resource is not in the server's allowed_resources list")
}
return []string{resource}, nil
}

// SetTrustedServiceValidator sets the validator for external principal token exchange.
// Can be called after construction to override the config-provided validator.
func (s *OAuthService) SetTrustedServiceValidator(v trustedServiceValidatorFunc) {
Expand Down Expand Up @@ -400,6 +456,16 @@ type TokenRequest struct {
// Role/PrivilegeScope this is a dedicated field, never settable via
// AdditionalClaims (`aud`/`scopes` are reserved).
Audience string
// Resource is the RFC 8707 resource indicator URI supplied by the caller.
// Empty means no resource was requested. Token() resolves it to
// ResolvedAudience before dispatching to any grant handler.
Resource string
// ResolvedAudience is the aud value computed by Token() from Resource (and
// DefaultAudience / issuer fallback). It is set BEFORE the grant-type
// switch so all grant handlers read from one place. Callers that bypass
// Token() (e.g. tests that call grant handlers directly) may leave this nil;
// IssueCredential then falls back to the issuer URL as before.
ResolvedAudience []string
// authorization_code grant fields:
Code string // HS256 auth code JWT
CodeVerifier string // PKCE S256 code verifier
Expand All @@ -419,6 +485,15 @@ type TokenRequest struct {

// Token handles the /oauth2/token endpoint dispatch.
func (s *OAuthService) Token(ctx context.Context, req TokenRequest) (*domain.AccessToken, error) {
// Resolve RFC 8707 resource indicator to an audience once, before dispatch,
// so every grant handler reads from req.ResolvedAudience without needing its
// own validation logic.
resolvedAud, err := s.resolveResourceAudience(req.Resource)
if err != nil {
return nil, err
}
req.ResolvedAudience = resolvedAud

switch req.GrantType {
case "client_credentials":
return s.clientCredentials(ctx, req)
Expand All @@ -441,6 +516,7 @@ func (s *OAuthService) Token(ctx context.Context, req TokenRequest) (*domain.Acc
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
DPoPKeyThumbprint: req.DPoPKeyThumbprint,
Audience: req.ResolvedAudience,
})
default:
// Check custom grant handlers registered via RegisterGrant.
Expand Down Expand Up @@ -513,6 +589,7 @@ func (s *OAuthService) clientCredentials(ctx context.Context, req TokenRequest)
IdentityPolicyID: policy.ID,
Scopes: scopes,
GrantType: domain.GrantTypeClientCredentials,
Audience: req.ResolvedAudience,
DPoPKeyThumbprint: req.DPoPKeyThumbprint,
})
if err != nil {
Expand Down Expand Up @@ -625,6 +702,7 @@ func (s *OAuthService) jwtBearer(ctx context.Context, req TokenRequest) (*domain
IdentityPolicyID: policy.ID,
Scopes: scopes,
GrantType: domain.GrantTypeJWTBearer,
Audience: req.ResolvedAudience,
DPoPKeyThumbprint: req.DPoPKeyThumbprint,
})
if err != nil {
Expand Down Expand Up @@ -840,6 +918,7 @@ func (s *OAuthService) tokenExchange(ctx context.Context, req TokenRequest) (*do
IdentityPolicyID: actorPolicy.ID,
Scopes: scopes,
GrantType: domain.GrantTypeTokenExchange,
Audience: req.ResolvedAudience,
DelegatedBy: delegatedBy,
ParentJTI: subjectJTI,
DelegationDepth: parentDepth + 1,
Expand Down Expand Up @@ -1004,12 +1083,18 @@ func (s *OAuthService) ExternalPrincipalExchange(ctx context.Context, req TokenR
// it got the profile it requested (scope-confusion / privilege-escalation).
var audience []string
if req.Audience != "" {
// Named audience profile wins (BR-7): audienceScopeProfiles resolves the
// aud and overrides any resource indicator that may have been supplied.
profile, ok := s.audienceScopeProfiles[req.Audience]
if !ok {
return nil, oauthBadRequest(oautherror.InvalidTarget, "unrecognized audience profile")
}
scopes = slices.Clone(profile)
audience = []string{req.Audience}
} else {
// No named audience profile — use the pre-resolved resource audience
// (nil when no resource + no default → IssueCredential falls back to issuer).
audience = req.ResolvedAudience
}

accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{
Expand Down Expand Up @@ -1251,6 +1336,7 @@ func (s *OAuthService) apiKeyGrant(ctx context.Context, req TokenRequest) (*doma
CredentialPolicyID: sk.CredentialPolicyID,
Scopes: scopes,
GrantType: domain.GrantTypeAPIKey,
Audience: req.ResolvedAudience,
UseRS256: true,
// sub = WIMSE URI (the identity), not the creator.
// owner_user_id is set from Identity.OwnerUserID automatically.
Expand Down Expand Up @@ -1667,6 +1753,7 @@ func (s *OAuthService) authorizationCode(ctx context.Context, req TokenRequest)
Identity: identity,
IdentityPolicyID: identityPolicyID,
GrantType: domain.GrantTypeAuthorizationCode,
Audience: req.ResolvedAudience,
UseRS256: true,
SubjectOverride: authCode.UserID,
ApplicationID: authCode.ClientID,
Expand Down Expand Up @@ -1911,6 +1998,7 @@ func (s *OAuthService) refreshToken(ctx context.Context, req TokenRequest) (*dom
Identity: identity,
IdentityPolicyID: identityPolicyID,
GrantType: domain.GrantTypeRefreshToken,
Audience: req.ResolvedAudience,
UseRS256: true,
SubjectOverride: oldToken.UserID,
ApplicationID: oldToken.ClientID,
Expand Down
40 changes: 40 additions & 0 deletions internal/service/oauth_audience_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,46 @@ func TestResolveAudienceScopeProfiles(t *testing.T) {
})
}

// TestValidateAllowedResources covers the startup-time validation for the RFC
// 8707 allowed_resources config list. URI syntactic validity is intentionally
// NOT checked here — that is deferred to request time in resolveResourceAudience.
// Startup only guards against blank entries that would never match anything.
func TestValidateAllowedResources(t *testing.T) {
t.Run("nil input returns empty list no error", func(t *testing.T) {
out, err := ValidateAllowedResources(nil)
require.NoError(t, err)
assert.Empty(t, out)
})

t.Run("empty slice returns empty list no error", func(t *testing.T) {
out, err := ValidateAllowedResources([]string{})
require.NoError(t, err)
assert.Empty(t, out)
})

t.Run("valid list returned as clone", func(t *testing.T) {
input := []string{"https://a.example.com", "https://b.example.com"}
out, err := ValidateAllowedResources(input)
require.NoError(t, err)
assert.Equal(t, input, out)
// Mutations to the returned slice must not affect the caller's input.
out[0] = "MUTATED"
assert.Equal(t, "https://a.example.com", input[0])
})

t.Run("blank entry rejected", func(t *testing.T) {
_, err := ValidateAllowedResources([]string{"https://a.example.com", ""})
require.Error(t, err)
assert.Contains(t, err.Error(), "blank")
})

t.Run("whitespace-only entry rejected", func(t *testing.T) {
_, err := ValidateAllowedResources([]string{" "})
require.Error(t, err)
assert.Contains(t, err.Error(), "blank")
})
}

// TestDefaultAudienceProfilesAreWithinAllowlist pins the invariant that every
// built-in default profile grants at least one scope and only scopes the server
// recognizes — so a future default can't drift out of allowedProfileScopes (which
Expand Down
Loading