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
- 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.
- Ensure SERVER_URL (VITE_SERVER_URL) and OIDC_CLIENT_ID (VITE_OIDC_CLIENT_ID) match the deployed auth service.
- Make sure the redirect_uri passed to exchangeCode is byte-identical to the one sent to the authorize endpoint.
- Retry the full sign-in flow from scratch so a fresh code/verifier/state are generated.
- 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
- Keep redirect_uri identical across authorize and token calls.
- Match VITE_SERVER_URL and VITE_OIDC_CLIENT_ID to the deployed auth service.
- Complete the redirect promptly so the auth code does not expire.
- Synchronize system clocks to avoid token endpoint time-window rejects.
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
- Token exchange failed: ${response.status} ${error}
- Token refresh failed: ${response.status}
- No reachable private LAN address is available for the curren
- Auth request failed (${response.status})
- listAccounts failed
AI-assisted analysis of moeru-ai/airi@27111382b4 (2026-08-12).
Data as JSON: /api/errors/92819966dc6a97e5.
Report an issue: GitHub.