diff --git a/docs/token-exchange-rfc8693.md b/docs/token-exchange-rfc8693.md new file mode 100644 index 00000000000..fb1cc834c77 --- /dev/null +++ b/docs/token-exchange-rfc8693.md @@ -0,0 +1,78 @@ +# OAuth 2.0 Token Exchange (RFC 8693) + +Hydra supports [RFC 8693 OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693.html), which allows a client to exchange a security token (subject token) for a new access token, optionally for a different audience or with delegation (actor token). + +## When to use + +- A resource server receives an access token and needs a new token to call a backend service (e.g. with a different audience or scopes). +- Impersonation or delegation scenarios where an actor token is provided along with the subject token. + +## Request + +`POST /oauth2/token` with `application/x-www-form-urlencoded`: + +| Parameter | Required | Description | +|-----------|----------|-------------| +| grant_type | Yes | `urn:ietf:params:oauth:grant-type:token-exchange` | +| subject_token | Yes | The token to exchange (e.g. an access token or JWT). | +| subject_token_type | Yes | Type of subject_token, e.g. `urn:ietf:params:oauth:token-type:access_token` or `urn:ietf:params:oauth:token-type:jwt`. | +| resource | No | URI of the target service where the new token will be used. | +| audience | No | Logical name of the target service (space-delimited or repeated). | +| scope | No | Requested scope for the issued token. | +| requested_token_type | No | Desired type of the issued token. | +| actor_token | No | For delegation: token representing the acting party. | +| actor_token_type | No | Required when actor_token is present. | + +Client authentication (e.g. HTTP Basic with client_id and client_secret) is required unless configured otherwise. The client must have `urn:ietf:params:oauth:grant-type:token-exchange` in its `grant_types`. + +## Response + +On success (200 OK), the response includes: + +| Field | Description | +|-------|-------------| +| access_token | The issued access token. | +| issued_token_type | **Required** by RFC 8693; e.g. `urn:ietf:params:oauth:token-type:access_token`. | +| token_type | Usually `Bearer`. | +| expires_in | Lifetime of the access token in seconds. | +| scope | Optional; scope of the issued token. | + +## Supported subject token types + +- **urn:ietf:params:oauth:token-type:access_token**: Opaque or JWT access token issued by this Hydra. Validated via introspection (storage lookup). +- **urn:ietf:params:oauth:token-type:jwt**: JWT; when the JWT is an access token issued by this server, it is validated the same way as access_token type. + +## Example + +```http +POST /oauth2/token HTTP/1.1 +Host: hydra.example.com +Content-Type: application/x-www-form-urlencoded +Authorization: Basic + +grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange +&subject_token= +&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token +&audience=https%3A%2F%2Fbackend.example.com%2Fapi +``` + +Successful response: + +```json +{ + "access_token": "eyJ...", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +## Configuration + +- **Grant type**: Add `urn:ietf:params:oauth:grant-type:token-exchange` to the client's `grant_types` (e.g. via Admin API or OIDC Dynamic Client Registration). +- **Skip client auth** (not recommended): Set `GrantTypeTokenExchangeCanSkipClientAuth: true` in Fosite config to allow unauthenticated token exchange requests. +- **OIDC Discovery**: `/.well-known/openid-configuration` includes `grant_types_supported` with token exchange when supported. + +## References + +- [RFC 8693 - OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693.html) diff --git a/fosite/client_with_custom_token_lifespans.go b/fosite/client_with_custom_token_lifespans.go index b46e2ba5457..91c8728ae28 100644 --- a/fosite/client_with_custom_token_lifespans.go +++ b/fosite/client_with_custom_token_lifespans.go @@ -32,6 +32,7 @@ type ClientLifespanConfig struct { ImplicitGrantAccessTokenLifespan *time.Duration `json:"implicit_grant_access_token_lifespan"` ImplicitGrantIDTokenLifespan *time.Duration `json:"implicit_grant_id_token_lifespan"` JwtBearerGrantAccessTokenLifespan *time.Duration `json:"jwt_bearer_grant_access_token_lifespan"` + TokenExchangeGrantAccessTokenLifespan *time.Duration `json:"token_exchange_grant_access_token_lifespan"` // RFC 8693 PasswordGrantAccessTokenLifespan *time.Duration `json:"password_grant_access_token_lifespan"` PasswordGrantRefreshTokenLifespan *time.Duration `json:"password_grant_refresh_token_lifespan"` RefreshTokenGrantIDTokenLifespan *time.Duration `json:"refresh_token_grant_id_token_lifespan"` @@ -81,6 +82,10 @@ func (c *DefaultClientWithCustomTokenLifespans) GetEffectiveLifespan(gt GrantTyp if tt == AccessToken { cl = c.TokenLifespans.JwtBearerGrantAccessTokenLifespan } + } else if gt == GrantTypeTokenExchange { + if tt == AccessToken { + cl = c.TokenLifespans.TokenExchangeGrantAccessTokenLifespan + } } else if gt == GrantTypePassword { if tt == AccessToken { cl = c.TokenLifespans.PasswordGrantAccessTokenLifespan diff --git a/fosite/compose/compose_rfc8693.go b/fosite/compose/compose_rfc8693.go new file mode 100644 index 00000000000..12b50d2512f --- /dev/null +++ b/fosite/compose/compose_rfc8693.go @@ -0,0 +1,19 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package compose + +import ( + "github.com/ory/hydra/v2/fosite" + "github.com/ory/hydra/v2/fosite/handler/oauth2" + "github.com/ory/hydra/v2/fosite/handler/rfc8693" +) + +// RFC8693TokenExchangeFactory creates an OAuth2 Token Exchange (RFC 8693) handler. +func RFC8693TokenExchangeFactory(config fosite.Configurator, storage fosite.Storage, strategy interface{}) interface{} { + return &rfc8693.Handler{ + Strategy: strategy.(oauth2.AccessTokenStrategyProvider), + Storage: storage.(oauth2.AccessTokenStorageProvider), + Config: config, + } +} diff --git a/fosite/config.go b/fosite/config.go index 0b7e5cd2d76..151bc4dd3a3 100644 --- a/fosite/config.go +++ b/fosite/config.go @@ -173,6 +173,12 @@ type GrantTypeJWTBearerCanSkipClientAuthProvider interface { GetGrantTypeJWTBearerCanSkipClientAuth(ctx context.Context) bool } +// GrantTypeTokenExchangeCanSkipClientAuthProvider returns the provider for configuring whether token exchange (RFC 8693) can skip client authentication. +type GrantTypeTokenExchangeCanSkipClientAuthProvider interface { + // GetGrantTypeTokenExchangeCanSkipClientAuth returns whether client authentication can be skipped for token exchange. + GetGrantTypeTokenExchangeCanSkipClientAuth(ctx context.Context) bool +} + // GrantTypeJWTBearerIDOptionalProvider returns the provider for configuring the grant type JWT bearer ID optional. type GrantTypeJWTBearerIDOptionalProvider interface { // GetGrantTypeJWTBearerIDOptional returns the grant type JWT bearer ID optional. diff --git a/fosite/config_default.go b/fosite/config_default.go index 52011112792..b4360d0b765 100644 --- a/fosite/config_default.go +++ b/fosite/config_default.go @@ -44,6 +44,7 @@ var ( _ EnablePKCEPlainChallengeMethodProvider = (*Config)(nil) _ EnforcePKCEProvider = (*Config)(nil) _ GrantTypeJWTBearerCanSkipClientAuthProvider = (*Config)(nil) + _ GrantTypeTokenExchangeCanSkipClientAuthProvider = (*Config)(nil) _ GrantTypeJWTBearerIDOptionalProvider = (*Config)(nil) _ GrantTypeJWTBearerIssuedDateOptionalProvider = (*Config)(nil) _ GetJWTMaxDurationProvider = (*Config)(nil) @@ -154,6 +155,9 @@ type Config struct { // GrantTypeJWTBearerCanSkipClientAuth indicates, if client authentication can be skipped, when using jwt as assertion. GrantTypeJWTBearerCanSkipClientAuth bool + // GrantTypeTokenExchangeCanSkipClientAuth indicates, if client authentication can be skipped for token exchange (RFC 8693). Default false. + GrantTypeTokenExchangeCanSkipClientAuth bool + // GrantTypeJWTBearerIDOptional indicates, if jti (JWT ID) claim required or not in JWT. GrantTypeJWTBearerIDOptional bool @@ -328,6 +332,11 @@ func (c *Config) GetGrantTypeJWTBearerCanSkipClientAuth(ctx context.Context) boo return c.GrantTypeJWTBearerCanSkipClientAuth } +// GetGrantTypeTokenExchangeCanSkipClientAuth returns the GrantTypeTokenExchangeCanSkipClientAuth field. +func (c *Config) GetGrantTypeTokenExchangeCanSkipClientAuth(ctx context.Context) bool { + return c.GrantTypeTokenExchangeCanSkipClientAuth +} + // GetEnforcePKCE If set to true, public clients must use PKCE. func (c *Config) GetEnforcePKCE(ctx context.Context) bool { return c.EnforcePKCE diff --git a/fosite/errors.go b/fosite/errors.go index 3ad2a47b571..89475ed328e 100644 --- a/fosite/errors.go +++ b/fosite/errors.go @@ -210,6 +210,12 @@ var ( ErrorField: errJTIKnownName, CodeField: http.StatusBadRequest, } + // ErrInvalidTarget is used when the authorization server is unwilling or unable to issue a token for the requested resource/audience (RFC 8693 section 2.2.2). + ErrInvalidTarget = &RFC6749Error{ + ErrorField: errInvalidTargetName, + DescriptionField: "The authorization server is unwilling or unable to issue a token for the requested resource or audience.", + CodeField: http.StatusBadRequest, + } ErrAuthorizationPending = &RFC6749Error{ DescriptionField: "The authorization request is still pending as the end user hasn't yet completed the user-interaction steps.", ErrorField: errAuthorizationPending, @@ -266,6 +272,7 @@ const ( errAuthorizationPending = "authorization_pending" errSlowDown = "slow_down" errDeviceExpiredToken = "expired_token" + errInvalidTargetName = "invalid_target" // RFC 8693 ) type ( diff --git a/fosite/fosite.go b/fosite/fosite.go index b7ab0547c6c..a21a3a0c336 100644 --- a/fosite/fosite.go +++ b/fosite/fosite.go @@ -106,6 +106,7 @@ type Configurator interface { EnforcePKCEForPublicClientsProvider EnablePKCEPlainChallengeMethodProvider GrantTypeJWTBearerCanSkipClientAuthProvider + GrantTypeTokenExchangeCanSkipClientAuthProvider GrantTypeJWTBearerIDOptionalProvider GrantTypeJWTBearerIssuedDateOptionalProvider GetJWTMaxDurationProvider diff --git a/fosite/handler/rfc8693/handler.go b/fosite/handler/rfc8693/handler.go new file mode 100644 index 00000000000..b61a970ba86 --- /dev/null +++ b/fosite/handler/rfc8693/handler.go @@ -0,0 +1,162 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693 + +import ( + "context" + "time" + + "github.com/ory/hydra/v2/fosite" + "github.com/ory/hydra/v2/fosite/handler/oauth2" + "github.com/ory/x/errorsx" +) + +// RFC 8693 token type identifiers (section 3). +const ( + TokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" + TokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" +) + +var _ fosite.TokenEndpointHandler = (*Handler)(nil) + +type Handler struct { + Storage oauth2.AccessTokenStorageProvider + Strategy oauth2.AccessTokenStrategyProvider + Config interface { + fosite.AccessTokenLifespanProvider + fosite.ScopeStrategyProvider + fosite.AudienceStrategyProvider + fosite.GrantTypeTokenExchangeCanSkipClientAuthProvider + } +} + +// CanHandleTokenEndpointRequest returns true when grant_type is token-exchange (RFC 8693). +func (c *Handler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + return requester.GetGrantTypes().ExactOne(string(fosite.GrantTypeTokenExchange)) +} + +// CanSkipClientAuth returns whether client authentication can be skipped for token exchange. +func (c *Handler) CanSkipClientAuth(ctx context.Context, requester fosite.AccessRequester) bool { + return c.Config.GetGrantTypeTokenExchangeCanSkipClientAuth(ctx) +} + +// CheckRequest validates that the request is a token exchange and the client is allowed. +func (c *Handler) CheckRequest(ctx context.Context, request fosite.AccessRequester) error { + if !c.CanHandleTokenEndpointRequest(ctx, request) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + if !c.CanSkipClientAuth(ctx, request) && request.GetClient() != nil && !request.GetClient().GetGrantTypes().Has(string(fosite.GrantTypeTokenExchange)) { + return errorsx.WithStack(fosite.ErrUnauthorizedClient.WithHintf("The OAuth 2.0 Client is not allowed to use authorization grant \"%s\".", fosite.GrantTypeTokenExchange)) + } + return nil +} + +// HandleTokenEndpointRequest implements RFC 8693 token exchange request handling. +func (c *Handler) HandleTokenEndpointRequest(ctx context.Context, request fosite.AccessRequester) error { + if err := c.CheckRequest(ctx, request); err != nil { + return err + } + + form := request.GetRequestForm() + subjectToken := form.Get("subject_token") + subjectTokenType := form.Get("subject_token_type") + + if subjectToken == "" { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("The subject_token request parameter must be set when using grant_type token-exchange.")) + } + if subjectTokenType == "" { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("The subject_token_type request parameter must be set when using grant_type token-exchange.")) + } + + // Support access_token and jwt types; for our server both resolve via access token introspection. + if subjectTokenType != TokenTypeAccessToken && subjectTokenType != TokenTypeJWT { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf("Unsupported subject_token_type: %s.", subjectTokenType)) + } + + // Resolve subject token via access token storage (introspection path). + sig := c.Strategy.AccessTokenStrategy().AccessTokenSignature(ctx, subjectToken) + subjectRequester, err := c.Storage.AccessTokenStorage().GetAccessTokenSession(ctx, sig, request.GetSession()) + if err != nil { + return errorsx.WithStack(fosite.ErrInvalidGrant.WithHint("The subject_token is invalid, expired, or revoked.").WithWrap(err).WithDebug(err.Error())) + } + if err := c.Strategy.AccessTokenStrategy().ValidateAccessToken(ctx, subjectRequester, subjectToken); err != nil { + return err + } + + // Copy subject and granted rights from subject token; restrict to requested scope/audience. + request.GetSession().SetExpiresAt(fosite.AccessToken, subjectRequester.GetSession().GetExpiresAt(fosite.AccessToken)) + if sub := subjectRequester.GetSession().GetSubject(); sub != "" { + if s, ok := request.GetSession().(interface{ SetSubject(string) }); ok { + s.SetSubject(sub) + } + } + + requestedScopes := request.GetRequestedScopes() + requestedAudience := request.GetRequestedAudience() + subjectGrantedScopes := subjectRequester.GetGrantedScopes() + subjectGrantedAudience := subjectRequester.GetGrantedAudience() + + if len(requestedScopes) == 0 { + for _, s := range subjectGrantedScopes { + request.GrantScope(s) + } + } else { + for _, scope := range requestedScopes { + if c.Config.GetScopeStrategy(ctx)(subjectGrantedScopes, scope) { + request.GrantScope(scope) + } + } + } + + if len(requestedAudience) == 0 { + for _, a := range subjectGrantedAudience { + request.GrantAudience(a) + } + } else { + for _, aud := range requestedAudience { + if fosite.DefaultAudienceMatchingStrategy(subjectGrantedAudience, []string{aud}) == nil { + request.GrantAudience(aud) + } + } + } + + atLifespan := fosite.GetEffectiveLifespan(request.GetClient(), fosite.GrantTypeTokenExchange, fosite.AccessToken, c.Config.GetAccessTokenLifespan(ctx)) + request.GetSession().SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(atLifespan).Round(time.Second)) + + return nil +} + +// PopulateTokenEndpointResponse issues the access token and sets issued_token_type (RFC 8693). +func (c *Handler) PopulateTokenEndpointResponse(ctx context.Context, request fosite.AccessRequester, response fosite.AccessResponder) error { + if err := c.CheckRequest(ctx, request); err != nil { + return err + } + + atLifespan := fosite.GetEffectiveLifespan(request.GetClient(), fosite.GrantTypeTokenExchange, fosite.AccessToken, c.Config.GetAccessTokenLifespan(ctx)) + _, err := c.issueAccessToken(ctx, atLifespan, request, response) + return err +} + +func (c *Handler) issueAccessToken(ctx context.Context, atLifespan time.Duration, requester fosite.AccessRequester, responder fosite.AccessResponder) (signature string, err error) { + token, signature, err := c.Strategy.AccessTokenStrategy().GenerateAccessToken(ctx, requester) + if err != nil { + return "", err + } + if err := c.Storage.AccessTokenStorage().CreateAccessTokenSession(ctx, signature, requester.Sanitize([]string{})); err != nil { + return "", err + } + + if !requester.GetSession().GetExpiresAt(fosite.AccessToken).IsZero() { + atLifespan = time.Duration(requester.GetSession().GetExpiresAt(fosite.AccessToken).UnixNano() - time.Now().UTC().UnixNano()) + } + + responder.SetAccessToken(token) + responder.SetTokenType("bearer") + responder.SetExpiresIn(atLifespan) + responder.SetScopes(requester.GetGrantedScopes()) + // RFC 8693 section 2.2.1: issued_token_type is REQUIRED in the response. + responder.SetExtra("issued_token_type", TokenTypeAccessToken) + + return signature, nil +} diff --git a/fosite/handler/rfc8693/handler_test.go b/fosite/handler/rfc8693/handler_test.go new file mode 100644 index 00000000000..3b54cd531c9 --- /dev/null +++ b/fosite/handler/rfc8693/handler_test.go @@ -0,0 +1,168 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package rfc8693_test + +import ( + "context" + "errors" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/ory/hydra/v2/fosite" + "github.com/ory/hydra/v2/fosite/handler/rfc8693" + "github.com/ory/hydra/v2/fosite/internal" +) + +func TestHandler_CanHandleTokenEndpointRequest(t *testing.T) { + h := &rfc8693.Handler{} + ctx := context.Background() + + req := fosite.NewAccessRequest(&fosite.DefaultSession{}) + req.GrantTypes = fosite.Arguments{string(fosite.GrantTypeTokenExchange)} + assert.True(t, h.CanHandleTokenEndpointRequest(ctx, req)) + + req.GrantTypes = fosite.Arguments{"authorization_code"} + assert.False(t, h.CanHandleTokenEndpointRequest(ctx, req)) +} + +func TestHandler_HandleTokenEndpointRequest_MissingSubjectToken(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + storage := internal.NewMockAccessTokenStorageProvider(ctrl) + strategy := internal.NewMockAccessTokenStrategyProvider(ctrl) + cfg := &fosite.Config{ + ScopeStrategy: fosite.HierarchicScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + AccessTokenLifespan: time.Hour, + GrantTypeTokenExchangeCanSkipClientAuth: false, + } + handler := &rfc8693.Handler{Storage: storage, Strategy: strategy, Config: cfg} + + req := fosite.NewAccessRequest(&fosite.DefaultSession{}) + req.GrantTypes = fosite.Arguments{string(fosite.GrantTypeTokenExchange)} + req.Client = &fosite.DefaultClient{GrantTypes: []string{string(fosite.GrantTypeTokenExchange)}} + req.Form = url.Values{} + req.Form.Set("subject_token_type", rfc8693.TokenTypeAccessToken) + // subject_token missing + + err := handler.HandleTokenEndpointRequest(context.Background(), req) + require.True(t, errors.Is(err, fosite.ErrInvalidRequest)) + assert.Contains(t, fosite.ErrorToRFC6749Error(err).HintField, "subject_token") +} + +func TestHandler_HandleTokenEndpointRequest_MissingSubjectTokenType(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + storage := internal.NewMockAccessTokenStorageProvider(ctrl) + strategy := internal.NewMockAccessTokenStrategyProvider(ctrl) + cfg := &fosite.Config{ + ScopeStrategy: fosite.HierarchicScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + AccessTokenLifespan: time.Hour, + GrantTypeTokenExchangeCanSkipClientAuth: false, + } + handler := &rfc8693.Handler{Storage: storage, Strategy: strategy, Config: cfg} + + req := fosite.NewAccessRequest(&fosite.DefaultSession{}) + req.GrantTypes = fosite.Arguments{string(fosite.GrantTypeTokenExchange)} + req.Client = &fosite.DefaultClient{GrantTypes: []string{string(fosite.GrantTypeTokenExchange)}} + req.Form = url.Values{} + req.Form.Set("subject_token", "some-token") + // subject_token_type missing + + err := handler.HandleTokenEndpointRequest(context.Background(), req) + require.True(t, errors.Is(err, fosite.ErrInvalidRequest)) + assert.Contains(t, fosite.ErrorToRFC6749Error(err).HintField, "subject_token_type") +} + +func TestHandler_HandleTokenEndpointRequest_UnsupportedSubjectTokenType(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + storage := internal.NewMockAccessTokenStorageProvider(ctrl) + strategy := internal.NewMockAccessTokenStrategyProvider(ctrl) + cfg := &fosite.Config{ + ScopeStrategy: fosite.HierarchicScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + AccessTokenLifespan: time.Hour, + GrantTypeTokenExchangeCanSkipClientAuth: false, + } + handler := &rfc8693.Handler{Storage: storage, Strategy: strategy, Config: cfg} + + req := fosite.NewAccessRequest(&fosite.DefaultSession{}) + req.GrantTypes = fosite.Arguments{string(fosite.GrantTypeTokenExchange)} + req.Client = &fosite.DefaultClient{GrantTypes: []string{string(fosite.GrantTypeTokenExchange)}} + req.Form = url.Values{} + req.Form.Set("subject_token", "x") + req.Form.Set("subject_token_type", "urn:ietf:params:oauth:token-type:saml2") + + err := handler.HandleTokenEndpointRequest(context.Background(), req) + require.True(t, errors.Is(err, fosite.ErrInvalidRequest)) + assert.Contains(t, fosite.ErrorToRFC6749Error(err).HintField, "Unsupported subject_token_type") +} + +func TestHandler_CheckRequest_ClientNotAllowedGrantType(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + storage := internal.NewMockAccessTokenStorageProvider(ctrl) + strategy := internal.NewMockAccessTokenStrategyProvider(ctrl) + cfg := &fosite.Config{ + ScopeStrategy: fosite.HierarchicScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + AccessTokenLifespan: time.Hour, + GrantTypeTokenExchangeCanSkipClientAuth: false, + } + handler := &rfc8693.Handler{Storage: storage, Strategy: strategy, Config: cfg} + + req := fosite.NewAccessRequest(&fosite.DefaultSession{}) + req.GrantTypes = fosite.Arguments{string(fosite.GrantTypeTokenExchange)} + req.Client = &fosite.DefaultClient{GrantTypes: []string{"authorization_code"}} // no token-exchange + + err := handler.CheckRequest(context.Background(), req) + require.True(t, errors.Is(err, fosite.ErrUnauthorizedClient)) +} + +func TestHandler_PopulateTokenEndpointResponse_SetsIssuedTokenType(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockATStorage := internal.NewMockAccessTokenStorage(ctrl) + mockATStorageProvider := internal.NewMockAccessTokenStorageProvider(ctrl) + mockATStorageProvider.EXPECT().AccessTokenStorage().Return(mockATStorage).MinTimes(1) + + mockStrategy := internal.NewMockAccessTokenStrategy(ctrl) + mockStrategyProvider := internal.NewMockAccessTokenStrategyProvider(ctrl) + mockStrategyProvider.EXPECT().AccessTokenStrategy().Return(mockStrategy).MinTimes(1) + + cfg := &fosite.Config{ + ScopeStrategy: fosite.HierarchicScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + AccessTokenLifespan: time.Hour, + GrantTypeTokenExchangeCanSkipClientAuth: false, + } + handler := &rfc8693.Handler{Storage: mockATStorageProvider, Strategy: mockStrategyProvider, Config: cfg} + + req := fosite.NewAccessRequest(&fosite.DefaultSession{}) + req.GrantTypes = fosite.Arguments{string(fosite.GrantTypeTokenExchange)} + req.Client = &fosite.DefaultClient{GrantTypes: []string{string(fosite.GrantTypeTokenExchange)}} + req.GrantScope("openid") + resp := fosite.NewAccessResponse() + + mockStrategy.EXPECT().GenerateAccessToken(gomock.Any(), gomock.Any()).Return("new-token", "sig", nil) + mockATStorage.EXPECT().CreateAccessTokenSession(gomock.Any(), "sig", gomock.Any()).Return(nil) + + err := handler.PopulateTokenEndpointResponse(context.Background(), req, resp) + require.NoError(t, err) + assert.Equal(t, "new-token", resp.GetAccessToken()) + assert.Equal(t, "bearer", resp.GetTokenType()) + assert.Equal(t, rfc8693.TokenTypeAccessToken, resp.GetExtra("issued_token_type")) +} diff --git a/fosite/oauth2.go b/fosite/oauth2.go index ee920a98ab8..fd362fc0e56 100644 --- a/fosite/oauth2.go +++ b/fosite/oauth2.go @@ -35,6 +35,7 @@ const ( GrantTypeClientCredentials GrantType = "client_credentials" GrantTypeJWTBearer GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" //nolint:gosec // this is not a hardcoded credential GrantTypeDeviceCode GrantType = "urn:ietf:params:oauth:grant-type:device_code" //nolint:gosec // this is not a hardcoded credential + GrantTypeTokenExchange GrantType = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // RFC 8693 BearerAccessToken string = "bearer" ) diff --git a/fosite/request.go b/fosite/request.go index 42c92abb125..52f4d22c479 100644 --- a/fosite/request.go +++ b/fosite/request.go @@ -159,7 +159,8 @@ func (a *Request) Merge(request Requester) { } } -var defaultAllowedParameters = []string{"grant_type", "response_type", "scope", "client_id"} +// defaultAllowedParameters lists form parameter names allowed in Sanitize. Do not add subject_token, actor_token (raw tokens must not be persisted). +var defaultAllowedParameters = []string{"grant_type", "response_type", "scope", "client_id", "subject_token_type", "resource", "requested_token_type", "actor_token_type"} func (a *Request) Sanitize(allowedParameters []string) Requester { b := new(Request) diff --git a/fositex/config.go b/fositex/config.go index ba4d1f8e7c6..8f38b448051 100644 --- a/fositex/config.go +++ b/fositex/config.go @@ -67,6 +67,7 @@ var ( compose.OAuth2TokenIntrospectionFactory, compose.OAuth2PKCEFactory, compose.RFC7523AssertionGrantFactory, + compose.RFC8693TokenExchangeFactory, compose.OIDCUserinfoVerifiableCredentialFactory, compose.RFC8628DeviceFactory, compose.RFC8628DeviceAuthorizationTokenFactory, @@ -145,6 +146,10 @@ func (c *Config) GetGrantTypeJWTBearerCanSkipClientAuth(context.Context) bool { return false } +func (c *Config) GetGrantTypeTokenExchangeCanSkipClientAuth(context.Context) bool { + return false +} + func (c *Config) GetAudienceStrategy(context.Context) fosite.AudienceMatchingStrategy { return fosite.DefaultAudienceMatchingStrategy } diff --git a/oauth2/handler.go b/oauth2/handler.go index 93de3d25be0..a5b39f9b50b 100644 --- a/oauth2/handler.go +++ b/oauth2/handler.go @@ -526,7 +526,7 @@ func (h *Handler) discoverOidcConfiguration(w http.ResponseWriter, r *http.Reque IDTokenSigningAlgValuesSupported: []string{key.Algorithm}, IDTokenSignedResponseAlg: []string{key.Algorithm}, UserinfoSignedResponseAlg: []string{key.Algorithm}, - GrantTypesSupported: []string{"authorization_code", "implicit", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"}, + GrantTypesSupported: []string{"authorization_code", "implicit", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange"}, ResponseModesSupported: []string{"query", "fragment", "form_post"}, UserinfoSigningAlgValuesSupported: []string{"none", key.Algorithm}, RequestParameterSupported: true, @@ -1075,6 +1075,7 @@ func (h *Handler) introspectOAuth2Token(w http.ResponseWriter, r *http.Request) type _ struct { // in: formData // required: true + // Use grant_type=urn:ietf:params:oauth:grant-type:token-exchange for RFC 8693 token exchange (subject_token, subject_token_type required). GrantType string `json:"grant_type"` // in: formData @@ -1088,6 +1089,24 @@ type _ struct { // in: formData ClientID string `json:"client_id"` + + // in: formData (RFC 8693 token exchange) + SubjectToken string `json:"subject_token"` + + // in: formData (RFC 8693 token exchange). e.g. urn:ietf:params:oauth:token-type:access_token or urn:ietf:params:oauth:token-type:jwt + SubjectTokenType string `json:"subject_token_type"` + + // in: formData (RFC 8693, optional) + Resource string `json:"resource"` + + // in: formData (RFC 8693, optional) + RequestedTokenType string `json:"requested_token_type"` + + // in: formData (RFC 8693, optional, for delegation) + ActorToken string `json:"actor_token"` + + // in: formData (RFC 8693, required when actor_token is present) + ActorTokenType string `json:"actor_token_type"` } // OAuth2 Token Exchange Result @@ -1114,6 +1133,9 @@ type _ struct { // The type of the token issued TokenType string `json:"token_type"` + + // Issued token type (RFC 8693). Present when grant_type is urn:ietf:params:oauth:grant-type:token-exchange. + IssuedTokenType string `json:"issued_token_type,omitempty"` } // swagger:route POST /oauth2/token oAuth2 oauth2TokenExchange @@ -1161,7 +1183,8 @@ func (h *Handler) oauth2TokenExchange(w http.ResponseWriter, r *http.Request) { if accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeClientCredentials)) || accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeJWTBearer)) || - accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypePassword)) { + accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypePassword)) || + accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeTokenExchange)) { var accessTokenKeyID string if h.c.AccessTokenStrategy(ctx, client.AccessTokenStrategySource(accessRequest.GetClient())) == "jwt" { accessTokenKeyID, err = h.r.AccessTokenJWTSigner().GetPublicKeyID(ctx) @@ -1189,29 +1212,32 @@ func (h *Handler) oauth2TokenExchange(w http.ResponseWriter, r *http.Request) { accessRequest.GrantAudience(aud) } } + // For token exchange (RFC 8693), subject and scopes/audience are already set by the handler from the subject token; do not overwrite. session.ClientID = accessRequest.GetClient().GetID() session.KID = accessTokenKeyID session.DefaultSession.Claims.Issuer = h.c.IssuerURL(ctx).String() session.DefaultSession.Claims.IssuedAt = time.Now().UTC() - scopes := accessRequest.GetRequestedScopes() + if !accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeTokenExchange)) { + scopes := accessRequest.GetRequestedScopes() - // Added for compatibility with MITREid - if h.c.GrantAllClientCredentialsScopesPerDefault(ctx) && len(scopes) == 0 { - for _, scope := range accessRequest.GetClient().GetScopes() { - accessRequest.GrantScope(scope) + // Added for compatibility with MITREid + if h.c.GrantAllClientCredentialsScopesPerDefault(ctx) && len(scopes) == 0 { + for _, scope := range accessRequest.GetClient().GetScopes() { + accessRequest.GrantScope(scope) + } } - } - for _, scope := range scopes { - if h.r.Config().GetScopeStrategy(ctx)(accessRequest.GetClient().GetScopes(), scope) { - accessRequest.GrantScope(scope) + for _, scope := range scopes { + if h.r.Config().GetScopeStrategy(ctx)(accessRequest.GetClient().GetScopes(), scope) { + accessRequest.GrantScope(scope) + } } - } - for _, audience := range accessRequest.GetRequestedAudience() { - if fosite.DefaultAudienceMatchingStrategy(accessRequest.GetClient().GetAudience(), []string{audience}) == nil { - accessRequest.GrantAudience(audience) + for _, audience := range accessRequest.GetRequestedAudience() { + if fosite.DefaultAudienceMatchingStrategy(accessRequest.GetClient().GetAudience(), []string{audience}) == nil { + accessRequest.GrantAudience(audience) + } } } }