import * as jose from 'jose'; import * as argon2 from 'argon2'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { Context } from 'hono'; import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js'; import { eq, and, gt } from 'drizzle-orm'; import { generateId, getNow, toDbDate } from './utils.js'; const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production'); const JWT_ISSUER = 'spanglish'; const JWT_AUDIENCE = 'spanglish-app'; export interface JWTPayload { sub: string; email: string; role: string; iat: number; exp: number; } // Password hashing with Argon2 (spec requirement) export async function hashPassword(password: string): Promise { return argon2.hash(password, { type: argon2.argon2id, memoryCost: 65536, // 64 MB timeCost: 3, parallelism: 4, }); } export async function verifyPassword(password: string, hash: string): Promise { // Support both bcrypt (legacy) and argon2 hashes for migration if (hash.startsWith('$argon2')) { return argon2.verify(hash, password); } // Legacy bcrypt support return bcrypt.compare(password, hash); } // Generate secure random token for magic links export function generateSecureToken(): string { return crypto.randomBytes(32).toString('hex'); } // Create magic link token export async function createMagicLinkToken( userId: string, type: 'login' | 'reset_password' | 'claim_account' | 'email_verification', expiresInMinutes: number = 10 ): Promise { const token = generateSecureToken(); const now = getNow(); const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000)); await (db as any).insert(magicLinkTokens).values({ id: generateId(), userId, token, type, expiresAt, createdAt: now, }); return token; } // Verify and consume magic link token export async function verifyMagicLinkToken( token: string, type: 'login' | 'reset_password' | 'claim_account' | 'email_verification' ): Promise<{ valid: boolean; userId?: string; error?: string }> { const now = getNow(); const tokenRecord = await dbGet( (db as any) .select() .from(magicLinkTokens) .where( and( eq((magicLinkTokens as any).token, token), eq((magicLinkTokens as any).type, type) ) ) ); if (!tokenRecord) { return { valid: false, error: 'Invalid token' }; } if (tokenRecord.usedAt) { return { valid: false, error: 'Token already used' }; } if (new Date(tokenRecord.expiresAt) < new Date()) { return { valid: false, error: 'Token expired' }; } // Mark token as used await (db as any) .update(magicLinkTokens) .set({ usedAt: now }) .where(eq((magicLinkTokens as any).id, tokenRecord.id)); return { valid: true, userId: tokenRecord.userId }; } // Create user session export async function createUserSession( userId: string, userAgent?: string, ipAddress?: string ): Promise { const sessionToken = generateSecureToken(); const now = getNow(); const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days await (db as any).insert(userSessions).values({ id: generateId(), userId, token: sessionToken, userAgent: userAgent || null, ipAddress: ipAddress || null, lastActiveAt: now, expiresAt, createdAt: now, }); return sessionToken; } // Get user's active sessions export async function getUserSessions(userId: string) { const now = getNow(); return dbAll( (db as any) .select() .from(userSessions) .where( and( eq((userSessions as any).userId, userId), gt((userSessions as any).expiresAt, now) ) ) ); } // Invalidate a specific session export async function invalidateSession(sessionId: string, userId: string): Promise { const result = await (db as any) .delete(userSessions) .where( and( eq((userSessions as any).id, sessionId), eq((userSessions as any).userId, userId) ) ); return true; } // Invalidate all user sessions (logout everywhere) export async function invalidateAllUserSessions(userId: string): Promise { await (db as any) .delete(userSessions) .where(eq((userSessions as any).userId, userId)); } // Password validation (min 10 characters per spec) export function validatePassword(password: string): { valid: boolean; error?: string } { if (password.length < 10) { return { valid: false, error: 'Password must be at least 10 characters long' }; } return { valid: true }; } export async function createToken(userId: string, email: string, role: string): Promise { const token = await new jose.SignJWT({ sub: userId, email, role }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) .setExpirationTime('7d') .sign(JWT_SECRET); return token; } export async function createRefreshToken(userId: string): Promise { const token = await new jose.SignJWT({ sub: userId, type: 'refresh' }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setIssuer(JWT_ISSUER) .setExpirationTime('30d') .sign(JWT_SECRET); return token; } export async function verifyToken(token: string): Promise { try { const { payload } = await jose.jwtVerify(token, JWT_SECRET, { issuer: JWT_ISSUER, audience: JWT_AUDIENCE, }); return payload as unknown as JWTPayload; } catch { return null; } } export async function getAuthUser(c: Context): Promise { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return null; } const token = authHeader.slice(7); const payload = await verifyToken(token); if (!payload) { return null; } const user = await dbGet( (db as any).select().from(users).where(eq((users as any).id, payload.sub)) ); return user || null; } export function requireAuth(roles?: string[]) { return async (c: Context, next: () => Promise) => { const user = await getAuthUser(c); if (!user) { return c.json({ error: 'Unauthorized' }, 401); } if (roles && !roles.includes(user.role)) { return c.json({ error: 'Forbidden' }, 403); } c.set('user', user); await next(); }; } export async function isFirstUser(): Promise { const result = await dbAll( (db as any).select().from(users).limit(1) ); return !result || result.length === 0; }