go-sdk/auth/extauth/client_credentials_test.go
Ravish Gupta 2e21834ad3
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>
2026-04-27 13:28:35 +02:00

293 lines
7.9 KiB
Go

// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package extauth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
func validClientCredentialsConfig() *ClientCredentialsHandlerConfig {
return &ClientCredentialsHandlerConfig{
Credentials: &oauthex.ClientCredentials{
ClientID: "test-client",
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test-secret"},
},
}
}
func TestNewClientCredentialsHandler_Validation(t *testing.T) {
tests := []struct {
name string
config *ClientCredentialsHandlerConfig
wantError string
}{
{
name: "nil config",
config: nil,
wantError: "config must be provided",
},
{
name: "nil credentials",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials = nil
return c
}(),
wantError: "credentials are required",
},
{
name: "missing client ID",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientID = ""
return c
}(),
wantError: "ClientID is required",
},
{
name: "missing client secret auth",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientSecretAuth = nil
return c
}(),
wantError: "clientSecretAuth is required",
},
{
name: "empty client secret",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientSecretAuth.ClientSecret = ""
return c
}(),
wantError: "ClientSecret is required",
},
{
name: "valid config",
config: validClientCredentialsConfig(),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := NewClientCredentialsHandler(tc.config)
if tc.wantError != "" {
if err == nil {
t.Errorf("expected error containing %q, got nil", tc.wantError)
} else if !strings.Contains(err.Error(), tc.wantError) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantError)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestClientCredentialsHandler_Authorize(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
ServeOAuthInsertedEndpoint: true,
},
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test-client": {Secret: "test-secret"},
},
},
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
Enabled: true,
},
})
authServer.Start(t)
resourceMux := http.NewServeMux()
resourceServer := httptest.NewServer(resourceMux)
t.Cleanup(resourceServer.Close)
resourceURL := resourceServer.URL + "/resource"
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
ScopesSupported: []string{"mcp:read", "mcp:write"},
}))
t.Run("successful authorization", func(t *testing.T) {
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
// TokenSource should be nil before Authorize.
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts != nil {
t.Fatal("expected nil TokenSource before Authorize")
}
// Simulate a 401 response from the MCP server.
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize failed: %v", err)
}
// TokenSource should now return a valid token.
ts, err = handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource after Authorize")
}
tok, err := ts.Token()
if err != nil {
t.Fatalf("Token() failed: %v", err)
}
if tok.AccessToken != "test_access_token" {
t.Errorf("got access token %q, want %q", tok.AccessToken, "test_access_token")
}
})
t.Run("bad credentials", func(t *testing.T) {
config := validClientCredentialsConfig()
config.Credentials.ClientSecretAuth.ClientSecret = "wrong-secret"
handler, err := NewClientCredentialsHandler(config)
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err == nil {
t.Error("expected Authorize to fail with bad credentials")
}
// TokenSource should still be nil after failed Authorize.
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts != nil {
t.Error("expected nil TokenSource after failed Authorize")
}
})
t.Run("scopes from WWW-Authenticate challenge", func(t *testing.T) {
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{
"WWW-Authenticate": []string{`Bearer scope="read write"`},
},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource")
}
})
t.Run("PRM via resource_metadata in challenge", func(t *testing.T) {
prmMux := http.NewServeMux()
prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
}))
prmServer := httptest.NewServer(prmMux)
t.Cleanup(prmServer.Close)
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{
"WWW-Authenticate": []string{`Bearer resource_metadata="` + prmServer.URL + `/custom-prm"`},
},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize with resource_metadata challenge failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource")
}
})
}
func TestSelectTokenAuthMethod(t *testing.T) {
tests := []struct {
name string
supported []string
want oauth2.AuthStyle
}{
{
name: "prefers client_secret_post",
supported: []string{"client_secret_basic", "client_secret_post"},
want: oauth2.AuthStyleInParams,
},
{
name: "falls back to client_secret_basic",
supported: []string{"client_secret_basic"},
want: oauth2.AuthStyleInHeader,
},
{
name: "auto detect when none supported",
supported: []string{"private_key_jwt"},
want: oauth2.AuthStyleAutoDetect,
},
{
name: "auto detect when empty",
supported: nil,
want: oauth2.AuthStyleAutoDetect,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := selectTokenAuthMethod(tc.supported)
if got != tc.want {
t.Errorf("selectTokenAuthMethod(%v) = %v, want %v", tc.supported, got, tc.want)
}
})
}
}