Skip to content

Support actor_token in RFC 8693 token exchange - #6331

Merged
jhrozek merged 9 commits into
stacklok:mainfrom
jhrozek:token-delegation-4-actor-token
Aug 17, 2026
Merged

Support actor_token in RFC 8693 token exchange#6331
jhrozek merged 9 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, letting the
exchange 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_token and
actor_token_type before this change ("not yet supported").

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). The binding check requires
    the actor_token's own client_id claim to equal the authenticated
    client's ID (proving the token was minted for this client); its
    sub claim is returned as the asserted actor identity, which may
    name a specific agent instance or delegate persona distinct from the
    client's own ID.
  • That asserted actor identity is used only for may_act.sub
    matching and the emitted act.sub claim. Every client-identity policy
    decision — the delegate-client allowlists (configuredDelegateClients,
    AllowedDelegateClients), and the subject token's own client_id
    binding — 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_token whose sub happens to equal that subject
    token's client_id claim — a full bypass of the "subject token was
    issued to a different client" check. A regression test
    (actor_token cannot bypass subject-token client_id binding) pins
    this.
  • id_token remains rejected as a subject_token_type value: an ID
    token's claim conventions differ from an access token's (e.g. aud
    names 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_token for
    actor_token_type: an actor presents a bearer credential
    (access_token/jwt), not an identity assertion.
  • 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 integration/unit coverage for: actor_token composing
    correctly with the configured-delegate-client relaxation (a
    mismatched actor_token is still rejected during actor-identity
    resolution, before delegation consent is ever reached); the asserted
    actor flowing into act.sub while the issued token's own client_id
    still 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_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:jwt
&actor_token=<agent's own self-issued JWT, client_id=agent-client-id, sub=agent-instance-42>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&client_id=agent-client-id
&client_secret=...

The delegated access token's act claim carries the asserted actor
identity; its own client_id still names the authenticated client:

"act": { "sub": "agent-instance-42" },
"client_id": "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) to assert a specific actor identity distinct
from their own client ID. id_token remains rejected as a
subject_token_type/actor_token_type value. No existing request shape
changes behavior.

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.05882% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.01%. Comparing base (8343851) to head (aec1464).

Files with missing lines Patch % Lines
pkg/authserver/server/tokenexchange/handler.go 96.96% 1 Missing and 1 partial ⚠️
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.
📢 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.

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/authserver/server/tokenexchange/handler.go
Comment thread pkg/authserver/server/tokenexchange/handler.go Outdated
Comment thread pkg/authserver/server/tokenexchange/handler.go Outdated
Comment thread test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_clients_test.go Outdated
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.
Comment thread pkg/authserver/server/tokenexchange/handler.go Outdated

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@jhrozek
jhrozek force-pushed the token-delegation-4-actor-token branch from 011aaf5 to aec1464 Compare August 17, 2026 15:42
@jhrozek jhrozek changed the title Support actor_token and id_token in RFC 8693 token exchange Support actor_token in RFC 8693 token exchange Aug 17, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jhrozek
jhrozek merged commit a354091 into stacklok:main Aug 17, 2026
76 of 83 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 18, 2026
2 tasks
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

2 participants