From e14e9097006553130a04f99438a142af5b133539 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sun, 31 May 2026 21:38:01 +0200 Subject: [PATCH] feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes --- app/api/security/recovery/generate/route.ts | 44 ++++ app/api/security/recovery/redeem/route.ts | 66 ++++++ app/globals.css | 6 + app/layout.tsx | 1 + app/login/page.tsx | 10 +- app/security/challenge/challenge-client.tsx | 185 ++++++++++++++++ app/security/challenge/page.tsx | 47 ++++ app/security/enroll/enroll-client.tsx | 79 +++++++ app/security/enroll/page.tsx | 34 +++ app/security/mfa-components.tsx | 229 ++++++++++++++++++++ app/security/page.tsx | 37 ++++ app/security/security-client.tsx | 182 ++++++++++++++++ lib/auth/mfa.ts | 33 +++ lib/auth/recovery.ts | 31 +++ lib/auth/webauthn-client.ts | 147 +++++++++++++ middleware.ts | 83 +++++-- package-lock.json | 203 ++++++++--------- package.json | 5 +- supabase/migrations/0002_mfa_recovery.sql | 30 +++ 19 files changed, 1310 insertions(+), 142 deletions(-) create mode 100644 app/api/security/recovery/generate/route.ts create mode 100644 app/api/security/recovery/redeem/route.ts create mode 100644 app/security/challenge/challenge-client.tsx create mode 100644 app/security/challenge/page.tsx create mode 100644 app/security/enroll/enroll-client.tsx create mode 100644 app/security/enroll/page.tsx create mode 100644 app/security/mfa-components.tsx create mode 100644 app/security/page.tsx create mode 100644 app/security/security-client.tsx create mode 100644 lib/auth/mfa.ts create mode 100644 lib/auth/recovery.ts create mode 100644 lib/auth/webauthn-client.ts create mode 100644 supabase/migrations/0002_mfa_recovery.sql diff --git a/app/api/security/recovery/generate/route.ts b/app/api/security/recovery/generate/route.ts new file mode 100644 index 0000000..bad7f92 --- /dev/null +++ b/app/api/security/recovery/generate/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { jsonNoStore } from '@/lib/admin/response'; +import { generateRecoveryCodes, hashRecoveryCode } from '@/lib/auth/recovery'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Generate a fresh set of recovery codes for the current user. Requires an + * aal2 session (the user just verified a factor). Any previously-issued codes + * are replaced. The plaintext codes are returned once and never persisted. + */ +export async function POST(): Promise { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); + + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (aal?.currentLevel !== 'aal2') { + return jsonNoStore({ error: 'aal2_required' }, { status: 403 }); + } + + const codes = generateRecoveryCodes(10); + const admin = getSupabaseAdmin(); + + const del = await admin + .from('mfa_recovery_codes') + .delete() + .eq('user_id', user.id); + if (del.error) return jsonNoStore({ error: 'failed' }, { status: 500 }); + + const rows = codes.map((c) => ({ + user_id: user.id, + code_hash: hashRecoveryCode(c), + })); + const ins = await admin.from('mfa_recovery_codes').insert(rows); + if (ins.error) return jsonNoStore({ error: 'failed' }, { status: 500 }); + + return jsonNoStore({ codes }); +} diff --git a/app/api/security/recovery/redeem/route.ts b/app/api/security/recovery/redeem/route.ts new file mode 100644 index 0000000..47c49b9 --- /dev/null +++ b/app/api/security/recovery/redeem/route.ts @@ -0,0 +1,66 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { jsonNoStore } from '@/lib/admin/response'; +import { hashRecoveryCode } from '@/lib/auth/recovery'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Redeem a single-use recovery code for account recovery. + * + * The user is authenticated (aal1) but locked out of step-up because they lost + * their authenticator/passkey. On a valid code we mark it used and DELETE all + * of the user's MFA factors via the admin API. GoTrue logs the user out of all + * sessions when a verified factor is deleted, so the client signs out and + * returns to /login; after signing back in (aal1, zero factors) the middleware + * forces fresh enrollment. We never fabricate an aal2 JWT. + */ +export async function POST(request: NextRequest): Promise { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); + + let body: { code?: string }; + try { + body = (await request.json()) as { code?: string }; + } catch { + return jsonNoStore({ error: 'bad_request' }, { status: 400 }); + } + const code = (body.code ?? '').trim(); + if (!code) return jsonNoStore({ error: 'invalid_code' }, { status: 400 }); + + const admin = getSupabaseAdmin(); + const hash = hashRecoveryCode(code); + + const { data: match, error } = await admin + .from('mfa_recovery_codes') + .select('id') + .eq('user_id', user.id) + .eq('code_hash', hash) + .is('used_at', null) + .limit(1) + .maybeSingle<{ id: number }>(); + if (error) return jsonNoStore({ error: 'failed' }, { status: 500 }); + if (!match) return jsonNoStore({ error: 'invalid_code' }, { status: 400 }); + + const upd = await admin + .from('mfa_recovery_codes') + .update({ used_at: new Date().toISOString() }) + .eq('id', match.id) + .is('used_at', null); + if (upd.error) return jsonNoStore({ error: 'failed' }, { status: 500 }); + + // Account recovery: remove every MFA factor so the user can re-enroll. + const { data: list } = await admin.auth.admin.mfa.listFactors({ + userId: user.id, + }); + for (const factor of list?.factors ?? []) { + await admin.auth.admin.mfa.deleteFactor({ id: factor.id, userId: user.id }); + } + + return jsonNoStore({ ok: true }); +} diff --git a/app/globals.css b/app/globals.css index 8061c10..bd6c665 100644 --- a/app/globals.css +++ b/app/globals.css @@ -45,6 +45,12 @@ a:hover { padding: 2rem 1rem; } +.stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + .nav { display: flex; justify-content: space-between; diff --git a/app/layout.tsx b/app/layout.tsx index 16ae673..b9e8e6e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -33,6 +33,7 @@ export default async function RootLayout({ <> Dashboard Billing + Security {isAdmin && Admin}
Need an account? diff --git a/app/security/challenge/challenge-client.tsx b/app/security/challenge/challenge-client.tsx new file mode 100644 index 0000000..ae71c2a --- /dev/null +++ b/app/security/challenge/challenge-client.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; +import { + passkeyErrorMessage, + stepUpWithPasskey, +} from '@/lib/auth/webauthn-client'; + +/** + * AAL2 step-up UI: verify with TOTP or passkey, or fall back to a single-use + * recovery code (which resets MFA and bounces the user back to /login to set up + * a fresh factor). + */ +export function ChallengeClient({ + totpFactorId, + passkeyFactorId, + next, +}: { + totpFactorId: string | null; + passkeyFactorId: string | null; + next: string; +}) { + const router = useRouter(); + const supabase = useMemo(() => createSupabaseBrowserClient(), []); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [code, setCode] = useState(''); + const [showRecovery, setShowRecovery] = useState(false); + const [recovery, setRecovery] = useState(''); + + function done() { + router.replace(next); + router.refresh(); + } + + async function verifyTotp(e: React.FormEvent) { + e.preventDefault(); + if (!totpFactorId) return; + setError(null); + setBusy(true); + try { + const { data: ch, error: ce } = await supabase.auth.mfa.challenge({ + factorId: totpFactorId, + }); + if (ce) throw ce; + const { error: ve } = await supabase.auth.mfa.verify({ + factorId: totpFactorId, + challengeId: ch.id, + code: code.trim(), + }); + if (ve) throw ve; + done(); + } catch (e) { + setError((e as Error).message); + setBusy(false); + } + } + + async function verifyPasskey() { + if (!passkeyFactorId) return; + setError(null); + setBusy(true); + try { + await stepUpWithPasskey(supabase, passkeyFactorId); + done(); + } catch (e) { + setError(passkeyErrorMessage(e)); + setBusy(false); + } + } + + async function redeemRecovery(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + const res = await fetch('/api/security/recovery/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: recovery.trim() }), + }); + const json = (await res.json()) as { ok?: boolean; error?: string }; + if (!res.ok || !json.ok) { + throw new Error( + json.error === 'invalid_code' + ? 'That recovery code is invalid or already used.' + : 'Recovery failed. Please try again.', + ); + } + // Deleting verified factors logs the user out everywhere; clear local + // cookies and return to login to re-enroll. + await supabase.auth.signOut().catch(() => {}); + router.replace('/login?mfa_reset=1'); + router.refresh(); + } catch (e) { + setError((e as Error).message); + setBusy(false); + } + } + + return ( +
+ {error &&

{error}

} + + {passkeyFactorId && ( +
+

Passkey

+

+ Verify with your device biometrics or security key. +

+ +
+ )} + + {totpFactorId && ( +
+

Authenticator app

+ + + setCode(e.target.value)} + /> +
+ +
+ +
+ )} + +
+

Lost access?

+ {showRecovery ? ( +
+

+ Enter a recovery code. This removes your current two-factor + methods so you can set up new ones after signing in again. +

+ + setRecovery(e.target.value)} + /> +
+ +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/app/security/challenge/page.tsx b/app/security/challenge/page.tsx new file mode 100644 index 0000000..9a70894 --- /dev/null +++ b/app/security/challenge/page.tsx @@ -0,0 +1,47 @@ +import { redirect } from 'next/navigation'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { safeNextPath } from '@/lib/auth/mfa'; +import { ChallengeClient } from './challenge-client'; + +export const dynamic = 'force-dynamic'; + +/** + * AAL2 step-up challenge. The middleware sends authenticated aal1 users here + * when they have verified factors. Users with no verified factors are sent to + * enrollment instead; users already at aal2 are forwarded to their target. + */ +export default async function ChallengePage({ + searchParams, +}: { + searchParams: { next?: string }; +}) { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const next = safeNextPath(searchParams.next); + + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (aal?.currentLevel === 'aal2') redirect(next); + + const { data: factors } = await supabase.auth.mfa.listFactors(); + const verified = factors?.all.filter((f) => f.status === 'verified') ?? []; + if (verified.length === 0) redirect('/security/enroll'); + + const totp = verified.find((f) => f.factor_type === 'totp'); + const passkey = verified.find((f) => f.factor_type === 'webauthn'); + + return ( +
+

Verify it's you

+

Complete two-factor authentication to continue.

+ +
+ ); +} diff --git a/app/security/enroll/enroll-client.tsx b/app/security/enroll/enroll-client.tsx new file mode 100644 index 0000000..c23bc52 --- /dev/null +++ b/app/security/enroll/enroll-client.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; +import { + PasskeyEnrollPanel, + RecoveryCodesPanel, + TotpEnrollPanel, +} from '../mfa-components'; + +/** + * Drives the mandatory first-factor enrollment: pick TOTP and/or passkey, then + * (once a factor is verified and the session is aal2) generate and display the + * one-time recovery codes before releasing the user to the dashboard. + */ +export function EnrollClient() { + const router = useRouter(); + const supabase = useMemo(() => createSupabaseBrowserClient(), []); + const [phase, setPhase] = useState<'choose' | 'recovery'>('choose'); + const [codes, setCodes] = useState([]); + const [error, setError] = useState(null); + + async function handleVerified() { + setError(null); + try { + const res = await fetch('/api/security/recovery/generate', { + method: 'POST', + }); + const json = (await res.json()) as { codes?: string[]; error?: string }; + if (!res.ok || !json.codes) { + throw new Error(json.error || 'Could not generate recovery codes.'); + } + setCodes(json.codes); + } catch (e) { + // The factor is already enabled; surface the error but still let the + // user proceed (they can regenerate codes later in Security settings). + setError( + `${(e as Error).message} You can generate recovery codes later in Security settings.`, + ); + setCodes([]); + } finally { + setPhase('recovery'); + } + } + + function finish() { + router.replace('/dashboard'); + router.refresh(); + } + + if (phase === 'recovery') { + return ( +
+ {error &&

{error}

} + {codes.length > 0 ? ( + + ) : ( +
+

Two-factor authentication enabled

+

Your account is now protected.

+
+ )} +
+ +
+
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/app/security/enroll/page.tsx b/app/security/enroll/page.tsx new file mode 100644 index 0000000..b9f57b5 --- /dev/null +++ b/app/security/enroll/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from 'next/navigation'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { EnrollClient } from './enroll-client'; + +export const dynamic = 'force-dynamic'; + +/** + * Mandatory MFA enrollment gate. Reachable only by authenticated users (the + * middleware sends users here when they have zero verified factors). If the + * user already has a verified factor, there is nothing to enroll, so we send + * them on to the dashboard. + */ +export default async function EnrollPage() { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const { data: factors } = await supabase.auth.mfa.listFactors(); + const verified = factors?.all.filter((f) => f.status === 'verified') ?? []; + if (verified.length > 0) redirect('/dashboard'); + + return ( +
+

Secure your account

+

+ Two-factor authentication is required. Add at least one method below to + continue. +

+ +
+ ); +} diff --git a/app/security/mfa-components.tsx b/app/security/mfa-components.tsx new file mode 100644 index 0000000..304c878 --- /dev/null +++ b/app/security/mfa-components.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useState } from 'react'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { + enrollPasskey, + passkeyErrorMessage, +} from '@/lib/auth/webauthn-client'; + +/** + * Remove any leftover *unverified* factors of a given type before starting a + * fresh enrollment. GoTrue rejects a second enroll with a duplicate friendly + * name, and abandoned/unverified factors would otherwise block re-enrollment + * (e.g. after a refresh mid-flow). Unenrolling unverified factors does not + * require aal2. + */ +async function cleanupUnverified( + supabase: SupabaseClient, + factorType: 'totp' | 'webauthn', +): Promise { + const { data } = await supabase.auth.mfa.listFactors(); + const stale = + data?.all.filter( + (f) => f.factor_type === factorType && f.status === 'unverified', + ) ?? []; + for (const f of stale) { + await supabase.auth.mfa.unenroll({ factorId: f.id }); + } +} + +/** TOTP authenticator-app enrollment: QR + manual secret, then code verify. */ +export function TotpEnrollPanel({ + supabase, + onVerified, +}: { + supabase: SupabaseClient; + onVerified: () => void; +}) { + const [stage, setStage] = useState<'idle' | 'pending'>('idle'); + const [qr, setQr] = useState(''); + const [secret, setSecret] = useState(''); + const [factorId, setFactorId] = useState(''); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function start() { + setError(null); + setBusy(true); + try { + await cleanupUnverified(supabase, 'totp'); + const { data, error } = await supabase.auth.mfa.enroll({ + factorType: 'totp', + friendlyName: 'Authenticator app', + }); + if (error) throw error; + if (!data || !('totp' in data)) { + throw new Error('Unexpected enrollment response.'); + } + setFactorId(data.id); + setQr(data.totp.qr_code); + setSecret(data.totp.secret); + setStage('pending'); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + + async function verify(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + const { data: ch, error: ce } = await supabase.auth.mfa.challenge({ + factorId, + }); + if (ce) throw ce; + const { error: ve } = await supabase.auth.mfa.verify({ + factorId, + challengeId: ch.id, + code: code.trim(), + }); + if (ve) throw ve; + onVerified(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + + if (stage === 'idle') { + return ( +
+

Authenticator app

+

+ Use an app like Google Authenticator, 1Password, or Authy to generate + 6-digit codes. +

+ {error &&

{error}

} + +
+ ); + } + + return ( +
+

Scan the QR code

+

+ Scan this with your authenticator app, then enter the 6-digit code to + confirm. +

+ {qr && ( + // eslint-disable-next-line @next/next/no-img-element + TOTP QR code + )} +

+ Can't scan? Enter this secret manually: +

+
{secret}
+
+ + setCode(e.target.value)} + /> + {error &&

{error}

} +
+ +
+
+
+ ); +} + +/** Passkey (WebAuthn) enrollment via the simplewebauthn ceremony. */ +export function PasskeyEnrollPanel({ + supabase, + onVerified, +}: { + supabase: SupabaseClient; + onVerified: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function start() { + setError(null); + setBusy(true); + try { + await cleanupUnverified(supabase, 'webauthn'); + await enrollPasskey(supabase, 'Passkey'); + onVerified(); + } catch (e) { + setError(passkeyErrorMessage(e)); + } finally { + setBusy(false); + } + } + + return ( +
+

Passkey

+

+ Use your device's biometrics, screen lock, or a hardware security + key. +

+ {error &&

{error}

} + +
+ ); +} + +/** One-time display of freshly generated recovery codes. */ +export function RecoveryCodesPanel({ codes }: { codes: string[] }) { + const [copied, setCopied] = useState(false); + + function copy() { + navigator.clipboard + .writeText(codes.join('\n')) + .then(() => setCopied(true)) + .catch(() => setCopied(false)); + } + + return ( +
+

Save your recovery codes

+

+ Store these somewhere safe. Each code can be used once to recover access + if you lose your authenticator and passkeys. They will not be shown + again. +

+
+ {codes.map((c) => ( + {c} + ))} +
+
+ +
+
+ ); +} diff --git a/app/security/page.tsx b/app/security/page.tsx new file mode 100644 index 0000000..5a535e5 --- /dev/null +++ b/app/security/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from 'next/navigation'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { SecurityClient } from './security-client'; + +export const dynamic = 'force-dynamic'; + +/** + * Security settings: manage MFA factors and regenerate recovery codes. The + * middleware guarantees the visitor is authenticated and at aal2 (mandatory + * MFA), so factor mutations here always run with the required assurance level. + */ +export default async function SecurityPage() { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const { data: factors } = await supabase.auth.mfa.listFactors(); + const verified = + factors?.all + .filter((f) => f.status === 'verified') + .map((f) => ({ + id: f.id, + type: f.factor_type, + friendlyName: f.friendly_name ?? null, + createdAt: f.created_at, + })) ?? []; + + return ( +
+

Security

+

Manage two-factor authentication for your account.

+ +
+ ); +} diff --git a/app/security/security-client.tsx b/app/security/security-client.tsx new file mode 100644 index 0000000..5d115d7 --- /dev/null +++ b/app/security/security-client.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; +import { + PasskeyEnrollPanel, + RecoveryCodesPanel, + TotpEnrollPanel, +} from './mfa-components'; + +type FactorView = { + id: string; + type: string; + friendlyName: string | null; + createdAt: string; +}; + +const TYPE_LABEL: Record = { + totp: 'Authenticator app', + webauthn: 'Passkey', + phone: 'Phone', +}; + +/** + * Security settings client: list verified factors, add new ones, remove + * factors (never the last one — mandatory MFA), and regenerate recovery codes. + */ +export function SecurityClient({ + initialFactors, +}: { + initialFactors: FactorView[]; +}) { + const router = useRouter(); + const supabase = useMemo(() => createSupabaseBrowserClient(), []); + const [factors] = useState(initialFactors); + const [adding, setAdding] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [codes, setCodes] = useState(null); + const [regenBusy, setRegenBusy] = useState(false); + + async function remove(id: string) { + if (factors.length <= 1) { + setError('You must keep at least one two-factor method enabled.'); + return; + } + setError(null); + setNotice(null); + setBusyId(id); + try { + const { error } = await supabase.auth.mfa.unenroll({ factorId: id }); + if (error) throw error; + router.refresh(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusyId(null); + } + } + + function onAdded() { + setAdding(null); + setNotice('Two-factor method added.'); + router.refresh(); + } + + async function regenerate() { + setError(null); + setNotice(null); + setCodes(null); + setRegenBusy(true); + try { + const res = await fetch('/api/security/recovery/generate', { + method: 'POST', + }); + const json = (await res.json()) as { codes?: string[]; error?: string }; + if (!res.ok || !json.codes) { + throw new Error(json.error || 'Could not generate recovery codes.'); + } + setCodes(json.codes); + } catch (e) { + setError((e as Error).message); + } finally { + setRegenBusy(false); + } + } + + return ( +
+ {error &&

{error}

} + {notice &&

{notice}

} + +
+

Your two-factor methods

+ {factors.length === 0 ? ( +

No methods enabled.

+ ) : ( + + + + + + + + + + {factors.map((f) => ( + + + + + + + ))} + +
MethodNameAdded +
{TYPE_LABEL[f.type] ?? f.type}{f.friendlyName ?? '—'}{new Date(f.createdAt).toLocaleDateString()} + +
+ )} +
+ + {adding === 'totp' && ( + + )} + {adding === 'webauthn' && ( + + )} + + {adding === null && ( +
+

Add a method

+
+ + +
+
+ )} + +
+

Recovery codes

+

+ Generate a new set of single-use recovery codes. This invalidates any + codes you were issued before. +

+ {codes && } +
+ +
+
+
+ ); +} diff --git a/lib/auth/mfa.ts b/lib/auth/mfa.ts new file mode 100644 index 0000000..10595d7 --- /dev/null +++ b/lib/auth/mfa.ts @@ -0,0 +1,33 @@ +/** + * Shared MFA constants/helpers used by both server and client code. + * + * The WebAuthn Relying Party ID is fixed to the registrable suffix + * `linumiq.net` so the same passkey works across `app-dev.linumiq.net` and + * `app.linumiq.net`. The RP origin is derived from NEXT_PUBLIC_APP_URL so each + * environment supplies its own concrete origin at challenge time. + */ +export const MFA_RP_ID = 'linumiq.net'; + +/** Origin (scheme + host) of this environment's app, from NEXT_PUBLIC_APP_URL. */ +export function getAppOrigin(): string { + const raw = process.env.NEXT_PUBLIC_APP_URL; + if (raw) { + try { + return new URL(raw).origin; + } catch { + // fall through to the localhost default + } + } + return 'http://localhost:3000'; +} + +/** + * Whitelist a `next` redirect target to same-origin relative paths only + * (open-redirect guard). Anything else falls back to /dashboard. + */ +export function safeNextPath(raw: string | null | undefined): string { + if (!raw || !raw.startsWith('/') || raw.startsWith('//')) { + return '/dashboard'; + } + return raw; +} diff --git a/lib/auth/recovery.ts b/lib/auth/recovery.ts new file mode 100644 index 0000000..6559aba --- /dev/null +++ b/lib/auth/recovery.ts @@ -0,0 +1,31 @@ +import { createHash, randomBytes } from 'crypto'; + +/** + * MFA recovery codes. + * + * GoTrue has no native recovery-code support, so we manage them ourselves in + * the `mfa_recovery_codes` table (service-role only; see migration 0002). We + * only ever persist a SHA-256 hash of each code — the plaintext is shown to the + * user exactly once at generation time. Codes are high-entropy random values, + * so a fast hash is sufficient (no need for bcrypt/per-code salt). + */ + +/** Normalise user input so hyphens / case / spacing don't matter on redeem. */ +function normalize(code: string): string { + return code.replace(/[^a-z0-9]/gi, '').toLowerCase(); +} + +/** Stable hash used both when storing and when redeeming a code. */ +export function hashRecoveryCode(code: string): string { + return createHash('sha256').update(normalize(code)).digest('hex'); +} + +/** Generate `n` formatted recovery codes (e.g. `a1b2-c3d4-e5f6`). */ +export function generateRecoveryCodes(n = 10): string[] { + const codes: string[] = []; + for (let i = 0; i < n; i++) { + const raw = randomBytes(6).toString('hex'); // 12 hex chars + codes.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`); + } + return codes; +} diff --git a/lib/auth/webauthn-client.ts b/lib/auth/webauthn-client.ts new file mode 100644 index 0000000..5b00fae --- /dev/null +++ b/lib/auth/webauthn-client.ts @@ -0,0 +1,147 @@ +'use client'; + +import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { MFA_RP_ID, getAppOrigin } from './mfa'; + +/** + * WebAuthn (passkey) MFA ceremonies. + * + * supabase-js 2.106 ships a native WebAuthn helper, but its verify step expects + * the raw browser `Credential` object, whereas we run the ceremony with + * `@simplewebauthn/browser` (which yields the W3C JSON form). So we drive the + * GoTrue REST endpoints directly with the user's bearer token and feed the JSON + * options to simplewebauthn, then persist the returned aal2 session via + * `setSession`. The RP id/origins are client-supplied (GoTrue v2.186 reads them + * from the challenge/verify body). + */ + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + +type GoTrueJson = Record & { + msg?: string; + error?: string; + error_description?: string; +}; + +async function getAccessToken(supabase: SupabaseClient): Promise { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + if (!token) throw new Error('No active session.'); + return token; +} + +async function gotrue( + token: string, + path: string, + body: unknown, +): Promise { + const res = await fetch(`${SUPABASE_URL}/auth/v1/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: ANON_KEY, + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + const json = (await res.json().catch(() => ({}))) as GoTrueJson; + if (!res.ok) { + throw new Error( + json.msg || + json.error_description || + json.error || + `Request failed (${res.status})`, + ); + } + return json; +} + +async function applyTokens( + supabase: SupabaseClient, + json: GoTrueJson, +): Promise { + const access_token = json.access_token as string | undefined; + const refresh_token = json.refresh_token as string | undefined; + if (access_token && refresh_token) { + await supabase.auth.setSession({ access_token, refresh_token }); + } +} + +function webauthnBody(extra: Record = {}) { + return { rpId: MFA_RP_ID, rpOrigins: [getAppOrigin()], ...extra }; +} + +/** + * Enroll + verify a brand new passkey. Requires the browser to be online and a + * platform/cross-platform authenticator available. On success the session is + * upgraded to aal2. + */ +export async function enrollPasskey( + supabase: SupabaseClient, + friendlyName: string, +): Promise { + const token = await getAccessToken(supabase); + const factor = await gotrue(token, 'factors', { + factor_type: 'webauthn', + friendly_name: friendlyName, + }); + const factorId = factor.id as string; + + const challenge = await gotrue(token, `factors/${factorId}/challenge`, { + webauthn: webauthnBody(), + }); + const webauthn = challenge.webauthn as { + credential_options: { publicKey: unknown }; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attResp = await startRegistration({ + optionsJSON: webauthn.credential_options.publicKey as any, + }); + + const verify = await gotrue(token, `factors/${factorId}/verify`, { + challenge_id: challenge.id, + webauthn: webauthnBody({ type: 'create', credential_response: attResp }), + }); + await applyTokens(supabase, verify); +} + +/** + * Run a passkey assertion against an already-verified factor to step the + * session up to aal2. + */ +export async function stepUpWithPasskey( + supabase: SupabaseClient, + factorId: string, +): Promise { + const token = await getAccessToken(supabase); + + const challenge = await gotrue(token, `factors/${factorId}/challenge`, { + webauthn: webauthnBody(), + }); + const webauthn = challenge.webauthn as { + credential_options: { publicKey: unknown }; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const asseResp = await startAuthentication({ + optionsJSON: webauthn.credential_options.publicKey as any, + }); + + const verify = await gotrue(token, `factors/${factorId}/verify`, { + challenge_id: challenge.id, + webauthn: webauthnBody({ type: 'request', credential_response: asseResp }), + }); + await applyTokens(supabase, verify); +} + +/** Human-friendly message for the common WebAuthn ceremony failures. */ +export function passkeyErrorMessage(e: unknown): string { + const err = e as { name?: string; message?: string }; + if (err?.name === 'NotAllowedError' || err?.name === 'AbortError') { + return 'Passkey prompt was dismissed or timed out. Please try again.'; + } + return err?.message || 'Passkey operation failed.'; +} diff --git a/middleware.ts b/middleware.ts index c668db9..180f5be 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,26 @@ import { createServerClient } from '@supabase/ssr'; import { NextResponse, type NextRequest } from 'next/server'; +// Path prefixes that are reachable without a completed MFA step-up. These are +// the auth flow itself, the MFA enrollment/challenge surface, their supporting +// APIs, and public email templates. Everything else (including the root path) +// requires aal2 once the user is authenticated. +const MFA_ALLOWLIST = [ + '/login', + '/signup', + '/auth', + '/security', + '/api/auth', + '/api/security', + '/email-templates', +]; + +function isMfaAllowlisted(path: string): boolean { + return MFA_ALLOWLIST.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`), + ); +} + export async function middleware(request: NextRequest) { let response = NextResponse.next({ request }); @@ -27,31 +47,36 @@ export async function middleware(request: NextRequest) { data: { user }, } = await supabase.auth.getUser(); + const path = request.nextUrl.pathname; + + // Carry any cookies Supabase rotated onto the working `response` over to a + // deny/redirect response, so a refreshed session/refresh token is always + // persisted — otherwise a fresh NextResponse would drop them and a + // concurrent request could spuriously 401. Also stamp `no-store` so these + // short-circuit responses are never cached by intermediaries or the browser. + const withCookies = (res: NextResponse): NextResponse => { + response.cookies.getAll().forEach((cookie) => res.cookies.set(cookie)); + res.headers.set('Cache-Control', 'no-store'); + return res; + }; + + const redirectTo = (pathname: string, search = ''): NextResponse => { + const url = request.nextUrl.clone(); + url.pathname = pathname; + url.search = search; + return withCookies(NextResponse.redirect(url)); + }; + // Defense-in-depth: guard the admin surface here in addition to the // per-route requireAdmin()/requireAdminApi() checks. - const path = request.nextUrl.pathname; if (path.startsWith('/admin') || path.startsWith('/api/admin')) { - // Carry any cookies Supabase rotated onto the working `response` over to a - // deny/redirect response, so a refreshed session/refresh token is always - // persisted — otherwise a fresh NextResponse would drop them and a - // concurrent request could spuriously 401. Also stamp `no-store` so these - // admin deny/redirect responses (which short-circuit before the route's - // own jsonNoStore runs) are never cached by intermediaries or the browser. - const withCookies = (res: NextResponse): NextResponse => { - response.cookies.getAll().forEach((cookie) => res.cookies.set(cookie)); - res.headers.set('Cache-Control', 'no-store'); - return res; - }; if (!user) { if (path.startsWith('/api/admin')) { return withCookies( NextResponse.json({ error: 'unauthorized' }, { status: 401 }), ); } - const url = request.nextUrl.clone(); - url.pathname = '/login'; - url.search = ''; - return withCookies(NextResponse.redirect(url)); + return redirectTo('/login'); } if (user.app_metadata?.role !== 'admin') { if (path.startsWith('/api/admin')) { @@ -59,10 +84,28 @@ export async function middleware(request: NextRequest) { NextResponse.json({ error: 'forbidden' }, { status: 403 }), ); } - const url = request.nextUrl.clone(); - url.pathname = '/dashboard'; - url.search = ''; - return withCookies(NextResponse.redirect(url)); + return redirectTo('/dashboard'); + } + } + + // Mandatory MFA gate for every authenticated request outside the allowlist. + if (user && !isMfaAllowlisted(path)) { + const [{ data: aal }, { data: factors }] = await Promise.all([ + supabase.auth.mfa.getAuthenticatorAssuranceLevel(), + supabase.auth.mfa.listFactors(), + ]); + const verifiedCount = + factors?.all.filter((f) => f.status === 'verified').length ?? 0; + + if (verifiedCount === 0) { + // No second factor at all → force enrollment. + return redirectTo('/security/enroll'); + } + if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { + // Has a factor but session is only aal1 → force step-up, preserving the + // originally requested destination. + const next = encodeURIComponent(`${path}${request.nextUrl.search}`); + return redirectTo('/security/challenge', `?next=${next}`); } } diff --git a/package-lock.json b/package-lock.json index 44b5a6a..2cce852 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,9 @@ "name": "linumiq-web", "version": "1.0.0", "dependencies": { - "@supabase/ssr": "0.5.2", - "@supabase/supabase-js": "2.45.4", + "@simplewebauthn/browser": "13.3.0", + "@supabase/ssr": "0.10.3", + "@supabase/supabase-js": "2.106.2", "next": "14.2.15", "react": "18.3.1", "react-dom": "18.3.1", @@ -168,91 +169,106 @@ "node": ">= 10" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { - "version": "2.65.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz", - "integrity": "sha512-+wboHfZufAE2Y612OsKeVP4rVOeGZzzMLD/Ac3HrTQkkY4qXNjI6Af9gtmxwccE5nFvTiF114FEbIQ1hRq5uUw==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz", + "integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/functions-js": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.1.tgz", - "integrity": "sha512-8sZ2ibwHlf+WkHDUZJUXqqmPvWQ3UHN0W30behOJngVh/qHHekhJLCFbh0AjkE9/FqqXtf9eoVvmYgfCLk5tNA==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz", + "integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/node-fetch": { - "version": "2.6.15", - "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", - "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" + "tslib": "2.8.1" }, "engines": { - "node": "4.x || >=6.0.0" + "node": ">=20.0.0" } }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, "node_modules/@supabase/postgrest-js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.1.tgz", - "integrity": "sha512-EOSEZFm5pPuCPGCmLF1VOCS78DfkSz600PBuvBND/IZmMciJ1pmsS3ss6TkB6UkuvTybYiBh7gKOYyxoEO3USA==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz", + "integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/realtime-js": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.2.tgz", - "integrity": "sha512-qyCQaNg90HmJstsvr2aJNxK2zgoKh9ZZA8oqb7UT2LCh3mj9zpa3Iwu167AuyNxsxrUE8eEJ2yH6wLCij4EApA==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz", + "integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14", - "@types/phoenix": "^1.5.4", - "@types/ws": "^8.5.10", - "ws": "^8.14.2" + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/ssr": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.5.2.tgz", - "integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.3.tgz", + "integrity": "sha512-ux2CJgX89h0Fz2lY7ZNafNG2SkXpyRc5dz77K9eKeBLPdtywQixKwIuetDeIViAJBp/buOUVmgj8PVesOklNpw==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.7.0" + "cookie": "^1.0.2" }, "peerDependencies": { - "@supabase/supabase-js": "^2.43.4" + "@supabase/supabase-js": "^2.105.3" } }, "node_modules/@supabase/storage-js": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.0.tgz", - "integrity": "sha512-iZenEdO6Mx9iTR6T7wC7sk6KKsoDPLq8rdu5VRy7+JiT1i8fnqfcOr6mfF2Eaqky9VQzhP8zZKQYjzozB65Rig==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz", + "integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==", "license": "MIT", "dependencies": { - "@supabase/node-fetch": "^2.6.14" + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@supabase/supabase-js": { - "version": "2.45.4", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.4.tgz", - "integrity": "sha512-E5p8/zOLaQ3a462MZnmnz03CrduA5ySH9hZyL03Y+QZLIOO4/Gs8Rdy4ZCKDHsN7x0xdanVEWWFN3pJFQr9/hg==", + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz", + "integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.65.0", - "@supabase/functions-js": "2.4.1", - "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.16.1", - "@supabase/realtime-js": "2.10.2", - "@supabase/storage-js": "2.7.0" + "@supabase/auth-js": "2.106.2", + "@supabase/functions-js": "2.106.2", + "@supabase/postgrest-js": "2.106.2", + "@supabase/realtime-js": "2.106.2", + "@supabase/storage-js": "2.106.2" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@swc/counter": { @@ -271,27 +287,16 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "20.16.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, - "node_modules/@types/phoenix": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", - "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -320,15 +325,6 @@ "@types/react": "*" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -367,12 +363,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/csstype": { @@ -388,6 +388,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -583,12 +592,6 @@ } } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -622,44 +625,8 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, "license": "MIT" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index 6358ab2..91b1371 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "start": "next start" }, "dependencies": { - "@supabase/ssr": "0.5.2", - "@supabase/supabase-js": "2.45.4", + "@simplewebauthn/browser": "13.3.0", + "@supabase/ssr": "0.10.3", + "@supabase/supabase-js": "2.106.2", "next": "14.2.15", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/supabase/migrations/0002_mfa_recovery.sql b/supabase/migrations/0002_mfa_recovery.sql new file mode 100644 index 0000000..82dc014 --- /dev/null +++ b/supabase/migrations/0002_mfa_recovery.sql @@ -0,0 +1,30 @@ +-- 0002_mfa_recovery.sql +-- Additive, idempotent migration for mandatory-MFA recovery codes. +-- Safe to run multiple times. + +-- 1. Recovery codes ------------------------------------------------------- +-- One row per single-use recovery code. We only ever store a SHA-256 hash of +-- the code; the plaintext is shown to the user exactly once at generation. +create table if not exists public.mfa_recovery_codes ( + id bigint generated always as identity primary key, + user_id uuid not null, + code_hash text not null, + used_at timestamptz, + created_at timestamptz not null default now() +); + +create index if not exists mfa_recovery_codes_user_id_idx + on public.mfa_recovery_codes (user_id); + +-- Fast lookup of an unused code by (user, hash) during redemption. +create unique index if not exists mfa_recovery_codes_user_hash_uidx + on public.mfa_recovery_codes (user_id, code_hash); + +-- 2. Lock down with RLS, NO policies --------------------------------------- +-- RLS enabled with NO policies so anon/authenticated roles cannot read or +-- write recovery codes at all. The application generates and redeems codes +-- exclusively through the service-role client, which bypasses RLS. +alter table public.mfa_recovery_codes enable row level security; + +comment on table public.mfa_recovery_codes is + 'Single-use MFA recovery codes (SHA-256 hashed). RLS enabled with no policies: service-role only.';