Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ ordering) live in [`specs.md`](./specs.md).
- `mcp_auth_consent_decisions_total{decision}` — funnel approve/deny
- `mcp_auth_access_denied_total{reason}` — every denial bucket
- `mcp_auth_replay_detected_total{kind}` — `code` / `refresh` /
`consent` / `callback_state`
`consent` / `callback_state`. `consent` also counts benign
double-submits (replay answers with a re-rendered consent page,
not a 4xx) — alert on sustained rate, not single ticks
- `mcp_auth_rate_limited_total{endpoint}` — pre-auth httprate 429s
- `mcp_auth_idp_exchange_throttled_total` — outbound bucket denials
- `mcp_auth_clients_registered_total`, `mcp_auth_token_seals_total{purpose}`,
Expand Down
35 changes: 18 additions & 17 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,14 @@ type Config struct {
// env: UPSTREAM_AUTHORIZATION_HEADER. Treat as a secret in
// deployment (mount from a Secret, not a ConfigMap).
UpstreamAuthorization string
// CSPFormActionExtra lists additional scheme://host[:port]
// origins appended to the consent page's CSP form-action source
// list, alongside 'self' and the discovered upstream authorize
// endpoint origin. Needed for IdP redirect chains that cross the
// authorize host: Entra B2C (*.b2clogin.com), personal Microsoft
// accounts (login.live.com), federated AD FS (customer-owned
// host), sovereign clouds (login.microsoftonline.us /
// .partner.microsoftonline.cn). Each entry is a single fixed
// origin — wildcards are intentionally not supported because the
// directive's role is an explicit allowlist defense-in-depth
// against HTML injection on the consent page. Empty for the
// common one-host-IdP case. env: CSP_FORM_ACTION_EXTRA,
// comma-separated.
// CSPFormActionExtra is DEPRECATED and ignored. The consent POST
// is now answered with a same-origin navigation interstitial
// (handlers.renderNavInterstitial), which ends Chromium's
// form-action redirect-chain enforcement at the proxy — no IdP or
// client origin needs to be listed, so form-action is 'self'-only
// again. The variable is still parsed and validated so existing
// deployments keep starting; a startup warning flags it for
// removal. env: CSP_FORM_ACTION_EXTRA, comma-separated.
CSPFormActionExtra []string
// secretWeakWarning is non-empty when TOKEN_SIGNING_SECRET matches
// an obvious-weakness pattern (all-same byte, or short repeating
Expand Down Expand Up @@ -716,10 +711,16 @@ var cspHostSourceHostPort = regexp.MustCompile(
)

// 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
// validation failure.
// (scheme://host[:port]) and returns the lower-cased canonical form.
// Returns an error with the offending input quoted on any validation
// failure.
//
// Since the consent interstitial removed form-action origin
// enumeration, the only caller is the deprecated
// CSP_FORM_ACTION_EXTRA parse path: the result is validated but
// discarded (nothing emits it into a header anymore). Kept so a
// malformed value still fails startup loud instead of silently
// changing meaning; delete together with CSP_FORM_ACTION_EXTRA.
//
// Canonicalisation: scheme and host are ASCII-lower-cased per CSP3
// §6.7.2.5 (host-source matching is case-insensitive); operators
Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`, 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. |
| `CSP_FORM_ACTION_EXTRA` | (empty) | **Deprecated, ignored.** `POST /consent` is now answered with a same-origin navigation interstitial (200 + meta refresh) instead of a 302, which terminates Chromium's form-action redirect-chain enforcement at the proxy — the consent page's `form-action` is `'self'`-only and no IdP / client origin needs to be enumerated. Still parsed and validated so existing deployments keep starting; a `csp_form_action_extra_deprecated` startup warning fires when set. Remove it from your deployment. |
| `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
Expand Down Expand Up @@ -160,7 +160,9 @@ sum(mcp_auth_consent_decisions_total{decision="approved"})
refresh-rotation outcomes.
- `mcp_auth_replay_detected_total{kind}` — `code` / `refresh` /
`consent` / `callback_state` replays caught by the Redis-backed
store.
store. The `consent` kind answers with a re-rendered consent page
(200), so it also counts benign double-submits / back-button
re-POSTs — not a pure attack signal.
- `mcp_auth_groups_claim_shape_mismatch_total` — id_token `groups`
claim failed to decode as `[]string`. **No denial occurs** — user
is admitted with empty groups; the counter surfaces an IdP schema
Expand Down
4 changes: 3 additions & 1 deletion docs/redis-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ The proxy's Prom counters surface Redis-related behavior:
counts 503s returned because Redis errored.
- `mcp_auth_replay_detected_total{kind="code"|"refresh"}` — counts
legitimate replay-detection events. A spike may be an attack, or a
broken client that retries with the same code.
broken client that retries with the same code. The `consent` kind
is softer: a replayed consent POST is answered with a re-rendered
consent page, so it also counts benign double-submits.
- `/readyz` on the metrics port — probes Redis with a cached
`Exists`. Feeds K8s readiness.

Expand Down
5 changes: 5 additions & 0 deletions docs/runbooks/consent-denials.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ stays clean.
3. **Automated test rig.** A CI job that drove `/authorize` and
expected a 302 now sees a 200 HTML page; whatever scraped
the form the wrong way looks like a denial in the logs.
Same trap one step later: `POST /consent` no longer 302s
either — approve/deny answer with a 200 navigation
interstitial whose `meta http-equiv="refresh"` carries the
next URL. Rigs must extract that target, not the `Location`
header.
4. **Consent page rendering broken behind the ingress.** Strict
CSP or content-rewriting at the L7 hop mangles the form;
user can't click. Symptom: zero approves, zero denies, just
Expand Down
13 changes: 6 additions & 7 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,12 @@ rather than assuming they're already covered.
- **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'`,
`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.
`base-uri 'none'`, `form-action 'self'`). The consent POST is
answered with a same-origin navigation interstitial (200 + meta
refresh), so `form-action` never needs to name the IdP or client
origins — an injected form cannot point anywhere but the proxy
itself. 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
Expand Down
37 changes: 2 additions & 35 deletions handlers/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,6 @@ type AuthorizeConfig struct {
// name via MCP_RESOURCE_NAME. Falls back to CanonicalResource
// when empty.
ResourceName string
// CSPFormActionExtra is an operator-supplied list of additional
// scheme://host[:port] origins appended to the consent page's
// form-action source list (alongside 'self' and the discovered
// upstream authorize endpoint origin). Needed for IdP redirect
// chains that cross the authorize host (Entra B2C, federated
// AD FS, personal MS accounts, sovereign clouds). Validated at
// config.Load time. Empty for the common case.
CSPFormActionExtra []string
}

// Authorize handles GET /authorize (OAuth 2.1 PKCE authorization request).
Expand All @@ -78,31 +70,6 @@ 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 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 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
consentCSPSources, idpOriginOK = buildConsentCSPSources(oauth2Cfg.Endpoint.AuthURL, authzCfg.CSPFormActionExtra)
if !idpOriginOK {
logger.Warn("consent_csp_idp_origin_missing",
zap.String("auth_url", oauth2Cfg.Endpoint.AuthURL),
zap.String("hint", "consent submit will be blocked in Chromium browsers; verify OIDC discovery"),
)
}
}
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if rejectRepeatedParams(w, q,
Expand Down Expand Up @@ -266,7 +233,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, consentCSPSources, sealedConsent{
renderConsent(w, tm, logger, baseURL, authzCfg.ResourceName, 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.
Expand All @@ -281,7 +248,7 @@ func Authorize(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg
Typ: token.PurposeConsent,
Audience: baseURL,
ExpiresAt: time.Now().Add(consentTTL),
})
}, false)
return
}

Expand Down
18 changes: 16 additions & 2 deletions handlers/callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,11 +499,25 @@ func callbackHandler(tm *token.Manager, logger *zap.Logger, audience string, oau
// upstream, so a parse error here is an invariant violation rather
// than attacker-controlled input.
func redirectAuthzError(w http.ResponseWriter, r *http.Request, redirectURI, state, errCode, errDesc, audience string) {
u, err := url.Parse(redirectURI)
target, err := authzErrorURL(redirectURI, state, errCode, errDesc, audience)
if err != nil {
writeOAuthError(w, http.StatusBadRequest, errCode, errDesc)
return
}
http.Redirect(w, r, target, http.StatusFound)
}

// authzErrorURL builds the RFC 6749 §4.1.2.1 error envelope on the
// client's redirect_uri. Split out of redirectAuthzError so POST
// /consent responses can deliver the same envelope through the
// navigation interstitial instead of a 302 — see
// renderNavInterstitial for why a form POST must not answer with a
// cross-origin redirect.
func authzErrorURL(redirectURI, state, errCode, errDesc, audience string) (string, error) {
u, err := url.Parse(redirectURI)
if err != nil {
return "", err
}
q := u.Query()
q.Set("error", errCode)
if errDesc != "" {
Expand All @@ -516,5 +530,5 @@ func redirectAuthzError(w http.ResponseWriter, r *http.Request, redirectURI, sta
u.RawQuery = q.Encode()
u.Fragment = ""
u.RawFragment = ""
http.Redirect(w, r, u.String(), http.StatusFound)
return u.String(), nil
}
Loading