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