@nxgt/janus 0.4.0 → 0.6.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 +159 -20
- package/dist/auth/config.d.ts +28 -1
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +6 -0
- 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 +92 -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 +14 -0
- package/dist/auth/second-factor/challenge.d.ts.map +1 -0
- package/dist/auth/second-factor/factor.d.ts +27 -0
- package/dist/auth/second-factor/factor.d.ts.map +1 -0
- package/dist/auth/second-factor/flows.d.ts +23 -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/sign-in-code.d.ts +17 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/totp.d.ts +36 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/types.d.ts +140 -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 +478 -51
- package/dist/index.js.map +15 -7
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -0
- package/docs/guide/adapters.md +12 -7
- package/docs/guide/email-flows.md +4 -0
- package/docs/guide/errors.md +19 -7
- package/docs/guide/second-factor.md +571 -0
- package/docs/guide/sessions.md +18 -0
- package/docs/guide/sign-in-code.md +471 -0
- package/docs/guide/users.md +10 -3
- package/docs/guide/vocabulary.md +9 -6
- package/docs/roadmap.md +39 -54
- package/docs/troubleshooting.md +414 -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",
|
|
@@ -213,9 +278,24 @@ function resolveConfig(config, where) {
|
|
|
213
278
|
types,
|
|
214
279
|
tokenTtlMs: {
|
|
215
280
|
verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
|
|
216
|
-
resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`)
|
|
281
|
+
resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
|
|
282
|
+
signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`)
|
|
217
283
|
},
|
|
218
|
-
cookie: resolveCookie(config.cookie ?? {}, where)
|
|
284
|
+
cookie: resolveCookie(config.cookie ?? {}, where),
|
|
285
|
+
secondFactor: resolveSecondFactor(config.secondFactor, where)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function resolveSecondFactor(config, where) {
|
|
289
|
+
if (config === undefined)
|
|
290
|
+
return null;
|
|
291
|
+
const at = `${where}: secondFactor`;
|
|
292
|
+
if (typeof config?.issuer !== "string" || config.issuer.trim() === "") {
|
|
293
|
+
throw new TypeError(`${at}.issuer must name your application — the authenticator app shows it beside the account`);
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
issuer: config.issuer,
|
|
297
|
+
sealer: resolveSealer(config.keys, `${at}.keys`),
|
|
298
|
+
challengeTtlMs: parseDuration(config.challenge ?? "5m", `${at}.challenge`)
|
|
219
299
|
};
|
|
220
300
|
}
|
|
221
301
|
function resolveType(name, type, at, schemaKey) {
|
|
@@ -336,6 +416,7 @@ function toUser(record) {
|
|
|
336
416
|
emailVerified: record.emailVerifiedAt !== null,
|
|
337
417
|
active: record.active,
|
|
338
418
|
hasPassword: record.password !== null,
|
|
419
|
+
hasSecondFactor: record.secondFactor?.confirmedAt != null,
|
|
339
420
|
version: record.version,
|
|
340
421
|
createdAt: record.createdAt,
|
|
341
422
|
updatedAt: record.updatedAt
|
|
@@ -499,6 +580,14 @@ async function writeUser(context, user, type, options, where, patchOf) {
|
|
|
499
580
|
const patch = await patchOf(record, now);
|
|
500
581
|
return context.store.users.updateUser(record.id, { ...patch, updatedAt: now }, record.version);
|
|
501
582
|
}
|
|
583
|
+
async function holderOfEmail(context, type, email) {
|
|
584
|
+
const wanted = normalizeEmail(email);
|
|
585
|
+
if (!isStorable(wanted))
|
|
586
|
+
return null;
|
|
587
|
+
const record = await context.store.users.findUserByLogin(type.name, wanted);
|
|
588
|
+
const held = record === null ? null : emailOf(type, record.fields);
|
|
589
|
+
return held !== null && normalizeEmail(held) === wanted ? record : null;
|
|
590
|
+
}
|
|
502
591
|
|
|
503
592
|
// src/auth/port/assert-stores.ts
|
|
504
593
|
var REQUIRED = {
|
|
@@ -544,9 +633,9 @@ function assertStores(stores, where) {
|
|
|
544
633
|
}
|
|
545
634
|
|
|
546
635
|
// src/auth/secrets.ts
|
|
547
|
-
import { createHash, randomBytes as
|
|
636
|
+
import { createHash, randomBytes as randomBytes3 } from "node:crypto";
|
|
548
637
|
function mintSecret() {
|
|
549
|
-
return
|
|
638
|
+
return randomBytes3(32).toString("base64url");
|
|
550
639
|
}
|
|
551
640
|
function hashSecret(secret) {
|
|
552
641
|
return createHash("sha256").update(secret).digest("hex");
|
|
@@ -570,7 +659,12 @@ async function openSession(context, type, user) {
|
|
|
570
659
|
createdAt: now
|
|
571
660
|
};
|
|
572
661
|
await context.store.sessions.insertSession(record);
|
|
573
|
-
return {
|
|
662
|
+
return {
|
|
663
|
+
status: "signedIn",
|
|
664
|
+
user: toUser(user),
|
|
665
|
+
session: toSession(record),
|
|
666
|
+
token
|
|
667
|
+
};
|
|
574
668
|
}
|
|
575
669
|
function sharedApi(context) {
|
|
576
670
|
const { store, clock, config } = context;
|
|
@@ -696,10 +790,373 @@ function headersOf(request) {
|
|
|
696
790
|
return request;
|
|
697
791
|
}
|
|
698
792
|
|
|
793
|
+
// src/auth/one-time.ts
|
|
794
|
+
import { randomInt, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
795
|
+
var CODE_ATTEMPTS = 5;
|
|
796
|
+
async function countCodeAttempt(context, secret, kind, where, userType) {
|
|
797
|
+
const token = await context.store.tokens.countAttempt(hashSecret(secret), kind);
|
|
798
|
+
refuseUnusable(token, context.clock.now(), where, "challenge");
|
|
799
|
+
if (token.attempts > CODE_ATTEMPTS) {
|
|
800
|
+
throw codeInvalid(where, token.userId, userType, 0);
|
|
801
|
+
}
|
|
802
|
+
return { token, attemptsLeft: CODE_ATTEMPTS - token.attempts };
|
|
803
|
+
}
|
|
804
|
+
function codeInvalid(where, userId, userType, attemptsLeft) {
|
|
805
|
+
return new TokenError2("CODE_INVALID", `${where}: the code does not match, or was already used`, {
|
|
806
|
+
operation: where,
|
|
807
|
+
userId,
|
|
808
|
+
userType,
|
|
809
|
+
...attemptsLeft === undefined ? {} : { attemptsLeft }
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
function refuseStale(type, user, token, where, noun) {
|
|
813
|
+
const email = emailOf(type, user.fields);
|
|
814
|
+
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
815
|
+
throw new TokenError2("TOKEN_STALE", `${where}: the ${noun} was sent to an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function refuseUnusable(token, now, where, noun) {
|
|
819
|
+
if (token === null) {
|
|
820
|
+
throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
821
|
+
operation: where
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
if (token.spentAt !== null) {
|
|
825
|
+
throw new TokenError2("TOKEN_SPENT", `${where}: the ${noun} was already used`, { operation: where });
|
|
826
|
+
}
|
|
827
|
+
if (token.expiresAt.getTime() <= now.getTime()) {
|
|
828
|
+
throw new TokenError2("TOKEN_EXPIRED", `${where}: the ${noun} has expired`, {
|
|
829
|
+
operation: where
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
var unknownOneTime = (where, noun) => new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
834
|
+
operation: where
|
|
835
|
+
});
|
|
836
|
+
async function spendOneTime(context, secret, kind, where, noun) {
|
|
837
|
+
const now = context.clock.now();
|
|
838
|
+
const token = await context.store.tokens.consumeToken(hashSecret(secret), kind, now);
|
|
839
|
+
refuseUnusable(token, now, where, noun);
|
|
840
|
+
return token;
|
|
841
|
+
}
|
|
842
|
+
async function burnOneTime(context, secret, kind) {
|
|
843
|
+
await context.store.tokens.consumeToken(hashSecret(secret), kind, context.clock.now());
|
|
844
|
+
}
|
|
845
|
+
var hashCode = (challenge, code) => hashSecret(`${challenge}${code}`);
|
|
846
|
+
function codeMatches(token, challenge, code) {
|
|
847
|
+
if (token.codeHash === null || !/^\d{6}$/.test(code))
|
|
848
|
+
return false;
|
|
849
|
+
const given = Buffer.from(hashCode(challenge, code));
|
|
850
|
+
const held = Buffer.from(token.codeHash);
|
|
851
|
+
return given.length === held.length && timingSafeEqual2(given, held);
|
|
852
|
+
}
|
|
853
|
+
async function issueOneTime(context, token) {
|
|
854
|
+
return insertOneTime(context, token, null);
|
|
855
|
+
}
|
|
856
|
+
async function issueCode(context, token) {
|
|
857
|
+
const code = String(randomInt(1e6)).padStart(6, "0");
|
|
858
|
+
return { ...await insertOneTime(context, token, code), code };
|
|
859
|
+
}
|
|
860
|
+
async function insertOneTime(context, token, code) {
|
|
861
|
+
const now = context.clock.now();
|
|
862
|
+
const secret = mintSecret();
|
|
863
|
+
const expiresAt = new Date(now.getTime() + token.ttlMs);
|
|
864
|
+
await context.store.tokens.insertToken({
|
|
865
|
+
tokenHash: hashSecret(secret),
|
|
866
|
+
kind: token.kind,
|
|
867
|
+
userId: token.userId,
|
|
868
|
+
address: token.address,
|
|
869
|
+
codeHash: code === null ? null : hashCode(secret, code),
|
|
870
|
+
attempts: 0,
|
|
871
|
+
expiresAt,
|
|
872
|
+
spentAt: null,
|
|
873
|
+
createdAt: now
|
|
874
|
+
});
|
|
875
|
+
return { secret, expiresAt };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/auth/totp.ts
|
|
879
|
+
import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
|
|
880
|
+
var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
|
|
881
|
+
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
882
|
+
function mintTotpSecret() {
|
|
883
|
+
return toBase32(randomBytes4(20));
|
|
884
|
+
}
|
|
885
|
+
function toBase32(bytes) {
|
|
886
|
+
let bits = 0;
|
|
887
|
+
let value = 0;
|
|
888
|
+
let out = "";
|
|
889
|
+
for (const byte of bytes) {
|
|
890
|
+
value = value << 8 | byte;
|
|
891
|
+
bits += 8;
|
|
892
|
+
while (bits >= 5) {
|
|
893
|
+
out += BASE32[value >>> bits - 5 & 31];
|
|
894
|
+
bits -= 5;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (bits > 0)
|
|
898
|
+
out += BASE32[value << 5 - bits & 31];
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
function fromBase32(text) {
|
|
902
|
+
let bits = 0;
|
|
903
|
+
let value = 0;
|
|
904
|
+
const out = [];
|
|
905
|
+
for (const char of text) {
|
|
906
|
+
const index = BASE32.indexOf(char);
|
|
907
|
+
if (index === -1)
|
|
908
|
+
throw new TypeError("totp: the secret is not base32");
|
|
909
|
+
value = value << 5 | index;
|
|
910
|
+
bits += 5;
|
|
911
|
+
if (bits >= 8) {
|
|
912
|
+
out.push(value >>> bits - 8 & 255);
|
|
913
|
+
bits -= 8;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return Buffer.from(out);
|
|
917
|
+
}
|
|
918
|
+
var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
|
|
919
|
+
function codeAt(secret, step) {
|
|
920
|
+
const counter = Buffer.alloc(8);
|
|
921
|
+
counter.writeBigUInt64BE(BigInt(step));
|
|
922
|
+
const digest = createHmac("sha1", secret).update(counter).digest();
|
|
923
|
+
const offset = digest[19] & 15;
|
|
924
|
+
const binary = digest.readUInt32BE(offset) & 2147483647;
|
|
925
|
+
return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
|
|
926
|
+
}
|
|
927
|
+
function matchStep(secret, code, now, lastStep) {
|
|
928
|
+
if (!/^\d{6}$/.test(code))
|
|
929
|
+
return null;
|
|
930
|
+
const current = stepAt(now);
|
|
931
|
+
let matched = null;
|
|
932
|
+
for (let step = current - TOTP.drift;step <= current + TOTP.drift; step++) {
|
|
933
|
+
const fresh = lastStep === null || step > lastStep;
|
|
934
|
+
if (equalCodes(codeAt(secret, step), code) && fresh && matched === null) {
|
|
935
|
+
matched = step;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return matched;
|
|
939
|
+
}
|
|
940
|
+
function equalCodes(a, b) {
|
|
941
|
+
let difference = a.length ^ b.length;
|
|
942
|
+
for (let i = 0;i < a.length; i++) {
|
|
943
|
+
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
944
|
+
}
|
|
945
|
+
return difference === 0;
|
|
946
|
+
}
|
|
947
|
+
function otpauthUri(issuer, account, secret) {
|
|
948
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
|
|
949
|
+
const query = new URLSearchParams({
|
|
950
|
+
secret,
|
|
951
|
+
issuer,
|
|
952
|
+
algorithm: "SHA1",
|
|
953
|
+
digits: String(TOTP.digits),
|
|
954
|
+
period: String(TOTP.stepMs / 1000)
|
|
955
|
+
});
|
|
956
|
+
return `otpauth://totp/${label}?${query}`;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/auth/second-factor/factor.ts
|
|
960
|
+
var isActive = (factor) => factor !== null && factor.confirmedAt !== null;
|
|
961
|
+
function requireSettings(context, where, why) {
|
|
962
|
+
const configured = context.config.secondFactor;
|
|
963
|
+
if (configured === null) {
|
|
964
|
+
throw new TypeError(`${where}: ${why}, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`);
|
|
965
|
+
}
|
|
966
|
+
return configured;
|
|
967
|
+
}
|
|
968
|
+
function acceptCode(configured, record, factor, code, now, where) {
|
|
969
|
+
const { plain, keyId } = unseal(configured.sealer, factor.secret, record.id, where);
|
|
970
|
+
const step = matchStep(fromBase32(plain), code, now, factor.lastStep);
|
|
971
|
+
if (step === null)
|
|
972
|
+
return null;
|
|
973
|
+
return {
|
|
974
|
+
...factor,
|
|
975
|
+
lastStep: step,
|
|
976
|
+
secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/auth/second-factor/challenge.ts
|
|
981
|
+
function challengeFlows(context, type, at) {
|
|
982
|
+
const { store, clock } = context;
|
|
983
|
+
const spend = (challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
|
|
984
|
+
return {
|
|
985
|
+
async issue(record, where) {
|
|
986
|
+
const configured = requireSettings(context, where, "the user's second factor is active");
|
|
987
|
+
const { secret, expiresAt } = await issueOneTime(context, {
|
|
988
|
+
kind: "secondFactor",
|
|
989
|
+
userId: record.id,
|
|
990
|
+
address: "",
|
|
991
|
+
ttlMs: configured.challengeTtlMs
|
|
992
|
+
});
|
|
993
|
+
return { status: "secondFactor", challenge: secret, expiresAt };
|
|
994
|
+
},
|
|
995
|
+
async confirm(challenge, code) {
|
|
996
|
+
const where = at("secondFactor.confirm");
|
|
997
|
+
const configured = requireSettings(context, where, "a second factor is being confirmed");
|
|
998
|
+
const secret = String(challenge);
|
|
999
|
+
const { token, attemptsLeft } = await countCodeAttempt(context, secret, "secondFactor", where, type.name);
|
|
1000
|
+
const record = await findRecord(context, token.userId, type.name);
|
|
1001
|
+
if (record === null)
|
|
1002
|
+
throw unknownOneTime(where, "challenge");
|
|
1003
|
+
if (!record.active) {
|
|
1004
|
+
await spend(secret, where);
|
|
1005
|
+
throw new UserInactiveError2(`${where}: the user is inactive`, {
|
|
1006
|
+
userId: record.id,
|
|
1007
|
+
userType: type.name
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
if (!isActive(record.secondFactor)) {
|
|
1011
|
+
await spend(secret, where);
|
|
1012
|
+
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 });
|
|
1013
|
+
}
|
|
1014
|
+
const now = clock.now();
|
|
1015
|
+
const accepted = acceptCode(configured, record, record.secondFactor, String(code), now, where);
|
|
1016
|
+
if (accepted === null) {
|
|
1017
|
+
if (attemptsLeft === 0) {
|
|
1018
|
+
await burnOneTime(context, secret, "secondFactor");
|
|
1019
|
+
}
|
|
1020
|
+
throw codeInvalid(where, record.id, type.name, attemptsLeft);
|
|
1021
|
+
}
|
|
1022
|
+
const written = await store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
|
|
1023
|
+
await spend(secret, where);
|
|
1024
|
+
return openSession(context, type, written);
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// src/auth/second-factor/lifecycle.ts
|
|
1030
|
+
function lifecycleFlows(context, type, at) {
|
|
1031
|
+
const accountOf = (record, where) => {
|
|
1032
|
+
if (type.password === null) {
|
|
1033
|
+
throw new TypeError(`${where}: the ${type.name} type does not sign in with a password, so it has no second factor`);
|
|
1034
|
+
}
|
|
1035
|
+
return String(record.fields[type.password.login]);
|
|
1036
|
+
};
|
|
1037
|
+
const refuse = (code, message, where, record) => new SecondFactorError2(code, `${where}: ${message}`, {
|
|
1038
|
+
operation: where,
|
|
1039
|
+
userId: record.id,
|
|
1040
|
+
userType: type.name
|
|
1041
|
+
});
|
|
1042
|
+
return {
|
|
1043
|
+
async enroll(user, options) {
|
|
1044
|
+
const where = at("secondFactor.enroll");
|
|
1045
|
+
const configured = requireSettings(context, where, "a second factor is being enrolled");
|
|
1046
|
+
const secret = mintTotpSecret();
|
|
1047
|
+
const written = await writeUser(context, user, type, options, where, (record) => {
|
|
1048
|
+
if (isActive(record.secondFactor)) {
|
|
1049
|
+
throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is active — disable it first", where, record);
|
|
1050
|
+
}
|
|
1051
|
+
accountOf(record, where);
|
|
1052
|
+
return {
|
|
1053
|
+
secondFactor: {
|
|
1054
|
+
method: "totp",
|
|
1055
|
+
secret: seal(configured.sealer, secret, record.id),
|
|
1056
|
+
confirmedAt: null,
|
|
1057
|
+
lastStep: null
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
});
|
|
1061
|
+
return {
|
|
1062
|
+
secret,
|
|
1063
|
+
uri: otpauthUri(configured.issuer, accountOf(written, where), secret)
|
|
1064
|
+
};
|
|
1065
|
+
},
|
|
1066
|
+
async activate(user, code, options) {
|
|
1067
|
+
const where = at("secondFactor.activate");
|
|
1068
|
+
const configured = requireSettings(context, where, "a second factor is being activated");
|
|
1069
|
+
const written = await writeUser(context, user, type, options, where, (record, now) => {
|
|
1070
|
+
const factor = record.secondFactor;
|
|
1071
|
+
if (factor === null) {
|
|
1072
|
+
throw refuse("SECOND_FACTOR_NOT_ENROLLED", "the user has no second factor waiting — call enroll first", where, record);
|
|
1073
|
+
}
|
|
1074
|
+
if (isActive(factor)) {
|
|
1075
|
+
throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is already active", where, record);
|
|
1076
|
+
}
|
|
1077
|
+
const accepted = acceptCode(configured, record, factor, String(code), now, where);
|
|
1078
|
+
if (accepted === null)
|
|
1079
|
+
throw codeInvalid(where, record.id, type.name);
|
|
1080
|
+
return { secondFactor: { ...accepted, confirmedAt: now } };
|
|
1081
|
+
});
|
|
1082
|
+
return toUser(written);
|
|
1083
|
+
},
|
|
1084
|
+
async disable(user, options) {
|
|
1085
|
+
const where = at("secondFactor.disable");
|
|
1086
|
+
return toUser(await writeUser(context, user, type, options, where, () => ({
|
|
1087
|
+
secondFactor: null
|
|
1088
|
+
})));
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// src/auth/second-factor/flows.ts
|
|
1094
|
+
function secondFactorFlows(context, type, at) {
|
|
1095
|
+
const challenges = challengeFlows(context, type, at);
|
|
1096
|
+
const api = {
|
|
1097
|
+
...lifecycleFlows(context, type, at),
|
|
1098
|
+
confirm: challenges.confirm
|
|
1099
|
+
};
|
|
1100
|
+
return {
|
|
1101
|
+
api,
|
|
1102
|
+
finish: (record, where) => isActive(record.secondFactor) ? challenges.issue(record, where) : openSession(context, type, record)
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/auth/sign-in-code.ts
|
|
1107
|
+
function signInCodeFlows(context, type, at, finish) {
|
|
1108
|
+
return {
|
|
1109
|
+
async request(email) {
|
|
1110
|
+
const record = await holderOfEmail(context, type, String(email));
|
|
1111
|
+
if (record === null || !record.active)
|
|
1112
|
+
return null;
|
|
1113
|
+
const { secret, code, expiresAt } = await issueCode(context, {
|
|
1114
|
+
kind: "signInCode",
|
|
1115
|
+
userId: record.id,
|
|
1116
|
+
address: String(record.fields[type.email]),
|
|
1117
|
+
ttlMs: context.config.tokenTtlMs.signInCode
|
|
1118
|
+
});
|
|
1119
|
+
return {
|
|
1120
|
+
code,
|
|
1121
|
+
challenge: secret,
|
|
1122
|
+
email: String(record.fields[type.email]),
|
|
1123
|
+
expiresAt,
|
|
1124
|
+
user: toUser(record)
|
|
1125
|
+
};
|
|
1126
|
+
},
|
|
1127
|
+
async confirm(challenge, code) {
|
|
1128
|
+
const where = at("signInCode.confirm");
|
|
1129
|
+
const secret = String(challenge);
|
|
1130
|
+
const { token, attemptsLeft } = await countCodeAttempt(context, secret, "signInCode", where, type.name);
|
|
1131
|
+
const user = await findRecord(context, token.userId, type.name);
|
|
1132
|
+
if (user === null)
|
|
1133
|
+
throw unknownOneTime(where, "challenge");
|
|
1134
|
+
if (!codeMatches(token, secret, String(code))) {
|
|
1135
|
+
if (token.attempts === CODE_ATTEMPTS) {
|
|
1136
|
+
await burnOneTime(context, secret, "signInCode");
|
|
1137
|
+
}
|
|
1138
|
+
throw codeInvalid(where, user.id, type.name, attemptsLeft);
|
|
1139
|
+
}
|
|
1140
|
+
await spendOneTime(context, secret, "signInCode", where, "challenge");
|
|
1141
|
+
refuseStale(type, user, token, where, "code");
|
|
1142
|
+
if (!user.active) {
|
|
1143
|
+
throw new UserInactiveError2(`${where}: the user is inactive`, {
|
|
1144
|
+
userId: user.id,
|
|
1145
|
+
userType: type.name
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
const proved = user.emailVerifiedAt === null ? await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now })) : user;
|
|
1149
|
+
return finish(proved, where);
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
699
1154
|
// src/auth/users.ts
|
|
700
1155
|
function typeApi(context, type) {
|
|
701
1156
|
const { store, clock } = context;
|
|
702
1157
|
const at = (operation) => context.config.single ? operation : `${type.name}.${operation}`;
|
|
1158
|
+
const secondFactor = secondFactorFlows(context, type, at);
|
|
1159
|
+
const signInCode = signInCodeFlows(context, type, at, secondFactor.finish);
|
|
703
1160
|
const byLogin = async (login) => isStorable(login) ? store.users.findUserByLogin(type.name, login) : null;
|
|
704
1161
|
const insert = async (input, where) => {
|
|
705
1162
|
const { password, active, ...rest } = input ?? {};
|
|
@@ -729,33 +1186,11 @@ function typeApi(context, type) {
|
|
|
729
1186
|
});
|
|
730
1187
|
};
|
|
731
1188
|
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
|
-
}
|
|
1189
|
+
const token = await spendOneTime(context, secret, kind, where, "token");
|
|
749
1190
|
const user = await findRecord(context, token.userId, type.name);
|
|
750
|
-
if (user === null)
|
|
751
|
-
throw
|
|
752
|
-
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
const email = emailOf(type, user.fields);
|
|
756
|
-
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
757
|
-
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 });
|
|
758
|
-
}
|
|
1191
|
+
if (user === null)
|
|
1192
|
+
throw unknownOneTime(where, "token");
|
|
1193
|
+
refuseStale(type, user, token, where, "token");
|
|
759
1194
|
return { token, user };
|
|
760
1195
|
};
|
|
761
1196
|
const issue = async (kind, user, where) => {
|
|
@@ -767,19 +1202,11 @@ function typeApi(context, type) {
|
|
|
767
1202
|
operation: where
|
|
768
1203
|
});
|
|
769
1204
|
}
|
|
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),
|
|
1205
|
+
const { secret, expiresAt } = await issueOneTime(context, {
|
|
775
1206
|
kind,
|
|
776
1207
|
userId: user.id,
|
|
777
1208
|
address: email,
|
|
778
|
-
|
|
779
|
-
attempts: 0,
|
|
780
|
-
expiresAt,
|
|
781
|
-
spentAt: null,
|
|
782
|
-
createdAt: now
|
|
1209
|
+
ttlMs: context.config.tokenTtlMs[kind]
|
|
783
1210
|
});
|
|
784
1211
|
return { token: secret, email, expiresAt };
|
|
785
1212
|
};
|
|
@@ -876,7 +1303,7 @@ function typeApi(context, type) {
|
|
|
876
1303
|
userType: type.name
|
|
877
1304
|
});
|
|
878
1305
|
}
|
|
879
|
-
return
|
|
1306
|
+
return secondFactor.finish(await rehashed(context, record, String(password)), where);
|
|
880
1307
|
},
|
|
881
1308
|
async findByLogin(login) {
|
|
882
1309
|
const rule = passwordRule(at("findByLogin"));
|
|
@@ -913,6 +1340,8 @@ function typeApi(context, type) {
|
|
|
913
1340
|
};
|
|
914
1341
|
}));
|
|
915
1342
|
},
|
|
1343
|
+
secondFactor: secondFactor.api,
|
|
1344
|
+
signInCode,
|
|
916
1345
|
verifyEmail: {
|
|
917
1346
|
async send(user) {
|
|
918
1347
|
const where = at("verifyEmail.send");
|
|
@@ -931,12 +1360,9 @@ function typeApi(context, type) {
|
|
|
931
1360
|
async request(email) {
|
|
932
1361
|
const where = at("resetPassword.request");
|
|
933
1362
|
passwordRule(where);
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
const held = record === null ? null : emailOf(type, record.fields);
|
|
937
|
-
if (record === null || held === null || normalizeEmail(held) !== wanted) {
|
|
1363
|
+
const record = await holderOfEmail(context, type, String(email));
|
|
1364
|
+
if (record === null)
|
|
938
1365
|
return null;
|
|
939
|
-
}
|
|
940
1366
|
const issued = await issue("resetPassword", record, where);
|
|
941
1367
|
return { ...issued, user: toUser(record) };
|
|
942
1368
|
},
|
|
@@ -998,6 +1424,7 @@ export {
|
|
|
998
1424
|
MAX_PAGE_SIZE2 as MAX_PAGE_SIZE,
|
|
999
1425
|
NotFoundError2 as NotFoundError,
|
|
1000
1426
|
PermissionDepthError2 as PermissionDepthError,
|
|
1427
|
+
SecondFactorError2 as SecondFactorError,
|
|
1001
1428
|
StoreConflict2 as StoreConflict,
|
|
1002
1429
|
StoreFailure2 as StoreFailure,
|
|
1003
1430
|
TokenError2 as TokenError,
|
|
@@ -1028,5 +1455,5 @@ export {
|
|
|
1028
1455
|
systemClock
|
|
1029
1456
|
};
|
|
1030
1457
|
|
|
1031
|
-
//# debugId=
|
|
1458
|
+
//# debugId=9A2F9DDAD478673A64756E2164756E21
|
|
1032
1459
|
//# sourceMappingURL=index.js.map
|