@nxgt/janus 0.12.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 +62 -13
- 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/email-flows/reset-password.d.ts.map +1 -1
- 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 +28 -5
- package/dist/auth/password-written.d.ts.map +1 -1
- package/dist/auth/port/types/tokens.d.ts +3 -2
- package/dist/auth/port/types/tokens.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/email-flows.d.ts +6 -3
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- 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 +234 -89
- package/dist/index.js.map +21 -16
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -2
- package/docs/guide/adapters.md +3 -2
- package/docs/guide/email-flows.md +40 -1
- package/docs/guide/errors.md +10 -2
- package/docs/guide/events.md +74 -13
- package/docs/guide/passwords.md +123 -3
- package/docs/guide/second-factor.md +4 -2
- package/docs/guide/users.md +16 -7
- package/docs/guide/vocabulary.md +2 -2
- package/docs/roadmap.md +34 -26
- package/docs/troubleshooting.md +66 -3
- 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,9 +1001,87 @@ 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
|
-
async function
|
|
982
|
-
|
|
1072
|
+
async function endWhatThePasswordOpened(context, userId) {
|
|
1073
|
+
const now = context.clock.now();
|
|
1074
|
+
const spent = await Promise.allSettled(["resetPassword", "secondFactor"].map((kind) => context.store.tokens.spendUserTokens(userId, kind, now)));
|
|
1075
|
+
const failed = spent.find((result) => result.status === "rejected");
|
|
1076
|
+
if (failed !== undefined)
|
|
1077
|
+
throw failed.reason;
|
|
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
|
+
}
|
|
983
1085
|
}
|
|
984
1086
|
async function heldByPassword(context, type, verified, password, result, where) {
|
|
985
1087
|
const now = await findRecord(context, verified.id, type.name);
|
|
@@ -1031,6 +1133,7 @@ function resetPasswordFlows(context, type, at) {
|
|
|
1031
1133
|
if (record === null)
|
|
1032
1134
|
return null;
|
|
1033
1135
|
const issued = await issueEmailToken(context, type, "resetPassword", record, where);
|
|
1136
|
+
await context.store.tokens.spendUserTokens(record.id, "resetPassword", context.clock.now(), hashSecret(issued.token));
|
|
1034
1137
|
return { ...issued, user: toUser(record) };
|
|
1035
1138
|
},
|
|
1036
1139
|
async confirm(secret, password) {
|
|
@@ -1054,7 +1157,7 @@ async function confirmReset(context, type, secret, password, where) {
|
|
|
1054
1157
|
});
|
|
1055
1158
|
try {
|
|
1056
1159
|
await context.store.sessions.revokeUserSessions(written.id, context.clock.now());
|
|
1057
|
-
await
|
|
1160
|
+
await endWhatThePasswordOpened(context, written.id);
|
|
1058
1161
|
} finally {
|
|
1059
1162
|
await emit(context, "user.passwordReset", written, written.updatedAt);
|
|
1060
1163
|
if (newlyVerified) {
|
|
@@ -1096,8 +1199,78 @@ function emailFlows(context, type, at) {
|
|
|
1096
1199
|
resetPassword: resetPasswordFlows(context, type, at)
|
|
1097
1200
|
};
|
|
1098
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
|
+
|
|
1099
1272
|
// src/auth/totp.ts
|
|
1100
|
-
import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
|
|
1273
|
+
import { createHmac as createHmac2, randomBytes as randomBytes4 } from "node:crypto";
|
|
1101
1274
|
var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
|
|
1102
1275
|
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1103
1276
|
function mintTotpSecret() {
|
|
@@ -1140,7 +1313,7 @@ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
|
|
|
1140
1313
|
function codeAt(secret, step) {
|
|
1141
1314
|
const counter = Buffer.alloc(8);
|
|
1142
1315
|
counter.writeBigUInt64BE(BigInt(step));
|
|
1143
|
-
const digest =
|
|
1316
|
+
const digest = createHmac2("sha1", secret).update(counter).digest();
|
|
1144
1317
|
const offset = digest[19] & 15;
|
|
1145
1318
|
const binary = digest.readUInt32BE(offset) & 2147483647;
|
|
1146
1319
|
return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
|
|
@@ -1267,6 +1440,7 @@ async function confirmChallenge(context, type, challenge, code, at) {
|
|
|
1267
1440
|
return refuseCode(context, type, opened, where);
|
|
1268
1441
|
const written = await context.store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
|
|
1269
1442
|
await spendChallenge(context, opened.secret, where);
|
|
1443
|
+
await restartSignInCount(context, type, written, where);
|
|
1270
1444
|
return openSession(context, type, written);
|
|
1271
1445
|
}
|
|
1272
1446
|
|
|
@@ -1278,12 +1452,12 @@ async function recoveryCodesLeftOf(context, type, user, where) {
|
|
|
1278
1452
|
}
|
|
1279
1453
|
|
|
1280
1454
|
// src/auth/second-factor/recovery-codes.ts
|
|
1281
|
-
import {
|
|
1455
|
+
import { randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
1282
1456
|
var RECOVERY_CODES = 10;
|
|
1283
1457
|
var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
1284
1458
|
var LENGTH = 10;
|
|
1285
1459
|
var SHAPE = /^[0-9a-hjkmnp-tv-z]{10}$/;
|
|
1286
|
-
var
|
|
1460
|
+
var PURPOSE2 = "janus/second-factor/recovery-codes/v1";
|
|
1287
1461
|
function mintRecoveryCodes() {
|
|
1288
1462
|
return Array.from({ length: RECOVERY_CODES }, () => {
|
|
1289
1463
|
let code = "";
|
|
@@ -1297,24 +1471,8 @@ function readRecoveryCode(typed) {
|
|
|
1297
1471
|
const code = typed.toLowerCase().replace(/[\s-]/g, "").replace(/o/g, "0").replace(/[il]/g, "1");
|
|
1298
1472
|
return SHAPE.test(code) ? code : null;
|
|
1299
1473
|
}
|
|
1300
|
-
var
|
|
1301
|
-
|
|
1302
|
-
let keys = derived.get(sealer);
|
|
1303
|
-
if (keys === undefined) {
|
|
1304
|
-
keys = new Map;
|
|
1305
|
-
derived.set(sealer, keys);
|
|
1306
|
-
}
|
|
1307
|
-
let key = keys.get(keyId);
|
|
1308
|
-
if (key === undefined) {
|
|
1309
|
-
const secret = sealer.keys.get(keyId);
|
|
1310
|
-
if (secret === undefined)
|
|
1311
|
-
return;
|
|
1312
|
-
key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), PURPOSE, 32));
|
|
1313
|
-
keys.set(keyId, key);
|
|
1314
|
-
}
|
|
1315
|
-
return key;
|
|
1316
|
-
}
|
|
1317
|
-
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]);
|
|
1318
1476
|
function hashRecoveryCode(sealer, userId, code) {
|
|
1319
1477
|
const read = readRecoveryCode(code) ?? code;
|
|
1320
1478
|
const key = macKey(sealer, sealer.sealWith);
|
|
@@ -1422,21 +1580,9 @@ function accountOf(type, record, where) {
|
|
|
1422
1580
|
}
|
|
1423
1581
|
|
|
1424
1582
|
// src/auth/second-factor/app-code-attempts.ts
|
|
1425
|
-
import { createHmac as createHmac3, hkdfSync as hkdfSync2 } from "node:crypto";
|
|
1426
1583
|
var APP_CODE_WINDOW_MS = 15 * 60000;
|
|
1427
1584
|
var WINDOW_MINUTES = APP_CODE_WINDOW_MS / 60000;
|
|
1428
|
-
var
|
|
1429
|
-
var MAX_LINKS = 32;
|
|
1430
|
-
var derived2 = new WeakMap;
|
|
1431
|
-
function counterKey(sealer) {
|
|
1432
|
-
let key = derived2.get(sealer);
|
|
1433
|
-
if (key === undefined) {
|
|
1434
|
-
const secret = sealer.keys.get(sealer.sealWith);
|
|
1435
|
-
key = Buffer.from(hkdfSync2("sha256", secret, Buffer.alloc(0), PURPOSE2, 32));
|
|
1436
|
-
derived2.set(sealer, key);
|
|
1437
|
-
}
|
|
1438
|
-
return key;
|
|
1439
|
-
}
|
|
1585
|
+
var PURPOSE3 = "janus/second-factor/regenerate-attempts/v1";
|
|
1440
1586
|
function linkHash(sealer, record, window, link) {
|
|
1441
1587
|
const parts = [
|
|
1442
1588
|
record.id,
|
|
@@ -1444,45 +1590,21 @@ function linkHash(sealer, record, window, link) {
|
|
|
1444
1590
|
String(record.secondFactor?.lastStep ?? ""),
|
|
1445
1591
|
String(link)
|
|
1446
1592
|
];
|
|
1447
|
-
|
|
1593
|
+
const key = derivedKey(sealer, PURPOSE3);
|
|
1594
|
+
return hashSecret(keyedHash(key, parts).toString("base64url"));
|
|
1448
1595
|
}
|
|
1449
1596
|
async function countAppCodeAttempt(context, sealer, record, where) {
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
let spent = 0;
|
|
1454
|
-
for (let link = 0;link < MAX_LINKS; link += 1) {
|
|
1455
|
-
const hash = linkHash(sealer, record, window, link);
|
|
1456
|
-
const counted = await countLink(context, hash, record, times, where);
|
|
1457
|
-
const attempts = spent + counted.attempts;
|
|
1458
|
-
if (counted.spentAt === null)
|
|
1459
|
-
return attemptsLeft(attempts, record, where);
|
|
1460
|
-
spent = attempts;
|
|
1461
|
-
}
|
|
1462
|
-
return attemptsLeft(Number.POSITIVE_INFINITY, record, where);
|
|
1463
|
-
}
|
|
1464
|
-
async function countLink(context, tokenHash, record, times, where) {
|
|
1465
|
-
const tokens = context.store.tokens;
|
|
1466
|
-
const counted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1467
|
-
if (counted !== null)
|
|
1468
|
-
return counted;
|
|
1469
|
-
await tokens.insertToken({
|
|
1470
|
-
tokenHash,
|
|
1471
|
-
kind: "secondFactor",
|
|
1597
|
+
const { attempts } = await countInWindow(context, {
|
|
1598
|
+
windowMs: APP_CODE_WINDOW_MS,
|
|
1599
|
+
spent: "carry",
|
|
1472
1600
|
userId: record.id,
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
+
})
|
|
1479
1607
|
});
|
|
1480
|
-
const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1481
|
-
if (inserted !== null)
|
|
1482
|
-
return inserted;
|
|
1483
|
-
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 });
|
|
1484
|
-
}
|
|
1485
|
-
function attemptsLeft(attempts, record, where) {
|
|
1486
1608
|
if (attempts > CODE_ATTEMPTS) {
|
|
1487
1609
|
throw new TokenError2("CODE_INVALID", `${where}: too many codes tried — wait for the next ${WINDOW_MINUTES}-minute window`, {
|
|
1488
1610
|
operation: where,
|
|
@@ -1553,6 +1675,7 @@ async function recoverWithCode(context, type, challenge, code, at) {
|
|
|
1553
1675
|
}, record.version);
|
|
1554
1676
|
try {
|
|
1555
1677
|
await spendChallenge(context, opened.secret, where);
|
|
1678
|
+
await restartSignInCount(context, type, written, where);
|
|
1556
1679
|
const signedIn = await openSession(context, type, written);
|
|
1557
1680
|
return { ...signedIn, recoveryCodesLeft: recoveryCodes.length };
|
|
1558
1681
|
} finally {
|
|
@@ -1793,7 +1916,7 @@ async function changePassword(context, type, user, change, options, where) {
|
|
|
1793
1916
|
}
|
|
1794
1917
|
};
|
|
1795
1918
|
});
|
|
1796
|
-
await
|
|
1919
|
+
await passwordChanged(context, written);
|
|
1797
1920
|
return written;
|
|
1798
1921
|
}
|
|
1799
1922
|
|
|
@@ -1835,7 +1958,11 @@ async function signIn(context, type, finish, input, where) {
|
|
|
1835
1958
|
const hasher = requireHasher(context, where);
|
|
1836
1959
|
const login = input?.[rule.login];
|
|
1837
1960
|
const password = input?.password;
|
|
1838
|
-
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);
|
|
1839
1966
|
const refuse = (reason) => new CredentialError2("CREDENTIALS_INVALID", `${where}: the login and the password do not match`, { reason, userType: type.name });
|
|
1840
1967
|
if (record === null || record.password === null) {
|
|
1841
1968
|
await hasher.verify(String(password), await context.dummyHash());
|
|
@@ -1850,6 +1977,9 @@ async function signIn(context, type, finish, input, where) {
|
|
|
1850
1977
|
userType: type.name
|
|
1851
1978
|
});
|
|
1852
1979
|
}
|
|
1980
|
+
if (!isActive(record.secondFactor)) {
|
|
1981
|
+
await restartSignInCount(context, type, record, where);
|
|
1982
|
+
}
|
|
1853
1983
|
const verified = await rehashed(context, record, String(password));
|
|
1854
1984
|
const result = await finish(verified, where);
|
|
1855
1985
|
if (!await heldByPassword(context, type, verified, String(password), result, where)) {
|
|
@@ -1884,7 +2014,7 @@ function passwordFlows(context, type, at, finish) {
|
|
|
1884
2014
|
checkPassword(type, password, where);
|
|
1885
2015
|
const hash = await requireHasher(context, where).hash(password);
|
|
1886
2016
|
const written = await writeUser(context, user, type, options, where, (_, now) => ({ password: { hash, updatedAt: now } }));
|
|
1887
|
-
await
|
|
2017
|
+
await passwordChanged(context, written);
|
|
1888
2018
|
return toUser(written);
|
|
1889
2019
|
},
|
|
1890
2020
|
async changePassword(user, change, options) {
|
|
@@ -1918,19 +2048,36 @@ async function deleteUser(context, type, user) {
|
|
|
1918
2048
|
}
|
|
1919
2049
|
|
|
1920
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
|
+
}
|
|
1921
2055
|
async function fieldsPatch(type, record, patch, where) {
|
|
1922
2056
|
const fields = await validateFields(type, { ...record.fields, ...patch }, where);
|
|
1923
|
-
const before = emailOf(type, record.fields);
|
|
1924
|
-
const after = emailOf(type, fields);
|
|
1925
|
-
const emailChanged = (before === null ? null : normalizeEmail(before)) !== (after === null ? null : normalizeEmail(after));
|
|
1926
2057
|
return {
|
|
1927
2058
|
fields,
|
|
1928
2059
|
logins: loginsOf(type, fields, where),
|
|
1929
2060
|
schemaVersion: type.schemaVersion,
|
|
1930
|
-
...emailChanged ? { emailVerifiedAt: null } : {}
|
|
2061
|
+
...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
|
|
1931
2062
|
};
|
|
1932
2063
|
}
|
|
1933
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
|
+
|
|
1934
2081
|
// src/auth/users/record-flows.ts
|
|
1935
2082
|
function recordFlows(context, type, at) {
|
|
1936
2083
|
const { store } = context;
|
|
@@ -1958,9 +2105,7 @@ function recordFlows(context, type, at) {
|
|
|
1958
2105
|
return { items: answer.items.map(toUser), nextCursor: answer.nextCursor };
|
|
1959
2106
|
},
|
|
1960
2107
|
async update(user, patch, options) {
|
|
1961
|
-
|
|
1962
|
-
const written = await writeUser(context, user, type, options, where, (record) => fieldsPatch(type, record, patch, where));
|
|
1963
|
-
return toUser(written);
|
|
2108
|
+
return toUser(await update(context, type, user, patch, options, at("update")));
|
|
1964
2109
|
},
|
|
1965
2110
|
async setActive(user, active, options) {
|
|
1966
2111
|
return toUser(await writeUser(context, user, type, options, at("setActive"), () => ({
|
|
@@ -2070,5 +2215,5 @@ export {
|
|
|
2070
2215
|
systemClock
|
|
2071
2216
|
};
|
|
2072
2217
|
|
|
2073
|
-
//# debugId=
|
|
2218
|
+
//# debugId=7CE655305FF58FDF64756E2164756E21
|
|
2074
2219
|
//# sourceMappingURL=index.js.map
|