feat(auth): token service with single-use, hashed storage

This commit is contained in:
2026-05-20 22:47:02 +02:00
parent 0e6bc8c640
commit 04fbe6e9c3
3 changed files with 148 additions and 0 deletions

View File

@@ -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<typeof makeTestDb>;
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');
});
});

View File

@@ -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<CreatedToken> {
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<void> {
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();
}

View File

@@ -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<UserRow> {
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!;
}