diff --git a/client/transport/oauth.go b/client/transport/oauth.go index f849a4854..ae184a22b 100644 --- a/client/transport/oauth.go +++ b/client/transport/oauth.go @@ -398,6 +398,36 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada // If we can't get the protected resource metadata, try OAuth Authorization Server discovery if resp.StatusCode != http.StatusOK { + // RFC 9728 allows the server to advertise a protected resource metadata URL + // via the WWW-Authenticate header when direct discovery fails. + if resourceMetadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")); resourceMetadataURL != "" { + protectedResource, err := h.fetchProtectedResourceFromURL(ctx, resourceMetadataURL) + if err == nil && len(protectedResource.AuthorizationServers) > 0 { + authServerURL := protectedResource.AuthorizationServers[0] + authMetadataURL, err := buildWellKnownURL(authServerURL, "oauth-authorization-server") + if err == nil { + h.fetchMetadataFromURL(ctx, authMetadataURL) + if h.serverMetadata != nil { + return + } + } + + openidMetadataURL, err := buildWellKnownURL(authServerURL, "openid-configuration") + if err == nil { + h.fetchMetadataFromURL(ctx, openidMetadataURL) + if h.serverMetadata != nil { + return + } + } + + metadata, err := h.getDefaultEndpoints(authServerURL) + if err == nil { + h.serverMetadata = metadata + return + } + } + } + authMetadataURL, err := buildWellKnownURL(baseURL, "oauth-authorization-server") if err != nil { h.metadataFetchErr = fmt.Errorf("failed to build authorization server metadata URL: %w", err) @@ -495,6 +525,57 @@ func buildWellKnownURL(baseURL string, suffix string) (string, error) { return root + "/.well-known/" + suffix + path, nil } +func extractResourceMetadataURL(wwwAuthenticateHeaders []string) string { + for _, header := range wwwAuthenticateHeaders { + for _, param := range strings.Split(header, ",") { + param = strings.TrimSpace(param) + if param == "" { + continue + } + + key, value, found := strings.Cut(param, "=") + if !found || !strings.EqualFold(strings.TrimSpace(key), "resource_metadata") { + continue + } + + value = strings.TrimSpace(value) + value = strings.Trim(value, "\"") + if value != "" { + return value + } + } + } + + return "" +} + +func (h *OAuthHandler) fetchProtectedResourceFromURL(ctx context.Context, protectedResourceURL string) (*OAuthProtectedResource, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create protected resource request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("MCP-Protocol-Version", "2025-03-26") + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send protected resource request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("protected resource request failed with status %d", resp.StatusCode) + } + + var protectedResource OAuthProtectedResource + if err := json.NewDecoder(resp.Body).Decode(&protectedResource); err != nil { + return nil, fmt.Errorf("failed to decode protected resource response: %w", err) + } + + return &protectedResource, nil +} + // fetchMetadataFromURL fetches and parses OAuth server metadata from a URL func (h *OAuthHandler) fetchMetadataFromURL(ctx context.Context, metadataURL string) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) diff --git a/client/transport/oauth_test.go b/client/transport/oauth_test.go index 2ddca1636..cf444a692 100644 --- a/client/transport/oauth_test.go +++ b/client/transport/oauth_test.go @@ -987,6 +987,106 @@ func TestOAuthHandler_GetServerMetadata_PathAwareDiscovery(t *testing.T) { assert.Equal(t, server.URL+"/oauth/googledrive/token", metadata.TokenEndpoint) } +func TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeader(t *testing.T) { + protectedResourceRequested := false + headerResourceMetadataRequested := false + authServerRequested := false + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-protected-resource": + protectedResourceRequested = true + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_request", resource_metadata="`+server.URL+`/.well-known/oauth-protected-resource/googledrive"`) + w.WriteHeader(http.StatusUnauthorized) + case "/.well-known/oauth-protected-resource/googledrive": + headerResourceMetadataRequested = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OAuthProtectedResource{ + AuthorizationServers: []string{server.URL + "/oauth/googledrive"}, + }) + case "/.well-known/oauth-authorization-server/oauth/googledrive": + authServerRequested = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthServerMetadata{ + Issuer: server.URL + "/oauth/googledrive", + AuthorizationEndpoint: server.URL + "/oauth/googledrive/authorize", + TokenEndpoint: server.URL + "/oauth/googledrive/token", + RegistrationEndpoint: server.URL + "/oauth/googledrive/register", + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + handler := NewOAuthHandler(OAuthConfig{ + ClientID: "test-client", + RedirectURI: "http://localhost/callback", + TokenStore: NewMemoryTokenStore(), + }) + handler.SetBaseURL(server.URL) + + metadata, err := handler.GetServerMetadata(context.Background()) + require.NoError(t, err) + assert.True(t, protectedResourceRequested) + assert.True(t, headerResourceMetadataRequested) + assert.True(t, authServerRequested) + assert.Equal(t, server.URL+"/oauth/googledrive", metadata.Issuer) + assert.Equal(t, server.URL+"/oauth/googledrive/authorize", metadata.AuthorizationEndpoint) + assert.Equal(t, server.URL+"/oauth/googledrive/token", metadata.TokenEndpoint) +} + +func TestOAuthHandler_GetServerMetadata_UsesResourceMetadataHeaderWithWhitespace(t *testing.T) { + protectedResourceRequested := false + headerResourceMetadataRequested := false + authServerRequested := false + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-protected-resource": + protectedResourceRequested = true + w.Header().Add("WWW-Authenticate", `Bearer error="invalid_request", resource_metadata = "`+server.URL+`/.well-known/oauth-protected-resource/googledrive"`) + w.WriteHeader(http.StatusUnauthorized) + case "/.well-known/oauth-protected-resource/googledrive": + headerResourceMetadataRequested = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OAuthProtectedResource{ + AuthorizationServers: []string{server.URL + "/oauth/googledrive"}, + }) + case "/.well-known/oauth-authorization-server/oauth/googledrive": + authServerRequested = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthServerMetadata{ + Issuer: server.URL + "/oauth/googledrive", + AuthorizationEndpoint: server.URL + "/oauth/googledrive/authorize", + TokenEndpoint: server.URL + "/oauth/googledrive/token", + RegistrationEndpoint: server.URL + "/oauth/googledrive/register", + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + handler := NewOAuthHandler(OAuthConfig{ + ClientID: "test-client", + RedirectURI: "http://localhost/callback", + TokenStore: NewMemoryTokenStore(), + }) + handler.SetBaseURL(server.URL) + + metadata, err := handler.GetServerMetadata(context.Background()) + require.NoError(t, err) + assert.True(t, protectedResourceRequested) + assert.True(t, headerResourceMetadataRequested) + assert.True(t, authServerRequested) + assert.Equal(t, server.URL+"/oauth/googledrive", metadata.Issuer) + assert.Equal(t, server.URL+"/oauth/googledrive/authorize", metadata.AuthorizationEndpoint) + assert.Equal(t, server.URL+"/oauth/googledrive/token", metadata.TokenEndpoint) +} + // TestOAuthHandler_RefreshToken_GitHubErrorIn200Response tests that we properly detect // GitHub's non-spec-compliant behavior of returning HTTP 200 with error details in the JSON body func TestOAuthHandler_RefreshToken_GitHubErrorIn200Response(t *testing.T) {