Skip to content

Limit delegate token exchange to source clients - #6988

Merged
jhrozek merged 3 commits into
mainfrom
stable-client-v2-e
Oct 29, 2026
Merged

jhrozek merged 3 commits into
mainfrom
stable-client-v2-e

Conversation

@jhrozek

@jhrozek jhrozek commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Why: A configured delegate client may exchange any self-issued token the server has issued, including tokens issued to unrelated clients. Those tokens are bearer credentials. A token that leaks from one client, or that a malicious dynamically registered client collects through normal consent, can be laundered through any delegate into that delegate's own downstream access. RFC 8693 leaves exchange policy to the authorization server, and RFC 8707 audience limits do not stop this when both clients share an audience, so the server has to bind the source itself.

What:

  • Adds an optional token_exchange block per delegate client with self_issued_subject_tokens (any or restricted), allowed_source_clients and api_audience.
  • restricted limits the delegate to subject tokens issued to itself or to an allowed source client, and requires any may_act to match.
  • allowed_source_clients and api_audience are valid only with restricted. A configured delegate cannot be listed as a source client.
  • The default stays any, so delegates without a block keep today's behaviour and the startup WARN. An explicit any also logs a WARN that names the client.
  • may_act does not replace this check. The server never writes may_act into its own tokens, and when one is present the handler skips the client_id binding, so it cannot narrow access on the self-issued path.

Flow: how the laundering works today

 user        client B (any client)      AS                       delegate A (configured, any)     API
  |                |                      |                              |                            |
  |--login/consent>|---authorize--------->|                              |                            |
  |                |<---token T (client_id=B, sub=user)-------------------|                            |
  |                |                      |                              |                            |
  |                |----- T handed over (leak, compromised B, ---------->|                            |
  |                |       colluding or malicious DCR client)            |                            |
  |                |                      |<--POST /token: token-exchange,                            |
  |                |                      |   subject_token=T, auth=A    |                            |
  |                |                      | checkSourcePolicy: any -> pass                            |
  |                |                      | checkDelegationConsent: no may_act,                       |
  |                |                      |   A is a configured delegate ->                           |
  |                |                      |   client_id check skipped                                 |
  |                |                      |---token D (sub=user, client_id=A, act.sub=A)------------->|
  |                |                      |                              |---D (as the user)--------->|

Flow: the same request with restricted and an empty source list

 client B              AS                                 delegate A
    |                    |                                     |
    |  T (client_id=B)                                         |
    |------------------------------------------------------->  |
    |                    |<--POST /token (subject_token=T)------|
    |                    | checkSourcePolicy:                   |
    |                    |   B != A and B not in []             |
    |                    |   -> invalid_grant                   |
    |                    |---X refused                          |

Threats this closes and does not close

Threat Closed by restricted?
A delegate reuses a token issued to an unrelated client B Yes
A malicious DCR client gets user consent, then passes the token to a colluding delegate Yes, if the delegate is restricted
A token leaks from B's logs or storage and is exchanged by A Partly: limits who can exchange, not who can steal
api_audience widens the audiences a delegate can reach Not addressed; needs review
Forged may_act on a self-issued token Not reachable: the server does not mint may_act

Builds on #6953, which has merged. This PR targets main.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Manual testing, run directly with go rather than through task:

  • go vet ./pkg/authserver/...: clean.
  • go test -ldflags=-extldflags=-Wl,-w ./pkg/authserver/...: all packages pass, including the new handler_source_policy_test.go and delegate_client_trust_mode_test.go.
  • go test on ./cmd/thv-operator/..., excluding test-integration: all packages pass. The envtest suites under test-integration were not run.
  • Not run: task test, task lint-fix, and E2E.

Changes

Area Change
pkg/authserver/config.go, inbound_grants.go token_exchange block, validation, grant_types rule
pkg/authserver/server/tokenexchange/handler.go, factory.go checkSourcePolicy, per-client policy wiring
pkg/authserver/server_impl.go Build and register split; startup WARN moved to warnBlanketSelfIssuedExchange
pkg/authserver/server/registration/configured_client.go Delegate client trust-mode plumbing
docs/arch/11, docs/arch/17 Behaviour and config reference
docs/server/*, sdk/go/* Regenerated OpenAPI and SDK output
Tests New source-policy, trust-mode and integration tests

Does this introduce a user-facing change?

Before: a delegate client could exchange any self-issued token. Its config had no token_exchange block, and a client that set grant_types was accepted without one.

After:

  • A delegate with no token_exchange block behaves as before: any, with the startup WARN.
  • A delegate can set self_issued_subject_tokens: restricted with allowed_source_clients and api_audience. It can then exchange only tokens issued to itself or to a listed source client.
  • Breaking for existing configs: a delegate that sets grant_types without a token_exchange block is now rejected at config validation. It must declare the block and an explicit mode (any or restricted).
  • An explicit any now logs a WARN that names the client.

Special notes for reviewers

  • Check ordering. checkSourcePolicy runs before checkDelegationConsent. In checkDelegationConsent the may_act case comes first and skips the client_id check. That is why restricted must check client_id in checkSourcePolicy, not rely on may_act.
  • Open risk 1: a stolen bearer token can still be exchanged by a delegate on its source list. Sender-constraining subject tokens (DPoP or mTLS binding) is the planned follow-up.
  • Open risk 2: api_audience can be granted to a delegate without subject-token coverage, for self-issued tokens only. This is a deliberate operator-chosen exception. It deserves a close look, since it allows a token to reach an audience the subject token never covered.
  • Spec basis: RFC 8693 §2.1 leaves exchange policy to the authorization server, so this is a server-side control and not something the spec requires.

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 29, 2026
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.29%. Comparing base (d321ebd) to head (9414c5f).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6988      +/-   ##
==========================================
+ Coverage   80.26%   80.29%   +0.02%     
==========================================
  Files         848      848              
  Lines       86308    86420     +112     
==========================================
+ Hits        69274    69387     +113     
+ Misses      17029    17028       -1     
  Partials        5        5              

☔ 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.

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 29, 2026

@ChrisJBurns ChrisJBurns 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.

  • medium · security — api_audience guard may miss external identities laundered via jwt-bearer (ID-JAG) (pkg/authserver/server/tokenexchange/handler.go, pkg/authserver/server/tokenexchange/handler_source_policy_test.go)

    The exemption skips tokens whose external_issuer claim is set. A restricted delegate that redeems an ID-JAG mints its own token. If that token lacks external_issuer, it can be exchanged into an uncovered api_audience. No test covers this path.

  • medium · correctness — Regenerate the Go SDK client for the new policy schema (sdk/go/client/client_test.go, sdk/go/openapi.json, sdk/go/openapi.yaml)

    The SDK OpenAPI snapshots add token_exchange, but no generated client types or serializers change. SDK callers cannot express restricted policies through the typed client, and typed read-modify-write flows cannot preserve the new block.

Comment thread pkg/authserver/config.go Resolved
ChrisJBurns
ChrisJBurns previously approved these changes Oct 29, 2026
jhrozek and others added 2 commits October 29, 2026 16:15
A configured delegate client may exchange any self-issued token the
server has issued, including tokens issued to unrelated clients. Those
tokens are bearer credentials, so a token that leaks from one client, or
that a malicious dynamically registered client collects through normal
consent, can be laundered through any delegate into that delegate's own
downstream access. RFC 8693 leaves exchange policy to the authorization
server, and RFC 8707 audience limits do not stop this when both clients
share an audience, so the server has to bind the source itself.

Add an optional token_exchange block per delegate with
self_issued_subject_tokens (any or restricted). Restricted limits the
delegate to subject tokens issued to itself or to its
allowed_source_clients, and requires any may_act to match. A may_act
claim cannot replace this check: the server never writes may_act into
its own tokens, and when one is present it skips the client_id binding,
so it cannot narrow access on the self-issued path.

Examples:

- Blocked laundering: a dashboard client B logs a user in, and a broker
  delegate A holds the token-exchange grant. With restricted and no
  allowed_source_clients, A cannot exchange B's token for its own
  downstream access.
- Intended sharing: A brokers requests for a CLI client B. Listing B in
  allowed_source_clients lets A exchange B's tokens and nothing else.
- Audience grant: A is granted api_audience for a reporting API with no
  subject-token coverage. This is an explicit, operator-chosen exception
  for self-issued tokens only.

The default stays any, so existing delegates keep today's behaviour and
get the startup WARN. A client that sets grant_types must declare the
block and a mode, so the trust level is an explicit choice.

Not closed here: a stolen bearer token can still be exchanged by a
delegate on the source list, and api_audience can be granted without
subject-token coverage for self-issued tokens. Sender-constraining
subject tokens is the follow-up for the first.

Co-authored-by: Mecatl <noreply@mecatl.dev>
The new SelfIssuedSubjectTokens field tag is 137 characters and cannot
be wrapped, so it takes the same lll exemption the file already uses for
its other long tag lines.

Adding DelegateClientTokenExchange raises the OpenAPI schema count to
204, so the deliberate contract pin moves with it.

Co-authored-by: Mecatl <noreply@mecatl.dev>
@jhrozek
jhrozek force-pushed the stable-client-v2-e branch from 3a4af72 to d92d44d Compare October 29, 2026 15:18
ChrisJBurns
ChrisJBurns previously approved these changes Oct 29, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 29, 2026
The api_audience exception is documented to apply to self-issued
subject tokens only, never to ones derived from an external identity.
The check looked only for an external_issuer claim, and only the
token-exchange handler writes it. A token minted by redeeming an ID-JAG
or a JWT-bearer assertion has an external-derived subject
(issuer#subject) but no such claim, so a restricted delegate could
redeem an ID-JAG and exchange the result into its own api_audience. That
skips the operator's per-subject allowed resources and the audience the
IdP chose.

Treat a subject containing the issuer#subject separator as derived from
an external identity too. Native login subjects are user IDs or
synthesized tk- values and never contain it, and a re-exchange keeps the
subject, so the rule holds across chains. If the subject format ever
changed, the failure is safe: api_audience is refused.

The fix reads the subject, not a new claim, so token shape, Cedar inputs
and audit output are unchanged. api_audience is new in this branch, so
no released behaviour changes.

Example: a restricted broker with api_audience https://reports.api
redeems an ID-JAG for https://idp.example.com#alice and gets a token
for https://mcp.a. Exchanging that token into reports.api was allowed;
it is now refused with invalid_target, like presenting the external
token directly.

Co-authored-by: Mecatl <noreply@mecatl.dev>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 29, 2026
@jhrozek
jhrozek merged commit 22276ab into main Oct 29, 2026
47 checks passed
@jhrozek
jhrozek deleted the stable-client-v2-e branch October 29, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants