feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes
This commit is contained in:
@@ -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<NextResponse> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -45,6 +45,12 @@ a:hover {
|
|||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default async function RootLayout({
|
|||||||
<>
|
<>
|
||||||
<Link href="/dashboard">Dashboard</Link>
|
<Link href="/dashboard">Dashboard</Link>
|
||||||
<Link href="/billing">Billing</Link>
|
<Link href="/billing">Billing</Link>
|
||||||
|
<Link href="/security">Security</Link>
|
||||||
{isAdmin && <Link href="/admin">Admin</Link>}
|
{isAdmin && <Link href="/admin">Admin</Link>}
|
||||||
<form action="/api/auth/signout" method="post" style={{ margin: 0 }}>
|
<form action="/api/auth/signout" method="post" style={{ margin: 0 }}>
|
||||||
<button className="secondary" type="submit">
|
<button className="secondary" type="submit">
|
||||||
|
|||||||
+8
-2
@@ -29,6 +29,10 @@ export default function LoginPage() {
|
|||||||
setError(
|
setError(
|
||||||
'Email verification failed or the link expired. Sign in below to resend a confirmation email.',
|
'Email verification failed or the link expired. Sign in below to resend a confirmation email.',
|
||||||
);
|
);
|
||||||
|
} else if (params.get('mfa_reset') === '1') {
|
||||||
|
setResendInfo(
|
||||||
|
'Your two-factor methods were reset. Sign in to set up a new one.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -59,7 +63,9 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push('/dashboard');
|
// Hand off to the MFA gate: the challenge page completes step-up (or
|
||||||
|
// redirects to enrollment for users without a verified factor).
|
||||||
|
router.push('/security/challenge');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,7 +126,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="row" style={{ marginTop: '1rem' }}>
|
<div className="row" style={{ marginTop: '1rem' }}>
|
||||||
<button type="submit" disabled={isPending}>
|
<button type="submit" disabled={isPending}>
|
||||||
{isPending ? 'Signing in…' : 'Sign in'}
|
{isPending ? 'Verifying…' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
<Link className="muted" href="/signup">
|
<Link className="muted" href="/signup">
|
||||||
Need an account?
|
Need an account?
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="stack">
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{passkeyFactorId && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Passkey</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Verify with your device biometrics or security key.
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={verifyPasskey} disabled={busy}>
|
||||||
|
{busy ? 'Waiting…' : 'Use a passkey'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totpFactorId && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Authenticator app</h2>
|
||||||
|
<form onSubmit={verifyTotp}>
|
||||||
|
<label htmlFor="challenge-code">6-digit code</label>
|
||||||
|
<input
|
||||||
|
id="challenge-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button type="submit" disabled={busy || code.trim().length < 6}>
|
||||||
|
{busy ? 'Verifying…' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Lost access?</h2>
|
||||||
|
{showRecovery ? (
|
||||||
|
<form onSubmit={redeemRecovery}>
|
||||||
|
<p className="muted">
|
||||||
|
Enter a recovery code. This removes your current two-factor
|
||||||
|
methods so you can set up new ones after signing in again.
|
||||||
|
</p>
|
||||||
|
<label htmlFor="recovery-code">Recovery code</label>
|
||||||
|
<input
|
||||||
|
id="recovery-code"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="xxxx-xxxx-xxxx"
|
||||||
|
required
|
||||||
|
value={recovery}
|
||||||
|
onChange={(e) => setRecovery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-danger"
|
||||||
|
disabled={busy || recovery.trim().length === 0}
|
||||||
|
>
|
||||||
|
{busy ? 'Working…' : 'Use recovery code'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setShowRecovery(true)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Use a recovery code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="container">
|
||||||
|
<h1>Verify it's you</h1>
|
||||||
|
<p className="muted">Complete two-factor authentication to continue.</p>
|
||||||
|
<ChallengeClient
|
||||||
|
totpFactorId={totp?.id ?? null}
|
||||||
|
passkeyFactorId={passkey?.id ?? null}
|
||||||
|
next={next}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="stack">
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{codes.length > 0 ? (
|
||||||
|
<RecoveryCodesPanel codes={codes} />
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Two-factor authentication enabled</h2>
|
||||||
|
<p className="muted">Your account is now protected.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="row">
|
||||||
|
<button type="button" onClick={finish}>
|
||||||
|
Continue to dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<TotpEnrollPanel supabase={supabase} onVerified={handleVerified} />
|
||||||
|
<PasskeyEnrollPanel supabase={supabase} onVerified={handleVerified} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="container">
|
||||||
|
<h1>Secure your account</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Two-factor authentication is required. Add at least one method below to
|
||||||
|
continue.
|
||||||
|
</p>
|
||||||
|
<EnrollClient />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Authenticator app</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Use an app like Google Authenticator, 1Password, or Authy to generate
|
||||||
|
6-digit codes.
|
||||||
|
</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="button" onClick={start} disabled={busy}>
|
||||||
|
{busy ? 'Preparing…' : 'Set up authenticator app'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Scan the QR code</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Scan this with your authenticator app, then enter the 6-digit code to
|
||||||
|
confirm.
|
||||||
|
</p>
|
||||||
|
{qr && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={qr}
|
||||||
|
alt="TOTP QR code"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
style={{ background: '#fff', padding: 8, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="muted" style={{ marginTop: '0.75rem' }}>
|
||||||
|
Can't scan? Enter this secret manually:
|
||||||
|
</p>
|
||||||
|
<div className="token">{secret}</div>
|
||||||
|
<form onSubmit={verify}>
|
||||||
|
<label htmlFor="totp-code">6-digit code</label>
|
||||||
|
<input
|
||||||
|
id="totp-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button type="submit" disabled={busy || code.trim().length < 6}>
|
||||||
|
{busy ? 'Verifying…' : 'Verify and enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string | null>(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 (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Passkey</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Use your device's biometrics, screen lock, or a hardware security
|
||||||
|
key.
|
||||||
|
</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="button" onClick={start} disabled={busy}>
|
||||||
|
{busy ? 'Waiting for passkey…' : 'Set up a passkey'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Save your recovery codes</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="token"
|
||||||
|
style={{ display: 'grid', gap: '0.25rem', lineHeight: 1.8 }}
|
||||||
|
>
|
||||||
|
{codes.map((c) => (
|
||||||
|
<span key={c}>{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button type="button" className="secondary" onClick={copy}>
|
||||||
|
{copied ? 'Copied' : 'Copy codes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="container">
|
||||||
|
<h1>Security</h1>
|
||||||
|
<p className="muted">Manage two-factor authentication for your account.</p>
|
||||||
|
<SecurityClient initialFactors={verified} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<FactorView[]>(initialFactors);
|
||||||
|
const [adding, setAdding] = useState<null | 'totp' | 'webauthn'>(null);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [codes, setCodes] = useState<string[] | null>(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 (
|
||||||
|
<div className="stack">
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{notice && <p className="success">{notice}</p>}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Your two-factor methods</h2>
|
||||||
|
{factors.length === 0 ? (
|
||||||
|
<p className="muted">No methods enabled.</p>
|
||||||
|
) : (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Added</th>
|
||||||
|
<th aria-label="actions" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{factors.map((f) => (
|
||||||
|
<tr key={f.id}>
|
||||||
|
<td>{TYPE_LABEL[f.type] ?? f.type}</td>
|
||||||
|
<td>{f.friendlyName ?? '—'}</td>
|
||||||
|
<td>{new Date(f.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-sm btn-danger"
|
||||||
|
onClick={() => remove(f.id)}
|
||||||
|
disabled={busyId === f.id || factors.length <= 1}
|
||||||
|
title={
|
||||||
|
factors.length <= 1
|
||||||
|
? 'At least one method is required'
|
||||||
|
: 'Remove this method'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busyId === f.id ? 'Removing…' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding === 'totp' && (
|
||||||
|
<TotpEnrollPanel supabase={supabase} onVerified={onAdded} />
|
||||||
|
)}
|
||||||
|
{adding === 'webauthn' && (
|
||||||
|
<PasskeyEnrollPanel supabase={supabase} onVerified={onAdded} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adding === null && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Add a method</h2>
|
||||||
|
<div className="row">
|
||||||
|
<button type="button" onClick={() => setAdding('totp')}>
|
||||||
|
Add authenticator app
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setAdding('webauthn')}
|
||||||
|
>
|
||||||
|
Add passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Recovery codes</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Generate a new set of single-use recovery codes. This invalidates any
|
||||||
|
codes you were issued before.
|
||||||
|
</p>
|
||||||
|
{codes && <RecoveryCodesPanel codes={codes} />}
|
||||||
|
<div className="row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={regenerate}
|
||||||
|
disabled={regenBusy}
|
||||||
|
>
|
||||||
|
{regenBusy ? 'Generating…' : 'Regenerate recovery codes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> & {
|
||||||
|
msg?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getAccessToken(supabase: SupabaseClient): Promise<string> {
|
||||||
|
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<GoTrueJson> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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.';
|
||||||
|
}
|
||||||
+63
-20
@@ -1,6 +1,26 @@
|
|||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import { NextResponse, type NextRequest } from 'next/server';
|
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) {
|
export async function middleware(request: NextRequest) {
|
||||||
let response = NextResponse.next({ request });
|
let response = NextResponse.next({ request });
|
||||||
|
|
||||||
@@ -27,31 +47,36 @@ export async function middleware(request: NextRequest) {
|
|||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser();
|
} = 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
|
// Defense-in-depth: guard the admin surface here in addition to the
|
||||||
// per-route requireAdmin()/requireAdminApi() checks.
|
// per-route requireAdmin()/requireAdminApi() checks.
|
||||||
const path = request.nextUrl.pathname;
|
|
||||||
if (path.startsWith('/admin') || path.startsWith('/api/admin')) {
|
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 (!user) {
|
||||||
if (path.startsWith('/api/admin')) {
|
if (path.startsWith('/api/admin')) {
|
||||||
return withCookies(
|
return withCookies(
|
||||||
NextResponse.json({ error: 'unauthorized' }, { status: 401 }),
|
NextResponse.json({ error: 'unauthorized' }, { status: 401 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const url = request.nextUrl.clone();
|
return redirectTo('/login');
|
||||||
url.pathname = '/login';
|
|
||||||
url.search = '';
|
|
||||||
return withCookies(NextResponse.redirect(url));
|
|
||||||
}
|
}
|
||||||
if (user.app_metadata?.role !== 'admin') {
|
if (user.app_metadata?.role !== 'admin') {
|
||||||
if (path.startsWith('/api/admin')) {
|
if (path.startsWith('/api/admin')) {
|
||||||
@@ -59,10 +84,28 @@ export async function middleware(request: NextRequest) {
|
|||||||
NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const url = request.nextUrl.clone();
|
return redirectTo('/dashboard');
|
||||||
url.pathname = '/dashboard';
|
}
|
||||||
url.search = '';
|
}
|
||||||
return withCookies(NextResponse.redirect(url));
|
|
||||||
|
// 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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+85
-118
@@ -8,8 +8,9 @@
|
|||||||
"name": "linumiq-web",
|
"name": "linumiq-web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "0.5.2",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@supabase/supabase-js": "2.45.4",
|
"@supabase/ssr": "0.10.3",
|
||||||
|
"@supabase/supabase-js": "2.106.2",
|
||||||
"next": "14.2.15",
|
"next": "14.2.15",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
@@ -168,91 +169,106 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.65.0",
|
"version": "2.106.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz",
|
||||||
"integrity": "sha512-+wboHfZufAE2Y612OsKeVP4rVOeGZzzMLD/Ac3HrTQkkY4qXNjI6Af9gtmxwccE5nFvTiF114FEbIQ1hRq5uUw==",
|
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14"
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/functions-js": {
|
"node_modules/@supabase/functions-js": {
|
||||||
"version": "2.4.1",
|
"version": "2.106.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz",
|
||||||
"integrity": "sha512-8sZ2ibwHlf+WkHDUZJUXqqmPvWQ3UHN0W30behOJngVh/qHHekhJLCFbh0AjkE9/FqqXtf9eoVvmYgfCLk5tNA==",
|
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14"
|
"tslib": "2.8.1"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"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": {
|
"node_modules/@supabase/postgrest-js": {
|
||||||
"version": "1.16.1",
|
"version": "2.106.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz",
|
||||||
"integrity": "sha512-EOSEZFm5pPuCPGCmLF1VOCS78DfkSz600PBuvBND/IZmMciJ1pmsS3ss6TkB6UkuvTybYiBh7gKOYyxoEO3USA==",
|
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14"
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/realtime-js": {
|
"node_modules/@supabase/realtime-js": {
|
||||||
"version": "2.10.2",
|
"version": "2.106.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz",
|
||||||
"integrity": "sha512-qyCQaNg90HmJstsvr2aJNxK2zgoKh9ZZA8oqb7UT2LCh3mj9zpa3Iwu167AuyNxsxrUE8eEJ2yH6wLCij4EApA==",
|
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14",
|
"@supabase/phoenix": "^0.4.2",
|
||||||
"@types/phoenix": "^1.5.4",
|
"tslib": "2.8.1"
|
||||||
"@types/ws": "^8.5.10",
|
},
|
||||||
"ws": "^8.14.2"
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/ssr": {
|
"node_modules/@supabase/ssr": {
|
||||||
"version": "0.5.2",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.3.tgz",
|
||||||
"integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==",
|
"integrity": "sha512-ux2CJgX89h0Fz2lY7ZNafNG2SkXpyRc5dz77K9eKeBLPdtywQixKwIuetDeIViAJBp/buOUVmgj8PVesOklNpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/cookie": "^0.6.0",
|
"cookie": "^1.0.2"
|
||||||
"cookie": "^0.7.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@supabase/supabase-js": "^2.43.4"
|
"@supabase/supabase-js": "^2.105.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/storage-js": {
|
"node_modules/@supabase/storage-js": {
|
||||||
"version": "2.7.0",
|
"version": "2.106.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz",
|
||||||
"integrity": "sha512-iZenEdO6Mx9iTR6T7wC7sk6KKsoDPLq8rdu5VRy7+JiT1i8fnqfcOr6mfF2Eaqky9VQzhP8zZKQYjzozB65Rig==",
|
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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": {
|
"node_modules/@supabase/supabase-js": {
|
||||||
"version": "2.45.4",
|
"version": "2.106.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.4.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz",
|
||||||
"integrity": "sha512-E5p8/zOLaQ3a462MZnmnz03CrduA5ySH9hZyL03Y+QZLIOO4/Gs8Rdy4ZCKDHsN7x0xdanVEWWFN3pJFQr9/hg==",
|
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.65.0",
|
"@supabase/auth-js": "2.106.2",
|
||||||
"@supabase/functions-js": "2.4.1",
|
"@supabase/functions-js": "2.106.2",
|
||||||
"@supabase/node-fetch": "2.6.15",
|
"@supabase/postgrest-js": "2.106.2",
|
||||||
"@supabase/postgrest-js": "1.16.1",
|
"@supabase/realtime-js": "2.106.2",
|
||||||
"@supabase/realtime-js": "2.10.2",
|
"@supabase/storage-js": "2.106.2"
|
||||||
"@supabase/storage-js": "2.7.0"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
@@ -271,27 +287,16 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.16.10",
|
"version": "20.16.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz",
|
||||||
"integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==",
|
"integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.19.2"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -320,15 +325,6 @@
|
|||||||
"@types/react": "*"
|
"@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": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
@@ -367,12 +363,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
@@ -388,6 +388,15 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -622,44 +625,8 @@
|
|||||||
"version": "6.19.8",
|
"version": "6.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -11,8 +11,9 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "0.5.2",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@supabase/supabase-js": "2.45.4",
|
"@supabase/ssr": "0.10.3",
|
||||||
|
"@supabase/supabase-js": "2.106.2",
|
||||||
"next": "14.2.15",
|
"next": "14.2.15",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
|||||||
@@ -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.';
|
||||||
Reference in New Issue
Block a user