diff --git a/go.mod b/go.mod index 2b7f16fb..29a4d0ba 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.10 require ( fyne.io/systray v1.11.0 + github.com/BurntSushi/toml v1.6.0 github.com/Microsoft/go-winio v0.6.2 github.com/blevesearch/bleve/v2 v2.5.2 github.com/dop251/goja v0.0.0-20251103141225-af2ceb9156d7 @@ -14,7 +15,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf - github.com/mark3labs/mcp-go v0.43.1 + github.com/mark3labs/mcp-go v0.44.0-beta.2 github.com/oklog/ulid/v2 v2.1.1 github.com/pkoukk/tiktoken-go v0.1.8 github.com/prometheus/client_golang v1.23.2 @@ -40,7 +41,6 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect - github.com/BurntSushi/toml v1.6.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect diff --git a/go.sum b/go.sum index e2ab9011..968c6663 100644 --- a/go.sum +++ b/go.sum @@ -156,8 +156,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.43.1 h1:WXNVd+bRM/7mOzCM9zulSwn/s9YEdAxbmeh9LoRHEXY= -github.com/mark3labs/mcp-go v0.43.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mark3labs/mcp-go v0.44.0-beta.2 h1:gfUT0m77E4odfgiHkqV/E+MQVaQ06rbutW7Ln0JRkBA= +github.com/mark3labs/mcp-go v0.44.0-beta.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/oauth/config.go b/internal/oauth/config.go index af2f7aea..66e3884b 100644 --- a/internal/oauth/config.go +++ b/internal/oauth/config.go @@ -617,25 +617,41 @@ func createOAuthConfigInternal(serverConfig *config.ServerConfig, storage *stora zap.String("redirect_uri", callbackServer.RedirectURI), zap.Int("port", callbackServer.Port)) - // Try to construct explicit metadata URLs to avoid timeout issues during auto-discovery - // Extract base URL from server URL for .well-known endpoints - baseURL, err := parseBaseURL(serverConfig.URL) - if err != nil { - logger.Warn("Failed to parse base URL for OAuth metadata", - zap.String("server", serverConfig.Name), - zap.String("url", serverConfig.URL), - zap.Error(err)) - baseURL = "" - } - + // Try to find a working metadata URL by validating multiple URL patterns + // Different servers use different URL formats: + // - Smithery: Uses separate domains (server.smithery.ai/x for MCP, auth.smithery.ai/x for OAuth) + // OAuth metadata at: https://auth.smithery.ai/.well-known/oauth-authorization-server/googledrive + // - Cloudflare: Same domain for MCP and OAuth + // OAuth metadata at: https://logs.mcp.cloudflare.com/.well-known/oauth-authorization-server var authServerMetadataURL string - if baseURL != "" { - authServerMetadataURL = baseURL + "/.well-known/oauth-authorization-server" - logger.Info("Using explicit OAuth metadata URL to avoid auto-discovery timeouts", - zap.String("server", serverConfig.Name), - zap.String("metadata_url", authServerMetadataURL)) + if serverConfig.URL != "" { + // First, try to discover the auth server URL from Protected Resource Metadata + // This is necessary for servers like Smithery that use separate domains + authServerURL := DiscoverAuthServerURL(serverConfig.URL, 5*time.Second) + urlToUse := serverConfig.URL + if authServerURL != "" { + urlToUse = authServerURL + logger.Info("Using discovered auth server URL for metadata discovery", + zap.String("server", serverConfig.Name), + zap.String("mcp_url", serverConfig.URL), + zap.String("auth_server_url", authServerURL)) + } + + // Now find the working metadata URL using the auth server URL (or server URL as fallback) + workingURL, err := FindWorkingMetadataURL(urlToUse, 10*time.Second) + if err != nil { + logger.Warn("Could not find working OAuth metadata URL, will rely on auto-discovery", + zap.String("server", serverConfig.Name), + zap.String("url_tried", urlToUse), + zap.Error(err)) + } else { + authServerMetadataURL = workingURL + logger.Info("Using validated OAuth metadata URL", + zap.String("server", serverConfig.Name), + zap.String("metadata_url", authServerMetadataURL)) + } } else { - logger.Info("Skipping OAuth metadata URL due to URL parsing issues", + logger.Info("Skipping OAuth metadata URL - no server URL configured", zap.String("server", serverConfig.Name)) } diff --git a/internal/oauth/discovery.go b/internal/oauth/discovery.go index 70bf0969..a5607151 100644 --- a/internal/oauth/discovery.go +++ b/internal/oauth/discovery.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" @@ -33,6 +34,228 @@ type OAuthServerMetadata struct { RegistrationEndpoint string `json:"registration_endpoint,omitempty"` } +// BuildRFC8414MetadataURLs constructs OAuth Authorization Server Metadata URLs per RFC 8414. +// +// RFC 8414 Section 3.1 specifies that when the issuer URL contains a path component, +// the well-known path should be inserted between the host and the path: +// +// https://example.com/path → https://example.com/.well-known/oauth-authorization-server/path +// +// However, some servers (like the current codebase's test servers) expect the path appended: +// +// https://example.com/path → https://example.com/path/.well-known/oauth-authorization-server +// +// This function returns both variants to try, with RFC 8414 compliant path first. +// +// For URLs without a path: +// +// https://example.com → https://example.com/.well-known/oauth-authorization-server +func BuildRFC8414MetadataURLs(authServerURL string) []string { + u, err := url.Parse(authServerURL) + if err != nil { + // Fallback to simple concatenation if URL parsing fails + return []string{authServerURL + "/.well-known/oauth-authorization-server"} + } + + // Clean the path (remove trailing slash) + path := strings.TrimSuffix(u.Path, "/") + + // Base URL without path + baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host) + + var urls []string + + if path == "" || path == "/" { + // No path - simple case + urls = append(urls, baseURL+"/.well-known/oauth-authorization-server") + } else { + // Has path - RFC 8414 says insert .well-known between host and path + // Example: https://auth.smithery.ai/googledrive + // → https://auth.smithery.ai/.well-known/oauth-authorization-server/googledrive + rfc8414URL := baseURL + "/.well-known/oauth-authorization-server" + path + urls = append(urls, rfc8414URL) + + // Also try legacy path (appending .well-known after path) for backward compatibility + // Example: https://auth.smithery.ai/googledrive/.well-known/oauth-authorization-server + legacyURL := strings.TrimSuffix(authServerURL, "/") + "/.well-known/oauth-authorization-server" + urls = append(urls, legacyURL) + + // Also try base URL without path suffix for servers like Cloudflare that host + // metadata at the root level regardless of the MCP server path + // Example: https://logs.mcp.cloudflare.com/mcp + // → https://logs.mcp.cloudflare.com/.well-known/oauth-authorization-server + baseOnlyURL := baseURL + "/.well-known/oauth-authorization-server" + urls = append(urls, baseOnlyURL) + } + + return urls +} + +// FindWorkingMetadataURL tries each URL from BuildRFC8414MetadataURLs and returns the first one +// that successfully returns valid OAuth metadata. This is used to pre-validate which URL format +// works for a given server before passing it to the OAuth handler. +// +// Returns the working URL and nil error on success, or empty string and error if none work. +func FindWorkingMetadataURL(serverURL string, timeout time.Duration) (string, error) { + logger := zap.L().Named("oauth.discovery") + + urls := BuildRFC8414MetadataURLs(serverURL) + var lastErr error + + for _, metadataURL := range urls { + logger.Debug("Validating OAuth metadata URL", + zap.String("server_url", serverURL), + zap.String("metadata_url", metadataURL)) + + metadata, err := fetchAuthorizationServerMetadata(metadataURL, timeout) + if err != nil { + lastErr = err + logger.Debug("Metadata URL validation failed", + zap.String("metadata_url", metadataURL), + zap.Error(err)) + continue + } + + // Validate required fields + if metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" { + lastErr = fmt.Errorf("metadata missing required fields") + logger.Debug("Metadata incomplete", + zap.String("metadata_url", metadataURL)) + continue + } + + logger.Info("Found working OAuth metadata URL", + zap.String("server_url", serverURL), + zap.String("metadata_url", metadataURL), + zap.String("issuer", metadata.Issuer)) + return metadataURL, nil + } + + return "", fmt.Errorf("no working metadata URL found for %s: %w", serverURL, lastErr) +} + +// discoverAuthServerMetadataWithFallback attempts to discover OAuth Authorization Server Metadata +// by trying multiple URL patterns per RFC 8414 Section 3.1. +// +// It first tries the RFC 8414 compliant URL (well-known inserted between host and path), +// then falls back to the legacy URL (well-known appended after path). +// +// Returns the first successfully discovered metadata and the URL that worked. +func discoverAuthServerMetadataWithFallback(authServerURL string, timeout time.Duration) (*OAuthServerMetadata, string, error) { + logger := zap.L().Named("oauth.discovery") + + urls := BuildRFC8414MetadataURLs(authServerURL) + var lastErr error + var urlsChecked []string + + for _, metadataURL := range urls { + urlsChecked = append(urlsChecked, metadataURL) + logger.Debug("Trying OAuth metadata URL", + zap.String("auth_server", authServerURL), + zap.String("metadata_url", metadataURL)) + + metadata, err := fetchAuthorizationServerMetadata(metadataURL, timeout) + if err == nil { + // Validate required fields + if metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" { + lastErr = fmt.Errorf("metadata missing required fields: authorization_endpoint=%q, token_endpoint=%q", + metadata.AuthorizationEndpoint, metadata.TokenEndpoint) + logger.Debug("OAuth metadata incomplete, trying next URL", + zap.String("metadata_url", metadataURL), + zap.Error(lastErr)) + continue + } + + logger.Info("✅ OAuth metadata discovered", + zap.String("auth_server", authServerURL), + zap.String("metadata_url", metadataURL), + zap.String("issuer", metadata.Issuer), + zap.String("authorization_endpoint", metadata.AuthorizationEndpoint), + zap.String("token_endpoint", metadata.TokenEndpoint)) + return metadata, metadataURL, nil + } + + lastErr = err + logger.Debug("OAuth metadata fetch failed, trying next URL", + zap.String("metadata_url", metadataURL), + zap.Error(err)) + } + + return nil, "", fmt.Errorf("failed to discover OAuth metadata from %v: %w", urlsChecked, lastErr) +} + +// DiscoverAuthServerURL attempts to discover the OAuth authorization server URL +// by fetching the Protected Resource Metadata (RFC 9728) from the MCP server. +// Returns the first authorization server URL, or empty string if discovery fails. +// +// This is important for servers like Smithery that use separate domains: +// - MCP Server: server.smithery.ai/googledrive +// - Auth Server: auth.smithery.ai/googledrive +func DiscoverAuthServerURL(serverURL string, timeout time.Duration) string { + logger := zap.L().Named("oauth.discovery") + + // First, make a preflight request to get the WWW-Authenticate header with resource_metadata + client := &http.Client{Timeout: timeout} + resp, err := client.Post(serverURL, "application/json", strings.NewReader("{}")) + if err != nil { + logger.Debug("Preflight request failed for auth server discovery", + zap.String("server_url", serverURL), + zap.Error(err)) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + logger.Debug("Server did not return 401, cannot discover auth server", + zap.String("server_url", serverURL), + zap.Int("status_code", resp.StatusCode)) + return "" + } + + // Extract resource_metadata URL from WWW-Authenticate header + wwwAuth := resp.Header.Get("WWW-Authenticate") + metadataURL := ExtractResourceMetadataURL(wwwAuth) + if metadataURL == "" { + // Try constructing PRM URL using RFC 9728 pattern + u, err := url.Parse(serverURL) + if err != nil { + return "" + } + path := strings.TrimSuffix(u.Path, "/") + baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host) + if path != "" { + metadataURL = baseURL + "/.well-known/oauth-protected-resource" + path + } else { + metadataURL = baseURL + "/.well-known/oauth-protected-resource" + } + logger.Debug("Constructed PRM URL from server URL", + zap.String("server_url", serverURL), + zap.String("prm_url", metadataURL)) + } + + // Fetch Protected Resource Metadata + metadata, err := DiscoverProtectedResourceMetadata(metadataURL, timeout) + if err != nil { + logger.Debug("Failed to fetch Protected Resource Metadata", + zap.String("metadata_url", metadataURL), + zap.Error(err)) + return "" + } + + // Return first authorization server + if len(metadata.AuthorizationServers) > 0 { + authServer := metadata.AuthorizationServers[0] + logger.Info("Discovered OAuth authorization server from PRM", + zap.String("server_url", serverURL), + zap.String("auth_server", authServer)) + return authServer + } + + logger.Debug("PRM has no authorization_servers", + zap.String("server_url", serverURL)) + return "" +} + // ExtractResourceMetadataURL parses WWW-Authenticate header to extract resource_metadata URL // Format: Bearer error="invalid_request", resource_metadata="https://..." func ExtractResourceMetadataURL(wwwAuthHeader string) string { @@ -291,13 +514,14 @@ func DetectOAuthAvailability(baseURL string, timeout time.Duration) bool { // OAuthMetadataValidationResult contains the result of OAuth metadata validation type OAuthMetadataValidationResult struct { - Valid bool - ProtectedResourceMetadata *ProtectedResourceMetadata - AuthorizationServerMetadata *OAuthServerMetadata - ProtectedResourceMetadataURL string - AuthorizationServerMetadataURL string - ProtectedResourceError error - AuthorizationServerError error + Valid bool + ProtectedResourceMetadata *ProtectedResourceMetadata + AuthorizationServerMetadata *OAuthServerMetadata + ProtectedResourceMetadataURL string + AuthorizationServerMetadataURL string + AuthorizationServerMetadataURLsTried []string // All URLs tried (RFC 8414 fallback) + ProtectedResourceError error + AuthorizationServerError error } // ValidateOAuthMetadata performs pre-flight validation of OAuth metadata. @@ -351,18 +575,19 @@ func ValidateOAuthMetadata(serverURL, serverName string, timeout time.Duration) logger.Debug("WWW-Authenticate header lacks resource_metadata", zap.String("server", serverName)) - // Try direct authorization server metadata discovery - baseURL, err := parseBaseURL(serverURL) - if err != nil { - return nil, nil // Can't validate, let OAuth flow handle it - } + // Try direct authorization server metadata discovery using RFC 8414 compliant paths + // Use the full server URL to properly construct RFC 8414 paths that include the path component + authMetadata, discoveredURL, err := discoverAuthServerMetadataWithFallback(serverURL, timeout) + result.AuthorizationServerMetadataURL = discoveredURL - authServerURL := baseURL + "/.well-known/oauth-authorization-server" - result.AuthorizationServerMetadataURL = authServerURL - - authMetadata, err := fetchAuthorizationServerMetadata(authServerURL, timeout) if err != nil { result.AuthorizationServerError = err + // Store all URLs tried for error context + urls := BuildRFC8414MetadataURLs(serverURL) + result.AuthorizationServerMetadataURLsTried = urls + if len(urls) > 0 { + result.AuthorizationServerMetadataURL = urls[0] + } // Return structured error return result, createMetadataError(serverName, serverURL, result) } @@ -403,27 +628,30 @@ func ValidateOAuthMetadata(serverURL, serverName string, timeout time.Duration) authServerBaseURL = baseURL } - authServerMetadataURL := authServerBaseURL + "/.well-known/oauth-authorization-server" - result.AuthorizationServerMetadataURL = authServerMetadataURL + // Use RFC 8414 compliant discovery with fallback for servers like Smithery + // that use non-standard well-known paths + authMetadata, discoveredURL, err := discoverAuthServerMetadataWithFallback(authServerBaseURL, timeout) + result.AuthorizationServerMetadataURL = discoveredURL // Store the URL that worked - authMetadata, err := fetchAuthorizationServerMetadata(authServerMetadataURL, timeout) if err != nil { result.AuthorizationServerError = err + // Store all URLs that were tried for better error messages + urls := BuildRFC8414MetadataURLs(authServerBaseURL) + result.AuthorizationServerMetadataURLsTried = urls + if len(urls) > 0 { + result.AuthorizationServerMetadataURL = urls[0] // Store first URL tried for error context + } logger.Debug("Failed to fetch authorization server metadata", zap.String("server", serverName), - zap.String("metadata_url", authServerMetadataURL), + zap.String("auth_server_base", authServerBaseURL), + zap.Strings("urls_tried", urls), zap.Error(err)) return result, createMetadataError(serverName, serverURL, result) } result.AuthorizationServerMetadata = authMetadata - // Step 5: Validate required fields - if authMetadata.AuthorizationEndpoint == "" || authMetadata.TokenEndpoint == "" { - result.AuthorizationServerError = fmt.Errorf("metadata missing required fields: authorization_endpoint=%q, token_endpoint=%q", - authMetadata.AuthorizationEndpoint, authMetadata.TokenEndpoint) - return result, createMetadataError(serverName, serverURL, result) - } + // Note: Required fields validation is already done in discoverAuthServerMetadataWithFallback result.Valid = true logger.Info("✅ OAuth metadata validation successful", @@ -484,14 +712,25 @@ func createMetadataError(serverName, serverURL string, result *OAuthMetadataVali errorType := errorTypeMetadataMissing errorCode := errorCodeNoMetadata message := "OAuth authorization server metadata not available" - suggestion := "The OAuth authorization server is not properly configured. Contact the server administrator." + + // Build suggestion based on URLs tried + var suggestion string + if len(result.AuthorizationServerMetadataURLsTried) > 0 { + suggestion = fmt.Sprintf( + "MCPProxy tried the following OAuth metadata URLs but none responded: %v. "+ + "Verify the authorization server supports OAuth 2.0 discovery (RFC 8414). "+ + "If using a custom OAuth server, ensure it exposes /.well-known/oauth-authorization-server.", + result.AuthorizationServerMetadataURLsTried) + } else { + suggestion = "The OAuth authorization server is not properly configured. Contact the server administrator." + } // Check if metadata was found but invalid if result.AuthorizationServerError != nil && strings.Contains(result.AuthorizationServerError.Error(), "missing required fields") { errorType = errorTypeMetadataInvalid errorCode = errorCodeBadMetadata message = "OAuth authorization server metadata is incomplete" - suggestion = "The OAuth server metadata is missing required fields. Contact the server administrator." + suggestion = "The OAuth server metadata is missing required fields (authorization_endpoint and/or token_endpoint). Contact the server administrator." } // Build details structure @@ -512,10 +751,11 @@ func createMetadataError(serverName, serverURL string, result *OAuthMetadataVali } } - if result.AuthorizationServerMetadataURL != "" { + if result.AuthorizationServerMetadataURL != "" || len(result.AuthorizationServerMetadataURLsTried) > 0 { details.AuthorizationServerMetadata = &metadataStatus{ - Found: result.AuthorizationServerMetadata != nil, - URLChecked: result.AuthorizationServerMetadataURL, + Found: result.AuthorizationServerMetadata != nil, + URLChecked: result.AuthorizationServerMetadataURL, + URLsChecked: result.AuthorizationServerMetadataURLsTried, } if result.AuthorizationServerError != nil { details.AuthorizationServerMetadata.Error = result.AuthorizationServerError.Error() @@ -558,6 +798,7 @@ type metadataErrorDetails struct { type metadataStatus struct { Found bool `json:"found"` URLChecked string `json:"url_checked"` + URLsChecked []string `json:"urls_checked,omitempty"` // All URLs tried (for RFC 8414 fallback) Error string `json:"error,omitempty"` AuthorizationServers []string `json:"authorization_servers,omitempty"` } diff --git a/internal/oauth/discovery_test.go b/internal/oauth/discovery_test.go index aa3dc7d4..f23645a1 100644 --- a/internal/oauth/discovery_test.go +++ b/internal/oauth/discovery_test.go @@ -427,3 +427,294 @@ func TestDiscoverProtectedResourceMetadata_Timeout(t *testing.T) { t.Errorf("Expected nil metadata on timeout, got %+v", metadata) } } + +// Test RFC 8414 URL building for authorization server metadata discovery +func TestBuildRFC8414MetadataURLs(t *testing.T) { + tests := []struct { + name string + authServerURL string + expectedURLs []string + }{ + { + name: "Simple base URL without path", + authServerURL: "https://auth.example.com", + expectedURLs: []string{"https://auth.example.com/.well-known/oauth-authorization-server"}, + }, + { + name: "Base URL with trailing slash", + authServerURL: "https://auth.example.com/", + expectedURLs: []string{"https://auth.example.com/.well-known/oauth-authorization-server"}, + }, + { + name: "Smithery-style URL with path (RFC 8414 compliant)", + authServerURL: "https://auth.smithery.ai/googledrive", + expectedURLs: []string{ + "https://auth.smithery.ai/.well-known/oauth-authorization-server/googledrive", + "https://auth.smithery.ai/googledrive/.well-known/oauth-authorization-server", + "https://auth.smithery.ai/.well-known/oauth-authorization-server", // Base URL fallback (Cloudflare-style) + }, + }, + { + name: "URL with multi-level path", + authServerURL: "https://auth.example.com/path1/path2/issuer", + expectedURLs: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/path1/path2/issuer", + "https://auth.example.com/path1/path2/issuer/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/oauth-authorization-server", // Base URL fallback + }, + }, + { + name: "URL with path and trailing slash", + authServerURL: "https://auth.smithery.ai/googledrive/", + expectedURLs: []string{ + "https://auth.smithery.ai/.well-known/oauth-authorization-server/googledrive", + "https://auth.smithery.ai/googledrive/.well-known/oauth-authorization-server", + "https://auth.smithery.ai/.well-known/oauth-authorization-server", // Base URL fallback + }, + }, + { + name: "Cloudflare-style URL with path", + authServerURL: "https://logs.mcp.cloudflare.com/mcp", + expectedURLs: []string{ + "https://logs.mcp.cloudflare.com/.well-known/oauth-authorization-server/mcp", + "https://logs.mcp.cloudflare.com/mcp/.well-known/oauth-authorization-server", + "https://logs.mcp.cloudflare.com/.well-known/oauth-authorization-server", // This one works for Cloudflare + }, + }, + { + name: "GitHub OAuth URL without path", + authServerURL: "https://github.com", + expectedURLs: []string{"https://github.com/.well-known/oauth-authorization-server"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + urls := BuildRFC8414MetadataURLs(tt.authServerURL) + + if len(urls) != len(tt.expectedURLs) { + t.Errorf("URL count mismatch: got %d, want %d", len(urls), len(tt.expectedURLs)) + t.Errorf("Got URLs: %v", urls) + t.Errorf("Want URLs: %v", tt.expectedURLs) + return + } + + for i, url := range urls { + if url != tt.expectedURLs[i] { + t.Errorf("URL[%d] = %q, want %q", i, url, tt.expectedURLs[i]) + } + } + }) + } +} + +// Test discovery with fallback - simulates Smithery server behavior +func TestDiscoverAuthServerMetadataWithFallback_SmitheryStyle(t *testing.T) { + // Simulate Smithery's behavior: RFC 8414 path works, legacy path returns 404 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Smithery uses RFC 8414 compliant path + if r.URL.Path == "/.well-known/oauth-authorization-server/googledrive" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{ + "issuer": "https://auth.smithery.ai/googledrive", + "authorization_endpoint": "https://auth.smithery.ai/googledrive/authorize", + "token_endpoint": "https://auth.smithery.ai/googledrive/token", + "registration_endpoint": "https://auth.smithery.ai/googledrive/register", + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"] + }`)) + return + } + // Legacy path returns 404 + if r.URL.Path == "/googledrive/.well-known/oauth-authorization-server" { + w.WriteHeader(404) + w.Write([]byte("Cannot GET /googledrive/.well-known/oauth-authorization-server")) + return + } + w.WriteHeader(404) + w.Write([]byte("Not found")) + })) + defer server.Close() + + // Use the server URL with path (simulating auth.smithery.ai/googledrive) + authServerURL := server.URL + "/googledrive" + + metadata, discoveredURL, err := discoverAuthServerMetadataWithFallback(authServerURL, 5*time.Second) + + if err != nil { + t.Fatalf("Expected success but got error: %v", err) + } + + if metadata == nil { + t.Fatal("Expected metadata but got nil") + } + + expectedURL := server.URL + "/.well-known/oauth-authorization-server/googledrive" + if discoveredURL != expectedURL { + t.Errorf("Discovered URL = %q, want %q", discoveredURL, expectedURL) + } + + if metadata.AuthorizationEndpoint != "https://auth.smithery.ai/googledrive/authorize" { + t.Errorf("AuthorizationEndpoint = %q, want %q", metadata.AuthorizationEndpoint, "https://auth.smithery.ai/googledrive/authorize") + } + + if metadata.TokenEndpoint != "https://auth.smithery.ai/googledrive/token" { + t.Errorf("TokenEndpoint = %q, want %q", metadata.TokenEndpoint, "https://auth.smithery.ai/googledrive/token") + } +} + +// Test discovery with fallback - simulates legacy server behavior (current codebase test servers) +func TestDiscoverAuthServerMetadataWithFallback_LegacyStyle(t *testing.T) { + // Simulate legacy behavior: legacy path works, RFC 8414 path returns 404 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Legacy path (path/.well-known/oauth-authorization-server) works + if r.URL.Path == "/myserver/.well-known/oauth-authorization-server" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{ + "issuer": "https://legacy.example.com/myserver", + "authorization_endpoint": "https://legacy.example.com/myserver/authorize", + "token_endpoint": "https://legacy.example.com/myserver/token", + "response_types_supported": ["code"] + }`)) + return + } + // RFC 8414 path returns 404 + if r.URL.Path == "/.well-known/oauth-authorization-server/myserver" { + w.WriteHeader(404) + w.Write([]byte("Not found")) + return + } + w.WriteHeader(404) + w.Write([]byte("Not found")) + })) + defer server.Close() + + authServerURL := server.URL + "/myserver" + + metadata, discoveredURL, err := discoverAuthServerMetadataWithFallback(authServerURL, 5*time.Second) + + if err != nil { + t.Fatalf("Expected success but got error: %v", err) + } + + if metadata == nil { + t.Fatal("Expected metadata but got nil") + } + + expectedURL := server.URL + "/myserver/.well-known/oauth-authorization-server" + if discoveredURL != expectedURL { + t.Errorf("Discovered URL = %q, want %q", discoveredURL, expectedURL) + } + + if metadata.AuthorizationEndpoint != "https://legacy.example.com/myserver/authorize" { + t.Errorf("AuthorizationEndpoint = %q", metadata.AuthorizationEndpoint) + } +} + +// Test discovery with fallback - both paths fail +func TestDiscoverAuthServerMetadataWithFallback_AllFail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte("Not found")) + })) + defer server.Close() + + authServerURL := server.URL + "/myserver" + + metadata, discoveredURL, err := discoverAuthServerMetadataWithFallback(authServerURL, 5*time.Second) + + if err == nil { + t.Fatal("Expected error but got nil") + } + + if metadata != nil { + t.Errorf("Expected nil metadata, got %+v", metadata) + } + + if discoveredURL != "" { + t.Errorf("Expected empty discoveredURL, got %q", discoveredURL) + } + + // Error should mention the URLs tried + if !containsSubstring(err.Error(), "/.well-known/oauth-authorization-server") { + t.Errorf("Error should mention well-known path, got: %v", err) + } +} + +// Test discovery with fallback - metadata is incomplete (missing required fields) +func TestDiscoverAuthServerMetadataWithFallback_IncompleteMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return metadata missing required fields + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{ + "issuer": "https://auth.example.com", + "response_types_supported": ["code"] + }`)) + })) + defer server.Close() + + metadata, _, err := discoverAuthServerMetadataWithFallback(server.URL, 5*time.Second) + + if err == nil { + t.Fatal("Expected error for incomplete metadata but got nil") + } + + if metadata != nil { + t.Errorf("Expected nil metadata for incomplete response, got %+v", metadata) + } + + // Error should mention missing fields + if !containsSubstring(err.Error(), "missing required fields") { + t.Errorf("Error should mention missing fields, got: %v", err) + } +} + +// Test discovery with simple base URL (no path) +func TestDiscoverAuthServerMetadataWithFallback_SimpleURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/oauth-authorization-server" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "response_types_supported": ["code"] + }`)) + return + } + w.WriteHeader(404) + })) + defer server.Close() + + metadata, discoveredURL, err := discoverAuthServerMetadataWithFallback(server.URL, 5*time.Second) + + if err != nil { + t.Fatalf("Expected success but got error: %v", err) + } + + if metadata == nil { + t.Fatal("Expected metadata but got nil") + } + + expectedURL := server.URL + "/.well-known/oauth-authorization-server" + if discoveredURL != expectedURL { + t.Errorf("Discovered URL = %q, want %q", discoveredURL, expectedURL) + } +} + +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr)) +} + +func containsSubstringHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}