-
Notifications
You must be signed in to change notification settings - Fork 740
feat: implement RFC9728 OAuth Protected Resource Metadata discovery #637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+35
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Configuration field cannot be updated after discovery.
Consider adding Based on PR comments discussion. 🤖 Prompt for AI Agents |
||
| // 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -148,6 +148,9 @@ func (c *SSE) Start(ctx context.Context) error { | |
| if err.Error() == "no valid token available, authorization required" { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Use errors.Is instead of string comparison. Lines 148 and 394 check
Replace with: -if err.Error() == "no valid token available, authorization required" {
+if errors.Is(err, ErrOAuthAuthorizationRequired) {As per coding guidelines: "Error handling: return sentinel errors, wrap with fmt.Errorf, and check with errors.Is/As." Also applies to: 394-394 🤖 Prompt for AI Agents |
||
| 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", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Helper functions are correct but API is incomplete for RFC9728 workflow.
The error helpers correctly extract the discovered metadata URL, but there's no corresponding API to apply the discovered URL:
GetResourceMetadataURL(err)extracts the URL.UpdateOAuthMetadataURL(client, url)or similar to reconfigure the handler.For a complete RFC9728 implementation, add a helper to update the handler's
ProtectedResourceMetadataURLand reset cached metadata, or document that users must create a new client with the discovered URL in the config.Based on PR comments discussion.
Also applies to: 87-105
🤖 Prompt for AI Agents