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)