Delegation & act-chain
TL;DR — When agent A calls agent B that calls agent C on behalf of user U, AuthPlane records the full chain in the token’s
actclaim per RFC 8693 §4.1.substays as U (the original principal);act.subnames the current actor;act.act.subnames the actor before them. Full chain reconstructable from the token alone — no external correlation needed. AuthPlane caps chain depth (token_exchange.max_chain_depth, default 5) to prevent runaway nesting. Only the outermost actor is authoritative for access control per §4.1 ¶6 — inner-hop metadata is audit-only.
The problem
Multi-agent flows lose accountability. Agent A hands work to agent B who fans out to C and D. Downstream services see a token — whose token, exactly? U’s? A’s? B’s? Auditing “who actually made this call” requires correlating across N systems and hoping the correlation IDs match.
act claim solves this: the token IS the audit trail. Any downstream service can read the token and reconstruct the delegation chain.
Simple delegation
User U’s agent A wants to hand work to sub-agent B:
1. A holds a token: sub=user-42
2. A → AS POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<A's token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
resource=https://downstream.example.com
scope=tools/read
(optional: actor_token=<...> + REQUIRED actor_token_type)
3. AS mints a delegated token — the actor identity is the *authenticated
client* on this token endpoint call (RFC 8693 §4.1 Figure 6). Passing
actor_token is only needed when the client wants to override that.
The issued token:
{
"sub": "user-42", // preserved from subject_token
"aud": "https://downstream.example.com",
"scope": "tools/read",
"act": {
"sub": "agent-B", // whoever's acting on user's behalf
"actor_type": "agent"
}
}
sub= original user (audit trail says “this ultimately serves user-42”).act.sub= the acting agent (audit trail says “agent-B is the one actually holding this token”).act.actor_type= “agent” or “service” — hint at whether the actor is a human-serving agent or a machine.
Downstream sees the token, knows: request is on behalf of user-42, currently held by agent-B.
Chained delegation (A → B → C)
Agent B now hands to sub-agent C:
1. B holds the delegated token: sub=user-42, act.sub=agent-B
2. B → AS POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<B's delegated token>
resource=https://downstream.example.com
client_id=agent-C (or whoever's initiating)
The issued token now has TWO levels of act:
{
"sub": "user-42",
"act": {
"sub": "agent-C", // outermost — current actor
"actor_type": "agent",
"act": {
"sub": "agent-B", // previous actor
"actor_type": "agent"
}
}
}
Reading:
sub = user-42— this call serves user-42.act.sub = agent-C— agent-C is holding this token right now.act.act.sub = agent-B— agent-C got the token from agent-B, who was acting on user-42’s behalf.
Full chain: user-42 → agent-B → agent-C. Reconstructable from the token alone.
Only the outermost actor is authoritative
Per RFC 8693 §4.1 ¶6, downstream services MUST use only top-level claims + the outermost act.sub for access-control decisions. Inner-hop metadata (act.act.sub, act.act.act.sub, …) is informational — audit + display only. Do NOT gate access on inner hops.
Why: only the outermost actor’s identity is directly verifiable from the OAuth client authentication that made the exchange call. Inner-hop values are self-reported and only as trustworthy as the issuer that stamped them.
Trust model — cross-issuer subject tokens
Inner act values are only as trustworthy as the issuer of the original subject token. AuthPlane only accepts its own tokens on the exchange path today, so every inner-hop value that exists was stamped by AuthPlane on a prior exchange — inherently trustworthy.
If AuthPlane ever gains federation on the exchange path (accepting subject tokens from other AS issuers), this changes: the AS you’re federating with could stamp arbitrary act values that AuthPlane would then pass through unverified. The SanitizeNonIdentityClaims function in internal/domain/token/exchange.go proactively strips structural claims (exp, nbf, aud, iat, jti) from inner hops so a hypothetical federated future can’t smuggle those either.
Chain depth limit
Chains can nest indefinitely without a cap. AuthPlane enforces token_exchange.max_chain_depth (default 5) — an exchange that would produce a chain deeper than the limit is rejected with chain_too_deep. Bump for pathological multi-hop deployments; keep low for normal flows.
Impersonation vs delegation
RFC 8693 distinguishes:
- Impersonation — no
actclaim in the result. The exchanged token looks like it was directly issued to the original subject. Legitimate for narrow-scoping (e.g., “give me a version of my own token with fewer scopes”). Guarded bytoken_exchange.allow_self_exchange(defaultfalse— reject self-impersonation) and per-resourcepolicy.exchange.allowed_client_ids. - Delegation —
actclaim added, growing the chain. Under RFC 8693 §4.1 the actor identity comes from the authenticated client on the token endpoint call; anactor_tokenis only needed when the client wants to assert an identity other than its own (and when present,actor_token_typeis REQUIRED per §2.1). This is the multi-agent case above.
AuthPlane’s default posture is to allow delegation and refuse self-impersonation. Change with token_exchange.allow_self_exchange: true if you specifically need self-narrowing.
The actor_type extra claim
AuthPlane adds actor_type inside act — "agent" when the actor is registered with is_agent: true, "service" otherwise. Downstream services can distinguish “an agent is acting on the user’s behalf” from “a machine-to-machine service in the chain”. Informational; not authoritative.
agent_id and agent_chain — AuthPlane extensions
Alongside RFC 8693 act, AuthPlane emits two flat claims:
agent_id— the outermost agent’s client_id when it’s registered withis_agent: true.agent_chain— ordered list of agent client_ids extracted from theactchain (capped at 8).
These are AuthPlane extensions (not standardized). They make it easy for downstream services to filter/log by agent without walking the nested act tree. See Concepts: Agent identity.
Related
- Concepts: Grants & flows → Token exchange
- Concepts: Agent identity
- Concepts: Token Vault — the other primary use of token exchange (vending upstream tokens)
- Reference: Configuration → Optional grants —
token_exchange.max_chain_depth - Reference: RFC compliance → RFC 8693
- Full source —
internal/domain/token/exchange.go—ActClaim,SanitizeNonIdentityClaims, chain traversal