-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): centralize session-cache eviction on auth lifecycle (#48) #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { | ||
| extractSessionToken, | ||
| sessionTokenCacheKey, | ||
| sessionUserIndexKey, | ||
| } from "./keys"; | ||
| import type { AuthRedis } from "./types"; | ||
|
|
||
| /** | ||
| * Shared RedisCache prefix for the short-TTL session validation cache. | ||
| * Every service that mounts `createAuthPlugin` must inject a Redis client | ||
| * constructed with this prefix so central eviction hits the same keys. | ||
| */ | ||
| export const SESSION_CACHE_REDIS_PREFIX = "reloop-session"; | ||
|
|
||
| /** Lifecycle events that require session-cache eviction. */ | ||
| export type SessionEvictionEvent = | ||
| | { type: "logout"; sessionToken: string; userId?: string | null } | ||
| | { type: "password-change"; userId: string } | ||
| | { type: "organization-switch"; userId: string }; | ||
|
|
||
| /** | ||
| * Apply one eviction event against the shared session-validation cache. | ||
| * | ||
| * - logout → delete that session-token entry (+ prune user index if userId known) | ||
| * - password-change / organization-switch → delete every token in the user index | ||
| */ | ||
| export async function applySessionCacheEviction( | ||
| redis: AuthRedis, | ||
| event: SessionEvictionEvent, | ||
| ): Promise<void> { | ||
| if (event.type === "logout") { | ||
| await evictSessionByToken(redis, event.sessionToken, event.userId); | ||
| return; | ||
| } | ||
| await evictAllSessionsForUser(redis, event.userId); | ||
| } | ||
|
|
||
| /** Delete one cached session and optionally remove it from the user index. */ | ||
| export async function evictSessionByToken( | ||
| redis: AuthRedis, | ||
| sessionToken: string, | ||
| userId?: string | null, | ||
| ): Promise<void> { | ||
| await redis.delete(sessionTokenCacheKey(sessionToken)); | ||
|
|
||
| if (!userId) return; | ||
|
|
||
| const indexKey = sessionUserIndexKey(userId); | ||
| const tokens = (await redis.get<string[]>(indexKey)) ?? []; | ||
| const next = tokens.filter((t) => t !== sessionToken); | ||
| if (next.length === 0) { | ||
| await redis.delete(indexKey); | ||
| } else { | ||
| await redis.set(indexKey, next); | ||
| } | ||
| } | ||
|
|
||
| /** Read the per-user index and delete every session-token entry + the index. */ | ||
| export async function evictAllSessionsForUser( | ||
| redis: AuthRedis, | ||
| userId: string, | ||
| ): Promise<void> { | ||
| const indexKey = sessionUserIndexKey(userId); | ||
| const tokens = (await redis.get<string[]>(indexKey)) ?? []; | ||
| for (const token of tokens) { | ||
| await redis.delete(sessionTokenCacheKey(token)); | ||
| } | ||
| await redis.delete(indexKey); | ||
|
Comment on lines
+64
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When password-change or org-switch eviction runs while another request caches a session for the same user, this function only deletes the tokens read before the race and then deletes the whole user index. The newly cached |
||
| } | ||
|
|
||
| /** | ||
| * Map a Better Auth request path (+ cookie / user) onto an eviction event. | ||
| * Returns null when the path is not a lifecycle event we care about. | ||
| */ | ||
| export function evictionEventFromAuthPath(opts: { | ||
| path: string; | ||
| cookieHeader?: string | null; | ||
| userId?: string | null; | ||
| }): SessionEvictionEvent | null { | ||
| const path = opts.path; | ||
|
|
||
| if (path === "/sign-out") { | ||
| const token = extractSessionToken(opts.cookieHeader ?? null); | ||
| if (!token) return null; | ||
| return { | ||
| type: "logout", | ||
| sessionToken: token, | ||
| userId: opts.userId ?? null, | ||
| }; | ||
| } | ||
|
|
||
| if (path === "/change-password" || path === "/reset-password") { | ||
| if (!opts.userId) return null; | ||
| return { type: "password-change", userId: opts.userId }; | ||
| } | ||
|
|
||
| if (path === "/organization/set-active") { | ||
| if (!opts.userId) return null; | ||
| return { type: "organization-switch", userId: opts.userId }; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Best-effort hook entrypoint: resolve an event from the path and apply it. | ||
| * Never throws — eviction must not break the auth response. | ||
| */ | ||
| export async function handleAuthLifecycleEviction( | ||
| redis: AuthRedis, | ||
| opts: { | ||
| path: string; | ||
| cookieHeader?: string | null; | ||
| userId?: string | null; | ||
| }, | ||
| ): Promise<SessionEvictionEvent | null> { | ||
| const event = evictionEventFromAuthPath(opts); | ||
| if (!event) return null; | ||
| try { | ||
| await applySessionCacheEviction(redis, event); | ||
| } catch { | ||
| // ignore — auth path must succeed even if cache is down | ||
| } | ||
|
Comment on lines
+119
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Redis cache client used here resolves |
||
| return event; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,11 +16,13 @@ import { | |
| } from "better-auth/plugins"; | ||
| import { eq } from "drizzle-orm"; | ||
| import { log } from "evlog"; | ||
| import { handleAuthLifecycleEviction } from "../middleware/eviction"; | ||
| import { ac, orgRoles } from "../permissions"; | ||
| import { platformAc, platformRoles } from "../platform-permissions"; | ||
| import { DEFAULT_USER_ROLE, PLATFORM_ADMIN_ROLE } from "../roles"; | ||
| import { authServerConfig } from "./config"; | ||
| import { redis } from "./redis"; | ||
| import { sessionCacheRedis } from "./session-cache-redis"; | ||
| import { | ||
| canCreateAccount, | ||
| findValidSignupInvite, | ||
|
|
@@ -126,6 +128,31 @@ export const auth = betterAuth({ | |
| after: createAuthMiddleware(async (ctx) => { | ||
| const { path, context } = ctx; | ||
| log.info({ message: String(ctx.path) }); | ||
|
|
||
| // Near-instant session-validation cache eviction (shared Redis). | ||
| // Logout → that token; password-change / org-switch → all user tokens. | ||
| try { | ||
| const cookieHeader = | ||
| typeof ctx.headers?.get === "function" | ||
| ? ctx.headers.get("cookie") | ||
| : null; | ||
| const sessionUser = | ||
| // biome-ignore lint/suspicious/noExplicitAny: Better Auth context shape | ||
| (context as any)?.session?.user ?? | ||
| // biome-ignore lint/suspicious/noExplicitAny: Better Auth context shape | ||
| (context as any)?.newSession?.user; | ||
| await handleAuthLifecycleEviction(sessionCacheRedis, { | ||
| path: String(path), | ||
| cookieHeader, | ||
| userId: sessionUser?.id ?? null, | ||
|
Comment on lines
+139
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For |
||
| }); | ||
| } catch (err) { | ||
| log.error({ | ||
| ...{ data: err }, | ||
| message: "Session cache eviction failed", | ||
| }); | ||
| } | ||
|
|
||
| // 🔐 User registered | ||
| if (path === "/sign-up/email-otp") { | ||
| const newSession = context.newSession; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { RedisCache } from "@reloop/cache/redis-client"; | ||
| import { SESSION_CACHE_REDIS_PREFIX } from "../middleware/eviction"; | ||
| import { authServerConfig } from "./config"; | ||
|
|
||
| /** | ||
| * Shared session-validation cache used by the middleware plugin and by | ||
| * central eviction in the auth service. Must use {@link SESSION_CACHE_REDIS_PREFIX} | ||
| * so every service hits the same Redis keys. | ||
| */ | ||
| export const sessionCacheRedis = new RedisCache( | ||
| SESSION_CACHE_REDIS_PREFIX, | ||
| 5, | ||
| authServerConfig.REDIS_URL, | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This read/filter/write of
session:user:*can race with the middleware path that appends a token to the same index. A logout can write an older filtered array after a concurrent session validation added a live token, leaving that token's cache entry present but no longer listed for future password-change or org-switch eviction.