diff --git a/api/agent.yaml b/api/agent.yaml index 01a170b05e..30c5a1651a 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -2372,6 +2372,15 @@ components: description: > Configuration applied when the access token's subject is the agent itself, issued only via the client_credentials grant. + defaultAudience: + type: string + maxLength: 2048 + description: > + The aud claim for access tokens that are not bound to a resource server, that is, + OIDC-only or scopeless requests. Falls back to the client_id when empty. A request + that targets a resource server (via the resource parameter or API scopes) uses that + resource server as the audience instead. + example: https://api.example.com AccessTokenSubConfig: type: object diff --git a/api/application.yaml b/api/application.yaml index 8e2f01cd21..0d105f8527 100644 --- a/api/application.yaml +++ b/api/application.yaml @@ -1211,6 +1211,15 @@ components: description: > Configuration applied when the access token's subject is the OAuth client itself, issued only via the client_credentials grant. + defaultAudience: + type: string + maxLength: 2048 + description: > + The aud claim for access tokens that are not bound to a resource server, that is, + OIDC-only or scopeless requests. Falls back to the client_id when empty. A request + that targets a resource server (via the resource parameter or API scopes) uses that + resource server as the audience instead. + example: https://api.example.com AccessTokenSubConfig: type: object diff --git a/backend/internal/inboundclient/error_constants.go b/backend/internal/inboundclient/error_constants.go index 7641215148..67e530c2f9 100644 --- a/backend/internal/inboundclient/error_constants.go +++ b/backend/internal/inboundclient/error_constants.go @@ -92,6 +92,8 @@ var ( ErrOAuthResponseTypesRequireAuthCode = errors.New("response types require authorization_code grant type") // ErrOAuthInvalidTokenEndpointAuthMethod is returned when an unsupported auth method is specified. ErrOAuthInvalidTokenEndpointAuthMethod = errors.New("invalid token endpoint auth method") + // ErrOAuthDefaultAudienceTooLong is returned when the access token default audience exceeds the maximum length. + ErrOAuthDefaultAudienceTooLong = errors.New("default audience exceeds the maximum allowed length") // ErrOAuthPrivateKeyJWTRequiresCertificate is returned when private_key_jwt is used without a certificate. ErrOAuthPrivateKeyJWTRequiresCertificate = errors.New("private_key_jwt requires a certificate") // ErrOAuthCertificateRequiresClientID is returned when a certificate is provided without an OAuth client ID. diff --git a/backend/internal/inboundclient/service.go b/backend/internal/inboundclient/service.go index 94aa17a962..0f4258e2fc 100644 --- a/backend/internal/inboundclient/service.go +++ b/backend/internal/inboundclient/service.go @@ -752,6 +752,24 @@ func validateOAuthProfile(p *providers.OAuthProfile, hasClientSecret bool) error if err := validateIDTokenConfig(p); err != nil { return err } + if err := validateAccessTokenConfig(p); err != nil { + return err + } + return nil +} + +// maxDefaultAudienceLength bounds the access token default audience, a single audience identifier +// (typically a URI), to a sane length. +const maxDefaultAudienceLength = 2048 + +// validateAccessTokenConfig validates the access token configuration. +func validateAccessTokenConfig(p *providers.OAuthProfile) error { + if p.Token == nil || p.Token.AccessToken == nil { + return nil + } + if len(p.Token.AccessToken.DefaultAudience) > maxDefaultAudienceLength { + return ErrOAuthDefaultAudienceTooLong + } return nil } @@ -1349,6 +1367,7 @@ func resolveOAuthTokens(in *providers.OAuthTokenConfig, } if in != nil && in.AccessToken != nil { accessToken.ClientConfig = in.AccessToken.ClientConfig + accessToken.DefaultAudience = in.AccessToken.DefaultAudience } var idToken *providers.IDTokenConfig diff --git a/backend/internal/inboundclient/service_test.go b/backend/internal/inboundclient/service_test.go index ea1cd2e479..59306e6222 100644 --- a/backend/internal/inboundclient/service_test.go +++ b/backend/internal/inboundclient/service_test.go @@ -21,6 +21,7 @@ package inboundclient import ( "context" "errors" + "strings" "testing" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -556,6 +557,21 @@ func (suite *InboundClientServiceTestSuite) TestValidate_ValidProfile() { assert.NoError(suite.T(), err) } +func (suite *InboundClientServiceTestSuite) TestValidate_DefaultAudienceTooLong() { + store := newInboundClientStoreInterfaceMock(suite.T()) + svc := newServiceForTest(store) + + p := validOAuthProfile() + p.Token = &providers.OAuthTokenConfig{ + AccessToken: &providers.AccessTokenConfig{ + DefaultAudience: strings.Repeat("a", maxDefaultAudienceLength+1), + }, + } + + err := svc.Validate(context.Background(), ptrInboundClient(), p, false) + assert.ErrorIs(suite.T(), err, ErrOAuthDefaultAudienceTooLong) +} + func (suite *InboundClientServiceTestSuite) TestValidate_InvalidGrantType() { store := newInboundClientStoreInterfaceMock(suite.T()) svc := newServiceForTest(store) @@ -1216,6 +1232,14 @@ func (suite *InboundClientServiceTestSuite) TestResolveOAuthTokens_ZeroValidityF assert.Equal(suite.T(), int64(86400), rt.ValidityPeriod) } +func (suite *InboundClientServiceTestSuite) TestResolveOAuthTokens_CarriesDefaultAudience() { + in := &providers.OAuthTokenConfig{ + AccessToken: &providers.AccessTokenConfig{DefaultAudience: "https://api.example.com"}, + } + at, _, _ := resolveOAuthTokens(in, &inboundmodel.AssertionConfig{ValidityPeriod: 900}) + assert.Equal(suite.T(), "https://api.example.com", at.DefaultAudience) +} + // ----- resolveScopeClaims ----- func (suite *InboundClientServiceTestSuite) TestResolveScopeClaims_NilReturnsEmptyMap() { diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go index e77d04bd56..cee4be483c 100644 --- a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go +++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go @@ -153,8 +153,9 @@ func (h *authorizationCodeGrantHandler) HandleGrant(ctx context.Context, tokenRe var accessTokenAudiences, accessTokenScopes []string if targetRS == nil { // OIDC-only (or scopeless) request with no resource: the token is not bound to a resource - // server, so its audience is the client_id and it carries only the OIDC scopes. - accessTokenAudiences = []string{tokenRequest.ClientID} + // server, so its audience is the app's configured default audiences (falling back to the + // client_id) and it carries only the OIDC scopes. + accessTokenAudiences = []string{oauthApp.ResolveDefaultAudience(tokenRequest.ClientID)} accessTokenScopes = oidcScopes } else { downscopedNonOidc, dErr := resourceindicators.DownscopeToResourceServer( diff --git a/backend/internal/oauth/oauth2/granthandlers/ciba.go b/backend/internal/oauth/oauth2/granthandlers/ciba.go index 71e1d12818..87e060e5af 100644 --- a/backend/internal/oauth/oauth2/granthandlers/ciba.go +++ b/backend/internal/oauth/oauth2/granthandlers/ciba.go @@ -297,8 +297,9 @@ func (h *cibaGrantHandler) issueTokens(ctx context.Context, record *ciba.CIBAAut // resolveIssuedAudiencesAndScopes derives the access-token audiences and scopes for a CIBA record. // A resource-bound record yields the RS identifier as the sole audience, with permission scopes -// refiltered against that RS. An unbound OIDC-only record keeps the client audience; an unbound -// record that unexpectedly carries permission scopes is rejected with invalid_grant. +// refiltered against that RS. An unbound OIDC-only record uses the app's configured default +// audience, falling back to the client_id; an unbound record that unexpectedly carries permission +// scopes is rejected with invalid_grant. func (h *cibaGrantHandler) resolveIssuedAudiencesAndScopes(ctx context.Context, record *ciba.CIBAAuthRequest, oauthApp *providers.OAuthClient, scopeStr string, ) ([]string, []string, *model.ErrorResponse) { @@ -311,7 +312,7 @@ func (h *cibaGrantHandler) resolveIssuedAudiencesAndScopes(ctx context.Context, ErrorDescription: "The authentication request is not bound to a resource server", } } - return []string{oauthApp.ClientID}, oidcScopes, nil + return []string{oauthApp.ResolveDefaultAudience(oauthApp.ClientID)}, oidcScopes, nil } resourceIdentifier := record.Resources[0] diff --git a/backend/internal/oauth/oauth2/granthandlers/ciba_test.go b/backend/internal/oauth/oauth2/granthandlers/ciba_test.go index f726b87377..40b8e958e8 100644 --- a/backend/internal/oauth/oauth2/granthandlers/ciba_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/ciba_test.go @@ -263,6 +263,30 @@ func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_NoOpenIDSk suite.Empty(resp.IDToken.Token) } +// An unbound OIDC-only CIBA access token uses the app's configured default audience for the aud +// claim instead of the client_id. +func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_UsesConfiguredDefaultAudience() { + suite.oauthApp.Token = &providers.OAuthTokenConfig{ + AccessToken: &providers.AccessTokenConfig{DefaultAudience: "https://api.example.com"}, + } + record := suite.pendingRecord() + record.State = ciba.CIBAStateAuthenticated + record.AuthorizedScopes = constants.ScopeOpenID + suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil) + suite.mockTokenBuilder.EXPECT().BuildAccessToken(mock.Anything, mock.MatchedBy( + func(ctx *tokenservice.AccessTokenBuildContext) bool { + return len(ctx.Audiences) == 1 && ctx.Audiences[0] == "https://api.example.com" + })).Return(&model.TokenDTO{Token: "access-token", TokenType: "Bearer"}, nil) + suite.mockTokenBuilder.EXPECT().BuildIDToken(mock.Anything, mock.Anything). + Return(&model.TokenDTO{Token: "id-token"}, nil) + suite.mockCIBAService.EXPECT().MarkConsumed(mock.Anything, "auth-req-1").Return(true, nil) + + resp, errResp := suite.handler.HandleGrant(context.Background(), suite.tokenReq, suite.oauthApp) + suite.Nil(errResp) + suite.NotNil(resp) + suite.Equal("access-token", resp.AccessToken.Token) +} + func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_OneTimeUseRace() { record := suite.boundAuthenticatedRecord(testScopeRead) suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil) diff --git a/backend/internal/oauth/oauth2/granthandlers/client_credentials.go b/backend/internal/oauth/oauth2/granthandlers/client_credentials.go index 7dfff8562c..be21c51927 100644 --- a/backend/internal/oauth/oauth2/granthandlers/client_credentials.go +++ b/backend/internal/oauth/oauth2/granthandlers/client_credentials.go @@ -89,14 +89,15 @@ func (h *clientCredentialsGrantHandler) HandleGrant(ctx context.Context, tokenRe // A client_credentials token carries no OIDC scopes, so every requested scope is a permission // scope. Bind the token to a single resource server (RFC 8707 resource or the configured // default). A request with neither scopes nor a resource is not bound to a resource server: its - // audience is the client_id and it carries no scopes. + // audience is the app's configured default audiences (falling back to the client_id) and it + // carries no scopes. targetRS, errResp := resourceindicators.ResolveAudienceBinding( ctx, h.resourceService, h.serverConfigService, tokenRequest.Resources, scopes) if errResp != nil { return nil, errResp } - audiences := []string{tokenRequest.ClientID} + audiences := []string{oauthApp.ResolveDefaultAudience(tokenRequest.ClientID)} if targetRS != nil { audiences = []string{targetRS.Identifier} diff --git a/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go b/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go index 34d598f821..b18ae26189 100644 --- a/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/client_credentials_test.go @@ -25,6 +25,7 @@ import ( "context" "errors" + "slices" "testing" "time" @@ -293,6 +294,50 @@ func (suite *ClientCredentialsGrantHandlerTestSuite) TestHandleGrant_Success() { } } +// A scopeless request (not bound to a resource server) uses the app's configured default audience +// for the aud claim instead of the client_id. +func (suite *ClientCredentialsGrantHandlerTestSuite) TestHandleGrant_ScopelessUsesConfiguredDefaultAudience() { + suite.mockJWTService.Mock = mock.Mock{} + suite.oauthApp.Token = &providers.OAuthTokenConfig{ + AccessToken: &providers.AccessTokenConfig{ + DefaultAudience: "https://api.example.com/booking", + }, + } + + tokenRequest := &model.TokenRequest{ + GrantType: "client_credentials", + ClientID: testClientID, + ClientSecret: "secret123", + Scope: "", + } + + expectedAudiences := []string{"https://api.example.com/booking"} + suite.mockTokenBuilder.On("BuildAccessToken", + mock.Anything, + mock.MatchedBy(func(ctx *tokenservice.AccessTokenBuildContext) bool { + return ctx.Subject == testEntityID && + slices.Equal(ctx.Audiences, expectedAudiences) && + ctx.ClientID == testClientID && + len(ctx.Scopes) == 0 + })).Return(&model.TokenDTO{ + Token: testJWTToken, + TokenType: constants.TokenTypeBearer, + IssuedAt: int64(1234567890), + ExpiresIn: 3600, + Scopes: []string{}, + ClientID: testClientID, + Subject: testEntityID, + Audiences: expectedAudiences, + }, nil) + + result, errResp := suite.handler.HandleGrant(context.Background(), tokenRequest, suite.oauthApp) + + assert.Nil(suite.T(), errResp) + assert.NotNil(suite.T(), result) + assert.Equal(suite.T(), expectedAudiences, result.AccessToken.Audiences) + suite.mockTokenBuilder.AssertExpectations(suite.T()) +} + func (suite *ClientCredentialsGrantHandlerTestSuite) TestHandleGrant_JWTGenerationError() { tokenRequest := &model.TokenRequest{ GrantType: "client_credentials", diff --git a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go index daad875a3a..b2552c8ba4 100644 --- a/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go +++ b/backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go @@ -150,8 +150,9 @@ func (h *jwtBearerGrantHandler) HandleGrant(ctx context.Context, tokenRequest *m var audiences []string if targetRS == nil { // OIDC-only assertion with no resource: the token is not bound to a resource server, so its - // audience is the client_id and it carries only the OIDC scopes. - audiences = []string{tokenRequest.ClientID} + // audience is the app's configured default audiences (falling back to the client_id) and it + // carries only the OIDC scopes. + audiences = []string{oauthApp.ResolveDefaultAudience(tokenRequest.ClientID)} grantedScopes = oidcScopes } else { permissionScopes, errResp = resourceindicators.DownscopeToResourceServer( diff --git a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go index 8f6064afab..21dd0ade62 100644 --- a/backend/internal/oauth/oauth2/granthandlers/token_exchange.go +++ b/backend/internal/oauth/oauth2/granthandlers/token_exchange.go @@ -225,7 +225,8 @@ func (h *tokenExchangeGrantHandler) HandleGrant(ctx context.Context, tokenReques // Bind the token to a single target resource server (RFC 8707 resource or configured default). // The RFC 8693 audience parameter is not honored. A request that resolves no permission scopes - // and carries no resource is not bound to a resource server: its audience is the client_id. + // and carries no resource is not bound to a resource server: its audience is the app's configured + // default audiences, falling back to the client_id. targetRS, resErr := resourceindicators.ResolveAudienceBinding( ctx, h.resourceService, h.serverConfigService, tokenRequest.Resources, permissionScopes) if resErr != nil { @@ -234,7 +235,7 @@ func (h *tokenExchangeGrantHandler) HandleGrant(ctx context.Context, tokenReques var finalAudiences []string if targetRS == nil { - finalAudiences = []string{tokenRequest.ClientID} + finalAudiences = []string{oauthApp.ResolveDefaultAudience(tokenRequest.ClientID)} finalScopes = oidcScopes } else { permissionScopes, resErr = resourceindicators.DownscopeToResourceServer( diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index b5a1fbd02f..6c84af9f01 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -596,8 +596,9 @@ type IDJAGConfig struct { // (UserConfig) or the OAuth client itself, issued only via the client_credentials grant // (ClientConfig). type AccessTokenConfig struct { - UserConfig *AccessTokenSubConfig `json:"userConfig,omitempty" yaml:"userConfig,omitempty" jsonschema:"Access token configuration applied when the token subject is an end user."` - ClientConfig *AccessTokenSubConfig `json:"clientConfig,omitempty" yaml:"clientConfig,omitempty" jsonschema:"Access token configuration applied when the token subject is the OAuth client itself, issued only via the client_credentials grant."` + UserConfig *AccessTokenSubConfig `json:"userConfig,omitempty" yaml:"userConfig,omitempty" jsonschema:"Access token configuration applied when the token subject is an end user."` + ClientConfig *AccessTokenSubConfig `json:"clientConfig,omitempty" yaml:"clientConfig,omitempty" jsonschema:"Access token configuration applied when the token subject is the OAuth client itself, issued only via the client_credentials grant."` + DefaultAudience string `json:"defaultAudience,omitempty" yaml:"defaultAudience,omitempty" jsonschema:"Audience for access tokens not bound to a resource server (OIDC-only or scopeless requests). Falls back to the client_id when empty."` } // AccessTokenSubConfig holds the validity period and attribute selection for one access diff --git a/backend/pkg/thunderidengine/providers/oauth_client.go b/backend/pkg/thunderidengine/providers/oauth_client.go index d44a66db11..9dd83b28d8 100644 --- a/backend/pkg/thunderidengine/providers/oauth_client.go +++ b/backend/pkg/thunderidengine/providers/oauth_client.go @@ -107,6 +107,17 @@ func (o *OAuthClient) ClientAccessTokenConfig() *AccessTokenSubConfig { return o.Token.AccessToken.ClientConfig } +// ResolveDefaultAudience returns the aud claim for an access token that is not bound to a +// resource server (an OIDC-only or scopeless request). It returns the application's configured +// default audience when set; otherwise it falls back to the given client_id. +func (o *OAuthClient) ResolveDefaultAudience(clientID string) string { + if o != nil && o.Token != nil && o.Token.AccessToken != nil && + o.Token.AccessToken.DefaultAudience != "" { + return o.Token.AccessToken.DefaultAudience + } + return clientID +} + // ValidateRedirectURI validates the provided redirect URI against the registered list. func ValidateRedirectURI(ctx context.Context, redirectURIs []string, redirectURI string) error { logger := log.GetLogger() diff --git a/backend/pkg/thunderidengine/providers/oauth_client_test.go b/backend/pkg/thunderidengine/providers/oauth_client_test.go index cc6dd57c65..f42ab4512e 100644 --- a/backend/pkg/thunderidengine/providers/oauth_client_test.go +++ b/backend/pkg/thunderidengine/providers/oauth_client_test.go @@ -125,6 +125,22 @@ func (suite *OAuthClientTestSuite) TestOAuthClient_ShouldAppendActorClaim() { }) } +func (suite *OAuthClientTestSuite) TestOAuthClient_ResolveDefaultAudience() { + suite.T().Run("returns the configured default audience", func(t *testing.T) { + client := &OAuthClient{Token: &OAuthTokenConfig{AccessToken: &AccessTokenConfig{ + DefaultAudience: "https://api.example.com", + }}} + assert.Equal(t, "https://api.example.com", client.ResolveDefaultAudience("client-123")) + }) + suite.T().Run("falls back to client_id when default audience unset", func(t *testing.T) { + client := &OAuthClient{Token: &OAuthTokenConfig{AccessToken: &AccessTokenConfig{}}} + assert.Equal(t, "client-123", client.ResolveDefaultAudience("client-123")) + }) + suite.T().Run("falls back to client_id when token config unset", func(t *testing.T) { + assert.Equal(t, "client-123", (&OAuthClient{}).ResolveDefaultAudience("client-123")) + }) +} + func (suite *OAuthClientTestSuite) TestOAuthClient_RequiresPAR() { suite.T().Run("client flag forces PAR", func(t *testing.T) { suite.setupRuntime(t, engineconfig.OAuthConfig{PAR: engineconfig.PARConfig{RequirePAR: false}}) diff --git a/docs/content/guides/getting-started/configuration.mdx b/docs/content/guides/getting-started/configuration.mdx index 1c8298934e..bafa32fe7a 100644 --- a/docs/content/guides/getting-started/configuration.mdx +++ b/docs/content/guides/getting-started/configuration.mdx @@ -855,7 +855,7 @@ The `null` origin is shared by sandboxed iframes, `file://` and `data:` document ## Default Resource Server -Sets the resource server for permission-bearing token requests that omit the `resource` parameter. It is stored in the server-config `defaultResourceServer` section, not in `deployment.yaml`. When no default is configured, a request that contains permission scopes but omits `resource` fails with `invalid_target`. OIDC-only and scopeless requests use the `client_id` audience instead. +Sets the resource server for permission-bearing token requests that omit the `resource` parameter. It is stored in the server-config `defaultResourceServer` section, not in `deployment.yaml`. When no default is configured, a request that contains permission scopes but omits `resource` fails with `invalid_target`. OIDC-only and scopeless requests are not bound to a resource server, so they use the application's default audience (`token.accessToken.defaultAudience`), or the `client_id` when it is unset. | Field | Description | |-------|-------------| diff --git a/docs/content/guides/guides/agents/agent-authentication.mdx b/docs/content/guides/guides/agents/agent-authentication.mdx index 09d227cf13..37d33840ed 100644 --- a/docs/content/guides/guides/agents/agent-authentication.mdx +++ b/docs/content/guides/guides/agents/agent-authentication.mdx @@ -104,7 +104,7 @@ Use `resource` (RFC 8707) for the resource server URI: -d 'resource=https://api.example.com/invoices' ``` -The RFC 8693 `audience` parameter is accepted for compatibility but does not determine the access token's `aud` claim. For permission-bearing requests, the access-token audience is the resolved `resource`, or the configured `defaultResourceServer` when `resource` is omitted. An OIDC-only or scopeless request without `resource` uses the `client_id` audience. +The RFC 8693 `audience` parameter is accepted for compatibility but does not determine the access token's `aud` claim. For permission-bearing requests, the access-token audience is the resolved `resource`, or the configured `defaultResourceServer` when `resource` is omitted. An OIDC-only or scopeless request without `resource` uses the agent's default audience (`token.accessToken.defaultAudience`), or the `client_id` when it is unset. ## Token Characteristics @@ -114,7 +114,7 @@ All agent access tokens are signed JWTs (`typ: at+jwt`) and share a common set o |---|---|---| | `sub` | The agent's resource ID. | The original user's subject identifier. | | `iss` | instance URL. | instance URL. | -| `aud` | The resolved resource server identifier, or Client ID for a scopeless request without `resource`. | The resolved resource server identifier, or Client ID when no permission scopes or `resource` are present. | +| `aud` | The resolved resource server identifier, or the default audience (falling back to Client ID) for a scopeless request without `resource`. | The resolved resource server identifier, or the default audience (falling back to Client ID) when no permission scopes or `resource` are present. | | `scope` | Authorized scopes from the agent's group/role assignments. | A subset of the user's original scopes. | | `client_id` | The agent's Client ID. | The agent's Client ID. | | `grant_type` | `client_credentials` | `urn:ietf:params:oauth:grant-type:token-exchange` | diff --git a/docs/content/guides/guides/identity-providers/token-exchange-idp.mdx b/docs/content/guides/guides/identity-providers/token-exchange-idp.mdx index 475546c44c..bf700e2ae4 100644 --- a/docs/content/guides/guides/identity-providers/token-exchange-idp.mdx +++ b/docs/content/guides/guides/identity-providers/token-exchange-idp.mdx @@ -101,7 +101,7 @@ A successful response returns a -issued access token: } ``` -The issued token's `sub` claim contains the external user's subject identifier from the original token. For permission-bearing requests, its `aud` claim is the explicit resource server identifier or the configured `defaultResourceServer`. An OIDC-only or scopeless request without `resource` uses the `client_id` audience. +The issued token's `sub` claim contains the external user's subject identifier from the original token. For permission-bearing requests, its `aud` claim is the explicit resource server identifier or the configured `defaultResourceServer`. An OIDC-only or scopeless request without `resource` uses the application's default audience (`token.accessToken.defaultAudience`), or the `client_id` when it is unset. ## How Validates the External Token diff --git a/docs/content/guides/guides/protocols/oauth-oidc/authorization-code.mdx b/docs/content/guides/guides/protocols/oauth-oidc/authorization-code.mdx index 0c76609344..d96cd6616c 100644 --- a/docs/content/guides/guides/protocols/oauth-oidc/authorization-code.mdx +++ b/docs/content/guides/guides/protocols/oauth-oidc/authorization-code.mdx @@ -96,7 +96,7 @@ curl -X POST https://{{productSlug}}.example.com/oauth2/token \ The `response_mode` parameter is optional. For `response_type=code`, uses `query` by default and returns the authorization response in the callback URL query string. Explicit `response_mode=query` is also accepted. Other response modes, such as `fragment` and `form_post`, return `invalid_request`. -Access tokens with permission scopes are bound to one resource server. Send `resource=https://api.example.com/...` on the authorization or token request when the client knows the target API, or configure `defaultResourceServer` for permission-bearing requests that omit `resource`. OIDC-only or scopeless requests without `resource` use the `client_id` audience. +Access tokens with permission scopes are bound to one resource server. Send `resource=https://api.example.com/...` on the authorization or token request when the client knows the target API, or configure `defaultResourceServer` for permission-bearing requests that omit `resource`. OIDC-only or scopeless requests without `resource` use the application's `token.accessToken.defaultAudience`, falling back to `client_id`. ## Related Guides diff --git a/docs/content/guides/guides/protocols/oauth-oidc/backchannel-authentication.mdx b/docs/content/guides/guides/protocols/oauth-oidc/backchannel-authentication.mdx index 99316b7496..322b6a1f2b 100644 --- a/docs/content/guides/guides/protocols/oauth-oidc/backchannel-authentication.mdx +++ b/docs/content/guides/guides/protocols/oauth-oidc/backchannel-authentication.mdx @@ -57,7 +57,7 @@ The flow proceeds in three phases: | Default polling interval | 5 seconds | | `slow_down` | Returned when the client polls faster than the current interval; the client must permanently increase the interval by 5 seconds | | Scope requirement | Must include `openid` | -| Resource binding (RFC 8707) | An optional `resource` binds the request to one resource server at initiation. When a permission scope is requested without `resource`, the configured `defaultResourceServer` applies; with no default, the request is rejected with `invalid_target`. The access token's `aud` is the bound resource server identifier, and its permissions are limited to that server. OIDC-only requests without `resource` keep the `client_id` audience. A `resource` supplied when polling must match the binding | +| Resource binding (RFC 8707) | An optional `resource` binds the request to one resource server at initiation. When a permission scope is requested without `resource`, the configured `defaultResourceServer` applies; with no default, the request is rejected with `invalid_target`. The access token's `aud` is the bound resource server identifier, and its permissions are limited to that server. OIDC-only requests without `resource` use the application's `token.accessToken.defaultAudience`, falling back to `client_id`. A `resource` supplied when polling must match the binding | | `binding_message` | Optional; maximum 256 printable characters; generates a default message when omitted | | Token issuance | Access token always issued; ID token issued when `openid` is in the scope; refresh token issued when `refresh_token` grant is configured on the client | | One-time use | atomically marks the `auth_req_id` as consumed on the first successful token exchange; subsequent polls return `invalid_grant` | diff --git a/docs/content/guides/guides/protocols/oauth-oidc/client-credentials.mdx b/docs/content/guides/guides/protocols/oauth-oidc/client-credentials.mdx index ee80ee1cf2..121013fc46 100644 --- a/docs/content/guides/guides/protocols/oauth-oidc/client-credentials.mdx +++ b/docs/content/guides/guides/protocols/oauth-oidc/client-credentials.mdx @@ -30,7 +30,7 @@ Use it for service-to-service calls, internal daemons, scheduled jobs, and CLI t | User attributes | Not included: there is no authenticated user | | Refresh token | Not issued for this grant (re-request when needed) | | Scope filtering | Requested scopes are filtered to permissions defined on the target resource server, then intersected with the permissions the client holds through its **role and group assignments** | -| `aud` claim | The supplied resource server identifier, or `defaultResourceServer` for a permission-bearing request without `resource`. A scopeless request without `resource` uses `client_id` | +| `aud` claim | The supplied resource server identifier, or `defaultResourceServer` for a permission-bearing request without `resource`. A scopeless request without `resource` uses the application's `token.accessToken.defaultAudience`, falling back to `client_id` | | ID token | Not issued: this is an OAuth-only flow, not OIDC | @@ -48,7 +48,7 @@ Use it for service-to-service calls, internal daemons, scheduled jobs, and CLI t 6. Save. :::note -When requesting permission scopes, the client must include `resource` unless `defaultResourceServer` is configured globally. A scopeless request does not require either setting and uses `client_id` as its audience. +When requesting permission scopes, the client must include `resource` unless `defaultResourceServer` is configured globally. A scopeless request does not require either setting and uses the application's `token.accessToken.defaultAudience` (falling back to `client_id`) as its audience. ::: diff --git a/docs/content/guides/guides/protocols/oauth-oidc/resource-indicators.mdx b/docs/content/guides/guides/protocols/oauth-oidc/resource-indicators.mdx index 010a54cd9d..f0d5f44d88 100644 --- a/docs/content/guides/guides/protocols/oauth-oidc/resource-indicators.mdx +++ b/docs/content/guides/guides/protocols/oauth-oidc/resource-indicators.mdx @@ -7,7 +7,7 @@ description: RFC 8707 Resource Indicators in {{ProductName}}, to target access t # Resource Indicators -**Resource Indicators for OAuth 2.0** ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)) lets a client tell *which* resource server the access token is for. The client passes a single `resource` parameter. looks it up against the registered resource servers, narrows the granted permission scopes to those owned by that resource server, and writes its identifier into the token's `aud` claim. When permission scopes are requested without `resource`, uses the configured `defaultResourceServer`. If neither target is available, token issuance fails with `invalid_target`. OIDC-only and scopeless requests without `resource` use the `client_id` as the access-token audience. +**Resource Indicators for OAuth 2.0** ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)) lets a client tell *which* resource server the access token is for. The client passes a single `resource` parameter. looks it up against the registered resource servers, narrows the granted permission scopes to those owned by that resource server, and writes its identifier into the token's `aud` claim. When permission scopes are requested without `resource`, uses the configured `defaultResourceServer`. If neither target is available, token issuance fails with `invalid_target`. OIDC-only and scopeless requests without `resource` are not bound to a resource server, so their audience is the application's configured default audience (`token.accessToken.defaultAudience`), or the `client_id` when it is unset. The effect: a token issued for the payments API cannot be replayed against the bookings API. Each token is bound to its intended audience. @@ -53,7 +53,7 @@ The issued access token carries the resource identifier as its `aud`: | Value rules | The `resource` must be an absolute URI with no fragment. A request with more than one `resource` parameter is rejected with `invalid_target`. | | Resolution | The URI is matched to a registered resource server's `identifier` | | Unknown identifier | Request rejected with `invalid_target` | -| No `resource` | Permission-bearing requests use `defaultResourceServer` and return `invalid_target` if it is unset. OIDC-only and scopeless requests use `client_id` as the audience | +| No `resource` | Permission-bearing requests use `defaultResourceServer` and return `invalid_target` if it is unset. OIDC-only and scopeless requests use the application's `token.accessToken.defaultAudience`, or `client_id` when it is unset | | CIBA | The request binds to a resource server at `/oauth2/bc-authorize`, before user authorization. A `resource` supplied at token-endpoint polling must equal that binding, or the poll is rejected with `invalid_target` | | Scope filtering | Requested scopes are filtered to those defined on the targeted resource server. Scopes not owned by that resource server are silently dropped | | OIDC standard scopes | `openid`, `profile`, `email`, `phone`, `address` are **not** filtered by resource indicators | @@ -66,7 +66,7 @@ The issued access token carries the resource identifier as its `aud`: | One `resource` | JSON string: `"aud": "https://api.example.com/payments"` | | Multiple `resource` parameters | Rejected with `invalid_target` | | No `resource`, permission scopes requested | The configured `defaultResourceServer` identifier. If no default is configured, the request is rejected with `invalid_target`. | -| No `resource`, no permission scopes | The requesting client's `client_id`. | +| No `resource`, no permission scopes | The application's configured default audience (`token.accessToken.defaultAudience`), or the requesting client's `client_id` when it is unset. | The `aud` claim is always a single string, per [RFC 7519 §4.1.3](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3). diff --git a/docs/content/guides/guides/protocols/oauth-oidc/token-exchange.mdx b/docs/content/guides/guides/protocols/oauth-oidc/token-exchange.mdx index 61923bdc4e..6a7dda0e32 100644 --- a/docs/content/guides/guides/protocols/oauth-oidc/token-exchange.mdx +++ b/docs/content/guides/guides/protocols/oauth-oidc/token-exchange.mdx @@ -33,7 +33,7 @@ Typical uses: | `actor_token_type` (input) | Same set as `subject_token_type`; used for delegation chains | | `requested_token_type` (output) | `urn:ietf:params:oauth:token-type:access_token` · `:jwt` only. **`id_token` and `refresh_token` outputs are not supported.** | | `audience` parameter | Accepted for RFC 8693 compatibility but does not determine the issued access token's `aud` claim | -| `resource` parameter | RFC 8707 resource indicator. The issued access token is bound to exactly this resource server. Permission-bearing requests use `defaultResourceServer` when it is omitted. OIDC-only or scopeless requests use `client_id` | +| `resource` parameter | RFC 8707 resource indicator. The issued access token is bound to exactly this resource server. Permission-bearing requests use `defaultResourceServer` when it is omitted. OIDC-only or scopeless requests use the application's `token.accessToken.defaultAudience`, falling back to `client_id` | | `scope` parameter | Requested scopes; downscoping is allowed, widening is rejected with `invalid_scope`. Final scopes must also be permissions on the target resource server and authorized for the issuing app or agent. | | Client authentication | Required, same methods as the token endpoint | | Issued token shape | `issued_token_type` is echoed in the response so the caller knows what it received | @@ -49,7 +49,7 @@ Typical uses: | `actor_token` | No | Token of the acting party in delegation | | `actor_token_type` | No | Required when `actor_token` is supplied | | `audience` | No | Logical audience value from RFC 8693. It is ignored for the access-token `aud`; use `resource` to select the token audience. | -| `resource` | No | Absolute URI of the target resource server (see [Resource Indicators](../resource-indicators)). If omitted, permission-bearing requests use the configured default resource server, while OIDC-only or scopeless requests use `client_id`. | +| `resource` | No | Absolute URI of the target resource server (see [Resource Indicators](../resource-indicators)). If omitted, permission-bearing requests use the configured default resource server, while OIDC-only or scopeless requests use the application's `token.accessToken.defaultAudience`, falling back to `client_id`. | | `scope` | No | Requested scopes must be a subset of the subject token's grant | diff --git a/docs/content/guides/key-concepts/tokens.mdx b/docs/content/guides/key-concepts/tokens.mdx index 87baf2e579..29888e1121 100644 --- a/docs/content/guides/key-concepts/tokens.mdx +++ b/docs/content/guides/key-concepts/tokens.mdx @@ -16,7 +16,7 @@ Access tokens are JWTs that a client presents to a resource server to access pro | Claim | Description | |-------|-------------| | `sub` | The subject, the user ID or client ID the token represents. | -| `aud` | The audience: one resource server identifier, or the `client_id` for an unbound OIDC-only or scopeless token. | +| `aud` | The audience: one resource server identifier, or the application's default audience (`token.accessToken.defaultAudience`, falling back to `client_id`) for an unbound OIDC-only or scopeless token. | | `scope` | The granted scopes, the permissions the token carries. | | `iss` | The issuer, the instance that issued the token. | | `exp` | The expiry time as a Unix timestamp. | @@ -27,7 +27,7 @@ The `aud` claim varies based on whether the client used [resource indicators](.. - **With `resource` parameter:** The `aud` claim contains the identifier of the targeted resource server. - **Without `resource`, with permission scopes:** uses the configured `defaultResourceServer`. If no default is configured, token issuance fails with `invalid_target`. -- **Without `resource` or permission scopes:** The `aud` claim contains the requesting client's `client_id`. +- **Without `resource` and permission scopes:** The `aud` claim contains the application's default audience (`token.accessToken.defaultAudience`), or the requesting client's `client_id` when it is unset. - **Serialization:** The single audience is serialized as a JSON string (for example, `"aud": "https://api.example.com/booking"`), per [RFC 7519 Section 4.1.3](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3). Resource servers that validate tokens should require `aud` to match their own resource server identifier. diff --git a/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx index 659d4e0686..c4a27c7ab1 100644 --- a/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx +++ b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx @@ -22,6 +22,7 @@ import OperationModesSection from './OperationModesSection'; import OwnerSection from './OwnerSection'; import SecuritySection from './SecuritySection'; import TokenEndpointAuthMethodSection from './TokenEndpointAuthMethodSection'; +import AudienceSection from '../../../../applications/components/edit-application/advanced-settings/AudienceSection'; import type {OAuth2Config} from '../../../../applications/models/oauth'; import type {Agent, AgentInboundAuthConfig, OAuthAgentConfig} from '../../../models/agent'; @@ -46,6 +47,15 @@ export default function EditAdvancedSettings({ onFieldChange('inboundAuthConfig', updatedInboundAuth); }; + const handleDefaultAudienceChange = (audience: string) => { + handleOAuth2ConfigChange({ + token: { + ...oauth2Config?.token, + accessToken: {...oauth2Config?.token?.accessToken, defaultAudience: audience}, + } as OAuth2Config['token'], + }); + }; + return ( @@ -70,6 +80,14 @@ export default function EditAdvancedSettings({ onOAuth2ConfigChange={handleOAuth2ConfigChange} disabled={agent.isReadOnly} /> + {oauth2Config && ( + + )} ); } diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AudienceSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AudienceSection.tsx new file mode 100644 index 0000000000..a0e869d042 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AudienceSection.tsx @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {SettingsCard} from '@thunderid/components'; +import {FormControl, FormLabel, TextField} from '@wso2/oxygen-ui'; +import {useTranslation} from 'react-i18next'; + +/** + * Props for the {@link AudienceSection} component. + */ +interface AudienceSectionProps { + /** + * Current default audience value (aud claim) for tokens not bound to a resource server. + */ + audience: string; + /** + * Callback fired whenever the audience value changes. + */ + onAudienceChange: (audience: string) => void; + /** + * Singular noun used to refer to the entity in user-visible copy (default: 'application'). + */ + entityLabel?: string; + /** + * Whether the input should be disabled (e.g. read-only resource). + */ + disabled?: boolean; +} + +/** + * Settings card for configuring the default audience of access tokens that are not bound to a + * resource server (OIDC-only or scopeless requests). When left empty, such a token's `aud` claim + * falls back to the client_id. The value is free-text so it can be any audience identifier, + * including external ones not registered in ThunderID. + * + * @param props - Component props + * @returns Default audience configuration within a SettingsCard + */ +export default function AudienceSection({ + audience, + onAudienceChange, + entityLabel = 'application', + disabled = false, +}: AudienceSectionProps) { + const {t} = useTranslation(); + + return ( + + + + {t('applications:edit.advanced.audience.label', 'Default audience (aud)')} + + onAudienceChange(e.target.value.trim())} + inputProps={{maxLength: 2048}} + placeholder={t('applications:edit.advanced.audience.placeholder', 'e.g. https://api.example.com')} + helperText={t('applications:edit.advanced.audience.hint', 'Leave empty to use the {{entity}} client ID.', { + entity: entityLabel, + })} + /> + + + ); +} diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx index 57bc06d2db..1a1acdbf51 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx @@ -19,6 +19,7 @@ import {Stack} from '@wso2/oxygen-ui'; import {useEffect, useState} from 'react'; import AttestationSection from './AttestationSection'; +import AudienceSection from './AudienceSection'; import CertificateSection from './CertificateSection'; import IdentityAssertionsSection from './IdentityAssertionsSection'; import MetadataSection from './MetadataSection'; @@ -144,6 +145,12 @@ export default function EditAdvancedSettings({ onFieldChange('inboundAuthConfig', updatedInboundAuth); }; + const handleDefaultAudienceChange = (audience: string) => { + handleTokenConfigChange({ + accessToken: {...oauth2Config?.token?.accessToken, defaultAudience: audience}, + }); + }; + return ( )} + {oauth2Config && ( + + )} ({ + useTranslation: () => ({ + t: (key: string, fallback?: string, opts?: {entity?: string}) => + (fallback ?? key).replace('{{entity}}', opts?.entity ?? ''), + }), +})); + +describe('AudienceSection', () => { + const onAudienceChange = vi.fn(); + + beforeEach(() => { + onAudienceChange.mockClear(); + }); + + it('renders the card and the current audience value', () => { + render(); + + expect(screen.getByText('Default Audience')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://api.example.com')).toBeInTheDocument(); + }); + + it('reports the typed audience (trimmed)', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('e.g. https://api.example.com'); + await user.type(input, 'x'); + + expect(onAudienceChange).toHaveBeenLastCalledWith('x'); + }); + + it('disables the input when disabled is set', () => { + render(); + + expect(screen.getByPlaceholderText('e.g. https://api.example.com')).toBeDisabled(); + }); +}); diff --git a/frontend/apps/console/src/features/applications/models/token.ts b/frontend/apps/console/src/features/applications/models/token.ts index f516336c3d..ab42d7c7ea 100644 --- a/frontend/apps/console/src/features/applications/models/token.ts +++ b/frontend/apps/console/src/features/applications/models/token.ts @@ -93,4 +93,10 @@ export interface AccessTokenConfig { * (client_credentials grant). */ clientConfig?: AccessTokenSubConfig; + + /** + * Audience (aud claim) for access tokens that are not bound to a resource server, i.e. + * OIDC-only or scopeless requests. Falls back to the client_id when empty. + */ + defaultAudience?: string; } diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index c3a16fe9c7..6757d4768c 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -2650,6 +2650,12 @@ const translations = { 'edit.token.scopes.add_custom.error.duplicate': 'This scope is already added', 'edit.token.scopes.add_custom.error.invalid': 'Scope name must not contain spaces', 'edit.token.scopes.openid_required': 'The openid scope is required and cannot be removed', + 'edit.advanced.audience.title': 'Default Audience', + 'edit.advanced.audience.description': + "The default aud for access tokens that don't target a resource server (OIDC only or scopeless).", + 'edit.advanced.audience.label': 'Default audience (aud)', + 'edit.advanced.audience.placeholder': 'e.g. https://api.example.com', + 'edit.advanced.audience.hint': 'Leave empty to use the {{entity}} client ID.', 'edit.token.scope_mapper.title': 'User Attribute Mapping', 'edit.token.scope_mapper.hint': 'Select a scope to configure which user attributes are exposed when it is requested.', diff --git a/tests/integration/oauth/token/default_resource_server_test.go b/tests/integration/oauth/token/default_resource_server_test.go index 8363c8f939..c772fb2bda 100644 --- a/tests/integration/oauth/token/default_resource_server_test.go +++ b/tests/integration/oauth/token/default_resource_server_test.go @@ -36,6 +36,10 @@ const ( defaultRSTestClientID = "default_rs_test_client" defaultRSTestClientSecret = "default_rs_test_secret" defaultRSTestIdentifier = "https://default-rs-token.example.com" + + defaultAudTestClientID = "default_aud_test_client" + defaultAudTestClientSecret = "default_aud_test_secret" + defaultAudTestAudience = "https://custom-audience.example.com" ) type DefaultResourceServerTestSuite struct { @@ -184,3 +188,109 @@ func (s *DefaultResourceServerTestSuite) TestNoResourceWithoutDefaultAndPermissi s.Equal(http.StatusBadRequest, status) s.Equal("invalid_target", body["error"]) } + +// createOAuthAppWithDefaultAudience creates a client_credentials app whose access token config +// carries a configured default audience (token.accessToken.defaultAudience). +func (s *DefaultResourceServerTestSuite) createOAuthAppWithDefaultAudience() string { + app := map[string]interface{}{ + "name": "Default Audience Token App", + "description": "Application for default audience token tests", + "ouId": s.ouID, + "isRegistrationFlowEnabled": false, + "inboundAuthConfig": []map[string]interface{}{ + { + "type": "oauth2", + "config": map[string]interface{}{ + "clientId": defaultAudTestClientID, + "clientSecret": defaultAudTestClientSecret, + "grantTypes": []string{"client_credentials"}, + "tokenEndpointAuthMethod": "client_secret_basic", + "token": map[string]interface{}{ + "accessToken": map[string]interface{}{ + "defaultAudience": defaultAudTestAudience, + }, + }, + }, + }, + }, + } + + payload, err := json.Marshal(app) + s.Require().NoError(err) + + req, err := http.NewRequest(http.MethodPost, testServerURL+"/applications", bytes.NewReader(payload)) + s.Require().NoError(err) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + s.Require().NoError(err) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + s.Require().Equal(http.StatusCreated, resp.StatusCode, string(body)) + + var respBody map[string]interface{} + s.Require().NoError(json.Unmarshal(body, &respBody)) + return respBody["id"].(string) +} + +func (s *DefaultResourceServerTestSuite) requestClientCredentialsAs( + clientID, clientSecret, scope, resource string, +) (int, map[string]interface{}) { + form := url.Values{} + form.Set("grant_type", "client_credentials") + if scope != "" { + form.Set("scope", scope) + } + if resource != "" { + form.Set("resource", resource) + } + + req, err := http.NewRequest(http.MethodPost, testServerURL+"/oauth2/token", strings.NewReader(form.Encode())) + s.Require().NoError(err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := s.client.Do(req) + s.Require().NoError(err) + defer resp.Body.Close() + + var respBody map[string]interface{} + s.Require().NoError(json.NewDecoder(resp.Body).Decode(&respBody)) + return resp.StatusCode, respBody +} + +// A scopeless request that is not bound to a resource server uses the app's configured default +// audience for the aud claim instead of the client_id. +func (s *DefaultResourceServerTestSuite) TestScopelessWithConfiguredDefaultAudienceUsesIt() { + appID := s.createOAuthAppWithDefaultAudience() + defer func() { _ = testutils.DeleteApplication(appID) }() + s.Require().NoError(testutils.PutDefaultResourceServer("")) + + status, body := s.requestClientCredentialsAs(defaultAudTestClientID, defaultAudTestClientSecret, "", "") + s.Equal(http.StatusOK, status) + + token, ok := body["access_token"].(string) + s.Require().True(ok, "response should contain an access token") + + claims, err := testutils.DecodeJWT(token) + s.Require().NoError(err) + s.Equal(defaultAudTestAudience, claims.Aud) +} + +// An explicit resource parameter binds the token to that resource server, taking precedence over +// the configured default audience. +func (s *DefaultResourceServerTestSuite) TestExplicitResourceOverridesConfiguredDefaultAudience() { + appID := s.createOAuthAppWithDefaultAudience() + defer func() { _ = testutils.DeleteApplication(appID) }() + + status, body := s.requestClientCredentialsAs(defaultAudTestClientID, defaultAudTestClientSecret, "", defaultRSTestIdentifier) + s.Equal(http.StatusOK, status) + + token, ok := body["access_token"].(string) + s.Require().True(ok, "response should contain an access token") + + claims, err := testutils.DecodeJWT(token) + s.Require().NoError(err) + s.Equal(defaultRSTestIdentifier, claims.Aud) +}