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:
bobbanandClaude Sonnet 5 committed 2026-09-21 20:29:29 +02:00
1 parent 1cae35a59e
commit 6d673db9ec
9 files changed
+318 -1

No files matched your search

+8 -1
View File
@@ -66,7 +66,14 @@ authRouter.get("/callback", async (req, res, next) => {
await upsertUserFromLogin({ sub: claims.sub, email, name });
delete req.session.pendingAuth;
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
req.session.user = {
sub: claims.sub,
email,
name,
idToken: tokens.id_token,
ip: req.ip,
userAgent: req.headers["user-agent"],
};
req.session.save((err) => {
if (err) return next(err);
res.redirect("/");
+2
View File
@@ -22,6 +22,7 @@ import { agentReportRouter } from "./routes/agentReport.js";
import { integrationsRouter } from "./routes/integrations.js";
import { settingsRouter } from "./routes/settings.js";
import { searchRouter } from "./routes/search.js";
import { sessionsRouter } from "./routes/sessions.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
@@ -82,6 +83,7 @@ app.use("/api/agent/report", agentReportRouter);
app.use("/api/integrations", integrationsRouter);
app.use("/api/settings", settingsRouter);
app.use("/api/search", searchRouter);
app.use("/api/sessions", sessionsRouter);
if (existsSync(webDist)) {
app.use(express.static(webDist));
+26
View File
@@ -0,0 +1,26 @@
import { Router } from "express";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { listSessions, destroySession } from "../services/sessionStore.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const sessionsRouter = Router();
sessionsRouter.use(requireAuth, requireRole("admin"));
sessionsRouter.get("/", asyncHandler(async (req, res) => {
const sessions = await listSessions();
res.json({ sessions, currentSessionId: req.sessionID });
}));
sessionsRouter.delete("/:id", asyncHandler(async (req, res) => {
await destroySession(req.params.id);
await recordAudit({
actor: req.currentUser!,
category: "session",
action: "revoke",
targetType: "session",
targetId: req.params.id,
});
res.status(204).end();
}));
+84
View File
@@ -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));
}
+2
View File
@@ -7,6 +7,8 @@ declare module "express-session" {
email?: string;
name?: string;
idToken?: string;
ip?: string;
userAgent?: string;
};
pendingAuth?: {
codeVerifier: string;