148 lines
4.6 KiB
TypeScript
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.';
|
|
}
|