feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes

This commit is contained in:
Gerhard Scheikl
2026-05-31 21:38:01 +02:00
parent 129e21529c
commit e14e909700
19 changed files with 1310 additions and 142 deletions
+33
View File
@@ -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;
}
+31
View File
@@ -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;
}
+147
View File
@@ -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.';
}