From 238a36d7f11a72ad5c68f4e834dd272b003d18c7 Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Wed, 19 Nov 2025 14:12:52 +0000 Subject: [PATCH] feat: implement RFC9728 OAuth Protected Resource Metadata discovery Add support for discovering OAuth authorization server metadata from WWW-Authenticate headers per RFC9728 Section 5.1. The MCP spec indicates that servers should return a 401 Unauthorized response with a WWW-Authenticate header containing the resource_metadata parameter. This parameter is used to discover the OAuth authorization server metadata. This change adds support for this discovery, allowing clients to automatically extract the OAuth metadata URL from the WWW-Authenticate header and use it to discover the OAuth authorization server configuration, rather than relying on it being on the /.well-known path of the base URL, which is not always the case (for example, https://mcp.linear.app/mcp/.well-known/oauth-protected-resource vs https://mcp.honeycomb.io/.well-known/oauth-protected-resource - note the lack of /mcp in one of these, even though both servers expect the /mcp path in the base URL). Changes: - Add AuthorizationRequiredError base error type with ResourceMetadataURL field - Add OAuthAuthorizationRequiredError that embeds AuthorizationRequiredError - Add ProtectedResourceMetadataURL to OAuthConfig for explicit configuration - Extract resource_metadata parameter from WWW-Authenticate headers in both streamable_http and sse transports - Update getServerMetadata() to use ProtectedResourceMetadataURL when provided - Add helper functions: IsAuthorizationRequiredError(), GetResourceMetadataURL() - Add comprehensive tests for metadata URL extraction and usage - Update OAuth example to demonstrate RFC9728 discovery This allows clients to properly discover OAuth endpoints when servers return 401 responses with WWW-Authenticate headers containing resource_metadata URLs, enabling correct OAuth flows without requiring well-known URL assumptions. RFC9728: https://datatracker.ietf.org/doc/html/rfc9728 --- client/oauth.go | 29 +++++ client/oauth_test.go | 85 ++++++++++++- client/transport/oauth.go | 20 ++- client/transport/sse.go | 78 +++++++++--- client/transport/sse_oauth_test.go | 63 ++++++++++ client/transport/streamable_http.go | 87 +++++++++++-- .../transport/streamable_http_oauth_test.go | 114 ++++++++++++++++++ examples/oauth_client/README.md | 33 +++-- examples/oauth_client/main.go | 6 + 9 files changed, 482 insertions(+), 33 deletions(-) diff --git a/client/oauth.go b/client/oauth.go index d6e3ceb99..1e07897ce 100644 --- a/client/oauth.go +++ b/client/oauth.go @@ -57,9 +57,18 @@ var GenerateCodeChallenge = transport.GenerateCodeChallenge // GenerateState generates a state parameter for OAuth var GenerateState = transport.GenerateState +// AuthorizationRequiredError is returned when a 401 Unauthorized response is received +type AuthorizationRequiredError = transport.AuthorizationRequiredError + // OAuthAuthorizationRequiredError is returned when OAuth authorization is required type OAuthAuthorizationRequiredError = transport.OAuthAuthorizationRequiredError +// IsAuthorizationRequiredError checks if an error is an AuthorizationRequiredError +func IsAuthorizationRequiredError(err error) bool { + var target *AuthorizationRequiredError + return errors.As(err, &target) +} + // IsOAuthAuthorizationRequiredError checks if an error is an OAuthAuthorizationRequiredError func IsOAuthAuthorizationRequiredError(err error) bool { var target *OAuthAuthorizationRequiredError @@ -74,3 +83,23 @@ func GetOAuthHandler(err error) *transport.OAuthHandler { } return nil } + +// GetResourceMetadataURL extracts the protected resource metadata URL from an authorization error. +// This URL is extracted from the WWW-Authenticate header per RFC9728 Section 5.1. +// Works with both AuthorizationRequiredError and OAuthAuthorizationRequiredError. +// Returns empty string if no metadata URL was discovered. +func GetResourceMetadataURL(err error) string { + // Try OAuthAuthorizationRequiredError first (contains AuthorizationRequiredError) + var oauthErr *OAuthAuthorizationRequiredError + if errors.As(err, &oauthErr) { + return oauthErr.ResourceMetadataURL + } + + // Try base AuthorizationRequiredError + var authErr *AuthorizationRequiredError + if errors.As(err, &authErr) { + return authErr.ResourceMetadataURL + } + + return "" +} diff --git a/client/oauth_test.go b/client/oauth_test.go index 21f87aa48..d32a349bc 100644 --- a/client/oauth_test.go +++ b/client/oauth_test.go @@ -119,10 +119,93 @@ func TestIsOAuthAuthorizationRequiredError(t *testing.T) { if IsOAuthAuthorizationRequiredError(err2) { t.Errorf("Expected IsOAuthAuthorizationRequiredError to return false") } - // Verify GetOAuthHandler returns nil handler = GetOAuthHandler(err2) if handler != nil { t.Errorf("Expected GetOAuthHandler to return nil") } } + +func TestGetResourceMetadataURL(t *testing.T) { + // Test with error containing metadata URL + metadataURL := "https://auth.example.com/.well-known/oauth-protected-resource" + err := &transport.OAuthAuthorizationRequiredError{ + Handler: transport.NewOAuthHandler(transport.OAuthConfig{}), + AuthorizationRequiredError: transport.AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + + // Verify GetResourceMetadataURL returns the correct URL + result := GetResourceMetadataURL(err) + if result != metadataURL { + t.Errorf("Expected GetResourceMetadataURL to return %q, got %q", metadataURL, result) + } + + // Test with error containing no metadata URL + err2 := &transport.OAuthAuthorizationRequiredError{ + Handler: transport.NewOAuthHandler(transport.OAuthConfig{}), + AuthorizationRequiredError: transport.AuthorizationRequiredError{ + ResourceMetadataURL: "", + }, + } + + result2 := GetResourceMetadataURL(err2) + if result2 != "" { + t.Errorf("Expected GetResourceMetadataURL to return empty string, got %q", result2) + } + + // Test with non-OAuth error + err3 := fmt.Errorf("some other error") + + result3 := GetResourceMetadataURL(err3) + if result3 != "" { + t.Errorf("Expected GetResourceMetadataURL to return empty string for non-OAuth error, got %q", result3) + } +} + +func TestIsAuthorizationRequiredError(t *testing.T) { + // Test with base AuthorizationRequiredError (401 without OAuth handler) + metadataURL := "https://auth.example.com/.well-known/oauth-protected-resource" + err := &transport.AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + + // Verify IsAuthorizationRequiredError returns true + if !IsAuthorizationRequiredError(err) { + t.Errorf("Expected IsAuthorizationRequiredError to return true for AuthorizationRequiredError") + } + + // Verify GetResourceMetadataURL returns the correct URL + result := GetResourceMetadataURL(err) + if result != metadataURL { + t.Errorf("Expected GetResourceMetadataURL to return %q, got %q", metadataURL, result) + } + + // Test with OAuthAuthorizationRequiredError (different type) + oauthErr := &transport.OAuthAuthorizationRequiredError{ + Handler: transport.NewOAuthHandler(transport.OAuthConfig{}), + AuthorizationRequiredError: transport.AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + + // Verify IsOAuthAuthorizationRequiredError returns true + if !IsOAuthAuthorizationRequiredError(oauthErr) { + t.Errorf("Expected IsOAuthAuthorizationRequiredError to return true for OAuthAuthorizationRequiredError") + } + + // Verify GetResourceMetadataURL works with OAuth error too + result2 := GetResourceMetadataURL(oauthErr) + if result2 != metadataURL { + t.Errorf("Expected GetResourceMetadataURL to return %q, got %q", metadataURL, result2) + } + + // Test with non-authorization error + err3 := fmt.Errorf("some other error") + + // Verify IsAuthorizationRequiredError returns false + if IsAuthorizationRequiredError(err3) { + t.Errorf("Expected IsAuthorizationRequiredError to return false for non-authorization error") + } +} diff --git a/client/transport/oauth.go b/client/transport/oauth.go index 0fce1d80f..0c0d9986c 100644 --- a/client/transport/oauth.go +++ b/client/transport/oauth.go @@ -32,6 +32,10 @@ type OAuthConfig struct { // AuthServerMetadataURL is the URL to the OAuth server metadata // If empty, the client will attempt to discover it from the base URL AuthServerMetadataURL string + // ProtectedResourceMetadataURL is the URL to the OAuth protected resource metadata + // per RFC9728. If set, this URL will be used to discover the authorization server. + // This is typically extracted from the WWW-Authenticate header's resource_metadata parameter. + ProtectedResourceMetadataURL string // PKCEEnabled enables PKCE for the OAuth flow (recommended for public clients) PKCEEnabled bool // HTTPClient is an optional HTTP client to use for requests. @@ -351,16 +355,26 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada return } - // Try to discover the authorization server via OAuth Protected Resource - // as per RFC 9728 (https://datatracker.ietf.org/doc/html/rfc9728) + // Always extract base URL for fallback scenarios baseURL, err := h.extractBaseURL() if err != nil { h.metadataFetchErr = fmt.Errorf("failed to extract base URL: %w", err) return } + // Determine the protected resource metadata URL with priority: + // 1. Explicit config (ProtectedResourceMetadataURL from RFC9728 WWW-Authenticate header) + // 2. Constructed from base URL + var protectedResourceURL string + if h.config.ProtectedResourceMetadataURL != "" { + // Use explicitly configured protected resource metadata URL + protectedResourceURL = h.config.ProtectedResourceMetadataURL + } else { + // Fall back to constructing the URL from base URL + protectedResourceURL = baseURL + "/.well-known/oauth-protected-resource" + } + // Try to fetch the OAuth Protected Resource metadata - protectedResourceURL := baseURL + "/.well-known/oauth-protected-resource" req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil) if err != nil { h.metadataFetchErr = fmt.Errorf("failed to create protected resource request: %w", err) diff --git a/client/transport/sse.go b/client/transport/sse.go index b85fb6caf..e81b5434d 100644 --- a/client/transport/sse.go +++ b/client/transport/sse.go @@ -148,6 +148,9 @@ func (c *SSE) Start(ctx context.Context) error { if err.Error() == "no valid token available, authorization required" { return &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, } } return fmt.Errorf("failed to get authorization header: %w", err) @@ -162,10 +165,24 @@ func (c *SSE) Start(ctx context.Context) error { if resp.StatusCode != http.StatusOK { resp.Body.Close() - // Handle OAuth unauthorized error - if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil { - return &OAuthAuthorizationRequiredError{ - Handler: c.oauthHandler, + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")) + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, } } return fmt.Errorf("unexpected status code: %d", resp.StatusCode) @@ -377,6 +394,9 @@ func (c *SSE) SendRequest( if err.Error() == "no valid token available, authorization required" { return nil, &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, } } return nil, fmt.Errorf("failed to get authorization header: %w", err) @@ -419,17 +439,29 @@ func (c *SSE) SendRequest( return nil, fmt.Errorf("failed to read response body: %w", err) } - // Check if we got an error response if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - deleteResponseChan() + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")) + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } - // Handle OAuth unauthorized error - if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil { - return nil, &OAuthAuthorizationRequiredError{ - Handler: c.oauthHandler, + // No OAuth handler, return base authorization error + return nil, &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, } } + // Read error body return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body) } @@ -521,6 +553,9 @@ func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNoti if errors.Is(err, ErrOAuthAuthorizationRequired) { return &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, } } return fmt.Errorf("failed to get authorization header: %w", err) @@ -541,13 +576,28 @@ func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNoti defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - // Handle OAuth unauthorized error - if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil { - return &OAuthAuthorizationRequiredError{ - Handler: c.oauthHandler, + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")) + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, } } + // Handle other error responses body, _ := io.ReadAll(resp.Body) return fmt.Errorf( "notification failed with status %d: %s", diff --git a/client/transport/sse_oauth_test.go b/client/transport/sse_oauth_test.go index f95c9a9ff..6f90b5919 100644 --- a/client/transport/sse_oauth_test.go +++ b/client/transport/sse_oauth_test.go @@ -239,3 +239,66 @@ func TestSSE_IsOAuthEnabled(t *testing.T) { t.Errorf("Expected IsOAuthEnabled() to return true") } } + +func TestSSE_OAuthMetadataDiscovery(t *testing.T) { + // Test that we correctly extract resource_metadata URL from WWW-Authenticate header per RFC9728 + const expectedMetadataURL = "https://auth.example.com/.well-known/oauth-protected-resource" + + // Create a test server that returns 401 with WWW-Authenticate header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return 401 with WWW-Authenticate header containing resource_metadata + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+expectedMetadataURL+`"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + // Create a token store with a valid token so the request reaches the server + // The server will still return 401 to simulate token rejection + tokenStore := NewMemoryTokenStore() + validToken := &Token{ + AccessToken: "test-token", + TokenType: "Bearer", + RefreshToken: "refresh-token", + ExpiresIn: 3600, + ExpiresAt: time.Now().Add(1 * time.Hour), // Valid for 1 hour + } + if err := tokenStore.SaveToken(context.Background(), validToken); err != nil { + t.Fatalf("Failed to save token: %v", err) + } + + // Create OAuth config + oauthConfig := OAuthConfig{ + ClientID: "test-client", + RedirectURI: "http://localhost:8085/callback", + Scopes: []string{"mcp.read", "mcp.write"}, + TokenStore: tokenStore, + PKCEEnabled: true, + } + + // Create SSE with OAuth + transport, err := NewSSE(server.URL, WithOAuth(oauthConfig)) + if err != nil { + t.Fatalf("Failed to create SSE: %v", err) + } + + // Start SSE which will trigger 401 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = transport.Start(ctx) + + // Verify the error is an OAuthAuthorizationRequiredError + if err == nil { + t.Fatalf("Expected error, got nil") + } + + var oauthErr *OAuthAuthorizationRequiredError + if !errors.As(err, &oauthErr) { + t.Fatalf("Expected OAuthAuthorizationRequiredError, got %T: %v", err, err) + } + + // Verify the discovered metadata URL was extracted from WWW-Authenticate header + if oauthErr.ResourceMetadataURL != expectedMetadataURL { + t.Errorf("Expected ResourceMetadataURL to be %q, got %q", + expectedMetadataURL, oauthErr.ResourceMetadataURL) + } +} diff --git a/client/transport/streamable_http.go b/client/transport/streamable_http.go index 043c1e45a..587f5f5ab 100644 --- a/client/transport/streamable_http.go +++ b/client/transport/streamable_http.go @@ -237,9 +237,49 @@ func (c *StreamableHTTP) SetProtocolVersion(version string) { // ErrOAuthAuthorizationRequired is a sentinel error for OAuth authorization required var ErrOAuthAuthorizationRequired = errors.New("no valid token available, authorization required") +// ErrAuthorizationRequired is a sentinel error for authorization required (401) +var ErrAuthorizationRequired = errors.New("authorization required") + +// extractResourceMetadataURL extracts the resource_metadata parameter from a WWW-Authenticate header +// per RFC9728 Section 5.1. Returns empty string if not found. +// Example: Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource" +func extractResourceMetadataURL(wwwAuthHeader string) string { + // Parse: Bearer resource_metadata="https://..." ... + const prefix = "resource_metadata=\"" + + idx := strings.Index(wwwAuthHeader, prefix) + if idx == -1 { + return "" + } + + start := idx + len(prefix) + end := strings.Index(wwwAuthHeader[start:], "\"") + if end == -1 { + return "" + } + + return wwwAuthHeader[start : start+end] +} + +// AuthorizationRequiredError is returned when a 401 Unauthorized response is received. +// It contains the protected resource metadata URL from the WWW-Authenticate header if present. +type AuthorizationRequiredError struct { + ResourceMetadataURL string // Extracted from WWW-Authenticate header per RFC9728 +} + +func (e *AuthorizationRequiredError) Error() string { + return ErrAuthorizationRequired.Error() +} + +func (e *AuthorizationRequiredError) Unwrap() error { + return ErrAuthorizationRequired +} + // OAuthAuthorizationRequiredError is returned when OAuth authorization is required +// and an OAuth handler is available. type OAuthAuthorizationRequiredError struct { Handler *OAuthHandler + AuthorizationRequiredError } func (e *OAuthAuthorizationRequiredError) Error() string { @@ -287,10 +327,24 @@ func (c *StreamableHTTP) SendRequest( // Check if we got an error response if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - // Handle OAuth unauthorized error - if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil { - return nil, &OAuthAuthorizationRequiredError{ - Handler: c.oauthHandler, + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")) + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return nil, &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, } } @@ -384,6 +438,9 @@ func (c *StreamableHTTP) sendHTTP( if errors.Is(err, ErrOAuthAuthorizationRequired) { return nil, &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, } } return nil, fmt.Errorf("failed to get authorization header: %w", err) @@ -559,10 +616,24 @@ func (c *StreamableHTTP) SendNotification(ctx context.Context, notification mcp. defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - // Handle OAuth unauthorized error - if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil { - return &OAuthAuthorizationRequiredError{ - Handler: c.oauthHandler, + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")) + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, } } diff --git a/client/transport/streamable_http_oauth_test.go b/client/transport/streamable_http_oauth_test.go index adf29ba66..ad853559d 100644 --- a/client/transport/streamable_http_oauth_test.go +++ b/client/transport/streamable_http_oauth_test.go @@ -218,3 +218,117 @@ func TestStreamableHTTP_IsOAuthEnabled(t *testing.T) { t.Errorf("Expected IsOAuthEnabled() to return true") } } + +func TestStreamableHTTP_OAuthMetadataDiscovery(t *testing.T) { + // Test that we correctly extract resource_metadata URL from WWW-Authenticate header per RFC9728 + const expectedMetadataURL = "https://auth.example.com/.well-known/oauth-protected-resource" + + // Create a test server that returns 401 with WWW-Authenticate header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return 401 with WWW-Authenticate header containing resource_metadata + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+expectedMetadataURL+`"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + // Create a token store with a valid token so the request reaches the server + // The server will still return 401 to simulate token rejection + tokenStore := NewMemoryTokenStore() + validToken := &Token{ + AccessToken: "test-token", + TokenType: "Bearer", + RefreshToken: "refresh-token", + ExpiresIn: 3600, + ExpiresAt: time.Now().Add(1 * time.Hour), // Valid for 1 hour + } + if err := tokenStore.SaveToken(context.Background(), validToken); err != nil { + t.Fatalf("Failed to save token: %v", err) + } + + // Create OAuth config + oauthConfig := OAuthConfig{ + ClientID: "test-client", + RedirectURI: "http://localhost:8085/callback", + Scopes: []string{"mcp.read", "mcp.write"}, + TokenStore: tokenStore, + PKCEEnabled: true, + } + + // Create StreamableHTTP with OAuth + transport, err := NewStreamableHTTP(server.URL, WithHTTPOAuth(oauthConfig)) + if err != nil { + t.Fatalf("Failed to create StreamableHTTP: %v", err) + } + + // Send a request that will trigger 401 + _, err = transport.SendRequest(context.Background(), JSONRPCRequest{ + JSONRPC: "2.0", + ID: mcp.NewRequestId(1), + Method: "test", + }) + + // Verify the error is an OAuthAuthorizationRequiredError + if err == nil { + t.Fatalf("Expected error, got nil") + } + + var oauthErr *OAuthAuthorizationRequiredError + if !errors.As(err, &oauthErr) { + t.Fatalf("Expected OAuthAuthorizationRequiredError, got %T: %v", err, err) + } + + // Verify the discovered metadata URL was extracted from WWW-Authenticate header + if oauthErr.ResourceMetadataURL != expectedMetadataURL { + t.Errorf("Expected ResourceMetadataURL to be %q, got %q", + expectedMetadataURL, oauthErr.ResourceMetadataURL) + } +} + +func TestExtractResourceMetadataURL(t *testing.T) { + // Test the extractResourceMetadataURL helper function + testCases := []struct { + name string + wwwAuth string + expectedURL string + }{ + { + name: "Valid Bearer with resource_metadata", + wwwAuth: `Bearer resource_metadata="https://auth.example.com/.well-known/oauth-protected-resource"`, + expectedURL: "https://auth.example.com/.well-known/oauth-protected-resource", + }, + { + name: "Bearer with resource_metadata and other parameters", + wwwAuth: `Bearer realm="example", resource_metadata="https://example.com/metadata", scope="read write"`, + expectedURL: "https://example.com/metadata", + }, + { + name: "No resource_metadata parameter", + wwwAuth: `Bearer realm="example", scope="read"`, + expectedURL: "", + }, + { + name: "Empty header", + wwwAuth: "", + expectedURL: "", + }, + { + name: "Malformed resource_metadata (no closing quote)", + wwwAuth: `Bearer resource_metadata="https://example.com/metadata`, + expectedURL: "", + }, + { + name: "DPoP scheme with resource_metadata", + wwwAuth: `DPoP resource_metadata="https://dpop.example.com/.well-known/oauth-protected-resource"`, + expectedURL: "https://dpop.example.com/.well-known/oauth-protected-resource", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := extractResourceMetadataURL(tc.wwwAuth) + if result != tc.expectedURL { + t.Errorf("Expected %q, got %q", tc.expectedURL, result) + } + }) + } +} diff --git a/examples/oauth_client/README.md b/examples/oauth_client/README.md index a60bb7c5f..fa28dcbe2 100644 --- a/examples/oauth_client/README.md +++ b/examples/oauth_client/README.md @@ -5,6 +5,7 @@ This example demonstrates how to use the OAuth capabilities of the MCP Go client ## Features - OAuth 2.1 authentication with PKCE support +- **RFC9728 OAuth Protected Resource Metadata discovery** - Automatically discovers OAuth server metadata from `WWW-Authenticate` headers - Dynamic client registration - Authorization code flow - Token refresh @@ -25,16 +26,19 @@ go run main.go 1. The client attempts to initialize a connection to the MCP server 2. If the server requires OAuth authentication, it will return a 401 Unauthorized response -3. The client detects this and starts the OAuth flow: +3. **The client automatically extracts the OAuth metadata URL from the `WWW-Authenticate` header** (per [RFC9728](https://datatracker.ietf.org/doc/html/rfc9728)) + - Example header: `WWW-Authenticate: Bearer resource_metadata="https://auth.example.com/.well-known/oauth-protected-resource"` + - This URL tells the client where to find the OAuth authorization server configuration +4. The client detects the OAuth requirement and starts the OAuth flow: - Generates PKCE code verifier and challenge - Generates a state parameter for security - Opens a browser to the authorization URL - Starts a local server to handle the callback -4. The user authorizes the application in their browser -5. The authorization server redirects back to the local callback server -6. The client exchanges the authorization code for an access token -7. The client retries the initialization with the access token -8. The client can now make authenticated requests to the MCP server +5. The user authorizes the application in their browser +6. The authorization server redirects back to the local callback server +7. The client exchanges the authorization code for an access token +8. The client retries the initialization with the access token +9. The client can now make authenticated requests to the MCP server ## Configuration @@ -56,4 +60,19 @@ The example requests the following scopes: - `mcp.read` - Read access to MCP resources - `mcp.write` - Write access to MCP resources -You can modify the scopes in the `oauthConfig` to match the requirements of your MCP server. \ No newline at end of file +You can modify the scopes in the `oauthConfig` to match the requirements of your MCP server. + +## RFC9728 OAuth Protected Resource Metadata + +This example demonstrates automatic OAuth metadata discovery per [RFC9728](https://datatracker.ietf.org/doc/html/rfc9728). When the MCP server returns a 401 Unauthorized response with a `WWW-Authenticate` header containing the `resource_metadata` parameter, the client automatically extracts and uses this URL to discover the OAuth authorization server configuration. + +The example code demonstrates this with: + +```go +// Check if server provided OAuth metadata URL via WWW-Authenticate header (RFC9728) +if metadataURL := client.GetDiscoveredMetadataURL(err); metadataURL != "" { + fmt.Printf("Server provided OAuth metadata URL: %s\n", metadataURL) +} +``` + +This makes the OAuth flow more robust and standards-compliant, as the server explicitly tells clients where to find OAuth configuration rather than relying on well-known locations. \ No newline at end of file diff --git a/examples/oauth_client/main.go b/examples/oauth_client/main.go index 27d3b6180..7b64ac29c 100644 --- a/examples/oauth_client/main.go +++ b/examples/oauth_client/main.go @@ -108,6 +108,12 @@ func maybeAuthorize(err error) { if client.IsOAuthAuthorizationRequiredError(err) { fmt.Println("OAuth authorization required. Starting authorization flow...") + // Check if server provided OAuth metadata URL via WWW-Authenticate header (RFC9728) + if metadataURL := client.GetResourceMetadataURL(err); metadataURL != "" { + fmt.Printf("Server provided OAuth metadata URL: %s\n", metadataURL) + fmt.Println("The client will automatically use this URL for OAuth configuration.") + } + // Get the OAuth handler from the error oauthHandler := client.GetOAuthHandler(err)