Skip to content

fix(server): map OAuth callback token-exchange failures per cause (424 vs 502) - #665

Merged
AminDhouib merged 3 commits into
devfrom
fix/oauth-callback-failure-status
Oct 26, 2026
Merged

AminDhouib merged 3 commits into
devfrom
fix/oauth-callback-failure-status

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

Fixes T-6087 (Cloudflare masks @useupup/server JSON 502s on the OAuth callback).

Decision (MASTER rule, 2026-10-26 00:14Z)

GET /auth/:provider/cb has two kinds of token-exchange failure, and the provider's answer tells them apart, so each is mapped separately:

Cause Status Why
Provider 4xx (expired / reused / forged code, or the integrator's client id, secret or redirect URI rejected), or an OAuth error in a 200 body 424 Client-supplied or client-controlled; retrying unchanged cannot succeed
Provider unreachable or timed out, provider 5xx / 429 / 408, or a 200 with neither a token nor an error 502 (unchanged) Our upstream or transient; clients retry it

The { error: 'Token exchange failed', code: 'AUTH_PROVIDER_ERROR' } body, the x-upup-request-id header and the cleared nonce cookie are identical for both. onError gets the same status the client saw. The refresh path's report-only stall status is still 502, now through the named constant.

Changes

  • respond.ts: DEPENDENCY_REJECTED_STATUS (424), UPSTREAM_UNAVAILABLE_STATUS (502) and providerFailureStatus().
  • oauth.ts: the callback's three failure branches use them.
  • tests/oauth-callback-failure-status.test.ts: per-cause matrix covering 400/401/403/404/422 → 424; 500/502/503/504/429/408 → 502; timeout → 502; 200 with an error → 424; 200 with neither → 502. Each case checks the body, the request id, the cleared cookie, that no token is stored, and the onError status. It also pins the helper and re-checks that a forged callback is 403 and a bad state is 400.
  • tests/response-contract.test.ts: the only 502/504 literal allowed in src/ is the UPSTREAM_UNAVAILABLE_STATUS declaration.
  • server-mode and oauth-cookie-hardening tests: a provider 400 is now 424.
  • Docs: server-http callback table and note, error-codes row, server README.
  • Patch changeset; no version bump.

Trade-off: a transient 502 is still replaced by Cloudflare's page behind Cloudflare. That is by design under the rule, and the cause still reaches onError.

Verification

Built on devino-* (no local build or test), and committed and pushed through the normal husky hooks:

  • Pre-commit: lint-staged (oxlint + prettier), then the unit suites: core (157 files), react (76) and server (48, 6 skipped), all passing.
  • Pre-push: typecheck, lint and knip, 20/20 turbo tasks successful.

Builds on the preserved WIP wip/cf502-2026-10-26 (36d406d).

…4 vs 502)

T-6087. GET /auth/:provider/cb answered 502 for every token-exchange failure.
Behind Cloudflare an origin 502 is replaced by Cloudflare's own text/plain
page, so a provider rejection lost its JSON body, AUTH_PROVIDER_ERROR code and
x-upup-request-id and looked like a Cloudflare outage.

Each cause is now mapped on its own:
- provider 4xx (expired/reused/forged code, or the integrator's client
  id/secret/redirect URI rejected), or an OAuth `error` in a 200 body:
  424 Failed Dependency. Retrying unchanged cannot succeed, clients do not
  retry it, and Cloudflare passes it through.
- provider unreachable/timed out, provider 5xx/429/408, or a 200 with neither
  a token nor an error: still 502, which clients retry.

The { error, code } body, the request id and the cleared nonce cookie are the
same for both. The statuses live in respond.ts as DEPENDENCY_REJECTED_STATUS /
UPSTREAM_UNAVAILABLE_STATUS + providerFailureStatus(); the response-contract
guard now allows no 502/504 literal in src/ except that one declaration.
Docs (server-http, error-codes, server README) and a patch changeset updated.

Builds on the preserved WIP branch wip/cf502-2026-10-26 (36d406d).
@codesandbox

codesandbox Bot commented Oct 26, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web Editor • VS Code • Insiders

Open Preview

- providerFailureStatus: only a real 4xx (other than 408/429) is 424; an
  unfollowed 3xx or a 1xx is not a client rejection and is now 502.
- A 200 whose body is not JSON is a malformed provider answer: 502 with the
  uniform body, instead of escaping to the router's generic 500.
- Docs, changeset and constant comments no longer claim a 502 is "safe to
  retry": the callback URL is never replayable (state consumed, code
  single-use), so both statuses mean "start the sign-in again"; 424 = that
  cannot succeed until the cause is fixed, 502 = it may work.
- Tests: helper edges (101/304/399), a 200 with an empty `error`, and a
  non-JSON 200.
@AminDhouib
AminDhouib merged commit efffaf1 into dev Oct 26, 2026
18 checks passed
@AminDhouib
AminDhouib deleted the fix/oauth-callback-failure-status branch October 26, 2026 03:41
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