{"record":{"id":"e95b596af9806a73","repo":"BloopAI/vibe-kanban","slug":"oauth-redeem-failed-res-status","errorCode":null,"errorMessage":"OAuth redeem failed (${res.status})","messagePattern":"OAuth redeem failed \\((.+?)\\)","errorType":"http","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/remote-web/src/shared/lib/api.ts","lineNumber":95,"sourceCode":"  return res.json();\n}\n\nexport async function redeemOAuth(\n  handoffId: string,\n  appCode: string,\n  appVerifier: string,\n): Promise<HandoffRedeemResponse> {\n  const res = await fetch(`${API_BASE}/v1/oauth/web/redeem`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      handoff_id: handoffId,\n      app_code: appCode,\n      app_verifier: appVerifier,\n    }),\n  });\n  if (!res.ok) {\n    throw new Error(`OAuth redeem failed (${res.status})`);\n  }\n  return res.json();\n}\n\nexport async function localLogin(\n  email: string,\n  password: string,\n): Promise<LocalLoginResponse> {\n  const res = await fetch(`${API_BASE}/v1/auth/local/login`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ email, password }),\n  });\n  if (!res.ok) {\n    throw new Error(`Local login failed (${res.status})`);\n  }\n  return res.json();\n}","sourceCodeStart":77,"sourceCodeEnd":113,"githubUrl":"https://github.com/BloopAI/vibe-kanban/blob/4deb7eca8f381f7cbc1f9d15515a9ab8f8009053/packages/remote-web/src/shared/lib/api.ts#L77-L113","documentation":"redeemOAuth exchanges an OAuth handoff (handoff_id, PKCE app_code and app_verifier) for access/refresh tokens by POSTing to ${API_BASE}/v1/oauth/web/redeem. On any non-OK HTTP status it throws 'OAuth redeem failed (<status>)'. This happens after the user returns from the identity provider, when the client tries to convert the provider authorization code into a session.","triggerScenarios":"Calling redeemOAuth(handoffId, appCode, appVerifier) returns non-2xx: the handoff_id expired or was already redeemed (400/404/409), the PKCE code_verifier does not match the challenge from initOAuth (400), the provider code is invalid/expired or was already consumed (400/401), user denied consent so no valid code exists, or the server errors out (500/502/503).","commonSituations":"User sits on the provider consent screen too long and the handoff expires, then completes login; browser back button re-runs redeem with a one-time code that was already used; multiple tabs finishing OAuth concurrently and consuming the same handoff; server clock/secret rotation invalidating tokens; missing state/PKCE pair after a redirect mishap.","solutions":["Check the status: 400/409 with already-used or expired handoff/code means restart the OAuth flow from initOAuth with a fresh PKCE pair.","Redirect the user back to the login/authorize URL instead of retrying redeem with stale parameters.","Verify appVerifier corresponds to the exact appChallenge sent to /v1/oauth/web/init (do not regenerate the pair mid-flow).","Guard against double redemption (e.g. React StrictMode double effects) with a ref/flag so redeem runs once per handoff.","Clear stale handoff state from the URL/storage before restarting the flow.","If 5xx, retry once after a short delay; otherwise inspect server logs for /v1/oauth/web/redeem."],"exampleFix":"// before: StrictMode/double-render redeems the same handoff twice -> 409\nuseEffect(() => { redeemOAuth(id, code, verifier).then(setTokens); }, [id, code, verifier]);\n\n// after: redeem exactly once, restart flow on failure\nconst done = useRef(false);\nuseEffect(() => {\n  if (done.current) return;\n  done.current = true;\n  redeemOAuth(id, code, verifier).then(setTokens).catch(() => restartOAuthFlow());\n}, [id, code, verifier]);","handlingStrategy":"try-catch","validationCode":"// validate OAuth callback params before redeeming\nconst params = new URLSearchParams(window.location.search);\nconst handoffId = params.get('handoff_id');\nconst code = params.get('code');\nif (!handoffId || !code || !appVerifier) {\n  throw new Error('Missing handoff_id/code/verifier; restart OAuth flow');\n}","typeGuard":"function isHandoffRedeemResponse(x: unknown): x is { access_token: string; refresh_token: string } {\n  return typeof x === 'object' && x !== null\n    && typeof (x as any).access_token === 'string'\n    && typeof (x as any).refresh_token === 'string';\n}","tryCatchPattern":"try {\n  const tokens = await redeemOAuth(handoffId, appCode, appVerifier);\n  saveTokens(tokens);\n} catch (e) {\n  const status = (e as Error).message.match(/\\((\\d+)\\)/)?.[1];\n  if (status && ['400', '401', '404', '409'].includes(status)) {\n    // handoff/code expired or already used — restart the flow with fresh PKCE\n    await startFreshOAuthFlow(returnTo);\n  } else {\n    showError('Sign-in could not be completed; please try again.');\n  }\n}","preventionTips":["Never reuse a handoff_id or authorization code — always start a fresh initOAuth call.","Keep the PKCE challenge/verifier pair for the whole flow; do not regenerate between init and redeem.","Guard redeem against double invocation (React StrictMode, double-click) with a ref or disabling the button.","Clear one-time OAuth params from the URL after a successful redeem.","Check tokens expire quickly; complete consent promptly to avoid handoff expiry."],"tags":["oauth","http","authentication","pkce"],"backgroundTag":"oauth-redeem-failed","analyzedSha":"4deb7eca8f381f7cbc1f9d15515a9ab8f8009053","analyzedAt":"2026-08-29T09:24:13.446Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}