passkey-sign-in.tsx196 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState } from 'react';
5
6interface Props {
7 projectId: string;
8 /** Optional publishable pk_briven_auth_… (apps that inject via proxy can omit). */
9 authPublicKey?: string | null;
10}
11
12// ── base64url helpers ────────────────────────────────────────────────────────
13
14function base64urlToUint8Array(b64: string): Uint8Array<ArrayBuffer> {
15 const base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
16 const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
17 const binary = atob(base64 + pad);
18 const bytes = new Uint8Array(binary.length) as Uint8Array<ArrayBuffer>;
19 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
20 return bytes;
21}
22
23function uint8ArrayToBase64url(bytes: Uint8Array): string {
24 let binary = '';
25 for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i] as number);
26 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
27}
28
29interface WebAuthnAllowedCredential {
30 id: string;
31 type: string;
32 transports?: AuthenticatorTransport[];
33}
34
35interface WebAuthnAuthOptions {
36 challenge: string;
37 rpId?: string;
38 timeout?: number;
39 userVerification?: UserVerificationRequirement;
40 allowCredentials?: WebAuthnAllowedCredential[];
41}
42
43/**
44 * Sign in with passkey — briven-engine FDI (not retired auth-tenant).
45 *
46 * POST /api/auth/webauthn/signin/options
47 * navigator.credentials.get()
48 * POST /api/auth/webauthn/signin/finish { challengeId, credential }
49 */
50export function PasskeySignIn({ projectId, authPublicKey }: Props) {
51 const router = useRouter();
52 const [pending, setPending] = useState(false);
53 const [error, setError] = useState<string | null>(null);
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 handlePasskeySignIn(): Promise<void> {
74 if (!window.PublicKeyCredential) {
75 setError('your browser does not support passkeys');
76 return;
77 }
78 setPending(true);
79 setError(null);
80 try {
81 const rpId = window.location.hostname;
82 const expectedOrigin = window.location.origin;
83
84 const optRes = await fdi('/webauthn/signin/options', { rpId, expectedOrigin });
85 if (!optRes.ok) {
86 if (optRes.status === 401) {
87 throw new Error(
88 'passkey sign-in needs a project Auth public key (pk_briven_auth_…) on this host',
89 );
90 }
91 if (optRes.status === 404 || optRes.status === 501) {
92 throw new Error('passkey sign-in is not enabled for this project');
93 }
94 const err = (await optRes.json().catch(() => ({}))) as {
95 message?: string;
96 code?: string;
97 };
98 throw new Error(err.message ?? err.code ?? `http ${optRes.status}`);
99 }
100 const data = (await optRes.json()) as {
101 status?: string;
102 challengeId?: string;
103 options?: WebAuthnAuthOptions;
104 challenge?: string;
105 };
106 if (data.status && data.status !== 'OK') {
107 throw new Error(data.status);
108 }
109 const challengeId = String(data.challengeId ?? '');
110 const options = (data.options ?? data) as WebAuthnAuthOptions;
111 if (!options.challenge || !challengeId) {
112 throw new Error('passkey challenge missing from server');
113 }
114
115 let credential: PublicKeyCredential | null = null;
116 try {
117 credential = (await navigator.credentials.get({
118 publicKey: {
119 challenge: base64urlToUint8Array(options.challenge),
120 rpId: options.rpId ?? rpId,
121 timeout: options.timeout,
122 userVerification: options.userVerification,
123 allowCredentials: (options.allowCredentials ?? []).map((c) => ({
124 id: base64urlToUint8Array(c.id),
125 type: c.type as PublicKeyCredentialType,
126 transports: c.transports,
127 })),
128 },
129 })) as PublicKeyCredential | null;
130 } catch (getErr) {
131 if (getErr instanceof DOMException && getErr.name === 'NotAllowedError') {
132 throw new Error('passkey prompt was dismissed');
133 }
134 throw getErr;
135 }
136
137 if (!credential) throw new Error('passkey prompt was cancelled');
138
139 const assertion = credential.response as AuthenticatorAssertionResponse;
140 const credentialJson = {
141 id: credential.id,
142 rawId: uint8ArrayToBase64url(new Uint8Array(credential.rawId)),
143 type: credential.type,
144 clientExtensionResults: credential.getClientExtensionResults(),
145 authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
146 response: {
147 authenticatorData: uint8ArrayToBase64url(new Uint8Array(assertion.authenticatorData)),
148 clientDataJSON: uint8ArrayToBase64url(new Uint8Array(assertion.clientDataJSON)),
149 signature: uint8ArrayToBase64url(new Uint8Array(assertion.signature)),
150 userHandle: assertion.userHandle
151 ? uint8ArrayToBase64url(new Uint8Array(assertion.userHandle))
152 : undefined,
153 },
154 };
155
156 const verRes = await fdi('/webauthn/signin/finish', {
157 challengeId,
158 credential: credentialJson,
159 response: credentialJson,
160 rpId,
161 expectedOrigin,
162 });
163 if (!verRes.ok) {
164 const err = (await verRes.json().catch(() => ({}))) as {
165 message?: string;
166 code?: string;
167 };
168 throw new Error(err.message ?? err.code ?? 'passkey verification failed');
169 }
170
171 router.push(`/auth/${projectId}/account`);
172 } catch (err) {
173 setError(err instanceof Error ? err.message : 'passkey sign-in failed');
174 } finally {
175 setPending(false);
176 }
177 }
178
179 return (
180 <div className="flex flex-col gap-2">
181 <button
182 type="button"
183 onClick={() => void handlePasskeySignIn()}
184 disabled={pending}
185 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"
186 >
187 {pending ? 'waiting for passkey…' : 'sign in with passkey'}
188 </button>
189 {error ? (
190 <p className="font-mono text-[11px] text-[var(--color-error)]" role="alert">
191 {error}
192 </p>
193 ) : null}
194 </div>
195 );
196}