/** * AuthService — authentication logic. * * JWT: hand-rolled HMAC-SHA256 (no external lib) using Node's built-in crypto. * Passwords: scrypt KDF with random salt (Node crypto, OWASP-recommended). */ import { createHmac, randomBytes, scryptSync, timingSafeEqual, randomUUID } from 'crypto'; import type { UserStore } from './UserStore.js'; import type { AuthResponse, Role, TokenPayload, User } from './auth.model.js'; // ── JWT helpers ─────────────────────────────────────────────────────────────── function b64url(input: string | Buffer): string { const buf = typeof input === 'string' ? Buffer.from(input) : input; return buf.toString('base64url'); } function signJwt(payload: TokenPayload, secret: string, expiresInSec = 60 * 60 * 8): string { const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const now = Math.floor(Date.now() / 1000); const body = b64url(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSec })); const sig = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest()); return `${header}.${body}.${sig}`; } export function verifyJwt(token: string, secret: string): TokenPayload { const parts = token.split('.'); if (parts.length !== 3) throw new Error('Invalid token format'); const [header, body, sig] = parts; const expected = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest()); if (sig !== expected) throw new Error('Invalid token signature'); const payload: TokenPayload = JSON.parse(Buffer.from(body, 'base64url').toString()); if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired'); return payload; } // ── Password helpers ────────────────────────────────────────────────────────── const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 }; function hashPassword(plain: string): string { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, { N: SCRYPT_PARAMS.N, r: SCRYPT_PARAMS.r, p: SCRYPT_PARAMS.p, }).toString('hex'); return `${salt}:${hash}`; } function verifyPassword(plain: string, stored: string): boolean { const [salt, hash] = stored.split(':'); if (!salt || !hash) return false; const attempt = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, { N: SCRYPT_PARAMS.N, r: SCRYPT_PARAMS.r, p: SCRYPT_PARAMS.p, }); return timingSafeEqual(Buffer.from(hash, 'hex'), attempt); } // ── AuthService ─────────────────────────────────────────────────────────────── export class AuthService { readonly #store: UserStore; readonly #secret: string; constructor(store: UserStore, secret: string) { this.#store = store; this.#secret = secret; } register(email: string, password: string, role: Role = 'viewer'): AuthResponse { const existing = this.#store.findByEmail(email); if (existing) throw Object.assign(new Error('Email already registered'), { statusCode: 409 }); const passwordHash = hashPassword(password); const user = this.#store.create(email, passwordHash, role); const token = this.#issueToken(user); return { token, user }; } login(email: string, password: string): AuthResponse { const row = this.#store.findByEmail(email); if (!row) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 }); const valid = verifyPassword(password, row.password_hash); if (!valid) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 }); this.#store.touchLogin(row.id); const user: User = { id: row.id, email: row.email, role: row.role, createdAt: row.created_at, lastLogin: row.last_login, }; const token = this.#issueToken(user); return { token, user }; } verify(token: string): TokenPayload { return verifyJwt(token, this.#secret); } /** * Generate a password reset token and print the reset link to the console. * Always returns success (no email enumeration). */ forgotPassword(email: string, appOrigin: string): void { this.#store.purgeExpiredTokens(); const user = this.#store.findByEmail(email); if (!user) return; // silent — don't reveal whether email exists const token = randomUUID().replace(/-/g, ''); // 32-char hex const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour this.#store.createResetToken(user.id, token, expiresAt); const link = `${appOrigin}/auth/reset-password?token=${token}`; console.log('\n🔐 Password reset requested for:', email); console.log(' Link (expires in 1 hour):'); console.log(` ${link}\n`); } /** * Validate a reset token and update the user's password. */ resetPassword(token: string, newPassword: string): void { const row = this.#store.findResetToken(token); if (!row) throw Object.assign(new Error('Invalid or expired reset link'), { statusCode: 400 }); if (row.used) throw Object.assign(new Error('Reset link already used'), { statusCode: 400 }); if (new Date(row.expires_at) < new Date()) { throw Object.assign(new Error('Reset link has expired'), { statusCode: 400 }); } const passwordHash = hashPassword(newPassword); this.#store.updatePassword(row.user_id, passwordHash); this.#store.markTokenUsed(token); } #issueToken(user: User): string { return signJwt({ sub: user.id, email: user.email, role: user.role }, this.#secret); } }