@aooth/user 0.1.6 → 0.1.8
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 +108 -22
- package/dist/atscript-db.d.cts +69 -12
- package/dist/atscript-db.d.mts +69 -12
- package/dist/atscript-db.mjs +103 -18
- package/dist/{user-store-BaBmH13V.mjs → federated-identity-store-Cmc7jBrw.mjs} +40 -1
- package/dist/federated-identity-store-DEEed8lA.d.cts +378 -0
- package/dist/federated-identity-store-DEEed8lA.d.mts +378 -0
- package/dist/{user-store-BPZVAboN.cjs → federated-identity-store-oRjhnR5l.cjs} +51 -0
- package/dist/index.cjs +301 -220
- package/dist/index.d.cts +114 -72
- package/dist/index.d.mts +114 -72
- package/dist/index.mjs +268 -189
- package/package.json +23 -9
- package/src/atscript-db/federated-identity.as +44 -0
- package/src/atscript-db/federated-identity.as.d.ts +62 -0
- package/src/atscript-db/user-credentials.as +7 -2
- package/src/atscript-db/user-credentials.as.d.ts +62 -0
- package/dist/user-store-B3EStUfT.d.cts +0 -246
- package/dist/user-store-C1lxahSB.d.mts +0 -246
package/dist/index.cjs
CHANGED
|
@@ -1,61 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const
|
|
2
|
+
const require_federated_identity_store = require("./federated-identity-store-oRjhnR5l.cjs");
|
|
3
3
|
let node_crypto = require("node:crypto");
|
|
4
|
-
//#region src/mfa/backup-codes.ts
|
|
5
|
-
/**
|
|
6
|
-
* Custom alphabet for backup codes — uppercase letters and digits with the
|
|
7
|
-
* easily-confused characters (I, O, L, 0, 1) removed (31 chars).
|
|
8
|
-
*/
|
|
9
|
-
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
10
|
-
const RAW_LENGTH = 10;
|
|
11
|
-
const GROUP_SIZE = 4;
|
|
12
|
-
/**
|
|
13
|
-
* Generate `count` cryptographically-random backup codes (default 10).
|
|
14
|
-
*
|
|
15
|
-
* Format: 10 characters from the 31-char safe alphabet (uppercase letters +
|
|
16
|
-
* digits, omitting I/O/L/0/1), grouped as `XXXX-XXXX-XX`.
|
|
17
|
-
*
|
|
18
|
-
* Returns plaintext codes for the caller to deliver to the user — these
|
|
19
|
-
* should be hashed via {@link hashMfaCode} before persistence and never
|
|
20
|
-
* shown to the user again.
|
|
21
|
-
*/
|
|
22
|
-
function generateBackupCodePlaintext(count = 10) {
|
|
23
|
-
const codes = [];
|
|
24
|
-
for (let i = 0; i < count; i++) codes.push(formatCode(require_user_store.generateSecureRandom(RAW_LENGTH, BACKUP_CODE_ALPHABET)));
|
|
25
|
-
return codes;
|
|
26
|
-
}
|
|
27
|
-
function formatCode(raw) {
|
|
28
|
-
const parts = [];
|
|
29
|
-
for (let i = 0; i < raw.length; i += GROUP_SIZE) parts.push(raw.slice(i, i + GROUP_SIZE));
|
|
30
|
-
return parts.join("-");
|
|
31
|
-
}
|
|
32
|
-
//#endregion
|
|
33
|
-
//#region src/mfa/codes.ts
|
|
34
|
-
/**
|
|
35
|
-
* SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
|
|
36
|
-
*
|
|
37
|
-
* Hex-encoded for stable, comparable output regardless of input case/format.
|
|
38
|
-
* Use {@link verifyMfaCode} to compare a submitted plaintext code against the
|
|
39
|
-
* stored hash in constant time.
|
|
40
|
-
*/
|
|
41
|
-
function hashMfaCode(code) {
|
|
42
|
-
return (0, node_crypto.createHash)("sha256").update(code).digest("hex");
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Constant-time comparison of a submitted plaintext code against an
|
|
46
|
-
* expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
|
|
47
|
-
*
|
|
48
|
-
* Returns false for malformed/empty expected hashes (timingSafeEqual
|
|
49
|
-
* requires equal-length, non-empty buffers).
|
|
50
|
-
*/
|
|
51
|
-
function verifyMfaCode(submitted, expectedHash) {
|
|
52
|
-
if (!expectedHash) return false;
|
|
53
|
-
const a = Buffer.from(hashMfaCode(submitted), "hex");
|
|
54
|
-
const b = Buffer.from(expectedHash, "hex");
|
|
55
|
-
if (a.length !== b.length) return false;
|
|
56
|
-
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
57
|
-
}
|
|
58
|
-
//#endregion
|
|
59
4
|
//#region src/base-x/base32.ts
|
|
60
5
|
/**
|
|
61
6
|
* Partially copied from "thirty-two" library, all credits to Chris Umbel.
|
|
@@ -168,7 +113,7 @@ function verifyTotpCode(secret, code, config) {
|
|
|
168
113
|
return matchedCounter;
|
|
169
114
|
}
|
|
170
115
|
function generateMfaCode(length = 6) {
|
|
171
|
-
return
|
|
116
|
+
return require_federated_identity_store.generateSecureRandom(length, "0123456789");
|
|
172
117
|
}
|
|
173
118
|
function hotpCode(key, counter, digits) {
|
|
174
119
|
const counterBuf = Buffer.alloc(8);
|
|
@@ -256,14 +201,14 @@ var PasswordHasher = class {
|
|
|
256
201
|
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
257
202
|
const digits = "0123456789";
|
|
258
203
|
const special = "!@#$%^&*()-_=+";
|
|
259
|
-
const all =
|
|
204
|
+
const all = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
|
260
205
|
const bytes = (0, node_crypto.randomBytes)(minLen);
|
|
261
206
|
const result = Array.from({ length: minLen });
|
|
262
207
|
result[0] = lower[bytes[0] % 26];
|
|
263
208
|
result[1] = upper[bytes[1] % 26];
|
|
264
209
|
result[2] = digits[bytes[2] % 10];
|
|
265
210
|
result[3] = special[bytes[3] % 14];
|
|
266
|
-
for (let i = 4; i < minLen; i++) result[i] = all[bytes[i] %
|
|
211
|
+
for (let i = 4; i < minLen; i++) result[i] = all[bytes[i] % 76];
|
|
267
212
|
const shuffleBytes = (0, node_crypto.randomBytes)(minLen);
|
|
268
213
|
for (let i = minLen - 1; i > 0; i--) {
|
|
269
214
|
const j = shuffleBytes[i] % (i + 1);
|
|
@@ -338,7 +283,8 @@ function resolveConfig(config) {
|
|
|
338
283
|
scryptR: config?.password?.scryptR ?? 8,
|
|
339
284
|
scryptP: config?.password?.scryptP ?? 1,
|
|
340
285
|
keyLength: config?.password?.keyLength ?? 64,
|
|
341
|
-
policies: normalizePolicies(config?.password?.policies)
|
|
286
|
+
policies: normalizePolicies(config?.password?.policies),
|
|
287
|
+
maxAgeMs: config?.password?.maxAgeMs ?? 0
|
|
342
288
|
},
|
|
343
289
|
lockout: {
|
|
344
290
|
threshold: config?.lockout?.threshold ?? 0,
|
|
@@ -359,7 +305,17 @@ function deviceTrustSafeEqual(a, b) {
|
|
|
359
305
|
if (ab.length !== bb.length) return false;
|
|
360
306
|
return (0, node_crypto.timingSafeEqual)(ab, bb);
|
|
361
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Orchestrates user credentials over a pluggable {@link UserStore}.
|
|
310
|
+
*
|
|
311
|
+
* Identity model: the stable surrogate **`id`** is the token subject. `getUser`
|
|
312
|
+
* and every mutation/admin method are keyed by `id` (the value carried in the
|
|
313
|
+
* session and returned by `useAuth().getUserId()`); only `login` (and other
|
|
314
|
+
* handle-driven entry points) take a `username`/`email` login handle, resolved
|
|
315
|
+
* via `UserStore.findByHandle`.
|
|
316
|
+
*/
|
|
362
317
|
var UserService = class {
|
|
318
|
+
store;
|
|
363
319
|
config;
|
|
364
320
|
hasher;
|
|
365
321
|
constructor(store, config) {
|
|
@@ -371,25 +327,31 @@ var UserService = class {
|
|
|
371
327
|
* Creates a user with `account.active: false`. The invite workflow relies
|
|
372
328
|
* on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
|
|
373
329
|
* inactive until they accept). For setup scripts / seeders / tests that
|
|
374
|
-
* don't go through invite, follow up with `activateAccount(
|
|
375
|
-
*
|
|
376
|
-
*
|
|
330
|
+
* don't go through invite, follow up with `activateAccount(id)` or `login()`
|
|
331
|
+
* will throw `UserAuthError("INACTIVE")` — which the login workflow
|
|
332
|
+
* deliberately re-maps to `"Invalid credentials"` to avoid account
|
|
377
333
|
* enumeration, so the failure is silent client-side.
|
|
378
334
|
*
|
|
335
|
+
* A stable `id` is minted here (server-managed surrogate, also the token
|
|
336
|
+
* subject) and returned on the record, so callers can `auth.issue(user.id)`
|
|
337
|
+
* without a re-read. Pass `id` via `extras` to override it.
|
|
338
|
+
*
|
|
379
339
|
* @param extras Optional partial user fields merged AFTER the base
|
|
380
340
|
* `UserCredentials` shape, so callers can populate consumer-specific
|
|
381
341
|
* required fields (e.g. `tenantId`) without subclassing the store.
|
|
382
342
|
* Because the merge is shallow and extras win, overlapping top-level
|
|
383
|
-
* keys (`id`, `account`, `mfa`, ...) replace the defaults
|
|
384
|
-
* pass nested objects with all required sub-fields if you
|
|
385
|
-
* override them.
|
|
343
|
+
* keys (`id`, `email`, `account`, `mfa`, ...) replace the defaults
|
|
344
|
+
* entirely — pass nested objects with all required sub-fields if you
|
|
345
|
+
* intend to override them.
|
|
386
346
|
*/
|
|
387
347
|
async createUser(username, password, extras) {
|
|
388
348
|
const pw = password ?? this.hasher.generatePassword();
|
|
349
|
+
const hash = await this.hasher.hash(pw);
|
|
389
350
|
const userData = {
|
|
351
|
+
id: (0, node_crypto.randomUUID)(),
|
|
390
352
|
username,
|
|
391
353
|
password: {
|
|
392
|
-
hash
|
|
354
|
+
hash,
|
|
393
355
|
history: [],
|
|
394
356
|
lastChanged: this.config.clock(),
|
|
395
357
|
isInitial: !password
|
|
@@ -412,19 +374,36 @@ var UserService = class {
|
|
|
412
374
|
await this.store.create(userData);
|
|
413
375
|
return userData;
|
|
414
376
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
377
|
+
/** Read by the stable `id` (the token subject). */
|
|
378
|
+
async getUser(id) {
|
|
379
|
+
const user = await this.store.findById(id);
|
|
380
|
+
if (!user) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
418
381
|
return user;
|
|
419
382
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Deterministic handle resolver — `username` exact, then `email` exact.
|
|
385
|
+
* Returns `null` when nothing matches. Maps a login/recovery handle to a row;
|
|
386
|
+
* `login` uses the same resolution.
|
|
387
|
+
*/
|
|
388
|
+
async findByHandle(handle) {
|
|
389
|
+
return this.store.findByHandle(handle);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Permissive lookup — `id`, then `username`, then `email` (ordered, first
|
|
393
|
+
* match). For internal / admin / recovery callers that may hold either an id
|
|
394
|
+
* or a handle. NOT for the login path (use {@link login}/{@link findByHandle}).
|
|
395
|
+
*/
|
|
396
|
+
async findByIdentifier(value) {
|
|
397
|
+
return this.store.findByIdentifier(value);
|
|
398
|
+
}
|
|
399
|
+
async login(handle, password, lockoutOverride) {
|
|
400
|
+
const user = await this.store.findByHandle(handle);
|
|
401
|
+
if (!user) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
402
|
+
if (!user.account.active) throw new require_federated_identity_store.UserAuthError("INACTIVE");
|
|
403
|
+
await this.ensureNotLockedOrThrow(user.id, user.account);
|
|
425
404
|
if (await this.hasher.verify(password, user.password.hash)) {
|
|
426
405
|
const now = this.config.clock();
|
|
427
|
-
await this.store.update(
|
|
406
|
+
await this.store.update(user.id, { set: { account: {
|
|
428
407
|
lastLogin: now,
|
|
429
408
|
failedLoginAttempts: 0
|
|
430
409
|
} } });
|
|
@@ -435,30 +414,30 @@ var UserService = class {
|
|
|
435
414
|
mfaRequired: this.hasConfirmedMfaMethods(user.mfa)
|
|
436
415
|
};
|
|
437
416
|
}
|
|
438
|
-
return this.incrementAndMaybeLock(
|
|
417
|
+
return this.incrementAndMaybeLock(user.id, user.account, "INVALID_CREDENTIALS", lockoutOverride);
|
|
439
418
|
}
|
|
440
|
-
async verifyPassword(
|
|
441
|
-
const user = await this.store.
|
|
442
|
-
if (!user) throw new
|
|
419
|
+
async verifyPassword(id, password) {
|
|
420
|
+
const user = await this.store.findById(id);
|
|
421
|
+
if (!user) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
443
422
|
return this.hasher.verify(password, user.password.hash);
|
|
444
423
|
}
|
|
445
|
-
async changePassword(
|
|
446
|
-
if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new
|
|
447
|
-
const user = await this.getUser(
|
|
448
|
-
if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new
|
|
449
|
-
await this.applyPasswordChange(
|
|
424
|
+
async changePassword(id, currentPassword, newPassword, repeatPassword) {
|
|
425
|
+
if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new require_federated_identity_store.UserAuthError("PASSWORDS_MISMATCH");
|
|
426
|
+
const user = await this.getUser(id);
|
|
427
|
+
if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new require_federated_identity_store.UserAuthError("INVALID_CREDENTIALS");
|
|
428
|
+
await this.applyPasswordChange(id, user, newPassword);
|
|
450
429
|
}
|
|
451
|
-
async setPassword(
|
|
452
|
-
const user = await this.getUser(
|
|
453
|
-
await this.applyPasswordChange(
|
|
430
|
+
async setPassword(id, newPassword) {
|
|
431
|
+
const user = await this.getUser(id);
|
|
432
|
+
await this.applyPasswordChange(id, user, newPassword);
|
|
454
433
|
}
|
|
455
434
|
/**
|
|
456
|
-
* Hard-delete the user row
|
|
457
|
-
* `UserAuthError("NOT_FOUND")` when no row matches
|
|
458
|
-
*
|
|
435
|
+
* Hard-delete the user row by `id`. Returns nothing on success. Throws
|
|
436
|
+
* `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
|
|
437
|
+
* workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
459
438
|
*/
|
|
460
|
-
async deleteUser(
|
|
461
|
-
if (!await this.store.delete(
|
|
439
|
+
async deleteUser(id) {
|
|
440
|
+
if (!await this.store.delete(id)) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
462
441
|
}
|
|
463
442
|
/**
|
|
464
443
|
* Deep-merge `patch` into the user record (top-level fields are shallow-
|
|
@@ -466,31 +445,31 @@ var UserService = class {
|
|
|
466
445
|
* `@db.patch.strategy 'merge'` declaration). Returns the patched record.
|
|
467
446
|
* Used by the invite workflow's `applyProfile` default fallback.
|
|
468
447
|
*/
|
|
469
|
-
async update(
|
|
470
|
-
if (!await this.store.update(
|
|
471
|
-
return this.getUser(
|
|
448
|
+
async update(id, patch) {
|
|
449
|
+
if (!await this.store.update(id, { set: patch })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
450
|
+
return this.getUser(id);
|
|
472
451
|
}
|
|
473
|
-
async activateAccount(
|
|
474
|
-
if (!await this.store.update(
|
|
452
|
+
async activateAccount(id) {
|
|
453
|
+
if (!await this.store.update(id, { set: { account: { active: true } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
475
454
|
}
|
|
476
|
-
async deactivateAccount(
|
|
477
|
-
if (!await this.store.update(
|
|
455
|
+
async deactivateAccount(id) {
|
|
456
|
+
if (!await this.store.update(id, { set: { account: { active: false } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
478
457
|
}
|
|
479
|
-
async lockAccount(
|
|
458
|
+
async lockAccount(id, reason, duration) {
|
|
480
459
|
const lockEnds = duration ? this.config.clock() + duration : 0;
|
|
481
|
-
if (!await this.store.update(
|
|
460
|
+
if (!await this.store.update(id, { set: { account: {
|
|
482
461
|
locked: true,
|
|
483
462
|
lockReason: reason,
|
|
484
463
|
lockEnds
|
|
485
|
-
} } })) throw new
|
|
464
|
+
} } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
486
465
|
}
|
|
487
|
-
async unlockAccount(
|
|
488
|
-
if (!await this.store.update(
|
|
466
|
+
async unlockAccount(id) {
|
|
467
|
+
if (!await this.store.update(id, { set: { account: {
|
|
489
468
|
locked: false,
|
|
490
469
|
lockReason: "",
|
|
491
470
|
lockEnds: 0,
|
|
492
471
|
failedLoginAttempts: 0
|
|
493
|
-
} } })) throw new
|
|
472
|
+
} } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
494
473
|
}
|
|
495
474
|
getLockStatus(account) {
|
|
496
475
|
if (!account.locked) return {
|
|
@@ -506,6 +485,24 @@ var UserService = class {
|
|
|
506
485
|
lockEnds: account.lockEnds
|
|
507
486
|
};
|
|
508
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Returns `true` when the user's password is older than
|
|
490
|
+
* `config.password.maxAgeMs`. Returns `false` when:
|
|
491
|
+
* - `maxAgeMs` is unset or `0` (expiry disabled)
|
|
492
|
+
* - `password.lastChanged` is `0` / falsy (no recorded change — never
|
|
493
|
+
* expire a user whose timestamp wasn't captured, since that would
|
|
494
|
+
* force-loop on every login)
|
|
495
|
+
*
|
|
496
|
+
* Consulted by `@aooth/auth-moost` `LoginWorkflow`'s `credentials`
|
|
497
|
+
* step to set `ctx.isPasswordExpired` when `guards.passwordExpiry`
|
|
498
|
+
* is true (the default).
|
|
499
|
+
*/
|
|
500
|
+
isPasswordExpired(user, now = this.config.clock()) {
|
|
501
|
+
const maxAgeMs = this.config.password.maxAgeMs;
|
|
502
|
+
const lastChanged = user.password.lastChanged;
|
|
503
|
+
if (!maxAgeMs || !lastChanged) return false;
|
|
504
|
+
return now - lastChanged > maxAgeMs;
|
|
505
|
+
}
|
|
509
506
|
async checkPolicies(password, passwordData) {
|
|
510
507
|
const result = {
|
|
511
508
|
passed: true,
|
|
@@ -533,13 +530,13 @@ var UserService = class {
|
|
|
533
530
|
errorMessage: p.errorMessage
|
|
534
531
|
}));
|
|
535
532
|
}
|
|
536
|
-
async addMfaMethod(
|
|
537
|
-
await this.store.withCas(
|
|
533
|
+
async addMfaMethod(id, method) {
|
|
534
|
+
await this.store.withCas(id, (user) => {
|
|
538
535
|
return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
|
|
539
536
|
});
|
|
540
537
|
}
|
|
541
|
-
async confirmMfaMethod(
|
|
542
|
-
await this.store.withCas(
|
|
538
|
+
async confirmMfaMethod(id, name) {
|
|
539
|
+
await this.store.withCas(id, (user) => {
|
|
543
540
|
let found = false;
|
|
544
541
|
const methods = user.mfa.methods.map((m) => {
|
|
545
542
|
if (m.name === name) {
|
|
@@ -551,90 +548,51 @@ var UserService = class {
|
|
|
551
548
|
}
|
|
552
549
|
return m;
|
|
553
550
|
});
|
|
554
|
-
if (!found) throw new
|
|
551
|
+
if (!found) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
555
552
|
return { set: { mfa: { methods } } };
|
|
556
553
|
});
|
|
557
554
|
}
|
|
558
|
-
async removeMfaMethod(
|
|
559
|
-
const user = await this.getUser(
|
|
555
|
+
async removeMfaMethod(id, name) {
|
|
556
|
+
const user = await this.getUser(id);
|
|
560
557
|
const update = { mfa: { methods: user.mfa.methods.filter((m) => m.name !== name) } };
|
|
561
558
|
if (user.mfa.defaultMethod === name) update.mfa.defaultMethod = "";
|
|
562
|
-
await this.store.update(
|
|
559
|
+
await this.store.update(id, { set: update });
|
|
563
560
|
}
|
|
564
|
-
async setDefaultMfaMethod(
|
|
565
|
-
const user = await this.getUser(
|
|
566
|
-
if (name && !user.mfa.methods.some((m) => m.name === name)) throw new
|
|
567
|
-
await this.store.update(
|
|
561
|
+
async setDefaultMfaMethod(id, name) {
|
|
562
|
+
const user = await this.getUser(id);
|
|
563
|
+
if (name && !user.mfa.methods.some((m) => m.name === name)) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
564
|
+
await this.store.update(id, { set: { mfa: {
|
|
568
565
|
defaultMethod: name,
|
|
569
566
|
autoSend: false
|
|
570
567
|
} } });
|
|
571
568
|
}
|
|
572
|
-
async setMfaAutoSend(
|
|
573
|
-
if (!await this.store.update(
|
|
569
|
+
async setMfaAutoSend(id, value) {
|
|
570
|
+
if (!await this.store.update(id, { set: { mfa: { autoSend: value } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
574
571
|
}
|
|
575
572
|
getAvailableMfaMethods(mfa) {
|
|
576
573
|
return mfa.methods.filter((m) => m.confirmed).map((m) => ({
|
|
577
574
|
name: m.name,
|
|
578
575
|
isDefault: mfa.defaultMethod === m.name,
|
|
579
|
-
masked:
|
|
576
|
+
masked: require_federated_identity_store.maskMfaValue(m)
|
|
580
577
|
}));
|
|
581
578
|
}
|
|
582
579
|
/**
|
|
583
|
-
* Generate `count` plaintext backup codes (default 10), persist their
|
|
584
|
-
* hashes (replacing any existing batch), and return the plaintext codes
|
|
585
|
-
* once for the caller to deliver to the user. Plaintext is never
|
|
586
|
-
* recoverable after this call returns.
|
|
587
|
-
*
|
|
588
|
-
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
589
|
-
*/
|
|
590
|
-
async generateBackupCodes(username, count = 10) {
|
|
591
|
-
const codes = generateBackupCodePlaintext(count);
|
|
592
|
-
const hashes = codes.map(hashMfaCode);
|
|
593
|
-
if (!await this.store.update(username, { set: { backupCodes: hashes } })) throw new require_user_store.UserAuthError("NOT_FOUND");
|
|
594
|
-
return codes;
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* Consume a backup code: returns `true` and removes the matching hash
|
|
598
|
-
* from storage if `code` matches a stored backup code; returns `false`
|
|
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`.
|
|
603
|
-
*
|
|
604
|
-
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
605
|
-
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
606
|
-
*/
|
|
607
|
-
async consumeBackupCode(username, code) {
|
|
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;
|
|
620
|
-
}
|
|
621
|
-
/**
|
|
622
580
|
* Verify a TOTP code against the user's confirmed `totp` MFA method.
|
|
623
581
|
* Failures bump the same `failedLoginAttempts` counter as `login` so an
|
|
624
582
|
* attacker who knows the password but not the TOTP gets `lockout.threshold`
|
|
625
583
|
* total tries across BOTH factors, not `2 * threshold`.
|
|
626
584
|
*/
|
|
627
|
-
async verifyMfa(
|
|
628
|
-
const user = await this.getUser(
|
|
629
|
-
if (!user.account.active) throw new
|
|
630
|
-
await this.ensureNotLockedOrThrow(
|
|
585
|
+
async verifyMfa(id, code, config, lockoutOverride) {
|
|
586
|
+
const user = await this.getUser(id);
|
|
587
|
+
if (!user.account.active) throw new require_federated_identity_store.UserAuthError("INACTIVE");
|
|
588
|
+
await this.ensureNotLockedOrThrow(id, user.account);
|
|
631
589
|
const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
632
|
-
if (!totp) throw new
|
|
590
|
+
if (!totp) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
633
591
|
const matchedCounter = verifyTotpCode(totp.value, code, config);
|
|
634
592
|
const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
|
|
635
593
|
if (matchedCounter !== null && !isReplay) {
|
|
636
594
|
let replayDuringCas = false;
|
|
637
|
-
await this.store.withCas(
|
|
595
|
+
await this.store.withCas(id, (current) => {
|
|
638
596
|
const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
639
597
|
if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
|
|
640
598
|
replayDuringCas = true;
|
|
@@ -649,7 +607,7 @@ var UserService = class {
|
|
|
649
607
|
});
|
|
650
608
|
if (!replayDuringCas) return;
|
|
651
609
|
}
|
|
652
|
-
await this.incrementAndMaybeLock(
|
|
610
|
+
await this.incrementAndMaybeLock(id, user.account, "MFA_INVALID", lockoutOverride);
|
|
653
611
|
}
|
|
654
612
|
/**
|
|
655
613
|
* Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
|
|
@@ -662,10 +620,10 @@ var UserService = class {
|
|
|
662
620
|
* Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
|
|
663
621
|
* totp method; MFA_INVALID on wrong code.
|
|
664
622
|
*/
|
|
665
|
-
async verifyTotpSetupCode(
|
|
666
|
-
const totp = (await this.getUser(
|
|
667
|
-
if (!totp) throw new
|
|
668
|
-
if (verifyTotpCode(totp.value, code, config) === null) throw new
|
|
623
|
+
async verifyTotpSetupCode(id, code, config) {
|
|
624
|
+
const totp = (await this.getUser(id)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
|
|
625
|
+
if (!totp) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
626
|
+
if (verifyTotpCode(totp.value, code, config) === null) throw new require_federated_identity_store.UserAuthError("MFA_INVALID");
|
|
669
627
|
}
|
|
670
628
|
getPasswordHasher() {
|
|
671
629
|
return this.hasher;
|
|
@@ -695,8 +653,8 @@ var UserService = class {
|
|
|
695
653
|
* write — the array shape is preserved end-to-end so DB adapters with a
|
|
696
654
|
* merge strategy replace the whole array.
|
|
697
655
|
*/
|
|
698
|
-
async addTrustedDevice(
|
|
699
|
-
await this.store.withCas(
|
|
656
|
+
async addTrustedDevice(id, record) {
|
|
657
|
+
await this.store.withCas(id, (user) => {
|
|
700
658
|
return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
|
|
701
659
|
});
|
|
702
660
|
}
|
|
@@ -705,13 +663,13 @@ var UserService = class {
|
|
|
705
663
|
* the configured secret, AND (b) matches a persisted record that is still
|
|
706
664
|
* within its expiry window and whose bound IP (if any) matches.
|
|
707
665
|
*/
|
|
708
|
-
async verifyTrustedDevice(
|
|
666
|
+
async verifyTrustedDevice(userId, token, ip) {
|
|
709
667
|
const secret = this.requireDeviceTrustSecret();
|
|
710
668
|
const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
|
|
711
669
|
if (sepIdx <= 0) return false;
|
|
712
670
|
const raw = token.slice(0, sepIdx);
|
|
713
|
-
if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${
|
|
714
|
-
const user = await this.store.
|
|
671
|
+
if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${userId}|${raw}|${ip ?? ""}`))) return false;
|
|
672
|
+
const user = await this.store.findById(userId);
|
|
715
673
|
if (!user) return false;
|
|
716
674
|
const list = user.trustedDevices ?? [];
|
|
717
675
|
const now = this.config.clock();
|
|
@@ -724,33 +682,33 @@ var UserService = class {
|
|
|
724
682
|
* Remove a specific trust record from the user. No-op when the record is
|
|
725
683
|
* absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
|
|
726
684
|
*/
|
|
727
|
-
async revokeTrustedDevice(
|
|
728
|
-
const user = await this.store.
|
|
685
|
+
async revokeTrustedDevice(id, token) {
|
|
686
|
+
const user = await this.store.findById(id);
|
|
729
687
|
if (!user) return;
|
|
730
688
|
const list = user.trustedDevices ?? [];
|
|
731
689
|
const next = list.filter((r) => r.token !== token);
|
|
732
690
|
if (next.length === list.length) return;
|
|
733
|
-
await this.store.update(
|
|
691
|
+
await this.store.update(id, { set: { trustedDevices: next } });
|
|
734
692
|
}
|
|
735
|
-
async listTrustedDevices(
|
|
736
|
-
return (await this.getUser(
|
|
693
|
+
async listTrustedDevices(id) {
|
|
694
|
+
return (await this.getUser(id)).trustedDevices ?? [];
|
|
737
695
|
}
|
|
738
696
|
requireDeviceTrustSecret() {
|
|
739
697
|
const secret = this.config.deviceTrust?.secret;
|
|
740
698
|
if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
|
|
741
699
|
return secret;
|
|
742
700
|
}
|
|
743
|
-
async applyPasswordChange(
|
|
701
|
+
async applyPasswordChange(id, user, newPassword) {
|
|
744
702
|
const policyResult = await this.checkPolicies(newPassword, user.password);
|
|
745
|
-
if (!policyResult.passed) throw new
|
|
703
|
+
if (!policyResult.passed) throw new require_federated_identity_store.UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
|
|
746
704
|
const hashesToCheck = [user.password.hash, ...user.password.history].filter(Boolean);
|
|
747
705
|
if (hashesToCheck.length > 0) {
|
|
748
|
-
if ((await Promise.all(hashesToCheck.map((h) => this.hasher.verify(newPassword, h)))).some(Boolean)) throw new
|
|
706
|
+
if ((await Promise.all(hashesToCheck.map((h) => this.hasher.verify(newPassword, h)))).some(Boolean)) throw new require_federated_identity_store.UserAuthError("PASSWORD_IN_HISTORY");
|
|
749
707
|
}
|
|
750
708
|
const newHash = await this.hasher.hash(newPassword);
|
|
751
709
|
const limit = this.config.password.historyLength;
|
|
752
710
|
const newHistory = limit > 0 ? [user.password.hash, ...user.password.history].filter(Boolean).slice(0, limit) : [];
|
|
753
|
-
await this.store.update(
|
|
711
|
+
await this.store.update(id, { set: { password: {
|
|
754
712
|
hash: newHash,
|
|
755
713
|
history: newHistory,
|
|
756
714
|
lastChanged: this.config.clock(),
|
|
@@ -764,11 +722,11 @@ var UserService = class {
|
|
|
764
722
|
* If `account.locked`: auto-unlock when the lock has expired (mutating
|
|
765
723
|
* `account` in place), or throw `LOCKED` otherwise.
|
|
766
724
|
*/
|
|
767
|
-
async ensureNotLockedOrThrow(
|
|
725
|
+
async ensureNotLockedOrThrow(id, account) {
|
|
768
726
|
const lockStatus = this.getLockStatus(account);
|
|
769
727
|
if (!lockStatus.locked) return;
|
|
770
728
|
if (lockStatus.expired) {
|
|
771
|
-
await this.store.update(
|
|
729
|
+
await this.store.update(id, { set: { account: {
|
|
772
730
|
locked: false,
|
|
773
731
|
lockReason: "",
|
|
774
732
|
lockEnds: 0
|
|
@@ -778,7 +736,7 @@ var UserService = class {
|
|
|
778
736
|
account.lockReason = "";
|
|
779
737
|
return;
|
|
780
738
|
}
|
|
781
|
-
throw new
|
|
739
|
+
throw new require_federated_identity_store.UserAuthError("LOCKED", void 0, {
|
|
782
740
|
reason: account.lockReason,
|
|
783
741
|
lockEnds: account.lockEnds
|
|
784
742
|
});
|
|
@@ -788,13 +746,21 @@ var UserService = class {
|
|
|
788
746
|
* and always throw `errorCode` (with `details.lockEnds` when the lockout
|
|
789
747
|
* just tripped). Used by both `login` and `verifyMfa` so the two factors
|
|
790
748
|
* share one counter.
|
|
749
|
+
*
|
|
750
|
+
* `lockoutOverride` lets a caller (e.g. a workflow policy resolver) force a
|
|
751
|
+
* different posture for THIS lock — notably `{ duration: 0 }` to make the
|
|
752
|
+
* lock permanent (admin-/recovery-lift only) instead of timed. Unset fields
|
|
753
|
+
* fall back to `this.config.lockout`.
|
|
791
754
|
*/
|
|
792
|
-
async incrementAndMaybeLock(
|
|
755
|
+
async incrementAndMaybeLock(id, account, errorCode, lockoutOverride) {
|
|
793
756
|
const newAttempts = account.failedLoginAttempts + 1;
|
|
794
|
-
const { threshold, duration } =
|
|
757
|
+
const { threshold, duration } = {
|
|
758
|
+
...this.config.lockout,
|
|
759
|
+
...lockoutOverride
|
|
760
|
+
};
|
|
795
761
|
if (threshold > 0 && newAttempts >= threshold) {
|
|
796
762
|
const lockEnds = duration ? this.config.clock() + duration : 0;
|
|
797
|
-
await this.store.update(
|
|
763
|
+
await this.store.update(id, {
|
|
798
764
|
inc: { "account.failedLoginAttempts": 1 },
|
|
799
765
|
set: { account: {
|
|
800
766
|
locked: true,
|
|
@@ -802,61 +768,148 @@ var UserService = class {
|
|
|
802
768
|
lockEnds
|
|
803
769
|
} }
|
|
804
770
|
});
|
|
805
|
-
throw new
|
|
771
|
+
throw new require_federated_identity_store.UserAuthError(errorCode, void 0, { lockEnds });
|
|
806
772
|
}
|
|
807
|
-
await this.store.update(
|
|
808
|
-
throw new
|
|
773
|
+
await this.store.update(id, { inc: { "account.failedLoginAttempts": 1 } });
|
|
774
|
+
throw new require_federated_identity_store.UserAuthError(errorCode);
|
|
809
775
|
}
|
|
810
776
|
};
|
|
811
777
|
//#endregion
|
|
812
778
|
//#region src/store/memory.ts
|
|
813
|
-
var UserStoreMemory = class extends
|
|
779
|
+
var UserStoreMemory = class extends require_federated_identity_store.UserStore {
|
|
780
|
+
/** Keyed by the stable surrogate `id` (the token subject). */
|
|
814
781
|
store = /* @__PURE__ */ new Map();
|
|
782
|
+
/**
|
|
783
|
+
* Optional seed. The map is keyed by each record's `id`; a record missing an
|
|
784
|
+
* `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
|
|
785
|
+
* keys are ignored — identity is the record's `id`.
|
|
786
|
+
*/
|
|
815
787
|
constructor(seed) {
|
|
816
788
|
super();
|
|
817
|
-
if (seed) for (const
|
|
789
|
+
if (seed) for (const value of Object.values(seed)) {
|
|
790
|
+
const cloned = structuredClone(value);
|
|
791
|
+
if (!cloned.id) cloned.id = (0, node_crypto.randomUUID)();
|
|
792
|
+
this.store.set(cloned.id, cloned);
|
|
793
|
+
}
|
|
818
794
|
}
|
|
819
|
-
async exists(
|
|
820
|
-
|
|
795
|
+
async exists(handle) {
|
|
796
|
+
for (const u of this.store.values()) if (u.username === handle) return true;
|
|
797
|
+
return false;
|
|
821
798
|
}
|
|
822
|
-
async
|
|
823
|
-
const user = this.store.get(
|
|
799
|
+
async findById(id) {
|
|
800
|
+
const user = this.store.get(id);
|
|
824
801
|
return user ? structuredClone(user) : null;
|
|
825
802
|
}
|
|
803
|
+
async findByHandle(handle) {
|
|
804
|
+
let byEmail = null;
|
|
805
|
+
for (const u of this.store.values()) {
|
|
806
|
+
if (u.username === handle) return structuredClone(u);
|
|
807
|
+
if (byEmail === null && u.email !== void 0 && u.email === handle) byEmail = u;
|
|
808
|
+
}
|
|
809
|
+
return byEmail ? structuredClone(byEmail) : null;
|
|
810
|
+
}
|
|
811
|
+
async findByIdentifier(value) {
|
|
812
|
+
const byId = this.store.get(value);
|
|
813
|
+
if (byId) return structuredClone(byId);
|
|
814
|
+
return this.findByHandle(value);
|
|
815
|
+
}
|
|
826
816
|
async create(data) {
|
|
827
|
-
if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
|
|
828
817
|
const cloned = structuredClone(data);
|
|
818
|
+
if (!cloned.id) cloned.id = (0, node_crypto.randomUUID)();
|
|
819
|
+
for (const u of this.store.values()) {
|
|
820
|
+
if (u.username === cloned.username) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
|
|
821
|
+
if (cloned.email !== void 0 && u.email === cloned.email) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `Email "${cloned.email}" already exists`);
|
|
822
|
+
}
|
|
829
823
|
cloned.version = 0;
|
|
830
|
-
this.store.set(
|
|
824
|
+
this.store.set(cloned.id, cloned);
|
|
831
825
|
}
|
|
832
|
-
async update(
|
|
833
|
-
const user = this.store.get(
|
|
826
|
+
async update(id, update) {
|
|
827
|
+
const user = this.store.get(id);
|
|
834
828
|
if (!user) return false;
|
|
835
829
|
if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
|
|
836
|
-
if (update.set)
|
|
837
|
-
if (update.inc) for (const [path, amount] of Object.entries(update.inc))
|
|
830
|
+
if (update.set) require_federated_identity_store.deepMerge(user, update.set);
|
|
831
|
+
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_federated_identity_store.incrementAtPath(user, path, amount);
|
|
838
832
|
user.version = (user.version ?? 0) + 1;
|
|
839
833
|
return true;
|
|
840
834
|
}
|
|
841
|
-
async delete(
|
|
842
|
-
return this.store.delete(
|
|
835
|
+
async delete(id) {
|
|
836
|
+
return this.store.delete(id);
|
|
843
837
|
}
|
|
844
|
-
async withCas(
|
|
838
|
+
async withCas(id, mutator, opts) {
|
|
845
839
|
const maxAttempts = opts?.maxAttempts ?? 2;
|
|
846
840
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
847
|
-
const current = await this.
|
|
848
|
-
if (!current) throw new
|
|
841
|
+
const current = await this.findById(id);
|
|
842
|
+
if (!current) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
849
843
|
const patch = mutator(current);
|
|
850
844
|
if (patch === null) return;
|
|
851
|
-
if (await this.update(
|
|
845
|
+
if (await this.update(id, {
|
|
852
846
|
...patch,
|
|
853
847
|
expectedVersion: current.version ?? 0
|
|
854
848
|
})) return;
|
|
855
849
|
}
|
|
856
|
-
throw new
|
|
850
|
+
throw new require_federated_identity_store.UserAuthError("CAS_EXHAUSTED");
|
|
857
851
|
}
|
|
858
852
|
};
|
|
859
853
|
//#endregion
|
|
854
|
+
//#region src/store/federated-identity-store-memory.ts
|
|
855
|
+
/**
|
|
856
|
+
* In-memory {@link FederatedIdentityStore} — the offline-testable reference
|
|
857
|
+
* impl (RFC IDP.md §9). Keyed by the composite `(provider, subject)`;
|
|
858
|
+
* `structuredClone` on every read/write isolates callers from later mutation
|
|
859
|
+
* (mirrors `UserStoreMemory`).
|
|
860
|
+
*/
|
|
861
|
+
var FederatedIdentityStoreMemory = class extends require_federated_identity_store.FederatedIdentityStore {
|
|
862
|
+
/** Keyed by `compositeKey(provider, subject)`. */
|
|
863
|
+
store = /* @__PURE__ */ new Map();
|
|
864
|
+
clock;
|
|
865
|
+
constructor(opts) {
|
|
866
|
+
super();
|
|
867
|
+
this.clock = opts?.clock ?? Date.now;
|
|
868
|
+
}
|
|
869
|
+
async find(provider, subject) {
|
|
870
|
+
const row = this.store.get(compositeKey(provider, subject));
|
|
871
|
+
return row ? structuredClone(row) : null;
|
|
872
|
+
}
|
|
873
|
+
async listForUser(userId) {
|
|
874
|
+
return [...this.store.values()].filter((row) => row.userId === userId).toSorted((a, b) => a.linkedAt - b.linkedAt).map((row) => structuredClone(row));
|
|
875
|
+
}
|
|
876
|
+
async link(rec) {
|
|
877
|
+
const key = compositeKey(rec.provider, rec.subject);
|
|
878
|
+
if (this.store.has(key)) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `Provider account "${rec.provider}:${rec.subject}" is already linked`);
|
|
879
|
+
const row = {
|
|
880
|
+
id: (0, node_crypto.randomUUID)(),
|
|
881
|
+
provider: rec.provider,
|
|
882
|
+
subject: rec.subject,
|
|
883
|
+
userId: rec.userId,
|
|
884
|
+
...require_federated_identity_store.pickDefinedProfile(rec),
|
|
885
|
+
linkedAt: this.clock()
|
|
886
|
+
};
|
|
887
|
+
this.store.set(key, structuredClone(row));
|
|
888
|
+
return structuredClone(row);
|
|
889
|
+
}
|
|
890
|
+
async unlink(provider, subject) {
|
|
891
|
+
return this.store.delete(compositeKey(provider, subject));
|
|
892
|
+
}
|
|
893
|
+
async touchLogin(provider, subject, profile) {
|
|
894
|
+
const row = this.store.get(compositeKey(provider, subject));
|
|
895
|
+
if (!row) return;
|
|
896
|
+
row.lastLoginAt = this.clock();
|
|
897
|
+
if (profile) Object.assign(row, require_federated_identity_store.pickDefinedProfile(profile));
|
|
898
|
+
}
|
|
899
|
+
async deleteAllForUser(userId) {
|
|
900
|
+
let removed = 0;
|
|
901
|
+
for (const [key, row] of this.store) if (row.userId === userId) {
|
|
902
|
+
this.store.delete(key);
|
|
903
|
+
removed++;
|
|
904
|
+
}
|
|
905
|
+
return removed;
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
/** Null-byte separator can't appear in a provider id / IdP subject, so the join is unambiguous. */
|
|
909
|
+
function compositeKey(provider, subject) {
|
|
910
|
+
return `${provider}${subject}`;
|
|
911
|
+
}
|
|
912
|
+
//#endregion
|
|
860
913
|
//#region src/password/policies.ts
|
|
861
914
|
const ppHasMinLength = (min = 8) => definePasswordPolicy({
|
|
862
915
|
rule: (v, min) => v.length >= min,
|
|
@@ -895,29 +948,57 @@ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
|
|
|
895
948
|
errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
|
|
896
949
|
});
|
|
897
950
|
//#endregion
|
|
951
|
+
//#region src/mfa/codes.ts
|
|
952
|
+
/**
|
|
953
|
+
* SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
|
|
954
|
+
*
|
|
955
|
+
* Hex-encoded for stable, comparable output regardless of input case/format.
|
|
956
|
+
* Use {@link verifyMfaCode} to compare a submitted plaintext code against the
|
|
957
|
+
* stored hash in constant time.
|
|
958
|
+
*/
|
|
959
|
+
function hashMfaCode(code) {
|
|
960
|
+
return (0, node_crypto.createHash)("sha256").update(code).digest("hex");
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Constant-time comparison of a submitted plaintext code against an
|
|
964
|
+
* expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
|
|
965
|
+
*
|
|
966
|
+
* Returns false for malformed/empty expected hashes (timingSafeEqual
|
|
967
|
+
* requires equal-length, non-empty buffers).
|
|
968
|
+
*/
|
|
969
|
+
function verifyMfaCode(submitted, expectedHash) {
|
|
970
|
+
if (!expectedHash) return false;
|
|
971
|
+
const a = Buffer.from(hashMfaCode(submitted), "hex");
|
|
972
|
+
const b = Buffer.from(expectedHash, "hex");
|
|
973
|
+
if (a.length !== b.length) return false;
|
|
974
|
+
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
975
|
+
}
|
|
976
|
+
//#endregion
|
|
977
|
+
exports.FederatedIdentityStore = require_federated_identity_store.FederatedIdentityStore;
|
|
978
|
+
exports.FederatedIdentityStoreMemory = FederatedIdentityStoreMemory;
|
|
898
979
|
exports.PasswordHasher = PasswordHasher;
|
|
899
980
|
exports.PasswordPolicy = PasswordPolicy;
|
|
900
|
-
exports.UserAuthError =
|
|
981
|
+
exports.UserAuthError = require_federated_identity_store.UserAuthError;
|
|
901
982
|
exports.UserService = UserService;
|
|
902
|
-
exports.UserStore =
|
|
983
|
+
exports.UserStore = require_federated_identity_store.UserStore;
|
|
903
984
|
exports.UserStoreMemory = UserStoreMemory;
|
|
904
985
|
exports.definePasswordPolicy = definePasswordPolicy;
|
|
905
|
-
exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
|
|
906
986
|
exports.generateMfaCode = generateMfaCode;
|
|
907
987
|
exports.generateTotpCode = generateTotpCode;
|
|
908
988
|
exports.generateTotpSecret = generateTotpSecret;
|
|
909
989
|
exports.generateTotpUri = generateTotpUri;
|
|
910
990
|
exports.hashMfaCode = hashMfaCode;
|
|
911
|
-
exports.maskEmail =
|
|
912
|
-
exports.maskMfaValue =
|
|
913
|
-
exports.maskPhone =
|
|
991
|
+
exports.maskEmail = require_federated_identity_store.maskEmail;
|
|
992
|
+
exports.maskMfaValue = require_federated_identity_store.maskMfaValue;
|
|
993
|
+
exports.maskPhone = require_federated_identity_store.maskPhone;
|
|
914
994
|
exports.normalizePolicies = normalizePolicies;
|
|
995
|
+
exports.pickDefinedProfile = require_federated_identity_store.pickDefinedProfile;
|
|
915
996
|
exports.ppHasLowerCase = ppHasLowerCase;
|
|
916
997
|
exports.ppHasMinLength = ppHasMinLength;
|
|
917
998
|
exports.ppHasNumber = ppHasNumber;
|
|
918
999
|
exports.ppHasSpecialChar = ppHasSpecialChar;
|
|
919
1000
|
exports.ppHasUpperCase = ppHasUpperCase;
|
|
920
1001
|
exports.ppMaxRepeatedChars = ppMaxRepeatedChars;
|
|
921
|
-
exports.setAtPath =
|
|
1002
|
+
exports.setAtPath = require_federated_identity_store.setAtPath;
|
|
922
1003
|
exports.verifyMfaCode = verifyMfaCode;
|
|
923
1004
|
exports.verifyTotpCode = verifyTotpCode;
|