feat(auth): token service with single-use, hashed storage
This commit is contained in:
49
packages/backend/src/services/auth/tokens.test.ts
Normal file
49
packages/backend/src/services/auth/tokens.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
82
packages/backend/src/services/auth/tokens.ts
Normal file
82
packages/backend/src/services/auth/tokens.ts
Normal 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();
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user