{"record":{"id":"d339690a61da1431","repo":"toeverything/AFFiNE","slug":"auth-session-revoked-d33969","errorCode":"AUTH_SESSION_REVOKED","errorMessage":"The auth session has been revoked.","messagePattern":"The auth session has been revoked\\.","errorType":"http","errorClass":"AuthSessionHttpError","httpStatus":401,"severity":"error","filePath":"packages/backend/server/src/core/auth/session-exchange.ts","lineNumber":128,"sourceCode":"  async refresh(req: Request, refreshToken: string, appVersion?: string) {\n    if (!isNativeClientRequest(req)) throw new ActionForbidden();\n    const selector = refreshToken.split('.')[1];\n    if (selector) {\n      const rateKey = `auth:session-refresh-rate:${selector}`;\n      const attempts = await this.cache.increaseWithTtl(rateKey, 60_000);\n      if (attempts > 30) throw new TooManyRequest();\n    }\n    const refreshed = await this.authSessions.refresh(refreshToken, appVersion);\n    if (refreshed.status !== 'rotated') {\n      const status =\n        refreshed.code === AuthSessionErrorCode.temporarilyUnavailable\n          ? HttpStatus.SERVICE_UNAVAILABLE\n          : HttpStatus.UNAUTHORIZED;\n      throw new AuthSessionHttpError(refreshed.code, status);\n    }\n    const session = await this.authSessions.get(refreshed.authSessionId);\n    if (!session) {\n      throw new AuthSessionHttpError(AuthSessionErrorCode.revoked);\n    }\n    return this.tokenPair(\n      session.userSession.userId,\n      refreshed.authSessionId,\n      refreshed.refreshToken,\n      refreshed.refreshExpiresAt,\n      session.absoluteExpiresAt\n    );\n  }\n\n  private async tokenPair(\n    userId: string,\n    authSessionId: string,\n    refreshToken: string,\n    refreshTokenExpiresAt: Date,\n    absoluteExpiresAt: Date\n  ) {\n    const access = await this.accessTokens.sign(userId, authSessionId);","sourceCodeStart":110,"sourceCodeEnd":146,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/core/auth/session-exchange.ts#L110-L146","documentation":"In SessionExchangeService.refresh, the rotation itself succeeded (status 'rotated'), but the follow-up authSessions.get(refreshed.authSessionId) returned nothing, so the response is surfaced as AUTH_SESSION_REVOKED. The session ceased to exist between rotation and lookup — revoked or deleted concurrently with the refresh.","triggerScenarios":"A refresh racing a revoke: user clicks sign-out(-everywhere) on another device, changePasswordAndRevokeSessions runs, or an admin revokes the session while this device is mid-refresh.","commonSituations":"Multi-device apps where sign-out on one device lands exactly as another refreshes; password change triggering session revocation mid-refresh; user switching accounts while a background refresh is in flight.","solutions":["Treat this as signed-out: clear the stored token pair and route the user to sign-in","Ensure sign-out/revoke on other devices also clears this device's tokens via push/socket so the race window shrinks","Do not retry with the same refresh token after a revoked result — the session is gone"],"exampleFix":"// before\nreturn await sessionExchange.refresh(req, refreshToken);\n\n// after\ntry {\n  return await sessionExchange.refresh(req, refreshToken);\n} catch (e) {\n  if (e instanceof AuthSessionHttpError && e.code === 'AUTH_SESSION_REVOKED') {\n    await clearTokenStore();\n    router.push('/signin');\n    return null;\n  }\n  throw e;\n}","handlingStrategy":"fallback","validationCode":null,"typeGuard":"function isSessionRevoked(e: unknown): boolean {\n  return (\n    typeof e === 'object' &&\n    e !== null &&\n    'code' in e &&\n    (e as { code?: string }).code === 'AUTH_SESSION_REVOKED'\n  );\n}","tryCatchPattern":"Catch AUTH_SESSION_REVOKED, purge the stored token pair and session state, and fall back to the sign-in screen. Retrying the refresh with any token is pointless — the session is gone.","preventionTips":["Propagate sign-out/password-change revocations to all devices so this race window shrinks","Clear local tokens eagerly on any revoked signal instead of looping refresh","In multi-device apps, expect revocation at any time and keep the re-login path smooth"],"tags":["auth","session","revocation"],"backgroundTag":"session-revoked","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}