@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/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 +167 -68
- package/dist/index.d.cts +64 -14
- package/dist/index.d.mts +64 -14
- package/dist/index.mjs +167 -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
|
|
@@ -339,6 +368,14 @@ var UserService = class {
|
|
|
339
368
|
this.hasher = new PasswordHasher(this.config.password);
|
|
340
369
|
}
|
|
341
370
|
/**
|
|
371
|
+
* Creates a user with `account.active: false`. The invite workflow relies
|
|
372
|
+
* on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
|
|
373
|
+
* inactive until they accept). For setup scripts / seeders / tests that
|
|
374
|
+
* don't go through invite, follow up with `activateAccount(username)` or
|
|
375
|
+
* `login()` will throw `UserAuthError("INACTIVE")` — which the login
|
|
376
|
+
* workflow deliberately re-maps to `"Invalid credentials"` to avoid account
|
|
377
|
+
* enumeration, so the failure is silent client-side.
|
|
378
|
+
*
|
|
342
379
|
* @param extras Optional partial user fields merged AFTER the base
|
|
343
380
|
* `UserCredentials` shape, so callers can populate consumer-specific
|
|
344
381
|
* required fields (e.g. `tenantId`) without subclassing the store.
|
|
@@ -418,7 +455,7 @@ var UserService = class {
|
|
|
418
455
|
/**
|
|
419
456
|
* Hard-delete the user row. Returns nothing on success. Throws
|
|
420
457
|
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
421
|
-
* invite workflow's `auth
|
|
458
|
+
* invite workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
422
459
|
*/
|
|
423
460
|
async deleteUser(username) {
|
|
424
461
|
if (!await this.store.delete(username)) throw new require_user_store.UserAuthError("NOT_FOUND");
|
|
@@ -491,30 +528,32 @@ var UserService = class {
|
|
|
491
528
|
}
|
|
492
529
|
getTransferablePolicies() {
|
|
493
530
|
return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
|
|
494
|
-
rule: p.
|
|
531
|
+
rule: p.serialized,
|
|
495
532
|
description: p.description,
|
|
496
533
|
errorMessage: p.errorMessage
|
|
497
534
|
}));
|
|
498
535
|
}
|
|
499
536
|
async addMfaMethod(username, method) {
|
|
500
|
-
|
|
501
|
-
|
|
537
|
+
await this.store.withCas(username, (user) => {
|
|
538
|
+
return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
|
|
539
|
+
});
|
|
502
540
|
}
|
|
503
541
|
async confirmMfaMethod(username, name) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 } } };
|
|
515
556
|
});
|
|
516
|
-
if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
517
|
-
await this.store.update(username, { set: { mfa: { methods } } });
|
|
518
557
|
}
|
|
519
558
|
async removeMfaMethod(username, name) {
|
|
520
559
|
const user = await this.getUser(username);
|
|
@@ -557,24 +596,27 @@ var UserService = class {
|
|
|
557
596
|
/**
|
|
558
597
|
* Consume a backup code: returns `true` and removes the matching hash
|
|
559
598
|
* from storage if `code` matches a stored backup code; returns `false`
|
|
560
|
-
* if no match (without modifying storage).
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
* underlying store API does not expose an atomic match-and-remove, so
|
|
565
|
-
* this is acceptable at the intended scale (backup codes are a fallback
|
|
566
|
-
* path, not a hot one). Wrap in your store's transaction primitive if a
|
|
567
|
-
* 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`.
|
|
568
603
|
*
|
|
569
604
|
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
605
|
+
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
570
606
|
*/
|
|
571
607
|
async consumeBackupCode(username, code) {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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;
|
|
578
620
|
}
|
|
579
621
|
/**
|
|
580
622
|
* Verify a TOTP code against the user's confirmed `totp` MFA method.
|
|
@@ -588,12 +630,43 @@ var UserService = class {
|
|
|
588
630
|
await this.ensureNotLockedOrThrow(username, user.account);
|
|
589
631
|
const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
590
632
|
if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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;
|
|
594
651
|
}
|
|
595
652
|
await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
|
|
596
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
|
+
}
|
|
597
670
|
getPasswordHasher() {
|
|
598
671
|
return this.hasher;
|
|
599
672
|
}
|
|
@@ -623,8 +696,9 @@ var UserService = class {
|
|
|
623
696
|
* merge strategy replace the whole array.
|
|
624
697
|
*/
|
|
625
698
|
async addTrustedDevice(username, record) {
|
|
626
|
-
|
|
627
|
-
|
|
699
|
+
await this.store.withCas(username, (user) => {
|
|
700
|
+
return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
|
|
701
|
+
});
|
|
628
702
|
}
|
|
629
703
|
/**
|
|
630
704
|
* Returns true when the supplied token (a) signs against the user+ip with
|
|
@@ -751,48 +825,72 @@ var UserStoreMemory = class extends require_user_store.UserStore {
|
|
|
751
825
|
}
|
|
752
826
|
async create(data) {
|
|
753
827
|
if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
|
|
754
|
-
|
|
828
|
+
const cloned = structuredClone(data);
|
|
829
|
+
cloned.version = 0;
|
|
830
|
+
this.store.set(data.username, cloned);
|
|
755
831
|
}
|
|
756
832
|
async update(username, update) {
|
|
757
833
|
const user = this.store.get(username);
|
|
758
834
|
if (!user) return false;
|
|
835
|
+
if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
|
|
759
836
|
if (update.set) require_user_store.deepMerge(user, update.set);
|
|
760
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;
|
|
761
839
|
return true;
|
|
762
840
|
}
|
|
763
841
|
async delete(username) {
|
|
764
842
|
return this.store.delete(username);
|
|
765
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
|
+
}
|
|
766
858
|
};
|
|
767
859
|
//#endregion
|
|
768
860
|
//#region src/password/policies.ts
|
|
769
|
-
const ppHasMinLength = (min = 8) => ({
|
|
770
|
-
rule:
|
|
861
|
+
const ppHasMinLength = (min = 8) => definePasswordPolicy({
|
|
862
|
+
rule: (v, min) => v.length >= min,
|
|
863
|
+
args: [min],
|
|
771
864
|
description: `Minimum length ${min}`,
|
|
772
865
|
errorMessage: `Password must be at least ${min} characters long`
|
|
773
866
|
});
|
|
774
|
-
const ppHasUpperCase = (n = 1) => ({
|
|
775
|
-
rule:
|
|
867
|
+
const ppHasUpperCase = (n = 1) => definePasswordPolicy({
|
|
868
|
+
rule: (v, n) => (v.match(/[A-Z]/g) || []).length >= n,
|
|
869
|
+
args: [n],
|
|
776
870
|
description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
|
|
777
871
|
errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
|
|
778
872
|
});
|
|
779
|
-
const ppHasLowerCase = (n = 1) => ({
|
|
780
|
-
rule:
|
|
873
|
+
const ppHasLowerCase = (n = 1) => definePasswordPolicy({
|
|
874
|
+
rule: (v, n) => (v.match(/[a-z]/g) || []).length >= n,
|
|
875
|
+
args: [n],
|
|
781
876
|
description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
|
|
782
877
|
errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
|
|
783
878
|
});
|
|
784
|
-
const ppHasNumber = (n = 1) => ({
|
|
785
|
-
rule:
|
|
879
|
+
const ppHasNumber = (n = 1) => definePasswordPolicy({
|
|
880
|
+
rule: (v, n) => (v.match(/\d/g) || []).length >= n,
|
|
881
|
+
args: [n],
|
|
786
882
|
description: `At least ${n} number${n === 1 ? "" : "s"}`,
|
|
787
883
|
errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
|
|
788
884
|
});
|
|
789
|
-
const ppHasSpecialChar = (n = 1) => ({
|
|
790
|
-
rule:
|
|
885
|
+
const ppHasSpecialChar = (n = 1) => definePasswordPolicy({
|
|
886
|
+
rule: (v, n) => (v.match(/[^A-Za-z0-9]/g) || []).length >= n,
|
|
887
|
+
args: [n],
|
|
791
888
|
description: `At least ${n} special character${n === 1 ? "" : "s"}`,
|
|
792
889
|
errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
|
|
793
890
|
});
|
|
794
|
-
const ppMaxRepeatedChars = (maxRepeated = 2) => ({
|
|
795
|
-
rule:
|
|
891
|
+
const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
|
|
892
|
+
rule: (v, maxRepeated) => !new RegExp(`(.)\\1{${maxRepeated},}`).test(v),
|
|
893
|
+
args: [maxRepeated],
|
|
796
894
|
description: `No more than ${maxRepeated} consecutive repeated characters`,
|
|
797
895
|
errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
|
|
798
896
|
});
|
|
@@ -803,6 +901,7 @@ exports.UserAuthError = require_user_store.UserAuthError;
|
|
|
803
901
|
exports.UserService = UserService;
|
|
804
902
|
exports.UserStore = require_user_store.UserStore;
|
|
805
903
|
exports.UserStoreMemory = UserStoreMemory;
|
|
904
|
+
exports.definePasswordPolicy = definePasswordPolicy;
|
|
806
905
|
exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
|
|
807
906
|
exports.generateMfaCode = generateMfaCode;
|
|
808
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
|
}
|
|
@@ -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
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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
|
-
|
|
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 };
|