{"record":{"id":"3a973c4b86589c80","repo":"ruvnet/ruflo","slug":"invalid-or-expired-state-parameter","errorCode":null,"errorMessage":"Invalid or expired state parameter","messagePattern":"Invalid or expired state parameter","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/mcp/src/oauth.ts","lineNumber":168,"sourceCode":"      codeVerifier,\n      timestamp: Date.now(),\n    });\n\n    const url = `${this.config.authorizationEndpoint}?${params.toString()}`;\n\n    this.logger.debug('Created authorization request', { state, usePKCE: !!codeVerifier });\n    this.emit('authorization:created', { state });\n\n    return { url, state, codeVerifier };\n  }\n\n  /**\n   * Exchange authorization code for tokens\n   */\n  async exchangeCode(code: string, state: string): Promise<OAuthTokens> {\n    const pending = this.pendingRequests.get(state);\n    if (!pending) {\n      throw new Error('Invalid or expired state parameter');\n    }\n\n    this.pendingRequests.delete(state);\n\n    const params = new URLSearchParams({\n      grant_type: 'authorization_code',\n      code,\n      redirect_uri: this.config.redirectUri,\n      client_id: this.config.clientId,\n    });\n\n    if (this.config.clientSecret) {\n      params.set('client_secret', this.config.clientSecret);\n    }\n\n    if (pending.codeVerifier) {\n      params.set('code_verifier', pending.codeVerifier);\n    }","sourceCodeStart":150,"sourceCodeEnd":186,"githubUrl":"https://github.com/ruvnet/ruflo/blob/fa13ee4ad60ac2090b1480656eb233521790d640/v3/@claude-flow/mcp/src/oauth.ts#L150-L186","documentation":"The OAuth manager stores each authorization request under its random state parameter in an in-memory pendingRequests map; exchangeCode deletes the entry on first use and throws when the state is unknown. The error therefore means: wrong state, already-consumed state, or state created by a different process/instance.","triggerScenarios":"exchangeCode(code, state) where state was never created by createAuthorizationRequest on this instance, was already consumed (map entry deleted), or the app restarted / the callback landed on another instance (pendingRequests is memory-only).","commonSituations":"Serverless or multi-instance deployments without sticky routing so leg 1 and leg 2 hit different instances; double-handling of the callback URL; an app restart between redirect and callback; testing with a stale callback URL.","solutions":["Guarantee both legs use the same OAuth manager instance (singleton, sticky sessions, or externalized pending-state)","Handle the callback exactly once per state; ignore duplicate deliveries","If the process may restart mid-flow, persist pendingRequests externally (redis/db) or accept restarts as flow restarts"],"exampleFix":"// before (new manager per invocation — pendingRequests is empty on the callback instance)\napp.get('/callback', async (req) => {\n  const oauth = new OAuthManager(config);\n  return oauth.exchangeCode(req.query.code, req.query.state); // throws: unknown state\n});\n\n// after (one shared instance for both legs)\nconst oauth = new OAuthManager(config); // module-level singleton / sticky-routed\napp.get('/authorize', () => oauth.createAuthorizationRequest(scopes));\napp.get('/callback', (req) => oauth.exchangeCode(req.query.code, req.query.state));","handlingStrategy":"validation","validationCode":"// Single shared instance + one-shot callback guard\nconst oauth = new OAuthManager(config); // module-level singleton, both legs use it\nconst handledStates = new Set<string>();\napp.get('/callback', async (req, res) => {\n  const { code, state } = req.query as Record<string, string>;\n  if (handledStates.has(state)) return res.redirect('/already-connected');\n  handledStates.add(state);\n  const tokens = await oauth.exchangeCode(code, state);\n});","typeGuard":null,"tryCatchPattern":"try {\n  await oauth.exchangeCode(code, state);\n} catch (e) {\n  if (e instanceof Error && e.message === 'Invalid or expired state parameter') {\n    // restart the flow (new createAuthorizationRequest) — do NOT retry the same state\n  }\n  throw e;\n}","preventionTips":["Use one OAuthManager instance for both legs (singleton or sticky sessions)","Persist pendingRequests externally if deploys or restarts can interrupt flows","De-duplicate callback deliveries (state is one-shot) at the route layer"],"tags":["oauth","state","csrf","callback","session-affinity"],"backgroundTag":"oauth-state-mismatch","analyzedSha":"fa13ee4ad60ac2090b1480656eb233521790d640","analyzedAt":"2026-08-18T21:34:22.708Z","contentChangedAt":"2026-08-18T21:34:22.708Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}