diff --git a/config.go b/config.go index 05837cf..4eef2a7 100644 --- a/config.go +++ b/config.go @@ -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 diff --git a/go.mod b/go.mod index f0f7d76..6e11d39 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d7b4754..a66eb40 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/handler/oauth.go b/internal/handler/oauth.go index 1445447..a38a25f 100644 --- a/internal/handler/oauth.go +++ b/internal/handler/oauth.go @@ -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"` @@ -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, diff --git a/internal/service/backchannel.go b/internal/service/backchannel.go index 540c72b..b4f66a7 100644 --- a/internal/service/backchannel.go +++ b/internal/service/backchannel.go @@ -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. @@ -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)) @@ -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; @@ -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, @@ -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 diff --git a/internal/service/oauth.go b/internal/service/oauth.go index 5603d8b..5dced86 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -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. @@ -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 @@ -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. @@ -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, } } @@ -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) { @@ -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 @@ -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) @@ -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. @@ -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 { @@ -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 { @@ -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, @@ -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{ @@ -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. @@ -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, @@ -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, diff --git a/internal/service/oauth_audience_test.go b/internal/service/oauth_audience_test.go index 101fe1b..cd548cc 100644 --- a/internal/service/oauth_audience_test.go +++ b/internal/service/oauth_audience_test.go @@ -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 diff --git a/server.go b/server.go index a424d26..557b1fc 100644 --- a/server.go +++ b/server.go @@ -265,12 +265,18 @@ func NewServer(cfg Config, opts ...ServerOption) (*Server, error) { if err != nil { return nil, fmt.Errorf("audience scope profiles: %w", err) } + allowedResources, err := service.ValidateAllowedResources(cfg.AllowedResources) + if err != nil { + return nil, fmt.Errorf("allowed_resources: %w", err) + } oauthSvc := service.NewOAuthService(credentialSvc, identitySvc, oauthClientSvc, apiKeyRepo, authCodeRepo, jwksSvc, refreshTokenSvc, service.OAuthServiceConfig{ Issuer: cfg.Token.Issuer, WIMSEDomain: cfg.WIMSEDomain, HMACSecret: cfg.Token.HMACSecret, AuthCodeIssuer: authCodeIssuer, AudienceScopeProfiles: audienceScopeProfiles, + AllowedResources: allowedResources, + DefaultAudience: cfg.DefaultAudience, }) // Strict client auth on introspection (RFC 7662) / revocation (RFC 7009): // require it whenever unauthenticated inspection is NOT allowed. Validate() diff --git a/tests/integration/jwt_svid_aud_test.go b/tests/integration/jwt_svid_aud_test.go index 434d2b6..1b26bf9 100644 --- a/tests/integration/jwt_svid_aud_test.go +++ b/tests/integration/jwt_svid_aud_test.go @@ -81,6 +81,39 @@ func TestIssuedTokenPreservesExplicitAudience(t *testing.T) { "explicit audience must be preserved, not overwritten by the issuer default") } +// TestIssuedTokenResourceOverridesAud verifies the RFC 8707 happy path: when +// the caller supplies resource= on client_credentials, the issued token's +// aud claim is overridden from the issuer default to the supplied URI. +func TestIssuedTokenResourceOverridesAud(t *testing.T) { + agentID := uid("aud-resource-override") + scopes := []string{"data:read"} + + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + const rs = "https://rs.test.example.com" + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + "resource": rs, + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + accessToken := decode(t, resp)["access_token"].(string) + require.NotEmpty(t, accessToken) + + parsed, err := jwt.ParseInsecure([]byte(accessToken)) + require.NoError(t, err) + + aud, _ := parsed.Audience() + assert.Equal(t, []string{rs}, aud, + "resource parameter must override the default issuer aud on the issued token") +} + // TestAuthjwtAcceptsDefaultedAudience is the end-to-end proof that the fix // restores interop with spec-compliant verifiers: a token issued without an // explicit audience must pass validation under an authjwt.Verifier that is diff --git a/tests/integration/resource_indicator_test.go b/tests/integration/resource_indicator_test.go new file mode 100644 index 0000000..f44d8e2 --- /dev/null +++ b/tests/integration/resource_indicator_test.go @@ -0,0 +1,261 @@ +package integration_test + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + zeroid "github.com/highflame-ai/zeroid" +) + +// newRFC8707Server spins up a second zeroid server with RFC 8707 config +// overrides. Both AllowedResources and DefaultAudience can be set independently; +// the server shares the same Postgres as the shared TestMain server. +func newRFC8707Server(t *testing.T, allowedResources []string, defaultAudience string) *httptest.Server { + t.Helper() + require.NoError(t, initFederationKeyMaterial(), "init key material") + cfg := zeroid.Config{ + Server: zeroid.ServerConfig{Port: "0", Env: "test", ShutdownTimeoutSeconds: 5}, + Database: zeroid.DatabaseConfig{URL: sharedDBURL, MaxOpenConns: 5, MaxIdleConns: 2}, + Keys: zeroid.KeysConfig{ + PrivateKeyPath: fedKeyPaths.privPath, + PublicKeyPath: fedKeyPaths.pubPath, + KeyID: "ri-test-key-1", + RSAPrivateKeyPath: fedKeyPaths.rsaPriv, + RSAPublicKeyPath: fedKeyPaths.rsaPub, + RSAKeyID: "ri-test-rsa-1", + }, + Token: zeroid.TokenConfig{ + Issuer: "https://restricted.zeroid.test", + DefaultTTL: 3600, + MaxTTL: 90 * 24 * 3600, + HMACSecret: testHMACSecret, + AllowUnauthenticatedTokenInspection: true, + }, + Telemetry: zeroid.TelemetryConfig{Enabled: false}, + Logging: zeroid.LoggingConfig{Level: "warn"}, + WIMSEDomain: testWIMSE, + AllowedResources: allowedResources, + DefaultAudience: defaultAudience, + Backchannel: zeroid.BackchannelConfig{AllowPrivateNotificationEndpoints: true}, + Attestation: zeroid.AttestationConfig{AllowPrivateIssuerEndpoints: true}, + } + srv, err := zeroid.NewServer(cfg) + require.NoError(t, err, "newRFC8707Server: NewServer failed") + httpSrv := httptest.NewServer(srv.Router()) + t.Cleanup(httpSrv.Close) + return httpSrv +} + +// newRestrictedServer is a convenience wrapper for tests that only need +// AllowedResources set (no DefaultAudience override). +func newRestrictedServer(t *testing.T, allowedResources []string) *httptest.Server { + return newRFC8707Server(t, allowedResources, "") +} + +// postJSON posts a JSON body to an arbitrary URL and returns the response. +func postJSON(t *testing.T, url string, body map[string]any) *http.Response { + t.Helper() + b, err := json.Marshal(body) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +// decodeBody decodes a JSON response body into a map. +func decodeBody(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + defer resp.Body.Close() //nolint:errcheck + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(raw, &m)) + return m +} + +// TestResourceIndicator covers the RFC 8707 resource indicator end-to-end. +// Open-mode sub-tests (no AllowedResources) run against the shared testServer. +// Restricted-mode sub-tests spin up a dedicated server. +func TestResourceIndicator(t *testing.T) { + scopes := []string{"data:read"} + + // ── Open-mode tests (shared testServer, no AllowedResources) ────────────── + + t.Run("valid_resource_open_mode_sets_aud", func(t *testing.T) { + agentID := uid("ri-open-cc") + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + "resource": "https://rs.example.com", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + claims := decodeJWTPayload(t, decode(t, resp)["access_token"].(string)) + assert.Equal(t, []string{"https://rs.example.com"}, audienceOf(t, claims), + "resource URI must be stamped as aud") + }) + + t.Run("malformed_resource_uri_returns_invalid_target", func(t *testing.T) { + agentID := uid("ri-malformed") + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + "resource": "not-a-uri", + }, nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "invalid_target", body["error"]) + }) + + t.Run("resource_with_fragment_returns_invalid_target", func(t *testing.T) { + agentID := uid("ri-fragment") + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + "resource": "https://rs.example.com#fragment", + }, nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "invalid_target", body["error"]) + }) + + t.Run("no_resource_no_default_falls_back_to_issuer", func(t *testing.T) { + agentID := uid("ri-no-resource") + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + claims := decodeJWTPayload(t, decode(t, resp)["access_token"].(string)) + assert.Equal(t, []string{testIssuer}, audienceOf(t, claims), + "no resource + no DefaultAudience must keep aud == issuer (regression guard)") + }) + + t.Run("jwt_bearer_resource_sets_aud", func(t *testing.T) { + agentID := uid("ri-jwt-bearer") + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + identity := registerIdentity(t, agentID, scopes, ecPublicKeyPEM(t, privKey)) + assertion := buildAssertion(t, privKey, identity.WIMSEURI) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "subject": assertion, + "scope": "data:read", + "resource": "https://rs-jwt.example.com", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + claims := decodeJWTPayload(t, decode(t, resp)["access_token"].(string)) + assert.Equal(t, []string{"https://rs-jwt.example.com"}, audienceOf(t, claims), + "jwt_bearer resource must be stamped as aud") + }) + + t.Run("no_resource_uses_default_audience", func(t *testing.T) { + const defaultAud = "https://default-rs.example.com" + srv := newRFC8707Server(t, nil, defaultAud) + + agentID := uid("ri-default-aud") + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + resp := postJSON(t, srv.URL+"/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + body := decodeBody(t, resp) + claims := decodeJWTPayload(t, body["access_token"].(string)) + assert.Equal(t, []string{defaultAud}, audienceOf(t, claims), + "no resource + DefaultAudience configured must stamp aud = defaultAudience") + }) + + // ── Restricted-mode tests (dedicated server with AllowedResources set) ──── + + t.Run("valid_resource_restricted_mode_sets_aud", func(t *testing.T) { + allowed := "https://allowed-rs.example.com" + srv := newRestrictedServer(t, []string{allowed}) + + agentID := uid("ri-restricted-ok") + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + resp := postJSON(t, srv.URL+"/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + "resource": allowed, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + body := decodeBody(t, resp) + claims := decodeJWTPayload(t, body["access_token"].(string)) + assert.Equal(t, []string{allowed}, audienceOf(t, claims)) + }) + + t.Run("blocked_resource_returns_invalid_target", func(t *testing.T) { + srv := newRestrictedServer(t, []string{"https://allowed-rs.example.com"}) + + agentID := uid("ri-restricted-blocked") + registerIdentity(t, agentID, scopes) + client := registerOAuthClient(t, agentID, scopes) + + resp := postJSON(t, srv.URL+"/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "account_id": testAccountID, + "project_id": testProjectID, + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "scope": "data:read", + "resource": "https://blocked.example.com", + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body := decodeBody(t, resp) + assert.Equal(t, "invalid_target", body["error"]) + }) +}