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 anactclaim 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"
| Parameter | Required | Description |
|---|---|---|
subject_token | yes | The token being exchanged (the user’s token). |
subject_token_type | yes | Type of the subject token. See table below. |
actor_token | no | The 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_type | no | Type of the actor token. Must be urn:ietf:params:oauth:token-type:access_token when present. |
scope | no | Requested scope for the new token. Bounded by the three-way intersection: requested ∩ subject token scopes ∩ client registered scopes. |
audience | no | Intended audience (aud) for the new token. Defaults to the requesting client_id. |
target_service | no | Kerberos 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 URI | Token type | Validation |
|---|---|---|
urn:ietf:params:oauth:token-type:access_token | ahdapa-issued JWT access token | Verified against ahdapa’s own JWKS; must not be expired. |
urn:ietf:params:oauth:token-type:id_token | OIDC ID token from a trusted upstream | Verified 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:
-
OBO actor gate — The requesting client must have
allow_token_exchange_actor = truein its registration. If this flag is absent (the default), the request is rejected immediately with403 access_deniedbefore any token parsing occurs. -
JWT validity — The
actor_tokenis verified as a signed JWT against ahdapa’s own JWKS. An actor token from an external issuer is not accepted. -
issandaudbinding — The actor token’sissmust match the ahdapa issuer URL. Itsaudmust contain the requestingclient_id(not the issuer URL). Ahdapa issues access tokens withaud = [client_id]per RFC 9068, so a token issued to a different client is rejected here, preventing cross-client actor token theft. -
subbinding — The actor token’ssubclaim must equal the requestingclient_id. Cross-client delegation (one client acting on behalf of a different client) is not supported. -
Act chain depth cap — The nested
actchain within the actor token is counted. Chains deeper than 5 hops are rejected with400 invalid_requestto prevent stack-overflow and DoS via unbounded nesting.
Scope intersection
The granted scope is the three-way intersection of:
- The scopes explicitly requested in the
scopeparameter (or the subject token’s full scope ifscopeis omitted), - The scopes carried in the subject token (
scopeclaim), and - 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 OKwith 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, anytarget_serviceis permitted (wildcard). - Otherwise, the SPN must appear in the rule’s
delegation_targetslist. - If no matching rule permits the SPN →
403 access_denied. - If
target_serviceis 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.