auth-password-policy.ts297 lines · main
1/**
2 * Password policy — Gap Fix #13 / Sprint S3.
3 *
4 * Per-tenant complexity + expiry + reuse rules.
5 * Enforced on sign-up / reset / change (bridge) and on session create (expiry).
6 */
7
8import { createHash } from 'node:crypto';
9
10import { ValidationError } from '@briven/shared';
11
12import { runInProjectDatabase } from '../db/data-plane.js';
13
14export interface PasswordPolicy {
15 minLength: number;
16 requireUppercase: boolean;
17 requireLowercase: boolean;
18 requireNumber: boolean;
19 requireSpecial: boolean;
20 /** When set, passwords older than this many days block new sessions. */
21 maxAgeDays: number | null;
22 /** How many previous passwords cannot be reused (0 = off). */
23 preventReuse: number;
24}
25
26export const DEFAULT_PASSWORD_POLICY: PasswordPolicy = {
27 minLength: 8,
28 requireUppercase: false,
29 requireLowercase: false,
30 requireNumber: false,
31 requireSpecial: false,
32 maxAgeDays: null,
33 preventReuse: 0,
34};
35
36/** Digest for history rows only — not used for authentication. */
37export function passwordHistoryDigest(password: string): string {
38 return createHash('sha256').update(password, 'utf8').digest('hex');
39}
40
41export function validatePassword(password: string, policy: PasswordPolicy): void {
42 if (password.length < policy.minLength) {
43 throw new ValidationError(`password must be at least ${policy.minLength} characters`);
44 }
45 if (policy.requireUppercase && !/[A-Z]/.test(password)) {
46 throw new ValidationError('password must contain an uppercase letter');
47 }
48 if (policy.requireLowercase && !/[a-z]/.test(password)) {
49 throw new ValidationError('password must contain a lowercase letter');
50 }
51 if (policy.requireNumber && !/[0-9]/.test(password)) {
52 throw new ValidationError('password must contain a number');
53 }
54 if (policy.requireSpecial && !/[^A-Za-z0-9]/.test(password)) {
55 throw new ValidationError('password must contain a special character');
56 }
57}
58
59export async function getPasswordPolicy(projectId: string): Promise<PasswordPolicy> {
60 const rows = await runInProjectDatabase<
61 Array<{
62 min_length: number;
63 require_uppercase: boolean;
64 require_lowercase: boolean;
65 require_number: boolean;
66 require_special: boolean;
67 max_age_days: number | null;
68 prevent_reuse: number;
69 }>
70 >(projectId, async (tx) =>
71 tx.unsafe(
72 `SELECT min_length, require_uppercase, require_lowercase, require_number, require_special, max_age_days, prevent_reuse
73 FROM "_briven_auth_password_policy"
74 LIMIT 1`,
75 ) as never,
76 );
77 const row = rows[0];
78 if (!row) return { ...DEFAULT_PASSWORD_POLICY };
79 return {
80 minLength: Number(row.min_length),
81 requireUppercase: Boolean(row.require_uppercase),
82 requireLowercase: Boolean(row.require_lowercase),
83 requireNumber: Boolean(row.require_number),
84 requireSpecial: Boolean(row.require_special),
85 maxAgeDays: row.max_age_days == null ? null : Number(row.max_age_days),
86 preventReuse: Number(row.prevent_reuse),
87 };
88}
89
90export async function setPasswordPolicy(
91 projectId: string,
92 patch: Partial<PasswordPolicy>,
93): Promise<PasswordPolicy> {
94 const current = await getPasswordPolicy(projectId);
95 const policy: PasswordPolicy = {
96 ...current,
97 ...patch,
98 // Explicit null maxAgeDays must clear the field.
99 maxAgeDays: patch.maxAgeDays === undefined ? current.maxAgeDays : patch.maxAgeDays,
100 };
101 if (policy.minLength < 6) {
102 throw new ValidationError('min_length must be at least 6');
103 }
104 if (policy.maxAgeDays !== null && policy.maxAgeDays < 1) {
105 throw new ValidationError('max_age_days must be at least 1');
106 }
107 if (policy.preventReuse < 0) {
108 throw new ValidationError('prevent_reuse must be >= 0');
109 }
110
111 await runInProjectDatabase(projectId, async (tx) => {
112 const existing = await tx.unsafe(
113 `SELECT 1 FROM "_briven_auth_password_policy" LIMIT 1`,
114 );
115 if ((existing as unknown[]).length > 0) {
116 await tx.unsafe(
117 `UPDATE "_briven_auth_password_policy"
118 SET min_length = $1, require_uppercase = $2, require_lowercase = $3,
119 require_number = $4, require_special = $5, max_age_days = $6,
120 prevent_reuse = $7, updated_at = now()`,
121 [
122 policy.minLength,
123 policy.requireUppercase,
124 policy.requireLowercase,
125 policy.requireNumber,
126 policy.requireSpecial,
127 policy.maxAgeDays,
128 policy.preventReuse,
129 ] as never,
130 );
131 } else {
132 await tx.unsafe(
133 `INSERT INTO "_briven_auth_password_policy"
134 (id, min_length, require_uppercase, require_lowercase, require_number, require_special, max_age_days, prevent_reuse, created_at, updated_at)
135 VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, now(), now())`,
136 [
137 policy.minLength,
138 policy.requireUppercase,
139 policy.requireLowercase,
140 policy.requireNumber,
141 policy.requireSpecial,
142 policy.maxAgeDays,
143 policy.preventReuse,
144 ] as never,
145 );
146 }
147 });
148
149 return policy;
150}
151
152/**
153 * Reject if `password` matches one of the last N history digests.
154 * Call BEFORE accepting a password change.
155 */
156export async function assertPasswordNotReused(
157 projectId: string,
158 userId: string,
159 password: string,
160 policy: PasswordPolicy = DEFAULT_PASSWORD_POLICY,
161): Promise<void> {
162 if (policy.preventReuse <= 0) return;
163 const digest = passwordHistoryDigest(password);
164 const rows = await runInProjectDatabase<Array<{ password_hash: string }>>(
165 projectId,
166 async (tx) =>
167 tx.unsafe(
168 `SELECT password_hash FROM "_briven_auth_password_history"
169 WHERE user_id = $1
170 ORDER BY created_at DESC
171 LIMIT $2`,
172 [userId, policy.preventReuse] as never,
173 ) as never,
174 );
175 if (rows.some((r) => r.password_hash === digest)) {
176 throw new ValidationError(
177 `password was used recently — choose a different password (last ${policy.preventReuse} not allowed)`,
178 );
179 }
180}
181
182/** Record a password change for reuse checks + age tracking. */
183export async function recordPasswordHistory(
184 projectId: string,
185 userId: string,
186 password: string,
187): Promise<void> {
188 const digest = passwordHistoryDigest(password);
189 await runInProjectDatabase(projectId, async (tx) => {
190 await tx.unsafe(
191 `INSERT INTO "_briven_auth_password_history" (id, user_id, password_hash, created_at)
192 VALUES (gen_random_uuid()::text, $1, $2, now())`,
193 [userId, digest] as never,
194 );
195 // Cap history growth (keep 20).
196 await tx.unsafe(
197 `DELETE FROM "_briven_auth_password_history"
198 WHERE user_id = $1
199 AND id NOT IN (
200 SELECT id FROM "_briven_auth_password_history"
201 WHERE user_id = $1
202 ORDER BY created_at DESC
203 LIMIT 20
204 )`,
205 [userId] as never,
206 );
207 // Clear admin force-reset if present.
208 await tx.unsafe(
209 `DELETE FROM "_briven_auth_password_force_reset" WHERE user_id = $1`,
210 [userId] as never,
211 );
212 });
213}
214
215/**
216 * True when the user must change password before getting a session:
217 * force-reset flag OR password older than maxAgeDays.
218 */
219export async function mustChangePassword(
220 projectId: string,
221 userId: string,
222): Promise<{ required: boolean; reason?: string }> {
223 const policy = await getPasswordPolicy(projectId);
224
225 const forced = await runInProjectDatabase<Array<{ user_id: string }>>(
226 projectId,
227 async (tx) =>
228 tx.unsafe(
229 `SELECT user_id FROM "_briven_auth_password_force_reset" WHERE user_id = $1 LIMIT 1`,
230 [userId] as never,
231 ) as never,
232 );
233 if (forced.length > 0) {
234 return { required: true, reason: 'password change required by administrator' };
235 }
236
237 if (policy.maxAgeDays == null || policy.maxAgeDays <= 0) {
238 return { required: false };
239 }
240
241 // Prefer last history row; fall back to credential account updated_at.
242 const ages = await runInProjectDatabase<Array<{ changed_at: Date | string | null }>>(
243 projectId,
244 async (tx) =>
245 tx.unsafe(
246 `SELECT COALESCE(
247 (SELECT created_at FROM "_briven_auth_password_history"
248 WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1),
249 (SELECT updated_at FROM "_briven_auth_accounts"
250 WHERE user_id = $1 AND provider_id = 'credential' LIMIT 1),
251 (SELECT created_at FROM "_briven_auth_users" WHERE id = $1 LIMIT 1)
252 ) AS changed_at`,
253 [userId] as never,
254 ) as never,
255 );
256 const raw = ages[0]?.changed_at;
257 if (!raw) return { required: false };
258 const changedAt = raw instanceof Date ? raw : new Date(raw);
259 if (Number.isNaN(changedAt.getTime())) return { required: false };
260 const ageMs = Date.now() - changedAt.getTime();
261 const maxMs = policy.maxAgeDays * 86_400_000;
262 if (ageMs > maxMs) {
263 return {
264 required: true,
265 reason: `password expired after ${policy.maxAgeDays} days — please reset your password`,
266 };
267 }
268 return { required: false };
269}
270
271/** Admin: require password change on next sign-in. */
272export async function forcePasswordReset(
273 projectId: string,
274 userId: string,
275 reason?: string,
276): Promise<void> {
277 await runInProjectDatabase(projectId, async (tx) => {
278 await tx.unsafe(
279 `INSERT INTO "_briven_auth_password_force_reset" (user_id, reason, forced_at)
280 VALUES ($1, $2, now())
281 ON CONFLICT (user_id) DO UPDATE SET reason = $2, forced_at = now()`,
282 [userId, reason ?? null] as never,
283 );
284 });
285}
286
287export async function clearForcePasswordReset(
288 projectId: string,
289 userId: string,
290): Promise<void> {
291 await runInProjectDatabase(projectId, async (tx) => {
292 await tx.unsafe(
293 `DELETE FROM "_briven_auth_password_force_reset" WHERE user_id = $1`,
294 [userId] as never,
295 );
296 });
297}