webauthn.ts476 lines · main
1/**
2 * briven-engine passkeys on Doltgres with @simplewebauthn/server verification.
3 */
4
5import { randomBytes } from 'node:crypto';
6
7import {
8 generateAuthenticationOptions,
9 generateRegistrationOptions,
10 verifyAuthenticationResponse,
11 verifyRegistrationResponse,
12 type AuthenticatorTransportFuture,
13 type RegistrationResponseJSON,
14 type AuthenticationResponseJSON,
15} from '@simplewebauthn/server';
16import { newId } from '@briven/shared';
17
18import { env } from '../../env.js';
19import { log } from '../../lib/logger.js';
20import { getEnginePool } from './db.js';
21import { isAuthCoreInitialized } from './engine.js';
22import { createEngineSession } from './native-session.js';
23import { projectIdToTenantId } from './project-map.js';
24
25function resolveTenant(projectId?: string, tenantId?: string): string {
26 if (tenantId) return tenantId;
27 if (projectId) return projectIdToTenantId(projectId);
28 return 'public';
29}
30
31function rpIdFrom(input?: string): string {
32 if (input) return input;
33 try {
34 return new URL(env.BRIVEN_WEB_ORIGIN ?? 'http://localhost:3000').hostname;
35 } catch {
36 return 'localhost';
37 }
38}
39
40function originFrom(rpId: string): string {
41 const web = env.BRIVEN_WEB_ORIGIN ?? 'http://localhost:3000';
42 try {
43 const u = new URL(web);
44 return u.origin;
45 } catch {
46 return rpId === 'localhost' ? 'http://localhost:3000' : `https://${rpId}`;
47 }
48}
49
50function b64urlToBuffer(s: string): Buffer {
51 const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
52 const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
53 return Buffer.from(b64, 'base64');
54}
55
56export async function createRegistrationOptions(input: {
57 userId: string;
58 userName: string;
59 projectId?: string;
60 tenantId?: string;
61 rpId?: string;
62}): Promise<
63 | {
64 status: 'OK';
65 challengeId: string;
66 options: Awaited<ReturnType<typeof generateRegistrationOptions>>;
67 engine: 'briven-engine';
68 storage: 'doltgres';
69 }
70 | { status: 'ERROR'; message: string }
71> {
72 if (!isAuthCoreInitialized()) {
73 return { status: 'ERROR', message: 'engine not ready' };
74 }
75 const tenantId = resolveTenant(input.projectId, input.tenantId);
76 const rpID = rpIdFrom(input.rpId);
77 const pool = getEnginePool();
78
79 const existing = await pool.query(
80 `SELECT credential_id, transports FROM be_webauthn_credentials WHERE user_id = $1`,
81 [input.userId],
82 );
83 const excludeCredentials = (
84 existing.rows as Array<{ credential_id: string; transports: string | null }>
85 ).map((r) => ({
86 id: r.credential_id,
87 transports: (r.transports?.split(',').filter(Boolean) ??
88 []) as AuthenticatorTransportFuture[],
89 }));
90
91 const options = await generateRegistrationOptions({
92 rpName: 'Briven Auth',
93 rpID,
94 userName: input.userName,
95 userID: new TextEncoder().encode(input.userId),
96 userDisplayName: input.userName,
97 attestationType: 'none',
98 excludeCredentials,
99 authenticatorSelection: {
100 residentKey: 'preferred',
101 userVerification: 'preferred',
102 },
103 });
104
105 const challengeId = `wac_${randomBytes(12).toString('hex')}`;
106 const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
107 await pool.query(
108 `INSERT INTO be_webauthn_challenges
109 (challenge_id, tenant_id, user_id, challenge, type, expires_at)
110 VALUES ($1, $2, $3, $4, 'registration', $5)`,
111 [challengeId, tenantId, input.userId, options.challenge, expiresAt.toISOString()],
112 );
113
114 return {
115 status: 'OK',
116 challengeId,
117 options,
118 engine: 'briven-engine',
119 storage: 'doltgres',
120 };
121}
122
123export async function finishRegistration(input: {
124 userId: string;
125 challengeId: string;
126 /** Full WebAuthn registration response JSON from navigator.credentials.create */
127 response?: RegistrationResponseJSON;
128 /** Legacy simplified path (local proofs without browser) */
129 credentialId?: string;
130 publicKey?: string;
131 transports?: string[];
132 projectId?: string;
133 expectedOrigin?: string;
134 rpId?: string;
135}): Promise<{
136 status: 'OK' | 'ERROR';
137 message?: string;
138 credentialDbId?: string;
139 verified?: boolean;
140}> {
141 if (!isAuthCoreInitialized()) {
142 return { status: 'ERROR', message: 'engine not ready' };
143 }
144 const pool = getEnginePool();
145 const ch = await pool.query(
146 `SELECT challenge, tenant_id, expires_at, user_id FROM be_webauthn_challenges
147 WHERE challenge_id = $1 AND type = 'registration' LIMIT 1`,
148 [input.challengeId],
149 );
150 const row = ch.rows[0] as
151 | {
152 challenge: string;
153 tenant_id: string;
154 expires_at: string | Date;
155 user_id: string | null;
156 }
157 | undefined;
158 if (!row || row.user_id !== input.userId) {
159 return { status: 'ERROR', message: 'invalid challenge' };
160 }
161 if (new Date(row.expires_at).getTime() < Date.now()) {
162 return { status: 'ERROR', message: 'challenge expired' };
163 }
164
165 const rpID = rpIdFrom(input.rpId);
166 const expectedOrigin = input.expectedOrigin ?? originFrom(rpID);
167
168 let credentialId: string;
169 let publicKey: string;
170 let counter = 0;
171 let transports: string | null = null;
172 let verified = false;
173
174 if (input.response) {
175 try {
176 const verification = await verifyRegistrationResponse({
177 response: input.response,
178 expectedChallenge: row.challenge,
179 expectedOrigin,
180 expectedRPID: rpID,
181 requireUserVerification: false,
182 });
183 if (!verification.verified || !verification.registrationInfo) {
184 return { status: 'ERROR', message: 'registration verification failed' };
185 }
186 const info = verification.registrationInfo;
187 credentialId = Buffer.from(info.credential.id).toString('base64url');
188 // credential.publicKey is Uint8Array
189 publicKey = Buffer.from(info.credential.publicKey).toString('base64url');
190 counter = info.credential.counter;
191 transports = input.response.response.transports?.join(',') ?? null;
192 verified = true;
193 } catch (err) {
194 log.warn('webauthn_register_verify_failed', {
195 message: err instanceof Error ? err.message : String(err),
196 });
197 return {
198 status: 'ERROR',
199 message: err instanceof Error ? err.message : 'verify failed',
200 };
201 }
202 } else if (input.credentialId && input.publicKey) {
203 // Dev/local simplified enrollment (no browser crypto)
204 if (env.BRIVEN_ENV === 'production') {
205 return {
206 status: 'ERROR',
207 message: 'full WebAuthn response required in production',
208 };
209 }
210 credentialId = input.credentialId;
211 publicKey = input.publicKey;
212 transports = input.transports?.join(',') ?? null;
213 verified = false;
214 } else {
215 return {
216 status: 'ERROR',
217 message: 'response (WebAuthn JSON) or credentialId+publicKey required',
218 };
219 }
220
221 const id = newId('bwc');
222 await pool.query(
223 `INSERT INTO be_webauthn_credentials
224 (id, user_id, tenant_id, credential_id, public_key, counter, transports)
225 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
226 [id, input.userId, row.tenant_id, credentialId, publicKey, counter, transports],
227 );
228 await pool.query(
229 `DELETE FROM be_webauthn_challenges WHERE challenge_id = $1`,
230 [input.challengeId],
231 );
232 return { status: 'OK', credentialDbId: id, verified };
233}
234
235export async function createAuthenticationOptions(input: {
236 projectId?: string;
237 tenantId?: string;
238 userId?: string;
239 rpId?: string;
240}): Promise<
241 | {
242 status: 'OK';
243 challengeId: string;
244 options: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
245 engine: 'briven-engine';
246 storage: 'doltgres';
247 }
248 | { status: 'ERROR'; message: string }
249> {
250 if (!isAuthCoreInitialized()) {
251 return { status: 'ERROR', message: 'engine not ready' };
252 }
253 const tenantId = resolveTenant(input.projectId, input.tenantId);
254 const rpID = rpIdFrom(input.rpId);
255 const pool = getEnginePool();
256
257 let allowCredentials:
258 | Array<{ id: string; transports?: AuthenticatorTransportFuture[] }>
259 | undefined;
260 if (input.userId) {
261 const creds = await pool.query(
262 `SELECT credential_id, transports FROM be_webauthn_credentials WHERE user_id = $1`,
263 [input.userId],
264 );
265 allowCredentials = (
266 creds.rows as Array<{ credential_id: string; transports: string | null }>
267 ).map((r) => ({
268 id: r.credential_id,
269 transports: (r.transports?.split(',').filter(Boolean) ??
270 []) as AuthenticatorTransportFuture[],
271 }));
272 }
273
274 const options = await generateAuthenticationOptions({
275 rpID,
276 allowCredentials,
277 userVerification: 'preferred',
278 });
279
280 const challengeId = `wac_${randomBytes(12).toString('hex')}`;
281 const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
282 await pool.query(
283 `INSERT INTO be_webauthn_challenges
284 (challenge_id, tenant_id, user_id, challenge, type, expires_at)
285 VALUES ($1, $2, $3, $4, 'authentication', $5)`,
286 [
287 challengeId,
288 tenantId,
289 input.userId ?? null,
290 options.challenge,
291 expiresAt.toISOString(),
292 ],
293 );
294
295 return {
296 status: 'OK',
297 challengeId,
298 options,
299 engine: 'briven-engine',
300 storage: 'doltgres',
301 };
302}
303
304export async function finishAuthentication(input: {
305 challengeId: string;
306 credentialId?: string;
307 /** Full WebAuthn authentication response */
308 response?: AuthenticationResponseJSON;
309 projectId?: string;
310 expectedOrigin?: string;
311 rpId?: string;
312}): Promise<
313 | {
314 status: 'OK';
315 userId: string;
316 verified: boolean;
317 session: {
318 handle: string;
319 userId: string;
320 accessToken: string;
321 refreshToken: string;
322 };
323 }
324 | { status: 'ERROR'; message: string }
325> {
326 if (!isAuthCoreInitialized()) {
327 return { status: 'ERROR', message: 'engine not ready' };
328 }
329 const pool = getEnginePool();
330 const ch = await pool.query(
331 `SELECT challenge, tenant_id, expires_at FROM be_webauthn_challenges
332 WHERE challenge_id = $1 AND type = 'authentication' LIMIT 1`,
333 [input.challengeId],
334 );
335 const row = ch.rows[0] as
336 | { challenge: string; tenant_id: string; expires_at: string | Date }
337 | undefined;
338 if (!row) return { status: 'ERROR', message: 'invalid challenge' };
339 if (new Date(row.expires_at).getTime() < Date.now()) {
340 return { status: 'ERROR', message: 'challenge expired' };
341 }
342
343 const credentialId =
344 input.credentialId ?? input.response?.id ?? input.response?.rawId;
345 if (!credentialId) {
346 return { status: 'ERROR', message: 'credentialId required' };
347 }
348
349 const cred = await pool.query(
350 `SELECT id, user_id, public_key, counter FROM be_webauthn_credentials
351 WHERE tenant_id = $1 AND credential_id = $2 LIMIT 1`,
352 [row.tenant_id, credentialId],
353 );
354 const c = cred.rows[0] as
355 | {
356 id: string;
357 user_id: string;
358 public_key: string;
359 counter: string | number;
360 }
361 | undefined;
362 if (!c) return { status: 'ERROR', message: 'unknown credential' };
363
364 let verified = false;
365 let newCounter = Number(c.counter) + 1;
366
367 if (input.response) {
368 const rpID = rpIdFrom(input.rpId);
369 const expectedOrigin = input.expectedOrigin ?? originFrom(rpID);
370 try {
371 const verification = await verifyAuthenticationResponse({
372 response: input.response,
373 expectedChallenge: row.challenge,
374 expectedOrigin,
375 expectedRPID: rpID,
376 credential: {
377 id: credentialId,
378 publicKey: b64urlToBuffer(c.public_key),
379 counter: Number(c.counter),
380 },
381 requireUserVerification: false,
382 });
383 if (!verification.verified) {
384 return { status: 'ERROR', message: 'authentication verification failed' };
385 }
386 verified = true;
387 newCounter = verification.authenticationInfo.newCounter;
388 } catch (err) {
389 log.warn('webauthn_auth_verify_failed', {
390 message: err instanceof Error ? err.message : String(err),
391 });
392 return {
393 status: 'ERROR',
394 message: err instanceof Error ? err.message : 'verify failed',
395 };
396 }
397 } else {
398 // Local proof path without browser assertion
399 if (env.BRIVEN_ENV === 'production') {
400 return {
401 status: 'ERROR',
402 message: 'full WebAuthn response required in production',
403 };
404 }
405 verified = false;
406 }
407
408 await pool.query(
409 `UPDATE be_webauthn_credentials SET counter = $2 WHERE id = $1`,
410 [c.id, newCounter],
411 );
412 await pool.query(
413 `DELETE FROM be_webauthn_challenges WHERE challenge_id = $1`,
414 [input.challengeId],
415 );
416
417 const session = await createEngineSession({
418 userId: c.user_id,
419 tenantId: row.tenant_id,
420 });
421 return {
422 status: 'OK',
423 userId: c.user_id,
424 verified,
425 session: {
426 handle: session.sessionHandle,
427 userId: session.userId,
428 accessToken: session.accessToken,
429 refreshToken: session.refreshToken,
430 },
431 };
432}
433
434export async function listPasskeys(userId: string): Promise<{
435 engine: 'briven-engine';
436 storage: 'doltgres';
437 credentials: Array<{ id: string; credentialId: string; createdAt: string }>;
438}> {
439 if (!isAuthCoreInitialized()) {
440 return { engine: 'briven-engine', storage: 'doltgres', credentials: [] };
441 }
442 const pool = getEnginePool();
443 const res = await pool.query(
444 `SELECT id, credential_id, created_at FROM be_webauthn_credentials
445 WHERE user_id = $1 ORDER BY created_at`,
446 [userId],
447 );
448 return {
449 engine: 'briven-engine',
450 storage: 'doltgres',
451 credentials: (
452 res.rows as Array<{
453 id: string;
454 credential_id: string;
455 created_at: Date | string;
456 }>
457 ).map((r) => ({
458 id: r.id,
459 credentialId: r.credential_id,
460 createdAt: new Date(r.created_at).toISOString(),
461 })),
462 };
463}
464
465export async function deletePasskey(
466 userId: string,
467 credentialDbId: string,
468): Promise<{ ok: boolean }> {
469 if (!isAuthCoreInitialized()) return { ok: false };
470 const pool = getEnginePool();
471 const res = await pool.query(
472 `DELETE FROM be_webauthn_credentials WHERE id = $1 AND user_id = $2`,
473 [credentialDbId, userId],
474 );
475 return { ok: (res.rowCount ?? 0) > 0 };
476}