From fee12a2ac0cf8738584211f62b99a92b98327486 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 27 Apr 2026 17:08:12 +0200 Subject: [PATCH] feat(oauth): /.well-known discovery endpoints (forgejo-mcp-broker-b2o) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RFC 8414 (oauth-authorization-server) and RFC 9728 (oauth- protected-resource) metadata documents. Both URLs are derived from cfg.Issuer at construction time, never from inbound request headers. Test TestDiscovery_IssuerIgnoresHostHeader explicitly probes this — a malicious Host: evil.example.com value must not leak into the published metadata. Defense against the OAuth metadata-spoofing class starts at the discovery layer. Capabilities published reflect the actual OAuth surface: - response_types_supported = ["code"] - grant_types_supported = ["authorization_code", "refresh_token"] - code_challenge_methods_supported = ["S256"] (PKCE only, no plain) - token_endpoint_auth_methods_supported = ["none"] (PKCE-only public clients) Protected-resource metadata advertises /mcp as the resource; phase 5 will mount the gated MCP endpoint there. Closes forgejo-mcp-broker-b2o. Co-Authored-By: Claude Opus 4.7 (1M context) --- .beads/issues.jsonl | 4 +- internal/oauth/oauth.go | 69 +++++++++++++++++++- internal/oauth/oauth_test.go | 122 +++++++++++++++++++++++++++++++++-- 3 files changed, 187 insertions(+), 8 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 425856a..d7be074 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -6,9 +6,9 @@ {"id":"forgejo-mcp-broker-am1","title":"Phase 4a: internal/bridge JSON-RPC pipe + SSE writer","description":"Given a supervisor.Child: inbound HTTP JSON -\u003e newline-framed stdin; stdout lines -\u003e SSE frames. Handle client disconnect without killing the child.","acceptance_criteria":"Unit tests with mock Child that echoes: request/response round trip, multiple concurrent requests with correct id routing, client disconnect mid-stream.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:59:35Z","started_at":"2026-04-27T11:56:15Z","closed_at":"2026-04-27T11:59:35Z","close_reason":"Bridge shipped: per-id routing, SSE responses for request/reply messages, 204 for notifications, structured 4xx/5xx for malformed input. Decoupled from supervisor (takes pipes directly) for clean testing via io.Pipe. 90.0% coverage.","dependencies":[{"issue_id":"forgejo-mcp-broker-am1","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:27Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"id":"forgejo-mcp-broker-wgo","title":"Phase 2e: OAuth security review + attack-path tests","description":"Phase 2 exit gate. Review every handler for classic OAuth vulns (open redirect, code replay, mix-up, token leak in logs, host spoofing). Add at least one test per attack class. Update design.md §8 with findings.","acceptance_criteria":"Review checklist documented. Tests added for: PKCE mismatch, stale code, token absent from log attributes, bad redirect_uri, mismatched state, replay of used code.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:14Z","dependencies":[{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-b2o","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"id":"forgejo-mcp-broker-zuq","title":"Phase 3a: internal/supervisor managed stdio subprocess","description":"Child type: Start, Stop(ctx) with SIGTERM -\u003e grace -\u003e SIGKILL, Wait+reap goroutine (no zombies), stderr drainer with prefix. Protocol-agnostic.","acceptance_criteria":"Unit tests against an echo-loop helper: round trip, graceful stop, kill-after-grace, child-exits-on-own detection, stderr capture. Manual spawn of real forgejo-mcp --transport stdio works.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:41:07Z","started_at":"2026-04-27T11:32:54Z","closed_at":"2026-04-27T11:41:07Z","close_reason":"internal/supervisor shipped: Start/Stop/Done/ExitErr/Pid, SIGTERM-\u003egrace-\u003eSIGKILL escalation, mandatory wait-and-reap. Test uses TestMain helper-process pattern; coverage 89.6% on the testable surface.","dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"forgejo-mcp-broker-b2o","title":"Phase 2d: OAuth discovery endpoints (/.well-known/*)","description":"GET /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. Issuer URLs MUST derive from cfg.PublicURL, never inbound headers (host-header attack defense per design.md §8).","acceptance_criteria":"Responses validate against RFC 8414/9728 shapes. Issuer URL sourced from config only. supported_scopes matches cfg.ForgejoOAuthScopes.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:13Z","dependencies":[{"issue_id":"forgejo-mcp-broker-b2o","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"id":"forgejo-mcp-broker-b2o","title":"Phase 2d: OAuth discovery endpoints (/.well-known/*)","description":"GET /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. Issuer URLs MUST derive from cfg.PublicURL, never inbound headers (host-header attack defense per design.md §8).","acceptance_criteria":"Responses validate against RFC 8414/9728 shapes. Issuer URL sourced from config only. supported_scopes matches cfg.ForgejoOAuthScopes.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:06:24Z","started_at":"2026-04-27T15:06:24Z","dependencies":[{"issue_id":"forgejo-mcp-broker-b2o","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"forgejo-mcp-broker-b9i","title":"Phase 2b: internal/forgejo OAuth client","description":"Broker-side OAuth client for upstream Forgejo: authorize URL builder, code-to-token exchange, refresh_token grant, userinfo fetch, revoke. Used by AS callback and refresh machinery. Stateless; caller owns persistence.","acceptance_criteria":"Unit tests with httptest.Server fake Forgejo cover each grant plus error paths (wrong code, expired refresh, revoked token). No state persisted in this package.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:31:27Z","started_at":"2026-04-27T11:29:17Z","closed_at":"2026-04-27T11:31:27Z","close_reason":"internal/forgejo shipped: AuthorizeURL, ExchangeCode, Refresh, FetchUserInfo. Structured *forgejo.Error for OAuth failures (errors.As-friendly). 95.1% coverage. Stateless — caller owns persistence. Revocation deferred since upstream Forgejo lacks the endpoint.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"forgejo-mcp-broker-pur","title":"Phase 2c: internal/oauth AS endpoints (register, authorize, callback, token, revoke)","description":"Five OAuth handlers per design.md §4.1. RFC 7591 DCR with ephemeral client IDs, authorize -\u003e Forgejo delegation, callback minting broker auth codes, token exchange with SHA-256 hashing at rest, revoke. PKCE S256 required.","acceptance_criteria":"End-to-end curl walkthrough from plan.md phase 2 passes. All tokens hashed at rest. Auth codes single-use, 10-min TTL. Rejects missing PKCE, non-S256, wrong verifier, expired codes/tokens. Handler coverage \u003e=80%.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:30:02Z","started_at":"2026-04-27T14:30:02Z","dependencies":[{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-b9i","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-cpb","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0} +{"id":"forgejo-mcp-broker-pur","title":"Phase 2c: internal/oauth AS endpoints (register, authorize, callback, token, revoke)","description":"Five OAuth handlers per design.md §4.1. RFC 7591 DCR with ephemeral client IDs, authorize -\u003e Forgejo delegation, callback minting broker auth codes, token exchange with SHA-256 hashing at rest, revoke. PKCE S256 required.","acceptance_criteria":"End-to-end curl walkthrough from plan.md phase 2 passes. All tokens hashed at rest. Auth codes single-use, 10-min TTL. Rejects missing PKCE, non-S256, wrong verifier, expired codes/tokens. Handler coverage \u003e=80%.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:04:43Z","started_at":"2026-04-27T14:30:02Z","closed_at":"2026-04-27T15:04:43Z","close_reason":"OAuth AS shipped: register, authorize, callback, token (auth_code + refresh grants), revoke. PKCE S256 enforced. Tokens hashed at rest. Refresh rotation with old-token revocation. Auth-code single-use via atomic UPDATE rows-affected. 81% coverage. ~1700 LOC. Unblocks 2d, 2e, and is a key dep for 5a.","dependencies":[{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-b9i","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-cpb","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0} {"id":"forgejo-mcp-broker-cpb","title":"Phase 2a: OAuth tables migration","description":"Add migrations/0002_oauth_tables.sql creating clients, auth_codes, access_tokens, refresh_tokens per design.md §4.2. Broker tokens stored as SHA-256 hashes; Forgejo tokens cleartext (subprocess spawning requires them). See plan.md phase 2.","acceptance_criteria":"Migration applies on a fresh DB and is idempotent on reopen. Schema matches design.md §4.2. Tests verify every table and key column exists.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:04Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:28:20Z","started_at":"2026-04-27T11:26:17Z","closed_at":"2026-04-27T11:28:20Z","close_reason":"0002_oauth_tables.sql shipped: clients/auth_codes/access_tokens/refresh_tokens with cascading FKs, indexes on hot-path columns, and an oauth_schema_version marker. PRAGMA-driven tests verify columns; FK cascade tested in both directions.","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"forgejo-mcp-broker-8ei","title":"Phase 1: internal/httpserver with /healthz and graceful shutdown","description":"Implement internal/httpserver: constructs a *http.Server bound to cfg.Listen, mounts GET /healthz (returns 200 with JSON build-info from the build-info package, including version, git revision, build date, and current store status), handles SIGTERM/SIGINT by initiating graceful shutdown with a configurable deadline (default 10s). Uses log/slog for structured JSON logs. Exposes a Run(ctx) error method that blocks until shutdown completes.","acceptance_criteria":"go test ./internal/httpserver passes; GET /healthz returns expected JSON; sending SIGTERM causes Run to return nil within 2 seconds after in-flight requests complete; slow in-flight request is allowed to finish within the deadline, then forcibly closed.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:20Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:26:43Z","started_at":"2026-04-24T15:24:09Z","closed_at":"2026-04-24T15:26:43Z","close_reason":"httpserver shipped: /healthz with store probe, graceful shutdown with force-close fallback, ExtraHandler extension point. 97.9% coverage. internal/log also implemented in the same commit (100% coverage).","dependencies":[{"issue_id":"forgejo-mcp-broker-8ei","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:46:19Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"forgejo-mcp-broker-t37","title":"Phase 1: wire cmd/broker/main.go and integration test","description":"Final phase 1 task: wire config → log → store → httpserver in cmd/broker/main.go. Parse config, init slog, open store, start httpserver, wait for shutdown signal, close store, exit. Add an integration test under cmd/broker/ that builds the binary, runs it with a valid env + temp store path, curls /healthz, sends SIGTERM, verifies clean exit within 2s. This is the acceptance gate for phase 1.","acceptance_criteria":"make build; make test (incl. integration) pass; running the binary with missing config fails with a clear error; running with valid config serves /healthz; SIGTERM shuts down cleanly within 2s; /healthz JSON includes version, git revision, build date, and store OK status.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:20Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:29:44Z","started_at":"2026-04-24T15:27:58Z","closed_at":"2026-04-24T15:29:44Z","close_reason":"Main wired, signal.NotifyContext triggers shutdown cascade, integration tests green. Phase 1 complete: binary starts with valid config, serves /healthz JSON, shuts down cleanly on SIGTERM within 2s.","dependencies":[{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-8ei","type":"blocks","created_at":"2026-04-24T16:48:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-9jh","type":"blocks","created_at":"2026-04-24T16:48:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-9nq","type":"blocks","created_at":"2026-04-24T16:48:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:48:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index b77a6fa..bf20574 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -127,7 +127,10 @@ func (s *Server) Close() { } } -// Handler returns the http.Handler exposing all five OAuth endpoints. +// Handler returns the http.Handler exposing OAuth endpoints plus the two +// discovery documents. The broker's outer mux should mount this at the +// root (not under /oauth) so the .well-known paths land at their spec- +// mandated location. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /oauth/register", s.handleRegister) @@ -135,6 +138,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /oauth/callback", s.handleCallback) mux.HandleFunc("POST /oauth/token", s.handleToken) mux.HandleFunc("POST /oauth/revoke", s.handleRevoke) + mux.HandleFunc("GET /.well-known/oauth-authorization-server", s.handleASMetadata) + mux.HandleFunc("GET /.well-known/oauth-protected-resource", s.handlePRMetadata) return mux } @@ -224,6 +229,68 @@ func (s *Server) reapPending() { }) } +// ============================================================================ +// /.well-known/* — discovery metadata (RFC 8414, RFC 9728) +// ============================================================================ +// +// All URLs in these documents are built from the configured issuer, never +// from inbound request headers. Publishing an attacker-controlled issuer +// is a classic OAuth metadata-spoofing vector — defending against it has +// to start at the discovery layer. + +type asMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + ScopesSupported []string `json:"scopes_supported,omitempty"` +} + +func (s *Server) handleASMetadata(w http.ResponseWriter, r *http.Request) { + md := asMetadata{ + Issuer: s.issuer, + AuthorizationEndpoint: s.issuer + "/oauth/authorize", + TokenEndpoint: s.issuer + "/oauth/token", + RegistrationEndpoint: s.issuer + "/oauth/register", + RevocationEndpoint: s.issuer + "/oauth/revoke", + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + } + if s.scopes != "" { + md.ScopesSupported = strings.Fields(s.scopes) + } + writeJSON(w, http.StatusOK, md) +} + +type prMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + BearerMethodsSupported []string `json:"bearer_methods_supported"` + ScopesSupported []string `json:"scopes_supported,omitempty"` +} + +func (s *Server) handlePRMetadata(w http.ResponseWriter, r *http.Request) { + md := prMetadata{ + // The protected resource is the MCP endpoint that ships in phase 5. + // Publishing it here lets clients discover where to send Bearer + // tokens once they've completed the OAuth dance. + Resource: s.issuer + "/mcp", + AuthorizationServers: []string{s.issuer}, + BearerMethodsSupported: []string{"header"}, + } + if s.scopes != "" { + md.ScopesSupported = strings.Fields(s.scopes) + } + writeJSON(w, http.StatusOK, md) +} + // ============================================================================ // /oauth/register — RFC 7591 dynamic client registration // ============================================================================ diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index a7c07e0..c2f0858 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -639,10 +639,6 @@ func TestRevoke_MissingToken(t *testing.T) { } } -// -------------------------------------------------------------------------- -// Server validation -// -------------------------------------------------------------------------- - // -------------------------------------------------------------------------- // Callback error paths // -------------------------------------------------------------------------- @@ -987,7 +983,123 @@ func TestReap_RemovesExpiredPending(t *testing.T) { } // -------------------------------------------------------------------------- -// Server validation +// Discovery (.well-known) +// -------------------------------------------------------------------------- + +func TestDiscovery_AuthorizationServerMetadata(t *testing.T) { + fx := newFixture(t) + resp, err := http.Get(fx.httpServer.URL + "/.well-known/oauth-authorization-server") + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } + + var md map[string]any + if err := json.NewDecoder(resp.Body).Decode(&md); err != nil { + t.Fatalf("decode: %v", err) + } + + // Issuer MUST come from cfg.Issuer, not from the request URL. + if md["issuer"] != "https://broker.example.com" { + t.Errorf("issuer = %v, want https://broker.example.com (config)", md["issuer"]) + } + for _, k := range []string{"authorization_endpoint", "token_endpoint", "registration_endpoint", "revocation_endpoint"} { + v, _ := md[k].(string) + if !strings.HasPrefix(v, "https://broker.example.com/") { + t.Errorf("%s = %q, want issuer-rooted URL", k, v) + } + } + + wantContains := map[string][]string{ + "code_challenge_methods_supported": {"S256"}, + "grant_types_supported": {"authorization_code", "refresh_token"}, + "response_types_supported": {"code"}, + "token_endpoint_auth_methods_supported": {"none"}, + } + for field, vals := range wantContains { + got, _ := md[field].([]any) + if len(got) == 0 { + t.Errorf("%s missing or empty", field) + continue + } + for _, want := range vals { + found := false + for _, g := range got { + if g == want { + found = true + break + } + } + if !found { + t.Errorf("%s does not include %q (got %v)", field, want, got) + } + } + } + + scopes, _ := md["scopes_supported"].([]any) + if len(scopes) != 2 { + t.Errorf("scopes_supported = %v, want 2 entries", scopes) + } +} + +func TestDiscovery_ProtectedResourceMetadata(t *testing.T) { + fx := newFixture(t) + resp, err := http.Get(fx.httpServer.URL + "/.well-known/oauth-protected-resource") + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + var md map[string]any + if err := json.NewDecoder(resp.Body).Decode(&md); err != nil { + t.Fatalf("decode: %v", err) + } + if md["resource"] != "https://broker.example.com/mcp" { + t.Errorf("resource = %v, want issuer-rooted /mcp", md["resource"]) + } + servers, _ := md["authorization_servers"].([]any) + if len(servers) != 1 || servers[0] != "https://broker.example.com" { + t.Errorf("authorization_servers = %v, want [config issuer]", servers) + } + bearer, _ := md["bearer_methods_supported"].([]any) + if len(bearer) == 0 || bearer[0] != "header" { + t.Errorf("bearer_methods_supported = %v, want [\"header\"]", bearer) + } +} + +func TestDiscovery_IssuerIgnoresHostHeader(t *testing.T) { + // Crafting a malicious Host header must not leak that host into the + // discovery document. Defense against the metadata-spoofing class of + // OAuth attack starts here. + fx := newFixture(t) + req, err := http.NewRequest(http.MethodGet, + fx.httpServer.URL+"/.well-known/oauth-authorization-server", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Host = "evil.example.com" + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if strings.Contains(string(body), "evil.example.com") { + t.Errorf("discovery doc leaked attacker-supplied Host: %s", body) + } +} + +// -------------------------------------------------------------------------- +// Helpers used by /callback flow tests // -------------------------------------------------------------------------- // startAuthorize walks just steps 1+2 of the flow (client → broker /authorize)