diff --git a/apps/web/lib/verify-session.ts b/apps/web/lib/verify-session.ts index 2a4863450..460332cf7 100644 --- a/apps/web/lib/verify-session.ts +++ b/apps/web/lib/verify-session.ts @@ -14,34 +14,164 @@ function isLocalDevRequest(request: Request): boolean { } } -// middleware.ts only checks the cookie is present; metered/proxy routes must verify it server-side. -export async function hasVerifiedSession(request: Request): Promise { - if (isLocalDevRequest(request)) { - return true - } +// In-process cache for verified sessions. The cookie is the identity; hashing it +// gives a fixed-length cache key without decoding the session. Short TTL keeps +// revocation latency low; promise deduplication absorbs burst traffic without +// fanning out to N concurrent backend hits. +const POSITIVE_TTL_MS = 10_000 +const NEGATIVE_TTL_MS = 5_000 +const MAX_ENTRIES = 1_000 - const cookie = request.headers.get("cookie") - if (!cookie) { - return false +type Entry = { + value: boolean + expiresAt: number + inflight?: Promise +} + +type ComputeResult = + | { kind: "valid" } // session present, user is set + | { kind: "no_user" } // valid response but no user (cached negatively) + | { kind: "unauthorized" } // 401/403 — definitive: invalidate, do not cache + | { kind: "transient" } // 5xx/timeout/parse — fall back to stale; do not overwrite + +const cache = new Map() + +async function hashKey(cookie: string): Promise { + // SubtleCrypto is available in Cloudflare Workers and Node 20+; this repo + // requires node >=20 per the root package.json engines field. + const bytes = new TextEncoder().encode(cookie) + const digest = await crypto.subtle.digest("SHA-256", bytes) + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +function touch(key: string, entry: Entry) { + // LRU: re-insert to move the key to the most-recent end of Map's insertion order. + cache.delete(key) + cache.set(key, entry) + if (cache.size > MAX_ENTRIES) { + // Oldest entry is the first key in iteration order; drop it. + const oldest = cache.keys().next().value + if (oldest !== undefined) cache.delete(oldest) } +} + +/** + * Manually invalidate the cache entry for a cookie. Useful on logout or + * after a server-side session revocation. + */ +export async function invalidateSession(cookie: string): Promise { + if (!cookie) return + cache.delete(await hashKey(cookie)) +} +async function performVerification(cookie: string): Promise { try { const response = await fetch(`${getBackendUrl()}/api/auth/get-session`, { headers: { cookie }, redirect: "error", cache: "no-store", }) + if (response.status === 401 || response.status === 403) { + return { kind: "unauthorized" } + } if (!response.ok) { - return false + return { kind: "transient" } } const session: unknown = await response.json() - return Boolean( + const hasUser = Boolean( session && typeof session === "object" && "user" in session && session.user, ) + return hasUser ? { kind: "valid" } : { kind: "no_user" } } catch { + return { kind: "transient" } + } +} + +/** + * Look up or compute the verification result for a cookie. Concurrent callers + * for the same cookie share a single in-flight fetch. + * + * - valid: caches as positive (POSITIVE_TTL_MS). + * - no_user: caches as negative (NEGATIVE_TTL_MS). + * - unauthorized: invalidates any prior entry; returns false; does not cache. + * - transient: returns the last cached value if any (stale-while-error), + * otherwise false. Does not overwrite the cache. + */ +async function getOrCompute(cookie: string): Promise { + const key = await hashKey(cookie) + const now = Date.now() + const existing = cache.get(key) + if (existing && existing.expiresAt > now && !existing.inflight) { + touch(key, existing) + return existing.value + } + if (existing?.inflight) { + return existing.inflight + } + // Snapshot stale value before placeholder overwrites it — needed for + // stale-while-error on transient failures after TTL expiry. + const staleSnapshot = existing ? { value: existing.value } : undefined + const inflight = (async () => { + const result = await performVerification(cookie) + switch (result.kind) { + case "valid": { + const entry: Entry = { + value: true, + expiresAt: Date.now() + POSITIVE_TTL_MS, + } + cache.set(key, entry) + touch(key, entry) + return true + } + case "no_user": { + const entry: Entry = { + value: false, + expiresAt: Date.now() + NEGATIVE_TTL_MS, + } + cache.set(key, entry) + touch(key, entry) + return false + } + case "unauthorized": { + cache.delete(key) + return false + } + case "transient": { + // Stale-while-error: return last known value if any. + if (staleSnapshot) return staleSnapshot.value + const prior = cache.get(key) + // prior is the placeholder here; prefer snapshot. + if (prior && prior.value !== false) return prior.value + return false + } + } + })() + const placeholder: Entry = { value: false, expiresAt: 0, inflight } + cache.set(key, placeholder) + try { + return await inflight + } catch (error) { + cache.delete(key) + throw error + } finally { + const settled = cache.get(key) + if (settled) settled.inflight = undefined + } +} + +// middleware.ts only checks the cookie is present; metered/proxy routes must verify it server-side. +export async function hasVerifiedSession(request: Request): Promise { + if (isLocalDevRequest(request)) { + return true + } + const cookie = request.headers.get("cookie") + if (!cookie) { return false } + return getOrCompute(cookie) }