Files
linumiq_net-web_app/lib/auth/webauthn-client.ts
T

148 lines
4.6 KiB
TypeScript

'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.';
}