passkey-register.tsx187 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Props {
6 projectId: string;
7}
8
9// ── base64url helpers ────────────────────────────────────────────────────────
10
11function base64urlToUint8Array(b64: string): Uint8Array<ArrayBuffer> {
12 const base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
13 const binary = atob(base64);
14 const bytes = new Uint8Array(binary.length) as Uint8Array<ArrayBuffer>;
15 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
16 return bytes;
17}
18
19function uint8ArrayToBase64url(bytes: Uint8Array): string {
20 let binary = '';
21 for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i] as number);
22 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
23}
24
25interface WebAuthnCredentialDescriptor {
26 id: string;
27 type: string;
28 transports?: AuthenticatorTransport[];
29}
30
31interface WebAuthnCreationOptions {
32 challenge: string;
33 rp: { id?: string; name: string };
34 user: { id: string; name: string; displayName: string };
35 pubKeyCredParams: PublicKeyCredentialParameters[];
36 timeout?: number;
37 excludeCredentials?: WebAuthnCredentialDescriptor[];
38 authenticatorSelection?: AuthenticatorSelectionCriteria;
39 attestation?: AttestationConveyancePreference;
40}
41
42interface ErrorBody {
43 code?: string;
44 message?: string;
45 error?: { code?: string; message?: string };
46}
47
48/**
49 * "Register a passkey" button for the hosted account page.
50 *
51 * Flow (@better-auth/passkey@1.6.9, two-leg WebAuthn ceremony — endpoint ids
52 * confirmed from the installed plugin dist):
53 * 1. GET /api/v1/auth-tenant/passkey/generate-register-options — creation
54 * options (requires a fresh session; this lives on the post-login account
55 * page so the cookie rides along)
56 * 2. navigator.credentials.create() — browser creates the passkey
57 * 3. POST /api/v1/auth-tenant/passkey/verify-registration — body { response }
58 * (the serialised credential) to verify and store
59 */
60export function PasskeyRegister({ projectId }: Props) {
61 const [pending, setPending] = useState(false);
62 const [error, setError] = useState<string | null>(null);
63 const [success, setSuccess] = useState(false);
64
65 async function handleRegister(): Promise<void> {
66 if (!window.PublicKeyCredential) {
67 setError('your browser does not support passkeys');
68 return;
69 }
70 setPending(true);
71 setError(null);
72 setSuccess(false);
73 try {
74 // Step 1: registration options (plugin requires a fresh session). GET.
75 const optRes = await fetch('/api/v1/auth-tenant/passkey/generate-register-options', {
76 method: 'GET',
77 credentials: 'include',
78 headers: { 'x-briven-project-id': projectId },
79 });
80 if (!optRes.ok) {
81 if (optRes.status === 404 || optRes.status === 501) {
82 throw new Error('passkey registration is not available for this account');
83 }
84 const err = (await optRes.json().catch(() => ({}))) as ErrorBody;
85 throw new Error(err.error?.message ?? err.message ?? err.code ?? `http ${optRes.status}`);
86 }
87 const opts = (await optRes.json()) as WebAuthnCreationOptions;
88
89 // Step 2: browser creates the passkey. Challenge + every credential id
90 // arrive base64url-encoded and must be decoded to byte buffers.
91 const credential = (await navigator.credentials.create({
92 publicKey: {
93 challenge: base64urlToUint8Array(opts.challenge),
94 rp: opts.rp,
95 user: {
96 id: base64urlToUint8Array(opts.user.id),
97 name: opts.user.name,
98 displayName: opts.user.displayName,
99 },
100 pubKeyCredParams: opts.pubKeyCredParams,
101 timeout: opts.timeout,
102 excludeCredentials: (opts.excludeCredentials ?? []).map((c) => ({
103 id: base64urlToUint8Array(c.id),
104 type: c.type as PublicKeyCredentialType,
105 transports: c.transports,
106 })),
107 authenticatorSelection: opts.authenticatorSelection,
108 attestation: opts.attestation,
109 },
110 })) as PublicKeyCredential | null;
111
112 if (!credential) throw new Error('passkey creation was cancelled');
113
114 const attestation = credential.response as AuthenticatorAttestationResponse;
115
116 // Step 3: serialise the credential to @simplewebauthn JSON, wrapped in
117 // `{ response }`, and verify. The plugin joins `response.transports`
118 // unconditionally, so it must be present (empty array when none).
119 const verRes = await fetch('/api/v1/auth-tenant/passkey/verify-registration', {
120 method: 'POST',
121 credentials: 'include',
122 headers: {
123 'content-type': 'application/json',
124 'x-briven-project-id': projectId,
125 },
126 body: JSON.stringify({
127 response: {
128 id: credential.id,
129 rawId: uint8ArrayToBase64url(new Uint8Array(credential.rawId)),
130 type: credential.type,
131 clientExtensionResults: credential.getClientExtensionResults(),
132 authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
133 response: {
134 clientDataJSON: uint8ArrayToBase64url(new Uint8Array(attestation.clientDataJSON)),
135 attestationObject: uint8ArrayToBase64url(
136 new Uint8Array(attestation.attestationObject),
137 ),
138 transports: attestation.getTransports ? attestation.getTransports() : [],
139 },
140 },
141 }),
142 });
143
144 if (!verRes.ok) {
145 const err = (await verRes.json().catch(() => ({}))) as ErrorBody;
146 throw new Error(
147 err.error?.message ?? err.message ?? err.code ?? 'passkey registration failed',
148 );
149 }
150
151 setSuccess(true);
152 } catch (err) {
153 if (err instanceof DOMException && err.name === 'NotAllowedError') {
154 setError('passkey prompt was dismissed');
155 } else {
156 setError(err instanceof Error ? err.message : 'passkey registration failed');
157 }
158 } finally {
159 setPending(false);
160 }
161 }
162
163 return (
164 <div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-4">
165 <p className="font-mono text-[11px] text-[var(--color-text-subtle)]">passkey</p>
166 {success ? (
167 <p className="font-mono text-xs text-[var(--color-text-muted)]">
168 passkey registered — you can now sign in without a password.
169 </p>
170 ) : (
171 <button
172 type="button"
173 onClick={() => void handleRegister()}
174 disabled={pending}
175 className="self-start rounded-md border border-[var(--color-border)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] disabled:opacity-50"
176 >
177 {pending ? 'registering…' : 'register a passkey'}
178 </button>
179 )}
180 {error ? (
181 <p className="font-mono text-[11px] text-[var(--color-error)]" role="alert">
182 {error}
183 </p>
184 ) : null}
185 </div>
186 );
187}