{"record":{"id":"da029693db4e632b","repo":"Budibase/budibase","slug":"slack-oauth-state-is-invalid-or-expired","errorCode":null,"errorMessage":"Slack OAuth state is invalid or expired","messagePattern":"Slack OAuth state is invalid or expired","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/server/src/api/controllers/ai/agents.ts","lineNumber":582,"sourceCode":"      state,\n    }),\n  }\n  ctx.status = 200\n}\n\nexport async function completeSlackOAuth(ctx: UserCtx<void, void>) {\n  const state = String(ctx.query.state || \"\").trim()\n  if (!state) {\n    throw new Error(\"Slack OAuth callback is missing state\")\n  }\n\n  const cacheKey = getSlackOAuthStateCacheKey(state)\n  const statePayload = (await cache.get(cacheKey, {\n    useTenancy: false,\n  })) as SlackOAuthState | undefined\n  await cache.destroy(cacheKey, { useTenancy: false })\n  if (!statePayload?.agentId || !statePayload.workspaceId) {\n    throw new Error(\"Slack OAuth state is invalid or expired\")\n  }\n\n  const oauthError = String(ctx.query.error || \"\").trim()\n  if (oauthError) {\n    throw new Error(\"Slack OAuth authorization failed\")\n  }\n\n  const code = String(ctx.query.code || \"\").trim()\n  if (!code) {\n    throw new Error(\"Slack OAuth callback is missing the authorization code\")\n  }\n\n  await context.doInWorkspaceContext(statePayload.workspaceId, async () => {\n    const agent = await sdk.ai.agents.getOrThrow(statePayload.agentId)\n    const clientId = agent.slackIntegration?.clientId?.trim()\n    const clientSecret = agent.slackIntegration?.clientSecret?.trim()\n    if (!clientId || !clientSecret) {\n      throw new Error(\"Slack OAuth client credentials are not configured\")","sourceCodeStart":564,"sourceCodeEnd":600,"githubUrl":"https://github.com/Budibase/budibase/blob/a81a902e9a8fe55b467d106765f6638f12e35c49/packages/server/src/api/controllers/ai/agents.ts#L564-L600","documentation":"The OAuth state value is used as a cache key holding { agentId, workspaceId } for the pending flow. The handler deletes the cached entry immediately after reading (single-use), and throws if the payload is missing or lacks agentId/workspaceId — meaning the state was unknown, already consumed, or expired.","triggerScenarios":"Callback with a state that has no cache entry: state already used (double callback/retry), cache TTL expired, Redis restarted/flushed, multi-instance deployment pointing at different caches, or a forged/garbage state string.","commonSituations":"User refreshing the callback page (second hit consumes the single-use state); long delay between initiate and approve exceeding the cache TTL; Redis eviction under memory pressure; cache.useTenancy misconfiguration so lookup misses.","solutions":["Restart the Slack OAuth flow to generate fresh state — the old entry is gone or consumed","Do not refresh/replay the callback URL; each state works exactly once","Check Redis availability and TTL configuration for the state cache key","Ensure all server instances share the same Redis cache instance","Confirm the initiate step stores { agentId, workspaceId } before redirecting to Slack"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"const payload = await cache.get(getSlackOAuthStateCacheKey(state), { useTenancy: false })\nif (!payload || !(payload as any).agentId || !(payload as any).workspaceId) {\n  // state unknown/expired: re-initiate the OAuth flow\n  return initiateSlackOAuth(agentId)\n}","typeGuard":"function isSlackOAuthState(p: unknown): p is SlackOAuthState {\n  return !!p && typeof p === \"object\" && typeof (p as SlackOAuthState).agentId === \"string\" && typeof (p as SlackOAuthState).workspaceId === \"string\"\n}","tryCatchPattern":"try {\n  await completeSlackOAuth(ctx)\n} catch (err) {\n  if (err.message.includes(\"invalid or expired\")) {\n    // state consumed/expired — instruct user to restart the install; do not retry the callback\n  } else { throw err }\n}","preventionTips":["Treat state as strictly single-use; never replay callback URLs","Set a generous but bounded cache TTL for state entries (covering slow approvals)","Use one shared Redis instance across all server replicas","Persist state before redirecting the user to Slack"],"tags":["slack","oauth","cache","expired-state"],"backgroundTag":"oauth-state-invalid-or-expired","analyzedSha":"a81a902e9a8fe55b467d106765f6638f12e35c49","analyzedAt":"2026-08-29T01:03:10.972Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}