passwordless.ts417 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 sendBrivenEngineEmail,
14 sendBrivenEngineSms,
15} from './delivery.js';
16import { createEngineSession } from './native-session.js';
17import { projectIdToTenantId } from './project-map.js';
18
19const CODE_TTL_MS = 15 * 60 * 1000;
20
21/** Exported for unit tests. */
22export function hashSecret(value: string): string {
23 return createHash('sha256').update(value).digest('hex');
24}
25
26/** Exported for unit tests. 100000–999999 */
27export function sixDigitCode(): string {
28 const n = 100000 + (randomBytes(3).readUIntBE(0, 3) % 900000);
29 return String(n);
30}
31
32/**
33 * Pure verify for stored code_hash forms:
34 * - single: hash(otp) or hash(link)
35 * - dual: "otpHash:linkHash"
36 */
37export function matchPasswordlessSecret(
38 stored: string,
39 input: { userInputCode?: string; linkCode?: string },
40): boolean {
41 if (!input.userInputCode && !input.linkCode) return false;
42 if (stored.includes(':')) {
43 const [otpHash, linkHash] = stored.split(':');
44 if (input.userInputCode && hashSecret(input.userInputCode) === otpHash) {
45 return true;
46 }
47 if (input.linkCode && hashSecret(input.linkCode) === linkHash) {
48 return true;
49 }
50 return false;
51 }
52 if (input.userInputCode && hashSecret(input.userInputCode) === stored) {
53 return true;
54 }
55 if (input.linkCode && hashSecret(input.linkCode) === stored) {
56 return true;
57 }
58 return false;
59}
60
61function resolveTenant(input: {
62 tenantId?: string;
63 projectId?: string;
64}): string {
65 if (input.tenantId) return input.tenantId;
66 if (input.projectId) return projectIdToTenantId(input.projectId);
67 return 'public';
68}
69
70async function ensureTenant(tenantId: string, projectId?: string): Promise<void> {
71 const pool = getEnginePool();
72 const existing = await pool.query(
73 `SELECT tenant_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
74 [tenantId],
75 );
76 if (!existing.rowCount) {
77 await pool.query(
78 `INSERT INTO be_tenants (tenant_id, project_id) VALUES ($1, $2)`,
79 [tenantId, projectId ?? tenantId],
80 );
81 }
82}
83
84export type CreatePasswordlessCodeResult =
85 | {
86 status: 'OK';
87 preAuthSessionId: string;
88 deviceId: string;
89 /** Only returned in non-production for local proof (never log in prod paths). */
90 userInputCode?: string;
91 /** Magic link for email flows (app should host consume page). */
92 linkCode?: string;
93 flowType: 'USER_INPUT_CODE' | 'MAGIC_LINK' | 'USER_INPUT_CODE_AND_MAGIC_LINK';
94 channel: 'email' | 'sms';
95 delivery: { ok: boolean; mode: string; message?: string };
96 }
97 | { status: 'BAD_REQUEST'; message: string };
98
99/**
100 * Create a passwordless code for email or phone.
101 * flow: USER_INPUT_CODE (OTP), MAGIC_LINK, or both.
102 */
103export async function createPasswordlessCode(input: {
104 email?: string;
105 phoneNumber?: string;
106 projectId?: string;
107 tenantId?: string;
108 /** Prefer USER_INPUT_CODE | MAGIC_LINK | both */
109 flowType?: 'USER_INPUT_CODE' | 'MAGIC_LINK' | 'USER_INPUT_CODE_AND_MAGIC_LINK';
110 /** Base URL for magic link, e.g. https://app.example.com/auth/verify */
111 magicLinkBaseUrl?: string;
112}): Promise<CreatePasswordlessCodeResult> {
113 const email = input.email?.trim().toLowerCase();
114 const phone = input.phoneNumber?.trim();
115 if (!email && !phone) {
116 return { status: 'BAD_REQUEST', message: 'email or phoneNumber required' };
117 }
118 if (email && phone) {
119 return {
120 status: 'BAD_REQUEST',
121 message: 'provide only one of email or phoneNumber',
122 };
123 }
124
125 const channel: 'email' | 'sms' = email ? 'email' : 'sms';
126 const flowType =
127 input.flowType ??
128 (channel === 'sms'
129 ? 'USER_INPUT_CODE'
130 : 'USER_INPUT_CODE_AND_MAGIC_LINK');
131
132 const tenantId = resolveTenant(input);
133 await ensureTenant(tenantId, input.projectId);
134
135 const preAuthSessionId = `pas_${randomBytes(16).toString('hex')}`;
136 const deviceId = `dev_${randomBytes(12).toString('hex')}`;
137 const userInputCode =
138 flowType === 'MAGIC_LINK' ? undefined : sixDigitCode();
139 const linkCode =
140 flowType === 'USER_INPUT_CODE'
141 ? undefined
142 : randomBytes(24).toString('base64url');
143
144 // Store one hash that accept-path can verify:
145 // - OTP only → hash(otp)
146 // - link only → hash(link)
147 // - both → hash(otp) AND we store otp in code_hash via dual-row style:
148 // Prefer storing hash of OTP when present (primary), plus hash of link
149 // in a second column would need schema change — use combined secret
150 // "otp||link" and accept either piece by re-checking against stored
151 // candidates at create time in a deterministic way:
152 // store hash(otp) if only otp; hash(link) if only link;
153 // if both: store hash(otp) as primary and also accept link via separate
154 // optional field. For simplicity with one column: store BOTH hashes
155 // joined as "otpHash:linkHash" when both present.
156 let codeHash: string;
157 if (userInputCode && linkCode) {
158 codeHash = `${hashSecret(userInputCode)}:${hashSecret(linkCode)}`;
159 } else if (userInputCode) {
160 codeHash = hashSecret(userInputCode);
161 } else {
162 codeHash = hashSecret(linkCode!);
163 }
164 const expiresAt = new Date(Date.now() + CODE_TTL_MS);
165
166 const pool = getEnginePool();
167 await pool.query(
168 `INSERT INTO be_passwordless_codes
169 (pre_auth_session_id, tenant_id, email, phone, code_hash, device_id, expires_at)
170 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
171 [
172 preAuthSessionId,
173 tenantId,
174 email ?? null,
175 phone ?? null,
176 codeHash,
177 deviceId,
178 expiresAt.toISOString(),
179 ],
180 );
181
182 let delivery: { ok: boolean; mode: string; message?: string } = {
183 ok: true,
184 mode: 'log',
185 };
186
187 if (channel === 'email' && email) {
188 const base =
189 input.magicLinkBaseUrl ??
190 `${(env.BRIVEN_WEB_ORIGIN ?? 'http://localhost:3000').replace(/\/$/, '')}/auth/verify`;
191 const urlWithLinkCode = linkCode
192 ? `${base}?preAuthSessionId=${encodeURIComponent(preAuthSessionId)}&linkCode=${encodeURIComponent(linkCode)}&deviceId=${encodeURIComponent(deviceId)}`
193 : undefined;
194 const bodyParts = [
195 userInputCode ? `Your Briven Auth code: ${userInputCode}` : null,
196 urlWithLinkCode ? `Magic link: ${urlWithLinkCode}` : null,
197 `Expires in ${CODE_TTL_MS / 60000} minutes.`,
198 ].filter(Boolean);
199 const sent = await sendBrivenEngineEmail({
200 email,
201 subject: 'Your Briven Auth sign-in',
202 body: bodyParts.join('\n'),
203 type: 'PASSWORDLESS_LOGIN',
204 projectId: input.projectId,
205 });
206 delivery = {
207 ok: sent.ok,
208 mode: sent.mode,
209 message: sent.message,
210 };
211 } else if (channel === 'sms' && phone) {
212 const sent = await sendBrivenEngineSms({
213 phoneNumber: phone,
214 userInputCode,
215 codeLifetime: CODE_TTL_MS,
216 type: 'PASSWORDLESS_LOGIN',
217 projectId: input.projectId,
218 });
219 delivery = {
220 ok: sent.ok,
221 mode: sent.mode,
222 message: sent.message,
223 };
224 }
225
226 log.info('briven_engine_passwordless_created', {
227 engine: 'briven-engine',
228 storage: 'doltgres',
229 channel,
230 flowType,
231 tenantId,
232 deliveryMode: delivery.mode,
233 });
234
235 const { recordBrivenEngineAudit } = await import('./audit.js');
236 void recordBrivenEngineAudit({
237 action: 'signin.passwordless.code_created',
238 tenantId,
239 projectId: input.projectId,
240 metadata: {
241 channel,
242 flowType,
243 deliveryOk: delivery.ok,
244 deliveryMode: delivery.mode,
245 hasEmail: Boolean(email),
246 hasPhone: Boolean(phone),
247 },
248 });
249
250 return {
251 status: 'OK',
252 preAuthSessionId,
253 deviceId,
254 userInputCode:
255 env.BRIVEN_ENV === 'production' ? undefined : userInputCode,
256 linkCode: env.BRIVEN_ENV === 'production' ? undefined : linkCode,
257 flowType,
258 channel,
259 delivery,
260 };
261}
262
263export type ConsumePasswordlessCodeResult =
264 | {
265 status: 'OK';
266 createdNewUser: boolean;
267 user: { id: string; email?: string; phone?: string; tenantId: string };
268 session: {
269 handle: string;
270 userId: string;
271 accessToken: string;
272 refreshToken: string;
273 };
274 }
275 | { status: 'RESTART_FLOW_ERROR' | 'INCORRECT_USER_INPUT_CODE_ERROR' | 'EXPIRED' | 'BAD_REQUEST'; message?: string };
276
277/**
278 * Consume OTP and/or magic link code → user + session on Doltgres.
279 */
280export async function consumePasswordlessCode(input: {
281 preAuthSessionId: string;
282 deviceId: string;
283 userInputCode?: string;
284 linkCode?: string;
285 projectId?: string;
286 tenantId?: string;
287}): Promise<ConsumePasswordlessCodeResult> {
288 if (!input.userInputCode && !input.linkCode) {
289 return {
290 status: 'BAD_REQUEST',
291 message: 'userInputCode or linkCode required',
292 };
293 }
294
295 const pool = getEnginePool();
296 const res = await pool.query(
297 `SELECT pre_auth_session_id, tenant_id, email, phone, code_hash, device_id, expires_at
298 FROM be_passwordless_codes
299 WHERE pre_auth_session_id = $1
300 LIMIT 1`,
301 [input.preAuthSessionId],
302 );
303 const row = res.rows[0] as
304 | {
305 pre_auth_session_id: string;
306 tenant_id: string;
307 email: string | null;
308 phone: string | null;
309 code_hash: string;
310 device_id: string;
311 expires_at: Date | string;
312 }
313 | undefined;
314
315 if (!row) {
316 return { status: 'RESTART_FLOW_ERROR', message: 'unknown preAuthSessionId' };
317 }
318 if (row.device_id !== input.deviceId) {
319 return { status: 'RESTART_FLOW_ERROR', message: 'deviceId mismatch' };
320 }
321 if (new Date(row.expires_at).getTime() < Date.now()) {
322 await pool.query(
323 `DELETE FROM be_passwordless_codes WHERE pre_auth_session_id = $1`,
324 [input.preAuthSessionId],
325 );
326 return { status: 'EXPIRED', message: 'code expired' };
327 }
328
329 if (
330 !matchPasswordlessSecret(row.code_hash, {
331 userInputCode: input.userInputCode,
332 linkCode: input.linkCode,
333 })
334 ) {
335 return { status: 'INCORRECT_USER_INPUT_CODE_ERROR' };
336 }
337
338 // One-time use
339 await pool.query(
340 `DELETE FROM be_passwordless_codes WHERE pre_auth_session_id = $1`,
341 [input.preAuthSessionId],
342 );
343
344 const tenantId = row.tenant_id;
345 let userId: string | null = null;
346 let createdNewUser = false;
347
348 if (row.email) {
349 const found = await pool.query(
350 `SELECT id FROM be_users WHERE tenant_id = $1 AND email = $2 LIMIT 1`,
351 [tenantId, row.email],
352 );
353 if (found.rows[0]) {
354 userId = (found.rows[0] as { id: string }).id;
355 } else {
356 userId = newId('beu');
357 createdNewUser = true;
358 await pool.query(
359 `INSERT INTO be_users (id, tenant_id, email, email_verified)
360 VALUES ($1, $2, $3, TRUE)`,
361 [userId, tenantId, row.email],
362 );
363 }
364 } else if (row.phone) {
365 const found = await pool.query(
366 `SELECT id FROM be_users WHERE tenant_id = $1 AND phone = $2 LIMIT 1`,
367 [tenantId, row.phone],
368 );
369 if (found.rows[0]) {
370 userId = (found.rows[0] as { id: string }).id;
371 } else {
372 userId = newId('beu');
373 createdNewUser = true;
374 await pool.query(
375 `INSERT INTO be_users (id, tenant_id, phone, email_verified)
376 VALUES ($1, $2, $3, TRUE)`,
377 [userId, tenantId, row.phone],
378 );
379 }
380 }
381
382 if (!userId) {
383 return { status: 'RESTART_FLOW_ERROR', message: 'no contact on code' };
384 }
385
386 const session = await createEngineSession({ userId, tenantId });
387
388 const { recordBrivenEngineAudit } = await import('./audit.js');
389 void recordBrivenEngineAudit({
390 action: 'signin.passwordless',
391 tenantId,
392 projectId: input.projectId,
393 userId,
394 metadata: {
395 createdNewUser,
396 channel: row.email ? 'email' : 'sms',
397 sessionHandle: session.sessionHandle,
398 },
399 });
400
401 return {
402 status: 'OK',
403 createdNewUser,
404 user: {
405 id: userId,
406 email: row.email ?? undefined,
407 phone: row.phone ?? undefined,
408 tenantId,
409 },
410 session: {
411 handle: session.sessionHandle,
412 userId: session.userId,
413 accessToken: session.accessToken,
414 refreshToken: session.refreshToken,
415 },
416 };
417}