diff --git a/packages/backend/src/services/auth/sessions.test.ts b/packages/backend/src/services/auth/sessions.test.ts new file mode 100644 index 0000000..0954870 --- /dev/null +++ b/packages/backend/src/services/auth/sessions.test.ts @@ -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; +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); + }); +}); diff --git a/packages/backend/src/services/auth/sessions.ts b/packages/backend/src/services/auth/sessions.ts new file mode 100644 index 0000000..f51be6f --- /dev/null +++ b/packages/backend/src/services/auth/sessions.ts @@ -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 { + 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 { + db.delete(sessionsAuth).where(eq(sessionsAuth.id, id)).run(); +} + +export async function invalidateAllForUser( + db: Db, + userId: number, + opts: { except?: string } = {} +): Promise { + 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 { + const now = nowSec(); + db.delete(sessionsAuth).where(sql`${sessionsAuth.expiresAt} <= ${now}`).run(); +}