Skip to content
Closed
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
78 changes: 78 additions & 0 deletions docs/token-exchange-rfc8693.md
Original file line number Diff line number Diff line change
@@ -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 <client_credentials>

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=<access_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)
5 changes: 5 additions & 0 deletions fosite/client_with_custom_token_lifespans.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions fosite/compose/compose_rfc8693.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
6 changes: 6 additions & 0 deletions fosite/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions fosite/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions fosite/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -266,6 +272,7 @@ const (
errAuthorizationPending = "authorization_pending"
errSlowDown = "slow_down"
errDeviceExpiredToken = "expired_token"
errInvalidTargetName = "invalid_target" // RFC 8693
)

type (
Expand Down
1 change: 1 addition & 0 deletions fosite/fosite.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ type Configurator interface {
EnforcePKCEForPublicClientsProvider
EnablePKCEPlainChallengeMethodProvider
GrantTypeJWTBearerCanSkipClientAuthProvider
GrantTypeTokenExchangeCanSkipClientAuthProvider
GrantTypeJWTBearerIDOptionalProvider
GrantTypeJWTBearerIssuedDateOptionalProvider
GetJWTMaxDurationProvider
Expand Down
162 changes: 162 additions & 0 deletions fosite/handler/rfc8693/handler.go
Original file line number Diff line number Diff line change
@@ -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"

Check failure on line 17 in fosite/handler/rfc8693/handler.go

View workflow job for this annotation

GitHub Actions / Run tests and lints

G101: Potential hardcoded credentials (gosec)
TokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt"

Check failure on line 18 in fosite/handler/rfc8693/handler.go

View workflow job for this annotation

GitHub Actions / Run tests and lints

G101: Potential hardcoded credentials (gosec)
)

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
}
Loading
Loading