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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions api/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/inboundclient/error_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions backend/internal/inboundclient/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions backend/internal/inboundclient/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package inboundclient
import (
"context"
"errors"
"strings"
"testing"

"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions backend/internal/oauth/oauth2/granthandlers/ciba.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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]
Expand Down
24 changes: 24 additions & 0 deletions backend/internal/oauth/oauth2/granthandlers/ciba_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (

"context"
"errors"
"slices"
"testing"
"time"

Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions backend/internal/oauth/oauth2/granthandlers/jwt_bearer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions backend/pkg/thunderidengine/providers/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions backend/pkg/thunderidengine/providers/oauth_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions backend/pkg/thunderidengine/providers/oauth_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}})
Expand Down
2 changes: 1 addition & 1 deletion docs/content/guides/getting-started/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|-------------|
Expand Down
4 changes: 2 additions & 2 deletions docs/content/guides/guides/agents/agent-authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` | <ProductName /> instance URL. | <ProductName /> 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` |
Expand Down
Loading
Loading