{"record":{"id":"f3d18e7598fc615b","repo":"decolua/9router","slug":"token-exchange-failed-error-f3d18e","errorCode":null,"errorMessage":"`Token exchange failed: ${error}`","messagePattern":"`Token exchange failed: (.+?)`","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/lib/oauth/providers/iflow.js","lineNumber":40,"sourceCode":"    const response = await fetch(config.tokenUrl, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        Accept: \"application/json\",\n        Authorization: `Basic ${basicAuth}`,\n      },\n      body: new URLSearchParams({\n        grant_type: \"authorization_code\",\n        code: code,\n        redirect_uri: redirectUri,\n        client_id: config.clientId,\n        client_secret: config.clientSecret,\n      }),\n    });\n\n    if (!response.ok) {\n      const error = await response.text();\n      throw new Error(`Token exchange failed: ${error}`);\n    }\n\n    return await response.json();\n  },\n  postExchange: async (tokens) => {\n    // Fetch user info (MUST succeed to get API key)\n    const userInfoRes = await fetch(\n      `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,\n      {\n        headers: {\n          Accept: \"application/json\",\n        },\n      }\n    );\n\n    if (!userInfoRes.ok) {\n      const errorText = await userInfoRes.text();\n      throw new Error(`Failed to fetch user info: ${errorText}`);","sourceCodeStart":22,"sourceCodeEnd":58,"githubUrl":"https://github.com/decolua/9router/blob/90b52e06ffd666b7929554211474d01588f6b1f8/src/lib/oauth/providers/iflow.js#L22-L58","documentation":"iFlow OAuth authorization-code token exchange failed. The provider's token endpoint returned a non-2xx HTTP status, so the body (usually a JSON OAuth error like {error, error_description} or an HTML error page) is read as text and rethrown. This happens after the user completes the authorize redirect, when the one-time code is redeemed for access/refresh tokens.","triggerScenarios":"POST to config.tokenUrl with grant_type=authorization_code returns response.ok === false — e.g. expired/already-redeemed authorization code, redirect_uri mismatch vs buildAuthUrl, wrong client_id/client_secret in the Basic Auth header, or provider-side 4xx/5xx.","commonSituations":"User waited too long before completing login (code expired); user retried the callback URL with the same code; misconfigured IFLOW_CONFIG clientSecret (placeholder from .env not set); redirectUri differs from the one used in buildAuthUrl; iFlow outage returning 5xx.","solutions":["Have the user restart the OAuth flow to get a fresh authorization code (codes are single-use and short-lived).","Verify IFLOW_CONFIG clientId/clientSecret match the iFlow app credentials and that redirectUri in exchangeToken is byte-identical to the one used in buildAuthUrl.","Log the raw `error` text (it contains the OAuth error_description) and match it against the OAuth spec: invalid_grant => expired/replayed code; invalid_client => bad credentials.","Check iFlow service status / network reachability if the status is 5xx, then retry the flow."],"exampleFix":"// before: opaque text-only error\nconst error = await response.text();\nthrow new Error(`Token exchange failed: ${error}`);\n// after: structured OAuth error\nconst body = await response.text();\nlet code = response.status, desc = body;\ntry { const j = JSON.parse(body); code = j.error; desc = j.error_description ?? body; } catch {}\nthrow new Error(`Token exchange failed (${response.status} ${code}): ${desc}`);","handlingStrategy":"try-catch","validationCode":"// before starting the flow: ensure credentials and redirect are configured\nfunction assertIflowConfig(config, redirectUri) {\n  if (!config.clientId || !config.clientSecret) throw new Error('iFlow clientId/clientSecret not configured');\n  if (!config.tokenUrl) throw new Error('iFlow tokenUrl missing');\n  if (!redirectUri || !redirectUri.startsWith('http')) throw new Error('redirectUri invalid');\n}","typeGuard":"function isOAuthTokenResponse(t) {\n  return t !== null && typeof t === 'object' && typeof t.access_token === 'string' && t.access_token.length > 0;\n}","tryCatchPattern":"try {\n  const tokens = await provider.exchangeToken(config, code, redirectUri);\n} catch (e) {\n  if (String(e.message).includes('invalid_grant')) {\n    // stale/replayed code: restart the authorize flow\n    return restartAuthFlow();\n  }\n  if (String(e.message).includes('invalid_client')) {\n    throw new Error('Check iFlow clientId/clientSecret configuration');\n  }\n  throw e; // 5xx etc: surface after logging raw body\n}","preventionTips":["Never reuse or cache authorization codes; always redirect the user through a fresh authorize URL.","Keep the redirectUri constant in a single shared config object used by both buildAuthUrl and exchangeToken.","Load clientId/clientSecret from env at startup and fail fast if missing instead of mid-flow.","Log the raw token-endpoint body on failure — the OAuth error_description pinpoints the cause."],"tags":["oauth","http-4xx","token-exchange","network"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"90b52e06ffd666b7929554211474d01588f6b1f8","analyzedAt":"2026-08-30T21:05:45.952Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}