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.
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.

1 participant