webauthn.ts697 lines · main
1/**
2 * briven-engine passkeys on Doltgres with @simplewebauthn/server verification.
3 *
4 * rpId / expectedOrigin MUST match the app host the user is on (e.g.
5 * pay.mavifinans.sh for mavi pay) — never hard-code briven.tech for tenant apps.
6 */
7
8import { randomBytes } from 'node:crypto';
9
10import {
11 generateAuthenticationOptions,
12 generateRegistrationOptions,
13 verifyAuthenticationResponse,
14 verifyRegistrationResponse,
15 type AuthenticatorTransportFuture,
16 type RegistrationResponseJSON,
17 type AuthenticationResponseJSON,
18} from '@simplewebauthn/server';
19import { newId } from '@briven/shared';
20
21import { env } from '../../env.js';
22import { log } from '../../lib/logger.js';
23import { getEnginePool } from './db.js';
24import { isAuthCoreInitialized } from './engine.js';
25import { createEngineSession } from './native-session.js';
26import { getBrivenEngineAppOrigins, getBrivenEngineBranding } from './project-config.js';
27import { projectIdToTenantId } from './project-map.js';
28
29function resolveTenant(projectId?: string, tenantId?: string): string {
30 if (tenantId) return tenantId;
31 if (projectId) return projectIdToTenantId(projectId);
32 return 'public';
33}
34
35function normalizeHttpOrigin(raw: string | null | undefined): string | null {
36 if (!raw?.trim()) return null;
37 try {
38 const u = new URL(raw.trim());
39 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
40 if (u.protocol === 'http:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') {
41 return null;
42 }
43 return `${u.protocol}//${u.host}`;
44 } catch {
45 return null;
46 }
47}
48
49/**
50 * rpId must equal the origin hostname, or be a parent domain of it
51 * (WebAuthn registrable-domain rule, simplified).
52 */
53export function rpIdMatchesOrigin(rpId: string, origin: string): boolean {
54 try {
55 const host = new URL(origin).hostname.toLowerCase();
56 const rp = rpId.toLowerCase().replace(/^\./, '');
57 if (!rp || !host) return false;
58 return host === rp || host.endsWith(`.${rp}`);
59 } catch {
60 return false;
61 }
62}
63
64/**
65 * Resolve which website "owns" this passkey ceremony.
66 *
67 * Priority:
68 * 1) explicit expectedOrigin / rpId from the app (mavi sends window.location)
69 * 2) browser Origin header (proxied by first-party /api/auth)
70 * 3) project's Allowed Domains list
71 * 4) last resort: BRIVEN_WEB_ORIGIN (hosted Briven only — not tenant apps)
72 */
73export async function resolveWebAuthnRp(input: {
74 projectId?: string;
75 rpId?: string | null;
76 expectedOrigin?: string | null;
77 /** Origin header from the browser (or first-party proxy). */
78 requestOrigin?: string | null;
79}): Promise<
80 | { ok: true; rpId: string; expectedOrigin: string; rpName: string }
81 | { ok: false; message: string }
82> {
83 const allowed = input.projectId
84 ? await getBrivenEngineAppOrigins(input.projectId)
85 : [];
86
87 const candidates: string[] = [];
88 const push = (raw: string | null | undefined) => {
89 const o = normalizeHttpOrigin(raw);
90 if (o && !candidates.includes(o)) candidates.push(o);
91 };
92 push(input.expectedOrigin);
93 push(input.requestOrigin);
94 for (const a of allowed) push(a);
95
96 // Hosted dashboard only — never preferred when the project has its own apps.
97 if (candidates.length === 0) {
98 push(env.BRIVEN_WEB_ORIGIN ?? null);
99 }
100
101 let expectedOrigin: string | null = null;
102 for (const o of candidates) {
103 if (allowed.length === 0 || allowed.includes(o)) {
104 expectedOrigin = o;
105 break;
106 }
107 }
108 // If Allowed Domains is empty, still accept https app origin from the request
109 // (first-day projects before they finish the domain checklist).
110 if (!expectedOrigin && candidates[0]) {
111 expectedOrigin = candidates[0];
112 }
113 if (!expectedOrigin) {
114 return {
115 ok: false,
116 message:
117 'Passkey needs an app origin. Open your app over HTTPS and add it under Auth → Allowed Domains.',
118 };
119 }
120
121 let rpId = (input.rpId ?? '').trim().toLowerCase() || null;
122 if (rpId && !rpIdMatchesOrigin(rpId, expectedOrigin)) {
123 // Ignore a mismatched client rpId; derive from origin instead.
124 rpId = null;
125 }
126 if (!rpId) {
127 try {
128 rpId = new URL(expectedOrigin).hostname;
129 } catch {
130 return { ok: false, message: 'invalid passkey origin' };
131 }
132 }
133
134 let rpName = 'Briven Auth';
135 if (input.projectId) {
136 try {
137 const brand = await getBrivenEngineBranding(input.projectId);
138 if (brand.senderName?.trim()) rpName = brand.senderName.trim();
139 } catch {
140 /* keep default */
141 }
142 }
143
144 return { ok: true, rpId, expectedOrigin, rpName };
145}
146
147function b64urlToBuffer(s: string): Buffer {
148 const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
149 const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
150 return Buffer.from(b64, 'base64');
151}
152
153export async function createRegistrationOptions(input: {
154 userId: string;
155 userName: string;
156 projectId?: string;
157 tenantId?: string;
158 rpId?: string;
159 expectedOrigin?: string;
160 requestOrigin?: string | null;
161}): Promise<
162 | {
163 status: 'OK';
164 challengeId: string;
165 options: Awaited<ReturnType<typeof generateRegistrationOptions>>;
166 engine: 'briven-engine';
167 storage: 'doltgres';
168 }
169 | { status: 'ERROR'; message: string }
170> {
171 if (!isAuthCoreInitialized()) {
172 return { status: 'ERROR', message: 'engine not ready' };
173 }
174 const tenantId = resolveTenant(input.projectId, input.tenantId);
175 const rp = await resolveWebAuthnRp({
176 projectId: input.projectId,
177 rpId: input.rpId,
178 expectedOrigin: input.expectedOrigin,
179 requestOrigin: input.requestOrigin,
180 });
181 if (!rp.ok) return { status: 'ERROR', message: rp.message };
182 const pool = getEnginePool();
183
184 const existing = await pool.query(
185 `SELECT credential_id, transports FROM be_webauthn_credentials WHERE user_id = $1`,
186 [input.userId],
187 );
188 const excludeCredentials = (
189 existing.rows as Array<{ credential_id: string; transports: string | null }>
190 ).map((r) => ({
191 id: r.credential_id,
192 transports: (r.transports?.split(',').filter(Boolean) ??
193 []) as AuthenticatorTransportFuture[],
194 }));
195
196 const options = await generateRegistrationOptions({
197 rpName: rp.rpName,
198 rpID: rp.rpId,
199 userName: input.userName,
200 userID: new TextEncoder().encode(input.userId),
201 userDisplayName: input.userName,
202 attestationType: 'none',
203 excludeCredentials,
204 authenticatorSelection: {
205 residentKey: 'preferred',
206 userVerification: 'preferred',
207 },
208 });
209
210 const challengeId = `wac_${randomBytes(12).toString('hex')}`;
211 const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
212 await insertWebauthnChallenge(pool, {
213 challengeId,
214 tenantId,
215 userId: input.userId,
216 challenge: options.challenge,
217 type: 'registration',
218 expiresAt: expiresAt.toISOString(),
219 rpId: rp.rpId,
220 expectedOrigin: rp.expectedOrigin,
221 });
222
223 return {
224 status: 'OK',
225 challengeId,
226 options,
227 engine: 'briven-engine',
228 storage: 'doltgres',
229 };
230}
231
232let rpColumnsReady: Promise<boolean> | null = null;
233/** Best-effort: add rp_id / expected_origin on challenges (Doltgres/Postgres). */
234async function ensureWebauthnRpColumns(
235 pool: ReturnType<typeof getEnginePool>,
236): Promise<boolean> {
237 if (!rpColumnsReady) {
238 rpColumnsReady = (async () => {
239 try {
240 await pool.query(
241 `ALTER TABLE be_webauthn_challenges ADD COLUMN IF NOT EXISTS rp_id TEXT`,
242 );
243 await pool.query(
244 `ALTER TABLE be_webauthn_challenges ADD COLUMN IF NOT EXISTS expected_origin TEXT`,
245 );
246 return true;
247 } catch (err) {
248 log.warn('webauthn_rp_columns_ensure_failed', {
249 message: err instanceof Error ? err.message : String(err),
250 });
251 rpColumnsReady = null;
252 return false;
253 }
254 })();
255 }
256 return rpColumnsReady;
257}
258
259async function insertWebauthnChallenge(
260 pool: ReturnType<typeof getEnginePool>,
261 row: {
262 challengeId: string;
263 tenantId: string;
264 userId: string | null;
265 challenge: string;
266 type: 'registration' | 'authentication';
267 expiresAt: string;
268 rpId: string;
269 expectedOrigin: string;
270 },
271): Promise<void> {
272 const hasCols = await ensureWebauthnRpColumns(pool);
273 if (hasCols) {
274 try {
275 await pool.query(
276 `INSERT INTO be_webauthn_challenges
277 (challenge_id, tenant_id, user_id, challenge, type, expires_at, rp_id, expected_origin)
278 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
279 [
280 row.challengeId,
281 row.tenantId,
282 row.userId,
283 row.challenge,
284 row.type,
285 row.expiresAt,
286 row.rpId,
287 row.expectedOrigin,
288 ],
289 );
290 return;
291 } catch {
292 /* fall through to legacy insert */
293 }
294 }
295 await pool.query(
296 `INSERT INTO be_webauthn_challenges
297 (challenge_id, tenant_id, user_id, challenge, type, expires_at)
298 VALUES ($1, $2, $3, $4, $5, $6)`,
299 [
300 row.challengeId,
301 row.tenantId,
302 row.userId,
303 row.challenge,
304 row.type,
305 row.expiresAt,
306 ],
307 );
308}
309
310async function loadWebauthnChallenge(
311 pool: ReturnType<typeof getEnginePool>,
312 challengeId: string,
313 type: 'registration' | 'authentication',
314): Promise<{
315 challenge: string;
316 tenant_id: string;
317 expires_at: string | Date;
318 user_id?: string | null;
319 rp_id?: string | null;
320 expected_origin?: string | null;
321} | null> {
322 await ensureWebauthnRpColumns(pool);
323 try {
324 const ch = await pool.query(
325 `SELECT challenge, tenant_id, expires_at, user_id, rp_id, expected_origin
326 FROM be_webauthn_challenges
327 WHERE challenge_id = $1 AND type = $2 LIMIT 1`,
328 [challengeId, type],
329 );
330 return (ch.rows[0] as never) ?? null;
331 } catch {
332 const ch = await pool.query(
333 `SELECT challenge, tenant_id, expires_at, user_id
334 FROM be_webauthn_challenges
335 WHERE challenge_id = $1 AND type = $2 LIMIT 1`,
336 [challengeId, type],
337 );
338 return (ch.rows[0] as never) ?? null;
339 }
340}
341
342export async function finishRegistration(input: {
343 userId: string;
344 challengeId: string;
345 /** Full WebAuthn registration response JSON from navigator.credentials.create */
346 response?: RegistrationResponseJSON;
347 /** Legacy simplified path (local proofs without browser) */
348 credentialId?: string;
349 publicKey?: string;
350 transports?: string[];
351 projectId?: string;
352 expectedOrigin?: string;
353 rpId?: string;
354 requestOrigin?: string | null;
355}): Promise<{
356 status: 'OK' | 'ERROR';
357 message?: string;
358 credentialDbId?: string;
359 verified?: boolean;
360}> {
361 if (!isAuthCoreInitialized()) {
362 return { status: 'ERROR', message: 'engine not ready' };
363 }
364 const pool = getEnginePool();
365 const row = await loadWebauthnChallenge(pool, input.challengeId, 'registration');
366 if (!row || row.user_id !== input.userId) {
367 return { status: 'ERROR', message: 'invalid challenge' };
368 }
369 if (new Date(row.expires_at).getTime() < Date.now()) {
370 return { status: 'ERROR', message: 'challenge expired' };
371 }
372
373 const rp = await resolveWebAuthnRp({
374 projectId: input.projectId,
375 // Prefer values bound at options-create time (cannot be spoofed mid-flow).
376 rpId: row.rp_id || input.rpId,
377 expectedOrigin: row.expected_origin || input.expectedOrigin,
378 requestOrigin: input.requestOrigin,
379 });
380 if (!rp.ok) return { status: 'ERROR', message: rp.message };
381 const rpID = rp.rpId;
382 const expectedOrigin = rp.expectedOrigin;
383
384 let credentialId: string;
385 let publicKey: string;
386 let counter = 0;
387 let transports: string | null = null;
388 let verified = false;
389
390 if (input.response) {
391 try {
392 const verification = await verifyRegistrationResponse({
393 response: input.response,
394 expectedChallenge: row.challenge,
395 expectedOrigin,
396 expectedRPID: rpID,
397 requireUserVerification: false,
398 });
399 if (!verification.verified || !verification.registrationInfo) {
400 return { status: 'ERROR', message: 'registration verification failed' };
401 }
402 const info = verification.registrationInfo;
403 credentialId = Buffer.from(info.credential.id).toString('base64url');
404 // credential.publicKey is Uint8Array
405 publicKey = Buffer.from(info.credential.publicKey).toString('base64url');
406 counter = info.credential.counter;
407 transports = input.response.response.transports?.join(',') ?? null;
408 verified = true;
409 } catch (err) {
410 log.warn('webauthn_register_verify_failed', {
411 message: err instanceof Error ? err.message : String(err),
412 });
413 return {
414 status: 'ERROR',
415 message: err instanceof Error ? err.message : 'verify failed',
416 };
417 }
418 } else if (input.credentialId && input.publicKey) {
419 // Dev/local simplified enrollment (no browser crypto)
420 if (env.BRIVEN_ENV === 'production') {
421 return {
422 status: 'ERROR',
423 message: 'full WebAuthn response required in production',
424 };
425 }
426 credentialId = input.credentialId;
427 publicKey = input.publicKey;
428 transports = input.transports?.join(',') ?? null;
429 verified = false;
430 } else {
431 return {
432 status: 'ERROR',
433 message: 'response (WebAuthn JSON) or credentialId+publicKey required',
434 };
435 }
436
437 const id = newId('bwc');
438 await pool.query(
439 `INSERT INTO be_webauthn_credentials
440 (id, user_id, tenant_id, credential_id, public_key, counter, transports)
441 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
442 [id, input.userId, row.tenant_id, credentialId, publicKey, counter, transports],
443 );
444 await pool.query(
445 `DELETE FROM be_webauthn_challenges WHERE challenge_id = $1`,
446 [input.challengeId],
447 );
448 return { status: 'OK', credentialDbId: id, verified };
449}
450
451export async function createAuthenticationOptions(input: {
452 projectId?: string;
453 tenantId?: string;
454 userId?: string;
455 rpId?: string;
456 expectedOrigin?: string;
457 requestOrigin?: string | null;
458}): Promise<
459 | {
460 status: 'OK';
461 challengeId: string;
462 options: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
463 engine: 'briven-engine';
464 storage: 'doltgres';
465 }
466 | { status: 'ERROR'; message: string }
467> {
468 if (!isAuthCoreInitialized()) {
469 return { status: 'ERROR', message: 'engine not ready' };
470 }
471 const tenantId = resolveTenant(input.projectId, input.tenantId);
472 const rp = await resolveWebAuthnRp({
473 projectId: input.projectId,
474 rpId: input.rpId,
475 expectedOrigin: input.expectedOrigin,
476 requestOrigin: input.requestOrigin,
477 });
478 if (!rp.ok) return { status: 'ERROR', message: rp.message };
479 const pool = getEnginePool();
480
481 let allowCredentials:
482 | Array<{ id: string; transports?: AuthenticatorTransportFuture[] }>
483 | undefined;
484 if (input.userId) {
485 const creds = await pool.query(
486 `SELECT credential_id, transports FROM be_webauthn_credentials WHERE user_id = $1`,
487 [input.userId],
488 );
489 allowCredentials = (
490 creds.rows as Array<{ credential_id: string; transports: string | null }>
491 ).map((r) => ({
492 id: r.credential_id,
493 transports: (r.transports?.split(',').filter(Boolean) ??
494 []) as AuthenticatorTransportFuture[],
495 }));
496 }
497
498 const options = await generateAuthenticationOptions({
499 rpID: rp.rpId,
500 allowCredentials,
501 userVerification: 'preferred',
502 });
503
504 const challengeId = `wac_${randomBytes(12).toString('hex')}`;
505 const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
506 await insertWebauthnChallenge(pool, {
507 challengeId,
508 tenantId,
509 userId: input.userId ?? null,
510 challenge: options.challenge,
511 type: 'authentication',
512 expiresAt: expiresAt.toISOString(),
513 rpId: rp.rpId,
514 expectedOrigin: rp.expectedOrigin,
515 });
516
517 return {
518 status: 'OK',
519 challengeId,
520 options,
521 engine: 'briven-engine',
522 storage: 'doltgres',
523 };
524}
525
526export async function finishAuthentication(input: {
527 challengeId: string;
528 credentialId?: string;
529 /** Full WebAuthn authentication response */
530 response?: AuthenticationResponseJSON;
531 projectId?: string;
532 expectedOrigin?: string;
533 rpId?: string;
534 requestOrigin?: string | null;
535}): Promise<
536 | {
537 status: 'OK';
538 userId: string;
539 verified: boolean;
540 session: {
541 handle: string;
542 userId: string;
543 accessToken: string;
544 refreshToken: string;
545 };
546 }
547 | { status: 'ERROR'; message: string }
548> {
549 if (!isAuthCoreInitialized()) {
550 return { status: 'ERROR', message: 'engine not ready' };
551 }
552 const pool = getEnginePool();
553 const row = await loadWebauthnChallenge(pool, input.challengeId, 'authentication');
554 if (!row) return { status: 'ERROR', message: 'invalid challenge' };
555 if (new Date(row.expires_at).getTime() < Date.now()) {
556 return { status: 'ERROR', message: 'challenge expired' };
557 }
558
559 const credentialId =
560 input.credentialId ?? input.response?.id ?? input.response?.rawId;
561 if (!credentialId) {
562 return { status: 'ERROR', message: 'credentialId required' };
563 }
564
565 const cred = await pool.query(
566 `SELECT id, user_id, public_key, counter FROM be_webauthn_credentials
567 WHERE tenant_id = $1 AND credential_id = $2 LIMIT 1`,
568 [row.tenant_id, credentialId],
569 );
570 const c = cred.rows[0] as
571 | {
572 id: string;
573 user_id: string;
574 public_key: string;
575 counter: string | number;
576 }
577 | undefined;
578 if (!c) return { status: 'ERROR', message: 'unknown credential' };
579
580 let verified = false;
581 let newCounter = Number(c.counter) + 1;
582
583 if (input.response) {
584 const rp = await resolveWebAuthnRp({
585 projectId: input.projectId,
586 rpId: row.rp_id || input.rpId,
587 expectedOrigin: row.expected_origin || input.expectedOrigin,
588 requestOrigin: input.requestOrigin,
589 });
590 if (!rp.ok) return { status: 'ERROR', message: rp.message };
591 try {
592 const verification = await verifyAuthenticationResponse({
593 response: input.response,
594 expectedChallenge: row.challenge,
595 expectedOrigin: rp.expectedOrigin,
596 expectedRPID: rp.rpId,
597 credential: {
598 id: credentialId,
599 publicKey: b64urlToBuffer(c.public_key),
600 counter: Number(c.counter),
601 },
602 requireUserVerification: false,
603 });
604 if (!verification.verified) {
605 return { status: 'ERROR', message: 'authentication verification failed' };
606 }
607 verified = true;
608 newCounter = verification.authenticationInfo.newCounter;
609 } catch (err) {
610 log.warn('webauthn_auth_verify_failed', {
611 message: err instanceof Error ? err.message : String(err),
612 });
613 return {
614 status: 'ERROR',
615 message: err instanceof Error ? err.message : 'verify failed',
616 };
617 }
618 } else {
619 // Local proof path without browser assertion
620 if (env.BRIVEN_ENV === 'production') {
621 return {
622 status: 'ERROR',
623 message: 'full WebAuthn response required in production',
624 };
625 }
626 verified = false;
627 }
628
629 await pool.query(
630 `UPDATE be_webauthn_credentials SET counter = $2 WHERE id = $1`,
631 [c.id, newCounter],
632 );
633 await pool.query(
634 `DELETE FROM be_webauthn_challenges WHERE challenge_id = $1`,
635 [input.challengeId],
636 );
637
638 const session = await createEngineSession({
639 userId: c.user_id,
640 tenantId: row.tenant_id,
641 });
642 return {
643 status: 'OK',
644 userId: c.user_id,
645 verified,
646 session: {
647 handle: session.sessionHandle,
648 userId: session.userId,
649 accessToken: session.accessToken,
650 refreshToken: session.refreshToken,
651 },
652 };
653}
654
655export async function listPasskeys(userId: string): Promise<{
656 engine: 'briven-engine';
657 storage: 'doltgres';
658 credentials: Array<{ id: string; credentialId: string; createdAt: string }>;
659}> {
660 if (!isAuthCoreInitialized()) {
661 return { engine: 'briven-engine', storage: 'doltgres', credentials: [] };
662 }
663 const pool = getEnginePool();
664 const res = await pool.query(
665 `SELECT id, credential_id, created_at FROM be_webauthn_credentials
666 WHERE user_id = $1 ORDER BY created_at`,
667 [userId],
668 );
669 return {
670 engine: 'briven-engine',
671 storage: 'doltgres',
672 credentials: (
673 res.rows as Array<{
674 id: string;
675 credential_id: string;
676 created_at: Date | string;
677 }>
678 ).map((r) => ({
679 id: r.id,
680 credentialId: r.credential_id,
681 createdAt: new Date(r.created_at).toISOString(),
682 })),
683 };
684}
685
686export async function deletePasskey(
687 userId: string,
688 credentialDbId: string,
689): Promise<{ ok: boolean }> {
690 if (!isAuthCoreInitialized()) return { ok: false };
691 const pool = getEnginePool();
692 const res = await pool.query(
693 `DELETE FROM be_webauthn_credentials WHERE id = $1 AND user_id = $2`,
694 [credentialDbId, userId],
695 );
696 return { ok: (res.rowCount ?? 0) > 0 };
697}