[orchestrator] Found by product-architect while correcting the OIDC wiki documentation in #1992 / PR #2022. Deliberately not documented as working-as-intended, because doing so would enshrine the bug and make the eventual code fix look like a regression against the wiki.
Problem
The two legs of the OIDC flow derive the callback URL differently:
- Leg 1 — authorization request,
server/src/routes/oidc.ts:45:
const redirectUri = `${fastify.config.externalUrl || `${request.protocol}://${request.host}`}/api/auth/oidc/callback`;
- Leg 2 — token exchange,
server/src/routes/oidc.ts:106: builds the current URL from the request origin unconditionally, ignoring EXTERNAL_URL.
That second URL is not merely used to validate response parameters. openid-client derives the token request's redirect_uri from it — node_modules/openid-client/build/index.js:974:
redirectUri = stripParams(currentUrl);
which is then passed to authorizationCodeGrantRequest(as, c, auth, authResponse, redirectUri, …) at line 994. The library's own typings say so (index.d.ts:493): "The module derives redirect_uri from the callback URL by stripping all query parameters."
So the provider receives:
| Leg |
redirect_uri sent |
| Authorization request |
<EXTERNAL_URL>/api/auth/oidc/callback |
| Token exchange |
<request-origin>/api/auth/oidc/callback |
RFC 6749 §4.1.3 requires the token request's redirect_uri to be identical to the one in the authorization request, and providers enforce this. The two agree only when the request origin happens to equal EXTERNAL_URL — leg 2 is correct by coincidence, not by construction.
Impact
The failure fires on exactly the configuration the (now corrected) wiki recommends: EXTERNAL_URL set behind a reverse proxy with TRUST_PROXY left at its default false. Leg 1 sends the correct public URL; leg 2 sends the container's internal origin. The provider rejects the exchange with invalid_grant — after a browser round-trip that appeared to be working, which makes it hard to diagnose.
In effect EXTERNAL_URL and TRUST_PROXY=true are a required pair today, which is not documented and not enforced anywhere.
Proposed fix
Rebuild the callback URL on the externalUrl origin — preserving path and query, which leg 2 genuinely needs for code / state / iss — before handing it to authorizationCodeGrant, so both legs derive from one expression. There is no technical reason for the divergence: leg 2 needs the response parameters, not the request origin.
Acceptance Criteria
Notes
[orchestrator] Found by
product-architectwhile correcting the OIDC wiki documentation in #1992 / PR #2022. Deliberately not documented as working-as-intended, because doing so would enshrine the bug and make the eventual code fix look like a regression against the wiki.Problem
The two legs of the OIDC flow derive the callback URL differently:
server/src/routes/oidc.ts:45:server/src/routes/oidc.ts:106: builds the current URL from the request origin unconditionally, ignoringEXTERNAL_URL.That second URL is not merely used to validate response parameters.
openid-clientderives the token request'sredirect_urifrom it —node_modules/openid-client/build/index.js:974:which is then passed to
authorizationCodeGrantRequest(as, c, auth, authResponse, redirectUri, …)at line 994. The library's own typings say so (index.d.ts:493): "The module derivesredirect_urifrom the callback URL by stripping all query parameters."So the provider receives:
redirect_urisent<EXTERNAL_URL>/api/auth/oidc/callback<request-origin>/api/auth/oidc/callbackRFC 6749 §4.1.3 requires the token request's
redirect_urito be identical to the one in the authorization request, and providers enforce this. The two agree only when the request origin happens to equalEXTERNAL_URL— leg 2 is correct by coincidence, not by construction.Impact
The failure fires on exactly the configuration the (now corrected) wiki recommends:
EXTERNAL_URLset behind a reverse proxy withTRUST_PROXYleft at its defaultfalse. Leg 1 sends the correct public URL; leg 2 sends the container's internal origin. The provider rejects the exchange withinvalid_grant— after a browser round-trip that appeared to be working, which makes it hard to diagnose.In effect
EXTERNAL_URLandTRUST_PROXY=trueare a required pair today, which is not documented and not enforced anywhere.Proposed fix
Rebuild the callback URL on the
externalUrlorigin — preserving path and query, which leg 2 genuinely needs forcode/state/iss— before handing it toauthorizationCodeGrant, so both legs derive from one expression. There is no technical reason for the divergence: leg 2 needs the response parameters, not the request origin.Acceptance Criteria
redirect_urifrom a single expression, so they cannot diverge.code,state,iss) — the fix must not strip what the grant needs.redirect_urisent at token exchange equals the one sent at authorization, for theEXTERNAL_URL-set-and-request-origin-differs case. This is the shape that is broken today, so the test must fail against currentmain/beta.EXTERNAL_URLunset case, so the fix does not regress the plain-host deployment.wiki/Architecture.mdandwiki/API-Contract.mdin Wiki documents a nonexistent OIDC_REDIRECT_URI env var and a four-variable OIDC gate #1992 becomes true of both legs with no edit. If instead this is declared working-as-intended, that decision — not the current text — is what the wiki should record, in a new Deviation Log row.Notes
backend-developer(code change; the architect explicitly ruled this is not a wiki fix).AUTH_RATE_LIMIT_*, the work that first exposed this drift surface).security-engineerlook given it is an auth-flow defect, though the failure mode is a rejected login rather than an accepted one.