diff --git a/pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go b/pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go index be57922af..e4a81f538 100644 --- a/pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go +++ b/pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go @@ -4,13 +4,18 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "net/url" + "os" + "slices" "sort" "strings" "sync" "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -898,3 +903,206 @@ func writeJSONRPC(t *testing.T, w http.ResponseWriter, id json.RawMessage, resul t.Fatalf("encode response: %v", err) } } + +// TestCatalogOAuthDiscoveryLive probes every oauth server in the +// embedded catalog and asserts the structural prerequisites for the +// docker-agent OAuth flow: +// +// - the MCP endpoint challenges with 401 + WWW-Authenticate (or at +// least surfaces a reachable origin), +// - /.well-known/oauth-protected-resource is reachable (200 +// or 404 — either is fine, the WWW-Authenticate fallback covers 404), +// - the authorization-server metadata advertises an HTTPS +// `registration_endpoint` (Dynamic Client Registration is REQUIRED +// by pkg/tools/mcp/oauth_login.go: without it docker-agent cannot +// bootstrap a client), +// - and `code_challenge_methods_supported` includes "S256". +// +// This test is SKIPPED by default because: +// - it makes real HTTPS calls to ~17 third-party servers, +// - results depend on the external services' availability, and +// - it is unsuitable for `task test` / CI without explicit opt-in. +// +// Run it explicitly with: +// +// MCP_CATALOG_OAUTH_LIVE=1 go test -run TestCatalogOAuthDiscoveryLive \ +// -v -count=1 -timeout=120s ./pkg/tools/builtin/mcpcatalog +func TestCatalogOAuthDiscoveryLive(t *testing.T) { + if os.Getenv("MCP_CATALOG_OAUTH_LIVE") == "" { + t.Skip("skipping live OAuth discovery probe: makes real HTTPS calls " + + "to every oauth server in the embedded catalog. " + + "Set MCP_CATALOG_OAUTH_LIVE=1 to run.") + } + + cat, err := Load() + require.NoError(t, err) + + client := &http.Client{Timeout: 10 * time.Second} + + type result struct { + id, url, authServer string + mcpStatus int + hasWWWAuth bool + prStatus int + hasDCR bool + hasS256 bool + notes []string + } + + var ( + oauthServers []Server + results []result + ) + for _, s := range cat.Servers { + if s.Auth.Type == "oauth" { + oauthServers = append(oauthServers, s) + } + } + require.NotEmpty(t, oauthServers, "expected at least one oauth server in catalog") + + for _, s := range oauthServers { + t.Run(s.ID, func(t *testing.T) { + r := result{id: s.ID, url: s.URL} + + // 1. Unauthenticated MCP request -> expect a 401 challenge. + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, s.URL, + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := client.Do(req) + if err != nil { + r.notes = append(r.notes, "MCP request error: "+err.Error()) + results = append(results, r) + t.Errorf("MCP request failed: %v", err) + return + } + r.mcpStatus = resp.StatusCode + r.hasWWWAuth = resp.Header.Get("WWW-Authenticate") != "" + resp.Body.Close() + + // 2. Protected-resource metadata at the origin. + parsed, err := url.Parse(s.URL) + require.NoError(t, err) + base := parsed.Scheme + "://" + parsed.Host + + prReq, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, + base+"/.well-known/oauth-protected-resource", http.NoBody) + prResp, err := client.Do(prReq) + if err != nil { + r.notes = append(r.notes, "protected-resource request error: "+err.Error()) + } else { + r.prStatus = prResp.StatusCode + if prResp.StatusCode == http.StatusOK { + var pr struct { + AuthorizationServers []string `json:"authorization_servers"` + } + _ = json.NewDecoder(prResp.Body).Decode(&pr) + if len(pr.AuthorizationServers) > 0 { + r.authServer = pr.AuthorizationServers[0] + } + } + prResp.Body.Close() + } + if r.authServer == "" { + // Fallback: many providers omit /oauth-protected-resource and + // expect the auth-server metadata to live at the origin. + r.authServer = base + } + + // 3. Authorization-server metadata + DCR + PKCE S256. + // Walk the same set of candidate metadata URLs that + // pkg/tools/mcp/oauth.go now tries: spec-compliant RFC 8414 §3.1 + // path-aware variant first, then the legacy "append to issuer" + // form, then OIDC fallbacks. Accepting any 200 mirrors what the + // runtime would do; the live probe must not be more strict than + // the discovery code itself. + candidates := authServerMetadataCandidates(r.authServer) + var ( + asResp *http.Response + lastStatus int + lastURL string + ) + for _, u := range candidates { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, u, http.NoBody) + resp, err := client.Do(req) + if err != nil { + r.notes = append(r.notes, "auth-server metadata error at "+u+": "+err.Error()) + continue + } + lastStatus, lastURL = resp.StatusCode, u + if resp.StatusCode == http.StatusOK { + asResp = resp + break + } + resp.Body.Close() + } + if asResp == nil { + r.notes = append(r.notes, fmt.Sprintf("no candidate returned 200 (last %d at %s)", lastStatus, lastURL)) + results = append(results, r) + t.Errorf("auth-server metadata unreachable") + return + } + defer asResp.Body.Close() + var asm struct { + RegistrationEndpoint string `json:"registration_endpoint"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + } + require.NoError(t, json.NewDecoder(asResp.Body).Decode(&asm)) + r.hasDCR = strings.HasPrefix(asm.RegistrationEndpoint, "https://") + r.hasS256 = slices.Contains(asm.CodeChallengeMethodsSupported, "S256") + + results = append(results, r) + + // Soft assertions: log everything, fail only on the must-haves. + t.Logf("mcp=%d www-auth=%v pr=%d auth-server=%s dcr=%v s256=%v", + r.mcpStatus, r.hasWWWAuth, r.prStatus, r.authServer, r.hasDCR, r.hasS256) + assert.True(t, r.hasDCR, + "server %s: authorization server must support Dynamic Client Registration "+ + "(registration_endpoint missing or non-HTTPS) — docker-agent cannot OAuth without it", + s.ID) + assert.True(t, r.hasS256, + "server %s: authorization server must advertise PKCE S256 in "+ + "code_challenge_methods_supported", s.ID) + }) + } + + // Pretty summary so a single CI run gives a readable report. + t.Cleanup(func() { + t.Log("== MCP catalog OAuth discovery summary ==") + for _, r := range results { + t.Logf("%-30s mcp=%d www-auth=%v pr=%d dcr=%v s256=%v %s", + r.id, r.mcpStatus, r.hasWWWAuth, r.prStatus, r.hasDCR, r.hasS256, + strings.Join(r.notes, "; ")) + } + }) +} + +// authServerMetadataCandidates mirrors the candidate URL list built by +// pkg/tools/mcp/oauth.go's metadataDiscoveryURLs for use by the live +// probe. Kept duplicated here on purpose: the probe is a black-box +// audit, and copying the small piece of URL math keeps it independent +// of any future refactor in the discovery code path. +func authServerMetadataCandidates(authServerURL string) []string { + if strings.Contains(authServerURL, "/.well-known/") { + return []string{authServerURL} + } + parsed, err := url.Parse(authServerURL) + if err != nil { + return []string{authServerURL} + } + origin := parsed.Scheme + "://" + parsed.Host + path := strings.TrimSuffix(parsed.Path, "/") + if path == "" { + return []string{ + origin + "/.well-known/oauth-authorization-server", + origin + "/.well-known/openid-configuration", + } + } + return []string{ + origin + "/.well-known/oauth-authorization-server" + path, + origin + path + "/.well-known/oauth-authorization-server", + origin + "/.well-known/openid-configuration" + path, + origin + path + "/.well-known/openid-configuration", + } +} diff --git a/pkg/tools/builtin/mcpcatalog/servers.json b/pkg/tools/builtin/mcpcatalog/servers.json index 2877542af..de326d351 100644 --- a/pkg/tools/builtin/mcpcatalog/servers.json +++ b/pkg/tools/builtin/mcpcatalog/servers.json @@ -3,7 +3,7 @@ "source_url": "https://desktop.docker.com/mcp/catalog/v3/catalog.json", "schema_version": 3, "filter": "type=remote AND remote.transport_type=streamable-http", - "count": 45, + "count": 44, "servers": [ { "id": "apify", @@ -328,33 +328,6 @@ "type": "none" } }, - { - "id": "grafbase", - "title": "Grafbase", - "description": "Build and deploy high-performance GraphQL APIs with federation, edge computing, and enterprise-grade governance.", - "url": "https://api.grafbase.com/mcp", - "transport": "streamable-http", - "category": "devops", - "tags": [ - "devops", - "graphql", - "database", - "remote" - ], - "icon": "https://www.google.com/s2/favicons?domain=grafbase.com&sz=64", - "readme": "http://desktop.docker.com/mcp/catalog/v3/readme/grafbase.md", - "headers": {}, - "auth": { - "type": "oauth", - "providers": [ - { - "provider": "grafbase", - "env": "GRAFBASE_PERSONAL_ACCESS_TOKEN", - "secret": "grafbase.personal_access_token" - } - ] - } - }, { "id": "granola-remote", "title": "Granola", diff --git a/pkg/tools/mcp/oauth.go b/pkg/tools/mcp/oauth.go index 9d9e53582..f51de3c36 100644 --- a/pkg/tools/mcp/oauth.go +++ b/pkg/tools/mcp/oauth.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "regexp" "strings" "sync" @@ -59,60 +60,133 @@ type AuthorizationServerMetadata struct { } func (o *oauth) getAuthorizationServerMetadata(ctx context.Context, authServerURL string) (*AuthorizationServerMetadata, error) { - // Build well-known metadata URL - metadataURL := authServerURL - if !strings.HasSuffix(authServerURL, "/.well-known/oauth-authorization-server") { - metadataURL = strings.TrimSuffix(authServerURL, "/") + "/.well-known/oauth-authorization-server" + candidates, err := metadataDiscoveryURLs(authServerURL) + if err != nil { + return nil, err + } + + // Walk the candidate list in order. Spec-compliant URLs (RFC 8414 §3.1) + // come first; the legacy "append the well-known suffix to the full + // issuer URL" forms come after for compatibility with the many auth + // servers that ship that way. + // + // A 200 with a decodable body wins. The candidates are best-effort + // guesses about where metadata might live, so a non-200 on any one of + // them must not short-circuit the probe — we keep trying. Only after + // every candidate has failed do we decide what to do: if everyone + // 404'd we fall back to default metadata (matching the legacy + // behaviour); if at least one candidate returned a non-404 status or a + // transport/decode error, we surface that so a misconfigured auth + // server doesn't get papered over. + var ( + notableErr error + notableStatus int + notableURL string + ) + for _, u := range candidates { + metadata, status, err := o.fetchAuthorizationServerMetadata(ctx, u) + if metadata != nil { + return validateAndFillDefaults(metadata, authServerURL), nil + } + switch { + case err != nil: + slog.DebugContext(ctx, "Metadata discovery candidate failed, trying next", + "url", u, "error", err) + if notableErr == nil && notableStatus == 0 { + notableErr, notableURL = err, u + } + case status != http.StatusNotFound: + slog.DebugContext(ctx, "Metadata discovery candidate returned unexpected status, trying next", + "url", u, "status", status) + if notableStatus == 0 { + notableStatus, notableURL = status, u + notableErr = nil + } + } } - // Attempt OAuth authorization server discovery + switch { + case notableErr != nil: + return nil, fmt.Errorf("failed to fetch authorization server metadata from %s: %w", notableURL, notableErr) + case notableStatus != 0: + return nil, fmt.Errorf("unexpected status %d from %s", notableStatus, notableURL) + } + + slog.DebugContext(ctx, "All metadata discovery URLs returned 404, returning default metadata", + "authServerURL", authServerURL) + return createDefaultMetadata(authServerURL), nil +} + +// fetchAuthorizationServerMetadata GETs a single discovery URL. Returns +// (metadata, 200, nil) on success, (nil, status, nil) on a non-OK status +// the caller should consider for fallback, or (nil, 0, err) on transport +// or decode failure. +func (o *oauth) fetchAuthorizationServerMetadata(ctx context.Context, metadataURL string) (*AuthorizationServerMetadata, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, http.NoBody) if err != nil { - return nil, err + return nil, 0, err } req.Header.Set("Accept", "application/json") resp, err := o.metadataClient.Do(req) if err != nil { - return nil, err + return nil, 0, err } defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - // Try OpenID Connect discovery as fallback - openIDURL := strings.Replace(metadataURL, "/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", 1) - oidcReq, err := http.NewRequestWithContext(ctx, http.MethodGet, openIDURL, http.NoBody) - if err != nil { - return nil, err - } - oidcReq.Header.Set("Accept", "application/json") + if resp.StatusCode != http.StatusOK { + return nil, resp.StatusCode, nil + } - oidcResp, err := o.metadataClient.Do(oidcReq) - if err != nil { - return nil, err - } - defer oidcResp.Body.Close() + var metadata AuthorizationServerMetadata + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + return nil, 0, fmt.Errorf("failed to decode metadata from %s: %w", metadataURL, err) + } + return &metadata, resp.StatusCode, nil +} - if oidcResp.StatusCode != http.StatusOK { - // Return default metadata if all discovery fails - return createDefaultMetadata(authServerURL), nil - } +// metadataDiscoveryURLs returns the ordered list of well-known URLs to try +// when discovering authorization-server metadata for authServerURL. +// +// For an issuer with a path component (e.g. https://access.stripe.com/mcp) +// RFC 8414 §3.1 requires inserting the well-known suffix between origin +// and path: +// +// https://access.stripe.com/.well-known/oauth-authorization-server/mcp +// +// Many widely-deployed auth servers (Auth0, Okta, …) however append the +// suffix to the full issuer URL instead, so we try both. OIDC Discovery +// 1.0 §4 unconditionally appends openid-configuration, which we keep as +// the last fallback. +// +// If authServerURL already contains /.well-known/, we trust the caller and +// return it as a single candidate. +func metadataDiscoveryURLs(authServerURL string) ([]string, error) { + if strings.Contains(authServerURL, "/.well-known/") { + return []string{authServerURL}, nil + } - var metadata AuthorizationServerMetadata - if err := json.NewDecoder(oidcResp.Body).Decode(&metadata); err != nil { - return nil, fmt.Errorf("failed to decode metadata from %s: %w", openIDURL, err) - } - return validateAndFillDefaults(&metadata, authServerURL), nil - } else if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, metadataURL) + parsed, err := url.Parse(authServerURL) + if err != nil { + return nil, fmt.Errorf("invalid authorization server URL %q: %w", authServerURL, err) } + origin := parsed.Scheme + "://" + parsed.Host + path := strings.TrimSuffix(parsed.Path, "/") - var metadata AuthorizationServerMetadata - if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { - return nil, fmt.Errorf("failed to decode metadata from %s: %w", metadataURL, err) + // No path: spec-compliant and append forms are identical. + if path == "" { + return []string{ + origin + "/.well-known/oauth-authorization-server", + origin + "/.well-known/openid-configuration", + }, nil } - return validateAndFillDefaults(&metadata, authServerURL), nil + return []string{ + origin + "/.well-known/oauth-authorization-server" + path, + origin + path + "/.well-known/oauth-authorization-server", + origin + "/.well-known/openid-configuration" + path, + origin + path + "/.well-known/openid-configuration", + }, nil } // validateAndFillDefaults validates required fields and fills in defaults diff --git a/pkg/tools/mcp/oauth_test.go b/pkg/tools/mcp/oauth_test.go index e3f00154e..4eaddd7dd 100644 --- a/pkg/tools/mcp/oauth_test.go +++ b/pkg/tools/mcp/oauth_test.go @@ -13,6 +13,8 @@ import ( "time" gomcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/httpclient" @@ -1044,3 +1046,179 @@ func TestExchangeCodeForTokenWithResourceSendsResource(t *testing.T) { t.Fatalf("resource = %q, want https://mcp.example.com", gotResource) } } + +// TestMetadataDiscoveryURLs covers the candidate-URL builder for the four +// shapes of issuer URL we see in the wild: +// +// - origin only (no path): one OAuth + one OIDC fallback, +// - issuer with a path: RFC 8414 §3.1 path-aware variant first, then the +// widely-deployed "append" form, then the OIDC equivalents, +// - URL already pointing at a /.well-known/ endpoint: pass through. +func TestMetadataDiscoveryURLs(t *testing.T) { + t.Run("origin only", func(t *testing.T) { + got, err := metadataDiscoveryURLs("https://auth.example.com") + require.NoError(t, err) + assert.Equal(t, []string{ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + }, got) + }) + + t.Run("issuer with path (Stripe-style)", func(t *testing.T) { + // access.stripe.com/mcp serves metadata at + // /.well-known/oauth-authorization-server/mcp + // (RFC 8414 §3.1) — the "append" form 404s. Both must be tried. + got, err := metadataDiscoveryURLs("https://access.stripe.com/mcp") + require.NoError(t, err) + assert.Equal(t, []string{ + "https://access.stripe.com/.well-known/oauth-authorization-server/mcp", + "https://access.stripe.com/mcp/.well-known/oauth-authorization-server", + "https://access.stripe.com/.well-known/openid-configuration/mcp", + "https://access.stripe.com/mcp/.well-known/openid-configuration", + }, got) + }) + + t.Run("trailing slash on path is normalized", func(t *testing.T) { + got, err := metadataDiscoveryURLs("https://auth.example.com/tenant/") + require.NoError(t, err) + assert.Equal(t, []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant", + "https://auth.example.com/tenant/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration/tenant", + "https://auth.example.com/tenant/.well-known/openid-configuration", + }, got) + }) + + t.Run("explicit well-known URL passes through", func(t *testing.T) { + in := "https://auth.example.com/.well-known/oauth-authorization-server" + got, err := metadataDiscoveryURLs(in) + require.NoError(t, err) + assert.Equal(t, []string{in}, got) + }) +} + +// TestGetAuthorizationServerMetadata_RFC8414PathAware reproduces the +// Stripe-remote failure surfaced by TestCatalogOAuthDiscoveryLive: the +// authorization server's metadata is published at the spec-compliant +// "well-known between origin and path" URL, while the legacy "append to +// the issuer URL" variant 404s. The discovery code must fall through and +// pick the spec-compliant one before giving up and returning default +// (i.e. broken) metadata. +func TestGetAuthorizationServerMetadata_RFC8414PathAware(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + + // The legacy "append" URL must NOT be hit successfully — return 404. + mux.HandleFunc("/mcp/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) + }) + // RFC 8414 §3.1 spec-compliant variant: well-known between origin and path. + mux.HandleFunc("/.well-known/oauth-authorization-server/mcp", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": srv.URL + "/mcp", + "authorization_endpoint": srv.URL + "/oauth/authorize", + "token_endpoint": srv.URL + "/oauth/token", + "registration_endpoint": srv.URL + "/oauth/register", + }) + }) + + o := &oauth{metadataClient: srv.Client()} + md, err := o.getAuthorizationServerMetadata(t.Context(), srv.URL+"/mcp") + require.NoError(t, err) + assert.Equal(t, srv.URL+"/oauth/authorize", md.AuthorizationEndpoint) + assert.Equal(t, srv.URL+"/oauth/token", md.TokenEndpoint) + assert.Equal(t, srv.URL+"/oauth/register", md.RegistrationEndpoint, + "the registration endpoint must come from the RFC 8414 path-aware metadata, "+ + "not from createDefaultMetadata's empty fallback") +} + +// TestGetAuthorizationServerMetadata_AppendFormStillWorks asserts the +// fallback path: many widely-deployed auth servers serve metadata at the +// "append" URL only, so that variant must still be tried and accepted. +func TestGetAuthorizationServerMetadata_AppendFormStillWorks(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + + mux.HandleFunc("/tenant/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": srv.URL + "/tenant", + "authorization_endpoint": srv.URL + "/tenant/authorize", + "token_endpoint": srv.URL + "/tenant/token", + }) + }) + + o := &oauth{metadataClient: srv.Client()} + md, err := o.getAuthorizationServerMetadata(t.Context(), srv.URL+"/tenant") + require.NoError(t, err) + assert.Equal(t, srv.URL+"/tenant/authorize", md.AuthorizationEndpoint) + assert.Equal(t, srv.URL+"/tenant/token", md.TokenEndpoint) +} + +// TestGetAuthorizationServerMetadata_NonFatalCandidateStatus asserts that +// a non-200/non-404 response on one candidate (e.g. a server that +// answers 403 on the path-aware variant it doesn't implement) does NOT +// abort the probe — the next candidate is still tried, and a 200 there +// wins. Without this, RFC 8414 §3.1 ordering regresses servers whose +// path-aware endpoint returns anything other than 404. +func TestGetAuthorizationServerMetadata_NonFatalCandidateStatus(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + + // First candidate: path-aware variant returns 403 (some gateways do this). + mux.HandleFunc("/.well-known/oauth-authorization-server/tenant", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + // Second candidate: legacy append form returns valid metadata. + mux.HandleFunc("/tenant/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": srv.URL + "/tenant", + "authorization_endpoint": srv.URL + "/tenant/authorize", + "token_endpoint": srv.URL + "/tenant/token", + }) + }) + + o := &oauth{metadataClient: srv.Client()} + md, err := o.getAuthorizationServerMetadata(t.Context(), srv.URL+"/tenant") + require.NoError(t, err, "a 403 on a speculative candidate must not abort the probe") + assert.Equal(t, srv.URL+"/tenant/authorize", md.AuthorizationEndpoint) +} + +// TestGetAuthorizationServerMetadata_AllUnreachableSurfacesError asserts +// that when every candidate fails with a non-404 status (i.e. nothing +// 404'd through to the "discovery is just absent" interpretation), the +// probe surfaces an error instead of silently returning fabricated +// default metadata that will fail later in the OAuth handshake. +func TestGetAuthorizationServerMetadata_AllUnreachableSurfacesError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + o := &oauth{metadataClient: srv.Client()} + _, err := o.getAuthorizationServerMetadata(t.Context(), srv.URL+"/tenant") + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +// TestGetAuthorizationServerMetadata_All404FallsBackToDefaults asserts +// the legacy behaviour: a 404 from every candidate means "this server +// doesn't expose discovery metadata", which is OK and we should fall +// back to fabricated defaults rather than erroring out. +func TestGetAuthorizationServerMetadata_All404FallsBackToDefaults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) + })) + defer srv.Close() + + o := &oauth{metadataClient: srv.Client()} + md, err := o.getAuthorizationServerMetadata(t.Context(), srv.URL+"/tenant") + require.NoError(t, err) + assert.Equal(t, srv.URL+"/tenant/authorize", md.AuthorizationEndpoint, + "defaults must be derived from the issuer URL") +}