Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions auth/extauth/client_credentials.go
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{
Comment thread
maciej-kisiel marked this conversation as resolved.
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) {
Comment thread
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
}
Loading
Loading