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(); }