Skip to content

Support actor_token and id_token in RFC 8693 token exchange - #6331

Open
jhrozek wants to merge 3 commits into
stacklok:mainfrom
jhrozek:token-delegation-4-actor-token
Open

Support actor_token and id_token in RFC 8693 token exchange#6331
jhrozek wants to merge 3 commits into
stacklok:mainfrom
jhrozek:token-delegation-4-actor-token

Conversation

@jhrozek

@jhrozek jhrozek commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 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 Add a consent model for external OIDC subject tokens #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" }

Fixes #5815

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label 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), and id_token is now an accepted
subject_token_type value in addition to access_token/jwt. No
existing request shape changes behavior.

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
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.87500% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.96%. Comparing base (cfba580) to head (162e8a6).

Files with missing lines Patch % Lines
pkg/authserver/server/tokenexchange/handler.go 96.82% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6331      +/-   ##
==========================================
+ Coverage   72.94%   72.96%   +0.01%     
==========================================
  Files         742      742              
  Lines       78236    78270      +34     
==========================================
+ Hits        57070    57106      +36     
+ Misses      17188    17182       -6     
- Partials     3978     3982       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support explicit actor_token and id_token subject type in token exchange

1 participant