Skip to content

Commit 2e21834

Browse files
ravygmaciej-kisiel
andauthored
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](#627 (comment)): > 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](modelcontextprotocol/modelcontextprotocol#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>
1 parent 2643b22 commit 2e21834

3 files changed

Lines changed: 572 additions & 0 deletions

File tree

auth/extauth/client_credentials.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by the license
3+
// that can be found in the LICENSE file.
4+
5+
package extauth
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
"slices"
14+
"strings"
15+
16+
"github.com/modelcontextprotocol/go-sdk/auth"
17+
"github.com/modelcontextprotocol/go-sdk/oauthex"
18+
"golang.org/x/oauth2"
19+
"golang.org/x/oauth2/clientcredentials"
20+
)
21+
22+
// ClientCredentialsHandlerConfig is the configuration for [ClientCredentialsHandler].
23+
type ClientCredentialsHandlerConfig struct {
24+
// Credentials contains the pre-registered client ID and secret.
25+
// REQUIRED. Both ClientID and ClientSecretAuth must be set, since the
26+
// client credentials grant requires a confidential client.
27+
Credentials *oauthex.ClientCredentials
28+
29+
// HTTPClient is an optional HTTP client for customization.
30+
// If nil, http.DefaultClient is used.
31+
// OPTIONAL.
32+
HTTPClient *http.Client
33+
}
34+
35+
// ClientCredentialsHandler is an implementation of [auth.OAuthHandler] that
36+
// uses the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) to
37+
// obtain access tokens.
38+
//
39+
// This handler is intended for service-to-service authentication where the
40+
// client has pre-registered credentials (client ID and secret) and does not
41+
// require user interaction. It bypasses both dynamic client registration and
42+
// the authorization code flow.
43+
//
44+
// The token endpoint and scopes are discovered automatically via Protected
45+
// Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414),
46+
// following the ext-auth specification SEP-1046.
47+
type ClientCredentialsHandler struct {
48+
config *ClientCredentialsHandlerConfig
49+
tokenSource oauth2.TokenSource
50+
}
51+
52+
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
53+
var _ auth.OAuthHandler = (*ClientCredentialsHandler)(nil)
54+
55+
// NewClientCredentialsHandler creates a new ClientCredentialsHandler.
56+
// It validates the configuration and returns an error if invalid.
57+
func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*ClientCredentialsHandler, error) {
58+
if config == nil {
59+
return nil, fmt.Errorf("config must be provided")
60+
}
61+
if config.Credentials == nil {
62+
return nil, fmt.Errorf("credentials are required")
63+
}
64+
if err := config.Credentials.Validate(); err != nil {
65+
return nil, fmt.Errorf("invalid credentials: %w", err)
66+
}
67+
if config.Credentials.ClientSecretAuth == nil {
68+
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
69+
}
70+
return &ClientCredentialsHandler{config: config}, nil
71+
}
72+
73+
// TokenSource returns the token source for outgoing requests.
74+
// Returns nil if authorization has not been performed yet.
75+
func (h *ClientCredentialsHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
76+
return h.tokenSource, nil
77+
}
78+
79+
// Authorize performs the Client Credentials grant to obtain an access token.
80+
// It is called when a request fails with 401 or 403.
81+
//
82+
// The flow follows the ext-auth specification SEP-1046:
83+
// 1. Discover Protected Resource Metadata from the request URL
84+
// 2. Discover Authorization Server Metadata from PRM
85+
// 3. Exchange client credentials for an access token at the token endpoint
86+
func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
87+
defer resp.Body.Close()
88+
defer io.Copy(io.Discard, resp.Body)
89+
90+
httpClient := h.config.HTTPClient
91+
if httpClient == nil {
92+
httpClient = http.DefaultClient
93+
}
94+
95+
// Step 1: Discover Protected Resource Metadata.
96+
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
97+
if err != nil {
98+
return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err)
99+
}
100+
101+
prm, err := getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String(), httpClient)
102+
if err != nil {
103+
return err
104+
}
105+
106+
if len(prm.AuthorizationServers) == 0 {
107+
return fmt.Errorf("protected resource metadata has no authorization servers specified")
108+
}
109+
110+
// Step 2: Discover Authorization Server Metadata.
111+
asm, err := auth.GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], httpClient)
112+
if err != nil {
113+
return fmt.Errorf("failed to get authorization server metadata: %w", err)
114+
}
115+
if asm == nil {
116+
// Fallback to 2025-03-26 spec: predefined endpoints.
117+
authServerURL := prm.AuthorizationServers[0]
118+
asm = &oauthex.AuthServerMeta{
119+
Issuer: authServerURL,
120+
TokenEndpoint: authServerURL + "/token",
121+
}
122+
}
123+
124+
// Determine scopes: use PRM's scopes_supported if available.
125+
scopes := scopesFromChallenges(wwwChallenges)
126+
if len(scopes) == 0 && len(prm.ScopesSupported) > 0 {
127+
scopes = prm.ScopesSupported
128+
}
129+
130+
// Step 3: Exchange client credentials for an access token.
131+
creds := h.config.Credentials
132+
cfg := &clientcredentials.Config{
133+
ClientID: creds.ClientID,
134+
ClientSecret: creds.ClientSecretAuth.ClientSecret,
135+
TokenURL: asm.TokenEndpoint,
136+
Scopes: scopes,
137+
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
138+
}
139+
140+
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
141+
h.tokenSource = cfg.TokenSource(ctxWithClient)
142+
143+
// Eagerly fetch a token to surface errors immediately.
144+
if _, err := h.tokenSource.Token(); err != nil {
145+
h.tokenSource = nil
146+
return fmt.Errorf("client credentials token request failed: %w", err)
147+
}
148+
return nil
149+
}
150+
151+
// getProtectedResourceMetadata discovers Protected Resource Metadata (RFC 9728)
152+
// from the request URL. This mirrors the logic in AuthorizationCodeHandler.
153+
func getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string, httpClient *http.Client) (*oauthex.ProtectedResourceMetadata, error) {
154+
// Use MCP server URL as the resource URI per
155+
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
156+
for _, u := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
157+
prm, err := oauthex.GetProtectedResourceMetadata(ctx, u.url, u.resource, httpClient)
158+
if err != nil {
159+
continue
160+
}
161+
if prm == nil {
162+
continue
163+
}
164+
if len(prm.AuthorizationServers) == 0 {
165+
// If we found PRM, we enforce the 2025-11-25 spec and not search further.
166+
return nil, fmt.Errorf("protected resource metadata has no authorization servers specified")
167+
}
168+
return prm, nil
169+
}
170+
// Fallback to 2025-03-26 spec: MCP server root is the Authorization Server.
171+
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery
172+
u, err := url.Parse(mcpServerURL)
173+
if err != nil {
174+
return nil, fmt.Errorf("failed to parse MCP server URL: %v", err)
175+
}
176+
u.Path = ""
177+
return &oauthex.ProtectedResourceMetadata{
178+
AuthorizationServers: []string{u.String()},
179+
Resource: mcpServerURL,
180+
}, nil
181+
}
182+
183+
type prmURL struct {
184+
url string
185+
resource string
186+
}
187+
188+
// protectedResourceMetadataURLs returns URLs to try for PRM discovery.
189+
// This mirrors the logic in AuthorizationCodeHandler.
190+
func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
191+
var urls []prmURL
192+
if metadataURL != "" {
193+
urls = append(urls, prmURL{url: metadataURL, resource: resourceURL})
194+
}
195+
ru, err := url.Parse(resourceURL)
196+
if err != nil {
197+
return urls
198+
}
199+
mu := *ru
200+
// At the path of the server's MCP endpoint.
201+
mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/")
202+
urls = append(urls, prmURL{url: mu.String(), resource: resourceURL})
203+
// At the root.
204+
mu.Path = "/.well-known/oauth-protected-resource"
205+
ru.Path = ""
206+
urls = append(urls, prmURL{url: mu.String(), resource: ru.String()})
207+
return urls
208+
}
209+
210+
// resourceMetadataURLFromChallenges returns a resource metadata URL from
211+
// WWW-Authenticate challenges, or the empty string if there is none.
212+
func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string {
213+
for _, c := range cs {
214+
if u := c.Params["resource_metadata"]; u != "" {
215+
return u
216+
}
217+
}
218+
return ""
219+
}
220+
221+
// scopesFromChallenges returns scopes from WWW-Authenticate challenges.
222+
// It only looks at challenges with the "Bearer" scheme.
223+
func scopesFromChallenges(cs []oauthex.Challenge) []string {
224+
for _, c := range cs {
225+
if c.Scheme == "bearer" && c.Params["scope"] != "" {
226+
return strings.Fields(c.Params["scope"])
227+
}
228+
}
229+
return nil
230+
}
231+
232+
// selectTokenAuthMethod selects the preferred token endpoint auth method based on
233+
// the authorization server's supported methods. Prefers client_secret_post over
234+
// client_secret_basic per the OAuth 2.1 draft.
235+
func selectTokenAuthMethod(supported []string) oauth2.AuthStyle {
236+
prefOrder := []string{
237+
"client_secret_post",
238+
"client_secret_basic",
239+
}
240+
for _, method := range prefOrder {
241+
if slices.Contains(supported, method) {
242+
switch method {
243+
case "client_secret_post":
244+
return oauth2.AuthStyleInParams
245+
case "client_secret_basic":
246+
return oauth2.AuthStyleInHeader
247+
}
248+
}
249+
}
250+
return oauth2.AuthStyleAutoDetect
251+
}

0 commit comments

Comments
 (0)