Skip to content

feat(authserver): add OAuth 2.0 client_credentials grant support for M2M clients#3962

Draft
RobertWi wants to merge 8 commits intostacklok:mainfrom
RobertWi:feat/client-credentials-grant
Draft

feat(authserver): add OAuth 2.0 client_credentials grant support for M2M clients#3962
RobertWi wants to merge 8 commits intostacklok:mainfrom
RobertWi:feat/client-credentials-grant

Conversation

@RobertWi
Copy link

@RobertWi RobertWi commented Mar 2, 2026

Summary

Add client_credentials grant type support to ToolHive's built-in OAuth authorization server, enabling machine-to-machine (M2M) authentication as recommended by the MCP specification (2025-03-26).

Motivation

The MCP spec explicitly recommends client_credentials for non-human clients:

MCP servers SHOULD support the OAuth grant types that best align with the intended audience. For instance:

  1. Authorization Code: useful when the client is acting on behalf of a (human) end user.
  2. Client Credentials: the client is another application (not a human)

PR #3425 correctly blocked client_credentials in DCR because the server couldn't fulfill it at the time. This PR implements the capability, then updates the allowlist.

Changes

Production code (5 files, ~100 lines)

  • server_impl.go: Wire OAuth2ClientCredentialsGrantFactory into fosite composition (1 line)
  • registration/dcr.go: Support confidential client registration — redirect_uris optional for client_credentials-only clients, client_secret_basic/client_secret_post auth methods accepted, secret generation
  • handlers/dcr.go: Generate and return client_secret for confidential clients in DCR response
  • handlers/token.go: Populate session subject with client ID for client_credentials grants so JWT has meaningful sub claim
  • handlers/discovery.go: Advertise client_credentials grant and client_secret_basic/client_secret_post in OAuth AS + OIDC discovery metadata

Storage / Authorization

No changes needed. The existing storage interface already satisfies fosite's requirements for client_credentials. Cedar authorization naturally handles Client:: principals derived from the sub claim.

Tests (5 files, ~340 lines)

  • Updated DCR validation tests: flipped client_credentials rejection cases to success, added 6 new confidential client test cases
  • Updated token handler tests: fixed unsupported grant type test, added 3 new client_credentials tests (success, wrong secret, resource parameter)
  • Updated discovery tests: added client_credentials + auth method assertions
  • Added 3 integration tests: basic flow with JWT claim validation, RFC 8707 audience binding, wrong secret rejection

Test Evidence

Full test suite with race detector — all 10 authserver packages pass:

$ go test ./pkg/authserver/... -count=1 -race

ok  	github.com/stacklok/toolhive/pkg/authserver	5.844s
ok  	github.com/stacklok/toolhive/pkg/authserver/runner	1.350s
ok  	github.com/stacklok/toolhive/pkg/authserver/server	1.666s
ok  	github.com/stacklok/toolhive/pkg/authserver/server/crypto	2.011s
ok  	github.com/stacklok/toolhive/pkg/authserver/server/handlers	7.307s
ok  	github.com/stacklok/toolhive/pkg/authserver/server/keys	1.360s
ok  	github.com/stacklok/toolhive/pkg/authserver/server/registration	3.573s
ok  	github.com/stacklok/toolhive/pkg/authserver/server/session	1.178s
ok  	github.com/stacklok/toolhive/pkg/authserver/storage	3.477s
ok  	github.com/stacklok/toolhive/pkg/authserver/upstream	2.098s

New tests added by this PR — all pass:

--- PASS: TestIntegration_ClientCredentials_BasicFlow (0.20s)
--- PASS: TestIntegration_ClientCredentials_WithAudience (0.20s)
--- PASS: TestIntegration_ClientCredentials_WrongSecret (0.34s)
--- PASS: TestTokenHandler_ClientCredentials_Success (0.17s)
--- PASS: TestTokenHandler_ClientCredentials_WrongSecret (0.15s)
--- PASS: TestTokenHandler_ClientCredentials_WithResource (0.15s)

go vet ./pkg/authserver/... — clean, no findings.

Backward Compatibility

Fully backward compatible. Existing public client registrations and authorization_code flows are unaffected:

  • defaultGrantTypes for empty DCR requests remain ["authorization_code", "refresh_token"]
  • Public client validation logic is unchanged (just moved into an else branch)
  • All existing tests pass without modification (only the two tests that explicitly tested client_credentials rejection were updated)

Diff Stats

 10 files changed, 436 insertions(+), 65 deletions(-)

doemijdienaammaar added 8 commits March 2, 2026 14:04
…rovider

Add compose.OAuth2ClientCredentialsGrantFactory to the fosite composition,
enabling the token endpoint to accept client_credentials grant requests.

This is the foundational change — subsequent commits will update DCR
validation, token session handling, and discovery metadata.

Ref: MCP spec 2025-03-26 recommends client_credentials for M2M clients.
…tials

Update DCR validation to accept client_credentials grant type and
confidential client semantics:
- redirect_uris optional for client_credentials-only clients
- token_endpoint_auth_method allows client_secret_basic/client_secret_post
- authorization_code no longer required when client_credentials is present
- refresh_token alone is still rejected (must accompany a primary grant)

Backward compatible: existing public client registrations are unaffected.
When a client registers with client_credentials grant type, the DCR
handler now generates a random secret, creates a confidential fosite
client with bcrypt-hashed secret, and returns the plaintext secret
in the registration response (RFC 7591 Section 3.2.1).

Public client registrations are unaffected.
For client_credentials grants, fosite uses the placeholder session
directly (no stored authorize session exists). Populate the session's
subject and client_id claims with the authenticated client ID so the
resulting JWT has a meaningful 'sub' claim for downstream authorization.

The Cedar authorizer extracts Client:: principal from the 'sub' claim,
so this is required for M2M authorization to work.
Update OAuth AS metadata and OIDC discovery endpoints to advertise:
- grant_types_supported: add client_credentials
- token_endpoint_auth_methods_supported: add client_secret_basic, client_secret_post

Per RFC 8414, this tells clients the server supports M2M authentication.
Update existing tests that expected client_credentials rejection to
verify acceptance. Add new test cases covering:
- client_secret_basic and client_secret_post auth methods
- redirect_uris handling for confidential clients
- rejection of auth_method=none for confidential clients
- refresh_token-only and implicit grant type rejection
- Wire OAuth2ClientCredentialsGrantFactory into test setup
- Register confidential test client with bcrypt-hashed secret
- Replace unsupported grant type test (was client_credentials, now implicit)
- Add success, wrong-secret, and resource-parameter tests for client_credentials
- Update discovery assertions to include client_credentials and auth methods
Add integration tests covering:
- Basic client_credentials flow with JWT claim validation (sub=clientID)
- RFC 8707 resource parameter for audience binding
- Wrong secret rejection (401 invalid_client)
- Verify no refresh token issued for client_credentials

Uses setupTestServer with new withConfidentialClient() option
that registers a bcrypt-hashed confidential client.
@JAORMX
Copy link
Collaborator

JAORMX commented Mar 18, 2026

Thanks for putting this together, @RobertWi. M2M auth is a real gap and the code is well-structured. However, after reviewing against the current MCP spec and OAuth security best practices, I have concerns about the overall approach.

The spec reference is outdated. The PR cites the 2025-03-26 MCP spec, but the latest (2025-11-25) moved client_credentials to an optional draft extension in a separate repo. See SEP-1046 for background.

DCR + client_credentials contradicts the extension spec. The extension explicitly states: "Dynamic Client Registration is not used in this flow. All credentials must be pre-registered through out-of-band administrative channels." This PR does the opposite: anyone who can reach the unauthenticated DCR endpoint can register a confidential client and immediately get tokens with zero human involvement. That's a fundamental change in security posture.

No principal differentiation. M2M tokens get sub=<client_id> (UUID), same format as user tokens. Cedar policies can't distinguish machine from human principals, so existing permit(principal, ...) rules would authorize both identically.

Mixed grant types edge case. Registering with ["authorization_code", "client_credentials"] creates a public client (no secret) that can never actually use client_credentials. Fosite blocks this at token time, but it creates dead-letter configs. RFC 7591 Section 5 explicitly warns against combining these.

If we want this (and I think we eventually should!), the approach should align with the extension spec:

  1. Pre-registered confidential clients only (admin-created, not through unauthenticated DCR)
  2. Reference the actual extension spec, not the outdated core spec
  3. Differentiate M2M principals in Cedar
  4. Consider waiting for the extension to move past draft status

Happy to discuss the right path forward!

@RobertWi
Copy link
Author

Thanks for the detailed review @JAORMX — sorry for the slow reply, needed to step back and do more homework on this.

You're right that my approach was off. I was trying to solve a practical problem — getting AI agents authenticated to MCP servers — and jumped to implementation without fully understanding where the spec landed. The unauthenticated DCR path was the wrong call, especially for the K8s case.

I've since dug into SEP-1046 and see it was accepted and landed as a draft extension in the ext-auth repo. So it seems like an agreed direction, though I'm not plugged into when (or if) it'll move past draft. That's part of my uncertainty here.

From what I can tell, there's currently no M2M auth path at all in ToolHive — agents simply can't authenticate programmatically. For my use case (fleet of agents in K8s), that's a blocker.

Given your feedback, is it feasible to move forward with something that aligns with the extension spec? I'm thinking along these lines:

  • Operator-provisioned credentials — instead of agents registering themselves via DCR, the operator creates and injects client credentials when deploying a workload. Similar to how K8s handles ServiceAccount tokens — the workload doesn't request its own identity, the platform provisions it. The operator already manages the lifecycle of MCP servers, so it's a natural place to also manage which agents get credentials to talk to them.
  • Separate identity model for machine principals — so Cedar can structurally tell human and machine apart, not just a flag on the same token format.
  • Support JWT assertions and client secrets as the extension spec describes.

Happy to rework this or close it — just want to understand if there's a viable path forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants