feat(auth): centralize session-cache eviction on auth lifecycle (#48)#57
Conversation
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
| const tokens = (await redis.get<string[]>(indexKey)) ?? []; | ||
| for (const token of tokens) { | ||
| await redis.delete(sessionTokenCacheKey(token)); | ||
| } | ||
| await redis.delete(indexKey); |
There was a problem hiding this comment.
Bulk Eviction Drops New Sessions
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 session:token:* entry can survive while its index entry is removed, so later bulk evictions cannot find it and stale session state can continue to be accepted until that cache entry expires.
| 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); |
There was a problem hiding this comment.
Logout Overwrites Concurrent Index Writes
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.
| try { | ||
| await applySessionCacheEviction(redis, event); | ||
| } catch { | ||
| // ignore — auth path must succeed even if cache is down | ||
| } |
There was a problem hiding this comment.
The Redis cache client used here resolves get, set, and delete failures instead of throwing, so this catch block usually never sees a Redis outage or failover. Logout, password-change, and org-switch requests can then return normally while no cache entries were deleted, leaving stale session-validation results active for their remaining TTL with no retry or error signal.
| 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, |
There was a problem hiding this comment.
Sign-Out Can Lose User Index Pruning
For /sign-out, the after-hook may run after Better Auth has cleared context.session, which makes userId fall back to null. The logout event still deletes the token from the cookie, but evictSessionByToken skips pruning session:user:*, leaving dead tokens in the user index and causing later user-wide evictions to operate on stale index contents.
Summary
session:token:…,session:user:…) + shared Redis prefixreloop-session.Stacked on #56 (#47). Closes #48.
Test plan
bun testinpackages/auth— 33 pass (incl. 13 eviction cases)tsc --noEmitfor@reloop/authGreptile Summary
This PR centralizes session-cache eviction in the auth lifecycle. The main changes are:
reloop-sessionRedis prefix for session validation cache keys.Confidence Score: 4/5
The session-cache eviction path needs fixes before merging.
packages/auth/src/middleware/eviction.ts; packages/auth/src/server/auth.ts
Security Review
The new eviction path can leave stale session-validation cache entries active when index updates race with session validation or Redis operations fail silently.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Auth as Auth lifecycle hook participant Evict as eviction.ts participant Redis as Redis session cache participant MW as Session middleware Auth->>Evict: password-change/org-switch(userId) Evict->>Redis: get(session:user:userId) Redis-->>Evict: [oldToken] MW->>Redis: set(session:token:newToken) MW->>Redis: set(session:user:userId, [oldToken,newToken]) Evict->>Redis: delete(session:token:oldToken) Evict->>Redis: delete(session:user:userId) Note over Redis: newToken cache entry survives while its index is removed%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Auth as Auth lifecycle hook participant Evict as eviction.ts participant Redis as Redis session cache participant MW as Session middleware Auth->>Evict: password-change/org-switch(userId) Evict->>Redis: get(session:user:userId) Redis-->>Evict: [oldToken] MW->>Redis: set(session:token:newToken) MW->>Redis: set(session:user:userId, [oldToken,newToken]) Evict->>Redis: delete(session:token:oldToken) Evict->>Redis: delete(session:user:userId) Note over Redis: newToken cache entry survives while its index is removedReviews (1): Last reviewed commit: "feat(auth): centralize session-cache evi..." | Re-trigger Greptile