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, multi-way scope intersection, delegation target guards, federation policy resolution, service identity resolution, 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 multi-way scope intersection (see Scope intersection below).
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 or upstream-issued JWT access tokenVerified against ahdapa’s own JWKS when the token’s iss is this node or a gossip peer. Otherwise, verified against the upstream issuer’s JWKS; iss must be in federation.trusted_issuers, auto-discovered IPA IdPs, or reachable via a configured OIDC Federation trust chain — identical trust model to the id_token row below. 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 intersection of up to five ceilings:

  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’s scope claim (when present),
  3. The scopes registered for the requesting client,
  4. The federation policy scope ceiling (when the subject was resolved via a federation policy), and
  5. The target client’s registered scopes (when the resolved subject matches a registered client_id, i.e. service identity resolution).

ID token special case: ID tokens (RFC 7519) do not carry a scope claim. When the subject token is an ID token, ceiling (2) is omitted – the remaining ceilings (client registration, policy, target client) still apply. This prevents the exchange from being rejected due to an empty scope intersection when a legitimate ID token is exchanged.

A client cannot receive more via OBO than the original subject token held (when it carries scopes), 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). In FreeIPA co-deployments, when the built-in allow_all HBAC rule is enabled (the default), HBAC evaluation is skipped entirely for token exchange.

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 tokens

To exchange an upstream provider’s ID token or access 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.

Access tokens from an external issuer: when an access_token subject token’s iss is not this node (or a cluster gossip peer), the same trust check applies as for id_token above. On success, the token’s sub is mapped to a local identity the same way an upstream id_token’s sub is — via an exact federated_accounts link, falling back to glob-pattern federation policy matching. An external access token whose subject has no federated account or policy match is rejected with invalid_grant, even if its signature is valid.

Replay protection depends on the token carrying a jti. When the subject token (an external id_token or access_token) includes a jti claim, it is checked against a replay cache and rejected with invalid_grant if reused before expiry. jti is optional per RFC 7519, and a token that omits it is still accepted — replay protection is simply not applied to it, silently, for that token. If your upstream IdP’s tokens don’t include a jti, be aware that a captured, still-valid token can be exchanged repeatedly until it expires.

Custom audience matching: When a client has allowed_token_exchange_audiences set, the ID token’s aud claim is accepted if it matches any value in that list in addition to the server’s own issuer. This supports CI providers (e.g. GitHub Actions getIDToken()) that set a custom audience in the OIDC JWT.

SSRF bypass for trusted issuers: When the upstream issuer is listed in federation.trusted_issuers (statically trusted), the URL safety check (is_safe_issuer_url) is skipped for both the issuer URL and its jwks_uri. This allows statically trusted issuers to use non-HTTPS URLs or private-network addresses that would otherwise be blocked by the SSRF guard.


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.