BloopAI/vibe-kanban · error · Error

OAuth redeem failed (${res.status})

Error message

OAuth redeem failed (${res.status})

What it means

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.

Source

Thrown at packages/remote-web/src/shared/lib/api.ts:95

  return res.json();
}

export async function redeemOAuth(
  handoffId: string,
  appCode: string,
  appVerifier: string,
): Promise<HandoffRedeemResponse> {
  const res = await fetch(`${API_BASE}/v1/oauth/web/redeem`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      handoff_id: handoffId,
      app_code: appCode,
      app_verifier: appVerifier,
    }),
  });
  if (!res.ok) {
    throw new Error(`OAuth redeem failed (${res.status})`);
  }
  return res.json();
}

export async function localLogin(
  email: string,
  password: string,
): Promise<LocalLoginResponse> {
  const res = await fetch(`${API_BASE}/v1/auth/local/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) {
    throw new Error(`Local login failed (${res.status})`);
  }
  return res.json();
}

View on GitHub (pinned to 4deb7eca8f)

Solutions

  1. Check the status: 400/409 with already-used or expired handoff/code means restart the OAuth flow from initOAuth with a fresh PKCE pair.
  2. Redirect the user back to the login/authorize URL instead of retrying redeem with stale parameters.
  3. Verify appVerifier corresponds to the exact appChallenge sent to /v1/oauth/web/init (do not regenerate the pair mid-flow).
  4. Guard against double redemption (e.g. React StrictMode double effects) with a ref/flag so redeem runs once per handoff.
  5. Clear stale handoff state from the URL/storage before restarting the flow.
  6. If 5xx, retry once after a short delay; otherwise inspect server logs for /v1/oauth/web/redeem.

Example fix

// before: StrictMode/double-render redeems the same handoff twice -> 409
useEffect(() => { redeemOAuth(id, code, verifier).then(setTokens); }, [id, code, verifier]);

// after: redeem exactly once, restart flow on failure
const done = useRef(false);
useEffect(() => {
  if (done.current) return;
  done.current = true;
  redeemOAuth(id, code, verifier).then(setTokens).catch(() => restartOAuthFlow());
}, [id, code, verifier]);
Defensive patterns

Strategy: try-catch

Validate before calling

// validate OAuth callback params before redeeming
const params = new URLSearchParams(window.location.search);
const handoffId = params.get('handoff_id');
const code = params.get('code');
if (!handoffId || !code || !appVerifier) {
  throw new Error('Missing handoff_id/code/verifier; restart OAuth flow');
}

Type guard

function isHandoffRedeemResponse(x: unknown): x is { access_token: string; refresh_token: string } {
  return typeof x === 'object' && x !== null
    && typeof (x as any).access_token === 'string'
    && typeof (x as any).refresh_token === 'string';
}

Try / catch

try {
  const tokens = await redeemOAuth(handoffId, appCode, appVerifier);
  saveTokens(tokens);
} catch (e) {
  const status = (e as Error).message.match(/\((\d+)\)/)?.[1];
  if (status && ['400', '401', '404', '409'].includes(status)) {
    // handoff/code expired or already used — restart the flow with fresh PKCE
    await startFreshOAuthFlow(returnTo);
  } else {
    showError('Sign-in could not be completed; please try again.');
  }
}

Prevention

When it happens

Trigger: 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).

Common situations: 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.

Related errors


AI-assisted analysis of BloopAI/vibe-kanban@4deb7eca8f (2026-08-29). Data as JSON: /api/errors/e95b596af9806a73. Report an issue: GitHub.