|
| 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