Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Token Exchange

The token exchange grant (RFC 8693) allows a client to exchange an existing token for a new one — typically to narrow scope, change the audience, or delegate authority from one service to another. Ahdapa implements the full RFC 8693 OBO (On-Behalf-Of) flow including actor token validation, act claim chains, three-way scope intersection, delegation target guards, and HBAC policy enforcement.


Use cases

  • Service-to-service delegation (OBO) — Service A holds a user’s access token and needs to call Service B on the user’s behalf. A exchanges the token for a new one addressed to Service B, presenting its own access token as the actor_token. The issued token carries an act claim identifying Service A as the actor.
  • Scope narrowing — A gateway holds a broad-scope token but wants to call a downstream service with only the scopes that service needs.
  • Federation bridge — A client presents an upstream IdP’s ID token and receives an ahdapa-issued access token.

Token request

POST /token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange. Authenticate the client at the token endpoint using any supported method.

curl -s -X POST https://idp.example.com/token \
  -u "CLIENT_ID:CLIENT_SECRET" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=SUBJECT_ACCESS_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "actor_token=ACTOR_ACCESS_TOKEN" \
  -d "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "scope=openid" \
  -d "audience=TARGET_CLIENT_ID" \
  -d "target_service=host/backend.example.com"
ParameterRequiredDescription
subject_tokenyesThe token being exchanged (the user’s token).
subject_token_typeyesType of the subject token. See table below.
actor_tokennoThe requesting client’s own access token, proving it is acting as the OBO actor. Required if allow_token_exchange_actor is set on the client.
actor_token_typenoType of the actor token. Must be urn:ietf:params:oauth:token-type:access_token when present.
scopenoRequested scope for the new token. Bounded by the three-way intersection: requested ∩ subject token scopes ∩ client registered scopes.
audiencenoIntended audience (aud) for the new token. Defaults to the requesting client_id.
target_servicenoKerberos SPN identifying the service the actor is delegating to (e.g. host/backend.example.com). When present, the HBAC rule must explicitly permit this SPN via delegation_targets or delegation_target_category.

Supported subject_token_type values

Type URIToken typeValidation
urn:ietf:params:oauth:token-type:access_tokenahdapa-issued JWT access tokenVerified against ahdapa’s own JWKS; must not be expired.
urn:ietf:params:oauth:token-type:id_tokenOIDC ID token from a trusted upstreamVerified against the upstream’s JWKS; iss must be in federation.trusted_issuers, auto-discovered IPA IdPs, or reachable via a configured OIDC Federation trust chain.

Actor token validation

When actor_token is supplied, ahdapa performs the following checks before any token is issued:

  1. OBO actor gate — The requesting client must have allow_token_exchange_actor = true in its registration. If this flag is absent (the default), the request is rejected immediately with 403 access_denied before any token parsing occurs.

  2. JWT validity — The actor_token is verified as a signed JWT against ahdapa’s own JWKS. An actor token from an external issuer is not accepted.

  3. iss and aud binding — The actor token’s iss must match the ahdapa issuer URL. Its aud must contain the requesting client_id (not the issuer URL). Ahdapa issues access tokens with aud = [client_id] per RFC 9068, so a token issued to a different client is rejected here, preventing cross-client actor token theft.

  4. sub binding — The actor token’s sub claim must equal the requesting client_id. Cross-client delegation (one client acting on behalf of a different client) is not supported.

  5. Act chain depth cap — The nested act chain within the actor token is counted. Chains deeper than 5 hops are rejected with 400 invalid_request to prevent stack-overflow and DoS via unbounded nesting.


Scope intersection

The granted scope is the three-way intersection of:

  1. The scopes explicitly requested in the scope parameter (or the subject token’s full scope if scope is omitted),
  2. The scopes carried in the subject token (scope claim), and
  3. The scopes registered for the requesting client.

A client cannot receive more via OBO than the original subject token held, regardless of its own registered scopes.

  • If the intersection is empty → 400 invalid_scope.
  • If the intersection is non-empty but smaller than requested → 200 OK with the narrowed scope (silent narrowing per RFC 8693).

Act claim

When actor_token is present and passes validation, the issued OBO token carries an act claim:

{
  "act": {
    "sub": "pipeline-agent",
    "workload_type": "pipeline-agent"
  }
}

sub is the actor’s client_id. workload_type is resolved from the CRDT-registered client entry — not from the actor token claim — to prevent label spoofing. If the client has no workload_type set, the field is omitted.

For multi-hop delegation, existing act chains from the actor token are preserved and nested inside the new act object (up to the depth cap of 5).


Delegation target guard

When target_service is included in the request, HBAC evaluation additionally checks whether the matching rule permits that Kerberos SPN:

  • If the rule has delegation_target_category = true, any target_service is permitted (wildcard).
  • Otherwise, the SPN must appear in the rule’s delegation_targets list.
  • If no matching rule permits the SPN → 403 access_denied.
  • If target_service is provided but no live HBAC rules exist → 403 access_denied (fail-closed for delegation).

HBAC enforcement

HBAC policy is evaluated for every token exchange request (when live rules exist). The evaluation uses the subject token’s sub as the identity, the requesting client_id, the granted scopes, and the target_service (if any).

Network-axis HBAC rules (source IP matching) are not applied during token exchange — the originating network of the OBO caller is not available at this point. Group-based HBAC rules are also not applied during OBO exchange, as group membership is not embedded in the subject token JWT.

If HBAC denies the request, the response is 403 access_denied and an audit event token_exchange_denied is written.


Response — 200 OK

{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6IkFCQ0QxMjM0In0...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "openid"
}

When a DPoP proof was included in the request, "token_type": "DPoP" is returned and the access token’s cnf.jkt contains the DPoP key thumbprint.

When the subject token came from an upstream IdP (ID token exchange), the resulting access token contains a sub_id claim in iss_sub format linking the token back to the upstream identity:

{
  "sub_id": {
    "format": "iss_sub",
    "iss": "https://upstream.example.com",
    "sub": "upstream-user-id"
  }
}

Grant type restriction

If the requesting client has an explicit grant_types list (set at registration), it must include "urn:ietf:params:oauth:grant-type:token-exchange". Clients without a grant_types list may use this grant type freely.


Client registration requirements

For a client to participate in OBO token exchange as an actor (supplying actor_token), its registration must have allow_token_exchange_actor = true. This field is false by default.

The workload_type field on the client registration provides a machine-readable label that appears in the act.workload_type claim of issued OBO tokens. Set this to a meaningful category such as "pipeline-agent" or "api-gateway" to enable audit correlation across multi-hop chains.

See Admin API — Client fields for how to set these fields.


Trusting upstream ID tokens

To exchange an upstream provider’s ID token, that provider’s issuer must be:

  • Listed in federation.trusted_issuers, or
  • Discovered automatically as a FreeIPA IdP ([ipa] gssapi = true), or
  • Reachable via an OIDC Federation 1.0 trust chain anchored in federation.trust_anchors.

An ID token from an untrusted issuer is rejected with invalid_grant.


Example: OBO exchange with actor token

# Step 1: obtain the subject user's access token (e.g. via authorization_code)
SUBJECT_TOKEN="<user access token>"

# Step 2: obtain the agent's own access token (client_credentials)
ACTOR_TOKEN=$(curl -s -X POST https://idp.example.com/token \
  -u "pipeline-agent:agent-secret" \
  -d "grant_type=client_credentials&scope=openid" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# Step 3: perform OBO exchange
curl -s -X POST https://idp.example.com/token \
  -u "pipeline-agent:agent-secret" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$SUBJECT_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "actor_token=$ACTOR_TOKEN" \
  -d "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "scope=openid" \
  -d "target_service=host/backend.example.com"

The issued token’s act claim will identify the pipeline agent and its workload_type. See also the OBO demo for a self-contained walkthrough.