@aooth/user 0.1.3 → 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/atscript-db.cjs +25 -1
- package/dist/atscript-db.d.cts +11 -1
- package/dist/atscript-db.d.mts +11 -1
- package/dist/atscript-db.mjs +25 -1
- package/dist/index.cjs +159 -68
- package/dist/index.d.cts +56 -14
- package/dist/index.d.mts +56 -14
- package/dist/index.mjs +159 -69
- package/dist/{user-store-Dbc9unW3.d.mts → user-store-B3EStUfT.d.cts} +65 -6
- package/dist/{user-store-CdWrTeqR.cjs → user-store-BPZVAboN.cjs} +2 -1
- package/dist/{user-store-B_l9vqlQ.mjs → user-store-BaBmH13V.mjs} +2 -1
- package/dist/{user-store-VJPWNgdp.d.cts → user-store-C1lxahSB.d.mts} +65 -6
- package/package.json +10 -11
- package/src/atscript-db/user-credentials.as +6 -1
package/dist/atscript-db.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_user_store = require("./user-store-
|
|
2
|
+
const require_user_store = require("./user-store-BPZVAboN.cjs");
|
|
3
3
|
//#region src/atscript-db/index.ts
|
|
4
4
|
function isConflict(err) {
|
|
5
5
|
return typeof err === "object" && err !== null && err.code === "CONFLICT";
|
|
@@ -34,11 +34,35 @@ var UsersStoreAtscriptDb = class extends require_user_store.UserStore {
|
|
|
34
34
|
if (update.set) Object.assign(patch, update.set);
|
|
35
35
|
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_user_store.setAtPath(patch, path, { $inc: amount });
|
|
36
36
|
if (Object.keys(patch).length <= 1) return true;
|
|
37
|
+
if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
|
|
37
38
|
return (await this.table.updateOne(patch)).matchedCount > 0;
|
|
38
39
|
}
|
|
39
40
|
async delete(username) {
|
|
40
41
|
return (await this.table.deleteMany({ username })).deletedCount > 0;
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Inline retry loop rather than delegating to @atscript/db's
|
|
45
|
+
* `withOptimisticRetry`: that helper expects the mutator to always return a
|
|
46
|
+
* patch object, but our contract lets the mutator return `null` (the
|
|
47
|
+
* race-loser detects nothing to do — see `UserService.consumeBackupCode`).
|
|
48
|
+
* Bridging would need a sentinel exception. The version-bump + $cas
|
|
49
|
+
* atomicity still happen at the atscript-db table layer via the
|
|
50
|
+
* `expectedVersion` we thread through `update()`.
|
|
51
|
+
*/
|
|
52
|
+
async withCas(username, mutator, opts) {
|
|
53
|
+
const maxAttempts = opts?.maxAttempts ?? 2;
|
|
54
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
55
|
+
const current = await this.findByUsername(username);
|
|
56
|
+
if (!current) throw new require_user_store.UserAuthError("NOT_FOUND");
|
|
57
|
+
const patch = mutator(current);
|
|
58
|
+
if (patch === null) return;
|
|
59
|
+
if (await this.update(username, {
|
|
60
|
+
...patch,
|
|
61
|
+
expectedVersion: current.version ?? 0
|
|
62
|
+
})) return;
|
|
63
|
+
}
|
|
64
|
+
throw new require_user_store.UserAuthError("CAS_EXHAUSTED");
|
|
65
|
+
}
|
|
42
66
|
};
|
|
43
67
|
//#endregion
|
|
44
68
|
exports.UsersStoreAtscriptDb = UsersStoreAtscriptDb;
|
package/dist/atscript-db.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { S as UserCredentials, n as WithCasOptions, t as UserStore, w as UserStoreUpdate } from "./user-store-B3EStUfT.cjs";
|
|
2
2
|
|
|
3
3
|
//#region src/atscript-db/index.d.ts
|
|
4
4
|
/**
|
|
@@ -50,6 +50,16 @@ declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends
|
|
|
50
50
|
create(data: UserCredentials & TUserCustom): Promise<void>;
|
|
51
51
|
update(username: string, update: UserStoreUpdate): Promise<boolean>;
|
|
52
52
|
delete(username: string): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Inline retry loop rather than delegating to @atscript/db's
|
|
55
|
+
* `withOptimisticRetry`: that helper expects the mutator to always return a
|
|
56
|
+
* patch object, but our contract lets the mutator return `null` (the
|
|
57
|
+
* race-loser detects nothing to do — see `UserService.consumeBackupCode`).
|
|
58
|
+
* Bridging would need a sentinel exception. The version-bump + $cas
|
|
59
|
+
* atomicity still happen at the atscript-db table layer via the
|
|
60
|
+
* `expectedVersion` we thread through `update()`.
|
|
61
|
+
*/
|
|
62
|
+
withCas(username: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
53
63
|
}
|
|
54
64
|
//#endregion
|
|
55
65
|
export { AuthUserTable, UserCredentialsRow, UsersStoreAtscriptDb };
|
package/dist/atscript-db.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { S as UserCredentials, n as WithCasOptions, t as UserStore, w as UserStoreUpdate } from "./user-store-C1lxahSB.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/atscript-db/index.d.ts
|
|
4
4
|
/**
|
|
@@ -50,6 +50,16 @@ declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends
|
|
|
50
50
|
create(data: UserCredentials & TUserCustom): Promise<void>;
|
|
51
51
|
update(username: string, update: UserStoreUpdate): Promise<boolean>;
|
|
52
52
|
delete(username: string): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Inline retry loop rather than delegating to @atscript/db's
|
|
55
|
+
* `withOptimisticRetry`: that helper expects the mutator to always return a
|
|
56
|
+
* patch object, but our contract lets the mutator return `null` (the
|
|
57
|
+
* race-loser detects nothing to do — see `UserService.consumeBackupCode`).
|
|
58
|
+
* Bridging would need a sentinel exception. The version-bump + $cas
|
|
59
|
+
* atomicity still happen at the atscript-db table layer via the
|
|
60
|
+
* `expectedVersion` we thread through `update()`.
|
|
61
|
+
*/
|
|
62
|
+
withCas(username: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
53
63
|
}
|
|
54
64
|
//#endregion
|
|
55
65
|
export { AuthUserTable, UserCredentialsRow, UsersStoreAtscriptDb };
|
package/dist/atscript-db.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as setAtPath, l as UserAuthError, t as UserStore } from "./user-store-
|
|
1
|
+
import { c as setAtPath, l as UserAuthError, t as UserStore } from "./user-store-BaBmH13V.mjs";
|
|
2
2
|
//#region src/atscript-db/index.ts
|
|
3
3
|
function isConflict(err) {
|
|
4
4
|
return typeof err === "object" && err !== null && err.code === "CONFLICT";
|
|
@@ -33,11 +33,35 @@ var UsersStoreAtscriptDb = class extends UserStore {
|
|
|
33
33
|
if (update.set) Object.assign(patch, update.set);
|
|
34
34
|
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) setAtPath(patch, path, { $inc: amount });
|
|
35
35
|
if (Object.keys(patch).length <= 1) return true;
|
|
36
|
+
if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
|
|
36
37
|
return (await this.table.updateOne(patch)).matchedCount > 0;
|
|
37
38
|
}
|
|
38
39
|
async delete(username) {
|
|
39
40
|
return (await this.table.deleteMany({ username })).deletedCount > 0;
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Inline retry loop rather than delegating to @atscript/db's
|
|
44
|
+
* `withOptimisticRetry`: that helper expects the mutator to always return a
|
|
45
|
+
* patch object, but our contract lets the mutator return `null` (the
|
|
46
|
+
* race-loser detects nothing to do — see `UserService.consumeBackupCode`).
|
|
47
|
+
* Bridging would need a sentinel exception. The version-bump + $cas
|
|
48
|
+
* atomicity still happen at the atscript-db table layer via the
|
|
49
|
+
* `expectedVersion` we thread through `update()`.
|
|
50
|
+
*/
|
|
51
|
+
async withCas(username, mutator, opts) {
|
|
52
|
+
const maxAttempts = opts?.maxAttempts ?? 2;
|
|
53
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
54
|
+
const current = await this.findByUsername(username);
|
|
55
|
+
if (!current) throw new UserAuthError("NOT_FOUND");
|
|
56
|
+
const patch = mutator(current);
|
|
57
|
+
if (patch === null) return;
|
|
58
|
+
if (await this.update(username, {
|
|
59
|
+
...patch,
|
|
60
|
+
expectedVersion: current.version ?? 0
|
|
61
|
+
})) return;
|
|
62
|
+
}
|
|
63
|
+
throw new UserAuthError("CAS_EXHAUSTED");
|
|
64
|
+
}
|
|
41
65
|
};
|
|
42
66
|
//#endregion
|
|
43
67
|
export { UsersStoreAtscriptDb };
|
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_user_store = require("./user-store-
|
|
2
|
+
const require_user_store = require("./user-store-BPZVAboN.cjs");
|
|
3
3
|
let node_crypto = require("node:crypto");
|
|
4
|
-
let _prostojs_ftring = require("@prostojs/ftring");
|
|
5
4
|
//#region src/mfa/backup-codes.ts
|
|
6
5
|
/**
|
|
7
6
|
* Custom alphabet for backup codes — uppercase letters and digits with the
|
|
@@ -146,6 +145,11 @@ function generateTotpCode(secret, config) {
|
|
|
146
145
|
const counter = Math.floor(now / 1e3 / period);
|
|
147
146
|
return hotpCode(decode(secret), counter, digits);
|
|
148
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns the matched HOTP counter when `code` is valid within the verification
|
|
150
|
+
* window, otherwise `null`. Returning the counter (not a bool) lets the caller
|
|
151
|
+
* persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
|
|
152
|
+
*/
|
|
149
153
|
function verifyTotpCode(secret, code, config) {
|
|
150
154
|
const period = config?.period ?? 30;
|
|
151
155
|
const digits = config?.digits ?? 6;
|
|
@@ -153,14 +157,15 @@ function verifyTotpCode(secret, code, config) {
|
|
|
153
157
|
const now = (config?.clock ?? Date.now)();
|
|
154
158
|
const counter = Math.floor(now / 1e3 / period);
|
|
155
159
|
const key = decode(secret);
|
|
156
|
-
if (typeof code !== "string" || code.length !== digits) return
|
|
160
|
+
if (typeof code !== "string" || code.length !== digits) return null;
|
|
157
161
|
const submitted = Buffer.from(code, "utf8");
|
|
158
|
-
let
|
|
162
|
+
let matchedCounter = null;
|
|
159
163
|
for (let i = -window; i <= window; i++) {
|
|
160
|
-
const
|
|
161
|
-
|
|
164
|
+
const stepCounter = counter + i;
|
|
165
|
+
const expected = Buffer.from(hotpCode(key, stepCounter, digits), "utf8");
|
|
166
|
+
if (expected.length === submitted.length && (0, node_crypto.timingSafeEqual)(expected, submitted)) matchedCounter = stepCounter;
|
|
162
167
|
}
|
|
163
|
-
return
|
|
168
|
+
return matchedCounter;
|
|
164
169
|
}
|
|
165
170
|
function generateMfaCode(length = 6) {
|
|
166
171
|
return require_user_store.generateSecureRandom(length, "0123456789");
|
|
@@ -269,33 +274,57 @@ var PasswordHasher = class {
|
|
|
269
274
|
};
|
|
270
275
|
//#endregion
|
|
271
276
|
//#region src/password/policy.ts
|
|
272
|
-
const fnPool = new _prostojs_ftring.FtringsPool();
|
|
273
277
|
function normalizePolicies(policies) {
|
|
274
278
|
return (policies || []).map((p) => p instanceof PasswordPolicy ? p : new PasswordPolicy(p));
|
|
275
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Helper that builds a `PasswordPolicyDef` from a real backend function plus
|
|
282
|
+
* its bound positional arguments. The function runs directly on the server
|
|
283
|
+
* (no sandbox, no eval); `serialized` is auto-derived as
|
|
284
|
+
* `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
|
|
285
|
+
* so the same constraint can ship to the frontend without re-implementing it.
|
|
286
|
+
*
|
|
287
|
+
* Bundler-safe: positional invocation means renamed parameters inside the
|
|
288
|
+
* function body stay consistent — the call site only relies on argument
|
|
289
|
+
* ORDER, not identifier preservation.
|
|
290
|
+
*
|
|
291
|
+
* Constraint on the rule body: it MUST reference only its own parameters.
|
|
292
|
+
* No closures over module imports, no `this`, no helpers from outer scope —
|
|
293
|
+
* `rule.toString()` ships the literal source; any free identifier the
|
|
294
|
+
* frontend cannot resolve breaks transferability. (`String.prototype.match`,
|
|
295
|
+
* regex literals, plain JS globals are fine.)
|
|
296
|
+
*
|
|
297
|
+
* Omit `args` to mark the policy backend-only — `serialized` stays
|
|
298
|
+
* `undefined` and `transferable` is false; frontend pre-validation skips it
|
|
299
|
+
* and only the server check enforces the rule.
|
|
300
|
+
*/
|
|
301
|
+
function definePasswordPolicy(opts) {
|
|
302
|
+
const { rule, args, description, errorMessage } = opts;
|
|
303
|
+
const def = { rule: args ? (v) => rule(v, ...args) : (v) => rule(v, ...[]) };
|
|
304
|
+
if (args) {
|
|
305
|
+
const argList = args.map((a) => JSON.stringify(a)).join(", ");
|
|
306
|
+
def.serialized = argList.length > 0 ? `(v) => (${rule.toString()})(v, ${argList})` : `(v) => (${rule.toString()})(v)`;
|
|
307
|
+
}
|
|
308
|
+
if (description !== void 0) def.description = description;
|
|
309
|
+
if (errorMessage !== void 0) def.errorMessage = errorMessage;
|
|
310
|
+
return def;
|
|
311
|
+
}
|
|
276
312
|
var PasswordPolicy = class {
|
|
277
313
|
rule;
|
|
314
|
+
serialized;
|
|
278
315
|
description;
|
|
279
316
|
errorMessage;
|
|
280
317
|
constructor(config) {
|
|
281
318
|
this.rule = config.rule;
|
|
319
|
+
if (config.serialized !== void 0) this.serialized = config.serialized;
|
|
282
320
|
this.description = config.description || "";
|
|
283
321
|
this.errorMessage = config.errorMessage || "";
|
|
284
322
|
}
|
|
285
|
-
_evalFn;
|
|
286
323
|
evaluate(password, context) {
|
|
287
|
-
|
|
288
|
-
else if (typeof this.rule === "string" && this.rule) {
|
|
289
|
-
const fn = fnPool.getFn(this.rule);
|
|
290
|
-
this._evalFn = (v, ctx) => fn({
|
|
291
|
-
v,
|
|
292
|
-
context: ctx
|
|
293
|
-
});
|
|
294
|
-
} else this._evalFn = () => true;
|
|
295
|
-
return this._evalFn(password, context);
|
|
324
|
+
return this.rule(password, context);
|
|
296
325
|
}
|
|
297
326
|
get transferable() {
|
|
298
|
-
return
|
|
327
|
+
return this.serialized !== void 0;
|
|
299
328
|
}
|
|
300
329
|
};
|
|
301
330
|
//#endregion
|
|
@@ -426,7 +455,7 @@ var UserService = class {
|
|
|
426
455
|
/**
|
|
427
456
|
* Hard-delete the user row. Returns nothing on success. Throws
|
|
428
457
|
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
429
|
-
* invite workflow's `auth
|
|
458
|
+
* invite workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
430
459
|
*/
|
|
431
460
|
async deleteUser(username) {
|
|
432
461
|
if (!await this.store.delete(username)) throw new require_user_store.UserAuthError("NOT_FOUND");
|
|
@@ -499,30 +528,32 @@ var UserService = class {
|
|
|
499
528
|
}
|
|
500
529
|
getTransferablePolicies() {
|
|
501
530
|
return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
|
|
502
|
-
rule: p.
|
|
531
|
+
rule: p.serialized,
|
|
503
532
|
description: p.description,
|
|
504
533
|
errorMessage: p.errorMessage
|
|
505
534
|
}));
|
|
506
535
|
}
|
|
507
536
|
async addMfaMethod(username, method) {
|
|
508
|
-
|
|
509
|
-
|
|
537
|
+
await this.store.withCas(username, (user) => {
|
|
538
|
+
return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
|
|
539
|
+
});
|
|
510
540
|
}
|
|
511
541
|
async confirmMfaMethod(username, name) {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
542
|
+
await this.store.withCas(username, (user) => {
|
|
543
|
+
let found = false;
|
|
544
|
+
const methods = user.mfa.methods.map((m) => {
|
|
545
|
+
if (m.name === name) {
|
|
546
|
+
found = true;
|
|
547
|
+
return {
|
|
548
|
+
...m,
|
|
549
|
+
confirmed: true
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
return m;
|
|
553
|
+
});
|
|
554
|
+
if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
555
|
+
return { set: { mfa: { methods } } };
|
|
523
556
|
});
|
|
524
|
-
if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
525
|
-
await this.store.update(username, { set: { mfa: { methods } } });
|
|
526
557
|
}
|
|
527
558
|
async removeMfaMethod(username, name) {
|
|
528
559
|
const user = await this.getUser(username);
|
|
@@ -565,24 +596,27 @@ var UserService = class {
|
|
|
565
596
|
/**
|
|
566
597
|
* Consume a backup code: returns `true` and removes the matching hash
|
|
567
598
|
* from storage if `code` matches a stored backup code; returns `false`
|
|
568
|
-
* if no match (without modifying storage).
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
* underlying store API does not expose an atomic match-and-remove, so
|
|
573
|
-
* this is acceptable at the intended scale (backup codes are a fallback
|
|
574
|
-
* path, not a hot one). Wrap in your store's transaction primitive if a
|
|
575
|
-
* stricter guarantee is required.
|
|
599
|
+
* if no match (without modifying storage). Single-use is enforced by
|
|
600
|
+
* optimistic-concurrency CAS on the version column — concurrent consumes
|
|
601
|
+
* of the same code race fairly and only one wins; the loser re-reads,
|
|
602
|
+
* finds the hash already removed, and returns `false`.
|
|
576
603
|
*
|
|
577
604
|
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
605
|
+
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
578
606
|
*/
|
|
579
607
|
async consumeBackupCode(username, code) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
608
|
+
let consumed = false;
|
|
609
|
+
await this.store.withCas(username, (user) => {
|
|
610
|
+
const hashes = user.backupCodes ?? [];
|
|
611
|
+
const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
|
|
612
|
+
if (idx < 0) {
|
|
613
|
+
consumed = false;
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
consumed = true;
|
|
617
|
+
return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
|
|
618
|
+
});
|
|
619
|
+
return consumed;
|
|
586
620
|
}
|
|
587
621
|
/**
|
|
588
622
|
* Verify a TOTP code against the user's confirmed `totp` MFA method.
|
|
@@ -596,12 +630,43 @@ var UserService = class {
|
|
|
596
630
|
await this.ensureNotLockedOrThrow(username, user.account);
|
|
597
631
|
const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
598
632
|
if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
633
|
+
const matchedCounter = verifyTotpCode(totp.value, code, config);
|
|
634
|
+
const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
|
|
635
|
+
if (matchedCounter !== null && !isReplay) {
|
|
636
|
+
let replayDuringCas = false;
|
|
637
|
+
await this.store.withCas(username, (current) => {
|
|
638
|
+
const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
639
|
+
if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
|
|
640
|
+
replayDuringCas = true;
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
const set = { mfa: { methods: current.mfa.methods.map((m) => m.name === "totp" && m.confirmed ? {
|
|
644
|
+
...m,
|
|
645
|
+
lastUsedWindow: matchedCounter
|
|
646
|
+
} : m) } };
|
|
647
|
+
if (current.account.failedLoginAttempts > 0) set.account = { failedLoginAttempts: 0 };
|
|
648
|
+
return { set };
|
|
649
|
+
});
|
|
650
|
+
if (!replayDuringCas) return;
|
|
602
651
|
}
|
|
603
652
|
await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
|
|
604
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
|
|
656
|
+
* enrollment. Differs from `verifyMfa`:
|
|
657
|
+
* - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
|
|
658
|
+
* - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
|
|
659
|
+
* pre-activation; lockout doesn't apply).
|
|
660
|
+
* - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
|
|
661
|
+
*
|
|
662
|
+
* Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
|
|
663
|
+
* totp method; MFA_INVALID on wrong code.
|
|
664
|
+
*/
|
|
665
|
+
async verifyTotpSetupCode(username, code, config) {
|
|
666
|
+
const totp = (await this.getUser(username)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
|
|
667
|
+
if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
668
|
+
if (verifyTotpCode(totp.value, code, config) === null) throw new require_user_store.UserAuthError("MFA_INVALID");
|
|
669
|
+
}
|
|
605
670
|
getPasswordHasher() {
|
|
606
671
|
return this.hasher;
|
|
607
672
|
}
|
|
@@ -631,8 +696,9 @@ var UserService = class {
|
|
|
631
696
|
* merge strategy replace the whole array.
|
|
632
697
|
*/
|
|
633
698
|
async addTrustedDevice(username, record) {
|
|
634
|
-
|
|
635
|
-
|
|
699
|
+
await this.store.withCas(username, (user) => {
|
|
700
|
+
return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
|
|
701
|
+
});
|
|
636
702
|
}
|
|
637
703
|
/**
|
|
638
704
|
* Returns true when the supplied token (a) signs against the user+ip with
|
|
@@ -759,48 +825,72 @@ var UserStoreMemory = class extends require_user_store.UserStore {
|
|
|
759
825
|
}
|
|
760
826
|
async create(data) {
|
|
761
827
|
if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
|
|
762
|
-
|
|
828
|
+
const cloned = structuredClone(data);
|
|
829
|
+
cloned.version = 0;
|
|
830
|
+
this.store.set(data.username, cloned);
|
|
763
831
|
}
|
|
764
832
|
async update(username, update) {
|
|
765
833
|
const user = this.store.get(username);
|
|
766
834
|
if (!user) return false;
|
|
835
|
+
if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
|
|
767
836
|
if (update.set) require_user_store.deepMerge(user, update.set);
|
|
768
837
|
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_user_store.incrementAtPath(user, path, amount);
|
|
838
|
+
user.version = (user.version ?? 0) + 1;
|
|
769
839
|
return true;
|
|
770
840
|
}
|
|
771
841
|
async delete(username) {
|
|
772
842
|
return this.store.delete(username);
|
|
773
843
|
}
|
|
844
|
+
async withCas(username, mutator, opts) {
|
|
845
|
+
const maxAttempts = opts?.maxAttempts ?? 2;
|
|
846
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
847
|
+
const current = await this.findByUsername(username);
|
|
848
|
+
if (!current) throw new require_user_store.UserAuthError("NOT_FOUND");
|
|
849
|
+
const patch = mutator(current);
|
|
850
|
+
if (patch === null) return;
|
|
851
|
+
if (await this.update(username, {
|
|
852
|
+
...patch,
|
|
853
|
+
expectedVersion: current.version ?? 0
|
|
854
|
+
})) return;
|
|
855
|
+
}
|
|
856
|
+
throw new require_user_store.UserAuthError("CAS_EXHAUSTED");
|
|
857
|
+
}
|
|
774
858
|
};
|
|
775
859
|
//#endregion
|
|
776
860
|
//#region src/password/policies.ts
|
|
777
|
-
const ppHasMinLength = (min = 8) => ({
|
|
778
|
-
rule:
|
|
861
|
+
const ppHasMinLength = (min = 8) => definePasswordPolicy({
|
|
862
|
+
rule: (v, min) => v.length >= min,
|
|
863
|
+
args: [min],
|
|
779
864
|
description: `Minimum length ${min}`,
|
|
780
865
|
errorMessage: `Password must be at least ${min} characters long`
|
|
781
866
|
});
|
|
782
|
-
const ppHasUpperCase = (n = 1) => ({
|
|
783
|
-
rule:
|
|
867
|
+
const ppHasUpperCase = (n = 1) => definePasswordPolicy({
|
|
868
|
+
rule: (v, n) => (v.match(/[A-Z]/g) || []).length >= n,
|
|
869
|
+
args: [n],
|
|
784
870
|
description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
|
|
785
871
|
errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
|
|
786
872
|
});
|
|
787
|
-
const ppHasLowerCase = (n = 1) => ({
|
|
788
|
-
rule:
|
|
873
|
+
const ppHasLowerCase = (n = 1) => definePasswordPolicy({
|
|
874
|
+
rule: (v, n) => (v.match(/[a-z]/g) || []).length >= n,
|
|
875
|
+
args: [n],
|
|
789
876
|
description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
|
|
790
877
|
errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
|
|
791
878
|
});
|
|
792
|
-
const ppHasNumber = (n = 1) => ({
|
|
793
|
-
rule:
|
|
879
|
+
const ppHasNumber = (n = 1) => definePasswordPolicy({
|
|
880
|
+
rule: (v, n) => (v.match(/\d/g) || []).length >= n,
|
|
881
|
+
args: [n],
|
|
794
882
|
description: `At least ${n} number${n === 1 ? "" : "s"}`,
|
|
795
883
|
errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
|
|
796
884
|
});
|
|
797
|
-
const ppHasSpecialChar = (n = 1) => ({
|
|
798
|
-
rule:
|
|
885
|
+
const ppHasSpecialChar = (n = 1) => definePasswordPolicy({
|
|
886
|
+
rule: (v, n) => (v.match(/[^A-Za-z0-9]/g) || []).length >= n,
|
|
887
|
+
args: [n],
|
|
799
888
|
description: `At least ${n} special character${n === 1 ? "" : "s"}`,
|
|
800
889
|
errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
|
|
801
890
|
});
|
|
802
|
-
const ppMaxRepeatedChars = (maxRepeated = 2) => ({
|
|
803
|
-
rule:
|
|
891
|
+
const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
|
|
892
|
+
rule: (v, maxRepeated) => !new RegExp(`(.)\\1{${maxRepeated},}`).test(v),
|
|
893
|
+
args: [maxRepeated],
|
|
804
894
|
description: `No more than ${maxRepeated} consecutive repeated characters`,
|
|
805
895
|
errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
|
|
806
896
|
});
|
|
@@ -811,6 +901,7 @@ exports.UserAuthError = require_user_store.UserAuthError;
|
|
|
811
901
|
exports.UserService = UserService;
|
|
812
902
|
exports.UserStore = require_user_store.UserStore;
|
|
813
903
|
exports.UserStoreMemory = UserStoreMemory;
|
|
904
|
+
exports.definePasswordPolicy = definePasswordPolicy;
|
|
814
905
|
exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
|
|
815
906
|
exports.generateMfaCode = generateMfaCode;
|
|
816
907
|
exports.generateTotpCode = generateTotpCode;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
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-B3EStUfT.cjs";
|
|
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:
|
|
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
|
}
|
|
@@ -75,7 +102,7 @@ declare class UserService<T extends object = object> {
|
|
|
75
102
|
/**
|
|
76
103
|
* Hard-delete the user row. Returns nothing on success. Throws
|
|
77
104
|
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
78
|
-
* invite workflow's `auth
|
|
105
|
+
* invite workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
79
106
|
*/
|
|
80
107
|
deleteUser(username: string): Promise<void>;
|
|
81
108
|
/**
|
|
@@ -110,16 +137,13 @@ declare class UserService<T extends object = object> {
|
|
|
110
137
|
/**
|
|
111
138
|
* Consume a backup code: returns `true` and removes the matching hash
|
|
112
139
|
* from storage if `code` matches a stored backup code; returns `false`
|
|
113
|
-
* if no match (without modifying storage).
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* underlying store API does not expose an atomic match-and-remove, so
|
|
118
|
-
* this is acceptable at the intended scale (backup codes are a fallback
|
|
119
|
-
* path, not a hot one). Wrap in your store's transaction primitive if a
|
|
120
|
-
* 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`.
|
|
121
144
|
*
|
|
122
145
|
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
146
|
+
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
123
147
|
*/
|
|
124
148
|
consumeBackupCode(username: string, code: string): Promise<boolean>;
|
|
125
149
|
/**
|
|
@@ -129,6 +153,18 @@ declare class UserService<T extends object = object> {
|
|
|
129
153
|
* total tries across BOTH factors, not `2 * threshold`.
|
|
130
154
|
*/
|
|
131
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>;
|
|
132
168
|
getPasswordHasher(): PasswordHasher;
|
|
133
169
|
getConfig(): Readonly<ResolvedConfig>;
|
|
134
170
|
/**
|
|
@@ -184,6 +220,7 @@ declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
|
|
|
184
220
|
create(data: UserCredentials & T): Promise<void>;
|
|
185
221
|
update(username: string, update: UserStoreUpdate): Promise<boolean>;
|
|
186
222
|
delete(username: string): Promise<boolean>;
|
|
223
|
+
withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
187
224
|
}
|
|
188
225
|
//#endregion
|
|
189
226
|
//#region src/password/policies.d.ts
|
|
@@ -198,7 +235,12 @@ declare const ppMaxRepeatedChars: (maxRepeated?: number) => PasswordPolicyDef;
|
|
|
198
235
|
declare function generateTotpSecret(bytes?: number): string;
|
|
199
236
|
declare function generateTotpUri(secret: string, issuer: string, account: string, config?: Pick<TotpConfig, "period" | "digits">): string;
|
|
200
237
|
declare function generateTotpCode(secret: string, config?: TotpConfig): string;
|
|
201
|
-
|
|
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;
|
|
202
244
|
declare function generateMfaCode(length?: number): string;
|
|
203
245
|
//#endregion
|
|
204
246
|
//#region src/mfa/codes.d.ts
|
|
@@ -238,4 +280,4 @@ declare function maskPhone(phone: string): string;
|
|
|
238
280
|
declare function maskMfaValue(method: MfaMethod): string;
|
|
239
281
|
declare function setAtPath(obj: object, path: string, value: unknown): void;
|
|
240
282
|
//#endregion
|
|
241
|
-
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 };
|