@nxgt/janus 0.3.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 +135 -15
- 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 +4 -3
- 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 +74 -3
- 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-8tksqzkr.js → index-gbwn2tts.js} +14 -3
- package/dist/chunks/{index-8tksqzkr.js.map → index-gbwn2tts.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/cases/users.d.ts.map +1 -1
- package/dist/conformance/fixtures.d.ts.map +1 -1
- package/dist/conformance/index.js +122 -5
- package/dist/conformance/index.js.map +6 -6
- 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 +390 -40
- package/dist/index.js.map +15 -8
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +129 -3
- 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 -1
- package/docs/roadmap.md +33 -46
- package/docs/troubleshooting.md +276 -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/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
|
|
@@ -518,7 +598,7 @@ var REQUIRED = {
|
|
|
518
598
|
"revokeUserSessions",
|
|
519
599
|
"deleteUserSessions"
|
|
520
600
|
],
|
|
521
|
-
tokens: ["insertToken", "consumeToken", "deleteUserTokens"]
|
|
601
|
+
tokens: ["insertToken", "consumeToken", "countAttempt", "deleteUserTokens"]
|
|
522
602
|
};
|
|
523
603
|
function assertStores(stores, where) {
|
|
524
604
|
if (typeof stores !== "object" || stores === null) {
|
|
@@ -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 ?? {};
|
|
@@ -721,6 +1092,7 @@ function typeApi(context, type) {
|
|
|
721
1092
|
fields,
|
|
722
1093
|
logins: loginsOf(type, fields, where),
|
|
723
1094
|
password: hash,
|
|
1095
|
+
secondFactor: null,
|
|
724
1096
|
emailVerifiedAt: null,
|
|
725
1097
|
version: 0,
|
|
726
1098
|
createdAt: now,
|
|
@@ -728,29 +1100,10 @@ function typeApi(context, type) {
|
|
|
728
1100
|
});
|
|
729
1101
|
};
|
|
730
1102
|
const redeem = async (kind, secret, where) => {
|
|
731
|
-
const
|
|
732
|
-
const token = await store.tokens.consumeToken(hashSecret(secret), kind, now);
|
|
733
|
-
if (token === null) {
|
|
734
|
-
throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
|
|
735
|
-
operation: where
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
if (token.spentAt !== null) {
|
|
739
|
-
throw new TokenError2("TOKEN_SPENT", `${where}: the token was already used`, {
|
|
740
|
-
operation: where
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
if (token.expiresAt.getTime() <= now.getTime()) {
|
|
744
|
-
throw new TokenError2("TOKEN_EXPIRED", `${where}: the token has expired`, {
|
|
745
|
-
operation: where
|
|
746
|
-
});
|
|
747
|
-
}
|
|
1103
|
+
const token = await spendOneTime(context, secret, kind, where, "token");
|
|
748
1104
|
const user = await findRecord(context, token.userId, type.name);
|
|
749
|
-
if (user === null)
|
|
750
|
-
throw
|
|
751
|
-
operation: where
|
|
752
|
-
});
|
|
753
|
-
}
|
|
1105
|
+
if (user === null)
|
|
1106
|
+
throw unknownOneTime(where, "token");
|
|
754
1107
|
const email = emailOf(type, user.fields);
|
|
755
1108
|
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
756
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 });
|
|
@@ -766,17 +1119,11 @@ function typeApi(context, type) {
|
|
|
766
1119
|
operation: where
|
|
767
1120
|
});
|
|
768
1121
|
}
|
|
769
|
-
const
|
|
770
|
-
const secret = mintSecret();
|
|
771
|
-
const expiresAt = new Date(now.getTime() + context.config.tokenTtlMs[kind]);
|
|
772
|
-
await store.tokens.insertToken({
|
|
773
|
-
tokenHash: hashSecret(secret),
|
|
1122
|
+
const { secret, expiresAt } = await issueOneTime(context, {
|
|
774
1123
|
kind,
|
|
775
1124
|
userId: user.id,
|
|
776
1125
|
address: email,
|
|
777
|
-
|
|
778
|
-
spentAt: null,
|
|
779
|
-
createdAt: now
|
|
1126
|
+
ttlMs: context.config.tokenTtlMs[kind]
|
|
780
1127
|
});
|
|
781
1128
|
return { token: secret, email, expiresAt };
|
|
782
1129
|
};
|
|
@@ -873,7 +1220,8 @@ function typeApi(context, type) {
|
|
|
873
1220
|
userType: type.name
|
|
874
1221
|
});
|
|
875
1222
|
}
|
|
876
|
-
|
|
1223
|
+
const signedIn = await rehashed(context, record, String(password));
|
|
1224
|
+
return secondFactor.required(signedIn) ? secondFactor.challenge(signedIn, where) : openSession(context, type, signedIn);
|
|
877
1225
|
},
|
|
878
1226
|
async findByLogin(login) {
|
|
879
1227
|
const rule = passwordRule(at("findByLogin"));
|
|
@@ -910,6 +1258,7 @@ function typeApi(context, type) {
|
|
|
910
1258
|
};
|
|
911
1259
|
}));
|
|
912
1260
|
},
|
|
1261
|
+
secondFactor: secondFactor.api,
|
|
913
1262
|
verifyEmail: {
|
|
914
1263
|
async send(user) {
|
|
915
1264
|
const where = at("verifyEmail.send");
|
|
@@ -995,6 +1344,7 @@ export {
|
|
|
995
1344
|
MAX_PAGE_SIZE2 as MAX_PAGE_SIZE,
|
|
996
1345
|
NotFoundError2 as NotFoundError,
|
|
997
1346
|
PermissionDepthError2 as PermissionDepthError,
|
|
1347
|
+
SecondFactorError2 as SecondFactorError,
|
|
998
1348
|
StoreConflict2 as StoreConflict,
|
|
999
1349
|
StoreFailure2 as StoreFailure,
|
|
1000
1350
|
TokenError2 as TokenError,
|
|
@@ -1025,5 +1375,5 @@ export {
|
|
|
1025
1375
|
systemClock
|
|
1026
1376
|
};
|
|
1027
1377
|
|
|
1028
|
-
//# debugId=
|
|
1378
|
+
//# debugId=FA6925B96B6EEFD664756E2164756E21
|
|
1029
1379
|
//# sourceMappingURL=index.js.map
|