Add session management: see who's signed in, revoke a session
No visibility existed into who was currently signed in or a way to force a device out. Sessions already live as files via session-file-store, so this reads that store directly rather than adding a new DB table: new Settings-adjacent "Sessions" page (admin-only, alongside Users) lists every live session with the user's name/email/resolved role, IP, a friendly "Browser on OS" summary parsed from the user-agent, last-active time, and expiry, with a Revoke button per row (extra confirmation if you revoke your own current session, since that signs you out immediately). IP and user-agent are now captured into the session at login (auth/router.ts) since express-session doesn't track them itself. session-file-store's own Store type doesn't declare its list() method, so sessionStore.ts adds a narrow local interface for it rather than losing type safety on the rest of the store. Verified against a real session directory seeded through the actual session-file-store APIs (not hand-written JSON): confirmed correct field resolution including a session whose user row was later deleted (role resolves to null instead of crashing), correctly excluded a mid-OIDC-login session with no completed user yet, correctly excluded an already-expired session, and confirmed revoke actually deletes the right session file and only that one. Confirmed the real dev database's mtime was untouched throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cae35a59e
commit
6d673db9ec
9 files changed
+318
-1
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import session from "express-session";
|
||||
import FileStoreFactory from "session-file-store";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, type UserRole } from "../db/schema.js";
|
||||
import { resolveDataPath } from "../paths.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
// A second FileStore instance pointed at the same directory/options as the one
|
||||
// index.ts hands to express-session — session-file-store is a stateless
|
||||
// wrapper around that directory, so a fresh instance reads/writes the exact
|
||||
// same files. Needed because express-session's own Store type doesn't
|
||||
// declare list()/get()/destroy() with useful signatures for this purpose.
|
||||
interface FileStoreWithList extends session.Store {
|
||||
list(callback: (err: unknown, files?: string[]) => void): void;
|
||||
}
|
||||
|
||||
const FileStore = FileStoreFactory(session);
|
||||
const sessionDir = resolveDataPath(env.sessionDir);
|
||||
const store = new FileStore({ path: sessionDir, logFn: () => {} }) as FileStoreWithList;
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
sub: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
role: UserRole | null; // null if the user row behind this session no longer exists
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
lastAccess: string; // ISO
|
||||
expiresAt: string | null; // ISO, from the session cookie
|
||||
}
|
||||
|
||||
function listFiles(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
store.list((err, files) => (err ? reject(err) : resolve(files ?? [])));
|
||||
});
|
||||
}
|
||||
|
||||
function getSession(sessionId: string): Promise<session.SessionData | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
store.get(sessionId, (err, sess) => (err ? reject(err) : resolve(sess ?? null)));
|
||||
});
|
||||
}
|
||||
|
||||
export function destroySession(sessionId: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
store.destroy(sessionId, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/** Every live (non-expired) session with a completed login, newest-active first. */
|
||||
export async function listSessions(): Promise<SessionSummary[]> {
|
||||
const files = await listFiles();
|
||||
const roleBySub = new Map<string, UserRole>();
|
||||
for (const u of await db.select({ oidcSub: users.oidcSub, role: users.role }).from(users)) {
|
||||
roleBySub.set(u.oidcSub, u.role);
|
||||
}
|
||||
|
||||
const results: SessionSummary[] = [];
|
||||
for (const file of files) {
|
||||
const id = file.replace(/\.json$/, "");
|
||||
let sess: session.SessionData | null;
|
||||
try {
|
||||
sess = await getSession(id);
|
||||
} catch {
|
||||
continue; // corrupted/unreadable file — skip rather than fail the whole list
|
||||
}
|
||||
// No `user` means an incomplete login (mid-OIDC-flow `pendingAuth` only) or an expired session store.get() already nulled out.
|
||||
if (!sess?.user) continue;
|
||||
const lastAccess = (sess as unknown as { __lastAccess?: number }).__lastAccess;
|
||||
results.push({
|
||||
id,
|
||||
sub: sess.user.sub,
|
||||
email: sess.user.email ?? null,
|
||||
name: sess.user.name ?? null,
|
||||
role: roleBySub.get(sess.user.sub) ?? null,
|
||||
ip: sess.user.ip ?? null,
|
||||
userAgent: sess.user.userAgent ?? null,
|
||||
lastAccess: lastAccess ? new Date(lastAccess).toISOString() : new Date(0).toISOString(),
|
||||
expiresAt: sess.cookie?.expires ? new Date(sess.cookie.expires).toISOString() : null,
|
||||
});
|
||||
}
|
||||
return results.sort((a, b) => b.lastAccess.localeCompare(a.lastAccess));
|
||||
}
|
||||
Reference in new issue
Block a user