generated from nholtware/service-template-express
99 lines
3 KiB
TypeScript
99 lines
3 KiB
TypeScript
import session from "express-session";
|
|
import type { Db } from "./db.js";
|
|
|
|
/** Postgres-backed sessions so every pod sees the same login. */
|
|
export class PgSessionStore extends session.Store {
|
|
private readonly timer: NodeJS.Timeout;
|
|
|
|
constructor(
|
|
private readonly db: Db,
|
|
private readonly ttlMs: number,
|
|
) {
|
|
super();
|
|
// Expired rows are ignored on read; sweep them occasionally.
|
|
this.timer = setInterval(() => {
|
|
void this.db
|
|
.query("DELETE FROM sessions WHERE expire < now()")
|
|
.catch(() => {});
|
|
}, 10 * 60_000);
|
|
this.timer.unref();
|
|
}
|
|
|
|
private expiry(sess: session.SessionData): Date {
|
|
const exp = sess.cookie?.expires;
|
|
return exp ? new Date(exp) : new Date(Date.now() + this.ttlMs);
|
|
}
|
|
|
|
get(
|
|
sid: string,
|
|
cb: (err?: unknown, sess?: session.SessionData | null) => void,
|
|
) {
|
|
this.db
|
|
.query<{ sess: session.SessionData }>(
|
|
"SELECT sess FROM sessions WHERE sid = $1 AND expire > now()",
|
|
[sid],
|
|
)
|
|
.then((r) => cb(null, r.rows[0]?.sess ?? null), cb);
|
|
}
|
|
|
|
set(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) {
|
|
this.db
|
|
.query(
|
|
`INSERT INTO sessions (sid, sess, expire) VALUES ($1, $2, $3)
|
|
ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire`,
|
|
[sid, JSON.stringify(sess), this.expiry(sess)],
|
|
)
|
|
.then(
|
|
() => cb?.(),
|
|
(e) => cb?.(e),
|
|
);
|
|
}
|
|
|
|
touch(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) {
|
|
this.db
|
|
.query("UPDATE sessions SET expire = $2 WHERE sid = $1", [
|
|
sid,
|
|
this.expiry(sess),
|
|
])
|
|
.then(
|
|
() => cb?.(),
|
|
(e) => cb?.(e),
|
|
);
|
|
}
|
|
|
|
/** Sign a user out everywhere except `exceptSid` (e.g. after a password change). */
|
|
async destroyForUser(userId: string, exceptSid: string): Promise<void> {
|
|
await this.db.query(
|
|
"DELETE FROM sessions WHERE sess->'passport'->>'user' = $1 AND sid <> $2",
|
|
[userId, exceptSid],
|
|
);
|
|
}
|
|
|
|
destroy(sid: string, cb?: (err?: unknown) => void) {
|
|
this.db.query("DELETE FROM sessions WHERE sid = $1", [sid]).then(
|
|
() => cb?.(),
|
|
(e) => cb?.(e),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** In-memory sessions (tests / no database), with the same revoke hook. */
|
|
export class MemorySessionStore extends session.MemoryStore {
|
|
async destroyForUser(userId: string, exceptSid: string): Promise<void> {
|
|
const all = await new Promise<Record<string, session.SessionData>>(
|
|
(resolve, reject) =>
|
|
this.all((err, sessions) =>
|
|
err ? reject(err) : resolve(sessions ?? {}),
|
|
),
|
|
);
|
|
for (const [sid, sess] of Object.entries(all)) {
|
|
const passport = (sess as { passport?: { user?: string } }).passport;
|
|
if (sid !== exceptSid && passport?.user === userId) {
|
|
await new Promise<void>((r) => this.destroy(sid, () => r()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export type RevocableStore = (PgSessionStore | MemorySessionStore) &
|
|
session.Store;
|