From 04fbe6e9c3749b9506c03728e5ba73bd785792bd Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 22:47:02 +0200 Subject: [PATCH] feat(auth): token service with single-use, hashed storage --- .../backend/src/services/auth/tokens.test.ts | 49 +++++++++++ packages/backend/src/services/auth/tokens.ts | 82 +++++++++++++++++++ packages/backend/src/tests/dbHelper.ts | 17 ++++ 3 files changed, 148 insertions(+) create mode 100644 packages/backend/src/services/auth/tokens.test.ts create mode 100644 packages/backend/src/services/auth/tokens.ts diff --git a/packages/backend/src/services/auth/tokens.test.ts b/packages/backend/src/services/auth/tokens.test.ts new file mode 100644 index 0000000..38017da --- /dev/null +++ b/packages/backend/src/services/auth/tokens.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeTestDb, createUserDirect } from '../../tests/dbHelper.js'; +import { generateToken, createAuthToken, consumeAuthToken, findValidAuthToken } from './tokens.js'; + +let env: ReturnType; +beforeEach(() => { env = makeTestDb(); }); + +describe('tokens', () => { + it('generates a URL-safe random token of the expected length', () => { + const t = generateToken(); + expect(t).toMatch(/^[A-Za-z0-9_-]+$/); + expect(t.length).toBeGreaterThanOrEqual(32); + }); + + it('stores a hashed token and finds it by plaintext', async () => { + const user = await createUserDirect(env.db, { email: 'a@example.com' }); + const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600); + const found = await findValidAuthToken(env.db, plaintext, 'verify_email'); + expect(found?.userId).toBe(user.id); + }); + + it('rejects an expired token', async () => { + const user = await createUserDirect(env.db, { email: 'b@example.com' }); + const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', -1); + expect(await findValidAuthToken(env.db, plaintext, 'verify_email')).toBeNull(); + }); + + it('rejects a wrong purpose', async () => { + const user = await createUserDirect(env.db, { email: 'c@example.com' }); + const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600); + expect(await findValidAuthToken(env.db, plaintext, 'password_reset')).toBeNull(); + }); + + it('consumes a token once', async () => { + const user = await createUserDirect(env.db, { email: 'd@example.com' }); + const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600); + const first = await consumeAuthToken(env.db, plaintext, 'verify_email'); + expect(first?.userId).toBe(user.id); + const second = await consumeAuthToken(env.db, plaintext, 'verify_email'); + expect(second).toBeNull(); + }); + + it('stores and returns the payload', async () => { + const user = await createUserDirect(env.db, { email: 'e@example.com' }); + const { plaintext } = await createAuthToken(env.db, user.id, 'change_email', 3600, 'new@example.com'); + const found = await findValidAuthToken(env.db, plaintext, 'change_email'); + expect(found?.payload).toBe('new@example.com'); + }); +}); diff --git a/packages/backend/src/services/auth/tokens.ts b/packages/backend/src/services/auth/tokens.ts new file mode 100644 index 0000000..576c7e4 --- /dev/null +++ b/packages/backend/src/services/auth/tokens.ts @@ -0,0 +1,82 @@ +import { randomBytes, createHash } from 'node:crypto'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import type { Db } from '../../db/client.js'; +import { authTokens } from '../../db/schema.js'; + +const TOKEN_BYTES = 32; +export type Purpose = 'verify_email' | 'password_reset' | 'invite' | 'change_email'; + +export function generateToken(): string { + return randomBytes(TOKEN_BYTES).toString('base64url'); +} + +function hashToken(plaintext: string): string { + return createHash('sha256').update(plaintext).digest('hex'); +} + +export interface CreatedToken { + plaintext: string; + hash: string; + expiresAt: number; +} + +export async function createAuthToken( + db: Db, + userId: number, + purpose: Purpose, + ttlSeconds: number, + payload?: string | null +): Promise { + const plaintext = generateToken(); + const hash = hashToken(plaintext); + const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds; + db.insert(authTokens).values({ + userId, + tokenHash: hash, + purpose, + payload: payload ?? null, + expiresAt, + }).run(); + return { plaintext, hash, expiresAt }; +} + +export async function findValidAuthToken( + db: Db, + plaintext: string, + purpose: Purpose +): Promise<{ id: number; userId: number; payload: string | null } | null> { + const now = Math.floor(Date.now() / 1000); + const hash = hashToken(plaintext); + const row = db.select().from(authTokens).where( + and( + eq(authTokens.tokenHash, hash), + eq(authTokens.purpose, purpose), + isNull(authTokens.usedAt), + sql`${authTokens.expiresAt} > ${now}` + ) + ).get(); + if (!row) return null; + return { id: row.id, userId: row.userId, payload: row.payload ?? null }; +} + +export async function consumeAuthToken( + db: Db, + plaintext: string, + purpose: Purpose +): Promise<{ userId: number; payload: string | null } | null> { + const now = Math.floor(Date.now() / 1000); + const row = await findValidAuthToken(db, plaintext, purpose); + if (!row) return null; + const r = db.update(authTokens).set({ usedAt: now }) + .where(and(eq(authTokens.id, row.id), isNull(authTokens.usedAt))) + .run(); + if (r.changes === 0) return null; + return { userId: row.userId, payload: row.payload }; +} + +export async function invalidateTokensForUser(db: Db, userId: number, purpose: Purpose): Promise { + const now = Math.floor(Date.now() / 1000); + db.update(authTokens).set({ usedAt: now }) + .where(and(eq(authTokens.userId, userId), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt))) + .run(); +} diff --git a/packages/backend/src/tests/dbHelper.ts b/packages/backend/src/tests/dbHelper.ts index cfbef9d..d3c4f28 100644 --- a/packages/backend/src/tests/dbHelper.ts +++ b/packages/backend/src/tests/dbHelper.ts @@ -1,9 +1,26 @@ import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { resolve } from 'node:path'; import { createDb, type Db } from '../db/client.js'; +import { users } from '../db/schema.js'; +import type { UserRow } from '../db/schema.js'; export function makeTestDb(): { db: Db; close: () => void } { const { db, sqlite } = createDb(':memory:'); migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') }); return { db, close: () => sqlite.close() }; } + +export async function createUserDirect( + db: Db, + init: { email: string; displayName?: string; role?: 'user' | 'sysadmin'; passwordHash?: string | null; emailVerifiedAt?: number | null; isActive?: boolean } +): Promise { + const [row] = db.insert(users).values({ + email: init.email, + displayName: init.displayName ?? 'Test User', + role: init.role ?? 'user', + passwordHash: init.passwordHash ?? null, + emailVerifiedAt: init.emailVerifiedAt ?? null, + isActive: init.isActive ?? true, + }).returning().all(); + return row!; +}