Skip to content

Add exchange refresh token client config - #6989

Merged
jhrozek merged 3 commits into
mainfrom
exchange-client-config-restack
Oct 29, 2026
Merged

jhrozek merged 3 commits into
mainfrom
exchange-client-config-restack

Conversation

@jhrozek

@jhrozek jhrozek commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #6988 (token-exchange source policy).

A delegate client that exchanges a login token for a refresh token gets a credential that outlives the login. Without per-client limits, one leaked token can become long-term access. This PR adds the operator-facing configuration that will bound those refresh tokens: an exchange_refresh_tokens block under token_exchange, with one lifetime cap per grant kind (id_jag_max_lifetime, refresh_token_max_lifetime).

What changed:

  • New exchange_refresh_tokens block with two duration caps. Each cap must be positive and at most 720h, and at least one must be set.
  • Validation requires api_audience, self_issued_subject_tokens: restricted, a confidential client, and the token-exchange grant. An empty cap refuses that grant kind.
  • The caps are projected into ConfiguredClientPolicy, keyed by token type. Nothing reads the projection yet.
  • Swagger and SDK regenerated.

Threat: reporting-svc (confidential, aud=crm.example) exchanges alice's login token; the access token AT1 leaks from a log line. Without the source policy, mallory-cli can exchange AT1 for a payroll.example token. Once refresh tokens can be minted through exchange, the same leak becomes a long-lived refresh token. The caps bound that window. Enforcement is in later PRs in this stack.

Type of change

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

Test plan

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

Ran go vet on pkg/authserver, server/..., runner/... and storage/...: passes. Local builds were blocked by a full disk, so the test suite has not been run on this branch. Config validation has table tests in registration/client_test.go and config_test.go from the parent commits.

API Compatibility

  • This PR does not break the v1beta1 API

Does this introduce a user-facing change?

Yes, additively. RunConfig and the auth server config accept a new optional token_exchange.exchange_refresh_tokens block. Before: the field is rejected as unknown. After: the field is accepted and validated, and has no effect on token issuance yet.

Special notes for reviewers

  • Inert in this PR. Nothing enforces the caps and no refresh tokens are issued here. The enforcing commits are later in the stack. Please review the validation and the config shape, not runtime behaviour.
  • Deviation from the ID-JAG draft. draft-ietf-oauth-identity-assertion-authz-grant-04 §4.3.4 and §4.4.3 say the exchange response SHOULD NOT carry a refresh token. This design does so deliberately, for user-delegated access. The later commits are where that deviation is exercised.
  • 720h is a local ceiling. No specification sets it.
  • Known gaps, fixed later in the stack: caps not enforced at issuance; a per-token cap alone does not bound a refresh chain (refresh tokens do not rotate and child grants cannot outlive their parent); an ID-JAG minted from a refresh token must respect the parent's expiry.
  • Not done here: docs/arch/17 has no subsection for this block yet, and DelegateClientTokenExchange.Clone() still shares the new pointer between copies. Both are planned before merge.

🤖 Generated with Claude Code

@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Oct 29, 2026
@jhrozek
jhrozek marked this pull request as ready for review October 29, 2026 09:27
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.38462% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.29%. Comparing base (22276ab) to head (cc7f5fb).

Files with missing lines Patch % Lines
pkg/authserver/server_impl.go 87.50% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6989      +/-   ##
==========================================
- Coverage   80.34%   80.29%   -0.05%     
==========================================
  Files         848      848              
  Lines       86420    86481      +61     
==========================================
+ Hits        69433    69444      +11     
- Misses      16982    17032      +50     
  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.

@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 · correctness — DelegateClientTokenExchange.Clone shares the new ExchangeRefreshTokens pointer (pkg/authserver/config.go)

    Clone promises a copy that shares nothing with the original, but the new *DelegateClientExchangeRefreshTokens is copied by pointer. Changing a cloned config would also change the source. The author knows, but it is not fixed in this diff.

jhrozek and others added 3 commits October 29, 2026 21:23
A delegate client that exchanges a login token for a refresh token gets
a credential that outlives the login. Without per-client limits, one
leaked token can become long-term access, and if the server accepts
any self-issued subject token, one client can spend another client's
token.

Threat this closes. reporting-svc is a confidential client whose access
token AT1 (5 minutes, aud=crm.example) is issued for alice. AT1 leaks
from a log line. Without the source policy in the parent commit,
mallory-cli, another confidential client, exchanges AT1 for a token
with aud=payroll.example. If exchange can also mint refresh tokens,
the same leak becomes a refresh token whose life is limited only by
the server default, and it keeps renewing while it is used.

This commit adds the configuration that lets an operator decide, for
each client, whether exchange may issue refresh tokens and how long
they may live:

    token_exchange:
      self_issued_subject_tokens: restricted
      api_audience: https://crm.example
      exchange_refresh_tokens:
        id_jag_max_lifetime: 5m
        refresh_token_max_lifetime: 720h

Validation requires at least one cap, each positive and at most 720h,
api_audience set, restricted subject tokens, and a confidential client
with the token-exchange grant. An empty cap refuses that grant kind.
The 720h ceiling is a local choice; no specification sets it. The caps
are projected into ConfiguredClientPolicy keyed by token type. Nothing
reads the projection yet, so this commit changes no behaviour.

The source check reads the client_id of the subject token, which names
the client that obtained it. Only that client, or a listed source
client, may exchange it. The check does not compare sub with iss. For
a login token, sub is the user and iss is the server, so requiring them
to match would reject every valid login token.

Still open after this commit. The later commits in this stack close
these:

- Caps are not enforced when a token is issued. The commit that adds
  refresh tokens through token exchange enforces them.
- A per-token cap alone does not bound a chain: each refresh could get
  a new full window. Refresh tokens do not rotate, and a child grant
  expires no later than its parent and cannot be a parent itself. The
  root's expiry, set once at issuance, becomes the bound.
- An ID-JAG minted from a refresh token must also respect the parent's
  expiry. The commit that derives child grants enforces this.

Co-authored-by: Mecatl <noreply@mecatl.dev>
The new config tests say "unparseable", which codespell rejects. Use
"unparsable".

The exchange_refresh_tokens block adds one schema to the OpenAPI
document, so the deliberate SDK schema count moves from 204 to 205.

Co-authored-by: Mecatl <noreply@mecatl.dev>
DelegateClientTokenExchange.Clone copied the ExchangeRefreshTokens
pointer, so a change to a cloned policy also changed the original.
Clone now copies the block, and its comment says the clone shares no
slices or pointers with the original.

Nothing writes to the block after cloning today, so no behaviour
changes.

Co-authored-by: Mecatl <noreply@mecatl.dev>
@jhrozek
jhrozek force-pushed the exchange-client-config-restack branch from de1d0da to cc7f5fb Compare October 29, 2026 20:53
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Oct 29, 2026
@jhrozek

jhrozek commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

@ChrisJBurns good catch, thanks. Clone copied the ExchangeRefreshTokens pointer, so a cloned policy shared it with the original. Nothing writes to it after cloning today, so it wasn't biting yet, but it would have later. Fixed in cc7f5fb: Clone now copies the block, and there's a test that fails without the fix.

@jhrozek
jhrozek merged commit 4798433 into main Oct 29, 2026
77 of 78 checks passed
@jhrozek
jhrozek deleted the exchange-client-config-restack branch October 29, 2026 22:35
@github-actions github-actions Bot mentioned this pull request Nov 2, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants