feat(auth): server-side auth sessions with rolling expiry

This commit is contained in:
2026-05-20 22:48:39 +02:00
parent 04fbe6e9c3
commit 4ef3eaae52
2 changed files with 121 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb, createUserDirect } from '../../tests/dbHelper.js';
import { createAuthSession, validateAuthSession, invalidateAuthSession, invalidateAllForUser } from './sessions.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('auth sessions', () => {
it('creates a session and validates it', async () => {
const u = await createUserDirect(env.db, { email: 'a@example.com' });
const s = await createAuthSession(env.db, u.id, { userAgent: 'jest', ip: '127.0.0.1' });
const v = await validateAuthSession(env.db, s.id);
expect(v?.userId).toBe(u.id);
});
it('rejects an unknown session id', async () => {
expect(await validateAuthSession(env.db, 'nope')).toBeNull();
});
it('rejects an expired session', async () => {
const u = await createUserDirect(env.db, { email: 'b@example.com' });
const s = await createAuthSession(env.db, u.id, { ttlSeconds: -1 });
expect(await validateAuthSession(env.db, s.id)).toBeNull();
});
it('invalidates one session', async () => {
const u = await createUserDirect(env.db, { email: 'c@example.com' });
const s = await createAuthSession(env.db, u.id);
await invalidateAuthSession(env.db, s.id);
expect(await validateAuthSession(env.db, s.id)).toBeNull();
});
it('invalidates all sessions for a user', async () => {
const u = await createUserDirect(env.db, { email: 'd@example.com' });
const a = await createAuthSession(env.db, u.id);
const b = await createAuthSession(env.db, u.id);
await invalidateAllForUser(env.db, u.id);
expect(await validateAuthSession(env.db, a.id)).toBeNull();
expect(await validateAuthSession(env.db, b.id)).toBeNull();
});
it('supports excluding one session when invalidating (keep current for change-password)', async () => {
const u = await createUserDirect(env.db, { email: 'e@example.com' });
const a = await createAuthSession(env.db, u.id);
const b = await createAuthSession(env.db, u.id);
await invalidateAllForUser(env.db, u.id, { except: b.id });
expect(await validateAuthSession(env.db, a.id)).toBeNull();
const v = await validateAuthSession(env.db, b.id);
expect(v?.userId).toBe(u.id);
});
});

View File

@@ -0,0 +1,70 @@
import { randomBytes } from 'node:crypto';
import { and, eq, ne, sql } from 'drizzle-orm';
import type { Db } from '../../db/client.js';
import { sessionsAuth } from '../../db/schema.js';
const DEFAULT_TTL = 30 * 24 * 60 * 60; // 30 days
const REFRESH_THRESHOLD = 24 * 60 * 60; // refresh expires_at when within 1 day of expiry
function nowSec() { return Math.floor(Date.now() / 1000); }
function genId(): string { return randomBytes(32).toString('hex'); }
export interface CreatedSession {
id: string;
expiresAt: number;
}
export async function createAuthSession(
db: Db,
userId: number,
opts: { ttlSeconds?: number; userAgent?: string | null; ip?: string | null } = {}
): Promise<CreatedSession> {
const id = genId();
const ttl = opts.ttlSeconds ?? DEFAULT_TTL;
const expiresAt = nowSec() + ttl;
db.insert(sessionsAuth).values({
id, userId, expiresAt, lastUsedAt: nowSec(),
userAgent: opts.userAgent ?? null, ip: opts.ip ?? null,
}).run();
return { id, expiresAt };
}
export async function validateAuthSession(
db: Db,
id: string
): Promise<{ userId: number; expiresAt: number } | null> {
const row = db.select().from(sessionsAuth).where(eq(sessionsAuth.id, id)).get();
if (!row) return null;
const now = nowSec();
if (row.expiresAt <= now) return null;
if (row.expiresAt - now < REFRESH_THRESHOLD) {
db.update(sessionsAuth)
.set({ expiresAt: now + DEFAULT_TTL, lastUsedAt: now })
.where(eq(sessionsAuth.id, id))
.run();
} else {
db.update(sessionsAuth).set({ lastUsedAt: now }).where(eq(sessionsAuth.id, id)).run();
}
return { userId: row.userId, expiresAt: row.expiresAt };
}
export async function invalidateAuthSession(db: Db, id: string): Promise<void> {
db.delete(sessionsAuth).where(eq(sessionsAuth.id, id)).run();
}
export async function invalidateAllForUser(
db: Db,
userId: number,
opts: { except?: string } = {}
): Promise<void> {
if (opts.except) {
db.delete(sessionsAuth).where(and(eq(sessionsAuth.userId, userId), ne(sessionsAuth.id, opts.except))).run();
} else {
db.delete(sessionsAuth).where(eq(sessionsAuth.userId, userId)).run();
}
}
export async function purgeExpiredSessions(db: Db): Promise<void> {
const now = nowSec();
db.delete(sessionsAuth).where(sql`${sessionsAuth.expiresAt} <= ${now}`).run();
}