Skip to content

Commit 9ce87a0

Browse files
committed
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
1 parent ecc6d8f commit 9ce87a0

File tree

9 files changed

+482
-33
lines changed

9 files changed

+482
-33
lines changed

client/oauth.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,18 @@ var GenerateCodeChallenge = transport.GenerateCodeChallenge
5757
// GenerateState generates a state parameter for OAuth
5858
var GenerateState = transport.GenerateState
5959

60+
// AuthorizationRequiredError is returned when a 401 Unauthorized response is received
61+
type AuthorizationRequiredError = transport.AuthorizationRequiredError
62+
6063
// OAuthAuthorizationRequiredError is returned when OAuth authorization is required
6164
type OAuthAuthorizationRequiredError = transport.OAuthAuthorizationRequiredError
6265

66+
// IsAuthorizationRequiredError checks if an error is an AuthorizationRequiredError
67+
func IsAuthorizationRequiredError(err error) bool {
68+
var target *AuthorizationRequiredError
69+
return errors.As(err, &target)
70+
}
71+
6372
// IsOAuthAuthorizationRequiredError checks if an error is an OAuthAuthorizationRequiredError
6473
func IsOAuthAuthorizationRequiredError(err error) bool {
6574
var target *OAuthAuthorizationRequiredError
@@ -74,3 +83,23 @@ func GetOAuthHandler(err error) *transport.OAuthHandler {
7483
}
7584
return nil
7685
}
86+
87+
// GetResourceMetadataURL extracts the protected resource metadata URL from an authorization error.
88+
// This URL is extracted from the WWW-Authenticate header per RFC9728 Section 5.1.
89+
// Works with both AuthorizationRequiredError and OAuthAuthorizationRequiredError.
90+
// Returns empty string if no metadata URL was discovered.
91+
func GetResourceMetadataURL(err error) string {
92+
// Try OAuthAuthorizationRequiredError first (contains AuthorizationRequiredError)
93+
var oauthErr *OAuthAuthorizationRequiredError
94+
if errors.As(err, &oauthErr) {
95+
return oauthErr.ResourceMetadataURL
96+
}
97+
98+
// Try base AuthorizationRequiredError
99+
var authErr *AuthorizationRequiredError
100+
if errors.As(err, &authErr) {
101+
return authErr.ResourceMetadataURL
102+
}
103+
104+
return ""
105+
}

client/oauth_test.go

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,93 @@ func TestIsOAuthAuthorizationRequiredError(t *testing.T) {
119119
if IsOAuthAuthorizationRequiredError(err2) {
120120
t.Errorf("Expected IsOAuthAuthorizationRequiredError to return false")
121121
}
122-
123122
// Verify GetOAuthHandler returns nil
124123
handler = GetOAuthHandler(err2)
125124
if handler != nil {
126125
t.Errorf("Expected GetOAuthHandler to return nil")
127126
}
128127
}
128+
129+
func TestGetResourceMetadataURL(t *testing.T) {
130+
// Test with error containing metadata URL
131+
metadataURL := "https://auth.example.com/.well-known/oauth-protected-resource"
132+
err := &transport.OAuthAuthorizationRequiredError{
133+
Handler: transport.NewOAuthHandler(transport.OAuthConfig{}),
134+
AuthorizationRequiredError: transport.AuthorizationRequiredError{
135+
ResourceMetadataURL: metadataURL,
136+
},
137+
}
138+
139+
// Verify GetResourceMetadataURL returns the correct URL
140+
result := GetResourceMetadataURL(err)
141+
if result != metadataURL {
142+
t.Errorf("Expected GetResourceMetadataURL to return %q, got %q", metadataURL, result)
143+
}
144+
145+
// Test with error containing no metadata URL
146+
err2 := &transport.OAuthAuthorizationRequiredError{
147+
Handler: transport.NewOAuthHandler(transport.OAuthConfig{}),
148+
AuthorizationRequiredError: transport.AuthorizationRequiredError{
149+
ResourceMetadataURL: "",
150+
},
151+
}
152+
153+
result2 := GetResourceMetadataURL(err2)
154+
if result2 != "" {
155+
t.Errorf("Expected GetResourceMetadataURL to return empty string, got %q", result2)
156+
}
157+
158+
// Test with non-OAuth error
159+
err3 := fmt.Errorf("some other error")
160+
161+
result3 := GetResourceMetadataURL(err3)
162+
if result3 != "" {
163+
t.Errorf("Expected GetResourceMetadataURL to return empty string for non-OAuth error, got %q", result3)
164+
}
165+
}
166+
167+
func TestIsAuthorizationRequiredError(t *testing.T) {
168+
// Test with base AuthorizationRequiredError (401 without OAuth handler)
169+
metadataURL := "https://auth.example.com/.well-known/oauth-protected-resource"
170+
err := &transport.AuthorizationRequiredError{
171+
ResourceMetadataURL: metadataURL,
172+
}
173+
174+
// Verify IsAuthorizationRequiredError returns true
175+
if !IsAuthorizationRequiredError(err) {
176+
t.Errorf("Expected IsAuthorizationRequiredError to return true for AuthorizationRequiredError")
177+
}
178+
179+
// Verify GetResourceMetadataURL returns the correct URL
180+
result := GetResourceMetadataURL(err)
181+
if result != metadataURL {
182+
t.Errorf("Expected GetResourceMetadataURL to return %q, got %q", metadataURL, result)
183+
}
184+
185+
// Test with OAuthAuthorizationRequiredError (different type)
186+
oauthErr := &transport.OAuthAuthorizationRequiredError{
187+
Handler: transport.NewOAuthHandler(transport.OAuthConfig{}),
188+
AuthorizationRequiredError: transport.AuthorizationRequiredError{
189+
ResourceMetadataURL: metadataURL,
190+
},
191+
}
192+
193+
// Verify IsOAuthAuthorizationRequiredError returns true
194+
if !IsOAuthAuthorizationRequiredError(oauthErr) {
195+
t.Errorf("Expected IsOAuthAuthorizationRequiredError to return true for OAuthAuthorizationRequiredError")
196+
}
197+
198+
// Verify GetResourceMetadataURL works with OAuth error too
199+
result2 := GetResourceMetadataURL(oauthErr)
200+
if result2 != metadataURL {
201+
t.Errorf("Expected GetResourceMetadataURL to return %q, got %q", metadataURL, result2)
202+
}
203+
204+
// Test with non-authorization error
205+
err3 := fmt.Errorf("some other error")
206+
207+
// Verify IsAuthorizationRequiredError returns false
208+
if IsAuthorizationRequiredError(err3) {
209+
t.Errorf("Expected IsAuthorizationRequiredError to return false for non-authorization error")
210+
}
211+
}

client/transport/oauth.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ type OAuthConfig struct {
3232
// AuthServerMetadataURL is the URL to the OAuth server metadata
3333
// If empty, the client will attempt to discover it from the base URL
3434
AuthServerMetadataURL string
35+
// ProtectedResourceMetadataURL is the URL to the OAuth protected resource metadata
36+
// per RFC9728. If set, this URL will be used to discover the authorization server.
37+
// This is typically extracted from the WWW-Authenticate header's resource_metadata parameter.
38+
ProtectedResourceMetadataURL string
3539
// PKCEEnabled enables PKCE for the OAuth flow (recommended for public clients)
3640
PKCEEnabled bool
3741
// HTTPClient is an optional HTTP client to use for requests.
@@ -351,16 +355,26 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada
351355
return
352356
}
353357

354-
// Try to discover the authorization server via OAuth Protected Resource
355-
// as per RFC 9728 (https://datatracker.ietf.org/doc/html/rfc9728)
358+
// Always extract base URL for fallback scenarios
356359
baseURL, err := h.extractBaseURL()
357360
if err != nil {
358361
h.metadataFetchErr = fmt.Errorf("failed to extract base URL: %w", err)
359362
return
360363
}
361364

365+
// Determine the protected resource metadata URL with priority:
366+
// 1. Explicit config (ProtectedResourceMetadataURL from RFC9728 WWW-Authenticate header)
367+
// 2. Constructed from base URL
368+
var protectedResourceURL string
369+
if h.config.ProtectedResourceMetadataURL != "" {
370+
// Use explicitly configured protected resource metadata URL
371+
protectedResourceURL = h.config.ProtectedResourceMetadataURL
372+
} else {
373+
// Fall back to constructing the URL from base URL
374+
protectedResourceURL = baseURL + "/.well-known/oauth-protected-resource"
375+
}
376+
362377
// Try to fetch the OAuth Protected Resource metadata
363-
protectedResourceURL := baseURL + "/.well-known/oauth-protected-resource"
364378
req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil)
365379
if err != nil {
366380
h.metadataFetchErr = fmt.Errorf("failed to create protected resource request: %w", err)

client/transport/sse.go

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ func (c *SSE) Start(ctx context.Context) error {
148148
if err.Error() == "no valid token available, authorization required" {
149149
return &OAuthAuthorizationRequiredError{
150150
Handler: c.oauthHandler,
151+
AuthorizationRequiredError: AuthorizationRequiredError{
152+
ResourceMetadataURL: "", // No response available in this code path
153+
},
151154
}
152155
}
153156
return fmt.Errorf("failed to get authorization header: %w", err)
@@ -162,10 +165,24 @@ func (c *SSE) Start(ctx context.Context) error {
162165

163166
if resp.StatusCode != http.StatusOK {
164167
resp.Body.Close()
165-
// Handle OAuth unauthorized error
166-
if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil {
167-
return &OAuthAuthorizationRequiredError{
168-
Handler: c.oauthHandler,
168+
// Handle unauthorized error
169+
if resp.StatusCode == http.StatusUnauthorized {
170+
// Extract discovered metadata URL per RFC9728
171+
metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate"))
172+
173+
// If OAuth handler exists, return OAuth-specific error
174+
if c.oauthHandler != nil {
175+
return &OAuthAuthorizationRequiredError{
176+
Handler: c.oauthHandler,
177+
AuthorizationRequiredError: AuthorizationRequiredError{
178+
ResourceMetadataURL: metadataURL,
179+
},
180+
}
181+
}
182+
183+
// No OAuth handler, return base authorization error
184+
return &AuthorizationRequiredError{
185+
ResourceMetadataURL: metadataURL,
169186
}
170187
}
171188
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
@@ -377,6 +394,9 @@ func (c *SSE) SendRequest(
377394
if err.Error() == "no valid token available, authorization required" {
378395
return nil, &OAuthAuthorizationRequiredError{
379396
Handler: c.oauthHandler,
397+
AuthorizationRequiredError: AuthorizationRequiredError{
398+
ResourceMetadataURL: "", // No response available in this code path
399+
},
380400
}
381401
}
382402
return nil, fmt.Errorf("failed to get authorization header: %w", err)
@@ -419,17 +439,29 @@ func (c *SSE) SendRequest(
419439
return nil, fmt.Errorf("failed to read response body: %w", err)
420440
}
421441

422-
// Check if we got an error response
423442
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
424-
deleteResponseChan()
443+
// Handle unauthorized error
444+
if resp.StatusCode == http.StatusUnauthorized {
445+
// Extract discovered metadata URL per RFC9728
446+
metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate"))
447+
448+
// If OAuth handler exists, return OAuth-specific error
449+
if c.oauthHandler != nil {
450+
return nil, &OAuthAuthorizationRequiredError{
451+
Handler: c.oauthHandler,
452+
AuthorizationRequiredError: AuthorizationRequiredError{
453+
ResourceMetadataURL: metadataURL,
454+
},
455+
}
456+
}
425457

426-
// Handle OAuth unauthorized error
427-
if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil {
428-
return nil, &OAuthAuthorizationRequiredError{
429-
Handler: c.oauthHandler,
458+
// No OAuth handler, return base authorization error
459+
return nil, &AuthorizationRequiredError{
460+
ResourceMetadataURL: metadataURL,
430461
}
431462
}
432463

464+
// Read error body
433465
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body)
434466
}
435467

@@ -521,6 +553,9 @@ func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNoti
521553
if errors.Is(err, ErrOAuthAuthorizationRequired) {
522554
return &OAuthAuthorizationRequiredError{
523555
Handler: c.oauthHandler,
556+
AuthorizationRequiredError: AuthorizationRequiredError{
557+
ResourceMetadataURL: "", // No response available in this code path
558+
},
524559
}
525560
}
526561
return fmt.Errorf("failed to get authorization header: %w", err)
@@ -541,13 +576,28 @@ func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNoti
541576
defer resp.Body.Close()
542577

543578
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
544-
// Handle OAuth unauthorized error
545-
if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil {
546-
return &OAuthAuthorizationRequiredError{
547-
Handler: c.oauthHandler,
579+
// Handle unauthorized error
580+
if resp.StatusCode == http.StatusUnauthorized {
581+
// Extract discovered metadata URL per RFC9728
582+
metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate"))
583+
584+
// If OAuth handler exists, return OAuth-specific error
585+
if c.oauthHandler != nil {
586+
return &OAuthAuthorizationRequiredError{
587+
Handler: c.oauthHandler,
588+
AuthorizationRequiredError: AuthorizationRequiredError{
589+
ResourceMetadataURL: metadataURL,
590+
},
591+
}
592+
}
593+
594+
// No OAuth handler, return base authorization error
595+
return &AuthorizationRequiredError{
596+
ResourceMetadataURL: metadataURL,
548597
}
549598
}
550599

600+
// Handle other error responses
551601
body, _ := io.ReadAll(resp.Body)
552602
return fmt.Errorf(
553603
"notification failed with status %d: %s",

client/transport/sse_oauth_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,66 @@ func TestSSE_IsOAuthEnabled(t *testing.T) {
239239
t.Errorf("Expected IsOAuthEnabled() to return true")
240240
}
241241
}
242+
243+
func TestSSE_OAuthMetadataDiscovery(t *testing.T) {
244+
// Test that we correctly extract resource_metadata URL from WWW-Authenticate header per RFC9728
245+
const expectedMetadataURL = "https://auth.example.com/.well-known/oauth-protected-resource"
246+
247+
// Create a test server that returns 401 with WWW-Authenticate header
248+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
249+
// Return 401 with WWW-Authenticate header containing resource_metadata
250+
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+expectedMetadataURL+`"`)
251+
w.WriteHeader(http.StatusUnauthorized)
252+
}))
253+
defer server.Close()
254+
255+
// Create a token store with a valid token so the request reaches the server
256+
// The server will still return 401 to simulate token rejection
257+
tokenStore := NewMemoryTokenStore()
258+
validToken := &Token{
259+
AccessToken: "test-token",
260+
TokenType: "Bearer",
261+
RefreshToken: "refresh-token",
262+
ExpiresIn: 3600,
263+
ExpiresAt: time.Now().Add(1 * time.Hour), // Valid for 1 hour
264+
}
265+
if err := tokenStore.SaveToken(context.Background(), validToken); err != nil {
266+
t.Fatalf("Failed to save token: %v", err)
267+
}
268+
269+
// Create OAuth config
270+
oauthConfig := OAuthConfig{
271+
ClientID: "test-client",
272+
RedirectURI: "http://localhost:8085/callback",
273+
Scopes: []string{"mcp.read", "mcp.write"},
274+
TokenStore: tokenStore,
275+
PKCEEnabled: true,
276+
}
277+
278+
// Create SSE with OAuth
279+
transport, err := NewSSE(server.URL, WithOAuth(oauthConfig))
280+
if err != nil {
281+
t.Fatalf("Failed to create SSE: %v", err)
282+
}
283+
284+
// Start SSE which will trigger 401
285+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
286+
defer cancel()
287+
err = transport.Start(ctx)
288+
289+
// Verify the error is an OAuthAuthorizationRequiredError
290+
if err == nil {
291+
t.Fatalf("Expected error, got nil")
292+
}
293+
294+
var oauthErr *OAuthAuthorizationRequiredError
295+
if !errors.As(err, &oauthErr) {
296+
t.Fatalf("Expected OAuthAuthorizationRequiredError, got %T: %v", err, err)
297+
}
298+
299+
// Verify the discovered metadata URL was extracted from WWW-Authenticate header
300+
if oauthErr.ResourceMetadataURL != expectedMetadataURL {
301+
t.Errorf("Expected ResourceMetadataURL to be %q, got %q",
302+
expectedMetadataURL, oauthErr.ResourceMetadataURL)
303+
}
304+
}

0 commit comments

Comments
 (0)