From 8c1754d11248f2e228664c068012374ce149656c Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 11 Nov 2026 20:50:09 +0100 Subject: [PATCH] feat(session): list a user's sessions and revoke all but one Part of #155 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 +++++ src/session/kv.ts | 12 ++++++++- src/session/local.ts | 22 ++++++++++++++- src/session/mod.ts | 23 +++++++++++++++- src/session/redis.ts | 17 +++++++++++- src/session/types.ts | 10 +++++-- tests/session/kv_session_store_test.ts | 13 +++++++++ tests/session/local_session_store_test.ts | 13 +++++++++ tests/session/manager_test.ts | 33 +++++++++++++++++++++++ tests/session/redis_session_store_test.ts | 16 +++++++++++ 10 files changed, 160 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4790d0a..872248c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ## [Unreleased] +### Added + +- `SessionManager.listForUser(userId)` and `revokeOthers(userId, keepSessionId)`, backed by a new + `SessionStore.listByUserId(userId)` on the local, Deno KV and Redis stores. They let an account page list a user's + live sessions and end all but the current one (plugin-admin builds "Sign out other sessions" on this). Custom + `SessionStore` implementations must add `listByUserId`. (#155) + ## [0.36.0] — 2026-11-10 Dune 0.36 moves the admin out of core and freezes the plugin and theme contracts as two small diff --git a/src/session/kv.ts b/src/session/kv.ts index 6e31ec93..4e2fba63 100644 --- a/src/session/kv.ts +++ b/src/session/kv.ts @@ -88,10 +88,20 @@ export function createKVSessionStore(config: KVSessionStoreConfig): SessionStore await ops.commit(); } + async function listByUserId(userId: string): Promise { + const out: Session[] = []; + for await (const entry of kv.list({ prefix: uKey(userId) })) { + const sessionId = entry.key[entry.key.length - 1] as string; + const result = await kv.get(sKey(sessionId)); + if (result.value !== null) out.push(result.value); + } + return out; + } + async function cleanup(): Promise { // Deno KV handles TTL expiry natively — no manual sweep required. return 0; } - return { get, set, delete: del, deleteByUserId, cleanup }; + return { get, set, delete: del, deleteByUserId, listByUserId, cleanup }; } diff --git a/src/session/local.ts b/src/session/local.ts index 6ae26482..8396aee1 100644 --- a/src/session/local.ts +++ b/src/session/local.ts @@ -82,6 +82,26 @@ export function createLocalSessionStore(config: LocalSessionStoreConfig): Sessio } } + async function listByUserId(userId: string): Promise { + const out: Session[] = []; + try { + const entries = await storage.list(sessionsDir); + for (const entry of entries) { + if (entry.isDirectory || !entry.name.endsWith(".json")) continue; + try { + const data = await storage.read(`${sessionsDir}/${entry.name}`); + const session: Session = JSON.parse(new TextDecoder().decode(data)); + if (session.userId === userId) out.push(session); + } catch { + // Skip corrupt files + } + } + } catch { + // Sessions dir may not exist yet + } + return out; + } + async function cleanup(): Promise { let cleaned = 0; try { @@ -110,5 +130,5 @@ export function createLocalSessionStore(config: LocalSessionStoreConfig): Sessio return cleaned; } - return { get, set, delete: del, deleteByUserId, cleanup }; + return { get, set, delete: del, deleteByUserId, listByUserId, cleanup }; } diff --git a/src/session/mod.ts b/src/session/mod.ts index 77d5a781..9e7a8ad2 100644 --- a/src/session/mod.ts +++ b/src/session/mod.ts @@ -112,6 +112,14 @@ export interface SessionManager { revoke(sessionId: string): Promise; /** Revoke all sessions for a user. */ revokeAll(userId: string): Promise; + /** + * The user's live (unexpired) sessions, newest first. A session's `id` is the + * bearer credential in its cookie, so never render it in a page: show a + * derived handle and look the session up again server-side to act on it. + */ + listForUser(userId: string): Promise; + /** Revoke all of a user's sessions except `keepSessionId` (the caller's own). */ + revokeOthers(userId: string, keepSessionId: string): Promise; /** Clean up expired sessions. */ cleanup(): Promise; } @@ -145,11 +153,24 @@ export function createSessionManager(store: SessionStore, lifetimeMs: number): S await store.deleteByUserId(userId); } + async function listForUser(userId: string): Promise { + const now = Date.now(); + return (await store.listByUserId(userId)) + .filter((s) => s.expiresAt >= now) + .sort((a, b) => b.createdAt - a.createdAt); + } + + async function revokeOthers(userId: string, keepSessionId: string): Promise { + for (const s of await store.listByUserId(userId)) { + if (s.id !== keepSessionId) await store.delete(s.id); + } + } + async function cleanup(): Promise { return store.cleanup(); } - return { create, get, revoke, revokeAll, cleanup }; + return { create, get, revoke, revokeAll, listForUser, revokeOthers, cleanup }; } async function generateSessionId(): Promise { diff --git a/src/session/redis.ts b/src/session/redis.ts index e57c3fbf..bd5a10e6 100644 --- a/src/session/redis.ts +++ b/src/session/redis.ts @@ -113,12 +113,27 @@ export function createRedisSessionStore(config: RedisSessionStoreConfig): Sessio await client.del(userSetKey(userId)); } + async function listByUserId(userId: string): Promise { + const ids = await client.smembers(userSetKey(userId)); + const out: Session[] = []; + for (const id of ids) { + const raw = await client.get(sessionKey(id)); + if (raw === null) continue; + try { + out.push(JSON.parse(raw) as Session); + } catch { + // Skip corrupt entries + } + } + return out; + } + async function cleanup(): Promise { // Redis handles TTL expiry natively — no manual sweep required. return 0; } - return { get, set, delete: del, deleteByUserId, cleanup }; + return { get, set, delete: del, deleteByUserId, listByUserId, cleanup }; } /** diff --git a/src/session/types.ts b/src/session/types.ts index c20d9c12..d709fdbc 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -46,11 +46,17 @@ export interface SessionStore { /** * Delete all sessions belonging to a user. - * Used on password change, explicit "sign out everywhere", and login - * (to ensure only one active session per user). + * Used on password change and explicit "sign out everywhere". */ deleteByUserId(userId: string): Promise; + /** + * All stored sessions belonging to a user, in no particular order. May + * include sessions that have expired but not yet been swept; callers that + * need only live ones filter on `expiresAt` (SessionManager does). + */ + listByUserId(userId: string): Promise; + /** * Remove expired sessions from the store and return the count deleted. * Backends that handle TTL natively (KV, Redis) should return 0 immediately — diff --git a/tests/session/kv_session_store_test.ts b/tests/session/kv_session_store_test.ts index 4a644625..6448d57f 100644 --- a/tests/session/kv_session_store_test.ts +++ b/tests/session/kv_session_store_test.ts @@ -96,6 +96,19 @@ Deno.test("KVSessionStore: deleteByUserId removes all sessions for a user", asyn kv.close(); }); +Deno.test("KVSessionStore: listByUserId returns only that user's sessions", async () => { + const kv = await Deno.openKv(":memory:"); + const store = createKVSessionStore({ kv, lifetimeMs: 3600 * 1000 }); + await store.set(makeSession({ id: "s1aaaa", userId: "alice" })); + await store.set(makeSession({ id: "s2bbbb", userId: "alice" })); + await store.set(makeSession({ id: "s3cccc", userId: "bob" })); + + const ids = (await store.listByUserId("alice")).map((s) => s.id).sort(); + assertEquals(ids, ["s1aaaa", "s2bbbb"]); + assertEquals(await store.listByUserId("nobody"), []); + kv.close(); +}); + Deno.test("KVSessionStore: deleteByUserId is safe when no sessions exist", async () => { const kv = await Deno.openKv(":memory:"); const store = createKVSessionStore({ kv, lifetimeMs: 3600 * 1000 }); diff --git a/tests/session/local_session_store_test.ts b/tests/session/local_session_store_test.ts index 2704ba3e..bc2568e3 100644 --- a/tests/session/local_session_store_test.ts +++ b/tests/session/local_session_store_test.ts @@ -118,6 +118,19 @@ Deno.test("LocalSessionStore: deleteByUserId is safe when no sessions exist", as await store.deleteByUserId("nobody"); }); +// ── listByUserId ─────────────────────────────────────────────────────────────── + +Deno.test("LocalSessionStore: listByUserId returns only that user's sessions", async () => { + const store = createLocalSessionStore({ storage: createMemoryStorage(), sessionsDir: ".sess", lifetimeMs: 3_600_000 }); + await store.set(makeSession({ id: "aaaa", userId: "alice" })); + await store.set(makeSession({ id: "bbbb", userId: "alice" })); + await store.set(makeSession({ id: "cccc", userId: "bob" })); + + const ids = (await store.listByUserId("alice")).map((s) => s.id).sort(); + assertEquals(ids, ["aaaa", "bbbb"]); + assertEquals(await store.listByUserId("nobody"), []); +}); + // ── cleanup ──────────────────────────────────────────────────────────────────── Deno.test("LocalSessionStore: cleanup removes only expired sessions", async () => { diff --git a/tests/session/manager_test.ts b/tests/session/manager_test.ts index 6dabcc2c..662ccdd1 100644 --- a/tests/session/manager_test.ts +++ b/tests/session/manager_test.ts @@ -135,6 +135,39 @@ Deno.test("createSessionManager: revokeAll deletes every session for a user, lea assertExists(await mgr.get(b1.id)); }); +Deno.test("createSessionManager: listForUser returns the user's live sessions, newest first", async () => { + const mgr = makeManager(); + const first = await mgr.create("user-a", "10.0.0.1"); + await new Promise((r) => setTimeout(r, 5)); + const second = await mgr.create("user-a", "10.0.0.2"); + await mgr.create("user-b"); + + const list = await mgr.listForUser("user-a"); + assertEquals(list.map((x) => x.id), [second.id, first.id]); + assertEquals(await mgr.listForUser("nobody"), []); +}); + +Deno.test("createSessionManager: listForUser leaves out expired sessions", async () => { + const mgr = makeManager(-1000); // sessions are created already expired + await mgr.create("user-a"); + assertEquals(await mgr.listForUser("user-a"), []); +}); + +Deno.test("createSessionManager: revokeOthers keeps only the given session and other users' sessions", async () => { + const mgr = makeManager(); + const keep = await mgr.create("user-a"); + const other1 = await mgr.create("user-a"); + const other2 = await mgr.create("user-a"); + const b1 = await mgr.create("user-b"); + + await mgr.revokeOthers("user-a", keep.id); + + assertExists(await mgr.get(keep.id)); + assertEquals(await mgr.get(other1.id), null); + assertEquals(await mgr.get(other2.id), null); + assertExists(await mgr.get(b1.id)); +}); + Deno.test("createSessionManager: cleanup removes expired sessions", async () => { const mgr = makeManager(-1000); // already expired await mgr.create("user-1"); diff --git a/tests/session/redis_session_store_test.ts b/tests/session/redis_session_store_test.ts index 7ff62ca8..18b8b1fc 100644 --- a/tests/session/redis_session_store_test.ts +++ b/tests/session/redis_session_store_test.ts @@ -230,6 +230,22 @@ Deno.test("RedisSessionStore: deleteByUserId removes all sessions for a user", a assertNotEquals(await store.get(s3.id), null); }); +Deno.test("RedisSessionStore: listByUserId returns only that user's sessions", async () => { + const store = createRedisSessionStore({ + client: new FakeRedisClient(), + lifetimeSec: 3600, + }); + const s1 = makeSession({ userId: "alice" }); + const s2 = makeSession({ userId: "alice" }); + await store.set(s1); + await store.set(s2); + await store.set(makeSession({ userId: "bob" })); + + const ids = (await store.listByUserId("alice")).map((s) => s.id).sort(); + assertEquals(ids, [s1.id, s2.id].sort()); + assertEquals(await store.listByUserId("nobody"), []); +}); + Deno.test("RedisSessionStore: deleteByUserId also clears the user's set key", async () => { const client = new FakeRedisClient(); const store = createRedisSessionStore({ client, lifetimeSec: 3600 });