@aooth/user 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as UserStoreUpdate, S as UserServiceConfig, _ as TotpConfig, a as LockoutConfig, b as UserAuthErrorType, c as MfaMethod, d as PasswordData, f as PasswordPolicyContext, g as PolicyCheckResult, h as PasswordPolicyInstance, i as LockStatus, l as MfaMethodInfo, m as PasswordPolicyEvalFn, n as AccountData, o as LoginResult, p as PasswordPolicyDef, r as DeepPartial, s as MfaData, t as UserStore, u as PasswordConfig, v as TransferablePolicy, x as UserCredentials, y as TrustedDeviceRecord } from "./user-store-Dbc9unW3.mjs";
1
+ import { C as UserServiceConfig, S as UserCredentials, _ as PolicyCheckResult, a as LockStatus, b as TrustedDeviceRecord, c as MfaData, d as PasswordConfig, f as PasswordData, g as PasswordPolicyInstance, h as PasswordPolicyEvalFn, i as DeepPartial, l as MfaMethod, m as PasswordPolicyDef, n as WithCasOptions, o as LockoutConfig, p as PasswordPolicyContext, r as AccountData, s as LoginResult, t as UserStore, u as MfaMethodInfo, v as TotpConfig, w as UserStoreUpdate, x as UserAuthErrorType, y as TransferablePolicy } from "./user-store-C1lxahSB.mjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -23,12 +23,39 @@ declare class PasswordHasher {
23
23
  //#endregion
24
24
  //#region src/password/policy.d.ts
25
25
  declare function normalizePolicies(policies?: (PasswordPolicyDef | PasswordPolicyInstance)[]): PasswordPolicy[];
26
+ /**
27
+ * Helper that builds a `PasswordPolicyDef` from a real backend function plus
28
+ * its bound positional arguments. The function runs directly on the server
29
+ * (no sandbox, no eval); `serialized` is auto-derived as
30
+ * `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
31
+ * so the same constraint can ship to the frontend without re-implementing it.
32
+ *
33
+ * Bundler-safe: positional invocation means renamed parameters inside the
34
+ * function body stay consistent — the call site only relies on argument
35
+ * ORDER, not identifier preservation.
36
+ *
37
+ * Constraint on the rule body: it MUST reference only its own parameters.
38
+ * No closures over module imports, no `this`, no helpers from outer scope —
39
+ * `rule.toString()` ships the literal source; any free identifier the
40
+ * frontend cannot resolve breaks transferability. (`String.prototype.match`,
41
+ * regex literals, plain JS globals are fine.)
42
+ *
43
+ * Omit `args` to mark the policy backend-only — `serialized` stays
44
+ * `undefined` and `transferable` is false; frontend pre-validation skips it
45
+ * and only the server check enforces the rule.
46
+ */
47
+ declare function definePasswordPolicy<A extends readonly unknown[]>(opts: {
48
+ rule: (v: string, ...args: A) => boolean | Promise<boolean>;
49
+ args?: A;
50
+ description?: string;
51
+ errorMessage?: string;
52
+ }): PasswordPolicyDef;
26
53
  declare class PasswordPolicy implements PasswordPolicyInstance {
27
- readonly rule: string | PasswordPolicyEvalFn;
54
+ readonly rule: PasswordPolicyEvalFn;
55
+ readonly serialized?: string;
28
56
  readonly description: string;
29
57
  readonly errorMessage: string;
30
58
  constructor(config: PasswordPolicyDef);
31
- protected _evalFn: PasswordPolicyEvalFn;
32
59
  evaluate(password: string, context?: PasswordPolicyContext): boolean | Promise<boolean>;
33
60
  get transferable(): boolean;
34
61
  }
@@ -50,6 +77,14 @@ declare class UserService<T extends object = object> {
50
77
  protected readonly hasher: PasswordHasher;
51
78
  constructor(store: UserStore<T>, config?: UserServiceConfig);
52
79
  /**
80
+ * Creates a user with `account.active: false`. The invite workflow relies
81
+ * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
82
+ * inactive until they accept). For setup scripts / seeders / tests that
83
+ * don't go through invite, follow up with `activateAccount(username)` or
84
+ * `login()` will throw `UserAuthError("INACTIVE")` — which the login
85
+ * workflow deliberately re-maps to `"Invalid credentials"` to avoid account
86
+ * enumeration, so the failure is silent client-side.
87
+ *
53
88
  * @param extras Optional partial user fields merged AFTER the base
54
89
  * `UserCredentials` shape, so callers can populate consumer-specific
55
90
  * required fields (e.g. `tenantId`) without subclassing the store.
@@ -67,7 +102,7 @@ declare class UserService<T extends object = object> {
67
102
  /**
68
103
  * Hard-delete the user row. Returns nothing on success. Throws
69
104
  * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
70
- * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
105
+ * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
71
106
  */
72
107
  deleteUser(username: string): Promise<void>;
73
108
  /**
@@ -102,16 +137,13 @@ declare class UserService<T extends object = object> {
102
137
  /**
103
138
  * Consume a backup code: returns `true` and removes the matching hash
104
139
  * from storage if `code` matches a stored backup code; returns `false`
105
- * if no match (without modifying storage).
106
- *
107
- * Read-then-write is not atomic at this layer: two concurrent consumes
108
- * of the same code may both succeed, with the last write winning. The
109
- * underlying store API does not expose an atomic match-and-remove, so
110
- * this is acceptable at the intended scale (backup codes are a fallback
111
- * path, not a hot one). Wrap in your store's transaction primitive if a
112
- * stricter guarantee is required.
140
+ * if no match (without modifying storage). Single-use is enforced by
141
+ * optimistic-concurrency CAS on the version column — concurrent consumes
142
+ * of the same code race fairly and only one wins; the loser re-reads,
143
+ * finds the hash already removed, and returns `false`.
113
144
  *
114
145
  * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
146
+ * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
115
147
  */
116
148
  consumeBackupCode(username: string, code: string): Promise<boolean>;
117
149
  /**
@@ -121,6 +153,18 @@ declare class UserService<T extends object = object> {
121
153
  * total tries across BOTH factors, not `2 * threshold`.
122
154
  */
123
155
  verifyMfa(username: string, code: string, config?: TotpConfig): Promise<void>;
156
+ /**
157
+ * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
158
+ * enrollment. Differs from `verifyMfa`:
159
+ * - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
160
+ * - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
161
+ * pre-activation; lockout doesn't apply).
162
+ * - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
163
+ *
164
+ * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
165
+ * totp method; MFA_INVALID on wrong code.
166
+ */
167
+ verifyTotpSetupCode(username: string, code: string, config?: TotpConfig): Promise<void>;
124
168
  getPasswordHasher(): PasswordHasher;
125
169
  getConfig(): Readonly<ResolvedConfig>;
126
170
  /**
@@ -176,6 +220,7 @@ declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
176
220
  create(data: UserCredentials & T): Promise<void>;
177
221
  update(username: string, update: UserStoreUpdate): Promise<boolean>;
178
222
  delete(username: string): Promise<boolean>;
223
+ withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
179
224
  }
180
225
  //#endregion
181
226
  //#region src/password/policies.d.ts
@@ -190,7 +235,12 @@ declare const ppMaxRepeatedChars: (maxRepeated?: number) => PasswordPolicyDef;
190
235
  declare function generateTotpSecret(bytes?: number): string;
191
236
  declare function generateTotpUri(secret: string, issuer: string, account: string, config?: Pick<TotpConfig, "period" | "digits">): string;
192
237
  declare function generateTotpCode(secret: string, config?: TotpConfig): string;
193
- declare function verifyTotpCode(secret: string, code: string, config?: TotpConfig): boolean;
238
+ /**
239
+ * Returns the matched HOTP counter when `code` is valid within the verification
240
+ * window, otherwise `null`. Returning the counter (not a bool) lets the caller
241
+ * persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
242
+ */
243
+ declare function verifyTotpCode(secret: string, code: string, config?: TotpConfig): number | null;
194
244
  declare function generateMfaCode(length?: number): string;
195
245
  //#endregion
196
246
  //#region src/mfa/codes.d.ts
@@ -230,4 +280,4 @@ declare function maskPhone(phone: string): string;
230
280
  declare function maskMfaValue(method: MfaMethod): string;
231
281
  declare function setAtPath(obj: object, path: string, value: unknown): void;
232
282
  //#endregion
233
- export { type AccountData, type DeepPartial, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
283
+ export { type AccountData, type DeepPartial, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, definePasswordPolicy, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
package/dist/index.mjs CHANGED
@@ -1,6 +1,5 @@
1
- import { a as maskEmail, c as setAtPath, i as incrementAtPath, l as UserAuthError, n as deepMerge, o as maskMfaValue, r as generateSecureRandom, s as maskPhone, t as UserStore } from "./user-store-B_l9vqlQ.mjs";
1
+ import { a as maskEmail, c as setAtPath, i as incrementAtPath, l as UserAuthError, n as deepMerge, o as maskMfaValue, r as generateSecureRandom, s as maskPhone, t as UserStore } from "./user-store-BaBmH13V.mjs";
2
2
  import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
3
- import { FtringsPool } from "@prostojs/ftring";
4
3
  //#region src/mfa/backup-codes.ts
5
4
  /**
6
5
  * Custom alphabet for backup codes — uppercase letters and digits with the
@@ -145,6 +144,11 @@ function generateTotpCode(secret, config) {
145
144
  const counter = Math.floor(now / 1e3 / period);
146
145
  return hotpCode(decode(secret), counter, digits);
147
146
  }
147
+ /**
148
+ * Returns the matched HOTP counter when `code` is valid within the verification
149
+ * window, otherwise `null`. Returning the counter (not a bool) lets the caller
150
+ * persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
151
+ */
148
152
  function verifyTotpCode(secret, code, config) {
149
153
  const period = config?.period ?? 30;
150
154
  const digits = config?.digits ?? 6;
@@ -152,14 +156,15 @@ function verifyTotpCode(secret, code, config) {
152
156
  const now = (config?.clock ?? Date.now)();
153
157
  const counter = Math.floor(now / 1e3 / period);
154
158
  const key = decode(secret);
155
- if (typeof code !== "string" || code.length !== digits) return false;
159
+ if (typeof code !== "string" || code.length !== digits) return null;
156
160
  const submitted = Buffer.from(code, "utf8");
157
- let matched = false;
161
+ let matchedCounter = null;
158
162
  for (let i = -window; i <= window; i++) {
159
- const expected = Buffer.from(hotpCode(key, counter + i, digits), "utf8");
160
- if (expected.length === submitted.length && timingSafeEqual(expected, submitted)) matched = true;
163
+ const stepCounter = counter + i;
164
+ const expected = Buffer.from(hotpCode(key, stepCounter, digits), "utf8");
165
+ if (expected.length === submitted.length && timingSafeEqual(expected, submitted)) matchedCounter = stepCounter;
161
166
  }
162
- return matched;
167
+ return matchedCounter;
163
168
  }
164
169
  function generateMfaCode(length = 6) {
165
170
  return generateSecureRandom(length, "0123456789");
@@ -268,33 +273,57 @@ var PasswordHasher = class {
268
273
  };
269
274
  //#endregion
270
275
  //#region src/password/policy.ts
271
- const fnPool = new FtringsPool();
272
276
  function normalizePolicies(policies) {
273
277
  return (policies || []).map((p) => p instanceof PasswordPolicy ? p : new PasswordPolicy(p));
274
278
  }
279
+ /**
280
+ * Helper that builds a `PasswordPolicyDef` from a real backend function plus
281
+ * its bound positional arguments. The function runs directly on the server
282
+ * (no sandbox, no eval); `serialized` is auto-derived as
283
+ * `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
284
+ * so the same constraint can ship to the frontend without re-implementing it.
285
+ *
286
+ * Bundler-safe: positional invocation means renamed parameters inside the
287
+ * function body stay consistent — the call site only relies on argument
288
+ * ORDER, not identifier preservation.
289
+ *
290
+ * Constraint on the rule body: it MUST reference only its own parameters.
291
+ * No closures over module imports, no `this`, no helpers from outer scope —
292
+ * `rule.toString()` ships the literal source; any free identifier the
293
+ * frontend cannot resolve breaks transferability. (`String.prototype.match`,
294
+ * regex literals, plain JS globals are fine.)
295
+ *
296
+ * Omit `args` to mark the policy backend-only — `serialized` stays
297
+ * `undefined` and `transferable` is false; frontend pre-validation skips it
298
+ * and only the server check enforces the rule.
299
+ */
300
+ function definePasswordPolicy(opts) {
301
+ const { rule, args, description, errorMessage } = opts;
302
+ const def = { rule: args ? (v) => rule(v, ...args) : (v) => rule(v, ...[]) };
303
+ if (args) {
304
+ const argList = args.map((a) => JSON.stringify(a)).join(", ");
305
+ def.serialized = argList.length > 0 ? `(v) => (${rule.toString()})(v, ${argList})` : `(v) => (${rule.toString()})(v)`;
306
+ }
307
+ if (description !== void 0) def.description = description;
308
+ if (errorMessage !== void 0) def.errorMessage = errorMessage;
309
+ return def;
310
+ }
275
311
  var PasswordPolicy = class {
276
312
  rule;
313
+ serialized;
277
314
  description;
278
315
  errorMessage;
279
316
  constructor(config) {
280
317
  this.rule = config.rule;
318
+ if (config.serialized !== void 0) this.serialized = config.serialized;
281
319
  this.description = config.description || "";
282
320
  this.errorMessage = config.errorMessage || "";
283
321
  }
284
- _evalFn;
285
322
  evaluate(password, context) {
286
- if (!this._evalFn) if (typeof this.rule === "function") this._evalFn = this.rule;
287
- else if (typeof this.rule === "string" && this.rule) {
288
- const fn = fnPool.getFn(this.rule);
289
- this._evalFn = (v, ctx) => fn({
290
- v,
291
- context: ctx
292
- });
293
- } else this._evalFn = () => true;
294
- return this._evalFn(password, context);
323
+ return this.rule(password, context);
295
324
  }
296
325
  get transferable() {
297
- return typeof this.rule === "string";
326
+ return this.serialized !== void 0;
298
327
  }
299
328
  };
300
329
  //#endregion
@@ -338,6 +367,14 @@ var UserService = class {
338
367
  this.hasher = new PasswordHasher(this.config.password);
339
368
  }
340
369
  /**
370
+ * Creates a user with `account.active: false`. The invite workflow relies
371
+ * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
372
+ * inactive until they accept). For setup scripts / seeders / tests that
373
+ * don't go through invite, follow up with `activateAccount(username)` or
374
+ * `login()` will throw `UserAuthError("INACTIVE")` — which the login
375
+ * workflow deliberately re-maps to `"Invalid credentials"` to avoid account
376
+ * enumeration, so the failure is silent client-side.
377
+ *
341
378
  * @param extras Optional partial user fields merged AFTER the base
342
379
  * `UserCredentials` shape, so callers can populate consumer-specific
343
380
  * required fields (e.g. `tenantId`) without subclassing the store.
@@ -417,7 +454,7 @@ var UserService = class {
417
454
  /**
418
455
  * Hard-delete the user row. Returns nothing on success. Throws
419
456
  * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
420
- * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
457
+ * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
421
458
  */
422
459
  async deleteUser(username) {
423
460
  if (!await this.store.delete(username)) throw new UserAuthError("NOT_FOUND");
@@ -490,30 +527,32 @@ var UserService = class {
490
527
  }
491
528
  getTransferablePolicies() {
492
529
  return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
493
- rule: p.rule,
530
+ rule: p.serialized,
494
531
  description: p.description,
495
532
  errorMessage: p.errorMessage
496
533
  }));
497
534
  }
498
535
  async addMfaMethod(username, method) {
499
- const methods = [...(await this.getUser(username)).mfa.methods.filter((m) => m.name !== method.name), method];
500
- await this.store.update(username, { set: { mfa: { methods } } });
536
+ await this.store.withCas(username, (user) => {
537
+ return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
538
+ });
501
539
  }
502
540
  async confirmMfaMethod(username, name) {
503
- const user = await this.getUser(username);
504
- let found = false;
505
- const methods = user.mfa.methods.map((m) => {
506
- if (m.name === name) {
507
- found = true;
508
- return {
509
- ...m,
510
- confirmed: true
511
- };
512
- }
513
- return m;
541
+ await this.store.withCas(username, (user) => {
542
+ let found = false;
543
+ const methods = user.mfa.methods.map((m) => {
544
+ if (m.name === name) {
545
+ found = true;
546
+ return {
547
+ ...m,
548
+ confirmed: true
549
+ };
550
+ }
551
+ return m;
552
+ });
553
+ if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
554
+ return { set: { mfa: { methods } } };
514
555
  });
515
- if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
516
- await this.store.update(username, { set: { mfa: { methods } } });
517
556
  }
518
557
  async removeMfaMethod(username, name) {
519
558
  const user = await this.getUser(username);
@@ -556,24 +595,27 @@ var UserService = class {
556
595
  /**
557
596
  * Consume a backup code: returns `true` and removes the matching hash
558
597
  * from storage if `code` matches a stored backup code; returns `false`
559
- * if no match (without modifying storage).
560
- *
561
- * Read-then-write is not atomic at this layer: two concurrent consumes
562
- * of the same code may both succeed, with the last write winning. The
563
- * underlying store API does not expose an atomic match-and-remove, so
564
- * this is acceptable at the intended scale (backup codes are a fallback
565
- * path, not a hot one). Wrap in your store's transaction primitive if a
566
- * stricter guarantee is required.
598
+ * if no match (without modifying storage). Single-use is enforced by
599
+ * optimistic-concurrency CAS on the version column — concurrent consumes
600
+ * of the same code race fairly and only one wins; the loser re-reads,
601
+ * finds the hash already removed, and returns `false`.
567
602
  *
568
603
  * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
604
+ * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
569
605
  */
570
606
  async consumeBackupCode(username, code) {
571
- const hashes = (await this.getUser(username)).backupCodes ?? [];
572
- const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
573
- if (idx < 0) return false;
574
- const remaining = hashes.filter((_, i) => i !== idx);
575
- await this.store.update(username, { set: { backupCodes: remaining } });
576
- return true;
607
+ let consumed = false;
608
+ await this.store.withCas(username, (user) => {
609
+ const hashes = user.backupCodes ?? [];
610
+ const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
611
+ if (idx < 0) {
612
+ consumed = false;
613
+ return null;
614
+ }
615
+ consumed = true;
616
+ return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
617
+ });
618
+ return consumed;
577
619
  }
578
620
  /**
579
621
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
@@ -587,12 +629,43 @@ var UserService = class {
587
629
  await this.ensureNotLockedOrThrow(username, user.account);
588
630
  const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
589
631
  if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
590
- if (verifyTotpCode(totp.value, code, config)) {
591
- if (user.account.failedLoginAttempts > 0) await this.store.update(username, { set: { account: { failedLoginAttempts: 0 } } });
592
- return;
632
+ const matchedCounter = verifyTotpCode(totp.value, code, config);
633
+ const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
634
+ if (matchedCounter !== null && !isReplay) {
635
+ let replayDuringCas = false;
636
+ await this.store.withCas(username, (current) => {
637
+ const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
638
+ if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
639
+ replayDuringCas = true;
640
+ return null;
641
+ }
642
+ const set = { mfa: { methods: current.mfa.methods.map((m) => m.name === "totp" && m.confirmed ? {
643
+ ...m,
644
+ lastUsedWindow: matchedCounter
645
+ } : m) } };
646
+ if (current.account.failedLoginAttempts > 0) set.account = { failedLoginAttempts: 0 };
647
+ return { set };
648
+ });
649
+ if (!replayDuringCas) return;
593
650
  }
594
651
  await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
595
652
  }
653
+ /**
654
+ * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
655
+ * enrollment. Differs from `verifyMfa`:
656
+ * - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
657
+ * - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
658
+ * pre-activation; lockout doesn't apply).
659
+ * - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
660
+ *
661
+ * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
662
+ * totp method; MFA_INVALID on wrong code.
663
+ */
664
+ async verifyTotpSetupCode(username, code, config) {
665
+ const totp = (await this.getUser(username)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
666
+ if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
667
+ if (verifyTotpCode(totp.value, code, config) === null) throw new UserAuthError("MFA_INVALID");
668
+ }
596
669
  getPasswordHasher() {
597
670
  return this.hasher;
598
671
  }
@@ -622,8 +695,9 @@ var UserService = class {
622
695
  * merge strategy replace the whole array.
623
696
  */
624
697
  async addTrustedDevice(username, record) {
625
- const next = [...(await this.getUser(username)).trustedDevices ?? [], record];
626
- await this.store.update(username, { set: { trustedDevices: next } });
698
+ await this.store.withCas(username, (user) => {
699
+ return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
700
+ });
627
701
  }
628
702
  /**
629
703
  * Returns true when the supplied token (a) signs against the user+ip with
@@ -750,50 +824,74 @@ var UserStoreMemory = class extends UserStore {
750
824
  }
751
825
  async create(data) {
752
826
  if (this.store.has(data.username)) throw new UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
753
- this.store.set(data.username, structuredClone(data));
827
+ const cloned = structuredClone(data);
828
+ cloned.version = 0;
829
+ this.store.set(data.username, cloned);
754
830
  }
755
831
  async update(username, update) {
756
832
  const user = this.store.get(username);
757
833
  if (!user) return false;
834
+ if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
758
835
  if (update.set) deepMerge(user, update.set);
759
836
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) incrementAtPath(user, path, amount);
837
+ user.version = (user.version ?? 0) + 1;
760
838
  return true;
761
839
  }
762
840
  async delete(username) {
763
841
  return this.store.delete(username);
764
842
  }
843
+ async withCas(username, mutator, opts) {
844
+ const maxAttempts = opts?.maxAttempts ?? 2;
845
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
846
+ const current = await this.findByUsername(username);
847
+ if (!current) throw new UserAuthError("NOT_FOUND");
848
+ const patch = mutator(current);
849
+ if (patch === null) return;
850
+ if (await this.update(username, {
851
+ ...patch,
852
+ expectedVersion: current.version ?? 0
853
+ })) return;
854
+ }
855
+ throw new UserAuthError("CAS_EXHAUSTED");
856
+ }
765
857
  };
766
858
  //#endregion
767
859
  //#region src/password/policies.ts
768
- const ppHasMinLength = (min = 8) => ({
769
- rule: `v.length >= ${min}`,
860
+ const ppHasMinLength = (min = 8) => definePasswordPolicy({
861
+ rule: (v, min) => v.length >= min,
862
+ args: [min],
770
863
  description: `Minimum length ${min}`,
771
864
  errorMessage: `Password must be at least ${min} characters long`
772
865
  });
773
- const ppHasUpperCase = (n = 1) => ({
774
- rule: `(v.match(/[A-Z]/g) || []).length >= ${n}`,
866
+ const ppHasUpperCase = (n = 1) => definePasswordPolicy({
867
+ rule: (v, n) => (v.match(/[A-Z]/g) || []).length >= n,
868
+ args: [n],
775
869
  description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
776
870
  errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
777
871
  });
778
- const ppHasLowerCase = (n = 1) => ({
779
- rule: `(v.match(/[a-z]/g) || []).length >= ${n}`,
872
+ const ppHasLowerCase = (n = 1) => definePasswordPolicy({
873
+ rule: (v, n) => (v.match(/[a-z]/g) || []).length >= n,
874
+ args: [n],
780
875
  description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
781
876
  errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
782
877
  });
783
- const ppHasNumber = (n = 1) => ({
784
- rule: `(v.match(/\\d/g) || []).length >= ${n}`,
878
+ const ppHasNumber = (n = 1) => definePasswordPolicy({
879
+ rule: (v, n) => (v.match(/\d/g) || []).length >= n,
880
+ args: [n],
785
881
  description: `At least ${n} number${n === 1 ? "" : "s"}`,
786
882
  errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
787
883
  });
788
- const ppHasSpecialChar = (n = 1) => ({
789
- rule: `(v.match(/[^A-Za-z0-9]/g) || []).length >= ${n}`,
884
+ const ppHasSpecialChar = (n = 1) => definePasswordPolicy({
885
+ rule: (v, n) => (v.match(/[^A-Za-z0-9]/g) || []).length >= n,
886
+ args: [n],
790
887
  description: `At least ${n} special character${n === 1 ? "" : "s"}`,
791
888
  errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
792
889
  });
793
- const ppMaxRepeatedChars = (maxRepeated = 2) => ({
794
- rule: `/(.)\\1{${maxRepeated},}/.test(v) === false`,
890
+ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
891
+ rule: (v, maxRepeated) => !new RegExp(`(.)\\1{${maxRepeated},}`).test(v),
892
+ args: [maxRepeated],
795
893
  description: `No more than ${maxRepeated} consecutive repeated characters`,
796
894
  errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
797
895
  });
798
896
  //#endregion
799
- export { PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
897
+ export { PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
@@ -2,6 +2,14 @@
2
2
  interface UserCredentials {
3
3
  id: string;
4
4
  username: string;
5
+ /**
6
+ * Server-managed optimistic-concurrency counter. Bumped by `UserStore.update`
7
+ * on every successful write; checked against `UserStoreUpdate.expectedVersion`
8
+ * for CAS. Callers MUST NOT write it directly — atscript-db rejects direct
9
+ * writes with `DbError("VERSION_COLUMN_WRITE")`. Optional in TS so pre-OCC
10
+ * fixtures keep compiling; the store seeds `0` on insert.
11
+ */
12
+ version?: number;
5
13
  password: PasswordData;
6
14
  account: AccountData;
7
15
  mfa: MfaData;
@@ -49,8 +57,8 @@ interface AccountData {
49
57
  * True while the user record exists from an admin-issued invite but the
50
58
  * invitee has not yet accepted (set password + activate). Used by
51
59
  * `InviteWorkflow` to gate the accept tail, reject duplicate invites, and
52
- * power `auth.reInvite` / `auth.cancelInvite`. Absent / `false` once the
53
- * invite has been accepted.
60
+ * power `auth/invite/resend` / `auth/invite/cancel`. Absent / `false` once
61
+ * the invite has been accepted.
54
62
  */
55
63
  pendingInvitation?: boolean;
56
64
  }
@@ -69,6 +77,12 @@ interface MfaMethod {
69
77
  confirmed: boolean;
70
78
  /** The method's value: email address, phone number, or TOTP secret */
71
79
  value: string;
80
+ /**
81
+ * Last HOTP counter accepted for this method (TOTP only). Server-managed
82
+ * replay guard — `verifyMfa` rejects any code whose matched counter is
83
+ * `<= lastUsedWindow`. Never written from user-facing input.
84
+ */
85
+ lastUsedWindow?: number;
72
86
  }
73
87
  interface UserServiceConfig {
74
88
  password?: PasswordConfig;
@@ -107,7 +121,23 @@ interface LockoutConfig {
107
121
  duration?: number;
108
122
  }
109
123
  interface PasswordPolicyDef {
110
- rule: string | PasswordPolicyEvalFn;
124
+ /**
125
+ * Backend evaluator. Function only — executed directly with no sandbox.
126
+ * String rules were removed: `@prostojs/ftring`'s sandbox does NOT block
127
+ * prototype-chain escapes (`constructor.constructor("return process")()`,
128
+ * `__proto__.x = ...`), so accepting strings was an RCE vector. Authors
129
+ * use `definePasswordPolicy({ rule, args })` to get both this fn AND the
130
+ * serialized form for free.
131
+ */
132
+ rule: PasswordPolicyEvalFn;
133
+ /**
134
+ * Pre-baked function-literal text shipped to clients for cross-tier
135
+ * validation via `getTransferablePolicies()`. Authored as
136
+ * `(v) => (${ruleSource})(v, ${args.map(JSON.stringify).join(', ')})` by
137
+ * `definePasswordPolicy`. Absent → the policy is backend-only (frontend
138
+ * skips it; server-side check remains authoritative).
139
+ */
140
+ serialized?: string;
111
141
  description?: string;
112
142
  errorMessage?: string;
113
143
  }
@@ -127,8 +157,15 @@ interface UserStoreUpdate {
127
157
  set?: DeepPartial<UserCredentials>;
128
158
  /** Dot-paths to atomically increment: e.g. {'account.failedLoginAttempts': 1} */
129
159
  inc?: Record<string, number>;
160
+ /**
161
+ * Optimistic concurrency control: when supplied, the store applies the
162
+ * update iff the row's current `version` equals this value. On mismatch
163
+ * the store returns `false` (same shape as "not found") and does NOT
164
+ * mutate. Service callers treat both states as "stale read, retry".
165
+ */
166
+ expectedVersion?: number;
130
167
  }
131
- type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED";
168
+ type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED" | "CAS_EXHAUSTED";
132
169
  interface LoginResult<T extends object = object> {
133
170
  user: UserCredentials & T;
134
171
  /** Whether MFA verification is required before granting full access */
@@ -171,6 +208,15 @@ interface TotpConfig {
171
208
  }
172
209
  //#endregion
173
210
  //#region src/store/user-store.d.ts
211
+ interface WithCasOptions {
212
+ /**
213
+ * Total attempts (1 initial + retries). Default `2` = one retry. Each
214
+ * attempt re-reads the row so the mutator runs against fresh state — that
215
+ * is the whole point of retry under OCC. Bump for high-contention writers
216
+ * (bulk admin scripts); leave at default for normal per-user request flow.
217
+ */
218
+ maxAttempts?: number;
219
+ }
174
220
  declare abstract class UserStore<T extends object = object> {
175
221
  abstract exists(username: string): Promise<boolean>;
176
222
  abstract findByUsername(username: string): Promise<(UserCredentials & T) | null>;
@@ -179,9 +225,22 @@ declare abstract class UserStore<T extends object = object> {
179
225
  /**
180
226
  * Hard-delete the row. Returns `true` when a row was removed, `false` when
181
227
  * the username was not found. Used by `UserService.deleteUser` (and in turn
182
- * by the invite workflow's `auth.cancelInvite` step).
228
+ * by the invite workflow's `auth/invite/cancel` step).
183
229
  */
184
230
  abstract delete(username: string): Promise<boolean>;
231
+ /**
232
+ * Run a read-modify-write cycle under optimistic concurrency. Each attempt
233
+ * fetches the current row, calls `mutator` with it, and applies the returned
234
+ * patch under CAS (`expectedVersion = current.version`). On CAS miss the
235
+ * cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null` to
236
+ * exit early without writing — used for "race-loser detects nothing left to
237
+ * do" paths (e.g. the backup code was already consumed by the winner).
238
+ *
239
+ * Throws `UserAuthError("NOT_FOUND")` when no row matches `username`, or
240
+ * `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors
241
+ * thrown from inside `mutator` propagate immediately without retry.
242
+ */
243
+ abstract withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
185
244
  }
186
245
  //#endregion
187
- export { UserStoreUpdate as C, UserServiceConfig as S, TotpConfig as _, LockoutConfig as a, UserAuthErrorType as b, MfaMethod as c, PasswordData as d, PasswordPolicyContext as f, PolicyCheckResult as g, PasswordPolicyInstance as h, LockStatus as i, MfaMethodInfo as l, PasswordPolicyEvalFn as m, AccountData as n, LoginResult as o, PasswordPolicyDef as p, DeepPartial as r, MfaData as s, UserStore as t, PasswordConfig as u, TransferablePolicy as v, UserCredentials as x, TrustedDeviceRecord as y };
246
+ export { UserServiceConfig as C, UserCredentials as S, PolicyCheckResult as _, LockStatus as a, TrustedDeviceRecord as b, MfaData as c, PasswordConfig as d, PasswordData as f, PasswordPolicyInstance as g, PasswordPolicyEvalFn as h, DeepPartial as i, MfaMethod as l, PasswordPolicyDef as m, WithCasOptions as n, LockoutConfig as o, PasswordPolicyContext as p, AccountData as r, LoginResult as s, UserStore as t, MfaMethodInfo as u, TotpConfig as v, UserStoreUpdate as w, UserAuthErrorType as x, TransferablePolicy as y };
@@ -11,7 +11,8 @@ const defaultMessages = {
11
11
  PASSWORD_IN_HISTORY: "Password was recently used",
12
12
  MFA_REQUIRED: "Multi-factor authentication is required",
13
13
  MFA_INVALID: "Invalid MFA code",
14
- MFA_NOT_CONFIGURED: "MFA method is not configured"
14
+ MFA_NOT_CONFIGURED: "MFA method is not configured",
15
+ CAS_EXHAUSTED: "Update conflict — please retry"
15
16
  };
16
17
  var UserAuthError = class extends Error {
17
18
  name = "UserAuthError";
@@ -11,7 +11,8 @@ const defaultMessages = {
11
11
  PASSWORD_IN_HISTORY: "Password was recently used",
12
12
  MFA_REQUIRED: "Multi-factor authentication is required",
13
13
  MFA_INVALID: "Invalid MFA code",
14
- MFA_NOT_CONFIGURED: "MFA method is not configured"
14
+ MFA_NOT_CONFIGURED: "MFA method is not configured",
15
+ CAS_EXHAUSTED: "Update conflict — please retry"
15
16
  };
16
17
  var UserAuthError = class extends Error {
17
18
  name = "UserAuthError";