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