passwordless.ts628 lines · main
1/**
2 * briven-engine passwordless: magic link (email) + OTP (email/SMS) on Doltgres.
3 */
4
5import { createHash, randomBytes } from 'node:crypto';
6
7import { newId } from '@briven/shared';
8
9import { log } from '../../lib/logger.js';
10import { env } from '../../env.js';
11import { getEnginePool } from './db.js';
12import {
13 authEmailSubject,
14 sendBrivenEngineEmail,
15 sendBrivenEngineSms,
16} from './delivery.js';
17import { createEngineSession } from './native-session.js';
18import {
19 getBrivenEngineAppOrigins,
20 getBrivenEngineBranding,
21} from './project-config.js';
22import { projectIdToTenantId } from './project-map.js';
23
24const CODE_TTL_MS = 15 * 60 * 1000;
25
26/**
27 * Where the magic-link email should send the user.
28 * Prefer: explicit base from the app → project Allowed Domains → request Origin
29 * → never the platform marketing site (briven.tech) for customer projects.
30 */
31export function pickMagicLinkAppOrigin(
32 origins: string[],
33 requestOrigin?: string | null,
34): string | null {
35 const norm = origins
36 .map((o) => {
37 try {
38 const u = new URL(o.includes('://') ? o : `https://${o}`);
39 return `${u.protocol}//${u.host}`;
40 } catch {
41 return null;
42 }
43 })
44 .filter((o): o is string => Boolean(o));
45
46 if (requestOrigin) {
47 try {
48 const u = new URL(requestOrigin);
49 const ro = `${u.protocol}//${u.host}`;
50 if (norm.includes(ro)) return ro;
51 } catch {
52 /* ignore bad Origin */
53 }
54 }
55
56 const prod = norm.find(
57 (o) =>
58 o.startsWith('https://') &&
59 !/localhost|127\.0\.0\.1/i.test(o),
60 );
61 if (prod) return prod;
62 return norm[0] ?? null;
63}
64
65function normalizeToOrigin(urlOrOrigin: string): string | null {
66 try {
67 const withProto = urlOrOrigin.includes('://')
68 ? urlOrOrigin
69 : `https://${urlOrOrigin}`;
70 const u = new URL(withProto);
71 if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
72 return `${u.protocol}//${u.host}`;
73 } catch {
74 return null;
75 }
76}
77
78/**
79 * Whether an explicit magic-link base is allowed for this project.
80 * Origin must be in Allowed Domains, or match the browser Origin if that
81 * Origin is also allowlisted (or Allowed Domains empty and Origin matches
82 * exactly — only non-production).
83 */
84export function isMagicLinkBaseAllowed(
85 explicitBase: string,
86 allowedOrigins: string[],
87 requestOrigin?: string | null,
88): boolean {
89 const explicitOrigin = normalizeToOrigin(explicitBase);
90 if (!explicitOrigin) return false;
91
92 const normAllowed = allowedOrigins
93 .map((o) => normalizeToOrigin(o))
94 .filter((o): o is string => Boolean(o));
95
96 if (normAllowed.includes(explicitOrigin)) return true;
97
98 // If Allowed Domains empty: only permit exact browser Origin in non-prod.
99 if (normAllowed.length === 0) {
100 if (env.BRIVEN_ENV === 'production') return false;
101 if (!requestOrigin) return false;
102 const ro = normalizeToOrigin(requestOrigin);
103 return ro === explicitOrigin;
104 }
105
106 // Request Origin allowlisted and explicit base is same origin as request.
107 if (requestOrigin) {
108 const ro = normalizeToOrigin(requestOrigin);
109 if (ro && normAllowed.includes(ro) && ro === explicitOrigin) return true;
110 }
111 return false;
112}
113
114export async function resolveMagicLinkBaseUrl(input: {
115 explicit?: string | null;
116 projectId?: string;
117 requestOrigin?: string | null;
118}): Promise<{ ok: true; base: string } | { ok: false; message: string }> {
119 let origins: string[] = [];
120 if (input.projectId) {
121 try {
122 origins = await getBrivenEngineAppOrigins(input.projectId);
123 } catch {
124 origins = [];
125 }
126 }
127
128 const explicit = input.explicit?.trim();
129 if (explicit) {
130 if (!isMagicLinkBaseAllowed(explicit, origins, input.requestOrigin)) {
131 return {
132 ok: false,
133 message:
134 'magicLinkBaseUrl origin is not on this project Allowed Domains list',
135 };
136 }
137 if (/\/auth\/verify\/?$/i.test(explicit) || /\/login\/magic\/?$/i.test(explicit)) {
138 return { ok: true, base: explicit.replace(/\/$/, '') };
139 }
140 return { ok: true, base: `${explicit.replace(/\/$/, '')}/auth/verify` };
141 }
142
143 const picked = pickMagicLinkAppOrigin(origins, input.requestOrigin);
144 if (picked) return { ok: true, base: `${picked}/auth/verify` };
145
146 // Non-production local engine tests only.
147 if (env.BRIVEN_ENV !== 'production') {
148 return {
149 ok: true,
150 base: `${(env.BRIVEN_WEB_ORIGIN ?? 'http://localhost:3000').replace(/\/$/, '')}/auth/verify`,
151 };
152 }
153
154 return {
155 ok: false,
156 message:
157 'no Allowed Domains for magic links — add your app origin under Auth → Domains',
158 };
159}
160
161/** Exported for unit tests. */
162export function hashSecret(value: string): string {
163 return createHash('sha256').update(value).digest('hex');
164}
165
166/** Exported for unit tests. 100000–999999 */
167export function sixDigitCode(): string {
168 const n = 100000 + (randomBytes(3).readUIntBE(0, 3) % 900000);
169 return String(n);
170}
171
172/**
173 * Pure verify for stored code_hash forms:
174 * - single: hash(otp) or hash(link)
175 * - dual: "otpHash:linkHash"
176 */
177export function matchPasswordlessSecret(
178 stored: string,
179 input: { userInputCode?: string; linkCode?: string },
180): boolean {
181 if (!input.userInputCode && !input.linkCode) return false;
182 if (stored.includes(':')) {
183 const [otpHash, linkHash] = stored.split(':');
184 if (input.userInputCode && hashSecret(input.userInputCode) === otpHash) {
185 return true;
186 }
187 if (input.linkCode && hashSecret(input.linkCode) === linkHash) {
188 return true;
189 }
190 return false;
191 }
192 if (input.userInputCode && hashSecret(input.userInputCode) === stored) {
193 return true;
194 }
195 if (input.linkCode && hashSecret(input.linkCode) === stored) {
196 return true;
197 }
198 return false;
199}
200
201function resolveTenant(input: {
202 tenantId?: string;
203 projectId?: string;
204}): { ok: true; tenantId: string } | { ok: false; message: string } {
205 if (input.tenantId) return { ok: true, tenantId: input.tenantId };
206 if (input.projectId) {
207 try {
208 return { ok: true, tenantId: projectIdToTenantId(input.projectId) };
209 } catch {
210 return { ok: false, message: 'invalid project id' };
211 }
212 }
213 if (env.BRIVEN_ENV === 'production') {
214 return {
215 ok: false,
216 message: 'project id required (shared public tenant disabled in production)',
217 };
218 }
219 return { ok: true, tenantId: 'public' };
220}
221
222async function ensureTenant(tenantId: string, projectId?: string): Promise<void> {
223 const pool = getEnginePool();
224 const existing = await pool.query(
225 `SELECT tenant_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
226 [tenantId],
227 );
228 if (!existing.rowCount) {
229 await pool.query(
230 `INSERT INTO be_tenants (tenant_id, project_id) VALUES ($1, $2)`,
231 [tenantId, projectId ?? tenantId],
232 );
233 }
234}
235
236export type CreatePasswordlessCodeResult =
237 | {
238 status: 'OK';
239 preAuthSessionId: string;
240 deviceId: string;
241 /** Only returned in non-production for local proof (never log in prod paths). */
242 userInputCode?: string;
243 /** Magic link for email flows (app should host consume page). */
244 linkCode?: string;
245 flowType: 'USER_INPUT_CODE' | 'MAGIC_LINK' | 'USER_INPUT_CODE_AND_MAGIC_LINK';
246 channel: 'email' | 'sms';
247 delivery: { ok: boolean; mode: string; message?: string };
248 }
249 | { status: 'BAD_REQUEST'; message: string };
250
251/**
252 * Create a passwordless code for email or phone.
253 * flow: USER_INPUT_CODE (OTP), MAGIC_LINK, or both.
254 */
255export async function createPasswordlessCode(input: {
256 email?: string;
257 phoneNumber?: string;
258 projectId?: string;
259 tenantId?: string;
260 /** Prefer USER_INPUT_CODE | MAGIC_LINK | both */
261 flowType?: 'USER_INPUT_CODE' | 'MAGIC_LINK' | 'USER_INPUT_CODE_AND_MAGIC_LINK';
262 /** Base URL for magic link, e.g. https://app.example.com/auth/verify */
263 magicLinkBaseUrl?: string;
264 /** Browser Origin / Referer — used when magicLinkBaseUrl omitted */
265 requestOrigin?: string | null;
266 /** User-Agent of the browser that requested the code (for email meta). */
267 userAgent?: string | null;
268 /** Sec-CH-UA client hint (Brave vs Chrome). */
269 clientHintsUa?: string | null;
270 /** Client IP that requested the code (for email meta + geo). */
271 clientIp?: string | null;
272}): Promise<CreatePasswordlessCodeResult> {
273 const email = input.email?.trim().toLowerCase();
274 const phone = input.phoneNumber?.trim();
275 if (!email && !phone) {
276 return { status: 'BAD_REQUEST', message: 'email or phoneNumber required' };
277 }
278 if (email && phone) {
279 return {
280 status: 'BAD_REQUEST',
281 message: 'provide only one of email or phoneNumber',
282 };
283 }
284
285 const channel: 'email' | 'sms' = email ? 'email' : 'sms';
286 const flowType =
287 input.flowType ??
288 (channel === 'sms'
289 ? 'USER_INPUT_CODE'
290 : 'USER_INPUT_CODE_AND_MAGIC_LINK');
291
292 const tenantRes = resolveTenant(input);
293 if (!tenantRes.ok) {
294 return { status: 'BAD_REQUEST', message: tenantRes.message };
295 }
296 const tenantId = tenantRes.tenantId;
297 await ensureTenant(tenantId, input.projectId);
298
299 const preAuthSessionId = `pas_${randomBytes(16).toString('hex')}`;
300 const deviceId = `dev_${randomBytes(12).toString('hex')}`;
301 const userInputCode =
302 flowType === 'MAGIC_LINK' ? undefined : sixDigitCode();
303 const linkCode =
304 flowType === 'USER_INPUT_CODE'
305 ? undefined
306 : randomBytes(24).toString('base64url');
307
308 // Store one hash that accept-path can verify:
309 // - OTP only → hash(otp)
310 // - link only → hash(link)
311 // - both → hash(otp) AND we store otp in code_hash via dual-row style:
312 // Prefer storing hash of OTP when present (primary), plus hash of link
313 // in a second column would need schema change — use combined secret
314 // "otp||link" and accept either piece by re-checking against stored
315 // candidates at create time in a deterministic way:
316 // store hash(otp) if only otp; hash(link) if only link;
317 // if both: store hash(otp) as primary and also accept link via separate
318 // optional field. For simplicity with one column: store BOTH hashes
319 // joined as "otpHash:linkHash" when both present.
320 let codeHash: string;
321 if (userInputCode && linkCode) {
322 codeHash = `${hashSecret(userInputCode)}:${hashSecret(linkCode)}`;
323 } else if (userInputCode) {
324 codeHash = hashSecret(userInputCode);
325 } else {
326 codeHash = hashSecret(linkCode!);
327 }
328 const expiresAt = new Date(Date.now() + CODE_TTL_MS);
329
330 const pool = getEnginePool();
331 await pool.query(
332 `INSERT INTO be_passwordless_codes
333 (pre_auth_session_id, tenant_id, email, phone, code_hash, device_id, expires_at)
334 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
335 [
336 preAuthSessionId,
337 tenantId,
338 email ?? null,
339 phone ?? null,
340 codeHash,
341 deviceId,
342 expiresAt.toISOString(),
343 ],
344 );
345
346 let delivery: { ok: boolean; mode: string; message?: string } = {
347 ok: true,
348 mode: 'log',
349 };
350
351 if (channel === 'email' && email) {
352 // Brand name from Auth → Branding (e.g. "mavi pay"), never hardcode "Briven Auth".
353 const branding = input.projectId
354 ? await getBrivenEngineBranding(input.projectId)
355 : null;
356 const appName = branding?.senderName?.trim() || 'your app';
357 const expiryMinutes = Math.round(CODE_TTL_MS / 60000);
358
359 const baseRes = await resolveMagicLinkBaseUrl({
360 explicit: input.magicLinkBaseUrl,
361 projectId: input.projectId,
362 requestOrigin: input.requestOrigin,
363 });
364 if (!baseRes.ok) {
365 // Magic-link flows must not send phishing URLs; OTP-only can continue
366 // without a link when base resolution fails.
367 if (flowType !== 'USER_INPUT_CODE' && linkCode) {
368 return { status: 'BAD_REQUEST', message: baseRes.message };
369 }
370 }
371 const base = baseRes.ok ? baseRes.base : null;
372 // Only build a magic-link URL when this flow actually requested one.
373 // OTP-only must not include a link (and vice versa for magic-link-only).
374 const urlWithLinkCode =
375 base && linkCode && flowType !== 'USER_INPUT_CODE'
376 ? `${base}?preAuthSessionId=${encodeURIComponent(preAuthSessionId)}&linkCode=${encodeURIComponent(linkCode)}&deviceId=${encodeURIComponent(deviceId)}`
377 : undefined;
378 const otpForEmail =
379 userInputCode && flowType !== 'MAGIC_LINK' ? userInputCode : undefined;
380
381 const subject =
382 otpForEmail && !urlWithLinkCode
383 ? authEmailSubject(appName, 'code', otpForEmail)
384 : authEmailSubject(appName, 'sign-in');
385
386 // Plain-text fallback for clients that ignore HTML (still no dual-channel leak).
387 const textParts = [
388 otpForEmail ? `Your ${appName} Auth code: ${otpForEmail}` : null,
389 urlWithLinkCode
390 ? `Sign in to ${appName}: open the button in the HTML version of this email, or visit:\n${urlWithLinkCode}`
391 : null,
392 `Expires in ${expiryMinutes} minutes.`,
393 `If you didn't request this, you can ignore this email.`,
394 ].filter(Boolean);
395
396 const sent = await sendBrivenEngineEmail({
397 email,
398 subject,
399 body: textParts.join('\n\n'),
400 type: 'PASSWORDLESS_LOGIN',
401 projectId: input.projectId,
402 // Structured fields drive the professional HTML (button / big code).
403 url: urlWithLinkCode ?? null,
404 code: otpForEmail ?? null,
405 expiryMinutes,
406 title: `sign in to ${appName}`,
407 ctaLabel: 'sign in',
408 userAgent: input.userAgent,
409 clientHintsUa: input.clientHintsUa,
410 clientIp: input.clientIp,
411 });
412 delivery = {
413 ok: sent.ok,
414 mode: sent.mode,
415 message: sent.message,
416 };
417 } else if (channel === 'sms' && phone) {
418 const branding = input.projectId
419 ? await getBrivenEngineBranding(input.projectId)
420 : null;
421 const appName = branding?.senderName?.trim() || 'your app';
422 const sent = await sendBrivenEngineSms({
423 phoneNumber: phone,
424 userInputCode,
425 codeLifetime: CODE_TTL_MS,
426 type: 'PASSWORDLESS_LOGIN',
427 projectId: input.projectId,
428 userContext: { appName, projectId: input.projectId },
429 });
430 delivery = {
431 ok: sent.ok,
432 mode: sent.mode,
433 message: sent.message,
434 };
435 }
436
437 log.info('briven_engine_passwordless_created', {
438 engine: 'briven-engine',
439 storage: 'doltgres',
440 channel,
441 flowType,
442 tenantId,
443 deliveryMode: delivery.mode,
444 });
445
446 const { recordBrivenEngineAudit } = await import('./audit.js');
447 void recordBrivenEngineAudit({
448 action: 'signin.passwordless.code_created',
449 tenantId,
450 projectId: input.projectId,
451 metadata: {
452 channel,
453 flowType,
454 deliveryOk: delivery.ok,
455 deliveryMode: delivery.mode,
456 hasEmail: Boolean(email),
457 hasPhone: Boolean(phone),
458 },
459 });
460
461 return {
462 status: 'OK',
463 preAuthSessionId,
464 deviceId,
465 userInputCode:
466 env.BRIVEN_ENV === 'production' ? undefined : userInputCode,
467 linkCode: env.BRIVEN_ENV === 'production' ? undefined : linkCode,
468 flowType,
469 channel,
470 delivery,
471 };
472}
473
474export type ConsumePasswordlessCodeResult =
475 | {
476 status: 'OK';
477 createdNewUser: boolean;
478 user: { id: string; email?: string; phone?: string; tenantId: string };
479 session: {
480 handle: string;
481 userId: string;
482 accessToken: string;
483 refreshToken: string;
484 };
485 }
486 | { status: 'RESTART_FLOW_ERROR' | 'INCORRECT_USER_INPUT_CODE_ERROR' | 'EXPIRED' | 'BAD_REQUEST'; message?: string };
487
488/**
489 * Consume OTP and/or magic link code → user + session on Doltgres.
490 */
491export async function consumePasswordlessCode(input: {
492 preAuthSessionId: string;
493 deviceId: string;
494 userInputCode?: string;
495 linkCode?: string;
496 projectId?: string;
497 tenantId?: string;
498}): Promise<ConsumePasswordlessCodeResult> {
499 if (!input.userInputCode && !input.linkCode) {
500 return {
501 status: 'BAD_REQUEST',
502 message: 'userInputCode or linkCode required',
503 };
504 }
505
506 const pool = getEnginePool();
507 const res = await pool.query(
508 `SELECT pre_auth_session_id, tenant_id, email, phone, code_hash, device_id, expires_at
509 FROM be_passwordless_codes
510 WHERE pre_auth_session_id = $1
511 LIMIT 1`,
512 [input.preAuthSessionId],
513 );
514 const row = res.rows[0] as
515 | {
516 pre_auth_session_id: string;
517 tenant_id: string;
518 email: string | null;
519 phone: string | null;
520 code_hash: string;
521 device_id: string;
522 expires_at: Date | string;
523 }
524 | undefined;
525
526 if (!row) {
527 return { status: 'RESTART_FLOW_ERROR', message: 'unknown preAuthSessionId' };
528 }
529 if (row.device_id !== input.deviceId) {
530 return { status: 'RESTART_FLOW_ERROR', message: 'deviceId mismatch' };
531 }
532 if (new Date(row.expires_at).getTime() < Date.now()) {
533 await pool.query(
534 `DELETE FROM be_passwordless_codes WHERE pre_auth_session_id = $1`,
535 [input.preAuthSessionId],
536 );
537 return { status: 'EXPIRED', message: 'code expired' };
538 }
539
540 if (
541 !matchPasswordlessSecret(row.code_hash, {
542 userInputCode: input.userInputCode,
543 linkCode: input.linkCode,
544 })
545 ) {
546 return { status: 'INCORRECT_USER_INPUT_CODE_ERROR' };
547 }
548
549 // One-time use
550 await pool.query(
551 `DELETE FROM be_passwordless_codes WHERE pre_auth_session_id = $1`,
552 [input.preAuthSessionId],
553 );
554
555 const tenantId = row.tenant_id;
556 let userId: string | null = null;
557 let createdNewUser = false;
558
559 if (row.email) {
560 const found = await pool.query(
561 `SELECT id FROM be_users WHERE tenant_id = $1 AND email = $2 LIMIT 1`,
562 [tenantId, row.email],
563 );
564 if (found.rows[0]) {
565 userId = (found.rows[0] as { id: string }).id;
566 } else {
567 userId = newId('beu');
568 createdNewUser = true;
569 await pool.query(
570 `INSERT INTO be_users (id, tenant_id, email, email_verified)
571 VALUES ($1, $2, $3, TRUE)`,
572 [userId, tenantId, row.email],
573 );
574 }
575 } else if (row.phone) {
576 const found = await pool.query(
577 `SELECT id FROM be_users WHERE tenant_id = $1 AND phone = $2 LIMIT 1`,
578 [tenantId, row.phone],
579 );
580 if (found.rows[0]) {
581 userId = (found.rows[0] as { id: string }).id;
582 } else {
583 userId = newId('beu');
584 createdNewUser = true;
585 await pool.query(
586 `INSERT INTO be_users (id, tenant_id, phone, email_verified)
587 VALUES ($1, $2, $3, TRUE)`,
588 [userId, tenantId, row.phone],
589 );
590 }
591 }
592
593 if (!userId) {
594 return { status: 'RESTART_FLOW_ERROR', message: 'no contact on code' };
595 }
596
597 const session = await createEngineSession({ userId, tenantId });
598
599 const { recordBrivenEngineAudit } = await import('./audit.js');
600 void recordBrivenEngineAudit({
601 action: 'signin.passwordless',
602 tenantId,
603 projectId: input.projectId,
604 userId,
605 metadata: {
606 createdNewUser,
607 channel: row.email ? 'email' : 'sms',
608 sessionHandle: session.sessionHandle,
609 },
610 });
611
612 return {
613 status: 'OK',
614 createdNewUser,
615 user: {
616 id: userId,
617 email: row.email ?? undefined,
618 phone: row.phone ?? undefined,
619 tenantId,
620 },
621 session: {
622 handle: session.sessionHandle,
623 userId: session.userId,
624 accessToken: session.accessToken,
625 refreshToken: session.refreshToken,
626 },
627 };
628}