Skip to content

fix(oauth): read the issuer's real AS metadata instead of guessing it - #37

Closed
marselsel wants to merge 2 commits into
mainfrom
fix/issuer-discovery
Closed

fix(oauth): read the issuer's real AS metadata instead of guessing it#37
marselsel wants to merge 2 commits into
mainfrom
fix/issuer-discovery

Conversation

@marselsel

Copy link
Copy Markdown
Owner

Problem

buildOAuthMetadata invented the entire authorization-server document — endpoints derived from the WorkOS URL layout, plus hardcoded grant_types_supported, code_challenge_methods_supported and scopes_supported. Those guesses were served to clients as fact.

For WorkOS they happen to be right, which is why it never bit us. For Entra the authorize endpoint is /oauth2/v2.0/authorize, not the /v2.0/oauth2/authorize we derive. This is the root cause behind the registration_endpoint bug fixed in #36 — that PR patched the one instance that was actively wrong; this one retires the class.

Solution

Fetch the issuer's own metadata at startup (RFC 8414, falling back to OIDC discovery — three URL layouts tried in order, since providers disagree). Precedence per field: explicit env override > discovered > derived default.

Safety

Each has a test:

  • A document whose issuer doesn't match is discarded, not merged. This document tells clients where to send users to authenticate; honouring one that speaks for a different issuer is a redirect-hijack primitive.
  • issuer is never taken from the document — it must match the iss claim byte-for-byte.
  • Any failure falls back to today's derived defaults and logs a warning. Unreachable issuer, timeout, 404, non-JSON — discovery can improve the document but never block startup. Fetch bounded at 5s.
  • Explicit overrides still win, including OAUTH_REGISTRATION_ENDPOINT=none, so discovery can't silently re-advertise DCR an operator disabled.

Verified against the live issuer

discovery=ok, and exactly two fields change — both previously untrue:

Field Before (guessed) After (from WorkOS)
grant_types_supported authorization_code, refresh_token + device_code
scopes_supported openid, email, profile + offline_access

Endpoints, issuer, jwks_uri, response_types_supported and code_challenge_methods_supported unchanged.

Tests

167 passing (up from 155). Covers the three URL layouts incl. a path-bearing Entra-style issuer, the issuer-mismatch rejection, the never-throws fallback, and each precedence rule.

Disable with OAUTH_DISCOVERY=false.

buildOAuthMetadata fabricated every field of the authorization-server document:
endpoints derived from the WorkOS URL layout ({issuer}/oauth2/*) plus hardcoded
grant_types_supported, code_challenge_methods_supported and scopes_supported.
For any IdP not sharing that layout the result was wrong — Entra's authorize
endpoint is /oauth2/v2.0/authorize, not the /v2.0/oauth2/authorize we derived —
and the guesses were presented to clients as fact. Same root cause as the
registration_endpoint bug fixed in 0.1.10.

The server now fetches the issuer's own metadata at startup (RFC 8414, falling
back to OIDC discovery; three URL layouts tried in order) and advertises what it
says. Precedence per field: explicit env override > discovered > derived default.

Safety properties, each with a test:
- A document whose `issuer` does not match is DISCARDED, not merged. This
  document tells clients where to send users to authenticate, so honouring one
  that speaks for another issuer would be a redirect-hijack primitive.
- `issuer` is never taken from the document; it must match the iss claim exactly.
- Any failure (unreachable, timeout, non-JSON, 404) falls back to the previous
  derived defaults and logs a warning. Discovery can improve the document, never
  prevent startup. Fetch is bounded at 5s.
- Explicit overrides still win, including OAUTH_REGISTRATION_ENDPOINT=none, so
  discovery cannot silently re-advertise DCR an operator turned off.

Verified against the live WorkOS issuer: discovery=ok, and the only fields that
change are two that were previously untrue — grant_types_supported gains
device_code and scopes_supported gains offline_access, both of which WorkOS
actually supports.

Disable with OAUTH_DISCOVERY=false. Release 0.1.11.
Addresses five findings from an adversarial review of the initial discovery
implementation, which only affected the ADVERTISED metadata:

1. Registration fallback regressed the 0.1.10 fix. When discovery succeeded but
   the issuer omitted registration_endpoint (WorkOS with DCR off), the code fell
   back to the derived {issuer}/oauth2/register guess and re-advertised the broken
   endpoint. A successful discovery is now authoritative: absent means omit; the
   derived guess is used only when discovery did not run or failed.

2. Discovery was half-wired: advertised != verified. The advertised jwks_uri came
   from the discovered doc, but signature verification and the userinfo email
   fallback still used the derived values. On a non-WorkOS IdP we would advertise
   one JWKS and verify against another. Endpoints are now resolved ONCE
   (resolveOAuthEndpoints: explicit > discovered > derived) and that single set
   drives both the advertised document and createAccessTokenVerifier.

3. Startup delay was up to 3x5s, not 5s. Discovery now shares one total time
   budget across all candidate URLs, and the candidate list is de-duplicated (a
   path-less issuer previously produced the same URL twice).

4. Discovered URLs were unvalidated. Every discovered endpoint is now checked
   (https, or http only on loopback) before being advertised or used; an http
   downgrade or malformed value is ignored in favour of the derived default. This
   is load-bearing now that jwks_uri feeds verification.

5. Issuer match is trailing-slash-insensitive (a doc whose issuer differs only by
   a trailing slash is accepted; a genuinely different issuer is still rejected).

177 tests (up from 167), each finding covered. Verified against the live WorkOS
issuer: discovery=ok, registration correctly omitted even without =none, and the
jwks_uri advertised equals the one used for verification.
@marselsel

Copy link
Copy Markdown
Owner Author

Closing this without merging — a deliberate decision, not abandonment. Recording the reasoning here since the analysis is worth preserving.

Why

The problem this PR set out to solve is already fixed and deployed. The concrete production bug — advertising a registration_endpoint that returns 400 after DCR was disabled — was fixed in 0.1.10 (#36) and verified live. Non-WorkOS operators already have working escape hatches shipped in that release: OAUTH_AUTHORIZATION_ENDPOINT, OAUTH_TOKEN_ENDPOINT, OAUTH_JWKS_URL, OAUTH_USERINFO_URL, and OAUTH_REGISTRATION_ENDPOINT=none.

This PR aimed to retire the class of "guessed metadata" bugs by fetching the issuer's real document at startup. Two adversarial review rounds later, that goal looks more expensive than it's worth:

  • Round 1 found the discovery was half-wired (advertised jwks_uri ≠ the one used for verification) and a regression of the 0.1.10 fix.
  • Round 2, reviewing the rework, found that wiring discovery into verification opened a new and worse hole: a matching-issuer document could point jwks_uri/userinfo_endpoint at an attacker host (no same-origin constraint), pinning signature verification to attacker keys and exfiltrating bearer tokens. Plus a PKCE-downgrade passthrough (code_challenge_methods_supported: ["S256","plain"]), silent discovery=ok failures on Auth0/Keycloak (RFC 8414 doc lacks userinfo_endpoint), and per-instance nondeterminism on a transient IdP blip.

When each fix for a review finding creates a new security finding, the design is fighting itself. "Fetch a remote document at boot and trust parts of it inside the auth path" is intrinsically hard to get right, and the benefit for the actual deployment (WorkOS, where discovered endpoints already equal the derived ones) is two cosmetic advertisement fields.

If a concrete non-WorkOS need appears

The right approach then is not to resurrect this implementation, but to wrap the discovery helpers the MCP SDK already ships (discoverAuthorizationServerMetadata in @modelcontextprotocol/sdk/client/auth.js, which zod-validates the document), adding only an issuer-host constraint on any endpoint that feeds verification.

The branch fix/issuer-discovery is preserved on the remote with the full implementation and tests, so none of the work is lost.

@marselsel marselsel closed this Aug 12, 2026
@marselsel
marselsel deleted the fix/issuer-discovery branch August 13, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant