customer-presentation/src/auth.js
2026-04-23 10:38:37 +02:00

86 lines
3.6 KiB
JavaScript

// ─────────────────────────────────────────────────────────────
// src/auth.js — /auth/* endpoints.
//
// POST /auth/login { email } → 200 {ok, firstName?} on success,
// 403 {error: "not_invited"} otherwise.
// POST /auth/logout → 200
// GET /auth/me → 200 {email, firstName} | 401
//
// There are no one-time codes anymore — the site is invite-list-only
// and the invite list IS the authentication factor. A person who knows
// an invited email can log in as them. The site exposes only
// marketing/preview content, so this is acceptable by design. If a user
// needs to be kicked out, remove their invite AND delete their session
// rows (see OPERATIONS.md).
// ─────────────────────────────────────────────────────────────
import { Router } from 'express';
import { q } from './db.js';
import { issueSession, clearSession } from './sessions.js';
import { rateLimit } from './middleware.js';
const router = Router();
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// ─── POST /auth/login ────────────────────────────────────────
// One API call handles the whole flow. On success we set the
// session cookie AND return the user's first name so the frontend
// can greet them without a second round-trip to /auth/me.
//
// Rate limit: 30 login attempts per IP per hour. Enough to cover
// legitimate re-logins after cookie loss, low enough to deter
// scripted enumeration of the invite list.
router.post(
'/login',
rateLimit({
key: (req) => `login:${req.ip}`,
max: 30,
windowMs: 60 * 60 * 1000,
}),
(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) {
// Honest "not invited" — we've decided enumeration is not a
// concern for this site (invite-list-only, preview content).
return res.status(403).json({ error: 'not_invited' });
}
issueSession(req, res, email);
return res.status(200).json({
ok: true,
firstName: invited.first_name || null,
});
}
);
// ─── POST /auth/logout ───────────────────────────────────────
router.post('/logout', (req, res) => {
clearSession(req, res);
return res.status(200).json({ ok: true });
});
// ─── GET /auth/me ────────────────────────────────────────────
// Returns the current user's email + first name, or 401 if no
// valid session. The frontend calls this on page load to decide
// which step of the entrance shell to show.
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();
// Look up the invite row to fetch first_name. If the invite was
// deleted after the session was issued, first_name will be null
// and the frontend falls back to anonymous copy.
const invited = q.getInvite.get(s.email);
return res.json({
email: s.email,
firstName: invited?.first_name || null,
});
});
export default router;