emailpassword.ts253 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 (briven-engine scrypt). */
23export function verifyPassword(password: string, stored: string): boolean {
24 // Foreign migration hashes use verifyPasswordFlexible (async).
25 if (stored.startsWith('import:')) return false;
26 const [salt, hash] = stored.split(':');
27 if (!salt || !hash) return false;
28 // Reject if more than one colon (e.g. accidental import: prefix mishandled)
29 if (stored.split(':').length !== 2) return false;
30 const derived = scryptSync(password, salt, 64);
31 const expected = Buffer.from(hash, 'hex');
32 if (derived.length !== expected.length) return false;
33 return timingSafeEqual(derived, expected);
34}
35
36/**
37 * Verify briven scrypt **or** foreign hashes imported as
38 * `import:bcrypt:$2b$…` / `import:argon2:$argon2id$…` (migration).
39 * On foreign success, caller should rehash to briven scrypt (upgrade).
40 */
41export async function verifyPasswordFlexible(
42 password: string,
43 stored: string,
44): Promise<{ ok: boolean; upgradeToBriven?: boolean }> {
45 if (stored.startsWith('import:bcrypt:')) {
46 const raw = stored.slice('import:bcrypt:'.length);
47 try {
48 const ok = await Bun.password.verify(password, raw);
49 return { ok, upgradeToBriven: ok };
50 } catch {
51 return { ok: false };
52 }
53 }
54 if (stored.startsWith('import:argon2:')) {
55 const raw = stored.slice('import:argon2:'.length);
56 try {
57 const ok = await Bun.password.verify(password, raw);
58 return { ok, upgradeToBriven: ok };
59 } catch {
60 return { ok: false };
61 }
62 }
63 return { ok: verifyPassword(password, stored) };
64}
65
66export type SignUpResult =
67 | { status: 'OK'; user: { id: string; email: string; tenantId: string } }
68 | { status: 'EMAIL_ALREADY_EXISTS_ERROR' };
69
70export type SignInResult =
71 | { status: 'OK'; user: { id: string; email: string; tenantId: string } }
72 | { status: 'WRONG_CREDENTIALS_ERROR' };
73
74export async function signUpEmailPassword(input: {
75 email: string;
76 password: string;
77 tenantId?: string;
78 projectId?: string;
79 /** Optional username (stored in metadata; enables username login when project flag on). */
80 username?: string;
81}): Promise<SignUpResult> {
82 let tenantId = input.tenantId;
83 if (!tenantId && input.projectId) {
84 tenantId = projectIdToTenantId(input.projectId);
85 }
86 if (!tenantId) {
87 // Production must not use shared public tenant (security deep-test C1).
88 const { env } = await import('../../env.js');
89 if (env.BRIVEN_ENV === 'production') {
90 throw new Error('project id required for sign-up');
91 }
92 tenantId = 'public';
93 }
94 const email = input.email.trim().toLowerCase();
95 const username = input.username?.trim().toLowerCase() || null;
96 if (username && !/^[a-z0-9_]{3,32}$/.test(username)) {
97 throw new Error('username must be 3–32 chars: a-z, 0-9, underscore');
98 }
99 const pool = getEnginePool();
100
101 const existing = await pool.query(
102 `SELECT id FROM be_users WHERE tenant_id = $1 AND email = $2 LIMIT 1`,
103 [tenantId, email],
104 );
105 if (existing.rowCount && existing.rowCount > 0) {
106 return { status: 'EMAIL_ALREADY_EXISTS_ERROR' };
107 }
108
109 const userId = newId('beu');
110 const { hash } = hashPassword(input.password);
111
112 // Doltgres: avoid relying on ON CONFLICT — probe first.
113 const ten = await pool.query(
114 `SELECT tenant_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
115 [tenantId],
116 );
117 if (!ten.rowCount) {
118 await pool.query(
119 `INSERT INTO be_tenants (tenant_id, project_id) VALUES ($1, $2)`,
120 [tenantId, input.projectId ?? tenantId],
121 );
122 }
123
124 const metadata = username ? JSON.stringify({ username }) : '{}';
125 await pool.query(
126 `INSERT INTO be_users (id, tenant_id, email, email_verified, metadata_json)
127 VALUES ($1, $2, $3, FALSE, $4)`,
128 [userId, tenantId, email, metadata],
129 );
130 await pool.query(
131 `INSERT INTO be_password_hashes (user_id, password_hash) VALUES ($1, $2)`,
132 [userId, hash],
133 );
134
135 const { recordBrivenEngineAudit } = await import('./audit.js');
136 void recordBrivenEngineAudit({
137 action: 'signup.password',
138 tenantId,
139 projectId: input.projectId,
140 userId,
141 metadata: { email },
142 });
143
144 return {
145 status: 'OK',
146 user: { id: userId, email, tenantId },
147 };
148}
149
150export async function signInEmailPassword(input: {
151 email: string;
152 password: string;
153 tenantId?: string;
154 projectId?: string;
155 /**
156 * When true (or project flag usernameLogin), `email` field may be a username
157 * stored in metadata_json.username.
158 */
159 allowUsername?: boolean;
160}): Promise<SignInResult> {
161 let tenantId = input.tenantId;
162 if (!tenantId && input.projectId) {
163 tenantId = projectIdToTenantId(input.projectId);
164 }
165 if (!tenantId) {
166 const { env } = await import('../../env.js');
167 if (env.BRIVEN_ENV === 'production') {
168 return { status: 'WRONG_CREDENTIALS_ERROR' };
169 }
170 tenantId = 'public';
171 }
172 const login = input.email.trim().toLowerCase();
173 const pool = getEnginePool();
174
175 let allowUsername = Boolean(input.allowUsername);
176 if (!allowUsername && input.projectId) {
177 try {
178 const { getBrivenEngineUsernameLogin } = await import('./project-config.js');
179 allowUsername = await getBrivenEngineUsernameLogin(input.projectId);
180 } catch {
181 allowUsername = false;
182 }
183 }
184
185 // Prefer exact email match; optional username via metadata (Doltgres-safe LIKE).
186 let res = await pool.query(
187 `SELECT u.id, u.email, u.tenant_id, p.password_hash
188 FROM be_users u
189 JOIN be_password_hashes p ON p.user_id = u.id
190 WHERE u.tenant_id = $1 AND u.email = $2
191 LIMIT 1`,
192 [tenantId, login],
193 );
194 if ((!res.rowCount || res.rowCount === 0) && allowUsername) {
195 res = await pool.query(
196 `SELECT u.id, u.email, u.tenant_id, p.password_hash
197 FROM be_users u
198 JOIN be_password_hashes p ON p.user_id = u.id
199 WHERE u.tenant_id = $1
200 AND u.metadata_json LIKE $2
201 LIMIT 1`,
202 [tenantId, `%"username":"${login}"%`],
203 );
204 }
205 const row = res.rows[0] as
206 | { id: string; email: string; tenant_id: string; password_hash: string }
207 | undefined;
208 if (!row) {
209 const { recordBrivenEngineAudit } = await import('./audit.js');
210 void recordBrivenEngineAudit({
211 action: 'signin.password.fail',
212 tenantId,
213 projectId: input.projectId,
214 metadata: { login },
215 });
216 return { status: 'WRONG_CREDENTIALS_ERROR' };
217 }
218 const check = await verifyPasswordFlexible(input.password, row.password_hash);
219 if (!check.ok) {
220 const { recordBrivenEngineAudit } = await import('./audit.js');
221 void recordBrivenEngineAudit({
222 action: 'signin.password.fail',
223 tenantId,
224 projectId: input.projectId,
225 metadata: { login },
226 });
227 return { status: 'WRONG_CREDENTIALS_ERROR' };
228 }
229 // Migration: after first successful foreign-hash login, upgrade to briven scrypt.
230 if (check.upgradeToBriven) {
231 const { hash } = hashPassword(input.password);
232 await pool.query(
233 `UPDATE be_password_hashes SET password_hash = $1, updated_at = NOW() WHERE user_id = $2`,
234 [hash, row.id],
235 );
236 }
237 const { recordBrivenEngineAudit } = await import('./audit.js');
238 void recordBrivenEngineAudit({
239 action: 'signin.password',
240 tenantId: row.tenant_id,
241 projectId: input.projectId,
242 userId: row.id,
243 metadata: { email: row.email },
244 });
245 return {
246 status: 'OK',
247 user: { id: row.id, email: row.email, tenantId: row.tenant_id },
248 };
249}
250
251export function hashRefreshToken(token: string): string {
252 return createHash('sha256').update(token).digest('hex');
253}