@aooth/user 0.1.18 → 0.1.20
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.d.cts +1 -1
- package/dist/atscript-db.d.mts +1 -1
- package/dist/{federated-identity-store-CI7Vgllp.d.cts → federated-identity-store-I0uyqf2M.d.cts} +29 -3
- package/dist/{federated-identity-store-CI7Vgllp.d.mts → federated-identity-store-I0uyqf2M.d.mts} +29 -3
- package/dist/index.cjs +190 -19
- package/dist/index.d.cts +100 -3
- package/dist/index.d.mts +100 -3
- package/dist/index.mjs +190 -20
- package/package.json +7 -7
- package/src/atscript-db/user-credentials.as +12 -0
- package/src/atscript-db/user-credentials.as.d.ts +8 -0
package/dist/atscript-db.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-
|
|
1
|
+
import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-I0uyqf2M.cjs";
|
|
2
2
|
|
|
3
3
|
//#region src/atscript-db/federated-identity-store.d.ts
|
|
4
4
|
/**
|
package/dist/atscript-db.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-
|
|
1
|
+
import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-I0uyqf2M.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/atscript-db/federated-identity-store.d.ts
|
|
4
4
|
/**
|
package/dist/{federated-identity-store-CI7Vgllp.d.cts → federated-identity-store-I0uyqf2M.d.cts}
RENAMED
|
@@ -19,6 +19,16 @@ interface UserCredentials {
|
|
|
19
19
|
* Absent when the user has never opted in.
|
|
20
20
|
*/
|
|
21
21
|
trustedDevices?: TrustedDeviceRecord[];
|
|
22
|
+
/**
|
|
23
|
+
* Recognition ledger — devices that completed a login. Used purely to
|
|
24
|
+
* suppress the "new sign-in" notification; carries NO security bypass
|
|
25
|
+
* (unlike `trustedDevices`, which skips MFA). Same record shape as trust
|
|
26
|
+
* (`ip` stays unused for recognition). Capped + LRU-evicted by most-recent
|
|
27
|
+
* verification — `expiresAt` is the LRU key, since verification slides it.
|
|
28
|
+
* Managed by `UserService.{issue,add,verify,list}SeenDevice` /
|
|
29
|
+
* `revokeSeenDevices`.
|
|
30
|
+
*/
|
|
31
|
+
seenDevices?: TrustedDeviceRecord[];
|
|
22
32
|
}
|
|
23
33
|
interface TrustedDeviceRecord {
|
|
24
34
|
/** `<raw>.<sig>` — what we hand back to the consumer and what they round-trip. */
|
|
@@ -55,6 +65,14 @@ interface AccountData {
|
|
|
55
65
|
* the invite has been accepted.
|
|
56
66
|
*/
|
|
57
67
|
pendingInvitation?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Correspondence address whose inbox the user PROVED ownership of — written
|
|
70
|
+
* at every inbox-proof moment (invite magic-link click, signup / recovery
|
|
71
|
+
* OTP, email-channel confirm, trusted federated profile). Auth-owned; NOT a
|
|
72
|
+
* login handle (no uniqueness, never used for account resolution). Read
|
|
73
|
+
* through `UserService.getCorrespondenceEmail`.
|
|
74
|
+
*/
|
|
75
|
+
verifiedEmail?: string;
|
|
58
76
|
}
|
|
59
77
|
interface MfaData {
|
|
60
78
|
/** Registered MFA methods */
|
|
@@ -85,12 +103,20 @@ interface UserServiceConfig {
|
|
|
85
103
|
clock?: () => number;
|
|
86
104
|
/**
|
|
87
105
|
* Device-trust config. Required (with a non-empty `secret`) when any
|
|
88
|
-
* `issueTrustedDevice` / `verifyTrustedDevice` API
|
|
89
|
-
*
|
|
106
|
+
* `issueTrustedDevice` / `verifyTrustedDevice` API — or any `*SeenDevice`
|
|
107
|
+
* recognition API, which reuses the same secret with a domain-separated
|
|
108
|
+
* payload — is called; the methods throw clearly when invoked without it.
|
|
90
109
|
*/
|
|
91
110
|
deviceTrust?: {
|
|
92
|
-
/** HMAC-SHA256 signing secret for trust-device tokens. */secret: string;
|
|
111
|
+
/** HMAC-SHA256 signing secret for trust-device and seen-device (recognition) tokens. */secret: string;
|
|
93
112
|
};
|
|
113
|
+
/**
|
|
114
|
+
* Name of the consumer's `@aooth.user.email`-annotated column (resolved at
|
|
115
|
+
* boot by `getAoothUserHandleSpec` in arbac-moost and threaded here as
|
|
116
|
+
* plain config). When set, `getCorrespondenceEmail` prefers that column's
|
|
117
|
+
* value over `account.verifiedEmail` and the confirmed email MFA method.
|
|
118
|
+
*/
|
|
119
|
+
emailField?: string;
|
|
94
120
|
}
|
|
95
121
|
interface PasswordConfig {
|
|
96
122
|
/** Pepper string prepended to password before hashing */
|
package/dist/{federated-identity-store-CI7Vgllp.d.mts → federated-identity-store-I0uyqf2M.d.mts}
RENAMED
|
@@ -19,6 +19,16 @@ interface UserCredentials {
|
|
|
19
19
|
* Absent when the user has never opted in.
|
|
20
20
|
*/
|
|
21
21
|
trustedDevices?: TrustedDeviceRecord[];
|
|
22
|
+
/**
|
|
23
|
+
* Recognition ledger — devices that completed a login. Used purely to
|
|
24
|
+
* suppress the "new sign-in" notification; carries NO security bypass
|
|
25
|
+
* (unlike `trustedDevices`, which skips MFA). Same record shape as trust
|
|
26
|
+
* (`ip` stays unused for recognition). Capped + LRU-evicted by most-recent
|
|
27
|
+
* verification — `expiresAt` is the LRU key, since verification slides it.
|
|
28
|
+
* Managed by `UserService.{issue,add,verify,list}SeenDevice` /
|
|
29
|
+
* `revokeSeenDevices`.
|
|
30
|
+
*/
|
|
31
|
+
seenDevices?: TrustedDeviceRecord[];
|
|
22
32
|
}
|
|
23
33
|
interface TrustedDeviceRecord {
|
|
24
34
|
/** `<raw>.<sig>` — what we hand back to the consumer and what they round-trip. */
|
|
@@ -55,6 +65,14 @@ interface AccountData {
|
|
|
55
65
|
* the invite has been accepted.
|
|
56
66
|
*/
|
|
57
67
|
pendingInvitation?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Correspondence address whose inbox the user PROVED ownership of — written
|
|
70
|
+
* at every inbox-proof moment (invite magic-link click, signup / recovery
|
|
71
|
+
* OTP, email-channel confirm, trusted federated profile). Auth-owned; NOT a
|
|
72
|
+
* login handle (no uniqueness, never used for account resolution). Read
|
|
73
|
+
* through `UserService.getCorrespondenceEmail`.
|
|
74
|
+
*/
|
|
75
|
+
verifiedEmail?: string;
|
|
58
76
|
}
|
|
59
77
|
interface MfaData {
|
|
60
78
|
/** Registered MFA methods */
|
|
@@ -85,12 +103,20 @@ interface UserServiceConfig {
|
|
|
85
103
|
clock?: () => number;
|
|
86
104
|
/**
|
|
87
105
|
* Device-trust config. Required (with a non-empty `secret`) when any
|
|
88
|
-
* `issueTrustedDevice` / `verifyTrustedDevice` API
|
|
89
|
-
*
|
|
106
|
+
* `issueTrustedDevice` / `verifyTrustedDevice` API — or any `*SeenDevice`
|
|
107
|
+
* recognition API, which reuses the same secret with a domain-separated
|
|
108
|
+
* payload — is called; the methods throw clearly when invoked without it.
|
|
90
109
|
*/
|
|
91
110
|
deviceTrust?: {
|
|
92
|
-
/** HMAC-SHA256 signing secret for trust-device tokens. */secret: string;
|
|
111
|
+
/** HMAC-SHA256 signing secret for trust-device and seen-device (recognition) tokens. */secret: string;
|
|
93
112
|
};
|
|
113
|
+
/**
|
|
114
|
+
* Name of the consumer's `@aooth.user.email`-annotated column (resolved at
|
|
115
|
+
* boot by `getAoothUserHandleSpec` in arbac-moost and threaded here as
|
|
116
|
+
* plain config). When set, `getCorrespondenceEmail` prefers that column's
|
|
117
|
+
* value over `account.verifiedEmail` and the confirmed email MFA method.
|
|
118
|
+
*/
|
|
119
|
+
emailField?: string;
|
|
94
120
|
}
|
|
95
121
|
interface PasswordConfig {
|
|
96
122
|
/** Pepper string prepended to password before hashing */
|
package/dist/index.cjs
CHANGED
|
@@ -293,14 +293,30 @@ function resolveConfig(config) {
|
|
|
293
293
|
duration: config?.lockout?.duration ?? 0
|
|
294
294
|
},
|
|
295
295
|
clock: config?.clock ?? Date.now,
|
|
296
|
-
...config?.deviceTrust && { deviceTrust: config.deviceTrust }
|
|
296
|
+
...config?.deviceTrust && { deviceTrust: config.deviceTrust },
|
|
297
|
+
...config?.emailField && { emailField: config.emailField }
|
|
297
298
|
};
|
|
298
299
|
}
|
|
299
300
|
const DEVICE_TRUST_TOKEN_BYTES = 32;
|
|
300
301
|
const DEVICE_TRUST_SEPARATOR = ".";
|
|
302
|
+
const SEEN_DEVICES_DEFAULT_CAP = 5;
|
|
301
303
|
function signDeviceTrust(secret, payload) {
|
|
302
304
|
return (0, node_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
|
|
303
305
|
}
|
|
306
|
+
function trustedDevicePayload(userId, raw, ip) {
|
|
307
|
+
return `${userId}|${raw}|${ip ?? ""}`;
|
|
308
|
+
}
|
|
309
|
+
function seenDevicePayload(userId, raw) {
|
|
310
|
+
return `seen|${userId}|${raw}`;
|
|
311
|
+
}
|
|
312
|
+
function parseDeviceTrustToken(token) {
|
|
313
|
+
const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
|
|
314
|
+
if (sepIdx <= 0) return void 0;
|
|
315
|
+
return {
|
|
316
|
+
raw: token.slice(0, sepIdx),
|
|
317
|
+
sig: token.slice(sepIdx + 1)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
304
320
|
function deviceTrustSafeEqual(a, b) {
|
|
305
321
|
const ab = Buffer.from(a);
|
|
306
322
|
const bb = Buffer.from(b);
|
|
@@ -451,8 +467,15 @@ var UserService = class {
|
|
|
451
467
|
if (!await this.store.update(id, { set: patch })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
452
468
|
return this.getUser(id);
|
|
453
469
|
}
|
|
454
|
-
|
|
455
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Flip the account active. `opts.verifiedEmail` lets callers that just
|
|
472
|
+
* proved an inbox (invite magic link, signup OTP) record the correspondence
|
|
473
|
+
* address in the same store write — see {@link setVerifiedEmail}.
|
|
474
|
+
*/
|
|
475
|
+
async activateAccount(id, opts) {
|
|
476
|
+
const account = { active: true };
|
|
477
|
+
if (opts?.verifiedEmail !== void 0) account.verifiedEmail = opts.verifiedEmail;
|
|
478
|
+
if (!await this.store.update(id, { set: { account } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
456
479
|
}
|
|
457
480
|
async deactivateAccount(id) {
|
|
458
481
|
if (!await this.store.update(id, { set: { account: { active: false } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
@@ -638,17 +661,7 @@ var UserService = class {
|
|
|
638
661
|
* `addTrustedDevice`). Throws when `deviceTrust.secret` is unset.
|
|
639
662
|
*/
|
|
640
663
|
issueTrustedDevice(userId, opts) {
|
|
641
|
-
|
|
642
|
-
const raw = (0, node_crypto.randomBytes)(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
|
|
643
|
-
const sig = signDeviceTrust(secret, `${userId}|${raw}|${opts.ip ?? ""}`);
|
|
644
|
-
const now = this.config.clock();
|
|
645
|
-
return {
|
|
646
|
-
token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
|
|
647
|
-
...opts.ip !== void 0 && { ip: opts.ip },
|
|
648
|
-
issuedAt: now,
|
|
649
|
-
expiresAt: now + opts.ttlMs,
|
|
650
|
-
...opts.name !== void 0 && { name: opts.name }
|
|
651
|
-
};
|
|
664
|
+
return this.mintDeviceRecord((raw) => trustedDevicePayload(userId, raw, opts.ip), opts);
|
|
652
665
|
}
|
|
653
666
|
/**
|
|
654
667
|
* Append a trust record to the user's `trustedDevices` list. Read-modify-
|
|
@@ -666,11 +679,7 @@ var UserService = class {
|
|
|
666
679
|
* within its expiry window and whose bound IP (if any) matches.
|
|
667
680
|
*/
|
|
668
681
|
async verifyTrustedDevice(userId, token, ip) {
|
|
669
|
-
|
|
670
|
-
const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
|
|
671
|
-
if (sepIdx <= 0) return false;
|
|
672
|
-
const raw = token.slice(0, sepIdx);
|
|
673
|
-
if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${userId}|${raw}|${ip ?? ""}`))) return false;
|
|
682
|
+
if (!this.checkDeviceTokenSig(token, (raw) => trustedDevicePayload(userId, raw, ip))) return false;
|
|
674
683
|
const user = await this.store.findById(userId);
|
|
675
684
|
if (!user) return false;
|
|
676
685
|
const list = user.trustedDevices ?? [];
|
|
@@ -700,6 +709,167 @@ var UserService = class {
|
|
|
700
709
|
if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
|
|
701
710
|
return secret;
|
|
702
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Shared mint core for trust + recognition records — the HMAC domain is set
|
|
714
|
+
* by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
|
|
715
|
+
*/
|
|
716
|
+
mintDeviceRecord(payloadFor, opts) {
|
|
717
|
+
const secret = this.requireDeviceTrustSecret();
|
|
718
|
+
const raw = (0, node_crypto.randomBytes)(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
|
|
719
|
+
const sig = signDeviceTrust(secret, payloadFor(raw));
|
|
720
|
+
const now = this.config.clock();
|
|
721
|
+
return {
|
|
722
|
+
token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
|
|
723
|
+
...opts.ip !== void 0 && { ip: opts.ip },
|
|
724
|
+
issuedAt: now,
|
|
725
|
+
expiresAt: now + opts.ttlMs,
|
|
726
|
+
...opts.name !== void 0 && { name: opts.name }
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Parse + constant-time HMAC check of a device token. Only the stateless
|
|
731
|
+
* half of verification — the persisted-record (expiry/IP) check stays with
|
|
732
|
+
* the caller. Throws only on a missing secret.
|
|
733
|
+
*/
|
|
734
|
+
checkDeviceTokenSig(token, payloadFor) {
|
|
735
|
+
const secret = this.requireDeviceTrustSecret();
|
|
736
|
+
const parsed = parseDeviceTrustToken(token);
|
|
737
|
+
if (!parsed) return false;
|
|
738
|
+
return deviceTrustSafeEqual(parsed.sig, signDeviceTrust(secret, payloadFor(parsed.raw)));
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Mint a freshly-signed recognition record (does NOT persist — pair with
|
|
742
|
+
* `addSeenDevice`). No IP binding — recognition is pure noise control.
|
|
743
|
+
* Throws when `deviceTrust.secret` is unset.
|
|
744
|
+
*/
|
|
745
|
+
issueSeenDevice(userId, opts) {
|
|
746
|
+
return this.mintDeviceRecord((raw) => seenDevicePayload(userId, raw), opts);
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Append a recognition record to the user's `seenDevices` ledger and enforce
|
|
750
|
+
* the cap (default 5): expired records are dropped first, then the
|
|
751
|
+
* least-recently-verified records (smallest `expiresAt` — verification
|
|
752
|
+
* slides it, so it doubles as the LRU key) are evicted until at cap.
|
|
753
|
+
* Read-modify-write under CAS — the array shape is preserved end-to-end so
|
|
754
|
+
* DB adapters with a merge strategy replace the whole array.
|
|
755
|
+
*/
|
|
756
|
+
async addSeenDevice(id, record, opts) {
|
|
757
|
+
const cap = opts?.cap ?? 5;
|
|
758
|
+
await this.store.withCas(id, (user) => {
|
|
759
|
+
let next = [...user.seenDevices ?? [], record];
|
|
760
|
+
if (next.length > cap) {
|
|
761
|
+
const now = this.config.clock();
|
|
762
|
+
next = next.filter((r) => r.expiresAt > now);
|
|
763
|
+
if (next.length > cap) {
|
|
764
|
+
next.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
765
|
+
next = next.slice(next.length - cap);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return { set: { seenDevices: next } };
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Returns true when the supplied token signs against the user with the
|
|
773
|
+
* configured secret (recognition domain) AND matches a persisted
|
|
774
|
+
* `seenDevices` record still within its expiry window. On a valid hit with
|
|
775
|
+
* `opts.slideTtlMs` set, the record's `expiresAt` is slid to
|
|
776
|
+
* `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
|
|
777
|
+
* token (only on a missing secret, mirroring `verifyTrustedDevice`).
|
|
778
|
+
*/
|
|
779
|
+
async verifySeenDevice(userId, token, opts) {
|
|
780
|
+
if (!this.checkDeviceTokenSig(token, (raw) => seenDevicePayload(userId, raw))) return false;
|
|
781
|
+
const slideTtlMs = opts?.slideTtlMs;
|
|
782
|
+
if (slideTtlMs === void 0) {
|
|
783
|
+
const user = await this.store.findById(userId);
|
|
784
|
+
if (!user) return false;
|
|
785
|
+
const now = this.config.clock();
|
|
786
|
+
return (user.seenDevices ?? []).some((r) => r.token === token && r.expiresAt > now);
|
|
787
|
+
}
|
|
788
|
+
let matched = false;
|
|
789
|
+
try {
|
|
790
|
+
await this.store.withCas(userId, (current) => {
|
|
791
|
+
matched = false;
|
|
792
|
+
const list = current.seenDevices ?? [];
|
|
793
|
+
const now = this.config.clock();
|
|
794
|
+
const idx = list.findIndex((r) => r.token === token && r.expiresAt > now);
|
|
795
|
+
if (idx === -1) return null;
|
|
796
|
+
matched = true;
|
|
797
|
+
const next = [...list];
|
|
798
|
+
next[idx] = {
|
|
799
|
+
...next[idx],
|
|
800
|
+
expiresAt: now + slideTtlMs
|
|
801
|
+
};
|
|
802
|
+
return { set: { seenDevices: next } };
|
|
803
|
+
});
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if (error instanceof require_federated_identity_store.UserAuthError && error.type === "NOT_FOUND") return false;
|
|
806
|
+
throw error;
|
|
807
|
+
}
|
|
808
|
+
return matched;
|
|
809
|
+
}
|
|
810
|
+
async listSeenDevices(id) {
|
|
811
|
+
return (await this.getUser(id)).seenDevices ?? [];
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Clear the whole recognition ledger. Unconditional single write — `update`
|
|
815
|
+
* already no-ops on a missing row, and an empty `seenDevices` is equivalent
|
|
816
|
+
* to an absent one everywhere it is read.
|
|
817
|
+
*/
|
|
818
|
+
async revokeSeenDevices(id) {
|
|
819
|
+
await this.store.update(id, { set: { seenDevices: [] } });
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* True when `deviceTrust.secret` is configured — lets the workflow layer
|
|
823
|
+
* skip device recognition entirely (degrade gracefully) instead of throwing.
|
|
824
|
+
*/
|
|
825
|
+
hasDeviceTrustSecret() {
|
|
826
|
+
return !!this.config.deviceTrust?.secret;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Record `account.verifiedEmail` — the correspondence address whose inbox
|
|
830
|
+
* the user just PROVED (invite magic-link click, signup / recovery OTP,
|
|
831
|
+
* email-channel confirm, trusted federated profile). Plain write — a later
|
|
832
|
+
* proof for a different address overwrites the previous one. Throws
|
|
833
|
+
* `UserAuthError("NOT_FOUND")` when no row matches, like the other account
|
|
834
|
+
* mutators.
|
|
835
|
+
*/
|
|
836
|
+
async setVerifiedEmail(id, email) {
|
|
837
|
+
if (!await this.store.update(id, { set: { account: { verifiedEmail: email } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Resolve the address security notices should go to — a pure accessor over
|
|
841
|
+
* a row the caller already holds (no store round-trip). PROVEN-first chain:
|
|
842
|
+
* 1. `account.verifiedEmail` — the auth-proven capture written by
|
|
843
|
+
* {@link setVerifiedEmail} at inbox-proof moments;
|
|
844
|
+
* 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
|
|
845
|
+
* (pre-capture legacy rows; going forward the confirm step writes
|
|
846
|
+
* `verifiedEmail` too, so this level fades to a fallback);
|
|
847
|
+
* 3. the consumer's email column (`config.emailField`, the
|
|
848
|
+
* `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
|
|
849
|
+
* (an admin-typed address proves nothing about the inbox).
|
|
850
|
+
* Returns `undefined` when none yields a non-empty address.
|
|
851
|
+
*
|
|
852
|
+
* Proven-first is deliberate for SECURITY notices: when an email changes,
|
|
853
|
+
* the notice should reach the previously-proven inbox, not an address
|
|
854
|
+
* nobody has demonstrated control of (mirrors the standard
|
|
855
|
+
* "your-email-was-changed goes to the OLD address" posture). Deployments
|
|
856
|
+
* that want app-canonical-first override this method.
|
|
857
|
+
*
|
|
858
|
+
* OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
|
|
859
|
+
* table, a tenant directory, an external CRM) — the return type admits
|
|
860
|
+
* `Promise` so an override can hit a store; the default stays sync (no
|
|
861
|
+
* Promise allocation). Callers `await` the result.
|
|
862
|
+
*/
|
|
863
|
+
getCorrespondenceEmail(user) {
|
|
864
|
+
if (user.account.verifiedEmail) return user.account.verifiedEmail;
|
|
865
|
+
const mfaEmail = user.mfa.methods.find((m) => m.name === "email" && m.confirmed && m.value);
|
|
866
|
+
if (mfaEmail) return mfaEmail.value;
|
|
867
|
+
const emailField = this.config.emailField;
|
|
868
|
+
if (emailField) {
|
|
869
|
+
const value = user[emailField];
|
|
870
|
+
if (typeof value === "string" && value) return value;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
703
873
|
async applyPasswordChange(id, user, newPassword) {
|
|
704
874
|
const policyResult = await this.checkPolicies(newPassword, user.password);
|
|
705
875
|
if (!policyResult.passed) throw new require_federated_identity_store.UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
|
|
@@ -1019,6 +1189,7 @@ exports.FederatedIdentityStore = require_federated_identity_store.FederatedIdent
|
|
|
1019
1189
|
exports.FederatedIdentityStoreMemory = FederatedIdentityStoreMemory;
|
|
1020
1190
|
exports.PasswordHasher = PasswordHasher;
|
|
1021
1191
|
exports.PasswordPolicy = PasswordPolicy;
|
|
1192
|
+
exports.SEEN_DEVICES_DEFAULT_CAP = SEEN_DEVICES_DEFAULT_CAP;
|
|
1022
1193
|
exports.UserAuthError = require_federated_identity_store.UserAuthError;
|
|
1023
1194
|
exports.UserService = UserService;
|
|
1024
1195
|
exports.UserStore = require_federated_identity_store.UserStore;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-
|
|
1
|
+
import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-I0uyqf2M.cjs";
|
|
2
2
|
|
|
3
3
|
//#region src/errors.d.ts
|
|
4
4
|
declare class UserAuthError extends Error {
|
|
@@ -70,7 +70,9 @@ interface ResolvedConfig {
|
|
|
70
70
|
deviceTrust?: {
|
|
71
71
|
secret: string;
|
|
72
72
|
};
|
|
73
|
+
emailField?: string;
|
|
73
74
|
}
|
|
75
|
+
declare const SEEN_DEVICES_DEFAULT_CAP = 5;
|
|
74
76
|
/**
|
|
75
77
|
* Orchestrates user credentials over a pluggable {@link UserStore}.
|
|
76
78
|
*
|
|
@@ -138,7 +140,14 @@ declare class UserService<T extends object = object> {
|
|
|
138
140
|
* Used by the invite workflow's `applyProfile` default fallback.
|
|
139
141
|
*/
|
|
140
142
|
update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
|
|
141
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Flip the account active. `opts.verifiedEmail` lets callers that just
|
|
145
|
+
* proved an inbox (invite magic link, signup OTP) record the correspondence
|
|
146
|
+
* address in the same store write — see {@link setVerifiedEmail}.
|
|
147
|
+
*/
|
|
148
|
+
activateAccount(id: string, opts?: {
|
|
149
|
+
verifiedEmail?: string;
|
|
150
|
+
}): Promise<void>;
|
|
142
151
|
deactivateAccount(id: string): Promise<void>;
|
|
143
152
|
lockAccount(id: string, reason: string, duration?: number): Promise<void>;
|
|
144
153
|
unlockAccount(id: string): Promise<void>;
|
|
@@ -213,6 +222,94 @@ declare class UserService<T extends object = object> {
|
|
|
213
222
|
revokeTrustedDevice(id: string, token: string): Promise<void>;
|
|
214
223
|
listTrustedDevices(id: string): Promise<TrustedDeviceRecord[]>;
|
|
215
224
|
private requireDeviceTrustSecret;
|
|
225
|
+
/**
|
|
226
|
+
* Shared mint core for trust + recognition records — the HMAC domain is set
|
|
227
|
+
* by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
|
|
228
|
+
*/
|
|
229
|
+
private mintDeviceRecord;
|
|
230
|
+
/**
|
|
231
|
+
* Parse + constant-time HMAC check of a device token. Only the stateless
|
|
232
|
+
* half of verification — the persisted-record (expiry/IP) check stays with
|
|
233
|
+
* the caller. Throws only on a missing secret.
|
|
234
|
+
*/
|
|
235
|
+
private checkDeviceTokenSig;
|
|
236
|
+
/**
|
|
237
|
+
* Mint a freshly-signed recognition record (does NOT persist — pair with
|
|
238
|
+
* `addSeenDevice`). No IP binding — recognition is pure noise control.
|
|
239
|
+
* Throws when `deviceTrust.secret` is unset.
|
|
240
|
+
*/
|
|
241
|
+
issueSeenDevice(userId: string, opts: {
|
|
242
|
+
ttlMs: number;
|
|
243
|
+
name?: string;
|
|
244
|
+
}): TrustedDeviceRecord;
|
|
245
|
+
/**
|
|
246
|
+
* Append a recognition record to the user's `seenDevices` ledger and enforce
|
|
247
|
+
* the cap (default 5): expired records are dropped first, then the
|
|
248
|
+
* least-recently-verified records (smallest `expiresAt` — verification
|
|
249
|
+
* slides it, so it doubles as the LRU key) are evicted until at cap.
|
|
250
|
+
* Read-modify-write under CAS — the array shape is preserved end-to-end so
|
|
251
|
+
* DB adapters with a merge strategy replace the whole array.
|
|
252
|
+
*/
|
|
253
|
+
addSeenDevice(id: string, record: TrustedDeviceRecord, opts?: {
|
|
254
|
+
cap?: number;
|
|
255
|
+
}): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* Returns true when the supplied token signs against the user with the
|
|
258
|
+
* configured secret (recognition domain) AND matches a persisted
|
|
259
|
+
* `seenDevices` record still within its expiry window. On a valid hit with
|
|
260
|
+
* `opts.slideTtlMs` set, the record's `expiresAt` is slid to
|
|
261
|
+
* `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
|
|
262
|
+
* token (only on a missing secret, mirroring `verifyTrustedDevice`).
|
|
263
|
+
*/
|
|
264
|
+
verifySeenDevice(userId: string, token: string, opts?: {
|
|
265
|
+
slideTtlMs?: number;
|
|
266
|
+
}): Promise<boolean>;
|
|
267
|
+
listSeenDevices(id: string): Promise<TrustedDeviceRecord[]>;
|
|
268
|
+
/**
|
|
269
|
+
* Clear the whole recognition ledger. Unconditional single write — `update`
|
|
270
|
+
* already no-ops on a missing row, and an empty `seenDevices` is equivalent
|
|
271
|
+
* to an absent one everywhere it is read.
|
|
272
|
+
*/
|
|
273
|
+
revokeSeenDevices(id: string): Promise<void>;
|
|
274
|
+
/**
|
|
275
|
+
* True when `deviceTrust.secret` is configured — lets the workflow layer
|
|
276
|
+
* skip device recognition entirely (degrade gracefully) instead of throwing.
|
|
277
|
+
*/
|
|
278
|
+
hasDeviceTrustSecret(): boolean;
|
|
279
|
+
/**
|
|
280
|
+
* Record `account.verifiedEmail` — the correspondence address whose inbox
|
|
281
|
+
* the user just PROVED (invite magic-link click, signup / recovery OTP,
|
|
282
|
+
* email-channel confirm, trusted federated profile). Plain write — a later
|
|
283
|
+
* proof for a different address overwrites the previous one. Throws
|
|
284
|
+
* `UserAuthError("NOT_FOUND")` when no row matches, like the other account
|
|
285
|
+
* mutators.
|
|
286
|
+
*/
|
|
287
|
+
setVerifiedEmail(id: string, email: string): Promise<void>;
|
|
288
|
+
/**
|
|
289
|
+
* Resolve the address security notices should go to — a pure accessor over
|
|
290
|
+
* a row the caller already holds (no store round-trip). PROVEN-first chain:
|
|
291
|
+
* 1. `account.verifiedEmail` — the auth-proven capture written by
|
|
292
|
+
* {@link setVerifiedEmail} at inbox-proof moments;
|
|
293
|
+
* 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
|
|
294
|
+
* (pre-capture legacy rows; going forward the confirm step writes
|
|
295
|
+
* `verifiedEmail` too, so this level fades to a fallback);
|
|
296
|
+
* 3. the consumer's email column (`config.emailField`, the
|
|
297
|
+
* `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
|
|
298
|
+
* (an admin-typed address proves nothing about the inbox).
|
|
299
|
+
* Returns `undefined` when none yields a non-empty address.
|
|
300
|
+
*
|
|
301
|
+
* Proven-first is deliberate for SECURITY notices: when an email changes,
|
|
302
|
+
* the notice should reach the previously-proven inbox, not an address
|
|
303
|
+
* nobody has demonstrated control of (mirrors the standard
|
|
304
|
+
* "your-email-was-changed goes to the OLD address" posture). Deployments
|
|
305
|
+
* that want app-canonical-first override this method.
|
|
306
|
+
*
|
|
307
|
+
* OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
|
|
308
|
+
* table, a tenant directory, an external CRM) — the return type admits
|
|
309
|
+
* `Promise` so an override can hit a store; the default stays sync (no
|
|
310
|
+
* Promise allocation). Callers `await` the result.
|
|
311
|
+
*/
|
|
312
|
+
getCorrespondenceEmail(user: UserCredentials & T): string | undefined | Promise<string | undefined>;
|
|
216
313
|
private applyPasswordChange;
|
|
217
314
|
private hasConfirmedMfaMethods;
|
|
218
315
|
/**
|
|
@@ -343,4 +440,4 @@ declare function maskPhone(phone: string): string;
|
|
|
343
440
|
declare function maskMfaValue(method: MfaMethod): string;
|
|
344
441
|
declare function setAtPath(obj: object, path: string, value: unknown): void;
|
|
345
442
|
//#endregion
|
|
346
|
-
export { type AccountData, type DeepPartial, type FederatedIdentity, FederatedIdentityStore, FederatedIdentityStoreMemory, type FederatedIdentityStoreMemoryOptions, type FederatedProfileSnapshot, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type NewFederatedIdentity, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
|
443
|
+
export { type AccountData, type DeepPartial, type FederatedIdentity, FederatedIdentityStore, FederatedIdentityStoreMemory, type FederatedIdentityStoreMemoryOptions, type FederatedProfileSnapshot, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type NewFederatedIdentity, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, SEEN_DEVICES_DEFAULT_CAP, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-
|
|
1
|
+
import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-I0uyqf2M.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/errors.d.ts
|
|
4
4
|
declare class UserAuthError extends Error {
|
|
@@ -70,7 +70,9 @@ interface ResolvedConfig {
|
|
|
70
70
|
deviceTrust?: {
|
|
71
71
|
secret: string;
|
|
72
72
|
};
|
|
73
|
+
emailField?: string;
|
|
73
74
|
}
|
|
75
|
+
declare const SEEN_DEVICES_DEFAULT_CAP = 5;
|
|
74
76
|
/**
|
|
75
77
|
* Orchestrates user credentials over a pluggable {@link UserStore}.
|
|
76
78
|
*
|
|
@@ -138,7 +140,14 @@ declare class UserService<T extends object = object> {
|
|
|
138
140
|
* Used by the invite workflow's `applyProfile` default fallback.
|
|
139
141
|
*/
|
|
140
142
|
update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
|
|
141
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Flip the account active. `opts.verifiedEmail` lets callers that just
|
|
145
|
+
* proved an inbox (invite magic link, signup OTP) record the correspondence
|
|
146
|
+
* address in the same store write — see {@link setVerifiedEmail}.
|
|
147
|
+
*/
|
|
148
|
+
activateAccount(id: string, opts?: {
|
|
149
|
+
verifiedEmail?: string;
|
|
150
|
+
}): Promise<void>;
|
|
142
151
|
deactivateAccount(id: string): Promise<void>;
|
|
143
152
|
lockAccount(id: string, reason: string, duration?: number): Promise<void>;
|
|
144
153
|
unlockAccount(id: string): Promise<void>;
|
|
@@ -213,6 +222,94 @@ declare class UserService<T extends object = object> {
|
|
|
213
222
|
revokeTrustedDevice(id: string, token: string): Promise<void>;
|
|
214
223
|
listTrustedDevices(id: string): Promise<TrustedDeviceRecord[]>;
|
|
215
224
|
private requireDeviceTrustSecret;
|
|
225
|
+
/**
|
|
226
|
+
* Shared mint core for trust + recognition records — the HMAC domain is set
|
|
227
|
+
* by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
|
|
228
|
+
*/
|
|
229
|
+
private mintDeviceRecord;
|
|
230
|
+
/**
|
|
231
|
+
* Parse + constant-time HMAC check of a device token. Only the stateless
|
|
232
|
+
* half of verification — the persisted-record (expiry/IP) check stays with
|
|
233
|
+
* the caller. Throws only on a missing secret.
|
|
234
|
+
*/
|
|
235
|
+
private checkDeviceTokenSig;
|
|
236
|
+
/**
|
|
237
|
+
* Mint a freshly-signed recognition record (does NOT persist — pair with
|
|
238
|
+
* `addSeenDevice`). No IP binding — recognition is pure noise control.
|
|
239
|
+
* Throws when `deviceTrust.secret` is unset.
|
|
240
|
+
*/
|
|
241
|
+
issueSeenDevice(userId: string, opts: {
|
|
242
|
+
ttlMs: number;
|
|
243
|
+
name?: string;
|
|
244
|
+
}): TrustedDeviceRecord;
|
|
245
|
+
/**
|
|
246
|
+
* Append a recognition record to the user's `seenDevices` ledger and enforce
|
|
247
|
+
* the cap (default 5): expired records are dropped first, then the
|
|
248
|
+
* least-recently-verified records (smallest `expiresAt` — verification
|
|
249
|
+
* slides it, so it doubles as the LRU key) are evicted until at cap.
|
|
250
|
+
* Read-modify-write under CAS — the array shape is preserved end-to-end so
|
|
251
|
+
* DB adapters with a merge strategy replace the whole array.
|
|
252
|
+
*/
|
|
253
|
+
addSeenDevice(id: string, record: TrustedDeviceRecord, opts?: {
|
|
254
|
+
cap?: number;
|
|
255
|
+
}): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* Returns true when the supplied token signs against the user with the
|
|
258
|
+
* configured secret (recognition domain) AND matches a persisted
|
|
259
|
+
* `seenDevices` record still within its expiry window. On a valid hit with
|
|
260
|
+
* `opts.slideTtlMs` set, the record's `expiresAt` is slid to
|
|
261
|
+
* `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
|
|
262
|
+
* token (only on a missing secret, mirroring `verifyTrustedDevice`).
|
|
263
|
+
*/
|
|
264
|
+
verifySeenDevice(userId: string, token: string, opts?: {
|
|
265
|
+
slideTtlMs?: number;
|
|
266
|
+
}): Promise<boolean>;
|
|
267
|
+
listSeenDevices(id: string): Promise<TrustedDeviceRecord[]>;
|
|
268
|
+
/**
|
|
269
|
+
* Clear the whole recognition ledger. Unconditional single write — `update`
|
|
270
|
+
* already no-ops on a missing row, and an empty `seenDevices` is equivalent
|
|
271
|
+
* to an absent one everywhere it is read.
|
|
272
|
+
*/
|
|
273
|
+
revokeSeenDevices(id: string): Promise<void>;
|
|
274
|
+
/**
|
|
275
|
+
* True when `deviceTrust.secret` is configured — lets the workflow layer
|
|
276
|
+
* skip device recognition entirely (degrade gracefully) instead of throwing.
|
|
277
|
+
*/
|
|
278
|
+
hasDeviceTrustSecret(): boolean;
|
|
279
|
+
/**
|
|
280
|
+
* Record `account.verifiedEmail` — the correspondence address whose inbox
|
|
281
|
+
* the user just PROVED (invite magic-link click, signup / recovery OTP,
|
|
282
|
+
* email-channel confirm, trusted federated profile). Plain write — a later
|
|
283
|
+
* proof for a different address overwrites the previous one. Throws
|
|
284
|
+
* `UserAuthError("NOT_FOUND")` when no row matches, like the other account
|
|
285
|
+
* mutators.
|
|
286
|
+
*/
|
|
287
|
+
setVerifiedEmail(id: string, email: string): Promise<void>;
|
|
288
|
+
/**
|
|
289
|
+
* Resolve the address security notices should go to — a pure accessor over
|
|
290
|
+
* a row the caller already holds (no store round-trip). PROVEN-first chain:
|
|
291
|
+
* 1. `account.verifiedEmail` — the auth-proven capture written by
|
|
292
|
+
* {@link setVerifiedEmail} at inbox-proof moments;
|
|
293
|
+
* 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
|
|
294
|
+
* (pre-capture legacy rows; going forward the confirm step writes
|
|
295
|
+
* `verifiedEmail` too, so this level fades to a fallback);
|
|
296
|
+
* 3. the consumer's email column (`config.emailField`, the
|
|
297
|
+
* `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
|
|
298
|
+
* (an admin-typed address proves nothing about the inbox).
|
|
299
|
+
* Returns `undefined` when none yields a non-empty address.
|
|
300
|
+
*
|
|
301
|
+
* Proven-first is deliberate for SECURITY notices: when an email changes,
|
|
302
|
+
* the notice should reach the previously-proven inbox, not an address
|
|
303
|
+
* nobody has demonstrated control of (mirrors the standard
|
|
304
|
+
* "your-email-was-changed goes to the OLD address" posture). Deployments
|
|
305
|
+
* that want app-canonical-first override this method.
|
|
306
|
+
*
|
|
307
|
+
* OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
|
|
308
|
+
* table, a tenant directory, an external CRM) — the return type admits
|
|
309
|
+
* `Promise` so an override can hit a store; the default stays sync (no
|
|
310
|
+
* Promise allocation). Callers `await` the result.
|
|
311
|
+
*/
|
|
312
|
+
getCorrespondenceEmail(user: UserCredentials & T): string | undefined | Promise<string | undefined>;
|
|
216
313
|
private applyPasswordChange;
|
|
217
314
|
private hasConfirmedMfaMethods;
|
|
218
315
|
/**
|
|
@@ -343,4 +440,4 @@ declare function maskPhone(phone: string): string;
|
|
|
343
440
|
declare function maskMfaValue(method: MfaMethod): string;
|
|
344
441
|
declare function setAtPath(obj: object, path: string, value: unknown): void;
|
|
345
442
|
//#endregion
|
|
346
|
-
export { type AccountData, type DeepPartial, type FederatedIdentity, FederatedIdentityStore, FederatedIdentityStoreMemory, type FederatedIdentityStoreMemoryOptions, type FederatedProfileSnapshot, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type NewFederatedIdentity, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
|
443
|
+
export { type AccountData, type DeepPartial, type FederatedIdentity, FederatedIdentityStore, FederatedIdentityStoreMemory, type FederatedIdentityStoreMemoryOptions, type FederatedProfileSnapshot, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type NewFederatedIdentity, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, SEEN_DEVICES_DEFAULT_CAP, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
package/dist/index.mjs
CHANGED
|
@@ -292,14 +292,30 @@ function resolveConfig(config) {
|
|
|
292
292
|
duration: config?.lockout?.duration ?? 0
|
|
293
293
|
},
|
|
294
294
|
clock: config?.clock ?? Date.now,
|
|
295
|
-
...config?.deviceTrust && { deviceTrust: config.deviceTrust }
|
|
295
|
+
...config?.deviceTrust && { deviceTrust: config.deviceTrust },
|
|
296
|
+
...config?.emailField && { emailField: config.emailField }
|
|
296
297
|
};
|
|
297
298
|
}
|
|
298
299
|
const DEVICE_TRUST_TOKEN_BYTES = 32;
|
|
299
300
|
const DEVICE_TRUST_SEPARATOR = ".";
|
|
301
|
+
const SEEN_DEVICES_DEFAULT_CAP = 5;
|
|
300
302
|
function signDeviceTrust(secret, payload) {
|
|
301
303
|
return createHmac("sha256", secret).update(payload).digest("hex");
|
|
302
304
|
}
|
|
305
|
+
function trustedDevicePayload(userId, raw, ip) {
|
|
306
|
+
return `${userId}|${raw}|${ip ?? ""}`;
|
|
307
|
+
}
|
|
308
|
+
function seenDevicePayload(userId, raw) {
|
|
309
|
+
return `seen|${userId}|${raw}`;
|
|
310
|
+
}
|
|
311
|
+
function parseDeviceTrustToken(token) {
|
|
312
|
+
const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
|
|
313
|
+
if (sepIdx <= 0) return void 0;
|
|
314
|
+
return {
|
|
315
|
+
raw: token.slice(0, sepIdx),
|
|
316
|
+
sig: token.slice(sepIdx + 1)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
303
319
|
function deviceTrustSafeEqual(a, b) {
|
|
304
320
|
const ab = Buffer.from(a);
|
|
305
321
|
const bb = Buffer.from(b);
|
|
@@ -450,8 +466,15 @@ var UserService = class {
|
|
|
450
466
|
if (!await this.store.update(id, { set: patch })) throw new UserAuthError("NOT_FOUND");
|
|
451
467
|
return this.getUser(id);
|
|
452
468
|
}
|
|
453
|
-
|
|
454
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Flip the account active. `opts.verifiedEmail` lets callers that just
|
|
471
|
+
* proved an inbox (invite magic link, signup OTP) record the correspondence
|
|
472
|
+
* address in the same store write — see {@link setVerifiedEmail}.
|
|
473
|
+
*/
|
|
474
|
+
async activateAccount(id, opts) {
|
|
475
|
+
const account = { active: true };
|
|
476
|
+
if (opts?.verifiedEmail !== void 0) account.verifiedEmail = opts.verifiedEmail;
|
|
477
|
+
if (!await this.store.update(id, { set: { account } })) throw new UserAuthError("NOT_FOUND");
|
|
455
478
|
}
|
|
456
479
|
async deactivateAccount(id) {
|
|
457
480
|
if (!await this.store.update(id, { set: { account: { active: false } } })) throw new UserAuthError("NOT_FOUND");
|
|
@@ -637,17 +660,7 @@ var UserService = class {
|
|
|
637
660
|
* `addTrustedDevice`). Throws when `deviceTrust.secret` is unset.
|
|
638
661
|
*/
|
|
639
662
|
issueTrustedDevice(userId, opts) {
|
|
640
|
-
|
|
641
|
-
const raw = randomBytes(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
|
|
642
|
-
const sig = signDeviceTrust(secret, `${userId}|${raw}|${opts.ip ?? ""}`);
|
|
643
|
-
const now = this.config.clock();
|
|
644
|
-
return {
|
|
645
|
-
token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
|
|
646
|
-
...opts.ip !== void 0 && { ip: opts.ip },
|
|
647
|
-
issuedAt: now,
|
|
648
|
-
expiresAt: now + opts.ttlMs,
|
|
649
|
-
...opts.name !== void 0 && { name: opts.name }
|
|
650
|
-
};
|
|
663
|
+
return this.mintDeviceRecord((raw) => trustedDevicePayload(userId, raw, opts.ip), opts);
|
|
651
664
|
}
|
|
652
665
|
/**
|
|
653
666
|
* Append a trust record to the user's `trustedDevices` list. Read-modify-
|
|
@@ -665,11 +678,7 @@ var UserService = class {
|
|
|
665
678
|
* within its expiry window and whose bound IP (if any) matches.
|
|
666
679
|
*/
|
|
667
680
|
async verifyTrustedDevice(userId, token, ip) {
|
|
668
|
-
|
|
669
|
-
const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
|
|
670
|
-
if (sepIdx <= 0) return false;
|
|
671
|
-
const raw = token.slice(0, sepIdx);
|
|
672
|
-
if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${userId}|${raw}|${ip ?? ""}`))) return false;
|
|
681
|
+
if (!this.checkDeviceTokenSig(token, (raw) => trustedDevicePayload(userId, raw, ip))) return false;
|
|
673
682
|
const user = await this.store.findById(userId);
|
|
674
683
|
if (!user) return false;
|
|
675
684
|
const list = user.trustedDevices ?? [];
|
|
@@ -699,6 +708,167 @@ var UserService = class {
|
|
|
699
708
|
if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
|
|
700
709
|
return secret;
|
|
701
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* Shared mint core for trust + recognition records — the HMAC domain is set
|
|
713
|
+
* by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
|
|
714
|
+
*/
|
|
715
|
+
mintDeviceRecord(payloadFor, opts) {
|
|
716
|
+
const secret = this.requireDeviceTrustSecret();
|
|
717
|
+
const raw = randomBytes(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
|
|
718
|
+
const sig = signDeviceTrust(secret, payloadFor(raw));
|
|
719
|
+
const now = this.config.clock();
|
|
720
|
+
return {
|
|
721
|
+
token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
|
|
722
|
+
...opts.ip !== void 0 && { ip: opts.ip },
|
|
723
|
+
issuedAt: now,
|
|
724
|
+
expiresAt: now + opts.ttlMs,
|
|
725
|
+
...opts.name !== void 0 && { name: opts.name }
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Parse + constant-time HMAC check of a device token. Only the stateless
|
|
730
|
+
* half of verification — the persisted-record (expiry/IP) check stays with
|
|
731
|
+
* the caller. Throws only on a missing secret.
|
|
732
|
+
*/
|
|
733
|
+
checkDeviceTokenSig(token, payloadFor) {
|
|
734
|
+
const secret = this.requireDeviceTrustSecret();
|
|
735
|
+
const parsed = parseDeviceTrustToken(token);
|
|
736
|
+
if (!parsed) return false;
|
|
737
|
+
return deviceTrustSafeEqual(parsed.sig, signDeviceTrust(secret, payloadFor(parsed.raw)));
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Mint a freshly-signed recognition record (does NOT persist — pair with
|
|
741
|
+
* `addSeenDevice`). No IP binding — recognition is pure noise control.
|
|
742
|
+
* Throws when `deviceTrust.secret` is unset.
|
|
743
|
+
*/
|
|
744
|
+
issueSeenDevice(userId, opts) {
|
|
745
|
+
return this.mintDeviceRecord((raw) => seenDevicePayload(userId, raw), opts);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Append a recognition record to the user's `seenDevices` ledger and enforce
|
|
749
|
+
* the cap (default 5): expired records are dropped first, then the
|
|
750
|
+
* least-recently-verified records (smallest `expiresAt` — verification
|
|
751
|
+
* slides it, so it doubles as the LRU key) are evicted until at cap.
|
|
752
|
+
* Read-modify-write under CAS — the array shape is preserved end-to-end so
|
|
753
|
+
* DB adapters with a merge strategy replace the whole array.
|
|
754
|
+
*/
|
|
755
|
+
async addSeenDevice(id, record, opts) {
|
|
756
|
+
const cap = opts?.cap ?? 5;
|
|
757
|
+
await this.store.withCas(id, (user) => {
|
|
758
|
+
let next = [...user.seenDevices ?? [], record];
|
|
759
|
+
if (next.length > cap) {
|
|
760
|
+
const now = this.config.clock();
|
|
761
|
+
next = next.filter((r) => r.expiresAt > now);
|
|
762
|
+
if (next.length > cap) {
|
|
763
|
+
next.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
764
|
+
next = next.slice(next.length - cap);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return { set: { seenDevices: next } };
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Returns true when the supplied token signs against the user with the
|
|
772
|
+
* configured secret (recognition domain) AND matches a persisted
|
|
773
|
+
* `seenDevices` record still within its expiry window. On a valid hit with
|
|
774
|
+
* `opts.slideTtlMs` set, the record's `expiresAt` is slid to
|
|
775
|
+
* `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
|
|
776
|
+
* token (only on a missing secret, mirroring `verifyTrustedDevice`).
|
|
777
|
+
*/
|
|
778
|
+
async verifySeenDevice(userId, token, opts) {
|
|
779
|
+
if (!this.checkDeviceTokenSig(token, (raw) => seenDevicePayload(userId, raw))) return false;
|
|
780
|
+
const slideTtlMs = opts?.slideTtlMs;
|
|
781
|
+
if (slideTtlMs === void 0) {
|
|
782
|
+
const user = await this.store.findById(userId);
|
|
783
|
+
if (!user) return false;
|
|
784
|
+
const now = this.config.clock();
|
|
785
|
+
return (user.seenDevices ?? []).some((r) => r.token === token && r.expiresAt > now);
|
|
786
|
+
}
|
|
787
|
+
let matched = false;
|
|
788
|
+
try {
|
|
789
|
+
await this.store.withCas(userId, (current) => {
|
|
790
|
+
matched = false;
|
|
791
|
+
const list = current.seenDevices ?? [];
|
|
792
|
+
const now = this.config.clock();
|
|
793
|
+
const idx = list.findIndex((r) => r.token === token && r.expiresAt > now);
|
|
794
|
+
if (idx === -1) return null;
|
|
795
|
+
matched = true;
|
|
796
|
+
const next = [...list];
|
|
797
|
+
next[idx] = {
|
|
798
|
+
...next[idx],
|
|
799
|
+
expiresAt: now + slideTtlMs
|
|
800
|
+
};
|
|
801
|
+
return { set: { seenDevices: next } };
|
|
802
|
+
});
|
|
803
|
+
} catch (error) {
|
|
804
|
+
if (error instanceof UserAuthError && error.type === "NOT_FOUND") return false;
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
return matched;
|
|
808
|
+
}
|
|
809
|
+
async listSeenDevices(id) {
|
|
810
|
+
return (await this.getUser(id)).seenDevices ?? [];
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Clear the whole recognition ledger. Unconditional single write — `update`
|
|
814
|
+
* already no-ops on a missing row, and an empty `seenDevices` is equivalent
|
|
815
|
+
* to an absent one everywhere it is read.
|
|
816
|
+
*/
|
|
817
|
+
async revokeSeenDevices(id) {
|
|
818
|
+
await this.store.update(id, { set: { seenDevices: [] } });
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* True when `deviceTrust.secret` is configured — lets the workflow layer
|
|
822
|
+
* skip device recognition entirely (degrade gracefully) instead of throwing.
|
|
823
|
+
*/
|
|
824
|
+
hasDeviceTrustSecret() {
|
|
825
|
+
return !!this.config.deviceTrust?.secret;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Record `account.verifiedEmail` — the correspondence address whose inbox
|
|
829
|
+
* the user just PROVED (invite magic-link click, signup / recovery OTP,
|
|
830
|
+
* email-channel confirm, trusted federated profile). Plain write — a later
|
|
831
|
+
* proof for a different address overwrites the previous one. Throws
|
|
832
|
+
* `UserAuthError("NOT_FOUND")` when no row matches, like the other account
|
|
833
|
+
* mutators.
|
|
834
|
+
*/
|
|
835
|
+
async setVerifiedEmail(id, email) {
|
|
836
|
+
if (!await this.store.update(id, { set: { account: { verifiedEmail: email } } })) throw new UserAuthError("NOT_FOUND");
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Resolve the address security notices should go to — a pure accessor over
|
|
840
|
+
* a row the caller already holds (no store round-trip). PROVEN-first chain:
|
|
841
|
+
* 1. `account.verifiedEmail` — the auth-proven capture written by
|
|
842
|
+
* {@link setVerifiedEmail} at inbox-proof moments;
|
|
843
|
+
* 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
|
|
844
|
+
* (pre-capture legacy rows; going forward the confirm step writes
|
|
845
|
+
* `verifiedEmail` too, so this level fades to a fallback);
|
|
846
|
+
* 3. the consumer's email column (`config.emailField`, the
|
|
847
|
+
* `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
|
|
848
|
+
* (an admin-typed address proves nothing about the inbox).
|
|
849
|
+
* Returns `undefined` when none yields a non-empty address.
|
|
850
|
+
*
|
|
851
|
+
* Proven-first is deliberate for SECURITY notices: when an email changes,
|
|
852
|
+
* the notice should reach the previously-proven inbox, not an address
|
|
853
|
+
* nobody has demonstrated control of (mirrors the standard
|
|
854
|
+
* "your-email-was-changed goes to the OLD address" posture). Deployments
|
|
855
|
+
* that want app-canonical-first override this method.
|
|
856
|
+
*
|
|
857
|
+
* OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
|
|
858
|
+
* table, a tenant directory, an external CRM) — the return type admits
|
|
859
|
+
* `Promise` so an override can hit a store; the default stays sync (no
|
|
860
|
+
* Promise allocation). Callers `await` the result.
|
|
861
|
+
*/
|
|
862
|
+
getCorrespondenceEmail(user) {
|
|
863
|
+
if (user.account.verifiedEmail) return user.account.verifiedEmail;
|
|
864
|
+
const mfaEmail = user.mfa.methods.find((m) => m.name === "email" && m.confirmed && m.value);
|
|
865
|
+
if (mfaEmail) return mfaEmail.value;
|
|
866
|
+
const emailField = this.config.emailField;
|
|
867
|
+
if (emailField) {
|
|
868
|
+
const value = user[emailField];
|
|
869
|
+
if (typeof value === "string" && value) return value;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
702
872
|
async applyPasswordChange(id, user, newPassword) {
|
|
703
873
|
const policyResult = await this.checkPolicies(newPassword, user.password);
|
|
704
874
|
if (!policyResult.passed) throw new UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
|
|
@@ -1014,4 +1184,4 @@ function verifyMfaCode(submitted, expectedHash) {
|
|
|
1014
1184
|
return timingSafeEqual(a, b);
|
|
1015
1185
|
}
|
|
1016
1186
|
//#endregion
|
|
1017
|
-
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 };
|
|
1187
|
+
export { FederatedIdentityStore, FederatedIdentityStoreMemory, PasswordHasher, PasswordPolicy, SEEN_DEVICES_DEFAULT_CAP, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aooth/user",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "User credential primitives for aoothjs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aoothjs",
|
|
@@ -60,14 +60,14 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@atscript/core": "^0.1.
|
|
64
|
-
"@atscript/db": "^0.1.
|
|
65
|
-
"@atscript/db-sql-tools": "^0.1.
|
|
66
|
-
"@atscript/db-sqlite": "^0.1.
|
|
67
|
-
"@atscript/typescript": "^0.1.
|
|
63
|
+
"@atscript/core": "^0.1.76",
|
|
64
|
+
"@atscript/db": "^0.1.105",
|
|
65
|
+
"@atscript/db-sql-tools": "^0.1.105",
|
|
66
|
+
"@atscript/db-sqlite": "^0.1.105",
|
|
67
|
+
"@atscript/typescript": "^0.1.76",
|
|
68
68
|
"@types/better-sqlite3": "^7.6.13",
|
|
69
69
|
"better-sqlite3": "^12.6.2",
|
|
70
|
-
"unplugin-atscript": "^0.1.
|
|
70
|
+
"unplugin-atscript": "^0.1.76"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"@atscript/db": ">=0.1.79"
|
|
@@ -28,6 +28,8 @@ export interface AoothUserCredentials {
|
|
|
28
28
|
failedLoginAttempts: number
|
|
29
29
|
lastLogin: number.timestamp
|
|
30
30
|
pendingInvitation?: boolean
|
|
31
|
+
// Correspondence address whose inbox the user proved (invite click, OTP, email confirm); auth-owned, not a login handle
|
|
32
|
+
verifiedEmail?: string
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
@db.patch.strategy 'merge'
|
|
@@ -46,4 +48,14 @@ export interface AoothUserCredentials {
|
|
|
46
48
|
expiresAt: number.timestamp
|
|
47
49
|
name?: string
|
|
48
50
|
}[]
|
|
51
|
+
|
|
52
|
+
// Recognition ledger — devices that completed a login (suppresses the new-sign-in notification; no security bypass)
|
|
53
|
+
@db.patch.strategy 'merge'
|
|
54
|
+
seenDevices?: {
|
|
55
|
+
token: string
|
|
56
|
+
ip?: string
|
|
57
|
+
issuedAt: number.timestamp
|
|
58
|
+
expiresAt: number.timestamp
|
|
59
|
+
name?: string
|
|
60
|
+
}[]
|
|
49
61
|
}
|
|
@@ -31,6 +31,7 @@ export declare class AoothUserCredentials {
|
|
|
31
31
|
failedLoginAttempts: number
|
|
32
32
|
lastLogin: number /* timestamp */
|
|
33
33
|
pendingInvitation?: boolean
|
|
34
|
+
verifiedEmail?: string
|
|
34
35
|
}
|
|
35
36
|
mfa: {
|
|
36
37
|
methods: {
|
|
@@ -49,6 +50,13 @@ export declare class AoothUserCredentials {
|
|
|
49
50
|
expiresAt: number /* timestamp */
|
|
50
51
|
name?: string
|
|
51
52
|
}[]
|
|
53
|
+
seenDevices?: {
|
|
54
|
+
token: string
|
|
55
|
+
ip?: string
|
|
56
|
+
issuedAt: number /* timestamp */
|
|
57
|
+
expiresAt: number /* timestamp */
|
|
58
|
+
name?: string
|
|
59
|
+
}[]
|
|
52
60
|
static __is_atscript_annotated_type: true
|
|
53
61
|
static type: TAtscriptTypeObject<keyof AoothUserCredentials, AoothUserCredentials>
|
|
54
62
|
static metadata: TMetadataMap<AtscriptMetadata>
|