Support actor_token in RFC 8693 token exchange - #6331
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6331 +/- ##
==========================================
+ Coverage 72.96% 73.01% +0.04%
==========================================
Files 742 742
Lines 78236 78271 +35
==========================================
+ Hits 57085 57146 +61
+ Misses 17172 17130 -42
- Partials 3979 3995 +16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
Panel review against RFC 8693, RFC 8725, RFC 9700, OIDC Core errata set 2, and the current IANA OAuth registry. The ID-token validation issue is the merge blocker; the other comments are concrete conformance and repository-rule fixes.
An agent calling the token-exchange grant could previously only be
identified by its own OAuth client credentials at the endpoint. RFC
8693 also defines an explicit actor_token: a second, self-issued JWT
the agent presents alongside the user's subject_token, giving the
exchange a request-level proof of possession distinct from client
authentication. The handler unconditionally rejected both actor_token
and actor_token_type before this change ("not yet supported"), and
only accepted urn:...:access_token/jwt as subject_token_type, so a
subject token minted as an OIDC id_token (a legitimate shape from many
IdPs) had no way to be exchanged.
What changed:
- Accept actor_token + actor_token_type instead of rejecting them
outright. resolveActorIdentity validates actor_token against the
server's own JWKS (self-issued only — an actor_token is never
accepted from an external trusted issuer) and requires its "sub" to
equal the authenticated client's ID before the exchange proceeds.
- Accept id_token as a valid subject_token_type value.
- Removed the now-superseded validateExchangeParams helper (the old,
actor_token-rejecting parameter validator) in favor of the new
validateFormParams/resolveActorIdentity split.
- Corrected docs/arch/token-delegation-act-chain.md, which still
described RFC 8693's chained-act-claim nesting as "not implemented"
— that landed separately in stacklok#6149 before this branch was rebased;
the doc had gone stale, not the behavior.
- Added an integration test proving actor_token composes correctly
with the configured-delegate-client relaxation (a client granted
blanket self-issued-token trust): a mismatched actor_token must
still be rejected during actor-identity resolution before delegation
consent is ever reached, so that blanket trust can never be misread
as also loosening the actor_token binding check.
What this enables: a client can additionally prove it holds a second,
independently-issued token bound to its own client_id at exchange time,
and subject tokens minted as id_tokens by IdPs that issue that shape
become exchangeable.
What this deliberately does NOT do, by design: actor_token's own claims
never flow into the delegated token's "act" claim. Because "sub" must
equal client.GetID(), the resulting actor identity is identical whether
or not actor_token is supplied — this is actor-token *confirmation*
(proof of possession), not RFC 8693's general actor-delegation use case
of asserting a distinct sub-client-granularity actor. That's a
scope boundary recorded in resolveActorIdentity's doc comment, not an
oversight.
No new server configuration is introduced — actor_token/actor_token_type
are request-time form parameters at /oauth/token, not RunConfig/CRD
fields. Example request, assuming a confidential client already
registered for the token-exchange grant:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<user's JWT>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&actor_token=<agent's own self-issued JWT, sub=agent-client-id>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&client_id=agent-client-id
&client_secret=...
The delegated access token's "act" claim is unaffected by actor_token's
presence — it always names the authenticated client:
"act": { "sub": "agent-client-id" }
Closes stacklok#5815
Neither followed docs/arch's numbered-and-indexed convention, neither was linked from docs/arch/README.md or referenced anywhere else. token-delegation-act-chain.md duplicated content already in docs/arch/17-token-exchange-delegation.md (nested provenance, depth cap). token-delegation-actor-id.md was an open design question (should act.sub be a SPIFFE URI) written as a doc file instead of a tracked issue - it belongs in the epic's issue tracker, not shipped as architecture documentation.
Extends the delegate-client e2e suite with a live HTTP round trip against the deployed pod: a matching self-issued actor_token still resolves to the delegate client as the recorded actor, and a mismatched actor_token is rejected with a real 400 from the running server, not just in unit tests.
resolveActorIdentity required an actor_token's "sub" to equal the authenticated client ID, making the token's own client_id claim useless and collapsing actor_token to a no-op self-check that could never assert an actor distinct from the OAuth client. Bind on the actor_token's "client_id" claim instead (the same proof-of-possession role a normal token's client_id plays) and let "sub" flow through as the actor identity, so actor_token can name a delegate persona distinct from the authenticated client while still proving it was minted for that client.
subject_token_type=id_token was accepted but validated with the exact same access-token profile as any other subject token, silently ignoring the semantic differences RFC 8693 assigns to id_token (e.g. an ID token's aud names the relying-party client, not a resource). Rather than validate an ID token as if it were an access token, decline the type until a real ID-token validation profile exists — matching the existing actor_token_type restriction.
The token-exchange delegation doc never covered actor_token at all, and didn't explain why id_token is rejected as a subject/actor token type. Document the client_id/sub split resolveActorIdentity now enforces and the rationale for declining id_token until a real validation profile exists.
JAORMX
left a comment
There was a problem hiding this comment.
The earlier ID-token validation, RFC 8693 invalid_request, terminology, and response-drain comments are addressed. One authorization blocker remains: the new distinct actor subject is being reused as the authenticated client identity for delegate policy and the emitted client_id; see the inline comment.
Before merge, please also rebase onto current main so #6333/#6334 release/workflow/chart changes disappear from this PR, and update the title/body: they still advertise id_token support, sub == client_id, and proof-of-possession behavior that the branch now intentionally removed.
resolveActorIdentity's actorSub (the actor_token's asserted identity, which the prior fix let differ from the authenticated client) was being reused as the authenticated client identity for delegate-client policy, AllowedDelegateClients checks, subject-token client_id binding, and the issued token's own client_id. That let a client exchange a subject token issued to a completely different client, simply by presenting an actor_token whose sub happened to equal that subject token's client_id claim — a full bypass of the "subject token was issued to a different client" binding. Route client.GetID() to every policy/binding decision, and reserve actorSub for what it is actually meant to represent: may_act.sub and the emitted act.sub claim.
011aaf5 to
aec1464
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Verified aec146474: the authenticated OAuth client now remains authoritative for configured/allowed delegate policy, subject-token client_id binding, and the issued RFC 9068 client_id; the asserted actor is limited to may_act.sub and act.sub. The new regression tests cover the original actor-sub collision bypass. The branch is rebased, unrelated release/workflow files are gone, and the title/body now match the implementation.
The remaining failing unit-test check is GitHub infrastructure: the job never reached checkout/tests because codeload repeatedly returned 429/503 while downloading actions. Security, lint, codegen, docs, Helm, and two lifecycle matrices pass; the third lifecycle matrix is still running.
Summary
An agent calling the token-exchange grant could previously only be
identified by its own OAuth client credentials at the endpoint. RFC
8693 also defines an explicit
actor_token: a second, self-issued JWTthe agent presents alongside the user's
subject_token, letting theexchange name a specific acting-agent identity distinct from the
authenticated OAuth client itself (RFC 8693 §2.1's actor-delegation
use case). The handler unconditionally rejected both
actor_tokenandactor_token_typebefore this change ("not yet supported").What changed:
actor_token+actor_token_typeinstead of rejecting themoutright.
resolveActorIdentityvalidatesactor_tokenagainst theserver's own JWKS (self-issued only — an
actor_tokenis neveraccepted from an external trusted issuer). The binding check requires
the actor_token's own
client_idclaim to equal the authenticatedclient's ID (proving the token was minted for this client); its
subclaim is returned as the asserted actor identity, which mayname a specific agent instance or delegate persona distinct from the
client's own ID.
may_act.submatching and the emitted
act.subclaim. Every client-identity policydecision — the delegate-client allowlists (
configuredDelegateClients,AllowedDelegateClients), and the subject token's ownclient_idbinding — is bound to the authenticated OAuth client (
client.GetID()),never to the asserted actor. Conflating the two would let a client
exchange a subject token issued to a different client, simply by
presenting an
actor_tokenwhosesubhappens to equal that subjecttoken's
client_idclaim — a full bypass of the "subject token wasissued to a different client" check. A regression test
(
actor_token cannot bypass subject-token client_id binding) pinsthis.
id_tokenremains rejected as asubject_token_typevalue: an IDtoken's claim conventions differ from an access token's (e.g.
audnames the relying-party client, not a resource), and this validator
applies neither a distinct validation profile nor id_token-specific
claim mapping. Accepting the type without that profile would silently
validate an ID token exactly like an access token, ignoring the
declared type's semantics. The same reasoning excludes
id_tokenforactor_token_type: an actor presents a bearer credential(
access_token/jwt), not an identity assertion.validateExchangeParamshelper (the old,actor_token-rejecting parameter validator) in favor of the newvalidateFormParams/resolveActorIdentitysplit.docs/arch/token-delegation-act-chain.md, which stilldescribed RFC 8693's chained-
act-claim nesting as "not implemented"— that landed separately in Add a consent model for external OIDC subject tokens #6149 before this branch was rebased;
the doc had gone stale, not the behavior.
actor_tokencomposingcorrectly with the configured-delegate-client relaxation (a
mismatched
actor_tokenis still rejected during actor-identityresolution, before delegation consent is ever reached); the asserted
actor flowing into
act.subwhile the issued token's ownclient_idstill names the authenticated client; and the client_id-binding
bypass regression above.
What this enables: a client can present a second, independently-issued
token that both proves it was minted for that client and names a more
specific actor (e.g. a particular agent instance) than the client's own
ID — without that asserted actor ever being able to influence which
subject tokens the client is allowed to exchange.
No new server configuration is introduced —
actor_token/actor_token_typeare request-time form parameters at
/oauth/token, notRunConfig/CRDfields. Example request, assuming a confidential client already
registered for the token-exchange grant:
The delegated access token's
actclaim carries the asserted actoridentity; its own
client_idstill names the authenticated client:Fixes #5815
Type of change
Test plan
task test)task lint-fix)API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Does this introduce a user-facing change?
Yes: clients performing RFC 8693 token exchange against the embedded
authorization server can now supply
actor_token/actor_token_type(previously always rejected) to assert a specific actor identity distinct
from their own client ID.
id_tokenremains rejected as asubject_token_type/actor_token_typevalue. No existing request shapechanges behavior.