moeru-ai/airi · error · Error

Token exchange failed: ${response.status} ${error}

Error message

Token exchange failed: ${response.status} ${error}

What it means

Thrown by exchangeCodeForTokens() when the token endpoint (OIDC_TOKEN_PATH) returns a non-2xx HTTP status during the authorization-code grant. The error string includes both the status code and the raw response body, so the upstream OAuth error (invalid_grant, invalid_client, etc.) is preserved for diagnosis. This runs only after the state check passed.

Source

Thrown at packages/stage-ui/src/libs/auth-oidc.ts:106

    code_verifier: flowState.codeVerifier,
    resource: SERVER_URL,
  }

  // Confidential clients must send the secret during token exchange.
  if (params.clientSecret)
    bodyParams.client_secret = params.clientSecret

  const body = new URLSearchParams(bodyParams)

  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 error = await response.text()
    throw new Error(`Token exchange failed: ${response.status} ${error}`)
  }

  return await response.json()
}

/**
 * Refresh an access token using a refresh token (RFC 6749 S6).
 * Pure function — returns new tokens without writing to any store.
 */
export async function refreshAccessToken(
  clientId: string,
  refreshToken: string,
  clientSecret?: string,
): Promise<TokenResponse> {
  const params: Record<string, string> = {
    grant_type: 'refresh_token',
    refresh_token: refreshToken,
    client_id: clientId,

View on GitHub (pinned to 27111382b4)

Solutions

  1. Read the error body in the message: 'invalid_grant' → code expired/reused or redirect_uri mismatch; 'invalid_client' → wrong client_secret/client_id; 'mismatching_code_verifier' → PKCE state lost.
  2. Ensure params.redirectUri in exchangeCodeForTokens is byte-identical to the redirect_uri sent in buildAuthorizationURL.
  3. Ensure params.clientSecret is provided for confidential clients and omitted (not wrong) for public clients.
  4. Confirm flowState.codeVerifier came from the same flow as the code (consumeFlowState), and that sessionStorage was not cleared mid-flow.
Defensive patterns

Strategy: try-catch

Validate before calling

// ensure params and flowState are internally consistent before the call
if (!code) throw new Error('Missing authorization code in callback')
if (params.redirectUri !== originalRedirectUri)
  throw new Error('redirect_uri changed between authorize and token exchange')
if (isConfidentialClient && !params.clientSecret)
  throw new Error('Confidential client missing client_secret')

Type guard

null

Try / catch

try {
  const tokens = await exchangeCodeForTokens(code, flowState, params, returnedState)
}
catch (err) {
  if (err instanceof Error && err.message.startsWith('Token exchange failed')) {
    // parse status/body: invalid_grant → restart flow; invalid_client → fix secret
  }
  else throw err
}

Prevention

When it happens

Trigger: POST to /api/auth/oauth2/token returns non-2xx. Common OAuth2 error causes: expired/already-used authorization code, redirect_uri mismatch between authorize and token requests, wrong client_id, wrong/missing client_secret for a confidential client, or a code_verifier that does not match the code_challenge sent at authorize time.

Common situations: The user took too long on the consent screen and the code expired. The redirect_uri was rewritten by a proxy so it no longer matches. PKCE verifier lost (sessionStorage cleared between authorize and callback). Client secret rotated server-side but not in the client config. Clock skew on the server rejecting the code.

Related errors


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