{"record":{"id":"99e0ab37de7a6003","repo":"decolua/9router","slug":"oidc-token-exchange-failed-res-status","errorCode":null,"errorMessage":"OIDC token exchange failed (${res.status})","messagePattern":"OIDC token exchange failed \\((.+?)\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/lib/auth/oidc.js","lineNumber":138,"sourceCode":"    code,\n    redirect_uri: redirectUri,\n    code_verifier: codeVerifier,\n  });\n\n  if (clientSecret) {\n    body.set(\"client_secret\", clientSecret);\n  }\n\n  const res = await fetch(tokenEndpoint, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n    body,\n  });\n\n  const data = await res.json().catch(() => ({}));\n  if (!res.ok) {\n    const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status})`;\n    throw new Error(message);\n  }\n\n  return data;\n}\n\nexport async function probeOidcClientSecret({\n  tokenEndpoint,\n  clientId,\n  clientSecret,\n  redirectUri,\n}) {\n  if (!clientSecret) {\n    return {\n      tested: false,\n      valid: null,\n      message: \"No client secret was provided, so secret validation was skipped.\",\n    };\n  }","sourceCodeStart":120,"sourceCodeEnd":156,"githubUrl":"https://github.com/decolua/9router/blob/90b52e06ffd666b7929554211474d01588f6b1f8/src/lib/auth/oidc.js#L120-L156","documentation":"exchangeOidcCode POSTs the authorization code to the IdP token endpoint and throws when the response is non-ok. It prefers the IdP's own error_description/error fields (standard OAuth2 error codes like invalid_grant, invalid_client) and only falls back to the generic `OIDC token exchange failed (<status>)` message when the response body is not JSON or lacks those fields. So this exact generic string appears mainly when the token endpoint returns an error without an OAuth2 JSON body.","triggerScenarios":"The callback flow calls exchangeOidcCode after the user returns from the IdP and the token endpoint responds non-ok with a non-JSON or JSON-less-of-error body: 400 from a redirect_uri/client_id mismatch without JSON body, 401 invalid_client with empty body, 500 from the IdP, HTML error pages from a proxy/gateway (502/504), or an expired/already-used authorization code (invalid_grant) when the IdP returns no description.","commonSituations":"redirect_uri configured in the IdP app doesn't exactly match the one sent (BASE_URL/x-forwarded headers mismatch behind a reverse proxy), wrong client_secret or a secret that was rotated, the authorization code was reused after a refresh/retry (codes are single-use) or expired (typically 30-60s), clock skew, or a corporate proxy intercepting the POST and returning an HTML error page.","solutions":["Log res.status and the raw response body at the throw site to see the IdP's actual error; prefer the error_description when present.","Verify the redirect_uri sent matches the IdP app registration exactly (scheme, host, port, path) — check BASE_URL/NEXT_PUBLIC_BASE_URL and x-forwarded-proto/host behind your reverse proxy.","Confirm clientId/clientSecret in dashboard settings are current; test them with probeOidcClientSecret (oidc.js) which classifies invalid_client vs benign errors.","Retry the login from scratch — authorization codes are single-use and short-lived; don't re-POST the same code, and check for clock skew (NTP) if using PKCE/nonce validation."],"exampleFix":"// before\nconst data = await res.json().catch(() => ({}));\nif (!res.ok) {\n  const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status})`;\n  throw new Error(message);\n}\n// after\nconst raw = await res.text();\nlet data = {};\ntry { data = JSON.parse(raw); } catch {}\nif (!res.ok) {\n  const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status}): ${raw.slice(0, 200)}`;\n  throw new Error(message);\n}","handlingStrategy":"try-catch","validationCode":"// Probe client credentials and redirect_uri before the real login flow\nconst probe = await probeOidcClientSecret({ tokenEndpoint, clientId, clientSecret, redirectUri });\nif (probe.tested && probe.valid === false) throw new Error(probe.message); // invalid_client","typeGuard":"function isOAuthTokenResponse(data) {\n  return !!data && typeof data === \"object\" && typeof data.access_token === \"string\";\n}","tryCatchPattern":"try {\n  const tokens = await exchangeOidcCode({ tokenEndpoint, clientId, clientSecret, code, redirectUri, codeVerifier });\n} catch (err) {\n  if (err.message === \"invalid_grant\") {\n    // code expired/used — restart the authorization flow with a fresh state+PKCE pair\n  } else if (err.message === \"invalid_client\") {\n    // credentials wrong — surface a settings fix hint\n  } else {\n    throw err;\n  }\n}","preventionTips":["Ensure redirect_uri matches the IdP app registration exactly, including scheme/host behind reverse proxies (set BASE_URL)","Never reuse an authorization code; generate fresh state/nonce/PKCE per login attempt and complete it within the code lifetime","Validate clientId/clientSecret with probeOidcClientSecret before going live","Check IdP token-endpoint requirements (some need client auth via Basic header instead of body params)"],"tags":["oidc","oauth","token-exchange","authentication"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"90b52e06ffd666b7929554211474d01588f6b1f8","analyzedAt":"2026-08-30T21:05:45.952Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}