import crypto from 'node:crypto'; import { type Database, and, createDb, desc, eq, gt, magicLinks, memberships, organizations, sessions, smsCodes, users, } from '@bmm/db'; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const SCRYPT_KEYLEN = 64; const SCRYPT_N = 16_384; function sha256(input: string): string { return crypto.createHash('sha256').update(input).digest('hex'); } function randomToken(bytes = 32): string { return crypto.randomBytes(bytes).toString('base64url'); } export function hashPassword(password: string): string { const salt = crypto.randomBytes(16).toString('hex'); const derived = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N }).toString('hex'); return `scrypt$${SCRYPT_N}$${salt}$${derived}`; } export function verifyPassword(password: string, stored: string): boolean { const parts = stored.split('$'); if (parts.length !== 4 || parts[0] !== 'scrypt') return false; const N = Number.parseInt(parts[1] ?? '', 10); const salt = parts[2]; const expectedHex = parts[3]; if (!Number.isFinite(N) || !salt || !expectedHex) return false; const expected = Buffer.from(expectedHex, 'hex'); const actual = crypto.scryptSync(password, salt, expected.length, { N }); if (actual.length !== expected.length) return false; return crypto.timingSafeEqual(actual, expected); } function slugify(input: string): string { return ( input .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') .slice(0, 48) || 'org' ); } export interface MagicLinkIssued { token: string; expiresAt: Date; } export async function issueMagicLink( email: string, db: Database = createDb(), ): Promise { const lower = email.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) { throw new Error('invalid_email'); } const token = randomToken(32); const tokenHash = sha256(token); const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS); await db.insert(magicLinks).values({ email: lower, tokenHash, expiresAt }); return { token, expiresAt }; } export interface ConsumedSession { sessionToken: string; userId: string; orgId: string; email: string | null; } export async function consumeMagicLink( token: string, meta: { ipAddress?: string; userAgent?: string } = {}, db: Database = createDb(), ): Promise { const tokenHash = sha256(token); const [row] = await db .select() .from(magicLinks) .where(and(eq(magicLinks.tokenHash, tokenHash), gt(magicLinks.expiresAt, new Date()))) .limit(1); if (!row || row.consumedAt) { throw new Error('invalid_or_expired_token'); } await db.update(magicLinks).set({ consumedAt: new Date() }).where(eq(magicLinks.id, row.id)); // Get or create user + default org let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0]; if (!user) { [user] = await db.insert(users).values({ email: row.email, emailVerified: true }).returning(); const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`; const [org] = await db .insert(organizations) .values({ slug: orgSlug, name: `${row.email.split('@')[0]}'s workspace` }) .returning(); if (!org) throw new Error('org_create_failed'); await db.insert(memberships).values({ orgId: org.id, userId: user!.id, role: 'owner' }); } else if (!user.emailVerified) { await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id)); } if (!user) throw new Error('user_resolve_failed'); const [membership] = await db .select() .from(memberships) .where(eq(memberships.userId, user.id)) .limit(1); if (!membership) throw new Error('no_org_membership'); const sessionToken = randomToken(32); const sessionHash = sha256(sessionToken); await db.insert(sessions).values({ userId: user.id, tokenHash: sessionHash, expiresAt: new Date(Date.now() + SESSION_TTL_MS), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email }; } /** * Get-or-create a user from a verified third-party identity (Google, etc.) and * mint a session. The caller is responsible for verifying the identity provider's * token BEFORE calling this — `email` must already be proven to belong to the user. */ export async function upsertOAuthLogin( input: { email: string; name?: string | null }, meta: { ipAddress?: string; userAgent?: string } = {}, db: Database = createDb(), ): Promise { const email = input.email.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { throw new Error('invalid_email'); } let user = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0]; if (!user) { [user] = await db .insert(users) .values({ email, emailVerified: true, name: input.name ?? undefined }) .returning(); if (!user) throw new Error('user_create_failed'); const orgSlug = `${slugify(email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`; const [org] = await db .insert(organizations) .values({ slug: orgSlug, name: `${email.split('@')[0]}'s workspace` }) .returning(); if (!org) throw new Error('org_create_failed'); await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' }); } else if (!user.emailVerified) { await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id)); } const resolved = user; const [membership] = await db .select() .from(memberships) .where(eq(memberships.userId, resolved.id)) .limit(1); if (!membership) throw new Error('no_org_membership'); const sessionToken = randomToken(32); await db.insert(sessions).values({ userId: resolved.id, tokenHash: sha256(sessionToken), expiresAt: new Date(Date.now() + SESSION_TTL_MS), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id)); return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email }; } // ---- SMS one-time-code login ---- const SMS_CODE_TTL_MS = 10 * 60 * 1000; // 10 min const SMS_RATE_WINDOW_MS = 15 * 60 * 1000; const SMS_MAX_PER_WINDOW = 3; // codes issued per phone per window const SMS_MAX_ATTEMPTS = 5; // wrong-code guesses per code /** Normalise to strict E.164 (+ and 8-15 digits). Throws on anything else. */ function normalizePhone(raw: string): string { const p = raw.replace(/[\s\-().]/g, ''); if (!/^\+[1-9]\d{7,14}$/.test(p)) throw new Error('invalid_phone'); return p; } export interface SmsCodeIssued { phone: string; code: string; expiresAt: Date; } /** Generate a 6-digit code for a phone number, store it hashed, rate-limited. */ export async function issueSmsCode( phoneRaw: string, db: Database = createDb(), ): Promise { const phone = normalizePhone(phoneRaw); const since = new Date(Date.now() - SMS_RATE_WINDOW_MS); const recent = await db .select({ id: smsCodes.id }) .from(smsCodes) .where(and(eq(smsCodes.phone, phone), gt(smsCodes.createdAt, since))); if (recent.length >= SMS_MAX_PER_WINDOW) throw new Error('rate_limited'); const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, '0'); const expiresAt = new Date(Date.now() + SMS_CODE_TTL_MS); await db.insert(smsCodes).values({ phone, codeHash: sha256(`${phone}:${code}`), expiresAt }); return { phone, code, expiresAt }; } /** Verify a code, then get-or-create the phone's user and mint a session. */ export async function consumeSmsCode( phoneRaw: string, code: string, meta: { ipAddress?: string; userAgent?: string } = {}, db: Database = createDb(), ): Promise { const phone = normalizePhone(phoneRaw); const [row] = await db .select() .from(smsCodes) .where(and(eq(smsCodes.phone, phone), gt(smsCodes.expiresAt, new Date()))) .orderBy(desc(smsCodes.createdAt)) .limit(1); if (!row || row.consumedAt) throw new Error('invalid_or_expired_code'); if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts'); if (sha256(`${phone}:${code}`) !== row.codeHash) { await db .update(smsCodes) .set({ attempts: row.attempts + 1 }) .where(eq(smsCodes.id, row.id)); throw new Error('invalid_code'); } await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id)); let user = (await db.select().from(users).where(eq(users.phone, phone)).limit(1))[0]; if (!user) { [user] = await db.insert(users).values({ phone }).returning(); if (!user) throw new Error('user_create_failed'); const orgSlug = `phone-${randomToken(4).toLowerCase()}`; const [org] = await db .insert(organizations) .values({ slug: orgSlug, name: 'My workspace' }) .returning(); if (!org) throw new Error('org_create_failed'); await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' }); } const resolved = user; const [membership] = await db .select() .from(memberships) .where(eq(memberships.userId, resolved.id)) .limit(1); if (!membership) throw new Error('no_org_membership'); const sessionToken = randomToken(32); await db.insert(sessions).values({ userId: resolved.id, tokenHash: sha256(sessionToken), expiresAt: new Date(Date.now() + SESSION_TTL_MS), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id)); return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email }; } export interface AuthedUser { userId: string; orgId: string; email: string | null; phone: string | null; role: string; isAdmin: boolean; } export async function getSession( sessionToken: string | null | undefined, db: Database = createDb(), ): Promise { if (!sessionToken) return null; const hash = sha256(sessionToken); const [row] = await db .select({ userId: sessions.userId, expiresAt: sessions.expiresAt, email: users.email, phone: users.phone, isAdmin: users.isAdmin, }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(eq(sessions.tokenHash, hash)) .limit(1); if (!row || row.expiresAt < new Date()) return null; const [membership] = await db .select({ orgId: memberships.orgId, role: memberships.role }) .from(memberships) .where(eq(memberships.userId, row.userId)) .limit(1); if (!membership) return null; return { userId: row.userId, orgId: membership.orgId, email: row.email, phone: row.phone, role: membership.role, isAdmin: row.isAdmin, }; } export async function destroySession( sessionToken: string, db: Database = createDb(), ): Promise { const hash = sha256(sessionToken); await db.delete(sessions).where(eq(sessions.tokenHash, hash)); } export interface PasswordLoginResult { sessionToken: string; userId: string; orgId: string; email: string | null; isAdmin: boolean; } export async function loginWithPassword( emailRaw: string, password: string, meta: { ipAddress?: string; userAgent?: string } = {}, db: Database = createDb(), ): Promise { const email = emailRaw.trim().toLowerCase(); const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1); if (!user || !user.passwordHash) { throw new Error('invalid_credentials'); } if (!verifyPassword(password, user.passwordHash)) { throw new Error('invalid_credentials'); } const [membership] = await db .select() .from(memberships) .where(eq(memberships.userId, user.id)) .limit(1); if (!membership) throw new Error('no_org_membership'); const sessionToken = randomToken(32); await db.insert(sessions).values({ userId: user.id, tokenHash: sha256(sessionToken), expiresAt: new Date(Date.now() + SESSION_TTL_MS), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id)); return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email, isAdmin: user.isAdmin, }; } export interface SeedAdminInput { email: string; password: string; name?: string; } export async function seedAdmin( input: SeedAdminInput, db: Database = createDb(), ): Promise<{ created: boolean; userId: string; orgId: string }> { const email = input.email.trim().toLowerCase(); const existing = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0]; if (existing) { const updates: Partial = {}; if (!existing.isAdmin) updates.isAdmin = true; if (!existing.passwordHash) updates.passwordHash = hashPassword(input.password); if (!existing.emailVerified) updates.emailVerified = true; if (input.name && !existing.name) updates.name = input.name; if (Object.keys(updates).length > 0) { await db.update(users).set(updates).where(eq(users.id, existing.id)); } const [m] = await db .select() .from(memberships) .where(eq(memberships.userId, existing.id)) .limit(1); if (!m) { // Has a user but no org — bootstrap one const orgSlug = `admin-${randomToken(3).toLowerCase()}`; const [org] = await db .insert(organizations) .values({ slug: orgSlug, name: `${input.name ?? 'Admin'} workspace`, plan: 'enterprise' }) .returning(); if (!org) throw new Error('org_create_failed'); await db.insert(memberships).values({ orgId: org.id, userId: existing.id, role: 'owner' }); return { created: false, userId: existing.id, orgId: org.id }; } return { created: false, userId: existing.id, orgId: m.orgId }; } const [user] = await db .insert(users) .values({ email, emailVerified: true, isAdmin: true, name: input.name, passwordHash: hashPassword(input.password), }) .returning(); if (!user) throw new Error('user_create_failed'); const orgSlug = `admin-${randomToken(3).toLowerCase()}`; const [org] = await db .insert(organizations) .values({ slug: orgSlug, name: `${input.name ?? 'Admin'} workspace`, plan: 'enterprise' }) .returning(); if (!org) throw new Error('org_create_failed'); await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' }); return { created: true, userId: user.id, orgId: org.id }; } export const __test = { sha256, randomToken, slugify };