Skip to content

Allow loopback delegate clients - #6426

Merged
jhrozek merged 4 commits into
mainfrom
delegate-on-loopback
Aug 25, 2026
Merged

jhrozek merged 4 commits into
mainfrom
delegate-on-loopback

Conversation

@jhrozek

@jhrozek jhrozek commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Delegate clients previously could not use a loopback HTTP issuer, even when the explicitly named insecure loopback opt-in was enabled; this blocked local development while the runtime transport policy already supports that constrained case.
  • Allow the opt-in at CRD admission and keep the shared runtime validator as the authority that rejects non-loopback HTTP issuers.
  • Update validation coverage and generated CRD/API documentation to reflect the policy.

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)

Verified the branch diff with git diff --check origin/main...HEAD; it completed without whitespace errors.

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.

Changes

File Change
cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go Permit the explicit loopback-HTTP delegate-client opt-in at admission and clarify the validator boundary.
pkg/authserver/config.go Enforce that the loopback HTTP opt-in is used only with a loopback issuer.
cmd/thv-operator/api/v1beta1/*_test.go, pkg/authserver/config_test.go Cover the revised admission and transport-validation policy.
cmd/thv-operator/config/crd/bases/*, deploy/charts/operator/crds/* Regenerate CRD schemas.
docs/operator/crd-api.md Regenerate CRD API reference documentation.

Does this introduce a user-facing change?

Yes. Configurations with delegate clients may now use a loopback HTTP issuer only when insecureAllowConfidentialOverLoopbackHTTP is explicitly enabled; non-loopback HTTP issuers remain rejected.

Special notes for reviewers

The CEL rule deliberately admits only the explicit opt-in. The shared Go transport validator performs URL parsing and the precise loopback-host validation, which CEL cannot safely express.

Generated with Claude Code

The CEL rule on EmbeddedAuthServerConfig rejected every http:// issuer
whenever delegateClients were configured. That blocked the supported local
and Kind token-exchange path, where the embedded auth server issues on
http://127.0.0.1 and the operator has already opted in through
insecureAllowConfidentialOverLoopbackHTTP.

Replace the categorical rule with one that admits an http:// issuer only
when that opt-in is explicitly set, and leave the loopback-host check to
the shared Go validator. CEL has no URL parser, so it cannot separate
http://127.0.0.1 from http://auth.example.com without a regex that risks
admitting a non-loopback host.

Tighten ValidateConfidentialClientTransport to carry that weight. It
previously short-circuited to nil as soon as the loopback opt-in was set,
and returned nil for a plain-HTTP non-loopback issuer, deferring both to
validateIssuerURL at pod startup. It now parses the issuer once and
rejects a non-loopback plain-HTTP issuer whether or not the opt-in is set,
so the misconfiguration surfaces as a reconcile error instead of a
crashloop. Nothing that previously reconciled cleanly is newly rejected.

Drop the envtest that asserted the removed CEL message, since the same
combination is now rejected at reconcile rather than at admission.

Fixes #6423
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Aug 25, 2026
@codecov

codecov Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.78%. Comparing base (f35ce22) to head (ed18af1).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6426      +/-   ##
==========================================
+ Coverage   77.76%   77.78%   +0.02%     
==========================================
  Files         759      760       +1     
  Lines       72979    73013      +34     
==========================================
+ Hits        56750    56793      +43     
+ Misses      16224    16215       -9     
  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.

rdimitrov
rdimitrov previously approved these changes 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 correctly opens the intended loopback delegate-client path, and the well-formed loopback/non-loopback host policy is sound. However, malformed HTTP issuers can pass admission and reconciliation before failing inside the workload, while the VirtualMCPServer validation path recommends enabling a flag that the confidential-client policy forbids. The public field documentation and negative admission coverage also need to be updated, so I am requesting changes.

Checklist

  • Tests: CI is green, but negative coverage for the new delegate CEL rule and malformed-issuer reconciliation is missing.
  • Docs: Generated artifacts are current, but the source and generated flag descriptions still document the previous behavior.
  • Registry impact: None.
  • Security: Well-formed loopback enforcement is sound; malformed issuer handling currently fails open during reconciliation.
  • Backwards compatibility: The schema relaxation is additive, and CRD compatibility checks pass.

Comment thread pkg/authserver/config.go Outdated
Comment thread pkg/authserver/config.go
Comment thread cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/S Small PR: 100-299 lines changed labels Aug 25, 2026
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 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.

Follow-up summary

The author addressed the VirtualMCP validation ordering, stale source/generated documentation, and missing negative CEL coverage. The malformed-issuer fix is only partial: it closes the exact parse-error path, but wrapping the raw net/url error can expose URL credentials, and parseable issuers that violate the runtime contract can still reconcile successfully before crashing at startup. The existing changes-requested verdict remains appropriate.

Checklist

  • Tests: The required Go test job currently fails because two new assertions expect invalid issuer URL while production returns require a valid issuer URL.
  • Docs: Addressed and regenerated.
  • Registry impact: None.
  • Security: Well-formed loopback enforcement remains sound; raw parse-error propagation must be sanitized.
  • Backwards compatibility: No new schema-compatibility issue found.

Comment thread pkg/authserver/config.go Outdated
Comment thread pkg/authserver/config.go
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 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.

The latest commit addresses my previously requested changes: malformed issuer errors are now sanitized, the loopback HTTP opt-in path applies full issuer validation, and regression coverage exercises both behaviors. The updated Go test job passes.

@jhrozek
jhrozek merged commit 9ec0186 into main Aug 25, 2026
61 checks passed
@jhrozek
jhrozek deleted the delegate-on-loopback branch August 25, 2026 13:22
jhrozek added a commit that referenced this pull request 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.
jhrozek added a commit that referenced this pull request Aug 26, 2026
* Add private-key JWT registration gate

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.

* Validate private-key JWT registration

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.

* Build private-key JWT DCR clients

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.

* Persist private-key JWT client metadata

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.

* Advertise private-key JWT authentication

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.

* Test private-key JWT token exchange

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.

* Exercise private-key JWT operator flow

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 private-key JWT registration

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.

* Harden private-key JWT DCR validation

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.

* Validate registered-client key algorithms

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.

* Unify the client-key signing-algorithm allowlist

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.

* Regenerate swagger docs for the new DCR config field

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.

* Reject EdDSA, mislabeled, and weak client keys at DCR

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.

* Make client-assertion replay atomic and bound its cost

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.

* Bound the private_key_jwt e2e test's HTTP calls

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.

* Remove the private_key_jwt registration transport gate

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.

* Regenerate CRD/swagger docs after rebasing onto main

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.

* Fix double t.Parallel() panic in concurrent replay tests

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.

* Fix stale error-substring assertion in transport test

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 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/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants