passkey-sign-in.tsx170 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState } from 'react';
5
6interface Props {
7 projectId: string;
8}
9
10// ── base64url helpers (no external dep needed) ───────────────────────────────
11
12function base64urlToUint8Array(b64: string): Uint8Array<ArrayBuffer> {
13 const base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
14 const binary = atob(base64);
15 const bytes = new Uint8Array(binary.length) as Uint8Array<ArrayBuffer>;
16 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
17 return bytes;
18}
19
20function uint8ArrayToBase64url(bytes: Uint8Array): string {
21 let binary = '';
22 for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i] as number);
23 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
24}
25
26interface WebAuthnAllowedCredential {
27 id: string;
28 type: string;
29 transports?: AuthenticatorTransport[];
30}
31
32interface WebAuthnAuthOptions {
33 challenge: string;
34 rpId?: string;
35 timeout?: number;
36 userVerification?: UserVerificationRequirement;
37 allowCredentials?: WebAuthnAllowedCredential[];
38}
39
40interface ErrorBody {
41 code?: string;
42 message?: string;
43 error?: { code?: string; message?: string };
44}
45
46/**
47 * "Sign in with passkey" button for the hosted sign-in flow.
48 *
49 * Flow (@better-auth/passkey@1.6.9, two-leg WebAuthn ceremony — endpoint ids
50 * confirmed from the installed plugin dist):
51 * 1. GET /api/v1/auth-tenant/passkey/generate-authenticate-options — challenge
52 * 2. navigator.credentials.get() — browser prompts the user
53 * 3. POST /api/v1/auth-tenant/passkey/verify-authentication — body { response }
54 * (the serialised assertion); the api sets the session cookie on success
55 */
56export function PasskeySignIn({ projectId }: Props) {
57 const router = useRouter();
58 const [pending, setPending] = useState(false);
59 const [error, setError] = useState<string | null>(null);
60
61 async function handlePasskeySignIn(): Promise<void> {
62 if (!window.PublicKeyCredential) {
63 setError('your browser does not support passkeys');
64 return;
65 }
66 setPending(true);
67 setError(null);
68 try {
69 // Step 1: authentication options (challenge). GET.
70 const optRes = await fetch('/api/v1/auth-tenant/passkey/generate-authenticate-options', {
71 method: 'GET',
72 credentials: 'include',
73 headers: { 'x-briven-project-id': projectId },
74 });
75 if (!optRes.ok) {
76 if (optRes.status === 404 || optRes.status === 501) {
77 throw new Error('passkey sign-in is not enabled for this account');
78 }
79 const err = (await optRes.json().catch(() => ({}))) as ErrorBody;
80 throw new Error(err.error?.message ?? err.message ?? err.code ?? `http ${optRes.status}`);
81 }
82 const opts = (await optRes.json()) as WebAuthnAuthOptions;
83
84 // Step 2: browser WebAuthn credential retrieval. Challenge + allowed
85 // credential ids arrive base64url-encoded and must be decoded.
86 const credential = (await navigator.credentials.get({
87 publicKey: {
88 challenge: base64urlToUint8Array(opts.challenge),
89 rpId: opts.rpId,
90 timeout: opts.timeout,
91 userVerification: opts.userVerification,
92 allowCredentials: (opts.allowCredentials ?? []).map((c) => ({
93 id: base64urlToUint8Array(c.id),
94 type: c.type as PublicKeyCredentialType,
95 transports: c.transports,
96 })),
97 },
98 })) as PublicKeyCredential | null;
99
100 if (!credential) throw new Error('passkey prompt was cancelled');
101
102 const assertion = credential.response as AuthenticatorAssertionResponse;
103
104 // Step 3: serialise the assertion to @simplewebauthn JSON, wrapped in
105 // `{ response }`, and verify; the api sets the session cookie on success.
106 const verRes = await fetch('/api/v1/auth-tenant/passkey/verify-authentication', {
107 method: 'POST',
108 credentials: 'include',
109 headers: {
110 'content-type': 'application/json',
111 'x-briven-project-id': projectId,
112 },
113 body: JSON.stringify({
114 response: {
115 id: credential.id,
116 rawId: uint8ArrayToBase64url(new Uint8Array(credential.rawId)),
117 type: credential.type,
118 clientExtensionResults: credential.getClientExtensionResults(),
119 authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
120 response: {
121 authenticatorData: uint8ArrayToBase64url(
122 new Uint8Array(assertion.authenticatorData),
123 ),
124 clientDataJSON: uint8ArrayToBase64url(new Uint8Array(assertion.clientDataJSON)),
125 signature: uint8ArrayToBase64url(new Uint8Array(assertion.signature)),
126 userHandle: assertion.userHandle
127 ? uint8ArrayToBase64url(new Uint8Array(assertion.userHandle))
128 : undefined,
129 },
130 },
131 }),
132 });
133
134 if (!verRes.ok) {
135 const err = (await verRes.json().catch(() => ({}))) as ErrorBody;
136 throw new Error(
137 err.error?.message ?? err.message ?? err.code ?? 'passkey verification failed',
138 );
139 }
140
141 router.push(`/auth/${projectId}/account`);
142 } catch (err) {
143 if (err instanceof DOMException && err.name === 'NotAllowedError') {
144 setError('passkey prompt was dismissed');
145 } else {
146 setError(err instanceof Error ? err.message : 'passkey sign-in failed');
147 }
148 } finally {
149 setPending(false);
150 }
151 }
152
153 return (
154 <div className="flex flex-col gap-2">
155 <button
156 type="button"
157 onClick={() => void handlePasskeySignIn()}
158 disabled={pending}
159 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"
160 >
161 {pending ? 'waiting for passkey…' : 'sign in with passkey'}
162 </button>
163 {error ? (
164 <p className="font-mono text-[11px] text-[var(--color-error)]" role="alert">
165 {error}
166 </p>
167 ) : null}
168 </div>
169 );
170}