{"record":{"id":"1fc5aa4306bbaf2f","repo":"yikart/AiToEarn","slug":"15010","errorCode":"15010","errorMessage":"Access token exchange failed","messagePattern":"Access token exchange failed","errorType":"exception","errorClass":"TwitterPlatformException","httpStatus":null,"severity":"error","filePath":"project/aitoearn-backend/apps/aitoearn-server/src/core/channels/platforms/twitter/twitter.service.ts","lineNumber":104,"sourceCode":"  }\n\n  async exchangeCode(\n    code: string,\n    codeVerifier: string,\n  ): Promise<{\n    accessToken: string\n    refreshToken?: string\n    expiresAt?: Date\n    scope?: string\n  }> {\n    let credential: Awaited<ReturnType<OAuth2['exchangeCode']>>\n    try {\n      const oauth = this.createOAuth2Client()\n      credential = await oauth.exchangeCode(code, codeVerifier)\n    }\n    catch (error) {\n      if (error instanceof Error) {\n        throw TwitterPlatformException.fromSdkOAuthError(error, {\n          code: ResponseCode.ChannelAccessTokenFailed,\n          context: { endpoint: 'POST /2/oauth2/token' },\n        })\n      }\n      throw error\n    }\n\n    return {\n      accessToken: credential.access_token,\n      refreshToken: credential.refresh_token,\n      expiresAt: credential.expires_in ? new Date(Date.now() + credential.expires_in * 1000) : undefined,\n      scope: credential.scope,\n    }\n  }\n\n  async refreshAccessToken(refreshToken: string): Promise<{\n    accessToken: string\n    refreshToken?: string","sourceCodeStart":86,"sourceCodeEnd":122,"githubUrl":"https://github.com/yikart/AiToEarn/blob/d3aa8bea5b146a8675607cf0144d891aad3e9683/project/aitoearn-backend/apps/aitoearn-server/src/core/channels/platforms/twitter/twitter.service.ts#L86-L122","documentation":"Thrown by TwitterService.exchangeCode when the OAuth2 authorization code cannot be exchanged for tokens at POST /2/oauth2/token. The SDK's oauth.exchangeCode(code, codeVerifier) throws (Twitter rejects the request), and the service wraps it via fromSdkOAuthError with ResponseCode.ChannelAccessTokenFailed (15010), categorized as Auth and non-retryable. The actual Twitter error_description is preserved in cause.platformMessage/raw.","triggerScenarios":"Calling exchangeCode with an authorization code that was already redeemed, expired (Twitter codes are valid ~30 seconds), revoked by the user, issued for a different redirect_uri/clientId than configured, or paired with a codeVerifier that does not match the original PKCE challenge.","commonSituations":"Users refreshing the callback page causing double code redemption; redirect URI in env differing even slightly (trailing slash) from the developer-portal setting; mixing China/intl app credentials or environments; user taking too long between authorize and callback; storing the wrong codeVerifier per state session.","solutions":["Read cause.platformMessage for Twitter's error_description (e.g. 'invalid_grant', 'Value passed for the redirect uri did not match') and fix that specific mismatch.","Ensure the redirectUri passed to createOAuth2Client is byte-identical to the one registered in the Twitter developer portal and the one used in generateAuthUrl.","Treat each authorization code as single-use: don't retry exchangeCode after a failure; restart the whole authorize flow.","Persist codeVerifier keyed by the state parameter and verify the callback state matches before exchanging.","Confirm clientId/clientSecret belong to the correct app/environment (CN vs intl) and match where the code was issued."],"exampleFix":"// before: reusing a stale code after a failed/redirected callback\ncatch (e) { return this.exchangeCode(code, storedVerifier) }\n// after: fail fast and require a fresh authorize flow\ncatch (e) {\n  logger.warn(`Token exchange failed: ${e.message}`)\n  throw new UnauthorizedException('Please restart the Twitter authorization')\n}","handlingStrategy":"try-catch","validationCode":"function isExchangeable(code: unknown, codeVerifier: unknown): boolean {\n  return typeof code === 'string' && code.length > 0\n    && typeof codeVerifier === 'string' && /^[A-Za-z0-9\\-._~]{43,128}$/.test(codeVerifier)\n}","typeGuard":"function isAuthFailure(e: unknown): e is ChannelPlatformException & { code: typeof ResponseCode.ChannelAccessTokenFailed } {\n  return e instanceof ChannelPlatformException && e.code === ResponseCode.ChannelAccessTokenFailed\n}","tryCatchPattern":"try {\n  const cred = await twitterService.exchangeCode(code, verifierForState(state))\n  await saveTokens(userId, cred)\n} catch (e) {\n  if (isAuthFailure(e)) {\n    // codes are single-use: never retry; require a fresh authorize flow\n    await clearPendingAuthState(userId)\n    throw new UnauthorizedException('Twitter authorization expired, please reconnect')\n  }\n  throw e\n}","preventionTips":["Bind codeVerifier to the OAuth state parameter server-side and validate state on callback.","Never retry exchangeCode with a used/failed code; restart the authorize flow instead.","Redirect fast after callback — the code expires in ~30 seconds.","Compare redirectUri strings exactly (including trailing slash) across authorize and token requests."],"tags":["oauth2","twitter","token-exchange","invalid-grant"],"backgroundTag":"oauth-code-exchange-failed","analyzedSha":"d3aa8bea5b146a8675607cf0144d891aad3e9683","analyzedAt":"2026-08-31T14:19:24.185Z","schemaVersion":2},"datasetVersion":"2026-08-31T19:17:28.585Z"}