extauth: add ClientCredentialsHandler for OAuth client credentials grant (#895)

## 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](https://github.com/modelcontextprotocol/go-sdk/issues/627#issuecomment-3640982691):

> 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](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046).
The JWT Assertions variant (RFC 7523) is left for a follow-up as its API
surface needs more design discussion.

## Test plan

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

Refs #627

Co-authored-by: Maciej Kisiel <mkisiel@google.com>
This commit is contained in:
Ravish Gupta 2026-04-27 04:28:35 -07:00 • committed by GitHub
commit 2e21834ad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -57,6 +57,15 @@ type JWTBearerConfig struct {
ValidAssertions []string
}
// ClientCredentialsConfig configures support for the client_credentials
// grant type (RFC 6749 Section 4.4) on a [FakeAuthorizationServer].
type ClientCredentialsConfig struct {
// Enabled controls whether the /token endpoint accepts
// grant_type=client_credentials and returns an access token
// if client authentication succeeds.
Enabled bool
}
// Config holds configuration for FakeAuthorizationServer.
type Config struct {
// The optional path component of the issuer URL.
@ -70,6 +79,9 @@ type Config struct {
// JWTBearerConfig enables RFC 7523 JWT Bearer grant at the /token endpoint.
// If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer.
JWTBearerConfig *JWTBearerConfig
// ClientCredentialsConfig enables RFC 6749 Section 4.4 client credentials
// grant at the /token endpoint.
ClientCredentialsConfig *ClientCredentialsConfig
}
// FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing.
@ -271,6 +283,8 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req
s.handleAuthorizationCodeGrant(w, r)
case "urn:ietf:params:oauth:grant-type:jwt-bearer":
s.handleJWTBearerGrant(w, r)
case "client_credentials":
s.handleClientCredentialsGrant(w, r)
default:
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
}
@ -332,6 +346,20 @@ func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r
})
}
func (s *FakeAuthorizationServer) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) {
if s.config.ClientCredentialsConfig == nil || !s.config.ClientCredentialsConfig.Enabled {
http.Error(w, "client_credentials grant not supported", http.StatusBadRequest)
return
}
// Client was already authenticated in handleToken.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "test_access_token",
"token_type": "Bearer",
"expires_in": 3600,
})
}
func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error {
clientID, clientSecret, ok := r.BasicAuth()
if !ok {