feat(auth): /api/auth routes + integration tests (pending wiring)

This commit is contained in:
2026-05-20 22:56:10 +02:00
parent 574e3de0e8
commit 70658556aa
2 changed files with 324 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
import { Router } from 'express';
import { eq, sql } from 'drizzle-orm';
import {
registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema,
forgotPasswordSchema, resetPasswordSchema, profileUpdateSchema, changePasswordSchema,
acceptInviteSchema, type PublicUser,
} from '@flashcard/shared';
import type { Db } from '../db/client.js';
import { users } from '../db/schema.js';
import { hashPassword, verifyPassword } from '../services/auth/passwords.js';
import { createAuthToken, consumeAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js';
import { createAuthSession, invalidateAuthSession, invalidateAllForUser } from '../services/auth/sessions.js';
import { getMailer } from '../services/auth/email.js';
import {
verifyEmailTemplate, passwordResetTemplate, changeEmailTemplate,
} from '../services/auth/templates.js';
import { SID_COOKIE, CSRF_COOKIE, sidCookieOptions, csrfCookieOptions, clearCookieOptions } from '../lib/cookies.js';
import { ApiError } from '../lib/errors.js';
import { requireAuth } from '../middleware/auth.js';
import { verifyCsrf } from '../middleware/csrf.js';
import { loginLimiter, registerLimiter, forgotPasswordLimiter, tokenLimiter } from '../middleware/rate-limit.js';
import { randomBytes } from 'node:crypto';
const VERIFY_TTL = 24 * 60 * 60;
const RESET_TTL = 60 * 60;
const CHANGE_EMAIL_TTL = 24 * 60 * 60;
function nowSec() { return Math.floor(Date.now() / 1000); }
function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; }
function toPublicUser(r: typeof users.$inferSelect): PublicUser {
return { id: r.id, email: r.email, displayName: r.displayName, role: r.role };
}
export function authRouter(db: Db): Router {
const r = Router();
r.get('/me', (req, res) => {
if (!req.user) {
res.status(401).json({ error: { code: 'UNAUTHENTICATED', message: 'Not signed in' } });
return;
}
res.json(req.user);
});
r.post('/register', registerLimiter, async (req, res, next) => {
try {
const input = registerSchema.parse(req.body);
const existing = db.select().from(users).where(eq(users.email, input.email)).get();
if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
const totalUsers = db.select({ c: sql<number>`count(*)`.as('c') }).from(users).get()?.c ?? 0;
const role: 'user' | 'sysadmin' = Number(totalUsers) === 0 ? 'sysadmin' : 'user';
const passwordHash = await hashPassword(input.password);
const [user] = db.insert(users).values({
email: input.email,
displayName: input.displayName,
passwordHash,
role,
}).returning().all();
const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL);
const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName);
await getMailer().send(user!.email, tpl);
res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role });
} catch (e) { next(e); }
});
r.post('/verify-email', tokenLimiter, async (req, res, next) => {
try {
const { token } = verifyEmailSchema.parse(req.body);
let consumed = await consumeAuthToken(db, token, 'verify_email');
let purpose: 'verify_email' | 'change_email' = 'verify_email';
if (!consumed) {
consumed = await consumeAuthToken(db, token, 'change_email');
purpose = 'change_email';
}
if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token');
if (purpose === 'verify_email') {
db.update(users).set({ emailVerifiedAt: nowSec(), updatedAt: nowSec() })
.where(eq(users.id, consumed.userId)).run();
} else {
const newEmail = consumed.payload;
if (!newEmail) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid token payload');
const taken = db.select().from(users).where(eq(users.email, newEmail)).get();
if (taken && taken.id !== consumed.userId) {
throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
}
db.update(users).set({ email: newEmail, pendingEmail: null, updatedAt: nowSec() })
.where(eq(users.id, consumed.userId)).run();
}
res.status(200).json({ ok: true });
} catch (e) { next(e); }
});
r.post('/resend-verification', tokenLimiter, async (req, res, next) => {
try {
const { email } = resendVerificationSchema.parse(req.body);
const u = db.select().from(users).where(eq(users.email, email)).get();
if (u && !u.emailVerifiedAt && u.isActive) {
await invalidateTokensForUser(db, u.id, 'verify_email');
const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL);
const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName);
await getMailer().send(u.email, tpl);
}
res.status(200).json({ ok: true });
} catch (e) { next(e); }
});
r.post('/login', loginLimiter, async (req, res, next) => {
try {
const input = loginSchema.parse(req.body);
const u = db.select().from(users).where(eq(users.email, input.email)).get();
if (!u || !u.passwordHash || !(await verifyPassword(input.password, u.passwordHash))) {
throw new ApiError(401, 'INVALID_CREDENTIALS', 'Invalid email or password');
}
if (!u.isActive) throw new ApiError(403, 'ACCOUNT_DISABLED', 'Account is disabled');
if (!u.emailVerifiedAt) throw new ApiError(403, 'EMAIL_NOT_VERIFIED', 'Email not verified');
const s = await createAuthSession(db, u.id, {
userAgent: req.headers['user-agent'] ?? null,
ip: req.ip ?? null,
});
const csrf = randomBytes(24).toString('base64url');
res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt));
res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt));
res.json(toPublicUser(u));
} catch (e) { next(e); }
});
r.post('/logout', requireAuth, verifyCsrf, async (req, res, next) => {
try {
if (req.sessionId) await invalidateAuthSession(db, req.sessionId);
res.clearCookie(SID_COOKIE, clearCookieOptions());
res.clearCookie(CSRF_COOKIE, { ...clearCookieOptions(), httpOnly: false });
res.status(204).end();
} catch (e) { next(e); }
});
r.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) => {
try {
const { email } = forgotPasswordSchema.parse(req.body);
const u = db.select().from(users).where(eq(users.email, email)).get();
if (u && u.isActive && u.emailVerifiedAt) {
await invalidateTokensForUser(db, u.id, 'password_reset');
const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL);
const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName);
await getMailer().send(u.email, tpl);
}
res.status(200).json({ ok: true });
} catch (e) { next(e); }
});
r.post('/reset-password', tokenLimiter, async (req, res, next) => {
try {
const { token, password } = resetPasswordSchema.parse(req.body);
const consumed = await consumeAuthToken(db, token, 'password_reset');
if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token');
const passwordHash = await hashPassword(password);
db.update(users).set({ passwordHash, updatedAt: nowSec() })
.where(eq(users.id, consumed.userId)).run();
await invalidateAllForUser(db, consumed.userId);
res.status(200).json({ ok: true });
} catch (e) { next(e); }
});
r.post('/accept-invite', tokenLimiter, async (req, res, next) => {
try {
const input = acceptInviteSchema.parse(req.body);
const consumed = await consumeAuthToken(db, input.token, 'invite');
if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token');
const passwordHash = await hashPassword(input.password);
const [updated] = db.update(users).set({
passwordHash, displayName: input.displayName,
emailVerifiedAt: nowSec(), updatedAt: nowSec(),
}).where(eq(users.id, consumed.userId)).returning().all();
if (!updated) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid invitation');
const s = await createAuthSession(db, updated.id, {
userAgent: req.headers['user-agent'] ?? null, ip: req.ip ?? null,
});
const csrf = randomBytes(24).toString('base64url');
res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt));
res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt));
res.status(200).json(toPublicUser(updated));
} catch (e) { next(e); }
});
r.patch('/profile', requireAuth, verifyCsrf, async (req, res, next) => {
try {
const input = profileUpdateSchema.parse(req.body);
const user = req.user!;
const updates: Record<string, unknown> = { updatedAt: nowSec() };
if (input.displayName !== undefined) updates.displayName = input.displayName;
let sendEmailChange: string | null = null;
if (input.email !== undefined && input.email !== user.email) {
const taken = db.select().from(users).where(eq(users.email, input.email)).get();
if (taken) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
updates.pendingEmail = input.email;
sendEmailChange = input.email;
}
const [updated] = db.update(users).set(updates).where(eq(users.id, user.id)).returning().all();
if (sendEmailChange && updated) {
const { plaintext } = await createAuthToken(db, updated.id, 'change_email', CHANGE_EMAIL_TTL, sendEmailChange);
const tpl = changeEmailTemplate(appUrl(), plaintext, updated.displayName, sendEmailChange);
await getMailer().send(sendEmailChange, tpl);
}
res.json(toPublicUser(updated!));
} catch (e) { next(e); }
});
r.post('/change-password', requireAuth, verifyCsrf, async (req, res, next) => {
try {
const input = changePasswordSchema.parse(req.body);
const user = req.user!;
const row = db.select().from(users).where(eq(users.id, user.id)).get();
if (!row?.passwordHash || !(await verifyPassword(input.currentPassword, row.passwordHash))) {
throw new ApiError(401, 'INVALID_CREDENTIALS', 'Current password is incorrect');
}
const passwordHash = await hashPassword(input.newPassword);
db.update(users).set({ passwordHash, updatedAt: nowSec() }).where(eq(users.id, user.id)).run();
if (req.sessionId) await invalidateAllForUser(db, user.id, { except: req.sessionId });
res.status(204).end();
} catch (e) { next(e); }
});
return r;
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
import { makeTestDb } from './dbHelper.js';
import { setMailerForTests, type Mailer } from '../services/auth/email.js';
class CaptureMailer implements Mailer {
sent: { to: string; subject: string; text: string; html: string }[] = [];
async send(to: string, m: { subject: string; html: string; text: string }) {
this.sent.push({ to, ...m });
}
}
let env: ReturnType<typeof makeTestDb>;
let mailer: CaptureMailer;
let app: ReturnType<typeof createApp>;
beforeEach(() => {
env = makeTestDb();
mailer = new CaptureMailer();
setMailerForTests(mailer);
app = createApp(env.db);
});
function tokenFromMail(text: string): string {
const m = text.match(/token=([^\s&"]+)/);
if (!m) throw new Error('no token in mail');
return decodeURIComponent(m[1]!);
}
function extractCookieValue(cookies: string[], name: string): string {
for (const c of cookies) {
if (c.startsWith(`${name}=`)) {
const v = c.split(';')[0]!.slice(name.length + 1);
return decodeURIComponent(v);
}
}
throw new Error(`cookie ${name} not found`);
}
describe('auth integration', () => {
it('register → verify → login → me → logout', async () => {
const reg = await request(app).post('/api/auth/register').send({
email: 'alice@example.com', displayName: 'Alice', password: 'secretpass',
});
expect(reg.status).toBe(201);
expect(mailer.sent).toHaveLength(1);
const verifyToken = tokenFromMail(mailer.sent[0]!.text);
const bad = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' });
expect(bad.status).toBe(403);
expect(bad.body.error.code).toBe('EMAIL_NOT_VERIFIED');
const ver = await request(app).post('/api/auth/verify-email').send({ token: verifyToken });
expect(ver.status).toBe(200);
const ok = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' });
expect(ok.status).toBe(200);
const cookies = ok.headers['set-cookie'] as unknown as string[];
expect(cookies.find((c) => c.startsWith('flashcard_sid='))).toBeDefined();
expect(cookies.find((c) => c.startsWith('flashcard_csrf='))).toBeDefined();
const me = await request(app).get('/api/auth/me').set('Cookie', cookies);
expect(me.status).toBe(200);
expect(me.body.email).toBe('alice@example.com');
const csrf = extractCookieValue(cookies, 'flashcard_csrf');
const logout = await request(app).post('/api/auth/logout').set('Cookie', cookies).set('x-csrf-token', csrf);
expect(logout.status).toBe(204);
const me2 = await request(app).get('/api/auth/me').set('Cookie', cookies);
expect(me2.status).toBe(401);
});
it('marks first registered user as sysadmin', async () => {
const r = await request(app).post('/api/auth/register').send({
email: 'first@example.com', displayName: 'First', password: 'secretpass',
});
expect(r.status).toBe(201);
const token = tokenFromMail(mailer.sent[0]!.text);
await request(app).post('/api/auth/verify-email').send({ token });
const login = await request(app).post('/api/auth/login').send({ email: 'first@example.com', password: 'secretpass' });
const cookies = login.headers['set-cookie'] as unknown as string[];
const me = await request(app).get('/api/auth/me').set('Cookie', cookies);
expect(me.body.role).toBe('sysadmin');
});
it('rejects duplicate email', async () => {
await request(app).post('/api/auth/register').send({ email: 'a@example.com', displayName: 'A', password: 'secretpass' });
const dup = await request(app).post('/api/auth/register').send({ email: 'A@example.com', displayName: 'A2', password: 'secretpass' });
expect(dup.status).toBe(409);
expect(dup.body.error.code).toBe('EMAIL_TAKEN');
});
});