279 lines
9.8 KiB
Go
279 lines
9.8 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 (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
|
"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
|
|
|
|
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
|
grantedScopes map[string][]string
|
|
}
|
|
|
|
// 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,
|
|
grantedScopes: make(map[string][]string),
|
|
}, 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",
|
|
}
|
|
}
|
|
|
|
creds := h.config.Credentials
|
|
if creds.Issuer != "" && !authutil.IssuersEqual(creds.Issuer, asm.Issuer) {
|
|
return fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, creds.Issuer)
|
|
}
|
|
|
|
// Determine requestedScopes: use PRM's scopes_supported if available.
|
|
requestedScopes := scopesFromChallenges(wwwChallenges)
|
|
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
|
requestedScopes = prm.ScopesSupported
|
|
}
|
|
|
|
// Accumulate scopes: union previously granted scopes with the newly
|
|
// challenged scopes so that step-up authorization does not lose
|
|
// permissions granted in earlier rounds (SEP-2350).
|
|
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
|
|
|
|
// Step 3: Exchange client credentials for an access token.
|
|
cfg := &clientcredentials.Config{
|
|
ClientID: creds.ClientID,
|
|
ClientSecret: creds.ClientSecretAuth.ClientSecret,
|
|
TokenURL: asm.TokenEndpoint,
|
|
Scopes: requestedScopes,
|
|
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
|
|
}
|
|
|
|
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
h.tokenSource = cfg.TokenSource(ctxWithClient)
|
|
|
|
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
|
|
}
|
|
|
|
func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
|
if h.tokenSource == nil {
|
|
return nil
|
|
}
|
|
tok, err := h.tokenSource.Token()
|
|
if err != nil {
|
|
h.tokenSource = nil
|
|
return fmt.Errorf("client credentials token request failed: %w", err)
|
|
}
|
|
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
|
h.grantedScopes[issuer] = requestedScopes
|
|
} else {
|
|
h.grantedScopes[issuer] = tokenScopes
|
|
}
|
|
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) {
|
|
// 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
|
|
}
|