moeru-ai/airi · error · Error

Token exchange failed (${response.status}): ${text}

Error message

Token exchange failed (${response.status}): ${text}

What it means

Thrown by exchangeCode during the OIDC authorization-code → token swap when the token endpoint (POST {SERVER_URL}/api/auth/oauth2/token) returns a non-2xx response. The HTTP status and raw response body are interpolated into the message, so the upstream OIDC provider's error detail is preserved.

Source

Thrown at apps/stage-tamagotchi/src/main/services/airi/auth.ts:156

async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise<TokenExchangeResult> {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: redirectUri,
    client_id: OIDC_CLIENT_ID,
    code_verifier: codeVerifier,
    resource: SERVER_URL,
  })

  const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  })

  if (!response.ok) {
    const text = await response.text()
    throw new Error(`Token exchange failed (${response.status}): ${text}`)
  }

  const data = await response.json() as Record<string, unknown>
  return {
    accessToken: data.access_token as string,
    refreshToken: data.refresh_token as string | undefined,
    idToken: data.id_token as string | undefined,
    expiresIn: data.expires_in as number,
  }
}

View on GitHub (pinned to 27111382b4)

Solutions

  1. Inspect the interpolated status and body: 400 with 'invalid_grant' usually means an expired/reused code or PKCE mismatch; 401 typically means bad client_id; 5xx means an upstream auth-service issue.
  2. Ensure SERVER_URL (VITE_SERVER_URL) and OIDC_CLIENT_ID (VITE_OIDC_CLIENT_ID) match the deployed auth service.
  3. Make sure the redirect_uri passed to exchangeCode is byte-identical to the one sent to the authorize endpoint.
  4. Retry the full sign-in flow from scratch so a fresh code/verifier/state are generated.
  5. Check the auth service logs at server/apps/auth for the matching token request.
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await exchangeCode(code, codeVerifier, redirectUri)
}
catch (error) {
  const msg = errorMessageFrom(error) ?? ''
  if (msg.startsWith('Token exchange failed')) {
    const [, status, body] = msg.match(/\((\d+)\):\s([\s\S]*)$/) ?? []
    // branch on status: 400 invalid_grant -> restart flow; 401 -> check client_id; 5xx -> retry/backoff
  }
  throw error
}

Prevention

When it happens

Trigger: Posting the PKCE token exchange after the browser redirect and receiving 4xx/5xx: invalid/expired/reused authorization code, wrong redirect_uri, PKCE code_verifier mismatch, wrong client_id, expired or tampered state, network proxy returning an error page, or the auth backend (SERVER_URL / VITE_SERVER_URL) being unreachable/misconfigured.

Common situations: Clock skew between Electron and the auth server; redirect URI in the token request not matching the one used at authorize; user took too long and the auth code expired; auth service redeployed with new client_id; VITE_SERVER_URL points at the wrong environment; a corporate proxy intercepted the POST.

Related errors


AI-assisted analysis of moeru-ai/airi@27111382b4 (2026-08-12). Data as JSON: /api/errors/92819966dc6a97e5. Report an issue: GitHub.