From f704b979d6ea38da7c10d709db19aa2d1ba2094a Mon Sep 17 00:00:00 2001 From: Damien Degois Date: Thu, 28 May 2026 20:42:47 +0200 Subject: [PATCH] fix(consent): widen CSP form-action to the client redirect_uri MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the upstream IdP session is already live, the consent POST's redirect chain stays in one navigation (POST /consent → IdP authorize → /callback → client redirect_uri) and Chromium enforces form-action across every hop. Without the client's redirect_uri origin in form-action, Chrome blocks the final hop. Append it per render, filtered through the CSP3 host-source check so a DCR-registered redirect_uri whose host smuggles a sub-delim cannot break out of the directive. --- config/config.go | 6 +- docs/configuration.md | 2 +- docs/threat-model.md | 11 +++- handlers/authorize.go | 28 +++++---- handlers/consent.go | 100 ++++++++++++++++++++++++++----- handlers/consent_test.go | 126 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 234 insertions(+), 39 deletions(-) diff --git a/config/config.go b/config/config.go index 1b02fd3..bc4b226 100644 --- a/config/config.go +++ b/config/config.go @@ -513,7 +513,7 @@ func Load() (*Config, error) { if o == "" { continue } - canon, err := canonicalCSPHostSource(o) + canon, err := CanonicalCSPHostSource(o) if err != nil { return nil, fmt.Errorf("CSP_FORM_ACTION_EXTRA: %w", err) } @@ -715,7 +715,7 @@ var cspHostSourceHostPort = regexp.MustCompile( `^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)(?::[0-9]+)?$`, ) -// canonicalCSPHostSource validates an operator-supplied CSP host-source +// CanonicalCSPHostSource validates an operator-supplied CSP host-source // (scheme://host[:port]) and returns the lower-cased canonical form // suitable for emission into the consent page's CSP form-action // directive. Returns an error with the offending input quoted on any @@ -736,7 +736,7 @@ var cspHostSourceHostPort = regexp.MustCompile( // - host containing any character outside the CSP host-char set // (catches "https://host.com;foo", "https://host_name.com", and // similar header-injection-shaped paste errors) -func canonicalCSPHostSource(in string) (string, error) { +func CanonicalCSPHostSource(in string) (string, error) { if in == "*" || strings.Contains(in, "*") { return "", fmt.Errorf("%q wildcard sources are not supported; supply explicit origins", in) } diff --git a/docs/configuration.md b/docs/configuration.md index fa5264f..27416ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,7 +92,7 @@ control. | `PKCE_REQUIRED` | `true` | Set `false` for legacy clients that omit PKCE (Cursor, MCP Inspector, ChatGPT). Rejected by `PROD_MODE`. | | `COMPAT_ALLOW_STATELESS` | `false` | Synthesize a server-side `state` on `/authorize` when the client omits it. Strict mode refuses the request; counter `mcp_auth_access_denied_total{reason="state_missing"}` fires either way. Rejected by `PROD_MODE`. | | `RENDER_CONSENT_PAGE` | `true` | Render an explicit proxy-side consent page on `/authorize` so the user sees who's asking and where they'll be redirected before the IdP login. Closes the silent-token-issuance path where a malicious DCR client + an active IdP session = tokens issued without any user interaction. Plain HTML, no JavaScript. Set `false` to fall back to the legacy silent-redirect — only when every caller is non-interactive and known-trusted. | -| `CSP_FORM_ACTION_EXTRA` | (empty) | Comma-separated additional `scheme://host[:port]` origins appended to the consent page's CSP `form-action` source list, alongside `'self'` and the discovered OIDC authorize endpoint. Needed when the IdP redirect chain crosses the authorize host: Entra B2C (`tenant.b2clogin.com`), personal Microsoft accounts (`login.live.com`), federated AD FS (customer host), sovereign clouds (`login.microsoftonline.us` / `login.partner.microsoftonline.cn`). Each entry is validated at startup against the CSP3 §2.4 host-source ABNF (stricter than RFC 3986 reg-name — only `ALPHA`/`DIGIT`/`-`/`.` in hostname labels, or `[IPv6]`); paths, queries, fragments, userinfo, wildcards, and sub-delim host characters (`;`, `,`, `&`, `_`, …) are rejected loud so a misconfigured allowlist cannot silently weaken the emitted header. Scheme and host are ASCII-lower-cased on the way in (per CSP3 §6.7.2.5) so the emitted header is greppable in the form an operator typed. Empty for the common one-host-IdP case. | +| `CSP_FORM_ACTION_EXTRA` | (empty) | Comma-separated additional `scheme://host[:port]` origins appended to the consent page's CSP `form-action` source list, alongside `'self'`, the discovered OIDC authorize endpoint, and the current request's validated `redirect_uri` origin (added per render so already-authenticated upstream sessions whose redirect chain stays in one navigation — `POST /consent` → IdP authorize → `/callback` → client `redirect_uri` — are not blocked by Chromium's form-action enforcement at the final hop; the redirect_uri origin is itself filtered through the same CSP3 host-source check below, with a `consent_csp_redirect_uri_skipped` warn when a registered client's host fails the check). Needed when the IdP redirect chain crosses the authorize host: Entra B2C (`tenant.b2clogin.com`), personal Microsoft accounts (`login.live.com`), federated AD FS (customer host), sovereign clouds (`login.microsoftonline.us` / `login.partner.microsoftonline.cn`). Each entry is validated at startup against the CSP3 §2.4 host-source ABNF (stricter than RFC 3986 reg-name — only `ALPHA`/`DIGIT`/`-`/`.` in hostname labels, or `[IPv6]`); paths, queries, fragments, userinfo, wildcards, and sub-delim host characters (`;`, `,`, `&`, `_`, …) are rejected loud so a misconfigured allowlist cannot silently weaken the emitted header. Scheme and host are ASCII-lower-cased on the way in (per CSP3 §6.7.2.5) so the emitted header is greppable in the form an operator typed. Empty for the common one-host-IdP case. | | `OIDC_ALLOW_INSECURE_HTTP` | `false` | Dev-only escape hatch for cleartext `http://` OIDC issuers (Docker Compose Keycloak demo). Rejected when `PROD_MODE=true`. | ## Logging and observability diff --git a/docs/threat-model.md b/docs/threat-model.md index c8a28da..b0dedbc 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -45,9 +45,14 @@ rather than assuming they're already covered. the IdP operator's own threat model. - **Browser-side XSS in the consent page.** The page is JS-free and CSP-locked (`default-src 'none'`, `style-src 'unsafe-inline'`, - `script-src` defaults to none, `frame-ancestors 'none'`). A - browser-engine bug that escapes contextual HTML escaping is not - separately mitigated. + `script-src` defaults to none, `frame-ancestors 'none'`, + `base-uri 'none'`). `form-action` is widened only to the upstream + IdP origin (from OIDC discovery), any `CSP_FORM_ACTION_EXTRA` + entries, and the current request's validated `redirect_uri` origin + — each of the three filtered through the CSP3 §2.4 host-source + ABNF check so a DCR-registered redirect_uri whose host smuggles a + sub-delim cannot break out of the directive. A browser-engine bug + that escapes contextual HTML escaping is not separately mitigated. - **Network-level MITM between proxy and IdP.** TLS verification is on by default in the `oauth2` library; an operator who disables it (or a CA compromise) lets a MITM observe the upstream code diff --git a/handlers/authorize.go b/handlers/authorize.go index d095aa3..d7a61f9 100644 --- a/handlers/authorize.go +++ b/handlers/authorize.go @@ -78,22 +78,24 @@ type AuthorizeConfig struct { // front-loaded above the response_type / resource / PKCE / state // checks. func Authorize(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *oauth2.Config, authzCfg AuthorizeConfig) http.HandlerFunc { - // Precompute the consent page's CSP once at startup. The IdP - // origin in form-action must match the upstream AuthURL so the - // consent POST's 302 to the IdP is not blocked by Chromium's - // form-action redirect-chain enforcement. Extra operator-supplied - // origins (CSP_FORM_ACTION_EXTRA) cover IdP topologies whose - // redirect chain leaves the authorize host (Entra B2C, federated - // AD FS, personal MS accounts, sovereign clouds). + // Precompute the static form-action source list once at startup. + // The IdP origin in form-action must match the upstream AuthURL + // so the consent POST's 302 to the IdP is not blocked by + // Chromium's form-action redirect-chain enforcement. Extra + // operator-supplied origins (CSP_FORM_ACTION_EXTRA) cover IdP + // topologies whose redirect chain leaves the authorize host + // (Entra B2C, federated AD FS, personal MS accounts, sovereign + // clouds). The client's redirect_uri origin is appended per + // render — see formatConsentCSP. // // Skipped entirely when RenderConsentPage is false: the silent - // fork bypasses renderConsent, the precomputed CSP is unused, and - // a "consent CSP misconfigured" warn on a deployment that doesn't - // render the consent page would be misleading noise. - var consentCSP string + // fork bypasses renderConsent, the precomputed sources are + // unused, and a "consent CSP misconfigured" warn on a deployment + // that doesn't render the consent page would be misleading noise. + var consentCSPSources []string if authzCfg.RenderConsentPage { var idpOriginOK bool - consentCSP, idpOriginOK = buildConsentCSP(oauth2Cfg.Endpoint.AuthURL, authzCfg.CSPFormActionExtra) + consentCSPSources, idpOriginOK = buildConsentCSPSources(oauth2Cfg.Endpoint.AuthURL, authzCfg.CSPFormActionExtra) if !idpOriginOK { logger.Warn("consent_csp_idp_origin_missing", zap.String("auth_url", oauth2Cfg.Endpoint.AuthURL), @@ -264,7 +266,7 @@ func Authorize(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg // redirect) replays from POST /consent on approval. if authzCfg.RenderConsentPage { metrics.AuthorizeInitiated.WithLabelValues("consent").Inc() - renderConsent(w, r, tm, logger, baseURL, authzCfg.ResourceName, consentCSP, sealedConsent{ + renderConsent(w, r, tm, logger, baseURL, authzCfg.ResourceName, consentCSPSources, sealedConsent{ // Per-render JTI: a fresh id every GET /authorize so // back-button = re-consent (each render gets its own // single-use claim slot) rather than dead-state errors. diff --git a/handlers/consent.go b/handlers/consent.go index 4bdcc89..765d0a4 100644 --- a/handlers/consent.go +++ b/handlers/consent.go @@ -7,9 +7,11 @@ import ( "html/template" "net/http" "net/url" + "slices" "strings" "time" + "github.com/babs/mcp-auth-proxy/config" "github.com/babs/mcp-auth-proxy/metrics" "github.com/babs/mcp-auth-proxy/replay" "github.com/babs/mcp-auth-proxy/token" @@ -119,10 +121,12 @@ var consentTmpl = template.Must(template.New("consent").Parse(` `)) -// buildConsentCSP returns the Content-Security-Policy header value -// for the consent page, with form-action widened to include the -// upstream IdP origin derived from authURL and any operator-supplied -// extra origins. +// buildConsentCSPSources returns the static form-action source list +// for the consent page CSP — 'self', the upstream IdP origin (when +// derivable from authURL) and any operator-supplied extras. The +// client's redirect_uri origin is NOT included here; it is appended +// per-render in formatConsentCSP because each consent page is bound +// to one validated redirect_uri. // // form-action is enforced against every URL in the redirect chain // initiated by a form submit on Blink-based browsers (Chrome, Edge, @@ -134,9 +138,9 @@ var consentTmpl = template.Must(template.New("consent").Parse(` // // authURL is the upstream OIDC authorization endpoint (set from // discovery at startup). On parse failure / empty value the -// returned CSP falls back to 'self' only — the consent page will -// be Chrome-broken but the proxy still serves; a startup log emits -// the warning so deployments do not silently regress. +// returned source list falls back to 'self' only — the consent page +// will be Chrome-broken but the proxy still serves; a startup log +// emits the warning so deployments do not silently regress. // // extraOrigins are additional scheme://host[:port] entries appended // verbatim. Validated at config-load time (config.Load) so they are @@ -145,8 +149,8 @@ var consentTmpl = template.Must(template.New("consent").Parse(` // personal MS accounts → login.live.com, federated AD FS → customer // host, sovereign clouds → cloud-specific login domains. Each extra // is a single fixed origin, not a wildcard. -func buildConsentCSP(authURL string, extraOrigins []string) (csp string, idpOriginOK bool) { - sources := make([]string, 0, 2+len(extraOrigins)) +func buildConsentCSPSources(authURL string, extraOrigins []string) (sources []string, idpOriginOK bool) { + sources = make([]string, 0, 3+len(extraOrigins)) sources = append(sources, "'self'") if authURL != "" { if u, err := url.Parse(authURL); err == nil && u.Scheme != "" && u.Host != "" { @@ -160,7 +164,70 @@ func buildConsentCSP(authURL string, extraOrigins []string) (csp string, idpOrig } } sources = append(sources, extraOrigins...) - return "default-src 'none'; style-src 'unsafe-inline'; form-action " + strings.Join(sources, " ") + "; frame-ancestors 'none'; base-uri 'none'", idpOriginOK + return sources, idpOriginOK +} + +// formatConsentCSP joins the precomputed source list with the +// per-render redirect_uri origin and returns the full +// Content-Security-Policy header value. +// +// The redirect_uri origin is added because Chromium's form-action +// check covers the *entire* redirect chain a form submit triggers. +// When the user already has a live session with the upstream IdP, +// the chain doesn't terminate at the IdP login page (which would +// end form-action enforcement at a 200 HTML response) — it stays in +// one navigation: POST /consent → IdP authorize 302 → proxy +// /callback 302 → client redirect_uri 302. The final hop crosses +// to the client's origin (e.g. https://claude.ai), and without it +// in form-action Chrome blocks the navigation at that step. The +// error surfaces in DevTools as "Sending form data to /consent +// violates form-action" — misleading; the form's action URL is +// just what Chrome names in the message, the actual block is the +// downstream hop. +// +// redirectURI has already been validated against the registered +// sealedClient.RedirectURIs at /authorize. Skipped when empty, +// when the parsed origin matches an entry already in baseSources +// (same-host loopback / proxy-hosted clients), or when the origin +// would not pass the CSP3 host-source ABNF check. +// +// The host-source check matters because RFC 3986 reg-name allows +// sub-delim characters (`;`, `,`, `&`, `=`) that, while accepted by +// Go's url.Parse, would terminate the form-action directive early +// when emitted into the header — same header-injection foot-gun the +// startup validator on CSP_FORM_ACTION_EXTRA closes for operator +// input. A malicious DCR client cannot weaken its own consent +// page's CSP by registering `https://evil.example;injected/cb`. +// When the redirect_uri host fails the check, the consent page +// renders with the unwidened CSP and a warn fires carrying the +// offending redirect_uri AND the client_id so operators can +// alert/group by client without a cross-grep against +// client_registered. In Chromium this surfaces as the original +// form-action block on the final redirect hop, but no header +// smuggling occurs. +func formatConsentCSP(logger *zap.Logger, baseSources []string, clientID, redirectURI string) string { + sources := baseSources + if redirectURI != "" { + if u, err := url.Parse(redirectURI); err == nil && u.Scheme != "" && u.Host != "" { + origin := strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + // Skip when already covered by 'self' / IdP origin / + // operator extras (proxy-hosted clients, same-IdP-tenant + // redirects). The dedup keeps the emitted header tight + // and matches the case-folded canonical form above. + if !slices.Contains(baseSources, origin) { + if _, err := config.CanonicalCSPHostSource(origin); err != nil { + logger.Warn("consent_csp_redirect_uri_skipped", + zap.String("client_id", clientID), + zap.String("redirect_uri", redirectURI), + zap.Error(err), + ) + } else { + sources = slices.Concat(baseSources, []string{origin}) + } + } + } + } + return "default-src 'none'; style-src 'unsafe-inline'; form-action " + strings.Join(sources, " ") + "; frame-ancestors 'none'; base-uri 'none'" } // renderConsent seals the validated /authorize parameters into a @@ -173,7 +240,7 @@ func buildConsentCSP(authURL string, extraOrigins []string) (csp string, idpOrig // the registered redirect_uri (server_error, RFC 6749 §4.1.2.1) // rather than rendering a partial page — the client is already // trusted at this point in the flow. -func renderConsent(w http.ResponseWriter, r *http.Request, tm *token.Manager, logger *zap.Logger, baseURL, resourceName, csp string, consent sealedConsent) { +func renderConsent(w http.ResponseWriter, r *http.Request, tm *token.Manager, logger *zap.Logger, baseURL, resourceName string, cspSources []string, consent sealedConsent) { consentToken, err := tm.SealJSON(consent, token.PurposeConsent) if err != nil { logger.Error("consent_seal_failed", zap.Error(err)) @@ -205,10 +272,13 @@ func renderConsent(w http.ResponseWriter, r *http.Request, tm *token.Manager, lo // style-src for this response only; script-src stays default // (none) so the page remains JavaScript-free, and frame-ancestors // stays none so the consent UI cannot be framed by an attacker - // origin. form-action also names the upstream IdP origin so the - // approve POST's 302 to the IdP is not blocked by Chromium's - // redirect-chain enforcement (see buildConsentCSP). - w.Header().Set("Content-Security-Policy", csp) + // origin. form-action names the upstream IdP origin AND the + // client's redirect_uri origin so the approve POST's redirect + // chain (POST /consent → IdP authorize → /callback → client + // redirect_uri, when the upstream session is already live) is + // not blocked by Chromium's form-action enforcement at the final + // hop. See formatConsentCSP. + w.Header().Set("Content-Security-Policy", formatConsentCSP(logger, cspSources, consent.ClientID, consent.RedirectURI)) w.WriteHeader(http.StatusOK) if err := consentTmpl.Execute(w, data); err != nil { // Body already started — log only. diff --git a/handlers/consent_test.go b/handlers/consent_test.go index d6217dd..407e0d3 100644 --- a/handlers/consent_test.go +++ b/handlers/consent_test.go @@ -350,13 +350,13 @@ func TestConsent_RelaxedCSP(t *testing.T) { } } -// TestBuildConsentCSP_IdPOrigin pins buildConsentCSP's two-mode +// TestBuildConsentCSPSources_IdPOrigin pins buildConsentCSPSources's two-mode // contract: a valid AuthURL widens form-action to include its // origin (scheme://host[:port]); a missing/invalid AuthURL falls // back to 'self' only and signals !idpOriginOK so the caller can // log a startup warning. Non-default ports must be preserved // (operators self-host IdPs on arbitrary ports). -func TestBuildConsentCSP_IdPOrigin(t *testing.T) { +func TestBuildConsentCSPSources_IdPOrigin(t *testing.T) { cases := []struct { name string authURL string @@ -433,7 +433,8 @@ func TestBuildConsentCSP_IdPOrigin(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - csp, ok := buildConsentCSP(tc.authURL, tc.extra) + sources, ok := buildConsentCSPSources(tc.authURL, tc.extra) + csp := formatConsentCSP(zap.NewNop(), sources, "", "") if ok != tc.wantOriginOK { t.Errorf("idpOriginOK = %v, want %v (csp=%q)", ok, tc.wantOriginOK, csp) } @@ -444,6 +445,123 @@ func TestBuildConsentCSP_IdPOrigin(t *testing.T) { } } +// TestFormatConsentCSP_RedirectURIOrigin pins the per-render +// redirect_uri origin append. When the upstream IdP session is +// already live, the consent POST's redirect chain stays in one +// navigation all the way to the client's redirect_uri — without +// that final origin in form-action, Chromium blocks the navigation +// at the last hop (the proxy's /callback 302 to the client) and +// surfaces a misleading "violates form-action 'self' " error +// naming /consent. The chain in this case is: +// +// POST /consent → IdP authorize → /callback → client redirect_uri +// +// when the user must log in, the IdP returns a 200 HTML login page, +// form-action enforcement ends there, and the post-login callback +// → client redirect_uri navigation is no longer governed by the +// consent page's CSP. +func TestFormatConsentCSP_RedirectURIOrigin(t *testing.T) { + base, _ := buildConsentCSPSources("https://login.microsoftonline.com/x/oauth2/v2.0/authorize", nil) + cases := []struct { + name string + redirectURI string + wantSubstr string + wantAbsent string + wantWarn bool + }{ + { + name: "cross-origin client redirect_uri appended", + redirectURI: "https://claude.ai/api/organizations/abc/mcp_callback", + wantSubstr: "form-action 'self' https://login.microsoftonline.com https://claude.ai;", + }, + { + name: "redirect_uri sharing self origin is deduped", + redirectURI: "https://login.microsoftonline.com/somewhere", + wantSubstr: "form-action 'self' https://login.microsoftonline.com;", + wantAbsent: "https://login.microsoftonline.com https://login.microsoftonline.com", + }, + { + name: "loopback redirect with port preserved", + redirectURI: "http://127.0.0.1:51789/oauth/callback", + wantSubstr: "http://127.0.0.1:51789", + }, + { + name: "IPv6 loopback redirect preserves brackets", + redirectURI: "http://[::1]:51789/oauth/callback", + wantSubstr: "http://[::1]:51789", + }, + { + name: "case canonicalised to lowercase", + redirectURI: "HTTPS://Claude.AI/Callback", + wantSubstr: "https://claude.ai", + }, + { + name: "empty redirect_uri leaves CSP unchanged", + redirectURI: "", + wantSubstr: "form-action 'self' https://login.microsoftonline.com;", + }, + { + name: "malformed redirect_uri silently dropped", + redirectURI: "::not-a-url::", + wantSubstr: "form-action 'self' https://login.microsoftonline.com;", + }, + { + // H1 / CSP header-injection defence: a DCR client whose + // host smuggles a sub-delim (legal per RFC 3986 reg-name, + // illegal per CSP3 §2.4 host-source) must NOT widen the + // directive — the offending origin would terminate + // form-action early and inject a bogus directive. + name: "sub-delim host rejected by CSP host-source check", + redirectURI: "https://evil.example;injected/cb", + wantSubstr: "form-action 'self' https://login.microsoftonline.com;", + wantAbsent: "evil.example", + wantWarn: true, + }, + { + // Underscore is valid in DNS resolution but not in CSP + // host-char (ALPHA/DIGIT/'-' only). Same defence as above. + name: "underscore host rejected by CSP host-source check", + redirectURI: "https://host_underscore.example/cb", + wantSubstr: "form-action 'self' https://login.microsoftonline.com;", + wantAbsent: "host_underscore", + wantWarn: true, + }, + } + const testClientID = "client-uuid-under-test" + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + core, logs := observer.New(zap.WarnLevel) + logger := zap.New(core) + csp := formatConsentCSP(logger, base, testClientID, tc.redirectURI) + if !strings.Contains(csp, tc.wantSubstr) { + t.Errorf("CSP missing %q: got %q", tc.wantSubstr, csp) + } + if tc.wantAbsent != "" && strings.Contains(csp, tc.wantAbsent) { + t.Errorf("CSP unexpectedly contained %q: got %q", tc.wantAbsent, csp) + } + warnEntries := logs.FilterMessage("consent_csp_redirect_uri_skipped").All() + if tc.wantWarn && len(warnEntries) == 0 { + t.Errorf("expected consent_csp_redirect_uri_skipped warn, got none (logs=%v)", logs.All()) + } + if !tc.wantWarn && len(warnEntries) != 0 { + t.Errorf("unexpected consent_csp_redirect_uri_skipped warn (logs=%v)", logs.All()) + } + // When the warn fires, both client_id and redirect_uri + // must be present so an operator can alert/group by + // client_id without joining against client_registered. + if tc.wantWarn && len(warnEntries) > 0 { + fields := warnEntries[0].ContextMap() + if got, _ := fields["client_id"].(string); got != testClientID { + t.Errorf("warn client_id = %q, want %q", got, testClientID) + } + if got, _ := fields["redirect_uri"].(string); got != tc.redirectURI { + t.Errorf("warn redirect_uri = %q, want %q", got, tc.redirectURI) + } + } + }) + } +} + // TestAuthorize_ConsentCSPWarn pins the construction-time gate on // the `consent_csp_idp_origin_missing` warn: it fires when // RenderConsentPage=true AND OIDC discovery returned an unusable @@ -455,7 +573,7 @@ func TestBuildConsentCSP_IdPOrigin(t *testing.T) { // warns about a page it does not render. func TestAuthorize_ConsentCSPWarn(t *testing.T) { tm := newTestTokenManager(t) - // oauth2.Config with empty AuthURL is the only state buildConsentCSP + // oauth2.Config with empty AuthURL is the only state buildConsentCSPSources // flags as !idpOriginOK from real call sites — every other shape // would have failed OIDC discovery before Authorize is constructed. brokenOAuth2 := &oauth2.Config{