@nxgt/janus 0.13.0 → 0.14.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 +56 -15
- package/dist/auth/config/index.d.ts +1 -1
- package/dist/auth/config/index.d.ts.map +1 -1
- package/dist/auth/config/janus-config.d.ts +23 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-sign-in.d.ts +5 -0
- package/dist/auth/config/resolve-sign-in.d.ts.map +1 -0
- package/dist/auth/config/resolved-config.d.ts +5 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/derived-keys.d.ts +9 -0
- package/dist/auth/derived-keys.d.ts.map +1 -0
- package/dist/auth/events.d.ts +19 -4
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time/index.d.ts +3 -1
- package/dist/auth/one-time/index.d.ts.map +1 -1
- package/dist/auth/one-time/window.d.ts +50 -0
- package/dist/auth/one-time/window.d.ts.map +1 -0
- package/dist/auth/password-written.d.ts +7 -0
- package/dist/auth/password-written.d.ts.map +1 -1
- package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
- package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery.d.ts.map +1 -1
- package/dist/auth/sign-in-attempts.d.ts +43 -0
- package/dist/auth/sign-in-attempts.d.ts.map +1 -0
- package/dist/auth/types/password.d.ts +8 -0
- package/dist/auth/types/password.d.ts.map +1 -1
- package/dist/auth/users/fields-patch.d.ts +8 -2
- package/dist/auth/users/fields-patch.d.ts.map +1 -1
- package/dist/auth/users/record-flows.d.ts.map +1 -1
- package/dist/auth/users/sign-in.d.ts +2 -2
- package/dist/auth/users/sign-in.d.ts.map +1 -1
- package/dist/auth/users/update.d.ts +12 -0
- package/dist/auth/users/update.d.ts.map +1 -0
- package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
- package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
- package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
- package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
- package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
- package/dist/conformance/index.js +3 -3
- package/dist/errors/janus-error/base.d.ts +1 -0
- package/dist/errors/janus-error/base.d.ts.map +1 -1
- package/dist/errors/janus-error/codes.d.ts +3 -1
- package/dist/errors/janus-error/codes.d.ts.map +1 -1
- package/dist/errors/janus-error/options.d.ts +9 -2
- package/dist/errors/janus-error/options.d.ts.map +1 -1
- package/dist/index.js +226 -86
- package/dist/index.js.map +20 -15
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -2
- package/docs/guide/errors.md +10 -2
- package/docs/guide/events.md +73 -12
- package/docs/guide/passwords.md +115 -17
- package/docs/guide/second-factor.md +4 -2
- package/docs/guide/users.md +11 -6
- package/docs/guide/vocabulary.md +1 -1
- package/docs/roadmap.md +27 -18
- package/docs/troubleshooting.md +55 -2
- package/package.json +1 -1
- /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
- /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
- /package/dist/chunks/{index-jp2kka6d.js.map → index-kj3st57z.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -7,13 +7,13 @@ import {
|
|
|
7
7
|
MAX_PAGE_SIZE2,
|
|
8
8
|
pageLimit2,
|
|
9
9
|
invalidCursor2
|
|
10
|
-
} from "./chunks/index-
|
|
10
|
+
} from "./chunks/index-kj3st57z.js";
|
|
11
11
|
import {
|
|
12
12
|
mintId2,
|
|
13
13
|
isId2,
|
|
14
14
|
mintedAt2,
|
|
15
15
|
createMemoryStores2
|
|
16
|
-
} from "./chunks/index-
|
|
16
|
+
} from "./chunks/index-baazjyvc.js";
|
|
17
17
|
import {
|
|
18
18
|
JanusError2,
|
|
19
19
|
UserInvalidError2,
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
formatTuple2,
|
|
39
39
|
parseTuple2,
|
|
40
40
|
parseSubject2
|
|
41
|
-
} from "./chunks/index-
|
|
41
|
+
} from "./chunks/index-hnwggyxs.js";
|
|
42
42
|
|
|
43
43
|
// src/auth/hashers.ts
|
|
44
44
|
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
@@ -296,6 +296,28 @@ function resolveSecondFactor(config, where) {
|
|
|
296
296
|
};
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
// src/auth/config/resolve-sign-in.ts
|
|
300
|
+
function resolveSignInThrottle(config, where) {
|
|
301
|
+
if (config !== undefined && (typeof config !== "object" || config === null)) {
|
|
302
|
+
throw new TypeError(`${where}: signIn must be an object — { throttle }`);
|
|
303
|
+
}
|
|
304
|
+
const throttle = config?.throttle ?? {};
|
|
305
|
+
if (throttle === false)
|
|
306
|
+
return null;
|
|
307
|
+
const at = `${where}: signIn.throttle`;
|
|
308
|
+
if (typeof throttle !== "object" || throttle === null) {
|
|
309
|
+
throw new TypeError(`${at} must be { attempts, window }, or false to count nothing`);
|
|
310
|
+
}
|
|
311
|
+
const attempts = throttle.attempts ?? 10;
|
|
312
|
+
if (!Number.isSafeInteger(attempts) || attempts < 1) {
|
|
313
|
+
throw new TypeError(`${at}.attempts must be a whole number above zero`);
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
attempts,
|
|
317
|
+
windowMs: parseDuration(throttle.window ?? "15m", `${at}.window`)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
299
321
|
// src/auth/config/resolve-type.ts
|
|
300
322
|
function resolveType(name, type, at, schemaKey) {
|
|
301
323
|
if (typeof type?.schema !== "object" || type.schema === null || typeof type.schema["~standard"]?.validate !== "function") {
|
|
@@ -342,6 +364,7 @@ function resolveConfig(config, where) {
|
|
|
342
364
|
types,
|
|
343
365
|
tokenTtlMs: resolveTokenTtls(config, where),
|
|
344
366
|
cookie: resolveCookie(config.cookie ?? {}, where),
|
|
367
|
+
signInThrottle: resolveSignInThrottle(config.signIn, where),
|
|
345
368
|
secondFactor: resolveSecondFactor(config.secondFactor, where)
|
|
346
369
|
};
|
|
347
370
|
}
|
|
@@ -632,7 +655,7 @@ function resolveEvents(events, where) {
|
|
|
632
655
|
}
|
|
633
656
|
return events;
|
|
634
657
|
}
|
|
635
|
-
async function emit(context, type, user, occurredAt) {
|
|
658
|
+
async function emit(context, type, user, occurredAt, extras = {}) {
|
|
636
659
|
const listener = context.events;
|
|
637
660
|
if (listener === null)
|
|
638
661
|
return;
|
|
@@ -641,7 +664,8 @@ async function emit(context, type, user, occurredAt) {
|
|
|
641
664
|
type,
|
|
642
665
|
occurredAt: new Date(occurredAt.getTime()),
|
|
643
666
|
userId: user.id,
|
|
644
|
-
userType: user.type
|
|
667
|
+
userType: user.type,
|
|
668
|
+
...extras
|
|
645
669
|
});
|
|
646
670
|
try {
|
|
647
671
|
await listener(event);
|
|
@@ -977,6 +1001,73 @@ async function insertOneTime(context, token, code) {
|
|
|
977
1001
|
});
|
|
978
1002
|
return { secret, expiresAt };
|
|
979
1003
|
}
|
|
1004
|
+
// src/auth/one-time/window.ts
|
|
1005
|
+
var MAX_LINKS = 32;
|
|
1006
|
+
var MAX_PROBES = 64;
|
|
1007
|
+
async function countInWindow(context, counting) {
|
|
1008
|
+
const now = context.clock.now().getTime();
|
|
1009
|
+
const window = Math.floor(now / counting.windowMs);
|
|
1010
|
+
const expiresAt = (window + 2) * counting.windowMs;
|
|
1011
|
+
const links = { context, counting, window, now, expiresAt };
|
|
1012
|
+
const counted = counting.spent === "carry" ? await countCarrying(links) : await countRestarting(links);
|
|
1013
|
+
return { ...counted, endsAt: (window + 1) * counting.windowMs };
|
|
1014
|
+
}
|
|
1015
|
+
async function countCarrying(links) {
|
|
1016
|
+
let carried = 0;
|
|
1017
|
+
for (let link = 0;link < MAX_LINKS; link += 1) {
|
|
1018
|
+
const hash = links.counting.linkHash(links.window, link);
|
|
1019
|
+
const counted = await countLink(links, hash);
|
|
1020
|
+
const attempts = carried + counted.attempts;
|
|
1021
|
+
if (counted.spentAt === null)
|
|
1022
|
+
return { attempts, link: hash };
|
|
1023
|
+
carried = attempts;
|
|
1024
|
+
}
|
|
1025
|
+
return { attempts: Number.POSITIVE_INFINITY, link: null };
|
|
1026
|
+
}
|
|
1027
|
+
async function countRestarting(links) {
|
|
1028
|
+
const tokens = links.context.store.tokens;
|
|
1029
|
+
let spent = -1;
|
|
1030
|
+
let absent = Number.POSITIVE_INFINITY;
|
|
1031
|
+
for (let probes = 0;probes < MAX_PROBES; probes += 1) {
|
|
1032
|
+
const next = absent === spent + 1;
|
|
1033
|
+
const link = next ? absent : absent === Number.POSITIVE_INFINITY ? 2 * spent + 2 : Math.floor((spent + absent) / 2);
|
|
1034
|
+
const hash = links.counting.linkHash(links.window, link);
|
|
1035
|
+
const counted = next ? await insertLink(links, hash) : await tokens.countAttempt(hash, "secondFactor");
|
|
1036
|
+
if (counted === null)
|
|
1037
|
+
absent = link;
|
|
1038
|
+
else if (counted.spentAt === null) {
|
|
1039
|
+
return { attempts: counted.attempts, link: hash };
|
|
1040
|
+
} else {
|
|
1041
|
+
spent = link;
|
|
1042
|
+
if (absent <= spent)
|
|
1043
|
+
absent = Number.POSITIVE_INFINITY;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
throw links.counting.vanished();
|
|
1047
|
+
}
|
|
1048
|
+
async function countLink(links, tokenHash) {
|
|
1049
|
+
const tokens = links.context.store.tokens;
|
|
1050
|
+
const counted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1051
|
+
return counted ?? insertLink(links, tokenHash);
|
|
1052
|
+
}
|
|
1053
|
+
async function insertLink(links, tokenHash) {
|
|
1054
|
+
const tokens = links.context.store.tokens;
|
|
1055
|
+
await tokens.insertToken({
|
|
1056
|
+
tokenHash,
|
|
1057
|
+
kind: "secondFactor",
|
|
1058
|
+
userId: links.counting.userId,
|
|
1059
|
+
address: "",
|
|
1060
|
+
codeHash: null,
|
|
1061
|
+
attempts: 0,
|
|
1062
|
+
expiresAt: new Date(links.expiresAt),
|
|
1063
|
+
spentAt: null,
|
|
1064
|
+
createdAt: new Date(links.now)
|
|
1065
|
+
});
|
|
1066
|
+
const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1067
|
+
if (inserted !== null)
|
|
1068
|
+
return inserted;
|
|
1069
|
+
throw links.counting.vanished();
|
|
1070
|
+
}
|
|
980
1071
|
// src/auth/password-written.ts
|
|
981
1072
|
async function endWhatThePasswordOpened(context, userId) {
|
|
982
1073
|
const now = context.clock.now();
|
|
@@ -985,6 +1076,13 @@ async function endWhatThePasswordOpened(context, userId) {
|
|
|
985
1076
|
if (failed !== undefined)
|
|
986
1077
|
throw failed.reason;
|
|
987
1078
|
}
|
|
1079
|
+
async function passwordChanged(context, written) {
|
|
1080
|
+
try {
|
|
1081
|
+
await endWhatThePasswordOpened(context, written.id);
|
|
1082
|
+
} finally {
|
|
1083
|
+
await emit(context, "user.passwordChanged", written, written.updatedAt);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
988
1086
|
async function heldByPassword(context, type, verified, password, result, where) {
|
|
989
1087
|
const now = await findRecord(context, verified.id, type.name);
|
|
990
1088
|
const kept = now !== null && now.password !== null && (now.password.hash === verified.password?.hash || await passwordMatches(context, now, password, where));
|
|
@@ -1101,8 +1199,78 @@ function emailFlows(context, type, at) {
|
|
|
1101
1199
|
resetPassword: resetPasswordFlows(context, type, at)
|
|
1102
1200
|
};
|
|
1103
1201
|
}
|
|
1202
|
+
// src/auth/derived-keys.ts
|
|
1203
|
+
import { createHmac, hkdfSync } from "node:crypto";
|
|
1204
|
+
var derived = new WeakMap;
|
|
1205
|
+
function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
|
|
1206
|
+
let keys = derived.get(sealer);
|
|
1207
|
+
if (keys === undefined) {
|
|
1208
|
+
keys = new Map;
|
|
1209
|
+
derived.set(sealer, keys);
|
|
1210
|
+
}
|
|
1211
|
+
const name = `${purpose}\x00${keyId}`;
|
|
1212
|
+
let key = keys.get(name);
|
|
1213
|
+
if (key === undefined) {
|
|
1214
|
+
const secret = sealer.keys.get(keyId);
|
|
1215
|
+
if (secret === undefined)
|
|
1216
|
+
return;
|
|
1217
|
+
key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), purpose, 32));
|
|
1218
|
+
keys.set(name, key);
|
|
1219
|
+
}
|
|
1220
|
+
return key;
|
|
1221
|
+
}
|
|
1222
|
+
function keyedHash(key, parts) {
|
|
1223
|
+
return createHmac("sha256", key).update(parts.join("\x00")).digest();
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// src/auth/sign-in-attempts.ts
|
|
1227
|
+
var PURPOSE = "janus/sign-in/attempts/v1";
|
|
1228
|
+
var UNSEALED = Buffer.from(PURPOSE);
|
|
1229
|
+
function counterKey(context) {
|
|
1230
|
+
const sealer = context.config.secondFactor?.sealer;
|
|
1231
|
+
return sealer === undefined ? UNSEALED : derivedKey(sealer, PURPOSE);
|
|
1232
|
+
}
|
|
1233
|
+
var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
|
|
1234
|
+
async function countLogin(context, type, login, where) {
|
|
1235
|
+
const throttle = context.config.signInThrottle;
|
|
1236
|
+
if (throttle === null)
|
|
1237
|
+
return null;
|
|
1238
|
+
const key = counterKey(context);
|
|
1239
|
+
const loginKey = keyed(key, [type.name, login]);
|
|
1240
|
+
return countInWindow(context, {
|
|
1241
|
+
windowMs: throttle.windowMs,
|
|
1242
|
+
spent: "restart",
|
|
1243
|
+
userId: loginKey,
|
|
1244
|
+
linkHash: (window, link) => hashSecret(keyed(key, [loginKey, String(window), String(link)])),
|
|
1245
|
+
vanished: () => new StoreFailure2(`${where}: the store dropped the attempts it had just stored`, { slot: "tokens", operation: "countAttempt" })
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
async function countSignInAttempt(context, type, login, where) {
|
|
1249
|
+
const counted = await countLogin(context, type, login, where);
|
|
1250
|
+
const limit = context.config.signInThrottle?.attempts;
|
|
1251
|
+
if (counted === null || limit === undefined || counted.attempts <= limit) {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const now = context.clock.now().getTime();
|
|
1255
|
+
throw new CredentialError2("CREDENTIALS_INVALID", `${where}: too many passwords tried at this login — wait for the next window`, {
|
|
1256
|
+
reason: "throttled",
|
|
1257
|
+
userType: type.name,
|
|
1258
|
+
retryAfter: Math.max(1, Math.ceil((counted.endsAt - now) / 1000))
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
async function restartSignInCount(context, type, record, where) {
|
|
1262
|
+
const rule = type.password;
|
|
1263
|
+
const login = rule === null ? undefined : record.fields[rule.login];
|
|
1264
|
+
if (rule === null || typeof login !== "string")
|
|
1265
|
+
return;
|
|
1266
|
+
const counted = await countLogin(context, type, rule.normalize(login), where);
|
|
1267
|
+
if (counted?.link == null)
|
|
1268
|
+
return;
|
|
1269
|
+
await context.store.tokens.consumeToken(counted.link, "secondFactor", context.clock.now());
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1104
1272
|
// src/auth/totp.ts
|
|
1105
|
-
import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
|
|
1273
|
+
import { createHmac as createHmac2, randomBytes as randomBytes4 } from "node:crypto";
|
|
1106
1274
|
var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
|
|
1107
1275
|
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1108
1276
|
function mintTotpSecret() {
|
|
@@ -1145,7 +1313,7 @@ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
|
|
|
1145
1313
|
function codeAt(secret, step) {
|
|
1146
1314
|
const counter = Buffer.alloc(8);
|
|
1147
1315
|
counter.writeBigUInt64BE(BigInt(step));
|
|
1148
|
-
const digest =
|
|
1316
|
+
const digest = createHmac2("sha1", secret).update(counter).digest();
|
|
1149
1317
|
const offset = digest[19] & 15;
|
|
1150
1318
|
const binary = digest.readUInt32BE(offset) & 2147483647;
|
|
1151
1319
|
return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
|
|
@@ -1272,6 +1440,7 @@ async function confirmChallenge(context, type, challenge, code, at) {
|
|
|
1272
1440
|
return refuseCode(context, type, opened, where);
|
|
1273
1441
|
const written = await context.store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
|
|
1274
1442
|
await spendChallenge(context, opened.secret, where);
|
|
1443
|
+
await restartSignInCount(context, type, written, where);
|
|
1275
1444
|
return openSession(context, type, written);
|
|
1276
1445
|
}
|
|
1277
1446
|
|
|
@@ -1283,12 +1452,12 @@ async function recoveryCodesLeftOf(context, type, user, where) {
|
|
|
1283
1452
|
}
|
|
1284
1453
|
|
|
1285
1454
|
// src/auth/second-factor/recovery-codes.ts
|
|
1286
|
-
import {
|
|
1455
|
+
import { randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
1287
1456
|
var RECOVERY_CODES = 10;
|
|
1288
1457
|
var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
1289
1458
|
var LENGTH = 10;
|
|
1290
1459
|
var SHAPE = /^[0-9a-hjkmnp-tv-z]{10}$/;
|
|
1291
|
-
var
|
|
1460
|
+
var PURPOSE2 = "janus/second-factor/recovery-codes/v1";
|
|
1292
1461
|
function mintRecoveryCodes() {
|
|
1293
1462
|
return Array.from({ length: RECOVERY_CODES }, () => {
|
|
1294
1463
|
let code = "";
|
|
@@ -1302,24 +1471,8 @@ function readRecoveryCode(typed) {
|
|
|
1302
1471
|
const code = typed.toLowerCase().replace(/[\s-]/g, "").replace(/o/g, "0").replace(/[il]/g, "1");
|
|
1303
1472
|
return SHAPE.test(code) ? code : null;
|
|
1304
1473
|
}
|
|
1305
|
-
var
|
|
1306
|
-
|
|
1307
|
-
let keys = derived.get(sealer);
|
|
1308
|
-
if (keys === undefined) {
|
|
1309
|
-
keys = new Map;
|
|
1310
|
-
derived.set(sealer, keys);
|
|
1311
|
-
}
|
|
1312
|
-
let key = keys.get(keyId);
|
|
1313
|
-
if (key === undefined) {
|
|
1314
|
-
const secret = sealer.keys.get(keyId);
|
|
1315
|
-
if (secret === undefined)
|
|
1316
|
-
return;
|
|
1317
|
-
key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), PURPOSE, 32));
|
|
1318
|
-
keys.set(keyId, key);
|
|
1319
|
-
}
|
|
1320
|
-
return key;
|
|
1321
|
-
}
|
|
1322
|
-
var mac = (key, userId, code) => createHmac2("sha256", key).update(`${userId}\x00${code}`).digest();
|
|
1474
|
+
var macKey = (sealer, keyId) => derivedKey(sealer, PURPOSE2, keyId);
|
|
1475
|
+
var mac = (key, userId, code) => keyedHash(key, [userId, code]);
|
|
1323
1476
|
function hashRecoveryCode(sealer, userId, code) {
|
|
1324
1477
|
const read = readRecoveryCode(code) ?? code;
|
|
1325
1478
|
const key = macKey(sealer, sealer.sealWith);
|
|
@@ -1427,21 +1580,9 @@ function accountOf(type, record, where) {
|
|
|
1427
1580
|
}
|
|
1428
1581
|
|
|
1429
1582
|
// src/auth/second-factor/app-code-attempts.ts
|
|
1430
|
-
import { createHmac as createHmac3, hkdfSync as hkdfSync2 } from "node:crypto";
|
|
1431
1583
|
var APP_CODE_WINDOW_MS = 15 * 60000;
|
|
1432
1584
|
var WINDOW_MINUTES = APP_CODE_WINDOW_MS / 60000;
|
|
1433
|
-
var
|
|
1434
|
-
var MAX_LINKS = 32;
|
|
1435
|
-
var derived2 = new WeakMap;
|
|
1436
|
-
function counterKey(sealer) {
|
|
1437
|
-
let key = derived2.get(sealer);
|
|
1438
|
-
if (key === undefined) {
|
|
1439
|
-
const secret = sealer.keys.get(sealer.sealWith);
|
|
1440
|
-
key = Buffer.from(hkdfSync2("sha256", secret, Buffer.alloc(0), PURPOSE2, 32));
|
|
1441
|
-
derived2.set(sealer, key);
|
|
1442
|
-
}
|
|
1443
|
-
return key;
|
|
1444
|
-
}
|
|
1585
|
+
var PURPOSE3 = "janus/second-factor/regenerate-attempts/v1";
|
|
1445
1586
|
function linkHash(sealer, record, window, link) {
|
|
1446
1587
|
const parts = [
|
|
1447
1588
|
record.id,
|
|
@@ -1449,45 +1590,21 @@ function linkHash(sealer, record, window, link) {
|
|
|
1449
1590
|
String(record.secondFactor?.lastStep ?? ""),
|
|
1450
1591
|
String(link)
|
|
1451
1592
|
];
|
|
1452
|
-
|
|
1593
|
+
const key = derivedKey(sealer, PURPOSE3);
|
|
1594
|
+
return hashSecret(keyedHash(key, parts).toString("base64url"));
|
|
1453
1595
|
}
|
|
1454
1596
|
async function countAppCodeAttempt(context, sealer, record, where) {
|
|
1455
|
-
const
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
let spent = 0;
|
|
1459
|
-
for (let link = 0;link < MAX_LINKS; link += 1) {
|
|
1460
|
-
const hash = linkHash(sealer, record, window, link);
|
|
1461
|
-
const counted = await countLink(context, hash, record, times, where);
|
|
1462
|
-
const attempts = spent + counted.attempts;
|
|
1463
|
-
if (counted.spentAt === null)
|
|
1464
|
-
return attemptsLeft(attempts, record, where);
|
|
1465
|
-
spent = attempts;
|
|
1466
|
-
}
|
|
1467
|
-
return attemptsLeft(Number.POSITIVE_INFINITY, record, where);
|
|
1468
|
-
}
|
|
1469
|
-
async function countLink(context, tokenHash, record, times, where) {
|
|
1470
|
-
const tokens = context.store.tokens;
|
|
1471
|
-
const counted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1472
|
-
if (counted !== null)
|
|
1473
|
-
return counted;
|
|
1474
|
-
await tokens.insertToken({
|
|
1475
|
-
tokenHash,
|
|
1476
|
-
kind: "secondFactor",
|
|
1597
|
+
const { attempts } = await countInWindow(context, {
|
|
1598
|
+
windowMs: APP_CODE_WINDOW_MS,
|
|
1599
|
+
spent: "carry",
|
|
1477
1600
|
userId: record.id,
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1601
|
+
linkHash: (window, link) => linkHash(sealer, record, window, link),
|
|
1602
|
+
vanished: () => new StoreConflict2("version", `${where}: the user changed while the code was checked — read it again and retry`, {
|
|
1603
|
+
userId: record.id,
|
|
1604
|
+
expectedVersion: record.version,
|
|
1605
|
+
operation: where
|
|
1606
|
+
})
|
|
1484
1607
|
});
|
|
1485
|
-
const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1486
|
-
if (inserted !== null)
|
|
1487
|
-
return inserted;
|
|
1488
|
-
throw new StoreConflict2("version", `${where}: the user changed while the code was checked — read it again and retry`, { userId: record.id, expectedVersion: record.version, operation: where });
|
|
1489
|
-
}
|
|
1490
|
-
function attemptsLeft(attempts, record, where) {
|
|
1491
1608
|
if (attempts > CODE_ATTEMPTS) {
|
|
1492
1609
|
throw new TokenError2("CODE_INVALID", `${where}: too many codes tried — wait for the next ${WINDOW_MINUTES}-minute window`, {
|
|
1493
1610
|
operation: where,
|
|
@@ -1558,6 +1675,7 @@ async function recoverWithCode(context, type, challenge, code, at) {
|
|
|
1558
1675
|
}, record.version);
|
|
1559
1676
|
try {
|
|
1560
1677
|
await spendChallenge(context, opened.secret, where);
|
|
1678
|
+
await restartSignInCount(context, type, written, where);
|
|
1561
1679
|
const signedIn = await openSession(context, type, written);
|
|
1562
1680
|
return { ...signedIn, recoveryCodesLeft: recoveryCodes.length };
|
|
1563
1681
|
} finally {
|
|
@@ -1798,7 +1916,7 @@ async function changePassword(context, type, user, change, options, where) {
|
|
|
1798
1916
|
}
|
|
1799
1917
|
};
|
|
1800
1918
|
});
|
|
1801
|
-
await
|
|
1919
|
+
await passwordChanged(context, written);
|
|
1802
1920
|
return written;
|
|
1803
1921
|
}
|
|
1804
1922
|
|
|
@@ -1840,7 +1958,11 @@ async function signIn(context, type, finish, input, where) {
|
|
|
1840
1958
|
const hasher = requireHasher(context, where);
|
|
1841
1959
|
const login = input?.[rule.login];
|
|
1842
1960
|
const password = input?.password;
|
|
1843
|
-
const
|
|
1961
|
+
const normalized = typeof login === "string" ? rule.normalize(login) : null;
|
|
1962
|
+
if (normalized !== null) {
|
|
1963
|
+
await countSignInAttempt(context, type, normalized, where);
|
|
1964
|
+
}
|
|
1965
|
+
const record = normalized === null ? null : await byLogin(context, type, normalized);
|
|
1844
1966
|
const refuse = (reason) => new CredentialError2("CREDENTIALS_INVALID", `${where}: the login and the password do not match`, { reason, userType: type.name });
|
|
1845
1967
|
if (record === null || record.password === null) {
|
|
1846
1968
|
await hasher.verify(String(password), await context.dummyHash());
|
|
@@ -1855,6 +1977,9 @@ async function signIn(context, type, finish, input, where) {
|
|
|
1855
1977
|
userType: type.name
|
|
1856
1978
|
});
|
|
1857
1979
|
}
|
|
1980
|
+
if (!isActive(record.secondFactor)) {
|
|
1981
|
+
await restartSignInCount(context, type, record, where);
|
|
1982
|
+
}
|
|
1858
1983
|
const verified = await rehashed(context, record, String(password));
|
|
1859
1984
|
const result = await finish(verified, where);
|
|
1860
1985
|
if (!await heldByPassword(context, type, verified, String(password), result, where)) {
|
|
@@ -1889,7 +2014,7 @@ function passwordFlows(context, type, at, finish) {
|
|
|
1889
2014
|
checkPassword(type, password, where);
|
|
1890
2015
|
const hash = await requireHasher(context, where).hash(password);
|
|
1891
2016
|
const written = await writeUser(context, user, type, options, where, (_, now) => ({ password: { hash, updatedAt: now } }));
|
|
1892
|
-
await
|
|
2017
|
+
await passwordChanged(context, written);
|
|
1893
2018
|
return toUser(written);
|
|
1894
2019
|
},
|
|
1895
2020
|
async changePassword(user, change, options) {
|
|
@@ -1923,19 +2048,36 @@ async function deleteUser(context, type, user) {
|
|
|
1923
2048
|
}
|
|
1924
2049
|
|
|
1925
2050
|
// src/auth/users/fields-patch.ts
|
|
2051
|
+
var normalized = (email) => email === null ? null : normalizeEmail(email);
|
|
2052
|
+
function emailChanged(type, before, after) {
|
|
2053
|
+
return normalized(emailOf(type, before)) !== normalized(emailOf(type, after));
|
|
2054
|
+
}
|
|
1926
2055
|
async function fieldsPatch(type, record, patch, where) {
|
|
1927
2056
|
const fields = await validateFields(type, { ...record.fields, ...patch }, where);
|
|
1928
|
-
const before = emailOf(type, record.fields);
|
|
1929
|
-
const after = emailOf(type, fields);
|
|
1930
|
-
const emailChanged = (before === null ? null : normalizeEmail(before)) !== (after === null ? null : normalizeEmail(after));
|
|
1931
2057
|
return {
|
|
1932
2058
|
fields,
|
|
1933
2059
|
logins: loginsOf(type, fields, where),
|
|
1934
2060
|
schemaVersion: type.schemaVersion,
|
|
1935
|
-
...emailChanged ? { emailVerifiedAt: null } : {}
|
|
2061
|
+
...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
|
|
1936
2062
|
};
|
|
1937
2063
|
}
|
|
1938
2064
|
|
|
2065
|
+
// src/auth/users/update.ts
|
|
2066
|
+
async function update(context, type, user, patch, options, where) {
|
|
2067
|
+
const seen = { replaced: null };
|
|
2068
|
+
const written = await writeUser(context, user, type, options, where, (record) => {
|
|
2069
|
+
seen.replaced = record;
|
|
2070
|
+
return fieldsPatch(type, record, patch, where);
|
|
2071
|
+
});
|
|
2072
|
+
const { replaced } = seen;
|
|
2073
|
+
if (replaced !== null && emailChanged(type, replaced.fields, written.fields)) {
|
|
2074
|
+
await emit(context, "user.emailChanged", written, written.updatedAt, {
|
|
2075
|
+
formerEmail: emailOf(type, replaced.fields)
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
return written;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
1939
2081
|
// src/auth/users/record-flows.ts
|
|
1940
2082
|
function recordFlows(context, type, at) {
|
|
1941
2083
|
const { store } = context;
|
|
@@ -1963,9 +2105,7 @@ function recordFlows(context, type, at) {
|
|
|
1963
2105
|
return { items: answer.items.map(toUser), nextCursor: answer.nextCursor };
|
|
1964
2106
|
},
|
|
1965
2107
|
async update(user, patch, options) {
|
|
1966
|
-
|
|
1967
|
-
const written = await writeUser(context, user, type, options, where, (record) => fieldsPatch(type, record, patch, where));
|
|
1968
|
-
return toUser(written);
|
|
2108
|
+
return toUser(await update(context, type, user, patch, options, at("update")));
|
|
1969
2109
|
},
|
|
1970
2110
|
async setActive(user, active, options) {
|
|
1971
2111
|
return toUser(await writeUser(context, user, type, options, at("setActive"), () => ({
|
|
@@ -2075,5 +2215,5 @@ export {
|
|
|
2075
2215
|
systemClock
|
|
2076
2216
|
};
|
|
2077
2217
|
|
|
2078
|
-
//# debugId=
|
|
2218
|
+
//# debugId=7CE655305FF58FDF64756E2164756E21
|
|
2079
2219
|
//# sourceMappingURL=index.js.map
|