import { createHash, randomBytes } from 'node:crypto'; import { and, eq, gt, lte, sql } from 'drizzle-orm'; import { accounts, recoveryLinks, users } from '~~/drizzle/schema'; import { db } from '~~/server/lib/db'; export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000; // a new person may take a while to get round to it export const SETUP_LINK_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; /** * An account an admin created has no password until its owner sets one with * a setup link. Until then nobody can sign in to it. */ export const hasPassword = async (userId: string): Promise => { const rows = await db .select({ id: accounts.id }) .from(accounts) .where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential'))) .limit(1); return rows.length > 0; }; // scripts/recover.ts makes links the same way, keep the two in step export const hashRecoveryToken = (token: string) => createHash('sha256').update(token).digest('hex'); /** * Makes a one-time link a user can set a password with and returns its token: * a recovery link for someone who forgot theirs, a setup link for an account * that never had one. Only one link works at a time, making a new one cancels * the ones before it. */ export const createRecoveryLink = async (userId: string, resetSecurity: boolean) => { const token = randomBytes(32).toString('base64url'); const lifetime = await hasPassword(userId) ? RECOVERY_LINK_LIFETIME_MS : SETUP_LINK_LIFETIME_MS; const expiresAt = new Date(Date.now() + lifetime); await db.transaction(async (tx) => { await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId)); await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date())); await tx.insert(recoveryLinks).values({ tokenHash: hashRecoveryToken(token), userId, resetSecurity, expiresAt, }); }); return { token, expiresAt }; }; export const findRecoveryLink = async (token: string) => { const rows = await db .select({ id: recoveryLinks.id, userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity, expiresAt: recoveryLinks.expiresAt, email: users.email, name: users.name, twoFactorEnabled: users.twoFactorEnabled, }) .from(recoveryLinks) .innerJoin(users, eq(users.id, recoveryLinks.userId)) .where(and(eq(recoveryLinks.tokenHash, hashRecoveryToken(token)), gt(recoveryLinks.expiresAt, new Date()))) .limit(1); return rows[0] ?? null; };