Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions packages/auth/src/middleware/eviction.ts
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);
Comment on lines +49 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

}
}

/** 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

}

/**
* 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Redis Eviction Fails Open

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.

return event;
}
9 changes: 9 additions & 0 deletions packages/auth/src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/auth/src/middleware/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions packages/auth/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

});
} catch (err) {
log.error({
...{ data: err },
message: "Session cache eviction failed",
});
}

// 🔐 User registered
if (path === "/sign-up/email-otp") {
const newSession = context.newSession;
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
14 changes: 14 additions & 0 deletions packages/auth/src/server/session-cache-redis.ts
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,
);
Loading