-
Notifications
You must be signed in to change notification settings - Fork 418
extauth: add ClientCredentialsHandler for OAuth client credentials grant #895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
maciej-kisiel
merged 2 commits into
modelcontextprotocol:main
from
ravyg:feat/627-oauth-client-credentials
Apr 27, 2026
+572
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| // 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 ( | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/url" | ||
| "slices" | ||
| "strings" | ||
|
|
||
| "github.com/modelcontextprotocol/go-sdk/auth" | ||
| "github.com/modelcontextprotocol/go-sdk/oauthex" | ||
| "golang.org/x/oauth2" | ||
| "golang.org/x/oauth2/clientcredentials" | ||
| ) | ||
|
|
||
| // ClientCredentialsHandlerConfig is the configuration for [ClientCredentialsHandler]. | ||
| type ClientCredentialsHandlerConfig struct { | ||
| // Credentials contains the pre-registered client ID and secret. | ||
| // REQUIRED. Both ClientID and ClientSecretAuth must be set, since the | ||
| // client credentials grant requires a confidential client. | ||
| Credentials *oauthex.ClientCredentials | ||
|
|
||
| // HTTPClient is an optional HTTP client for customization. | ||
| // If nil, http.DefaultClient is used. | ||
| // OPTIONAL. | ||
| HTTPClient *http.Client | ||
| } | ||
|
|
||
| // ClientCredentialsHandler is an implementation of [auth.OAuthHandler] that | ||
| // uses the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) to | ||
| // obtain access tokens. | ||
| // | ||
| // This handler is intended for service-to-service authentication where the | ||
| // client has pre-registered credentials (client ID and secret) and does not | ||
| // require user interaction. It bypasses both dynamic client registration and | ||
| // the authorization code flow. | ||
| // | ||
| // The token endpoint and scopes are discovered automatically via Protected | ||
| // Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414), | ||
| // following the ext-auth specification SEP-1046. | ||
| type ClientCredentialsHandler struct { | ||
| config *ClientCredentialsHandlerConfig | ||
| tokenSource oauth2.TokenSource | ||
| } | ||
|
|
||
| // Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler. | ||
| var _ auth.OAuthHandler = (*ClientCredentialsHandler)(nil) | ||
|
|
||
| // NewClientCredentialsHandler creates a new ClientCredentialsHandler. | ||
| // It validates the configuration and returns an error if invalid. | ||
| func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*ClientCredentialsHandler, error) { | ||
| if config == nil { | ||
| return nil, fmt.Errorf("config must be provided") | ||
| } | ||
| if config.Credentials == nil { | ||
| return nil, fmt.Errorf("credentials are required") | ||
| } | ||
| if err := config.Credentials.Validate(); err != nil { | ||
| return nil, fmt.Errorf("invalid credentials: %w", err) | ||
| } | ||
| if config.Credentials.ClientSecretAuth == nil { | ||
| return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant") | ||
| } | ||
| return &ClientCredentialsHandler{config: config}, nil | ||
| } | ||
|
|
||
| // TokenSource returns the token source for outgoing requests. | ||
| // Returns nil if authorization has not been performed yet. | ||
| func (h *ClientCredentialsHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { | ||
| return h.tokenSource, nil | ||
| } | ||
|
|
||
| // Authorize performs the Client Credentials grant to obtain an access token. | ||
| // It is called when a request fails with 401 or 403. | ||
| // | ||
| // The flow follows the ext-auth specification SEP-1046: | ||
| // 1. Discover Protected Resource Metadata from the request URL | ||
| // 2. Discover Authorization Server Metadata from PRM | ||
| // 3. Exchange client credentials for an access token at the token endpoint | ||
| func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { | ||
| defer resp.Body.Close() | ||
| defer io.Copy(io.Discard, resp.Body) | ||
|
|
||
| httpClient := h.config.HTTPClient | ||
| if httpClient == nil { | ||
| httpClient = http.DefaultClient | ||
| } | ||
|
|
||
| // Step 1: Discover Protected Resource Metadata. | ||
| wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err) | ||
| } | ||
|
|
||
| prm, err := getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String(), httpClient) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| if len(prm.AuthorizationServers) == 0 { | ||
| return fmt.Errorf("protected resource metadata has no authorization servers specified") | ||
| } | ||
|
|
||
| // Step 2: Discover Authorization Server Metadata. | ||
| asm, err := auth.GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], httpClient) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to get authorization server metadata: %w", err) | ||
| } | ||
| if asm == nil { | ||
| // Fallback to 2025-03-26 spec: predefined endpoints. | ||
| authServerURL := prm.AuthorizationServers[0] | ||
| asm = &oauthex.AuthServerMeta{ | ||
| Issuer: authServerURL, | ||
| TokenEndpoint: authServerURL + "/token", | ||
| } | ||
| } | ||
|
|
||
| // Determine scopes: use PRM's scopes_supported if available. | ||
| scopes := scopesFromChallenges(wwwChallenges) | ||
| if len(scopes) == 0 && len(prm.ScopesSupported) > 0 { | ||
| scopes = prm.ScopesSupported | ||
| } | ||
|
|
||
| // Step 3: Exchange client credentials for an access token. | ||
| creds := h.config.Credentials | ||
| cfg := &clientcredentials.Config{ | ||
| ClientID: creds.ClientID, | ||
| ClientSecret: creds.ClientSecretAuth.ClientSecret, | ||
| TokenURL: asm.TokenEndpoint, | ||
| Scopes: scopes, | ||
| AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported), | ||
| } | ||
|
|
||
| ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) | ||
| h.tokenSource = cfg.TokenSource(ctxWithClient) | ||
|
|
||
| // Eagerly fetch a token to surface errors immediately. | ||
| if _, err := h.tokenSource.Token(); err != nil { | ||
| h.tokenSource = nil | ||
| return fmt.Errorf("client credentials token request failed: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // getProtectedResourceMetadata discovers Protected Resource Metadata (RFC 9728) | ||
| // from the request URL. This mirrors the logic in AuthorizationCodeHandler. | ||
| func getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string, httpClient *http.Client) (*oauthex.ProtectedResourceMetadata, error) { | ||
|
maciej-kisiel marked this conversation as resolved.
|
||
| // Use MCP server URL as the resource URI per | ||
| // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri. | ||
| for _, u := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) { | ||
| prm, err := oauthex.GetProtectedResourceMetadata(ctx, u.url, u.resource, httpClient) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| if prm == nil { | ||
| continue | ||
| } | ||
| if len(prm.AuthorizationServers) == 0 { | ||
| // If we found PRM, we enforce the 2025-11-25 spec and not search further. | ||
| return nil, fmt.Errorf("protected resource metadata has no authorization servers specified") | ||
| } | ||
| return prm, nil | ||
| } | ||
| // Fallback to 2025-03-26 spec: MCP server root is the Authorization Server. | ||
| // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery | ||
| u, err := url.Parse(mcpServerURL) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to parse MCP server URL: %v", err) | ||
| } | ||
| u.Path = "" | ||
| return &oauthex.ProtectedResourceMetadata{ | ||
| AuthorizationServers: []string{u.String()}, | ||
| Resource: mcpServerURL, | ||
| }, nil | ||
| } | ||
|
|
||
| type prmURL struct { | ||
| url string | ||
| resource string | ||
| } | ||
|
|
||
| // protectedResourceMetadataURLs returns URLs to try for PRM discovery. | ||
| // This mirrors the logic in AuthorizationCodeHandler. | ||
| func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { | ||
| var urls []prmURL | ||
| if metadataURL != "" { | ||
| urls = append(urls, prmURL{url: metadataURL, resource: resourceURL}) | ||
| } | ||
| ru, err := url.Parse(resourceURL) | ||
| if err != nil { | ||
| return urls | ||
| } | ||
| mu := *ru | ||
| // At the path of the server's MCP endpoint. | ||
| mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/") | ||
| urls = append(urls, prmURL{url: mu.String(), resource: resourceURL}) | ||
| // At the root. | ||
| mu.Path = "/.well-known/oauth-protected-resource" | ||
| ru.Path = "" | ||
| urls = append(urls, prmURL{url: mu.String(), resource: ru.String()}) | ||
| return urls | ||
| } | ||
|
|
||
| // resourceMetadataURLFromChallenges returns a resource metadata URL from | ||
| // WWW-Authenticate challenges, or the empty string if there is none. | ||
| func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string { | ||
| for _, c := range cs { | ||
| if u := c.Params["resource_metadata"]; u != "" { | ||
| return u | ||
| } | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| // scopesFromChallenges returns scopes from WWW-Authenticate challenges. | ||
| // It only looks at challenges with the "Bearer" scheme. | ||
| func scopesFromChallenges(cs []oauthex.Challenge) []string { | ||
| for _, c := range cs { | ||
| if c.Scheme == "bearer" && c.Params["scope"] != "" { | ||
| return strings.Fields(c.Params["scope"]) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // selectTokenAuthMethod selects the preferred token endpoint auth method based on | ||
| // the authorization server's supported methods. Prefers client_secret_post over | ||
| // client_secret_basic per the OAuth 2.1 draft. | ||
| func selectTokenAuthMethod(supported []string) oauth2.AuthStyle { | ||
| prefOrder := []string{ | ||
| "client_secret_post", | ||
| "client_secret_basic", | ||
| } | ||
| for _, method := range prefOrder { | ||
| if slices.Contains(supported, method) { | ||
| switch method { | ||
| case "client_secret_post": | ||
| return oauth2.AuthStyleInParams | ||
| case "client_secret_basic": | ||
| return oauth2.AuthStyleInHeader | ||
| } | ||
| } | ||
| } | ||
| return oauth2.AuthStyleAutoDetect | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.