Skip to content

Add private_key_jwt DCR client authentication - #6427

Merged
jhrozek merged 19 commits into
mainfrom
dcr-private-key-jwt
Aug 26, 2026
Merged

jhrozek merged 19 commits into
mainfrom
dcr-private-key-jwt

Conversation

@jhrozek

@jhrozek jhrozek commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Delegate clients currently authenticate to the embedded OAuth authorization server either with none (public, PKCE-only) or with a client_secret ToolHive mints and hands back — meaning a shared secret has to be received and stored by the calling agent. This adds RFC 7523 §2.2 private_key_jwt as a third, independently-gated Dynamic Client Registration (RFC 7591) auth method: the client generates its own keypair, registers with the public half, and thereafter authenticates by signing a client_assertion JWT with the private key it never has to transmit or store server-side. This is unrelated to the RFC 7523 §2.1 JWT-bearer grant already supported (#6391) — that's a different mechanism (a JWT used directly as the grant) on a different code path.

What changed:

  • New AllowPrivateKeyJWTRegistration flag threaded through the CRD (EmbeddedAuthServerConfig) → RunConfig/Config → AuthorizationServerConfig, independent of AllowConfidentialClientRegistration, with its own transport guard rejecting the combination with cleartext HTTP.
  • DCR validation for private_key_jwt: requires a non-empty inline JWKS (no jwks_uri — see Special notes) with a valid public signing key whose algorithm matches the declared token_endpoint_auth_signing_alg; grant types pinned to exactly the token-exchange grant.
  • Client construction, Redis persistence (public-key-only), and discovery advertisement (token_endpoint_auth_methods_supported, token_endpoint_auth_signing_alg_values_supported) for the new method.
  • A single source of truth for the accepted signing-algorithm allowlist (crypto.SupportedClientKeyAlgorithms), replacing a hand-maintained copy that had already drifted and silently excluded Ed25519/EdDSA.
  • Docs (docs/arch/11-auth-server-storage.md, docs/arch/17-token-exchange-delegation.md) and an operator-level e2e test against a real kind cluster.

Related: #6425 (not fixed here — see Special notes).

Type of change

  • New feature
  • Bug fix
  • 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)

Manually verified during review: go build ./pkg/authserver/... and go test for the crypto, registration, and handlers packages (including new EdDSA regression tests). CI is expected to run the full task test/task lint-fix/task test-e2e suite, including the kind-cluster e2e test added in this branch (virtualmcp_private_key_jwt_test.go), which was passing as of when it was added but has not been re-run locally after the later fixup commits.

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.

AllowPrivateKeyJWTRegistration is a new, optional, default-false field on EmbeddedAuthServerConfig — purely additive.

Changes

File Change
cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go New AllowPrivateKeyJWTRegistration CRD field + CEL validation
cmd/thv-operator/pkg/controllerutil/authserver.go Wire the flag through to RunConfig
pkg/authserver/config.go RunConfig/Config fields + ValidatePrivateKeyJWTRegistrationTransport
pkg/authserver/server/provider.go Thread the flag into AuthorizationServerConfig
pkg/authserver/server/registration/dcr.go Core DCR validation for private_key_jwt; signing-algorithm allowlist
pkg/authserver/server/registration/client.go privateKeyJWTClient construction
pkg/authserver/server/handlers/dcr.go Wire validation into the registration handler; echo JWKS in the response
pkg/authserver/server/handlers/discovery.go Advertise the method + signing algorithms
pkg/authserver/server/crypto/keys.go SupportedClientKeyAlgorithms (single source of truth), nil-signer fix
pkg/authserver/storage/redis.go Persist client JWKS (public-key-only)
pkg/oauthproto/{constants,dcr,discovery}.go Wire types: new auth method constant, JWKS fields, discovery field
docs/arch/{11-auth-server-storage,17-token-exchange-delegation}.md Storage schema + registration/delegation flow docs
test/e2e/thv-operator/virtualmcp/virtualmcp_private_key_jwt_test.go Kind-cluster e2e: register → delegated token exchange
*_test.go (throughout) Unit + integration coverage, including new EdDSA regression tests

Does this introduce a user-facing change?

Yes. Operators can opt a VirtualMCPServer's EmbeddedAuthServerConfig into allowPrivateKeyJWTRegistration: true, letting delegate clients register and authenticate via private_key_jwt instead of a ToolHive-issued client_secret. Off by default; no behavior change for existing configurations.

Special notes for reviewers

  • jwks_uri is deliberately not supported — only inline jwks. An unauthenticated registration endpoint that makes the AS fetch an attacker-controlled URL is an SSRF surface; nothing in this feature needs it.
  • Gate inbound DCR (/oauth/register) behind an initial access token #6425 (filed during review): RegisterClientHandler//oauth/register has no registration-time authentication gate at all (predates this branch, Add POST /oauth/register handler for dynamic client registration #3428) — AllowPrivateKeyJWTRegistration/AllowConfidentialClientRegistration control what a client can register as, not who may register. Not specific to private_key_jwt, not a blocker for this PR, but worth reviewers' awareness.
  • Discovery/registration coupling (reviewed, deliberately deferred): token_endpoint_auth_signing_alg_values_supported is currently gated purely by AllowPrivateKeyJWTRegistration, with no equivalent to HasStaticDelegateClients's OR-clause for a future statically-provisioned private_key_jwt client path (e.g. SPIFFE-issued). Nothing breaks today; revisit when static/SPIFFE-provisioned private_key_jwt clients become possible without DCR.
  • EdDSA fix included: found during review that the signing-algorithm allowlist had drifted and silently excluded Ed25519, since no test exercised it. Fixed by exporting a single source of truth (crypto.SupportedClientKeyAlgorithms) instead of a hand-maintained copy, with regression tests at both layers. Note: this closes the gap at the DCR-validation and discovery layers; an end-to-end EdDSA token-exchange test (proving fosite's assertion verification actually accepts an EdDSA-signed client_assertion) was not added and would be good follow-up scrutiny.
  • Client-submitted JWKS at registration time was researched against RFC 7591/7523 and current practice (Okta/Auth0/Keycloak/Entra) during review — this is the spec-intended model (the AS never generates a client's signing keypair), not equivalent to a client supplying its own client_secret.

Generated with Claude Code

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Aug 25, 2026
@codecov

codecov Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.69072% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.84%. Comparing base (7c9c55e) to head (371ce70).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/authserver/server/registration/dcr.go 88.60% 9 Missing ⚠️
pkg/authserver/server/crypto/keys.go 71.42% 6 Missing ⚠️
pkg/authserver/storage/redis.go 88.46% 3 Missing ⚠️
pkg/authserver/storage/memory.go 80.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6427      +/-   ##
==========================================
+ Coverage   77.81%   77.84%   +0.03%     
==========================================
  Files         760      761       +1     
  Lines       73133    73317     +184     
==========================================
+ Hits        56908    57076     +168     
- Misses      16220    16236      +16     
  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 Aug 25, 2026

@samuv samuv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

This PR adds opt-in private_key_jwt dynamic client registration with CRD/runtime plumbing, persistence, discovery metadata, documentation, and end-to-end coverage. The overall wiring is coherent, but the current implementation exposes client authentication with two blocking security problems: replay consumption is not atomic, and assertion exp/jti are unbounded so a registered caller can grow replay state indefinitely. It also advertises EdDSA even though the pinned Fosite runtime rejects EdDSA client assertions. Requesting changes before merge.

Checklist

  • Tests: Broad unit/integration/E2E coverage and CI is green, but missing end-to-end EdDSA, concurrent replay, assertion-bound, mislabeled-key, and weak-RSA cases.
  • Docs: Extensive, but currently overclaim EdDSA support and contradict the loopback override field contract.
  • Registry impact: None.
  • Security: Blocking replay and storage-growth issues; RSA key-strength validation is also missing.
  • Backwards compatibility: Additive and default-off; no existing-client compatibility regression identified.

Comment thread pkg/authserver/server/crypto/keys.go Outdated
Comment thread pkg/authserver/integration_test.go
Comment thread pkg/authserver/server_impl.go
Comment thread pkg/authserver/server/registration/dcr.go Outdated
Comment thread pkg/authserver/server/crypto/keys.go
Comment thread cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go Outdated
Comment thread test/e2e/thv-operator/virtualmcp/virtualmcp_private_key_jwt_test.go Outdated
jhrozek added 16 commits August 25, 2026 23:26
Thread a new AllowPrivateKeyJWTRegistration flag through the CRD
(EmbeddedAuthServerConfig), RunConfig/Config, and
AuthorizationServerConfig, and add ValidatePrivateKeyJWTRegistrationTransport
to reject combining it with insecureAllowHTTP or a plain-HTTP issuer.

private_key_jwt DCR needs its own on/off switch, kept independent of
AllowConfidentialClientRegistration: it authenticates clients by key
possession rather than a shared secret, so it carries a different
threat model (no secret to leak, but also none to revoke). This
commit only carries the capability through the config pipeline — the
flag is inert until the next commit teaches DCR validation to act
on it.
Teach ValidateDCRRequest to accept token_endpoint_auth_method=
private_key_jwt when the new flag is set: require a non-empty inline
JWKS containing a valid public signing key whose algorithm matches
the declared token_endpoint_auth_signing_alg, reject jwks_uri, and
pin grant_types to exactly the token-exchange grant for these
clients.

RFC 7523 only defines how a client asserts its identity at the token
endpoint once registered — DCR still has to decide what registration
metadata is acceptable. Without these checks, a client could register
a key/algorithm pair it doesn't actually hold, or metadata that would
only surface as a confusing failure later at token-exchange time
instead of at registration. Grant types are pinned because
private_key_jwt registrations exist here for the delegation/
token-exchange flow, not authorization-code; accepting other grants
would register clients for capabilities nothing downstream wires up.
Extend buildDCRClient and registration.New to construct a fosite
client carrying the validated JWKS and signing algorithm instead of a
generated client secret, via a new privateKeyJWTClient type, and echo
jwks/jwks_uri/token_endpoint_auth_signing_alg back in the DCR
response.

Validation alone doesn't make a private_key_jwt client usable:
fosite's client-authentication strategy needs the key material
attached to the client record it looks up at the token endpoint, and
RFC 7591 §3.2.1 requires the registration response to reflect the
metadata the server actually stored so the caller can confirm what
got registered.
Add JSONWebKeys/TokenEndpointAuthSigningAlgorithm fields to the
Redis-backed storedClient, stripping to public-only key material via
a new publicJSONWebKeySet helper before writing.

A private_key_jwt client's key material has to survive a server
restart or a Redis-backed multi-replica deployment the same way
client_secret_* clients already do, or every registered client would
stop working the moment the process serving it restarted. Stripping
to public keys before persisting matters because a caller could
mistakenly submit a JWK with a private half; storage should never
retain private key material it has no business holding, even
transiently.
Add token_endpoint_auth_signing_alg_values_supported to the discovery
document, advertise private_key_jwt in
token_endpoint_auth_methods_supported when the flag is set, and
factor the signing-algorithm allowlist out into an exported
SupportedSigningAlgorithms so DCR validation and discovery share one
list.

RFC 8414 clients choose an auth method from what discovery
advertises, so leaving private_key_jwt out of the metadata would mean
a client could register successfully but have no spec-compliant way
to discover that it's supported. Sharing the algorithm allowlist with
validation closes a gap where discovery could claim support for an
algorithm registration would then reject — exactly the kind of
self-inconsistency a client can't work around on its own.
Wire allowPrivateKeyJWTRegistration through the integration test
harness, tighten ValidatePrivateKeyJWTRegistrationTransport to only
exempt a genuine loopback issuer when
InsecureAllowConfidentialOverLoopbackHTTP is set (matching the
confidential-client transport check it was modeled on), and add
integration coverage that registers a private_key_jwt client and
carries it through the RFC 8693 token-exchange grant end to end.

The transport guard's original plain-HTTP check didn't distinguish a
genuine local loopback issuer from any other HTTP issuer, so the
in-process test server had no legitimate way to opt into the
combination it needs — the same problem
AllowConfidentialClientRegistration already solved with the same
flag. Reusing it instead of inventing a parallel opt-in keeps both
registration paths' "is this issuer trustworthy enough for plaintext"
answer consistent. The integration test then closes the real gap:
everything so far validated registration and storage in isolation,
but nothing had proven a registered private_key_jwt client could sign
an assertion and actually receive a token.
Add an e2e test that registers a client via private_key_jwt and
performs the delegated token exchange through a real
VirtualMCPServer/EmbeddedAuthServerConfig deployment on a kind
cluster.

The integration test in the previous commit proves the auth-server
logic in isolation, but the feature is only reachable in production
through the operator's CRD wiring (applySimpleAuthServerConfigFields,
admission CEL rules), which unit and integration tests never
exercise. Per this repo's standing convention, a feature that only
reaches users through the operator isn't verified until it's proven
through a real cluster and real CRDs, not just RunConfig
construction.
Document the private_key_jwt storage schema in
docs/arch/11-auth-server-storage.md, and the registration/
token-exchange flow — including the RFC 7523 §2.2 vs §2.1
distinction — in docs/arch/17-token-exchange-delegation.md.

This is the second client-authentication method and the first
key-based one the storage layer and delegation flow support; nothing
in the existing architecture docs explained how a client
authenticates without a secret or how that interacts with delegation.
The RFC 7523 §2.1/§2.2 distinction is called out specifically because
both the already-documented JWT-bearer grant and this feature involve
a JWT and cite RFC 7523, and someone skimming the code without that
distinction spelled out could plausibly conflate two unrelated
mechanisms.
Registration validation had two gaps: DCR requests for other auth
methods could echo back and retain attacker-supplied JWKS/JWKS
URI/signing-alg fields even though those fields are meaningless
outside private_key_jwt, and several edge cases around grant types,
redirect URIs, and JWK content weren't rejected. Zero the key fields
unless the client actually registers with private_key_jwt, tighten
the remaining validation, and drop the now-unused duplicate cloneJWKS
helper in favor of cloneJSONWebKeySet.

Also document the use:"sig" requirement on registered JWKs, which
callers would otherwise only discover via a rejection error.
Add ValidateAlgorithmForPublicKey and rsaAlgorithmsForClientKeys, and
fix a nil-signer panic in the pre-existing ValidateAlgorithmForKey by
extracting both into a shared validateAlgorithmForPublicKeyValue
parameterized on which RSA algorithms each caller accepts.

Registered private_key_jwt clients submit an RSA/EC/Ed25519 public
key plus a claimed signing algorithm; nothing was checking that the
two are actually compatible (e.g. an EC key claiming RS256), which
would otherwise only surface as an opaque signature-verification
failure at token-exchange time instead of a clear rejection at
registration. Extracting the shared EC/Ed25519 logic — rather than
duplicating it a second time for client keys — also exposed that the
existing signing-key validator dereferenced a typed-nil
crypto.Signer before checking it, which is now guarded explicitly
per key type instead of relying on a comment that no longer matched
what the code actually did.
registration.SupportedSigningAlgorithms() hand-listed RS/PS/ES
algorithms separately from crypto.rsaAlgorithmsForClientKeys, which
validateAlgorithmForPublicKeyValue already used to check key/algorithm
compatibility. The two lists had to be kept in sync by hand, and had
already drifted: the hand-listed copy was missing EdDSA entirely,
silently rejecting every Ed25519 private_key_jwt registration at the
token_endpoint_auth_signing_alg allowlist check, even though
DeriveAlgorithm, ValidateAlgorithmForKey, and
validateAlgorithmForPublicKeyValue all fully support Ed25519 keys.
Nothing caught this because no test exercised Ed25519/EdDSA
registration at all.

Export crypto.SupportedClientKeyAlgorithms as the single source of
truth (RSA algorithms plus the three EC algorithms and EdDSA), and
have registration.SupportedSigningAlgorithms delegate to it instead
of maintaining a parallel copy. Switch the two RSA-algorithm allowlists
from map[string]bool to []string plus slices.Contains, since a
deterministic order is required now that one of them is echoed
directly in the discovery document's
token_endpoint_auth_signing_alg_values_supported.

Add regression tests at both layers: crypto asserts EdDSA is present
in the shared list, registration asserts an Ed25519 key actually
registers successfully end-to-end.
Adding AllowPrivateKeyJWTRegistration to RunConfig required
regenerating the checked-in swagger docs (docs/server/{docs.go,
swagger.json,swagger.yaml}); the CI docs-verification job caught the
omission.
Review found three gaps in private_key_jwt key validation:

- SupportedClientKeyAlgorithms advertised and accepted EdDSA, but
  fosite v0.49.0's client-assertion verification (client_authentication.go)
  only switches on the RS*/ES*/PS*/HS* families. An EdDSA client could
  register successfully and then never be able to authenticate.
  Excluded it, with regression tests locking in the exclusion and
  matching doc updates.
- validatePrivateKeyJWTKey returned early once a JWK's own alg label
  matched the requested algorithm, without checking that the label
  actually matched the key's type/curve. An EC key mislabeled "RS256"
  passed registration despite never being able to sign or verify with
  it. Now always runs ValidateAlgorithmForPublicKey's structural check.
- The RSA branch of validateAlgorithmForPublicKeyValue checked
  algorithm compatibility but never modulus size or exponent, despite
  MinRSAKeyBits already being defined for the server's own signing
  key. Existing tests even registered a toy N=3,E=3 key. Now rejects
  keys below MinRSAKeyBits and non-odd/degenerate exponents; toy-key
  fixtures replaced with real 2048-bit generated keys.
Two related review findings on the private_key_jwt client-assertion
replay path:

- fosite calls Storage.ClientAssertionJWTValid (check) and
  SetClientAssertionJWT (record) as two separate operations, and both
  backends only overwrote the replay marker on the second call. Two
  concurrent requests presenting the same assertion could both pass
  the check before either recorded it. ClientAssertionJWTValid is now
  the atomic checkpoint (a write-lock check-and-insert in memory,
  Redis SET NX), reserving the jti for a short fixed window;
  SetClientAssertionJWT unconditionally extends that reservation to
  the assertion's real expiry, since only the caller that won the
  reservation reaches it. Added a 20-goroutine concurrent test per
  backend proving exactly one validator wins.
- A registered client could submit an assertion with an arbitrary
  future exp and an arbitrarily long jti; fosite persists the replay
  marker keyed on both before the rest of validation completes.
  Redis's TTL is computed from that client-controlled exp, so a
  client could set exp decades out and get an effectively permanent
  key; memory's lazy sweep never reaches an entry that never expires.
  Added MaxAssertionLifespan (5 minutes) and MaxAssertionJTILength
  (256 bytes), enforced in both backends before persisting.

ConsumeAssertionJWT, the unrelated RFC 8693 actor-token replay path,
is deliberately left untouched.
Both the DCR registration and token-exchange requests used
http.DefaultClient with a background context, so a stalled
port-forward or half-open connection could hang the suite
indefinitely. Share a 30s-timeout http.Client between them, matching
the convention already used elsewhere in this test directory.
Review flagged that this PR's ValidatePrivateKeyJWTRegistrationTransport
reused InsecureAllowConfidentialOverLoopbackHTTP, a flag documented as
having no effect without confidential-client registration, silently
breaking that contract. The suggested fixes were either to document
the reuse or give private_key_jwt its own loopback flag.

Both fixes assumed the underlying gate was justified. It isn't: unlike
confidential registration, which mints and returns a client_secret,
private_key_jwt registration never returns anything secret — the
client submits its own public key and gets back a client_id and the
same JWKS it sent. There is nothing here for cleartext HTTP to
expose. ToolHive's own DCR validation already treats private_key_jwt
at the public-client tier (validateAuthMethod applies no HTTPS or
redirect-URI restriction to it, same as "none"); the transport gate
was the only place it got confidential-grade treatment, copied from
the confidential-client check without re-deriving whether the threat
model transfers. RFC 7591 §5's blanket TLS requirement applies
uniformly regardless of auth method, so nothing in the spec singles
out private_key_jwt for stricter treatment than "none" either.

Removes ValidatePrivateKeyJWTRegistrationTransport, its two call
sites, and the CRD field/CEL rule/flag this PR had added to work
around it (InsecureAllowPrivateKeyJWTOverLoopbackHTTP). private_key_jwt
registration is now governed only by the issuer's general
insecureAllowHTTP policy, the same as any public client.
The rebase onto origin/main conflicted on files main had independently
evolved (delegate-client loopback CEL rules, malformed-issuer error
handling). The generated artifacts were resolved by taking one side
during conflict resolution as a placeholder; this regenerates them
from the merged source so they match it exactly.
@jhrozek
jhrozek force-pushed the dcr-private-key-jwt branch from 41929e4 to 0235071 Compare August 25, 2026 21:38
@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 Aug 25, 2026
Both TestMemoryStorage_ClientAssertionJWT_ConcurrentReplay and
TestRedisStorage_ClientAssertionJWT_ConcurrentReplay called
t.Parallel() themselves and then passed t into withStorage/
withRedisStorage, which also call t.Parallel() internally — every
other top-level test using these helpers directly relies on the
helper for this and doesn't call it a second time. The double call
panics ("t.Parallel called multiple times"), which aborted the whole
storage package's test binary in CI.
@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 Aug 26, 2026
TestValidateConfidentialClientTransport's loop asserted every error
contains "cleartext HTTP", which was true before upstream's malformed-
issuer cases landed (#6426) — those fail with "confidential clients
require a valid issuer URL" instead, from a different validation
branch. The rebase merged in the new cases without updating this
assertion. Match upstream's own fix: assert on "confidential clients",
the substring actually common to every error path in
ValidateConfidentialClientTransport.
@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 Aug 26, 2026

@samuv samuv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

Follow-up on the previous request-changes review. The blocking items are addressed: EdDSA is no longer advertised, client-assertion replay consumption is atomic on both backends, assertion exp/jti are bounded before persist, mislabeled keys and weak RSA material are rejected at DCR, and the e2e HTTP calls now have a timeout.

Removing ValidatePrivateKeyJWTRegistrationTransport is a reasonable resolution of the loopback-flag contract issue — private_key_jwt DCR never returns a secret, so treating it like a public client under the issuer's general insecureAllowHTTP policy is consistent. Approving on that basis.

Non-blocking leftover: rejection tests for MaxAssertionLifespan / MaxAssertionJTILength (and dedicated weak-RSA negative cases) would still be useful follow-up so those checks cannot regress silently.

@jhrozek
jhrozek merged commit c0285d3 into main Aug 26, 2026
49 checks passed
@jhrozek
jhrozek deleted the dcr-private-key-jwt branch August 26, 2026 09:30
@github-actions github-actions Bot mentioned this pull request Aug 26, 2026
2 tasks
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