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)
+}