Skip to main content

Token exchange

This page describes the broker RFC 8693 token-exchange flows. The token endpoint supports third-party token exchange and local user impersonation. They share a grant type but use different requests, token issuance, and authorization.

note

For the concept and the request flow, read Token exchange. To deploy transparent exchange at an Envoy-based gateway, see the token-exchange gateway guide. The complete generated contract for this endpoint lives in the end-user API reference.

The broker supports two RFC 8693 flows:

  • Third-party token exchange returns a stored provider credential. The broker resolves a resource and validates the user delegation.
  • User impersonation is available only in local mode. A privileged client provides assertion, actor, and subject credentials for the audience target agent. The broker issues a local access token. sub contains the subject. act.iss and act.sub contain the validated actor issuer and identity.

The following sections describe the request and response contracts.

Overview​

  • Endpoint: POST /oauth2/token (end-user server, port 8000).
  • Grant type: urn:ietf:params:oauth:grant-type:token-exchange.
  • Content type: application/x-www-form-urlencoded.

The token endpoint uses grant_type to identify an RFC 8693 request. It processes the request under RFC 8693. The same endpoint also supports authorization_code and client_credentials. See API overview and OAuth2 server modes.

Requests require exactly one Content-Type field. Per RFC 6749 Section 3.2, a parameter sent without a value is treated as omitted. A singleton form parameter with more than one non-empty value returns 400 invalid_request. The singleton parameters are grant_type, client_id, client_secret, code, redirect_uri, refresh_token, code_verifier, scope, subject_token, subject_token_type, client_assertion, client_assertion_type, actor_token, actor_token_type, and requested_token_type.

The resource and audience parameters are repeatable under RFC 8693 Section 2.1. Repeated resource values that normalize to the same URI are accepted. Because the broker returns one service token, distinct resource values return 400 invalid_target per RFC 8693 Section 2.2.2.

Key concepts​

Tokens​

Subject token (subject_token) — A JWT that carries the user principal and agent identifier. CEL expressions select these values. The default user claim is sub. The default agent claim is azp. The privileged client presents the token for exchange.

Client assertion (client_assertion) — A JWT that identifies the privileged client, such as a gateway or reverse proxy. The broker validates it with the upstream OAuth2 server JWKS. Its sub identifies the gateway in audit data.

Third-party token — The OAuth2 access token held in the broker token vault for a target service, such as GitHub or Google. A successful exchange returns this token.

Resource parameter​

resource identifies the service token to return. The broker compares it to service protected_resources. The broker removes a trailing slash before comparison. A resource URI can belong to only one service.

Configure protected_resources for a service through the admin API. See Manage agents and services. The resource must be a valid HTTP or HTTPS URI. The broker stores it without a trailing slash. A duplicate URI in another service returns 409 conflict.

User grant​

Before it returns a token, the broker validates the user grant. The subject token identifies the user and agent. The grant must permit the agent to use the target service. Without an active, unexpired grant, the broker denies token exchange. This enforces consent at request time.

Third-party token exchange request​

POST /oauth2/token with Content-Type: application/x-www-form-urlencoded. This section applies only when the request does not activate user impersonation.

Required parameters​

ParameterValueDescription
grant_typeurn:ietf:params:oauth:grant-type:token-exchangeSelects RFC 8693 token exchange.
subject_tokenJWT stringThe user's token (contains user principal + agent identifier).
subject_token_typeurn:ietf:params:oauth:token-type:access_tokenThe only accepted subject-token type.
client_assertionJWT stringThe gateway's authentication token, validated against upstream JWKS.
client_assertion_typeurn:ietf:params:oauth:client-assertion-type:jwt-bearerThe client authentication method.
resourceURI stringTarget service resource URI (for example https://api.github.com).

Both type identifiers must match exactly. Missing identifiers, empty values, case changes, and surrounding whitespace return 400 invalid_request. The broker does not supply defaults.

Optional parameters​

ParameterValueDescription
scopeSpace-delimited scopesRequested scopes.
audienceStringIntended audience for the issued token.

Request example​

curl -X POST http://localhost:8000/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "client_assertion=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "resource=https://api.github.com"

Third-party token exchange response​

A successful third-party exchange returns 200 OK with Content-Type: application/json.

{
"access_token": "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"token_type": "Bearer",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"expires_in": 3600,
"scope": "read:user repo"
}

Response fields​

FieldPresenceDescription
access_tokenAlwaysThe third-party OAuth2 access token from the token vault.
token_typeAlwaysToken type, passed through from the stored token (typically Bearer).
issued_token_typeAlwaysurn:ietf:params:oauth:token-type:access_token.
expires_inOptionalRemaining lifetime in whole seconds. Omitted means unknown; zero means less than one second remains and must not use default caching.
scopeOptionalScopes associated with the token, when they differ from the request.
refresh_tokenOptionalA refresh token — typically absent, since the broker manages refresh.
granted_permission_setsOptionalMap of permission-set UUID → array of service UUIDs, when the exchange is scoped to specific permission sets.

Token exchange returns a still-valid stored access token immediately. If it expires within token_refresh.lookahead_duration (default 5m) and has a usable refresh token, the broker submits background refresh work. The exchange does not wait for that work. A failed or dropped background refresh does not change the response. The lookahead window does not guarantee a minimum remaining lifetime for the returned token.

After the stored access token expires, the broker waits for an on-demand refresh using the stored refresh token. Concurrent refreshes for the same principal and service share work in one process. Across replicas, the broker locks and re-reads the session before refreshing and committing rotated tokens.

Refresh retries complete HTTP 5xx and 429 error responses and pre-send dial failures, with at most three attempts and backoff delays of 1s and 2s. It honors Retry-After up to 10 seconds; larger values, including numeric overflow, stop retries. It does not retry incomplete response bodies, timeouts, other HTTP 4xx responses, or ambiguous transport failures after sending request headers. A lost response can lose a provider rotation. The broker leaves stored credentials unchanged; a later provider rejection can require login.

Refresh commits explicit provider token type and scope with the new credentials. Omitted scope retains the stored scope; an explicit empty scope clears it. Scope narrowing therefore applies to subsequent exchange authorization and responses. Session lists and consent checks consider a connection usable until it requires reauthentication: the provider rejected its refresh token, or its access token expired without a stored refresh token. A recorded rejection stops token exchange immediately, even while the stored access token is still valid. The broker never predicts refresh-token expiry.

During refresh, absent or null expires_in metadata leaves expiry unspecified. Malformed or unrepresentable values, including positive lifetimes below one second, fail with provider_response_invalid instead of producing a non-expiring token. Other positive fractions are truncated to whole seconds.

Success example​

curl -X POST http://localhost:8000/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$UPSTREAM_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "client_assertion=$GATEWAY_JWT" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "resource=https://api.github.com"
{
"access_token": "ghu_1234567890abcdefghijklmnopqrstuvwxyz",
"token_type": "Bearer",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"expires_in": 28800
}

Third-party token exchange errors​

Third-party token-exchange errors use the OAuth2 error, error_description, and optional error_uri fields. Descriptions are credential-free summaries, not stable diagnostic identifiers. Clients use the code and recovery URI, not description text.

HTTP statuserrorWhen it occurs
400invalid_requestThe request is malformed or missing a required parameter.
400invalid_targetThe resource matches no configured service, or repeated resource values identify distinct targets.
400invalid_grantRejected subject credentials, or a missing or unusable provider session.
401invalid_clientThe client_assertion (or broker credentials) cannot be verified.
403access_deniedMissing, expired, or insufficient consent, or CEL policy denial.
500server_errorConfiguration or infrastructure failure, including ambiguous registered resources.

400 invalid_request​

{
"error": "invalid_request",
"error_description": "subject_token is required"
}

Common causes include a missing subject_token, client_assertion, or resource, or an invalid resource URI. A supplied subject credential that fails format, signature, or claim validation returns invalid_grant, not invalid_request.

400 invalid_target​

{
"error": "invalid_target",
"error_description": "No service configured for the requested resource"
}

No service has the requested URI in its protected_resources.

400 invalid_grant​

{
"error": "invalid_grant",
"error_description": "User session is unavailable. Please re-authenticate.",
"error_uri": "https://broker.example.com/api/third-party/550e8400-e29b-41d4-a716-446655440000/oauth2/authorize?redirect_uri=https%3A%2F%2Fbroker.example.com%2Fsessions"
}

Rejected subject credentials do not require provider-session recovery and have no recovery URI. A missing, expired, scope-deficient, or provider-rejected session has a service-specific error_uri. The broker builds it as <public URL>/api/third-party/{serviceId}/oauth2/authorize?redirect_uri=<url.QueryEscape(<public URL>/sessions)>. The configured public URL supplies the origin. The resolved service UUID supplies {serviceId}. Opening the link starts third-party OAuth with PKCE. The provider callback returns to /sessions. The link is not broker /oauth2/authorize, a provider URL, or a direct session-management page. Provider 5xx/429 responses remain infrastructure errors, regardless of their OAuth error code.

401 invalid_client​

{
"error": "invalid_client",
"error_description": "Invalid client_assertion signature"
}

The client_assertion signature did not validate against the upstream JWKS. The assertion can also be expired or have no broker audience.

403 access_denied​

{
"error": "access_denied",
"error_description": "User has not granted this agent access to the requested service"
}

Missing, revoked, expired, empty, stale, or insufficient grants return access_denied with the agent consent-management error_uri. The broker denies these requests before token-vault access. A false CEL policy also returns access_denied, without a consent recovery URI. CEL compilation or configuration failure instead returns server_error.

500 server_error​

{
"error": "server_error",
"error_description": "An unexpected error occurred"
}

Diagnostic attributes​

Logs, spans, and metrics use the following bounded attributes:

AttributeValues
token_exchange.outcomesuccess, authorization_denied, authentication_failed, reauth_required, invalid_request, configuration_error, infrastructure_error, canceled
token_exchange.failure_stagerequest_validation, exchange_routing, subject_validation, client_validation, identity_resolution, client_authorization, resource_resolution, grant_authorization, session_lookup, refresh, scope_validation, response_write
token_exchange.failure_detailTyped origin detail; unknown failures use internal_unclassified at the known stage
token_exchange.recovery_actionreconsent, reauthenticate, fix_configuration, retry, none
token_exchange.recovery_targetconsent, subject_identity, calling_client, provider_session, broker_configuration, none
token_exchange.exchange_kindthird_party, impersonation. ExtProc uses unknown when the broker profile is not observable.

Successful logs and spans omit failure-only stage/detail fields. Successful metric observations use none for those fields. Recovery values are diagnostic recommendations, not instructions for automatic retries.

Missing resources are malformed requests. Rejected subject/client credentials are authentication failures. Conflicting resource values use request_validation / request_malformed diagnostics while returning invalid_target; no registration lookup has occurred. resource_unregistered means a lookup found no configured service. CEL false is authorization denial. CEL compilation or configuration failure is a configuration error. Missing agents and ambiguous registered resources are configuration errors. Unavailable repositories and JWKS retrieval are infrastructure failures.

Missing or unusable consent grants remain authorization denial with reconsent targeting consent. They return access_denied and the agent consent-management error_uri before token-vault access. Grant coverage denials use the first applicable detail below. Scope unions, agent ceilings, HTTP status, and recovery behavior are unchanged.

DetailMeaning
grant_emptyThe grant has no permission-set entries.
grant_permission_set_undeclaredA referenced permission set is no longer declared by the agent.
grant_permission_set_missingA referenced permission-set definition is missing, including unrelated stale references.
grant_service_omittedThe grant's effective inclusion entries omit the requested service.
grant_service_definition_missingThe included service is absent from applicable granted definitions.
grant_service_requirement_excludedNonempty agent service requirements exclude the included, defined service.
grant_scope_intersection_emptyThe explicit agent ceiling removes every scope from a nonempty permission-set union.

grant_missing and grant_expired retain their existing meanings. An originally empty scope union remains covered. RequireAllScopes bypasses the ceiling; an agent with no service requirements uses the permission-set union directly.

Missing, locally expired, scope-deficient, or provider-rejected sessions require reauthenticate targeting provider_session. Their OAuth response is invalid_grant with the service-specific OAuth2 initiation URL as error_uri; the encoded return URI targets /sessions. Provider refresh rejection and recorded local expiry are distinct causes. Provider 5xx/429 responses remain infrastructure failures even when their bodies contain an OAuth rejection code. Provider client-authentication rejection instead indicates broker configuration failure. Credential-file availability failures use credential_source_unavailable with outcome infrastructure_error at the affected session stage. They return server_error without a recovery URI. Missing or mismatched established client identity instead uses credential_identity_changed, reauth_required, and reauthenticate targeting provider_session. Identity failures return invalid_grant with the same service-specific error_uri before provider authentication.

Session OperationError carries immutable operation/detail/kind/dependency/status/allowlisted-code metadata. Origin metadata maps to exchange diagnostics without inspecting error text. ExtProc OperationError captures its diagnostic once at construction. Callers cannot override that snapshot. Individual caller cancellation is canceled at its current stage. Shared-operation and dependency deadlines remain infrastructure failures. Cancellation during provider refresh backoff retains the last provider error as a cause, including its dependency, HTTP status, and allowlisted OAuth error code.

Telemetry identifies registered services with token_exchange.service.id, not requested URIs or service display names. Broker exchange logs and spans also carry scalar resolved authorization context; these fields are not metric labels or response fields:

AttributePresence
token_exchange.agent.idOnly after the broker resolves a registered agent.
token_exchange.grant.idOnly when the user-agent grant was found, including an expired grant.
token_exchange.grant.updated_atFound grant's nonzero stored timestamp, UTC RFC3339Nano.
token_exchange.grant.valid_untilFound grant's explicit expiry, UTC RFC3339Nano; omitted for indefinite grants.

The same available context survives authorization and session denials, cancellation, and response-write failures. Missing grants have no invented grant metadata; parsed but unregistered agent candidates are not exported. Impersonation does not acquire third-party grant context. The existing authenticated actor, calling peer, and trace ID remain the request identity.

Successful grant writes return persisted identity and timestamps to the domain through the supplied grant value. Atomic upserts preserve the winning grant ID and creation time; PostgreSQL metadata is published only after commit and reflects stored timestamp precision. Expiry pointers are defensive copies.

Grant revocation uses the atomically deleted row as its observation snapshot, not a preceding lookup. Agent and permission-set deletion records are emitted only when the primary entity was actually deleted; repeated absent deletes retain their existing HTTP responses without another successful deletion record. These outcomes do not change adapter cascade behavior.

Session failures also carry bounded token_exchange.session.* metadata. Telemetry excludes descriptions, causes, unvalidated JWT claims, JOSE headers, provider bodies/headers, endpoint URLs, resource paths, and recovery URIs. Established authenticated actor and calling-peer audit fields remain unchanged. Span status descriptions are static. Raw errors never become exception events.

ExtProc records only observed stages and uses unknown for unreported broker exchange profiles. extproc.token_exchange spans have span kind Server. They set the error status only when the client receives a 5xx response, unless the caller canceled the request. A 4xx response, such as a re-authentication challenge or a header-only policy denial, leaves the status unset. Use token_exchange.outcome to find those requests. For a request with a body, the span ends after the headers phase. A later policy denial is recorded on extproc.opa.evaluate, and token_exchange.outcome stays success because the exchange succeeded. When the exchange call fails, ExtProc extproc.token_exchange spans also carry the attributes below. All extproc.client_assertion.refresh spans carry them. Other extproc.token_exchange failures carry only the diagnostic attributes above. Rejected token-exchange metadata gets a 503 response, so it also carries the error status and error.type=rejected.

AttributeValues
token_exchange.operationexchange, assertion_refresh, configuration
token_exchange.dependencylocal, broker, oauth_provider
error.typeFailures only: configuration, unavailable, rejected, invalid_response, caller_canceled, assertion_expired, circuit_open, internal
token_exchange.oauth_error_codeAllowlisted OAuth error code from the broker or OAuth provider; unknown otherwise. On extproc.token_exchange, only for broker errors.
token_exchange.broker_status_codeBroker HTTP status on extproc.token_exchange broker errors.

OPA audit data retains action, result code, protocol, allowlisted MCP method, duration, policy reasons, tool name, and target server name.

Outbound instrumentation propagates trace context and preserves the actual request URL, body, and headers for the dependency. It records only bounded method/status/error kind. Token-endpoint inbound instrumentation uses a credential-free request view, then restores the original request before handling it. HTTP method telemetry uses the standard-method allowlist. Extension methods become _OTHER without changing request routing. Token-endpoint panic logs use bounded summaries, not panic values or stacks. OTel resources exclude process command arguments and command lines, including configured overrides.

Grant and definition lifecycle audits​

The domain mutation service emits one successful operational record after persistence. Request handlers do not emit a second success or infer a mutation from the HTTP status. These synchronous logs are observations, not a durable transactional audit ledger.

ActionsPurpose-limited fields
grant_created, grant_updatedOwner principal, agent_id, committed grant_id, valid_until, created_at, updated_at, and typed {permission_set_id, included_service_ids} entries.
grant_revokedAtomically deleted grant's owner, IDs, validity, update time and granted entries, plus revoked_at.
agent_created, agent_updatedRegistered agent_id, typed permission_sets, and service_requirements with service ID, requirement type, required scopes and all-scopes flag.
agent_deletedThe primary agent ID actually deleted; no inferred grant cascade count or per-grant revocation claim.
permission_set_created, permission_set_updatedpermission_set_id and typed service_scopes.
permission_set_deletedThe permission-set ID actually deleted.

Audit timestamps use UTC RFC3339Nano. Indefinite grant validity is JSON null. Stored definition scopes are authorization metadata, not issued-token scope values; names, profile text, credentials, token/JWT material, arbitrary errors and sensitive URIs are excluded. None of these identity or snapshot fields are metric labels.

Updates include applicable previous_observed_* validity, definition/inclusion and update-time fields only when that state was read before writing. They describe that observed state, not a transactionally guaranteed immediate predecessor. A raced initial consent upsert that updates another request's winning grant is grant_updated, with no fabricated previous snapshot. A sequential unchanged consent submission returns the existing grant without a mutation record; an actual successful definition Update remains audited.

Validation or persistence failure, absent idempotent deletion, and rejected empty consent POSTs emit no successful mutation event. An empty POST cannot revoke an existing grant. Explicit consent DELETE retains 204 then 404 behavior; repeated absent agent or permission-set DELETEs remain 204 without false audits.

The existing request-security logger decorator supplies authenticated actor and trace_id; the grant owner is not used to invent an actor. Authorized mutation handlers finalize the existing capture holder immediately before the domain call. An admin request without an authenticated principal remains anonymous, and plain domain calls do not manufacture request identity.

User impersonation​

User impersonation is a separate RFC 8693 profile on this endpoint. It is available only when oauth2_authorization_server.mode is local. Proxy and hybrid modes reject the request without forwarding it upstream. Impersonation does not get a third-party token or accept resource. Eligible humans require an active user grant; eligible signed self-agent subjects do not, under ADR 045.

The token contains subject claim sub and actor claim act. In RFC 8693 terms, this is delegation. This feature uses the name "impersonation" for operators. act preserves actor accountability.

Activation and request​

The request activates impersonation when it contains one audience. The value must be <impersonation.audience_prefix>/<canonical lower-case AgentID UUID or canonical_id>. The suffix must resolve to a registered target agent. It can be the canonical target UUID or canonical_id. Both forms produce the target UUID as agent_id. The target agent supplies the local token policy agent.* context and optional-scope allow list. The routing audience does not set issued aud. Only token_claims_expression controls this claim.

Send these form fields:

ParameterValueRequirement
grant_typeurn:ietf:params:oauth:grant-type:token-exchangeRequired.
audience<audience_prefix>/<canonical lower-case AgentID UUID or canonical_id>Required. Exactly one value.
client_assertion_typeurn:ietf:params:oauth:client-assertion-type:jwt-bearerRequired.
client_assertionSigned JWTRequired. Authenticates the privileged client.
actor_token_typeurn:ietf:params:oauth:token-type:jwtRequired.
actor_tokenSigned JWTRequired. Its validated issuer and identity populate act.iss and act.sub.
subject_token_typeurn:ietf:params:oauth:token-type:jwtRequired.
subject_tokenSigned JWT or unsigned alg:none JWT for a matching verification: none ruleRequired. Its extracted identity populates human sub; eligible agent sub is target-derived.
scopeLiteral-space-separated scopesOptional. Each non-reserved scope must be in the target allowed_scopes. An empty allow list permits all. offline and offline_access are reserved and permitted.
requested_token_typeurn:ietf:params:oauth:token-type:access_tokenOptional. Another value returns invalid_request.

resource must be absent. A bare suffix or invalid suffix returns invalid_request. A well-formed UUID or canonical-ID suffix for an unregistered target returns invalid_target.

curl -X POST http://localhost:8000/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "audience=https://broker.example.com/impersonation/550e8400-e29b-41d4-a716-446655440000" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=$CLIENT_ASSERTION" \
-d "actor_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "actor_token=$ACTOR_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "subject_token=$SUBJECT_TOKEN" \
-d "scope=read"

Subject profiles​

Both profiles use subject_token_type=urn:ietf:params:oauth:token-type:jwt. The signed profile validates the client assertion, actor, and subject. It validates the selected issuer, audience, expiry, not-before time, signature, and asymmetric algorithm allow list.

The unverified-subject profile is a broker extension. A matching rule declares verification: none. It accepts an unsigned alg:none JWT as subject_token. It rejects signed JWSs on this path. It does not allow unsigned client assertions, actors, or signed subjects. A signed client assertion and the subject-binding CEL predicate authorize caller-provided claims. The predicate must bind subject_token.email before the broker issues an email claim.

Category eligibility​

allowed_subject_categories is rule-local: omission means [human]; [agent] permits only agents and [human, agent] permits both (either order). A request is agent-category only when its signed subject credential verifies and its raw string sub exactly equals the audience-selected target UUID. An unsigned subject with that exact sub is agent-category only when the rule's authorization predicate binds subject_token.sub. A sub or extracted subject principal that is another registered agent's canonical UUID is rejected on every rule, without human fallback. Other subjects are human. No aliases, UUID normalization, principal-expression mapping or request category input selects agent issuance. Signed equality is ineligible on human-only/omitted rules even with active same-text delegation. Ineligibility falls through, and no eligible rule returns generic 403 access_denied (impersonation not permitted) without error_uri.

Every principal extraction and CEL authorization remains mandatory. Eligible agents skip delegation and credential email extraction, create no grant/session, and mint reserved sub == agent_id == target UUID with existing act and no category claim. Output email and audience remain policy-owned. Humans retain the extracted principal as minted subject and delegation principal. Missing/expired delegation is terminal 403 access_denied with the established target consent error_uri; verifier failure is 500 server_error without one. A denied human never retries as agent. Unsigned rules may list agent, but it has no effect; ADR 031 controls remain intact.

Response and errors​

Success returns a locally signed broker JWT. It does not return a provider credential:

{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"expires_in": 3600,
"scope": "read"
}

The JWT contains sub, target-derived agent_id, act, local-policy claims, and the granted scope. The response includes scope only when it has a value. Each requested non-reserved scope must be in the target allow list. An empty allow list permits all scopes. offline and offline_access are reserved and permitted. A disallowed scope returns invalid_scope. Malformed credentials and target syntax return invalid_request. An untrusted client assertion returns invalid_client. A valid request with no permitted rule returns access_denied. Errors do not expose credentials, signing keys, trust sources, or rejected scopes.

Audit events​

Each impersonation decision records a credential-free structured log event named impersonation_decision. The event contains the target, selected rule, validated credential roles, safe identities, OAuth error code, outcome, and request_id. After classification it also contains subject_category (human or agent); subject_identity is the represented/minted subject. It contains no wholesale claims. When one token-exchange grant and its audience identify an impersonation request, repeated nonempty parameters also produce this event before credential authentication. The event records invalid_request and the resolved target, without unvalidated identities or credential values. Separate token-exchange diagnostic fields distinguish missing and expired grants. The event excludes routing audiences, issuer URLs, free-form failure categories, credentials, issued access tokens, and signing keys.

See Configuration and examples/config/impersonation.yaml for operator setup and examples.

Agent-subject boundary​

Third-party exchange evaluates token_exchange.claim_extraction.agent_subject_expression after validated principal extraction and security capture finalization, before agent extraction, authorization, resources, grants or sessions. The default is:

has(subject_token.sub) && has(subject_token.agent_id) && subject_token.sub == subject_token.agent_id

A matching result returns 403 access_denied, description agent-subject tokens cannot be exchanged for third-party service tokens, with no error_uri or human grant/session/provider access, even with same-text human state. Default detection also rejects equal-claim legacy, mapped-principal/unsigned human and trusted external issuer tokens. An evaluation error/timeout returns 500 server_error (identity_resolution/cel_evaluation_failed); dynamic non-boolean output returns 500 server_error (identity_resolution/cel_configuration). Neither is treated as false or consults human state. Compilation errors, static output types other than boolean/dynamic, and expressions that do not reference subject_token.sub fail startup.

A non-empty override replaces the default entirely. A configured expression must reference subject_token.sub (field selection, has(), or subject_token["sub"]); otherwise startup fails with a CEL configuration error, so false, true, or an azp-only comparison cannot be configured. Operators must retain broker agent-token matching, for example by preserving the default as a disjunct. A non-matching override that references sub, such as has(subject_token.sub) && subject_token.sub == 'nobody', removes categorical rejection and same-text human-credential isolation. Existing checks can then read human grants/sessions and return consent errors. This is not supported third-party agent identity or guaranteed exchange success.

Diagnostic detailFailure stageExchange kindExternal result
subject_category_ineligibleclient_authorizationimpersonationRule fall-through; no match is generic 403 access_denied, no error_uri
subject_category_unsupportedidentity_resolutionthird_party403 access_denied, no error_uri

See the token-endpoint request/claims/denial examples, concepts, configuration, and detector example.

Configuration​

Configure token exchange under token_exchange. The Configuration reference describes all settings. These two settings matter most:

  • Claim extraction — CEL expressions get the principal and agent identifier from the subject token.
  • Authorization — A CEL expression evaluates client_assertion claims. It determines whether a privileged client can exchange a token.
token_exchange:
claim_extraction:
principal_expression: subject_token.sub
agent_id_expression: subject_token.azp
authorization:
cel:
expression: "client_assertion.iss == 'api-gateway.example.com'"

The authorization expression can allow all clients ("true"). It can also select one issuer or several issuers:

token_exchange:
authorization:
cel:
expression: |
client_assertion.iss in [
'api-gateway.example.com',
'reverse-proxy.example.com'
]

Worked example​

This walks a GitHub delegation end to end. Administrative calls go to the admin server (port 14000); consent and token exchange go to the end-user server (port 8000).

# 1. Admin registers the GitHub service with a protected resource (admin API, :14000)
curl -X POST http://localhost:14000/api/services \
-H "X-Remote-User: admin@example.com" \
-H "Content-Type: application/json" \
-d '{
"display_name": "GitHub",
"client_id": "github-oauth-client",
"issuer_uri": "https://github.com",
"protected_resources": ["https://api.github.com"]
}'
# → {"id": "aa0e8400-e29b-41d4-a716-446655440000", ...}

# 2. Admin creates a permission set bundling the GitHub scopes (admin API, :14000)
curl -X POST http://localhost:14000/api/permission-sets \
-H "X-Remote-User: admin@example.com" \
-H "Content-Type: application/json" \
-d '{
"name": "Read repositories",
"description": "Read access to GitHub repositories",
"service_scopes": [
{
"service_id": "aa0e8400-e29b-41d4-a716-446655440000",
"scopes": ["repo"],
"requirement_type": "mandatory"
}
]
}'
# → {"id": "770e8400-e29b-41d4-a716-446655440001", ...}

# 3. Admin registers the agent (admin API, :14000)
curl -X POST http://localhost:14000/api/agents \
-H "X-Remote-User: admin@example.com" \
-H "Content-Type: application/json" \
-d '{"display_name": "GitHub Assistant"}'
# → {"id": "550e8400-e29b-41d4-a716-446655440000", ...}

# 4. User grants the permission set for that service (end-user API, :8000)
curl -X POST http://localhost:8000/api/consent/agents/550e8400-e29b-41d4-a716-446655440000/grants \
-H "X-Remote-User: user@example.com" \
-H "Content-Type: application/json" \
-d '{
"granted_permission_sets": {
"770e8400-e29b-41d4-a716-446655440001": [
"aa0e8400-e29b-41d4-a716-446655440000"
]
}
}'
# → 201 {"data": {"agent_id": "550e8400-...", "granted_permission_sets": {...}}}

# 5. User completes the GitHub authorization flow in the browser,
# which stores an encrypted third-party session.

# 6. The gateway exchanges the agent's token for the GitHub token (end-user API, :8000)
curl -X POST http://localhost:8000/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$UPSTREAM_JWT_WITH_USER_SUB_AND_AGENT_AZP" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "client_assertion=$GATEWAY_JWT" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "resource=https://api.github.com"
# → 200 {"access_token": "ghu_...", "token_type": "Bearer", ...}

# 7. The gateway calls GitHub with the exchanged token
curl -H "Authorization: Bearer ghu_..." https://api.github.com/user

Security considerations​

  • Both JWTs are verified. The client_assertion and subject_token signatures are validated against the upstream OAuth2 server's JWKS; expired or wrongly-audienced tokens are rejected.
  • A user grant is always required. The broker returns a token only when the principal has an active, non-expired grant for the agent and service.
  • Token values are never logged. The returned access_token does not appear in logs; audit records reference the principal, agent, service, and resource, not the token.
  • Transport is HTTPS. Terminate TLS in front of the broker; tokens must not cross the network in the clear.