// ───────────────────────────────────────────────────────────── // src/auth.js — /auth/* endpoints. // // POST /auth/request-code { email } → 200 always (no enum leak) // POST /auth/verify-code { email, code } → 200 on success + Set-Cookie // POST /auth/logout → 200 // ───────────────────────────────────────────────────────────── import { Router } from 'express'; import { q } from './db.js'; import { sendCode } from './mail.js'; import { randomCode, hashCode, constantTimeEqual, issueSession, clearSession, CODE_TTL_MS, } from './sessions.js'; import { rateLimit } from './middleware.js'; const router = Router(); const MAX_VERIFY_ATTEMPTS = 5; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; // ─── POST /auth/request-code ───────────────────────────────── // ALWAYS returns 200 (once past the format check) — we must not // reveal whether an address is on the invite list. router.post( '/request-code', rateLimit({ key: (req) => `req:${req.ip}`, max: 5, windowMs: 60 * 60 * 1000, // 5 requests per IP per hour }), async (req, res) => { const email = String(req.body?.email || '').trim().toLowerCase(); if (!EMAIL_RE.test(email)) { return res.status(400).json({ error: 'invalid_email' }); } const invited = q.getInvite.get(email); if (invited) { const code = randomCode(); q.upsertCode.run(email, hashCode(code), Date.now() + CODE_TTL_MS); try { await sendCode(email, code); } catch (err) { // Log, but still return 200 to the client. If SMTP is misconfigured // we want to know *immediately* in the logs, not via users. console.error('[auth] SMTP send failed for', email, err?.message || err); } } // Uniform response regardless of invite status return res.status(200).json({ ok: true }); } ); // ─── POST /auth/verify-code ────────────────────────────────── router.post( '/verify-code', rateLimit({ key: (req) => `verify:${req.ip}`, max: 20, windowMs: 60 * 60 * 1000, // 20 verify attempts per IP per hour }), (req, res) => { const email = String(req.body?.email || '').trim().toLowerCase(); const code = String(req.body?.code || '').trim(); if (!EMAIL_RE.test(email) || !/^\d{6}$/.test(code)) { return res.status(401).json({ error: 'invalid' }); } const row = q.getCode.get(email, Date.now()); if (!row) { return res.status(401).json({ error: 'invalid_or_expired' }); } if (row.attempts >= MAX_VERIFY_ATTEMPTS) { q.deleteCode.run(email); // force user to request a new code return res.status(429).json({ error: 'too_many_attempts' }); } const submitted = hashCode(code); if (!constantTimeEqual(submitted, row.code_hash)) { q.incAttempts.run(email); return res.status(401).json({ error: 'wrong_code' }); } // Success: single-use — delete the code, issue a session q.deleteCode.run(email); issueSession(req, res, email); return res.status(200).json({ ok: true }); } ); // ─── POST /auth/logout ─────────────────────────────────────── router.post('/logout', (req, res) => { clearSession(req, res); return res.status(200).json({ ok: true }); }); // ─── GET /auth/me ─ convenience for debugging, returns current email or 401 router.get('/me', (req, res) => { const s = req.cookies?.fenja_session ? q.getSession.get(req.cookies.fenja_session, Date.now()) : null; if (!s) return res.status(401).end(); return res.json({ email: s.email }); }); export default router;