Let RFC 8693 delegate clients self-register with private_key_jwt (no shared secret)
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
Description
Description
RFC 8693 delegate clients today can only authenticate to the embedded auth
server with a ToolHive-issued shared secret — either statically
pre-provisioned (delegateClients[]) or minted via unauthenticated RFC 7591
DCR (allowConfidentialClientRegistration). Neither lets an agent that
already holds its own keypair authenticate with it directly; every path
still requires ToolHive to generate and hand out a secret.
RFC 7523 §2.2 private_key_jwt client authentication is the standard
answer: a client declares its own public key (jwks inline or jwks_uri)
at DCR registration time and thereafter authenticates by signing a
client_assertion JWT with its own private key. No secret is ever issued,
transmitted, or stored by the AS.
Do not confuse this with the RFC 7523 §2.1 JWT-bearer grant (#6336/#6337,
already shipped) — that's a different mechanism entirely (an external
issuer's bearer assertion presented directly for a token, no client
involved). This is about client authentication for an already-DCR-
registered client, a different half of the same RFC section.
What's done
Implemented on branch dcr-private-key-jwt (based on origin/main,
currently unmerged):
- New
AllowPrivateKeyJWTRegistrationflag onEmbeddedAuthServerConfig
(separate fromAllowConfidentialClientRegistration, so an operator can
allow secretless registration without also allowing secret-based
confidential DCR). - DCR (
pkg/authserver/server/registration/) accepts
token_endpoint_auth_method=private_key_jwt+ inlinejwks/jwks_uri,
validates them, and never returns aclient_secretfor this method. - Reuses fosite's existing generic
private_key_jwtclient-authentication
support and existingClientAssertionJWTValid/SetClientAssertionJWT
replay-protection storage (both already implemented on the memory and
Redis backends) — no new consent logic was needed:
checkDelegationConsentonly branches onclientIDpost-authentication,
never on how the client authenticated, so aprivate_key_jwt-registered
client flows through the existingExternalActorAuthorized/
AllowedDelegateClients(wildcard"*"supported) consent path unchanged. - Unit tests, integration test, and a new e2e test
(test/e2e/thv-operator/virtualmcp/virtualmcp_private_key_jwt_test.go)
against a real operator-created auth server.
Verified
- Full test suite passes (
go test ./pkg/authserver/...). - Rebuilt operator/vmcp images from this branch and ran the new e2e test
live against a real kind cluster — confirmed working (one test bug found
and fixed along the way: the test'sTrustedIssuerConfigset wildcard
AllowedDelegateClientsbut noAllowedActors/ActorMatcher, so
ExternalActorAuthorizedconsent could never fire; fixed by adding
ActorMatcher: "true"). - Built a full demo (
~/devel/manifests/rfc8693-delegation-private-key-jwt-demo/,
not part of this repo) proving the flow end to end against a real Entra
tenant: an agent generates its own RSA keypair, self-registers via DCR
declaring only the public key, signs its ownclient_assertion, exchanges
a real Entra device-code-login subject token for a delegated (act-
bearing) ToolHive token, uses that token to make a real MCP tool call
through vmcp, and a replayedclient_assertionis correctly rejected.
Process note
Per house convention, significant features get an RFC in toolhive-rfcs
before implementation. This one was scoped and implemented directly instead
— decided (with the epic owner) to be small and well-bounded enough
(reuses existing fosite/storage machinery, no new consent logic) to skip
that step and track as a sub-issue of #5194 instead.
Not in scope here (explicit follow-up idea, not this issue)
Nothing in any of this epic's demos so far has fed a delegated token into an
actual authorization decision — every demo mints and decodes a token, but
never uses act to change what an agent can do. A natural follow-up: a
Cedar policy that reads act and gates a second, higher-privilege tool
(the current demo backend only exposes one trivial echo tool, so this
needs a richer backend + policy design — a separate piece of work, not a
polish item on this one).
Related
- Epic: #5194
- Builds on #6364 (actor matcher), #6391 (JWT-bearer grant)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Review the existing implementation on branch dcr-private-key-jwt, starting in pkg/authserver/server/registration/ and the EmbeddedAuthServerConfig changes. Run go test ./pkg/authserver/... and inspect test/e2e/thv-operator/virtualmcp/virtualmcp_private_key_jwt_test.go. Done means the implementation is integrated, tests pass, and the private_key_jwt flow works without issuing a client secret.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- authentication, backend-api-design, security, testing-qa
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 20/100