@aooth/user 0.1.7 → 0.1.9
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 +121 -23
- package/dist/atscript-db.d.cts +81 -12
- package/dist/atscript-db.d.mts +81 -12
- package/dist/atscript-db.mjs +116 -19
- package/dist/{user-store-BPZVAboN.cjs → federated-identity-store-BEEEcoaP.cjs} +52 -0
- package/dist/{user-store-BaBmH13V.mjs → federated-identity-store-CHW1xtMp.mjs} +41 -1
- package/dist/{user-store-62LCSa8q.d.mts → federated-identity-store-CI7Vgllp.d.cts} +143 -24
- package/dist/{user-store-BZsKtBHy.d.cts → federated-identity-store-CI7Vgllp.d.mts} +143 -24
- package/dist/index.cjs +321 -220
- package/dist/index.d.cts +123 -73
- package/dist/index.d.mts +123 -73
- package/dist/index.mjs +289 -190
- 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 +4 -2
- package/src/atscript-db/user-credentials.as.d.ts +61 -0
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-BEEEcoaP.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);
|
|
@@ -360,7 +305,17 @@ function deviceTrustSafeEqual(a, b) {
|
|
|
360
305
|
if (ab.length !== bb.length) return false;
|
|
361
306
|
return (0, node_crypto.timingSafeEqual)(ab, bb);
|
|
362
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
|
+
*/
|
|
363
317
|
var UserService = class {
|
|
318
|
+
store;
|
|
364
319
|
config;
|
|
365
320
|
hasher;
|
|
366
321
|
constructor(store, config) {
|
|
@@ -372,25 +327,31 @@ var UserService = class {
|
|
|
372
327
|
* Creates a user with `account.active: false`. The invite workflow relies
|
|
373
328
|
* on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
|
|
374
329
|
* inactive until they accept). For setup scripts / seeders / tests that
|
|
375
|
-
* don't go through invite, follow up with `activateAccount(
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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
|
|
378
333
|
* enumeration, so the failure is silent client-side.
|
|
379
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
|
+
*
|
|
380
339
|
* @param extras Optional partial user fields merged AFTER the base
|
|
381
340
|
* `UserCredentials` shape, so callers can populate consumer-specific
|
|
382
341
|
* required fields (e.g. `tenantId`) without subclassing the store.
|
|
383
342
|
* Because the merge is shallow and extras win, overlapping top-level
|
|
384
|
-
* keys (`id`, `account`, `mfa`, ...) replace the defaults
|
|
385
|
-
* pass nested objects with all required sub-fields if you
|
|
386
|
-
* 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.
|
|
387
346
|
*/
|
|
388
347
|
async createUser(username, password, extras) {
|
|
389
348
|
const pw = password ?? this.hasher.generatePassword();
|
|
349
|
+
const hash = await this.hasher.hash(pw);
|
|
390
350
|
const userData = {
|
|
351
|
+
id: (0, node_crypto.randomUUID)(),
|
|
391
352
|
username,
|
|
392
353
|
password: {
|
|
393
|
-
hash
|
|
354
|
+
hash,
|
|
394
355
|
history: [],
|
|
395
356
|
lastChanged: this.config.clock(),
|
|
396
357
|
isInitial: !password
|
|
@@ -413,19 +374,36 @@ var UserService = class {
|
|
|
413
374
|
await this.store.create(userData);
|
|
414
375
|
return userData;
|
|
415
376
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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");
|
|
419
381
|
return user;
|
|
420
382
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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);
|
|
426
404
|
if (await this.hasher.verify(password, user.password.hash)) {
|
|
427
405
|
const now = this.config.clock();
|
|
428
|
-
await this.store.update(
|
|
406
|
+
await this.store.update(user.id, { set: { account: {
|
|
429
407
|
lastLogin: now,
|
|
430
408
|
failedLoginAttempts: 0
|
|
431
409
|
} } });
|
|
@@ -436,30 +414,30 @@ var UserService = class {
|
|
|
436
414
|
mfaRequired: this.hasConfirmedMfaMethods(user.mfa)
|
|
437
415
|
};
|
|
438
416
|
}
|
|
439
|
-
return this.incrementAndMaybeLock(
|
|
417
|
+
return this.incrementAndMaybeLock(user.id, user.account, "INVALID_CREDENTIALS", lockoutOverride);
|
|
440
418
|
}
|
|
441
|
-
async verifyPassword(
|
|
442
|
-
const user = await this.store.
|
|
443
|
-
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");
|
|
444
422
|
return this.hasher.verify(password, user.password.hash);
|
|
445
423
|
}
|
|
446
|
-
async changePassword(
|
|
447
|
-
if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new
|
|
448
|
-
const user = await this.getUser(
|
|
449
|
-
if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new
|
|
450
|
-
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);
|
|
451
429
|
}
|
|
452
|
-
async setPassword(
|
|
453
|
-
const user = await this.getUser(
|
|
454
|
-
await this.applyPasswordChange(
|
|
430
|
+
async setPassword(id, newPassword) {
|
|
431
|
+
const user = await this.getUser(id);
|
|
432
|
+
await this.applyPasswordChange(id, user, newPassword);
|
|
455
433
|
}
|
|
456
434
|
/**
|
|
457
|
-
* Hard-delete the user row
|
|
458
|
-
* `UserAuthError("NOT_FOUND")` when no row matches
|
|
459
|
-
*
|
|
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.
|
|
460
438
|
*/
|
|
461
|
-
async deleteUser(
|
|
462
|
-
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");
|
|
463
441
|
}
|
|
464
442
|
/**
|
|
465
443
|
* Deep-merge `patch` into the user record (top-level fields are shallow-
|
|
@@ -467,31 +445,31 @@ var UserService = class {
|
|
|
467
445
|
* `@db.patch.strategy 'merge'` declaration). Returns the patched record.
|
|
468
446
|
* Used by the invite workflow's `applyProfile` default fallback.
|
|
469
447
|
*/
|
|
470
|
-
async update(
|
|
471
|
-
if (!await this.store.update(
|
|
472
|
-
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);
|
|
473
451
|
}
|
|
474
|
-
async activateAccount(
|
|
475
|
-
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");
|
|
476
454
|
}
|
|
477
|
-
async deactivateAccount(
|
|
478
|
-
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");
|
|
479
457
|
}
|
|
480
|
-
async lockAccount(
|
|
458
|
+
async lockAccount(id, reason, duration) {
|
|
481
459
|
const lockEnds = duration ? this.config.clock() + duration : 0;
|
|
482
|
-
if (!await this.store.update(
|
|
460
|
+
if (!await this.store.update(id, { set: { account: {
|
|
483
461
|
locked: true,
|
|
484
462
|
lockReason: reason,
|
|
485
463
|
lockEnds
|
|
486
|
-
} } })) throw new
|
|
464
|
+
} } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
487
465
|
}
|
|
488
|
-
async unlockAccount(
|
|
489
|
-
if (!await this.store.update(
|
|
466
|
+
async unlockAccount(id) {
|
|
467
|
+
if (!await this.store.update(id, { set: { account: {
|
|
490
468
|
locked: false,
|
|
491
469
|
lockReason: "",
|
|
492
470
|
lockEnds: 0,
|
|
493
471
|
failedLoginAttempts: 0
|
|
494
|
-
} } })) throw new
|
|
472
|
+
} } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
495
473
|
}
|
|
496
474
|
getLockStatus(account) {
|
|
497
475
|
if (!account.locked) return {
|
|
@@ -552,13 +530,13 @@ var UserService = class {
|
|
|
552
530
|
errorMessage: p.errorMessage
|
|
553
531
|
}));
|
|
554
532
|
}
|
|
555
|
-
async addMfaMethod(
|
|
556
|
-
await this.store.withCas(
|
|
533
|
+
async addMfaMethod(id, method) {
|
|
534
|
+
await this.store.withCas(id, (user) => {
|
|
557
535
|
return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
|
|
558
536
|
});
|
|
559
537
|
}
|
|
560
|
-
async confirmMfaMethod(
|
|
561
|
-
await this.store.withCas(
|
|
538
|
+
async confirmMfaMethod(id, name) {
|
|
539
|
+
await this.store.withCas(id, (user) => {
|
|
562
540
|
let found = false;
|
|
563
541
|
const methods = user.mfa.methods.map((m) => {
|
|
564
542
|
if (m.name === name) {
|
|
@@ -570,90 +548,51 @@ var UserService = class {
|
|
|
570
548
|
}
|
|
571
549
|
return m;
|
|
572
550
|
});
|
|
573
|
-
if (!found) throw new
|
|
551
|
+
if (!found) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
574
552
|
return { set: { mfa: { methods } } };
|
|
575
553
|
});
|
|
576
554
|
}
|
|
577
|
-
async removeMfaMethod(
|
|
578
|
-
const user = await this.getUser(
|
|
555
|
+
async removeMfaMethod(id, name) {
|
|
556
|
+
const user = await this.getUser(id);
|
|
579
557
|
const update = { mfa: { methods: user.mfa.methods.filter((m) => m.name !== name) } };
|
|
580
558
|
if (user.mfa.defaultMethod === name) update.mfa.defaultMethod = "";
|
|
581
|
-
await this.store.update(
|
|
559
|
+
await this.store.update(id, { set: update });
|
|
582
560
|
}
|
|
583
|
-
async setDefaultMfaMethod(
|
|
584
|
-
const user = await this.getUser(
|
|
585
|
-
if (name && !user.mfa.methods.some((m) => m.name === name)) throw new
|
|
586
|
-
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: {
|
|
587
565
|
defaultMethod: name,
|
|
588
566
|
autoSend: false
|
|
589
567
|
} } });
|
|
590
568
|
}
|
|
591
|
-
async setMfaAutoSend(
|
|
592
|
-
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");
|
|
593
571
|
}
|
|
594
572
|
getAvailableMfaMethods(mfa) {
|
|
595
573
|
return mfa.methods.filter((m) => m.confirmed).map((m) => ({
|
|
596
574
|
name: m.name,
|
|
597
575
|
isDefault: mfa.defaultMethod === m.name,
|
|
598
|
-
masked:
|
|
576
|
+
masked: require_federated_identity_store.maskMfaValue(m)
|
|
599
577
|
}));
|
|
600
578
|
}
|
|
601
579
|
/**
|
|
602
|
-
* Generate `count` plaintext backup codes (default 10), persist their
|
|
603
|
-
* hashes (replacing any existing batch), and return the plaintext codes
|
|
604
|
-
* once for the caller to deliver to the user. Plaintext is never
|
|
605
|
-
* recoverable after this call returns.
|
|
606
|
-
*
|
|
607
|
-
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
608
|
-
*/
|
|
609
|
-
async generateBackupCodes(username, count = 10) {
|
|
610
|
-
const codes = generateBackupCodePlaintext(count);
|
|
611
|
-
const hashes = codes.map(hashMfaCode);
|
|
612
|
-
if (!await this.store.update(username, { set: { backupCodes: hashes } })) throw new require_user_store.UserAuthError("NOT_FOUND");
|
|
613
|
-
return codes;
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* Consume a backup code: returns `true` and removes the matching hash
|
|
617
|
-
* from storage if `code` matches a stored backup code; returns `false`
|
|
618
|
-
* if no match (without modifying storage). Single-use is enforced by
|
|
619
|
-
* optimistic-concurrency CAS on the version column — concurrent consumes
|
|
620
|
-
* of the same code race fairly and only one wins; the loser re-reads,
|
|
621
|
-
* finds the hash already removed, and returns `false`.
|
|
622
|
-
*
|
|
623
|
-
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
624
|
-
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
625
|
-
*/
|
|
626
|
-
async consumeBackupCode(username, code) {
|
|
627
|
-
let consumed = false;
|
|
628
|
-
await this.store.withCas(username, (user) => {
|
|
629
|
-
const hashes = user.backupCodes ?? [];
|
|
630
|
-
const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
|
|
631
|
-
if (idx < 0) {
|
|
632
|
-
consumed = false;
|
|
633
|
-
return null;
|
|
634
|
-
}
|
|
635
|
-
consumed = true;
|
|
636
|
-
return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
|
|
637
|
-
});
|
|
638
|
-
return consumed;
|
|
639
|
-
}
|
|
640
|
-
/**
|
|
641
580
|
* Verify a TOTP code against the user's confirmed `totp` MFA method.
|
|
642
581
|
* Failures bump the same `failedLoginAttempts` counter as `login` so an
|
|
643
582
|
* attacker who knows the password but not the TOTP gets `lockout.threshold`
|
|
644
583
|
* total tries across BOTH factors, not `2 * threshold`.
|
|
645
584
|
*/
|
|
646
|
-
async verifyMfa(
|
|
647
|
-
const user = await this.getUser(
|
|
648
|
-
if (!user.account.active) throw new
|
|
649
|
-
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);
|
|
650
589
|
const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
651
|
-
if (!totp) throw new
|
|
590
|
+
if (!totp) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
|
|
652
591
|
const matchedCounter = verifyTotpCode(totp.value, code, config);
|
|
653
592
|
const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
|
|
654
593
|
if (matchedCounter !== null && !isReplay) {
|
|
655
594
|
let replayDuringCas = false;
|
|
656
|
-
await this.store.withCas(
|
|
595
|
+
await this.store.withCas(id, (current) => {
|
|
657
596
|
const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
658
597
|
if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
|
|
659
598
|
replayDuringCas = true;
|
|
@@ -668,7 +607,7 @@ var UserService = class {
|
|
|
668
607
|
});
|
|
669
608
|
if (!replayDuringCas) return;
|
|
670
609
|
}
|
|
671
|
-
await this.incrementAndMaybeLock(
|
|
610
|
+
await this.incrementAndMaybeLock(id, user.account, "MFA_INVALID", lockoutOverride);
|
|
672
611
|
}
|
|
673
612
|
/**
|
|
674
613
|
* Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
|
|
@@ -681,10 +620,10 @@ var UserService = class {
|
|
|
681
620
|
* Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
|
|
682
621
|
* totp method; MFA_INVALID on wrong code.
|
|
683
622
|
*/
|
|
684
|
-
async verifyTotpSetupCode(
|
|
685
|
-
const totp = (await this.getUser(
|
|
686
|
-
if (!totp) throw new
|
|
687
|
-
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");
|
|
688
627
|
}
|
|
689
628
|
getPasswordHasher() {
|
|
690
629
|
return this.hasher;
|
|
@@ -714,8 +653,8 @@ var UserService = class {
|
|
|
714
653
|
* write — the array shape is preserved end-to-end so DB adapters with a
|
|
715
654
|
* merge strategy replace the whole array.
|
|
716
655
|
*/
|
|
717
|
-
async addTrustedDevice(
|
|
718
|
-
await this.store.withCas(
|
|
656
|
+
async addTrustedDevice(id, record) {
|
|
657
|
+
await this.store.withCas(id, (user) => {
|
|
719
658
|
return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
|
|
720
659
|
});
|
|
721
660
|
}
|
|
@@ -724,13 +663,13 @@ var UserService = class {
|
|
|
724
663
|
* the configured secret, AND (b) matches a persisted record that is still
|
|
725
664
|
* within its expiry window and whose bound IP (if any) matches.
|
|
726
665
|
*/
|
|
727
|
-
async verifyTrustedDevice(
|
|
666
|
+
async verifyTrustedDevice(userId, token, ip) {
|
|
728
667
|
const secret = this.requireDeviceTrustSecret();
|
|
729
668
|
const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
|
|
730
669
|
if (sepIdx <= 0) return false;
|
|
731
670
|
const raw = token.slice(0, sepIdx);
|
|
732
|
-
if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${
|
|
733
|
-
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);
|
|
734
673
|
if (!user) return false;
|
|
735
674
|
const list = user.trustedDevices ?? [];
|
|
736
675
|
const now = this.config.clock();
|
|
@@ -743,33 +682,33 @@ var UserService = class {
|
|
|
743
682
|
* Remove a specific trust record from the user. No-op when the record is
|
|
744
683
|
* absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
|
|
745
684
|
*/
|
|
746
|
-
async revokeTrustedDevice(
|
|
747
|
-
const user = await this.store.
|
|
685
|
+
async revokeTrustedDevice(id, token) {
|
|
686
|
+
const user = await this.store.findById(id);
|
|
748
687
|
if (!user) return;
|
|
749
688
|
const list = user.trustedDevices ?? [];
|
|
750
689
|
const next = list.filter((r) => r.token !== token);
|
|
751
690
|
if (next.length === list.length) return;
|
|
752
|
-
await this.store.update(
|
|
691
|
+
await this.store.update(id, { set: { trustedDevices: next } });
|
|
753
692
|
}
|
|
754
|
-
async listTrustedDevices(
|
|
755
|
-
return (await this.getUser(
|
|
693
|
+
async listTrustedDevices(id) {
|
|
694
|
+
return (await this.getUser(id)).trustedDevices ?? [];
|
|
756
695
|
}
|
|
757
696
|
requireDeviceTrustSecret() {
|
|
758
697
|
const secret = this.config.deviceTrust?.secret;
|
|
759
698
|
if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
|
|
760
699
|
return secret;
|
|
761
700
|
}
|
|
762
|
-
async applyPasswordChange(
|
|
701
|
+
async applyPasswordChange(id, user, newPassword) {
|
|
763
702
|
const policyResult = await this.checkPolicies(newPassword, user.password);
|
|
764
|
-
if (!policyResult.passed) throw new
|
|
703
|
+
if (!policyResult.passed) throw new require_federated_identity_store.UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
|
|
765
704
|
const hashesToCheck = [user.password.hash, ...user.password.history].filter(Boolean);
|
|
766
705
|
if (hashesToCheck.length > 0) {
|
|
767
|
-
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");
|
|
768
707
|
}
|
|
769
708
|
const newHash = await this.hasher.hash(newPassword);
|
|
770
709
|
const limit = this.config.password.historyLength;
|
|
771
710
|
const newHistory = limit > 0 ? [user.password.hash, ...user.password.history].filter(Boolean).slice(0, limit) : [];
|
|
772
|
-
await this.store.update(
|
|
711
|
+
await this.store.update(id, { set: { password: {
|
|
773
712
|
hash: newHash,
|
|
774
713
|
history: newHistory,
|
|
775
714
|
lastChanged: this.config.clock(),
|
|
@@ -783,11 +722,11 @@ var UserService = class {
|
|
|
783
722
|
* If `account.locked`: auto-unlock when the lock has expired (mutating
|
|
784
723
|
* `account` in place), or throw `LOCKED` otherwise.
|
|
785
724
|
*/
|
|
786
|
-
async ensureNotLockedOrThrow(
|
|
725
|
+
async ensureNotLockedOrThrow(id, account) {
|
|
787
726
|
const lockStatus = this.getLockStatus(account);
|
|
788
727
|
if (!lockStatus.locked) return;
|
|
789
728
|
if (lockStatus.expired) {
|
|
790
|
-
await this.store.update(
|
|
729
|
+
await this.store.update(id, { set: { account: {
|
|
791
730
|
locked: false,
|
|
792
731
|
lockReason: "",
|
|
793
732
|
lockEnds: 0
|
|
@@ -797,7 +736,7 @@ var UserService = class {
|
|
|
797
736
|
account.lockReason = "";
|
|
798
737
|
return;
|
|
799
738
|
}
|
|
800
|
-
throw new
|
|
739
|
+
throw new require_federated_identity_store.UserAuthError("LOCKED", void 0, {
|
|
801
740
|
reason: account.lockReason,
|
|
802
741
|
lockEnds: account.lockEnds
|
|
803
742
|
});
|
|
@@ -807,13 +746,21 @@ var UserService = class {
|
|
|
807
746
|
* and always throw `errorCode` (with `details.lockEnds` when the lockout
|
|
808
747
|
* just tripped). Used by both `login` and `verifyMfa` so the two factors
|
|
809
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`.
|
|
810
754
|
*/
|
|
811
|
-
async incrementAndMaybeLock(
|
|
755
|
+
async incrementAndMaybeLock(id, account, errorCode, lockoutOverride) {
|
|
812
756
|
const newAttempts = account.failedLoginAttempts + 1;
|
|
813
|
-
const { threshold, duration } =
|
|
757
|
+
const { threshold, duration } = {
|
|
758
|
+
...this.config.lockout,
|
|
759
|
+
...lockoutOverride
|
|
760
|
+
};
|
|
814
761
|
if (threshold > 0 && newAttempts >= threshold) {
|
|
815
762
|
const lockEnds = duration ? this.config.clock() + duration : 0;
|
|
816
|
-
await this.store.update(
|
|
763
|
+
await this.store.update(id, {
|
|
817
764
|
inc: { "account.failedLoginAttempts": 1 },
|
|
818
765
|
set: { account: {
|
|
819
766
|
locked: true,
|
|
@@ -821,60 +768,186 @@ var UserService = class {
|
|
|
821
768
|
lockEnds
|
|
822
769
|
} }
|
|
823
770
|
});
|
|
824
|
-
throw new
|
|
771
|
+
throw new require_federated_identity_store.UserAuthError(errorCode, void 0, { lockEnds });
|
|
825
772
|
}
|
|
826
|
-
await this.store.update(
|
|
827
|
-
throw new
|
|
773
|
+
await this.store.update(id, { inc: { "account.failedLoginAttempts": 1 } });
|
|
774
|
+
throw new require_federated_identity_store.UserAuthError(errorCode);
|
|
828
775
|
}
|
|
829
776
|
};
|
|
830
777
|
//#endregion
|
|
831
778
|
//#region src/store/memory.ts
|
|
832
|
-
var UserStoreMemory = class extends
|
|
779
|
+
var UserStoreMemory = class extends require_federated_identity_store.UserStore {
|
|
780
|
+
/** Keyed by the stable surrogate `id` (the token subject). */
|
|
833
781
|
store = /* @__PURE__ */ new Map();
|
|
834
|
-
|
|
782
|
+
/**
|
|
783
|
+
* Ordered secondary handle fields (e.g. email then phone) resolved from the
|
|
784
|
+
* model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
|
|
785
|
+
* `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
|
|
786
|
+
* enforced unique by `create`. Empty when no handles are configured (login by
|
|
787
|
+
* email/phone unavailable).
|
|
788
|
+
*/
|
|
789
|
+
handleFields;
|
|
790
|
+
/**
|
|
791
|
+
* Optional seed. The map is keyed by each record's `id`; a record missing an
|
|
792
|
+
* `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
|
|
793
|
+
* keys are ignored — identity is the record's `id`.
|
|
794
|
+
*
|
|
795
|
+
* `opts.handleFields` names the ordered secondary login handles (mirroring
|
|
796
|
+
* `UsersStoreAtscriptDb`); omit it for username-only resolution.
|
|
797
|
+
*/
|
|
798
|
+
constructor(seed, opts) {
|
|
835
799
|
super();
|
|
836
|
-
|
|
800
|
+
this.handleFields = opts?.handleFields ?? [];
|
|
801
|
+
if (seed) for (const value of Object.values(seed)) {
|
|
802
|
+
const cloned = structuredClone(value);
|
|
803
|
+
if (!cloned.id) cloned.id = (0, node_crypto.randomUUID)();
|
|
804
|
+
this.store.set(cloned.id, cloned);
|
|
805
|
+
}
|
|
837
806
|
}
|
|
838
|
-
async exists(
|
|
839
|
-
|
|
807
|
+
async exists(handle) {
|
|
808
|
+
for (const u of this.store.values()) if (u.username === handle) return true;
|
|
809
|
+
return false;
|
|
840
810
|
}
|
|
841
|
-
async
|
|
842
|
-
const user = this.store.get(
|
|
811
|
+
async findById(id) {
|
|
812
|
+
const user = this.store.get(id);
|
|
843
813
|
return user ? structuredClone(user) : null;
|
|
844
814
|
}
|
|
815
|
+
async findByHandle(handle) {
|
|
816
|
+
let byHandle = null;
|
|
817
|
+
for (const u of this.store.values()) {
|
|
818
|
+
if (u.username === handle) return structuredClone(u);
|
|
819
|
+
if (byHandle === null) {
|
|
820
|
+
const rec = u;
|
|
821
|
+
for (const field of this.handleFields) {
|
|
822
|
+
const value = rec[field];
|
|
823
|
+
if (value !== void 0 && value === handle) {
|
|
824
|
+
byHandle = u;
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return byHandle ? structuredClone(byHandle) : null;
|
|
831
|
+
}
|
|
832
|
+
async findByIdentifier(value) {
|
|
833
|
+
const byId = this.store.get(value);
|
|
834
|
+
if (byId) return structuredClone(byId);
|
|
835
|
+
return this.findByHandle(value);
|
|
836
|
+
}
|
|
845
837
|
async create(data) {
|
|
846
|
-
if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
|
|
847
838
|
const cloned = structuredClone(data);
|
|
839
|
+
if (!cloned.id) cloned.id = (0, node_crypto.randomUUID)();
|
|
840
|
+
for (const u of this.store.values()) if (u.username === cloned.username) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
|
|
841
|
+
this.assertHandlesUnique(cloned);
|
|
848
842
|
cloned.version = 0;
|
|
849
|
-
this.store.set(
|
|
843
|
+
this.store.set(cloned.id, cloned);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
|
|
847
|
+
* one of `rec`'s handle-column values — the in-memory mirror of the
|
|
848
|
+
* atscript-db store's unique index, so `create` and `update` enforce the same
|
|
849
|
+
* collision contract (which `promote-to-handle` swallows best-effort). Handle
|
|
850
|
+
* values are string columns; a non-string is never a collision.
|
|
851
|
+
*/
|
|
852
|
+
assertHandlesUnique(rec, excludeId) {
|
|
853
|
+
for (const field of this.handleFields) {
|
|
854
|
+
const value = rec[field];
|
|
855
|
+
if (typeof value !== "string") continue;
|
|
856
|
+
for (const [otherId, other] of this.store) {
|
|
857
|
+
if (otherId === excludeId) continue;
|
|
858
|
+
if (other[field] === value) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `${field} "${value}" already exists`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
850
861
|
}
|
|
851
|
-
async update(
|
|
852
|
-
const user = this.store.get(
|
|
862
|
+
async update(id, update) {
|
|
863
|
+
const user = this.store.get(id);
|
|
853
864
|
if (!user) return false;
|
|
854
865
|
if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
|
|
855
|
-
if (update.set)
|
|
856
|
-
|
|
866
|
+
if (update.set) {
|
|
867
|
+
this.assertHandlesUnique(update.set, id);
|
|
868
|
+
require_federated_identity_store.deepMerge(user, update.set);
|
|
869
|
+
}
|
|
870
|
+
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_federated_identity_store.incrementAtPath(user, path, amount);
|
|
857
871
|
user.version = (user.version ?? 0) + 1;
|
|
858
872
|
return true;
|
|
859
873
|
}
|
|
860
|
-
async delete(
|
|
861
|
-
return this.store.delete(
|
|
874
|
+
async delete(id) {
|
|
875
|
+
return this.store.delete(id);
|
|
862
876
|
}
|
|
863
|
-
async withCas(
|
|
877
|
+
async withCas(id, mutator, opts) {
|
|
864
878
|
const maxAttempts = opts?.maxAttempts ?? 2;
|
|
865
879
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
866
|
-
const current = await this.
|
|
867
|
-
if (!current) throw new
|
|
880
|
+
const current = await this.findById(id);
|
|
881
|
+
if (!current) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
868
882
|
const patch = mutator(current);
|
|
869
883
|
if (patch === null) return;
|
|
870
|
-
if (await this.update(
|
|
884
|
+
if (await this.update(id, {
|
|
871
885
|
...patch,
|
|
872
886
|
expectedVersion: current.version ?? 0
|
|
873
887
|
})) return;
|
|
874
888
|
}
|
|
875
|
-
throw new
|
|
889
|
+
throw new require_federated_identity_store.UserAuthError("CAS_EXHAUSTED");
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/store/federated-identity-store-memory.ts
|
|
894
|
+
/**
|
|
895
|
+
* In-memory {@link FederatedIdentityStore} — the offline-testable reference
|
|
896
|
+
* impl (RFC IDP.md §9). Keyed by the composite `(provider, subject)`;
|
|
897
|
+
* `structuredClone` on every read/write isolates callers from later mutation
|
|
898
|
+
* (mirrors `UserStoreMemory`).
|
|
899
|
+
*/
|
|
900
|
+
var FederatedIdentityStoreMemory = class extends require_federated_identity_store.FederatedIdentityStore {
|
|
901
|
+
/** Keyed by `compositeKey(provider, subject)`. */
|
|
902
|
+
store = /* @__PURE__ */ new Map();
|
|
903
|
+
clock;
|
|
904
|
+
constructor(opts) {
|
|
905
|
+
super();
|
|
906
|
+
this.clock = opts?.clock ?? Date.now;
|
|
907
|
+
}
|
|
908
|
+
async find(provider, subject) {
|
|
909
|
+
const row = this.store.get(compositeKey(provider, subject));
|
|
910
|
+
return row ? structuredClone(row) : null;
|
|
911
|
+
}
|
|
912
|
+
async listForUser(userId) {
|
|
913
|
+
return [...this.store.values()].filter((row) => row.userId === userId).toSorted((a, b) => a.linkedAt - b.linkedAt).map((row) => structuredClone(row));
|
|
914
|
+
}
|
|
915
|
+
async link(rec) {
|
|
916
|
+
const key = compositeKey(rec.provider, rec.subject);
|
|
917
|
+
if (this.store.has(key)) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `Provider account "${rec.provider}:${rec.subject}" is already linked`);
|
|
918
|
+
const row = {
|
|
919
|
+
id: (0, node_crypto.randomUUID)(),
|
|
920
|
+
provider: rec.provider,
|
|
921
|
+
subject: rec.subject,
|
|
922
|
+
userId: rec.userId,
|
|
923
|
+
...require_federated_identity_store.pickDefinedProfile(rec),
|
|
924
|
+
linkedAt: this.clock()
|
|
925
|
+
};
|
|
926
|
+
this.store.set(key, structuredClone(row));
|
|
927
|
+
return structuredClone(row);
|
|
928
|
+
}
|
|
929
|
+
async unlink(provider, subject) {
|
|
930
|
+
return this.store.delete(compositeKey(provider, subject));
|
|
931
|
+
}
|
|
932
|
+
async touchLogin(provider, subject, profile) {
|
|
933
|
+
const row = this.store.get(compositeKey(provider, subject));
|
|
934
|
+
if (!row) return;
|
|
935
|
+
row.lastLoginAt = this.clock();
|
|
936
|
+
if (profile) Object.assign(row, require_federated_identity_store.pickDefinedProfile(profile));
|
|
937
|
+
}
|
|
938
|
+
async deleteAllForUser(userId) {
|
|
939
|
+
let removed = 0;
|
|
940
|
+
for (const [key, row] of this.store) if (row.userId === userId) {
|
|
941
|
+
this.store.delete(key);
|
|
942
|
+
removed++;
|
|
943
|
+
}
|
|
944
|
+
return removed;
|
|
876
945
|
}
|
|
877
946
|
};
|
|
947
|
+
/** Null-byte separator can't appear in a provider id / IdP subject, so the join is unambiguous. */
|
|
948
|
+
function compositeKey(provider, subject) {
|
|
949
|
+
return `${provider}${subject}`;
|
|
950
|
+
}
|
|
878
951
|
//#endregion
|
|
879
952
|
//#region src/password/policies.ts
|
|
880
953
|
const ppHasMinLength = (min = 8) => definePasswordPolicy({
|
|
@@ -914,29 +987,57 @@ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
|
|
|
914
987
|
errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
|
|
915
988
|
});
|
|
916
989
|
//#endregion
|
|
990
|
+
//#region src/mfa/codes.ts
|
|
991
|
+
/**
|
|
992
|
+
* SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
|
|
993
|
+
*
|
|
994
|
+
* Hex-encoded for stable, comparable output regardless of input case/format.
|
|
995
|
+
* Use {@link verifyMfaCode} to compare a submitted plaintext code against the
|
|
996
|
+
* stored hash in constant time.
|
|
997
|
+
*/
|
|
998
|
+
function hashMfaCode(code) {
|
|
999
|
+
return (0, node_crypto.createHash)("sha256").update(code).digest("hex");
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Constant-time comparison of a submitted plaintext code against an
|
|
1003
|
+
* expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
|
|
1004
|
+
*
|
|
1005
|
+
* Returns false for malformed/empty expected hashes (timingSafeEqual
|
|
1006
|
+
* requires equal-length, non-empty buffers).
|
|
1007
|
+
*/
|
|
1008
|
+
function verifyMfaCode(submitted, expectedHash) {
|
|
1009
|
+
if (!expectedHash) return false;
|
|
1010
|
+
const a = Buffer.from(hashMfaCode(submitted), "hex");
|
|
1011
|
+
const b = Buffer.from(expectedHash, "hex");
|
|
1012
|
+
if (a.length !== b.length) return false;
|
|
1013
|
+
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
1014
|
+
}
|
|
1015
|
+
//#endregion
|
|
1016
|
+
exports.FederatedIdentityStore = require_federated_identity_store.FederatedIdentityStore;
|
|
1017
|
+
exports.FederatedIdentityStoreMemory = FederatedIdentityStoreMemory;
|
|
917
1018
|
exports.PasswordHasher = PasswordHasher;
|
|
918
1019
|
exports.PasswordPolicy = PasswordPolicy;
|
|
919
|
-
exports.UserAuthError =
|
|
1020
|
+
exports.UserAuthError = require_federated_identity_store.UserAuthError;
|
|
920
1021
|
exports.UserService = UserService;
|
|
921
|
-
exports.UserStore =
|
|
1022
|
+
exports.UserStore = require_federated_identity_store.UserStore;
|
|
922
1023
|
exports.UserStoreMemory = UserStoreMemory;
|
|
923
1024
|
exports.definePasswordPolicy = definePasswordPolicy;
|
|
924
|
-
exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
|
|
925
1025
|
exports.generateMfaCode = generateMfaCode;
|
|
926
1026
|
exports.generateTotpCode = generateTotpCode;
|
|
927
1027
|
exports.generateTotpSecret = generateTotpSecret;
|
|
928
1028
|
exports.generateTotpUri = generateTotpUri;
|
|
929
1029
|
exports.hashMfaCode = hashMfaCode;
|
|
930
|
-
exports.maskEmail =
|
|
931
|
-
exports.maskMfaValue =
|
|
932
|
-
exports.maskPhone =
|
|
1030
|
+
exports.maskEmail = require_federated_identity_store.maskEmail;
|
|
1031
|
+
exports.maskMfaValue = require_federated_identity_store.maskMfaValue;
|
|
1032
|
+
exports.maskPhone = require_federated_identity_store.maskPhone;
|
|
933
1033
|
exports.normalizePolicies = normalizePolicies;
|
|
1034
|
+
exports.pickDefinedProfile = require_federated_identity_store.pickDefinedProfile;
|
|
934
1035
|
exports.ppHasLowerCase = ppHasLowerCase;
|
|
935
1036
|
exports.ppHasMinLength = ppHasMinLength;
|
|
936
1037
|
exports.ppHasNumber = ppHasNumber;
|
|
937
1038
|
exports.ppHasSpecialChar = ppHasSpecialChar;
|
|
938
1039
|
exports.ppHasUpperCase = ppHasUpperCase;
|
|
939
1040
|
exports.ppMaxRepeatedChars = ppMaxRepeatedChars;
|
|
940
|
-
exports.setAtPath =
|
|
1041
|
+
exports.setAtPath = require_federated_identity_store.setAtPath;
|
|
941
1042
|
exports.verifyMfaCode = verifyMfaCode;
|
|
942
1043
|
exports.verifyTotpCode = verifyTotpCode;
|