From 8537ca209d0c4bc548c03cd9357647583b4024eb Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 13 Jul 2026 09:52:59 +0530 Subject: [PATCH] feat(auth): centralize session-cache eviction on auth lifecycle (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make session validation cache revocation near-instant. Auth service hooks logout, password-change, and organization-switch to evict using the shared middleware key convention (session:token / session:user). - applySessionCacheEviction + path→event mapping in @reloop/auth/middleware - sessionCacheRedis with SESSION_CACHE_REDIS_PREFIX for cross-service keys - Wire Better Auth after-hooks in the runtime instance - Package tests assert token + user-index entries are gone after each event --- packages/auth/src/middleware/eviction.ts | 125 +++++++++++ packages/auth/src/middleware/index.ts | 9 + packages/auth/src/middleware/types.ts | 7 +- packages/auth/src/server/auth.ts | 27 +++ packages/auth/src/server/index.ts | 1 + .../auth/src/server/session-cache-redis.ts | 14 ++ packages/auth/test/session-eviction.test.ts | 208 ++++++++++++++++++ 7 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 packages/auth/src/middleware/eviction.ts create mode 100644 packages/auth/src/server/session-cache-redis.ts create mode 100644 packages/auth/test/session-eviction.test.ts diff --git a/packages/auth/src/middleware/eviction.ts b/packages/auth/src/middleware/eviction.ts new file mode 100644 index 000000000..443107d57 --- /dev/null +++ b/packages/auth/src/middleware/eviction.ts @@ -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 { + 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 { + await redis.delete(sessionTokenCacheKey(sessionToken)); + + if (!userId) return; + + const indexKey = sessionUserIndexKey(userId); + const tokens = (await redis.get(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 { + const indexKey = sessionUserIndexKey(userId); + const tokens = (await redis.get(indexKey)) ?? []; + for (const token of tokens) { + await redis.delete(sessionTokenCacheKey(token)); + } + await redis.delete(indexKey); +} + +/** + * 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 { + const event = evictionEventFromAuthPath(opts); + if (!event) return null; + try { + await applySessionCacheEviction(redis, event); + } catch { + // ignore — auth path must succeed even if cache is down + } + return event; +} diff --git a/packages/auth/src/middleware/index.ts b/packages/auth/src/middleware/index.ts index ce0c281bd..795b194ba 100644 --- a/packages/auth/src/middleware/index.ts +++ b/packages/auth/src/middleware/index.ts @@ -3,6 +3,15 @@ * Not yet mounted by services (pilot migration is a later ticket). */ +export { + applySessionCacheEviction, + evictAllSessionsForUser, + evictionEventFromAuthPath, + evictSessionByToken, + handleAuthLifecycleEviction, + SESSION_CACHE_REDIS_PREFIX, + type SessionEvictionEvent, +} from "./eviction"; export { extractSessionToken, sessionTokenCacheKey, diff --git a/packages/auth/src/middleware/types.ts b/packages/auth/src/middleware/types.ts index 302b8379b..e99edc2a6 100644 --- a/packages/auth/src/middleware/types.ts +++ b/packages/auth/src/middleware/types.ts @@ -19,7 +19,12 @@ export type AuthRedis = { export type AuthMiddlewareConfig = { /** Base URL of the auth service (e.g. https://local.reloop.sh). */ baseUrl: string; - /** Shared Redis used for short-TTL session cache + API-key cache. */ + /** + * Shared Redis used for short-TTL session cache + API-key cache. + * Session keys use a package-owned convention; construct RedisCache with + * `SESSION_CACHE_REDIS_PREFIX` so central eviction in the auth service + * hits the same keys. + */ redis: AuthRedis; /** Session-cache TTL in seconds. Default 5. */ ttl?: number; diff --git a/packages/auth/src/server/auth.ts b/packages/auth/src/server/auth.ts index 048e6b15f..7f63b4ea0 100644 --- a/packages/auth/src/server/auth.ts +++ b/packages/auth/src/server/auth.ts @@ -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, + }); + } catch (err) { + log.error({ + ...{ data: err }, + message: "Session cache eviction failed", + }); + } + // 🔐 User registered if (path === "/sign-up/email-otp") { const newSession = context.newSession; diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts index e3c5bff1f..356f7a5c5 100644 --- a/packages/auth/src/server/index.ts +++ b/packages/auth/src/server/index.ts @@ -7,4 +7,5 @@ export { auth, OpenAPI } from "./auth"; export { authServerConfig } from "./config"; export { redis as authRedis } from "./redis"; +export { sessionCacheRedis } from "./session-cache-redis"; export * from "./signup-invite"; diff --git a/packages/auth/src/server/session-cache-redis.ts b/packages/auth/src/server/session-cache-redis.ts new file mode 100644 index 000000000..888fff788 --- /dev/null +++ b/packages/auth/src/server/session-cache-redis.ts @@ -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, +); diff --git a/packages/auth/test/session-eviction.test.ts b/packages/auth/test/session-eviction.test.ts new file mode 100644 index 000000000..93dbf28e1 --- /dev/null +++ b/packages/auth/test/session-eviction.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; +import { + type AuthContext, + applySessionCacheEviction, + evictAllSessionsForUser, + evictionEventFromAuthPath, + evictSessionByToken, + handleAuthLifecycleEviction, + sessionTokenCacheKey, + sessionUserIndexKey, +} from "../src/middleware"; +import { MemoryRedis } from "./memory-redis"; + +async function seedUserSessions( + redis: MemoryRedis, + userId: string, + tokens: string[], +): Promise { + for (const token of tokens) { + const ctx: AuthContext = { + userId, + organizationId: "org-1", + role: "user", + authType: "session", + }; + await redis.set(sessionTokenCacheKey(token), ctx, 5); + } + await redis.set(sessionUserIndexKey(userId), tokens, 5); +} + +describe("session cache eviction", () => { + test("logout evicts the specific session-token entry and prunes the user index", async () => { + const redis = new MemoryRedis(); + const userId = "user-1"; + const keep = "tok-keep"; + const drop = "tok-drop"; + await seedUserSessions(redis, userId, [keep, drop]); + + await applySessionCacheEviction(redis, { + type: "logout", + sessionToken: drop, + userId, + }); + + expect(await redis.get(sessionTokenCacheKey(drop))).toBeUndefined(); + expect(await redis.get(sessionTokenCacheKey(keep))).toBeDefined(); + expect(await redis.get(sessionUserIndexKey(userId))).toEqual([ + keep, + ]); + }); + + test("logout without userId still deletes the token entry", async () => { + const redis = new MemoryRedis(); + const token = "tok-solo"; + await redis.set(sessionTokenCacheKey(token), { + userId: "u", + organizationId: "o", + role: null, + authType: "session", + } satisfies AuthContext); + + await applySessionCacheEviction(redis, { + type: "logout", + sessionToken: token, + }); + + expect(await redis.get(sessionTokenCacheKey(token))).toBeUndefined(); + }); + + test("password-change evicts all tokens via the per-user index", async () => { + const redis = new MemoryRedis(); + const userId = "user-pw"; + const tokens = ["t1", "t2", "t3"]; + await seedUserSessions(redis, userId, tokens); + + await applySessionCacheEviction(redis, { + type: "password-change", + userId, + }); + + for (const t of tokens) { + expect(await redis.get(sessionTokenCacheKey(t))).toBeUndefined(); + } + expect(await redis.get(sessionUserIndexKey(userId))).toBeUndefined(); + }); + + test("organization-switch evicts all tokens via the per-user index", async () => { + const redis = new MemoryRedis(); + const userId = "user-org"; + const tokens = ["a", "b"]; + await seedUserSessions(redis, userId, tokens); + + await applySessionCacheEviction(redis, { + type: "organization-switch", + userId, + }); + + for (const t of tokens) { + expect(await redis.get(sessionTokenCacheKey(t))).toBeUndefined(); + } + expect(await redis.get(sessionUserIndexKey(userId))).toBeUndefined(); + }); + + test("evictAllSessionsForUser is a no-op when the index is empty", async () => { + const redis = new MemoryRedis(); + await evictAllSessionsForUser(redis, "nobody"); + expect(await redis.get(sessionUserIndexKey("nobody"))).toBeUndefined(); + }); + + test("evictSessionByToken removes the last index entry entirely", async () => { + const redis = new MemoryRedis(); + const userId = "user-last"; + const token = "only"; + await seedUserSessions(redis, userId, [token]); + + await evictSessionByToken(redis, token, userId); + + expect(await redis.get(sessionTokenCacheKey(token))).toBeUndefined(); + expect(await redis.get(sessionUserIndexKey(userId))).toBeUndefined(); + }); +}); + +describe("evictionEventFromAuthPath (lifecycle mapping)", () => { + test("maps /sign-out to logout with token from cookie", () => { + const event = evictionEventFromAuthPath({ + path: "/sign-out", + cookieHeader: "reloop.session_token=abc123.sig; other=1", + userId: "u1", + }); + expect(event).toEqual({ + type: "logout", + sessionToken: "abc123", + userId: "u1", + }); + }); + + test("maps /change-password and /reset-password to password-change", () => { + expect( + evictionEventFromAuthPath({ path: "/change-password", userId: "u" }), + ).toEqual({ type: "password-change", userId: "u" }); + expect( + evictionEventFromAuthPath({ path: "/reset-password", userId: "u" }), + ).toEqual({ type: "password-change", userId: "u" }); + }); + + test("maps /organization/set-active to organization-switch", () => { + expect( + evictionEventFromAuthPath({ + path: "/organization/set-active", + userId: "u", + }), + ).toEqual({ type: "organization-switch", userId: "u" }); + }); + + test("ignores unrelated paths", () => { + expect( + evictionEventFromAuthPath({ path: "/get-session", userId: "u" }), + ).toBeNull(); + }); + + test("handleAuthLifecycleEviction drives logout end-to-end", async () => { + const redis = new MemoryRedis(); + const token = "live-tok"; + const userId = "live-user"; + await seedUserSessions(redis, userId, [token, "other"]); + + const event = await handleAuthLifecycleEviction(redis, { + path: "/sign-out", + cookieHeader: `reloop.session_token=${token}.sig`, + userId, + }); + + expect(event?.type).toBe("logout"); + expect(await redis.get(sessionTokenCacheKey(token))).toBeUndefined(); + expect(await redis.get(sessionTokenCacheKey("other"))).toBeDefined(); + }); + + test("handleAuthLifecycleEviction drives password-change end-to-end", async () => { + const redis = new MemoryRedis(); + const userId = "pw-user"; + await seedUserSessions(redis, userId, ["x", "y"]); + + const event = await handleAuthLifecycleEviction(redis, { + path: "/change-password", + userId, + }); + + expect(event?.type).toBe("password-change"); + expect(await redis.get(sessionTokenCacheKey("x"))).toBeUndefined(); + expect(await redis.get(sessionTokenCacheKey("y"))).toBeUndefined(); + expect(await redis.get(sessionUserIndexKey(userId))).toBeUndefined(); + }); + + test("handleAuthLifecycleEviction drives organization-switch end-to-end", async () => { + const redis = new MemoryRedis(); + const userId = "org-user"; + await seedUserSessions(redis, userId, ["o1"]); + + const event = await handleAuthLifecycleEviction(redis, { + path: "/organization/set-active", + userId, + }); + + expect(event?.type).toBe("organization-switch"); + expect(await redis.get(sessionTokenCacheKey("o1"))).toBeUndefined(); + expect(await redis.get(sessionUserIndexKey(userId))).toBeUndefined(); + }); +});