Repository Public
1// Who is signed in, and the passkey ceremonies. Screens never call WebAuthn directly.2import { startAuthentication, startRegistration } from '@simplewebauthn/browser';3import { useQuery, useQueryClient } from '@tanstack/react-query';4import { ApiError, get, post } from './api.ts';5import type { AuthState } from './types.ts';67export const AUTH_KEY = ['auth'];8export function useAuth() {9 const q = useQuery({ queryKey: AUTH_KEY, queryFn: () => get<AuthState>('/auth/state'), staleTime: 30_000 });10 return { state: q.data, loading: q.isLoading, error: q.error, refetch: q.refetch };11}12export function useRefreshAuth() { const qc = useQueryClient(); return () => qc.invalidateQueries({ queryKey: AUTH_KEY }); }1314/** The user dismissed the system passkey sheet, or the device has no usable passkey. */15export class PasskeyCancelled extends Error {}16const ceremony = async <T>(fn: () => Promise<T>): Promise<T> => {17 try { return await fn(); } catch (e) {18 const name = (e as Error)?.name;19 if (name === 'NotAllowedError' || name === 'AbortError') throw new PasskeyCancelled('cancelled');20 throw e;21 }22};23export const passkeysSupported = () => typeof window.PublicKeyCredential === 'function';2425export async function signIn(): Promise<void> {26 const o = await post<{ challengeId: string; options: any }>('/auth/login/options');27 const response = await ceremony(() => startAuthentication({ optionsJSON: o.options }));28 await post('/auth/login/verify', { challengeId: o.challengeId, response });29}30export async function register(invite: string, displayName: string, label: string): Promise<string[]> {31 const o = await post<{ challengeId: string; options: any }>('/auth/register/options', { invite, displayName });32 const response = await ceremony(() => startRegistration({ optionsJSON: o.options }));33 return (await post<{ recoveryCodes: string[] }>('/auth/register/verify', { invite, challengeId: o.challengeId, response, label })).recoveryCodes;34}35export async function reauthenticate(): Promise<void> {36 const o = await post<{ challengeId: string; options: any }>('/auth/reauth/options');37 const response = await ceremony(() => startAuthentication({ optionsJSON: o.options }));38 await post('/auth/reauth/verify', { challengeId: o.challengeId, response });39}40export async function addPasskey(label: string): Promise<void> {41 const o = await withFreshAuth(() => post<{ challengeId: string; options: any }>('/account/passkeys/options'));42 const response = await ceremony(() => startRegistration({ optionsJSON: o.options }));43 await post('/account/passkeys/verify', { challengeId: o.challengeId, response, label });44}45/** Run a sensitive call; when the server asks for a recent passkey confirmation, do it and retry once. */46export async function withFreshAuth<T>(fn: () => Promise<T>): Promise<T> {47 try { return await fn(); } catch (e) {48 if (!(e instanceof ApiError) || e.code !== 'reauth_required') throw e;49 await reauthenticate();50 return fn();51 }52}53/** A readable default name for a new passkey, from the device. */54export function deviceLabel(): string {55 const ua = navigator.userAgent;56 const device = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Macintosh/.test(ua) ? 'Mac' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : 'Appareil';57 const browser = /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Firefox\//.test(ua) ? 'Firefox' : /Safari\//.test(ua) ? 'Safari' : '';58 return [device, browser].filter(Boolean).join(' · ');59}60