Skip to content

OIDC token exchange sends a different redirect_uri than the authorization request (invalid_grant behind a proxy) #2026

Description

@steilerDev

[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_grantafter 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

  • 1 Both legs derive their redirect_uri from a single expression, so they cannot diverge.
  • 2 Leg 2 still receives the full query string (code, state, iss) — the fix must not strip what the grant needs.
  • 3 A test pins that the redirect_uri sent at token exchange equals the one sent at authorization, for the EXTERNAL_URL-set-and-request-origin-differs case. This is the shape that is broken today, so the test must fail against current main/beta.
  • 4 A test covers the EXTERNAL_URL unset case, so the fix does not regress the plain-host deployment.
  • 5 Once fixed, the OIDC callback-URL paragraph added to wiki/Architecture.md and wiki/API-Contract.md in 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions