emailpassword.ts149 lines · main
1/**
2 * briven-engine EmailPassword on Doltgres only.
3 */
4
5import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
6
7import { newId } from '@briven/shared';
8
9import { getEnginePool } from './db.js';
10import { projectIdToTenantId } from './project-map.js';
11
12/** Exported for unit tests. Format: saltHex:scryptHex */
13export function hashPassword(
14 password: string,
15 salt?: string,
16): { hash: string; salt: string } {
17 const s = salt ?? randomBytes(16).toString('hex');
18 const derived = scryptSync(password, s, 64).toString('hex');
19 return { hash: `${s}:${derived}`, salt: s };
20}
21
22/** Exported for unit tests. Constant-time compare. */
23export function verifyPassword(password: string, stored: string): boolean {
24 const [salt, hash] = stored.split(':');
25 if (!salt || !hash) return false;
26 const derived = scryptSync(password, salt, 64);
27 const expected = Buffer.from(hash, 'hex');
28 if (derived.length !== expected.length) return false;
29 return timingSafeEqual(derived, expected);
30}
31
32export type SignUpResult =
33 | { status: 'OK'; user: { id: string; email: string; tenantId: string } }
34 | { status: 'EMAIL_ALREADY_EXISTS_ERROR' };
35
36export type SignInResult =
37 | { status: 'OK'; user: { id: string; email: string; tenantId: string } }
38 | { status: 'WRONG_CREDENTIALS_ERROR' };
39
40export async function signUpEmailPassword(input: {
41 email: string;
42 password: string;
43 tenantId?: string;
44 projectId?: string;
45}): Promise<SignUpResult> {
46 const tenantId =
47 input.tenantId ??
48 (input.projectId ? projectIdToTenantId(input.projectId) : 'public');
49 const email = input.email.trim().toLowerCase();
50 const pool = getEnginePool();
51
52 const existing = await pool.query(
53 `SELECT id FROM be_users WHERE tenant_id = $1 AND email = $2 LIMIT 1`,
54 [tenantId, email],
55 );
56 if (existing.rowCount && existing.rowCount > 0) {
57 return { status: 'EMAIL_ALREADY_EXISTS_ERROR' };
58 }
59
60 const userId = newId('beu');
61 const { hash } = hashPassword(input.password);
62
63 // Doltgres: avoid relying on ON CONFLICT — probe first.
64 const ten = await pool.query(
65 `SELECT tenant_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
66 [tenantId],
67 );
68 if (!ten.rowCount) {
69 await pool.query(
70 `INSERT INTO be_tenants (tenant_id, project_id) VALUES ($1, $2)`,
71 [tenantId, input.projectId ?? tenantId],
72 );
73 }
74
75 await pool.query(
76 `INSERT INTO be_users (id, tenant_id, email, email_verified)
77 VALUES ($1, $2, $3, FALSE)`,
78 [userId, tenantId, email],
79 );
80 await pool.query(
81 `INSERT INTO be_password_hashes (user_id, password_hash) VALUES ($1, $2)`,
82 [userId, hash],
83 );
84
85 const { recordBrivenEngineAudit } = await import('./audit.js');
86 void recordBrivenEngineAudit({
87 action: 'signup.password',
88 tenantId,
89 projectId: input.projectId,
90 userId,
91 metadata: { email },
92 });
93
94 return {
95 status: 'OK',
96 user: { id: userId, email, tenantId },
97 };
98}
99
100export async function signInEmailPassword(input: {
101 email: string;
102 password: string;
103 tenantId?: string;
104 projectId?: string;
105}): Promise<SignInResult> {
106 const tenantId =
107 input.tenantId ??
108 (input.projectId ? projectIdToTenantId(input.projectId) : 'public');
109 const email = input.email.trim().toLowerCase();
110 const pool = getEnginePool();
111
112 const res = await pool.query(
113 `SELECT u.id, u.email, u.tenant_id, p.password_hash
114 FROM be_users u
115 JOIN be_password_hashes p ON p.user_id = u.id
116 WHERE u.tenant_id = $1 AND u.email = $2
117 LIMIT 1`,
118 [tenantId, email],
119 );
120 const row = res.rows[0] as
121 | { id: string; email: string; tenant_id: string; password_hash: string }
122 | undefined;
123 if (!row || !verifyPassword(input.password, row.password_hash)) {
124 const { recordBrivenEngineAudit } = await import('./audit.js');
125 void recordBrivenEngineAudit({
126 action: 'signin.password.fail',
127 tenantId,
128 projectId: input.projectId,
129 metadata: { email },
130 });
131 return { status: 'WRONG_CREDENTIALS_ERROR' };
132 }
133 const { recordBrivenEngineAudit } = await import('./audit.js');
134 void recordBrivenEngineAudit({
135 action: 'signin.password',
136 tenantId: row.tenant_id,
137 projectId: input.projectId,
138 userId: row.id,
139 metadata: { email: row.email },
140 });
141 return {
142 status: 'OK',
143 user: { id: row.id, email: row.email, tenantId: row.tenant_id },
144 };
145}
146
147export function hashRefreshToken(token: string): string {
148 return createHash('sha256').update(token).digest('hex');
149}