@nxgt/janus 0.4.0 → 0.5.0
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/README.md +102 -18
- package/dist/auth/config.d.ts +25 -1
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/index.d.ts +3 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +39 -0
- package/dist/auth/one-time.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +10 -7
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/sealing.d.ts +39 -0
- package/dist/auth/sealing.d.ts.map +1 -0
- package/dist/auth/second-factor/challenge.d.ts +20 -0
- package/dist/auth/second-factor/challenge.d.ts.map +1 -0
- package/dist/auth/second-factor/factor.d.ts +30 -0
- package/dist/auth/second-factor/factor.d.ts.map +1 -0
- package/dist/auth/second-factor/flows.d.ts +19 -0
- package/dist/auth/second-factor/flows.d.ts.map +1 -0
- package/dist/auth/second-factor/lifecycle.d.ts +8 -0
- package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts.map +1 -1
- package/dist/auth/totp.d.ts +36 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/types.d.ts +91 -7
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts +2 -2
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
- package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
- package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
- package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
- package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +15 -4
- package/dist/conformance/index.js.map +3 -3
- package/dist/errors/janus-error.d.ts +28 -2
- package/dist/errors/janus-error.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +388 -41
- package/dist/index.js.map +14 -7
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +1 -0
- package/docs/guide/adapters.md +12 -7
- package/docs/guide/errors.md +18 -6
- package/docs/guide/second-factor.md +569 -0
- package/docs/guide/sessions.md +18 -0
- package/docs/guide/users.md +8 -2
- package/docs/guide/vocabulary.md +6 -3
- package/docs/roadmap.md +26 -48
- package/docs/troubleshooting.md +260 -5
- package/package.json +1 -1
- /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
- /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
- /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -5,13 +5,13 @@ import {
|
|
|
5
5
|
MAX_PAGE_SIZE2,
|
|
6
6
|
pageLimit2,
|
|
7
7
|
invalidCursor2
|
|
8
|
-
} from "./chunks/index-
|
|
8
|
+
} from "./chunks/index-5vr13kkb.js";
|
|
9
9
|
import {
|
|
10
10
|
mintId2,
|
|
11
11
|
isId2,
|
|
12
12
|
mintedAt2,
|
|
13
13
|
createMemoryStores2
|
|
14
|
-
} from "./chunks/index-
|
|
14
|
+
} from "./chunks/index-gbwn2tts.js";
|
|
15
15
|
import {
|
|
16
16
|
JanusError2,
|
|
17
17
|
StoreFailure2,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
CredentialError2,
|
|
22
22
|
UserInactiveError2,
|
|
23
23
|
TokenError2,
|
|
24
|
+
SecondFactorError2,
|
|
24
25
|
InvalidCursorError2,
|
|
25
26
|
UnsupportedError2,
|
|
26
27
|
PermissionDepthError2,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
formatTuple2,
|
|
34
35
|
parseTuple2,
|
|
35
36
|
parseSubject2
|
|
36
|
-
} from "./chunks/index-
|
|
37
|
+
} from "./chunks/index-06vp9c5r.js";
|
|
37
38
|
|
|
38
39
|
// src/auth/hashers.ts
|
|
39
40
|
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
@@ -140,6 +141,69 @@ function parseDuration(value, where) {
|
|
|
140
141
|
return ms;
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
// src/auth/sealing.ts
|
|
145
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "node:crypto";
|
|
146
|
+
var TAG_BYTES = 16;
|
|
147
|
+
var KEY_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
148
|
+
var KEY = /^[A-Za-z0-9+/_-]{43}=?$/;
|
|
149
|
+
function resolveSealer(keys, where) {
|
|
150
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
151
|
+
throw new TypeError(`${where}: expected at least one key — [{ id, key }], the first seals`);
|
|
152
|
+
}
|
|
153
|
+
const decoded = new Map;
|
|
154
|
+
for (const entry of keys) {
|
|
155
|
+
const id = entry?.id;
|
|
156
|
+
if (typeof id !== "string" || !KEY_ID.test(id)) {
|
|
157
|
+
throw new TypeError(`${where}: every key needs an id of letters, digits, _ and -, at most 64 of them`);
|
|
158
|
+
}
|
|
159
|
+
if (decoded.has(id)) {
|
|
160
|
+
throw new TypeError(`${where}: two keys have the id "${id}"`);
|
|
161
|
+
}
|
|
162
|
+
const key = entry.key;
|
|
163
|
+
if (typeof key !== "string" || !KEY.test(key)) {
|
|
164
|
+
throw new TypeError(`${where}: the key "${id}" is not 32 bytes in base64 — make one with openssl rand -base64 32`);
|
|
165
|
+
}
|
|
166
|
+
decoded.set(id, Buffer.from(key, "base64"));
|
|
167
|
+
}
|
|
168
|
+
return { sealWith: keys[0].id, keys: decoded };
|
|
169
|
+
}
|
|
170
|
+
function seal(sealer, plain, boundTo) {
|
|
171
|
+
const key = sealer.keys.get(sealer.sealWith);
|
|
172
|
+
const iv = randomBytes2(12);
|
|
173
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
174
|
+
cipher.setAAD(Buffer.from(boundTo));
|
|
175
|
+
const sealed = Buffer.concat([
|
|
176
|
+
cipher.update(plain, "utf8"),
|
|
177
|
+
cipher.final(),
|
|
178
|
+
cipher.getAuthTag()
|
|
179
|
+
]);
|
|
180
|
+
return `v1.${sealer.sealWith}.${iv.toString("base64url")}.${sealed.toString("base64url")}`;
|
|
181
|
+
}
|
|
182
|
+
function unseal(sealer, sealed, boundTo, where) {
|
|
183
|
+
const parts = sealed.split(".");
|
|
184
|
+
const [version, keyId, iv, body] = parts;
|
|
185
|
+
const bytes = Buffer.from(body ?? "", "base64url");
|
|
186
|
+
if (parts.length !== 4 || version !== "v1" || keyId === undefined || iv === undefined || bytes.length <= TAG_BYTES) {
|
|
187
|
+
throw new TypeError(`${where}: the stored secret is not a sealed one`);
|
|
188
|
+
}
|
|
189
|
+
const key = sealer.keys.get(keyId);
|
|
190
|
+
if (key === undefined) {
|
|
191
|
+
throw new TypeError(`${where}: the secret is sealed with the key "${keyId}", which secondFactor.keys no longer holds — keep a key until no secret is sealed with it`);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url"), { authTagLength: TAG_BYTES });
|
|
195
|
+
decipher.setAAD(Buffer.from(boundTo));
|
|
196
|
+
decipher.setAuthTag(bytes.subarray(bytes.length - TAG_BYTES));
|
|
197
|
+
const plain = Buffer.concat([
|
|
198
|
+
decipher.update(bytes.subarray(0, bytes.length - TAG_BYTES)),
|
|
199
|
+
decipher.final()
|
|
200
|
+
]).toString("utf8");
|
|
201
|
+
return { plain, keyId };
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
throw new TypeError(`${where}: the secret does not open with the key "${keyId}" — was that key changed under the same id, or the secret copied from another user?`, { cause });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
143
207
|
// src/auth/config.ts
|
|
144
208
|
var SINGLE_TYPE = "user";
|
|
145
209
|
var RESERVED_FIELDS = [
|
|
@@ -148,6 +212,7 @@ var RESERVED_FIELDS = [
|
|
|
148
212
|
"emailVerified",
|
|
149
213
|
"active",
|
|
150
214
|
"hasPassword",
|
|
215
|
+
"hasSecondFactor",
|
|
151
216
|
"version",
|
|
152
217
|
"createdAt",
|
|
153
218
|
"updatedAt",
|
|
@@ -215,7 +280,21 @@ function resolveConfig(config, where) {
|
|
|
215
280
|
verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
|
|
216
281
|
resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`)
|
|
217
282
|
},
|
|
218
|
-
cookie: resolveCookie(config.cookie ?? {}, where)
|
|
283
|
+
cookie: resolveCookie(config.cookie ?? {}, where),
|
|
284
|
+
secondFactor: resolveSecondFactor(config.secondFactor, where)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function resolveSecondFactor(config, where) {
|
|
288
|
+
if (config === undefined)
|
|
289
|
+
return null;
|
|
290
|
+
const at = `${where}: secondFactor`;
|
|
291
|
+
if (typeof config?.issuer !== "string" || config.issuer.trim() === "") {
|
|
292
|
+
throw new TypeError(`${at}.issuer must name your application — the authenticator app shows it beside the account`);
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
issuer: config.issuer,
|
|
296
|
+
sealer: resolveSealer(config.keys, `${at}.keys`),
|
|
297
|
+
challengeTtlMs: parseDuration(config.challenge ?? "5m", `${at}.challenge`)
|
|
219
298
|
};
|
|
220
299
|
}
|
|
221
300
|
function resolveType(name, type, at, schemaKey) {
|
|
@@ -336,6 +415,7 @@ function toUser(record) {
|
|
|
336
415
|
emailVerified: record.emailVerifiedAt !== null,
|
|
337
416
|
active: record.active,
|
|
338
417
|
hasPassword: record.password !== null,
|
|
418
|
+
hasSecondFactor: record.secondFactor?.confirmedAt != null,
|
|
339
419
|
version: record.version,
|
|
340
420
|
createdAt: record.createdAt,
|
|
341
421
|
updatedAt: record.updatedAt
|
|
@@ -544,9 +624,9 @@ function assertStores(stores, where) {
|
|
|
544
624
|
}
|
|
545
625
|
|
|
546
626
|
// src/auth/secrets.ts
|
|
547
|
-
import { createHash, randomBytes as
|
|
627
|
+
import { createHash, randomBytes as randomBytes3 } from "node:crypto";
|
|
548
628
|
function mintSecret() {
|
|
549
|
-
return
|
|
629
|
+
return randomBytes3(32).toString("base64url");
|
|
550
630
|
}
|
|
551
631
|
function hashSecret(secret) {
|
|
552
632
|
return createHash("sha256").update(secret).digest("hex");
|
|
@@ -570,7 +650,12 @@ async function openSession(context, type, user) {
|
|
|
570
650
|
createdAt: now
|
|
571
651
|
};
|
|
572
652
|
await context.store.sessions.insertSession(record);
|
|
573
|
-
return {
|
|
653
|
+
return {
|
|
654
|
+
status: "signedIn",
|
|
655
|
+
user: toUser(user),
|
|
656
|
+
session: toSession(record),
|
|
657
|
+
token
|
|
658
|
+
};
|
|
574
659
|
}
|
|
575
660
|
function sharedApi(context) {
|
|
576
661
|
const { store, clock, config } = context;
|
|
@@ -696,10 +781,296 @@ function headersOf(request) {
|
|
|
696
781
|
return request;
|
|
697
782
|
}
|
|
698
783
|
|
|
784
|
+
// src/auth/one-time.ts
|
|
785
|
+
function refuseUnusable(token, now, where, noun) {
|
|
786
|
+
if (token === null) {
|
|
787
|
+
throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
788
|
+
operation: where
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
if (token.spentAt !== null) {
|
|
792
|
+
throw new TokenError2("TOKEN_SPENT", `${where}: the ${noun} was already used`, { operation: where });
|
|
793
|
+
}
|
|
794
|
+
if (token.expiresAt.getTime() <= now.getTime()) {
|
|
795
|
+
throw new TokenError2("TOKEN_EXPIRED", `${where}: the ${noun} has expired`, {
|
|
796
|
+
operation: where
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
var unknownOneTime = (where, noun) => new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
801
|
+
operation: where
|
|
802
|
+
});
|
|
803
|
+
async function spendOneTime(context, secret, kind, where, noun) {
|
|
804
|
+
const now = context.clock.now();
|
|
805
|
+
const token = await context.store.tokens.consumeToken(hashSecret(secret), kind, now);
|
|
806
|
+
refuseUnusable(token, now, where, noun);
|
|
807
|
+
return token;
|
|
808
|
+
}
|
|
809
|
+
async function issueOneTime(context, token) {
|
|
810
|
+
const now = context.clock.now();
|
|
811
|
+
const secret = mintSecret();
|
|
812
|
+
const expiresAt = new Date(now.getTime() + token.ttlMs);
|
|
813
|
+
await context.store.tokens.insertToken({
|
|
814
|
+
tokenHash: hashSecret(secret),
|
|
815
|
+
kind: token.kind,
|
|
816
|
+
userId: token.userId,
|
|
817
|
+
address: token.address,
|
|
818
|
+
codeHash: null,
|
|
819
|
+
attempts: 0,
|
|
820
|
+
expiresAt,
|
|
821
|
+
spentAt: null,
|
|
822
|
+
createdAt: now
|
|
823
|
+
});
|
|
824
|
+
return { secret, expiresAt };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/auth/totp.ts
|
|
828
|
+
import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
|
|
829
|
+
var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
|
|
830
|
+
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
831
|
+
function mintTotpSecret() {
|
|
832
|
+
return toBase32(randomBytes4(20));
|
|
833
|
+
}
|
|
834
|
+
function toBase32(bytes) {
|
|
835
|
+
let bits = 0;
|
|
836
|
+
let value = 0;
|
|
837
|
+
let out = "";
|
|
838
|
+
for (const byte of bytes) {
|
|
839
|
+
value = value << 8 | byte;
|
|
840
|
+
bits += 8;
|
|
841
|
+
while (bits >= 5) {
|
|
842
|
+
out += BASE32[value >>> bits - 5 & 31];
|
|
843
|
+
bits -= 5;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (bits > 0)
|
|
847
|
+
out += BASE32[value << 5 - bits & 31];
|
|
848
|
+
return out;
|
|
849
|
+
}
|
|
850
|
+
function fromBase32(text) {
|
|
851
|
+
let bits = 0;
|
|
852
|
+
let value = 0;
|
|
853
|
+
const out = [];
|
|
854
|
+
for (const char of text) {
|
|
855
|
+
const index = BASE32.indexOf(char);
|
|
856
|
+
if (index === -1)
|
|
857
|
+
throw new TypeError("totp: the secret is not base32");
|
|
858
|
+
value = value << 5 | index;
|
|
859
|
+
bits += 5;
|
|
860
|
+
if (bits >= 8) {
|
|
861
|
+
out.push(value >>> bits - 8 & 255);
|
|
862
|
+
bits -= 8;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return Buffer.from(out);
|
|
866
|
+
}
|
|
867
|
+
var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
|
|
868
|
+
function codeAt(secret, step) {
|
|
869
|
+
const counter = Buffer.alloc(8);
|
|
870
|
+
counter.writeBigUInt64BE(BigInt(step));
|
|
871
|
+
const digest = createHmac("sha1", secret).update(counter).digest();
|
|
872
|
+
const offset = digest[19] & 15;
|
|
873
|
+
const binary = digest.readUInt32BE(offset) & 2147483647;
|
|
874
|
+
return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
|
|
875
|
+
}
|
|
876
|
+
function matchStep(secret, code, now, lastStep) {
|
|
877
|
+
if (!/^\d{6}$/.test(code))
|
|
878
|
+
return null;
|
|
879
|
+
const current = stepAt(now);
|
|
880
|
+
let matched = null;
|
|
881
|
+
for (let step = current - TOTP.drift;step <= current + TOTP.drift; step++) {
|
|
882
|
+
const fresh = lastStep === null || step > lastStep;
|
|
883
|
+
if (equalCodes(codeAt(secret, step), code) && fresh && matched === null) {
|
|
884
|
+
matched = step;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return matched;
|
|
888
|
+
}
|
|
889
|
+
function equalCodes(a, b) {
|
|
890
|
+
let difference = a.length ^ b.length;
|
|
891
|
+
for (let i = 0;i < a.length; i++) {
|
|
892
|
+
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
893
|
+
}
|
|
894
|
+
return difference === 0;
|
|
895
|
+
}
|
|
896
|
+
function otpauthUri(issuer, account, secret) {
|
|
897
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
|
|
898
|
+
const query = new URLSearchParams({
|
|
899
|
+
secret,
|
|
900
|
+
issuer,
|
|
901
|
+
algorithm: "SHA1",
|
|
902
|
+
digits: String(TOTP.digits),
|
|
903
|
+
period: String(TOTP.stepMs / 1000)
|
|
904
|
+
});
|
|
905
|
+
return `otpauth://totp/${label}?${query}`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/auth/second-factor/factor.ts
|
|
909
|
+
var isActive = (factor) => factor !== null && factor.confirmedAt !== null;
|
|
910
|
+
function requireSettings(context, where, why) {
|
|
911
|
+
const configured = context.config.secondFactor;
|
|
912
|
+
if (configured === null) {
|
|
913
|
+
throw new TypeError(`${where}: ${why}, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`);
|
|
914
|
+
}
|
|
915
|
+
return configured;
|
|
916
|
+
}
|
|
917
|
+
function acceptCode(configured, record, factor, code, now, where) {
|
|
918
|
+
const { plain, keyId } = unseal(configured.sealer, factor.secret, record.id, where);
|
|
919
|
+
const step = matchStep(fromBase32(plain), code, now, factor.lastStep);
|
|
920
|
+
if (step === null)
|
|
921
|
+
return null;
|
|
922
|
+
return {
|
|
923
|
+
...factor,
|
|
924
|
+
lastStep: step,
|
|
925
|
+
secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
function codeInvalid(type, where, userId, attemptsLeft) {
|
|
929
|
+
return new TokenError2("CODE_INVALID", `${where}: the code does not match, or was already used`, {
|
|
930
|
+
operation: where,
|
|
931
|
+
userId,
|
|
932
|
+
userType: type.name,
|
|
933
|
+
...attemptsLeft === undefined ? {} : { attemptsLeft }
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/auth/second-factor/challenge.ts
|
|
938
|
+
var CHALLENGE_ATTEMPTS = 5;
|
|
939
|
+
function challengeFlows(context, type, at) {
|
|
940
|
+
const { store, clock } = context;
|
|
941
|
+
const spend = (challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
|
|
942
|
+
return {
|
|
943
|
+
async issue(record, where) {
|
|
944
|
+
const configured = requireSettings(context, where, "the user's second factor is active");
|
|
945
|
+
const { secret, expiresAt } = await issueOneTime(context, {
|
|
946
|
+
kind: "secondFactor",
|
|
947
|
+
userId: record.id,
|
|
948
|
+
address: "",
|
|
949
|
+
ttlMs: configured.challengeTtlMs
|
|
950
|
+
});
|
|
951
|
+
return { status: "secondFactor", challenge: secret, expiresAt };
|
|
952
|
+
},
|
|
953
|
+
async confirm(challenge, code) {
|
|
954
|
+
const where = at("secondFactor.confirm");
|
|
955
|
+
const configured = requireSettings(context, where, "a second factor is being confirmed");
|
|
956
|
+
const secret = String(challenge);
|
|
957
|
+
const token = await store.tokens.countAttempt(hashSecret(secret), "secondFactor");
|
|
958
|
+
refuseUnusable(token, clock.now(), where, "challenge");
|
|
959
|
+
if (token.attempts > CHALLENGE_ATTEMPTS) {
|
|
960
|
+
throw codeInvalid(type, where, token.userId, 0);
|
|
961
|
+
}
|
|
962
|
+
const attemptsLeft = CHALLENGE_ATTEMPTS - token.attempts;
|
|
963
|
+
const record = await findRecord(context, token.userId, type.name);
|
|
964
|
+
if (record === null)
|
|
965
|
+
throw unknownOneTime(where, "challenge");
|
|
966
|
+
if (!record.active) {
|
|
967
|
+
await spend(secret, where);
|
|
968
|
+
throw new UserInactiveError2(`${where}: the user is inactive`, {
|
|
969
|
+
userId: record.id,
|
|
970
|
+
userType: type.name
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
if (!isActive(record.secondFactor)) {
|
|
974
|
+
await spend(secret, where);
|
|
975
|
+
throw new SecondFactorError2("SECOND_FACTOR_NOT_ENROLLED", `${where}: the user no longer has a second factor — sign in again`, { operation: where, userId: record.id, userType: type.name });
|
|
976
|
+
}
|
|
977
|
+
const now = clock.now();
|
|
978
|
+
const accepted = acceptCode(configured, record, record.secondFactor, String(code), now, where);
|
|
979
|
+
if (accepted === null) {
|
|
980
|
+
if (attemptsLeft === 0)
|
|
981
|
+
await spend(secret, where);
|
|
982
|
+
throw codeInvalid(type, where, record.id, attemptsLeft);
|
|
983
|
+
}
|
|
984
|
+
const written = await store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
|
|
985
|
+
await spend(secret, where);
|
|
986
|
+
return openSession(context, type, written);
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/auth/second-factor/lifecycle.ts
|
|
992
|
+
function lifecycleFlows(context, type, at) {
|
|
993
|
+
const accountOf = (record, where) => {
|
|
994
|
+
if (type.password === null) {
|
|
995
|
+
throw new TypeError(`${where}: the ${type.name} type does not sign in with a password, so it has no second factor`);
|
|
996
|
+
}
|
|
997
|
+
return String(record.fields[type.password.login]);
|
|
998
|
+
};
|
|
999
|
+
const refuse = (code, message, where, record) => new SecondFactorError2(code, `${where}: ${message}`, {
|
|
1000
|
+
operation: where,
|
|
1001
|
+
userId: record.id,
|
|
1002
|
+
userType: type.name
|
|
1003
|
+
});
|
|
1004
|
+
return {
|
|
1005
|
+
async enroll(user, options) {
|
|
1006
|
+
const where = at("secondFactor.enroll");
|
|
1007
|
+
const configured = requireSettings(context, where, "a second factor is being enrolled");
|
|
1008
|
+
const secret = mintTotpSecret();
|
|
1009
|
+
const written = await writeUser(context, user, type, options, where, (record) => {
|
|
1010
|
+
if (isActive(record.secondFactor)) {
|
|
1011
|
+
throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is active — disable it first", where, record);
|
|
1012
|
+
}
|
|
1013
|
+
accountOf(record, where);
|
|
1014
|
+
return {
|
|
1015
|
+
secondFactor: {
|
|
1016
|
+
method: "totp",
|
|
1017
|
+
secret: seal(configured.sealer, secret, record.id),
|
|
1018
|
+
confirmedAt: null,
|
|
1019
|
+
lastStep: null
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
});
|
|
1023
|
+
return {
|
|
1024
|
+
secret,
|
|
1025
|
+
uri: otpauthUri(configured.issuer, accountOf(written, where), secret)
|
|
1026
|
+
};
|
|
1027
|
+
},
|
|
1028
|
+
async activate(user, code, options) {
|
|
1029
|
+
const where = at("secondFactor.activate");
|
|
1030
|
+
const configured = requireSettings(context, where, "a second factor is being activated");
|
|
1031
|
+
const written = await writeUser(context, user, type, options, where, (record, now) => {
|
|
1032
|
+
const factor = record.secondFactor;
|
|
1033
|
+
if (factor === null) {
|
|
1034
|
+
throw refuse("SECOND_FACTOR_NOT_ENROLLED", "the user has no second factor waiting — call enroll first", where, record);
|
|
1035
|
+
}
|
|
1036
|
+
if (isActive(factor)) {
|
|
1037
|
+
throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is already active", where, record);
|
|
1038
|
+
}
|
|
1039
|
+
const accepted = acceptCode(configured, record, factor, String(code), now, where);
|
|
1040
|
+
if (accepted === null)
|
|
1041
|
+
throw codeInvalid(type, where, record.id);
|
|
1042
|
+
return { secondFactor: { ...accepted, confirmedAt: now } };
|
|
1043
|
+
});
|
|
1044
|
+
return toUser(written);
|
|
1045
|
+
},
|
|
1046
|
+
async disable(user, options) {
|
|
1047
|
+
const where = at("secondFactor.disable");
|
|
1048
|
+
return toUser(await writeUser(context, user, type, options, where, () => ({
|
|
1049
|
+
secondFactor: null
|
|
1050
|
+
})));
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/auth/second-factor/flows.ts
|
|
1056
|
+
function secondFactorFlows(context, type, at) {
|
|
1057
|
+
const challenges = challengeFlows(context, type, at);
|
|
1058
|
+
const api = {
|
|
1059
|
+
...lifecycleFlows(context, type, at),
|
|
1060
|
+
confirm: challenges.confirm
|
|
1061
|
+
};
|
|
1062
|
+
return {
|
|
1063
|
+
api,
|
|
1064
|
+
challenge: challenges.issue,
|
|
1065
|
+
required: (record) => isActive(record.secondFactor)
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
699
1069
|
// src/auth/users.ts
|
|
700
1070
|
function typeApi(context, type) {
|
|
701
1071
|
const { store, clock } = context;
|
|
702
1072
|
const at = (operation) => context.config.single ? operation : `${type.name}.${operation}`;
|
|
1073
|
+
const secondFactor = secondFactorFlows(context, type, at);
|
|
703
1074
|
const byLogin = async (login) => isStorable(login) ? store.users.findUserByLogin(type.name, login) : null;
|
|
704
1075
|
const insert = async (input, where) => {
|
|
705
1076
|
const { password, active, ...rest } = input ?? {};
|
|
@@ -729,29 +1100,10 @@ function typeApi(context, type) {
|
|
|
729
1100
|
});
|
|
730
1101
|
};
|
|
731
1102
|
const redeem = async (kind, secret, where) => {
|
|
732
|
-
const
|
|
733
|
-
const token = await store.tokens.consumeToken(hashSecret(secret), kind, now);
|
|
734
|
-
if (token === null) {
|
|
735
|
-
throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
|
|
736
|
-
operation: where
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
if (token.spentAt !== null) {
|
|
740
|
-
throw new TokenError2("TOKEN_SPENT", `${where}: the token was already used`, {
|
|
741
|
-
operation: where
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
if (token.expiresAt.getTime() <= now.getTime()) {
|
|
745
|
-
throw new TokenError2("TOKEN_EXPIRED", `${where}: the token has expired`, {
|
|
746
|
-
operation: where
|
|
747
|
-
});
|
|
748
|
-
}
|
|
1103
|
+
const token = await spendOneTime(context, secret, kind, where, "token");
|
|
749
1104
|
const user = await findRecord(context, token.userId, type.name);
|
|
750
|
-
if (user === null)
|
|
751
|
-
throw
|
|
752
|
-
operation: where
|
|
753
|
-
});
|
|
754
|
-
}
|
|
1105
|
+
if (user === null)
|
|
1106
|
+
throw unknownOneTime(where, "token");
|
|
755
1107
|
const email = emailOf(type, user.fields);
|
|
756
1108
|
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
757
1109
|
throw new TokenError2("TOKEN_STALE", `${where}: the token was sent to an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
|
|
@@ -767,19 +1119,11 @@ function typeApi(context, type) {
|
|
|
767
1119
|
operation: where
|
|
768
1120
|
});
|
|
769
1121
|
}
|
|
770
|
-
const
|
|
771
|
-
const secret = mintSecret();
|
|
772
|
-
const expiresAt = new Date(now.getTime() + context.config.tokenTtlMs[kind]);
|
|
773
|
-
await store.tokens.insertToken({
|
|
774
|
-
tokenHash: hashSecret(secret),
|
|
1122
|
+
const { secret, expiresAt } = await issueOneTime(context, {
|
|
775
1123
|
kind,
|
|
776
1124
|
userId: user.id,
|
|
777
1125
|
address: email,
|
|
778
|
-
|
|
779
|
-
attempts: 0,
|
|
780
|
-
expiresAt,
|
|
781
|
-
spentAt: null,
|
|
782
|
-
createdAt: now
|
|
1126
|
+
ttlMs: context.config.tokenTtlMs[kind]
|
|
783
1127
|
});
|
|
784
1128
|
return { token: secret, email, expiresAt };
|
|
785
1129
|
};
|
|
@@ -876,7 +1220,8 @@ function typeApi(context, type) {
|
|
|
876
1220
|
userType: type.name
|
|
877
1221
|
});
|
|
878
1222
|
}
|
|
879
|
-
|
|
1223
|
+
const signedIn = await rehashed(context, record, String(password));
|
|
1224
|
+
return secondFactor.required(signedIn) ? secondFactor.challenge(signedIn, where) : openSession(context, type, signedIn);
|
|
880
1225
|
},
|
|
881
1226
|
async findByLogin(login) {
|
|
882
1227
|
const rule = passwordRule(at("findByLogin"));
|
|
@@ -913,6 +1258,7 @@ function typeApi(context, type) {
|
|
|
913
1258
|
};
|
|
914
1259
|
}));
|
|
915
1260
|
},
|
|
1261
|
+
secondFactor: secondFactor.api,
|
|
916
1262
|
verifyEmail: {
|
|
917
1263
|
async send(user) {
|
|
918
1264
|
const where = at("verifyEmail.send");
|
|
@@ -998,6 +1344,7 @@ export {
|
|
|
998
1344
|
MAX_PAGE_SIZE2 as MAX_PAGE_SIZE,
|
|
999
1345
|
NotFoundError2 as NotFoundError,
|
|
1000
1346
|
PermissionDepthError2 as PermissionDepthError,
|
|
1347
|
+
SecondFactorError2 as SecondFactorError,
|
|
1001
1348
|
StoreConflict2 as StoreConflict,
|
|
1002
1349
|
StoreFailure2 as StoreFailure,
|
|
1003
1350
|
TokenError2 as TokenError,
|
|
@@ -1028,5 +1375,5 @@ export {
|
|
|
1028
1375
|
systemClock
|
|
1029
1376
|
};
|
|
1030
1377
|
|
|
1031
|
-
//# debugId=
|
|
1378
|
+
//# debugId=FA6925B96B6EEFD664756E2164756E21
|
|
1032
1379
|
//# sourceMappingURL=index.js.map
|