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,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.';
|
||||
}
|
||||
Reference in New Issue
Block a user