feat(authserver): add OAuth 2.0 client_credentials grant support for M2M clients#3962
feat(authserver): add OAuth 2.0 client_credentials grant support for M2M clients#3962RobertWi wants to merge 8 commits intostacklok:mainfrom
Conversation
…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.
|
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:
Happy to discuss the right path forward! |
|
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 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:
Happy to rework this or close it — just want to understand if there's a viable path forward. |
Summary
Add
client_credentialsgrant 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_credentialsfor non-human clients:PR #3425 correctly blocked
client_credentialsin 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: WireOAuth2ClientCredentialsGrantFactoryinto fosite composition (1 line)registration/dcr.go: Support confidential client registration —redirect_urisoptional forclient_credentials-only clients,client_secret_basic/client_secret_postauth methods accepted, secret generationhandlers/dcr.go: Generate and returnclient_secretfor confidential clients in DCR responsehandlers/token.go: Populate session subject with client ID forclient_credentialsgrants so JWT has meaningfulsubclaimhandlers/discovery.go: Advertiseclient_credentialsgrant andclient_secret_basic/client_secret_postin OAuth AS + OIDC discovery metadataStorage / Authorization
No changes needed. The existing storage interface already satisfies fosite's requirements for
client_credentials. Cedar authorization naturally handlesClient::principals derived from thesubclaim.Tests (5 files, ~340 lines)
client_credentialsrejection cases to success, added 6 new confidential client test casesclient_credentialstests (success, wrong secret, resource parameter)client_credentials+ auth method assertionsTest Evidence
Full test suite with race detector — all 10 authserver packages pass:
New tests added by this PR — all pass:
go vet ./pkg/authserver/...— clean, no findings.Backward Compatibility
Fully backward compatible. Existing public client registrations and
authorization_codeflows are unaffected:defaultGrantTypesfor empty DCR requests remain["authorization_code", "refresh_token"]elsebranch)client_credentialsrejection were updated)Diff Stats