Skip to content

extauth: add ClientCredentialsHandler for OAuth client credentials grant#895

Open
ravyg wants to merge 2 commits intomodelcontextprotocol:mainfrom
ravyg:feat/627-oauth-client-credentials
Open

extauth: add ClientCredentialsHandler for OAuth client credentials grant#895
ravyg wants to merge 2 commits intomodelcontextprotocol:mainfrom
ravyg:feat/627-oauth-client-credentials

Conversation

@ravyg
Copy link
Copy Markdown
Contributor

@ravyg ravyg commented Apr 14, 2026

Summary

  • Add ClientCredentialsHandler implementing auth.OAuthHandler using the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) for service-to-service authentication with pre-registered credentials.
  • Bypasses both dynamic client registration and the authorization code flow — the handler takes a client ID + secret directly and exchanges them at the token endpoint.
  • Supports two modes: direct TokenEndpoint URL, or metadata discovery via AuthServerURL (RFC 8414).
  • Add client_credentials grant type support to the fake authorization server in internal/oauthtest for testing.

Context

Per @jba's comment on #627:

Our default implementation for that framework should support client ID and client secret as options, to handle that variant. [...] If those are set, our implementation would bypass dynamic client reservation. That is just a couple of lines of code, I believe.

The client-side OAuth scaffolding (#785) that this was blocked on is now complete, as @brkane noted.

Implements the client credentials variant of SEP-1046. The JWT Assertions variant (RFC 7523) is left for a follow-up as its API surface needs more design discussion.

Test plan

  • TestNewClientCredentialsHandler_Validation — validates all config error cases (nil config, missing fields, mutual exclusivity)
  • TestClientCredentialsHandler_Authorize/direct_token_endpoint — end-to-end with fake auth server
  • TestClientCredentialsHandler_Authorize/metadata_discovery — discovers token endpoint via RFC 8414 metadata
  • TestClientCredentialsHandler_Authorize/bad_credentials — verifies failure with wrong secret
  • go test ./... -count=1 passes
  • go vet ./... clean

Refs #627

Add an implementation of auth.OAuthHandler that uses the OAuth 2.0
Client Credentials grant (RFC 6749 Section 4.4) for service-to-service
authentication with pre-registered credentials. This bypasses both
dynamic client registration and the authorization code flow.

The handler supports two modes:
- Direct token endpoint URL
- Metadata discovery via AuthServerURL (RFC 8414)

Also adds client_credentials grant type support to the fake
authorization server in internal/oauthtest for testing.

Refs modelcontextprotocol#627
@ravyg ravyg force-pushed the feat/627-oauth-client-credentials branch from 013c785 to 0977382 Compare April 14, 2026 04:59
@ravyg ravyg marked this pull request as ready for review April 14, 2026 05:12
Comment on lines +2 to +3
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.

Let's make sure we use the new version of the header in newly added files.

return nil, fmt.Errorf("config must be provided")
}
if config.Credentials == nil {
return nil, fmt.Errorf("credentials is required")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, fmt.Errorf("credentials is required")
return nil, fmt.Errorf("credentials are required")

httpClient = http.DefaultClient
}

tokenEndpoint := h.config.TokenEndpoint
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reading the specification for this method, my understanding is that it extends the general Authorization guidance from MCP. This would mean that we shouldn't have TokenEndpoint or AuthServerURL, but rather use the request URL to fetch PRM and then ASM like in `auth/authorization_code.go. The sequence diagram there confirms that fetching PRM is expected: https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx#flow-steps.

In that scenario scopes are also coming from PRM and don't need to be specified explicitly on the handler config.

}

creds := h.config.Credentials
cfg := &clientcredentials.Config{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should set AuthStyle based on ASM's token_endpoint_auth_methods_supported and our priority order (client_secret_post is preferred over client_secret_basic).

// token endpoint auth methods from the authorization server metadata.
// Returns [oauth2.AuthStyleInHeader] (HTTP Basic) by default, which is the
// recommended method per RFC 6749 Section 2.3.1.
func AuthStyle(methods []string) oauth2.AuthStyle {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume you forgot to use this function to set the style in the config. Either way, please un-export this function and take a look at a similar logic we've introduced for authorization code handler. I believe it's slightly more correct.

func selectTokenAuthMethod(supported []string) oauth2.AuthStyle {
prefOrder := []string{
// Preferred in OAuth 2.1 draft: https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-14.html#name-client-secret.
"client_secret_post",
"client_secret_basic",
}
for _, method := range prefOrder {
if slices.Contains(supported, method) {
return authMethodToStyle(method)
}
}
return oauth2.AuthStyleAutoDetect
}
func authMethodToStyle(method string) oauth2.AuthStyle {
switch method {
case "client_secret_post":
return oauth2.AuthStyleInParams
case "client_secret_basic":
return oauth2.AuthStyleInHeader
case "none":
// "none" is equivalent to "client_secret_post" but without sending client secret.
return oauth2.AuthStyleInParams
default:
// "client_secret_basic" is the default per https://datatracker.ietf.org/doc/html/rfc7591#section-2.
return oauth2.AuthStyleInHeader
}
}

}{
{
name: "nil config",
config: nil,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look how the corresponding test cases are built for EnterpriseHandler: there is a reusable validXConfig function that produces configuration that is correct and each test case precisely overwrites the part that it targets. You don't have to rely on any ordering of validations with this strategy.

if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantError)
}
if got := err.Error(); !contains(got, tc.wantError) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use strings.Contains.

t.Fatalf("expected error containing %q, got nil", tc.wantError)
}
if got := err.Error(); !contains(got, tc.wantError) {
t.Fatalf("error %q does not contain %q", got, tc.wantError)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: You can use Errorf here and in line 104.

}

func TestClientCredentialsHandler_Authorize(t *testing.T) {
ctx := context.Background()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer t.Context().


// ClientCredentialsEnabled enables support for the client_credentials
// grant type (RFC 6749 Section 4.4) on a [FakeAuthorizationServer].
// When true, the /token endpoint accepts grant_type=client_credentials
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part should be moved to the doc comment of Enabled.

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