passkey-register.tsx195 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Props {
6 projectId: string;
7 /** Optional publishable pk_briven_auth_… */
8 authPublicKey?: string | null;
9}
10
11// ── base64url helpers ────────────────────────────────────────────────────────
12
13function base64urlToUint8Array(b64: string): Uint8Array<ArrayBuffer> {
14 const base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
15 const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
16 const binary = atob(base64 + pad);
17 const bytes = new Uint8Array(binary.length) as Uint8Array<ArrayBuffer>;
18 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
19 return bytes;
20}
21
22function uint8ArrayToBase64url(bytes: Uint8Array): string {
23 let binary = '';
24 for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i] as number);
25 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
26}
27
28interface WebAuthnCredentialDescriptor {
29 id: string;
30 type: string;
31 transports?: AuthenticatorTransport[];
32}
33
34interface WebAuthnCreationOptions {
35 challenge: string;
36 rp: { id?: string; name: string };
37 user: { id: string; name: string; displayName: string };
38 pubKeyCredParams: PublicKeyCredentialParameters[];
39 timeout?: number;
40 excludeCredentials?: WebAuthnCredentialDescriptor[];
41 authenticatorSelection?: AuthenticatorSelectionCriteria;
42 attestation?: AttestationConveyancePreference;
43}
44
45/**
46 * Register a passkey — briven-engine FDI (session cookie required).
47 *
48 * SuperTokens model: sign in first (magic link / OTP / password), then add passkey.
49 */
50export function PasskeyRegister({ projectId, authPublicKey }: Props) {
51 const [pending, setPending] = useState(false);
52 const [error, setError] = useState<string | null>(null);
53 const [success, setSuccess] = useState(false);
54
55 async function fdi(path: string, body: Record<string, unknown>): Promise<Response> {
56 const headers: Record<string, string> = {
57 'content-type': 'application/json',
58 'x-briven-project-id': projectId,
59 rid: 'webauthn',
60 'st-auth-mode': 'cookie',
61 };
62 if (authPublicKey?.startsWith('pk_briven_auth_')) {
63 headers.authorization = `Bearer ${authPublicKey}`;
64 }
65 return fetch(`/api/auth${path}`, {
66 method: 'POST',
67 credentials: 'include',
68 headers,
69 body: JSON.stringify(body),
70 });
71 }
72
73 async function handleRegister(): Promise<void> {
74 if (!window.PublicKeyCredential) {
75 setError('your browser does not support passkeys');
76 return;
77 }
78 setPending(true);
79 setError(null);
80 setSuccess(false);
81 try {
82 const rpId = window.location.hostname;
83 const expectedOrigin = window.location.origin;
84
85 const optRes = await fdi('/webauthn/register/options', { rpId, expectedOrigin });
86 if (!optRes.ok) {
87 if (optRes.status === 401) {
88 throw new Error('sign in first, then add a passkey');
89 }
90 const err = (await optRes.json().catch(() => ({}))) as {
91 message?: string;
92 code?: string;
93 };
94 throw new Error(err.message ?? err.code ?? `http ${optRes.status}`);
95 }
96 const data = (await optRes.json()) as {
97 status?: string;
98 challengeId?: string;
99 options?: WebAuthnCreationOptions;
100 };
101 if (data.status && data.status !== 'OK') {
102 throw new Error(data.status);
103 }
104 const challengeId = String(data.challengeId ?? '');
105 const opts = (data.options ?? data) as WebAuthnCreationOptions;
106 if (!opts.challenge || !challengeId) {
107 throw new Error('passkey registration challenge missing');
108 }
109
110 const credential = (await navigator.credentials.create({
111 publicKey: {
112 challenge: base64urlToUint8Array(opts.challenge),
113 rp: opts.rp,
114 user: {
115 id: base64urlToUint8Array(opts.user.id),
116 name: opts.user.name,
117 displayName: opts.user.displayName,
118 },
119 pubKeyCredParams: opts.pubKeyCredParams,
120 timeout: opts.timeout,
121 excludeCredentials: (opts.excludeCredentials ?? []).map((c) => ({
122 id: base64urlToUint8Array(c.id),
123 type: c.type as PublicKeyCredentialType,
124 transports: c.transports,
125 })),
126 authenticatorSelection: opts.authenticatorSelection,
127 attestation: opts.attestation,
128 },
129 })) as PublicKeyCredential | null;
130
131 if (!credential) throw new Error('passkey creation was cancelled');
132
133 const attestation = credential.response as AuthenticatorAttestationResponse;
134 const credentialJson = {
135 id: credential.id,
136 rawId: uint8ArrayToBase64url(new Uint8Array(credential.rawId)),
137 type: credential.type,
138 clientExtensionResults: credential.getClientExtensionResults(),
139 authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
140 response: {
141 clientDataJSON: uint8ArrayToBase64url(new Uint8Array(attestation.clientDataJSON)),
142 attestationObject: uint8ArrayToBase64url(new Uint8Array(attestation.attestationObject)),
143 transports: attestation.getTransports ? attestation.getTransports() : [],
144 },
145 };
146
147 const verRes = await fdi('/webauthn/register/finish', {
148 challengeId,
149 credential: credentialJson,
150 response: credentialJson,
151 rpId,
152 expectedOrigin,
153 });
154 if (!verRes.ok) {
155 const err = (await verRes.json().catch(() => ({}))) as {
156 message?: string;
157 code?: string;
158 };
159 throw new Error(err.message ?? err.code ?? 'passkey registration failed');
160 }
161 setSuccess(true);
162 } catch (err) {
163 if (err instanceof DOMException && err.name === 'NotAllowedError') {
164 setError('passkey prompt was dismissed');
165 } else {
166 setError(err instanceof Error ? err.message : 'passkey registration failed');
167 }
168 } finally {
169 setPending(false);
170 }
171 }
172
173 return (
174 <div className="flex flex-col gap-2">
175 <button
176 type="button"
177 onClick={() => void handleRegister()}
178 disabled={pending || success}
179 className="w-full rounded-md border border-[var(--color-border)] px-3 py-2 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] disabled:opacity-50"
180 >
181 {success ? 'passkey saved' : pending ? 'waiting for passkey…' : 'add a passkey'}
182 </button>
183 {error ? (
184 <p className="font-mono text-[11px] text-[var(--color-error)]" role="alert">
185 {error}
186 </p>
187 ) : null}
188 {success ? (
189 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
190 next time you can sign in with this passkey on this site
191 </p>
192 ) : null}
193 </div>
194 );
195}