step-up.ts52 lines · main
1import type { MiddlewareHandler } from 'hono';
2import { eq } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { users } from '../db/schema.js';
6
7import type { User } from './session.js';
8
9/**
10 * Step-up authentication gate. Requires the caller's session-bound user
11 * to have a recent `last_mfa_at` attestation, set by
12 * `POST /v1/auth/step-up` after a successful password re-prompt.
13 *
14 * Per CLAUDE.md §5.4 admin actions need 2FA within the last 10 minutes.
15 * Real TOTP/WebAuthn is a Phase 3 follow-up; v1 enforces *recency* via a
16 * password re-prompt — same shape, weaker primitive.
17 *
18 * Returns 403 `step_up_required` when the attestation is missing or
19 * older than `maxAgeMs`. The dashboard catches that and surfaces a
20 * step-up prompt; on success the original request can be retried.
21 */
22export function requireRecentMfa(maxAgeMin = 10): MiddlewareHandler {
23 const maxAgeMs = maxAgeMin * 60 * 1000;
24 return async (c, next) => {
25 const user = c.get('user') as User | null;
26 if (!user) {
27 return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
28 }
29 // Re-read the user row so we see the freshest last_mfa_at. The
30 // session-attached user may be cached and stale by the cookie's TTL.
31 const db = getDb();
32 const rows = await db
33 .select({ lastMfaAt: users.lastMfaAt })
34 .from(users)
35 .where(eq(users.id, user.id))
36 .limit(1);
37 const lastMfaAt = rows[0]?.lastMfaAt ?? null;
38 const fresh = lastMfaAt && Date.now() - lastMfaAt.getTime() <= maxAgeMs;
39 if (!fresh) {
40 return c.json(
41 {
42 code: 'step_up_required',
43 message: `this action requires step-up auth within the last ${maxAgeMin} minutes`,
44 maxAgeMin,
45 },
46 403,
47 );
48 }
49 await next();
50 return;
51 };
52}