stacklok / stacklok/toolhive

Let RFC 8693 delegate clients self-register with private_key_jwt (no shared secret)

Open
#6,421 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

authentication enhancement go needs-triage
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 AllowPrivateKeyJWTRegistration flag on EmbeddedAuthServerConfig
    (separate from AllowConfidentialClientRegistration, 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 + inline jwks/jwks_uri,
    validates them, and never returns a client_secret for this method.
  • Reuses fosite's existing generic private_key_jwt client-authentication
    support and existing ClientAssertionJWTValid/SetClientAssertionJWT
    replay-protection storage (both already implemented on the memory and
    Redis backends) — no new consent logic was needed:
    checkDelegationConsent only branches on clientID post-authentication,
    never on how the client authenticated, so a private_key_jwt-registered
    client flows through the existing ExternalActorAuthorized/
    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's TrustedIssuerConfig set wildcard
    AllowedDelegateClients but no AllowedActors/ActorMatcher, so
    ExternalActorAuthorized consent 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 own client_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 replayed client_assertion is 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.