@nxgt/janus 0.13.0 → 0.15.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 +132 -25
- 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 +25 -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 +6 -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-sign-in.d.ts +40 -0
- package/dist/auth/email-sign-in.d.ts.map +1 -0
- package/dist/auth/events.d.ts +22 -6
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/magic-link.d.ts +22 -0
- package/dist/auth/magic-link.d.ts.map +1 -0
- 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/port/types/tokens.d.ts +6 -3
- 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/sign-in-code.d.ts +1 -3
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +3 -3
- package/dist/auth/types/index.d.ts +1 -0
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +8 -1
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/types/magic-link.d.ts +46 -0
- package/dist/auth/types/magic-link.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/any-type-api.d.ts +2 -2
- package/dist/auth/users/any-type-api.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/type-api.d.ts +2 -1
- package/dist/auth/users/type-api.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/cases/tokens/kinds.d.ts +4 -1
- package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
- package/dist/conformance/index.js +26 -4
- package/dist/conformance/index.js.map +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 +296 -101
- package/dist/index.js.map +24 -17
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +4 -3
- package/docs/guide/adapters.md +11 -7
- package/docs/guide/email-flows.md +11 -7
- package/docs/guide/errors.md +12 -4
- package/docs/guide/events.md +75 -14
- package/docs/guide/magic-link.md +411 -0
- package/docs/guide/passwords.md +116 -17
- package/docs/guide/second-factor.md +6 -3
- package/docs/guide/sign-in-code.md +14 -6
- package/docs/guide/users.md +13 -7
- package/docs/guide/vocabulary.md +6 -5
- package/docs/roadmap.md +37 -23
- package/docs/troubleshooting.md +203 -9
- 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
|
}
|
|
@@ -384,6 +407,7 @@ function resolveTokenTtls(config, where) {
|
|
|
384
407
|
verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
|
|
385
408
|
resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
|
|
386
409
|
signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`),
|
|
410
|
+
magicLink: parseDuration(config.tokens?.magicLink ?? "15m", `${where}: tokens.magicLink`),
|
|
387
411
|
stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`)
|
|
388
412
|
};
|
|
389
413
|
}
|
|
@@ -632,7 +656,7 @@ function resolveEvents(events, where) {
|
|
|
632
656
|
}
|
|
633
657
|
return events;
|
|
634
658
|
}
|
|
635
|
-
async function emit(context, type, user, occurredAt) {
|
|
659
|
+
async function emit(context, type, user, occurredAt, extras = {}) {
|
|
636
660
|
const listener = context.events;
|
|
637
661
|
if (listener === null)
|
|
638
662
|
return;
|
|
@@ -641,7 +665,8 @@ async function emit(context, type, user, occurredAt) {
|
|
|
641
665
|
type,
|
|
642
666
|
occurredAt: new Date(occurredAt.getTime()),
|
|
643
667
|
userId: user.id,
|
|
644
|
-
userType: user.type
|
|
668
|
+
userType: user.type,
|
|
669
|
+
...extras
|
|
645
670
|
});
|
|
646
671
|
try {
|
|
647
672
|
await listener(event);
|
|
@@ -977,6 +1002,73 @@ async function insertOneTime(context, token, code) {
|
|
|
977
1002
|
});
|
|
978
1003
|
return { secret, expiresAt };
|
|
979
1004
|
}
|
|
1005
|
+
// src/auth/one-time/window.ts
|
|
1006
|
+
var MAX_LINKS = 32;
|
|
1007
|
+
var MAX_PROBES = 64;
|
|
1008
|
+
async function countInWindow(context, counting) {
|
|
1009
|
+
const now = context.clock.now().getTime();
|
|
1010
|
+
const window = Math.floor(now / counting.windowMs);
|
|
1011
|
+
const expiresAt = (window + 2) * counting.windowMs;
|
|
1012
|
+
const links = { context, counting, window, now, expiresAt };
|
|
1013
|
+
const counted = counting.spent === "carry" ? await countCarrying(links) : await countRestarting(links);
|
|
1014
|
+
return { ...counted, endsAt: (window + 1) * counting.windowMs };
|
|
1015
|
+
}
|
|
1016
|
+
async function countCarrying(links) {
|
|
1017
|
+
let carried = 0;
|
|
1018
|
+
for (let link = 0;link < MAX_LINKS; link += 1) {
|
|
1019
|
+
const hash = links.counting.linkHash(links.window, link);
|
|
1020
|
+
const counted = await countLink(links, hash);
|
|
1021
|
+
const attempts = carried + counted.attempts;
|
|
1022
|
+
if (counted.spentAt === null)
|
|
1023
|
+
return { attempts, link: hash };
|
|
1024
|
+
carried = attempts;
|
|
1025
|
+
}
|
|
1026
|
+
return { attempts: Number.POSITIVE_INFINITY, link: null };
|
|
1027
|
+
}
|
|
1028
|
+
async function countRestarting(links) {
|
|
1029
|
+
const tokens = links.context.store.tokens;
|
|
1030
|
+
let spent = -1;
|
|
1031
|
+
let absent = Number.POSITIVE_INFINITY;
|
|
1032
|
+
for (let probes = 0;probes < MAX_PROBES; probes += 1) {
|
|
1033
|
+
const next = absent === spent + 1;
|
|
1034
|
+
const link = next ? absent : absent === Number.POSITIVE_INFINITY ? 2 * spent + 2 : Math.floor((spent + absent) / 2);
|
|
1035
|
+
const hash = links.counting.linkHash(links.window, link);
|
|
1036
|
+
const counted = next ? await insertLink(links, hash) : await tokens.countAttempt(hash, "secondFactor");
|
|
1037
|
+
if (counted === null)
|
|
1038
|
+
absent = link;
|
|
1039
|
+
else if (counted.spentAt === null) {
|
|
1040
|
+
return { attempts: counted.attempts, link: hash };
|
|
1041
|
+
} else {
|
|
1042
|
+
spent = link;
|
|
1043
|
+
if (absent <= spent)
|
|
1044
|
+
absent = Number.POSITIVE_INFINITY;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
throw links.counting.vanished();
|
|
1048
|
+
}
|
|
1049
|
+
async function countLink(links, tokenHash) {
|
|
1050
|
+
const tokens = links.context.store.tokens;
|
|
1051
|
+
const counted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1052
|
+
return counted ?? insertLink(links, tokenHash);
|
|
1053
|
+
}
|
|
1054
|
+
async function insertLink(links, tokenHash) {
|
|
1055
|
+
const tokens = links.context.store.tokens;
|
|
1056
|
+
await tokens.insertToken({
|
|
1057
|
+
tokenHash,
|
|
1058
|
+
kind: "secondFactor",
|
|
1059
|
+
userId: links.counting.userId,
|
|
1060
|
+
address: "",
|
|
1061
|
+
codeHash: null,
|
|
1062
|
+
attempts: 0,
|
|
1063
|
+
expiresAt: new Date(links.expiresAt),
|
|
1064
|
+
spentAt: null,
|
|
1065
|
+
createdAt: new Date(links.now)
|
|
1066
|
+
});
|
|
1067
|
+
const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
|
|
1068
|
+
if (inserted !== null)
|
|
1069
|
+
return inserted;
|
|
1070
|
+
throw links.counting.vanished();
|
|
1071
|
+
}
|
|
980
1072
|
// src/auth/password-written.ts
|
|
981
1073
|
async function endWhatThePasswordOpened(context, userId) {
|
|
982
1074
|
const now = context.clock.now();
|
|
@@ -985,6 +1077,13 @@ async function endWhatThePasswordOpened(context, userId) {
|
|
|
985
1077
|
if (failed !== undefined)
|
|
986
1078
|
throw failed.reason;
|
|
987
1079
|
}
|
|
1080
|
+
async function passwordChanged(context, written) {
|
|
1081
|
+
try {
|
|
1082
|
+
await endWhatThePasswordOpened(context, written.id);
|
|
1083
|
+
} finally {
|
|
1084
|
+
await emit(context, "user.passwordChanged", written, written.updatedAt);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
988
1087
|
async function heldByPassword(context, type, verified, password, result, where) {
|
|
989
1088
|
const now = await findRecord(context, verified.id, type.name);
|
|
990
1089
|
const kept = now !== null && now.password !== null && (now.password.hash === verified.password?.hash || await passwordMatches(context, now, password, where));
|
|
@@ -1101,8 +1200,137 @@ function emailFlows(context, type, at) {
|
|
|
1101
1200
|
resetPassword: resetPasswordFlows(context, type, at)
|
|
1102
1201
|
};
|
|
1103
1202
|
}
|
|
1203
|
+
// src/auth/email-sign-in.ts
|
|
1204
|
+
async function signInHolder(context, type, email) {
|
|
1205
|
+
const record = await holderOfEmail(context, type, email);
|
|
1206
|
+
return record === null || !record.active ? null : record;
|
|
1207
|
+
}
|
|
1208
|
+
async function keepOnlyLatest(context, userId, kind, secret) {
|
|
1209
|
+
await context.store.tokens.spendUserTokens(userId, kind, context.clock.now(), hashSecret(secret));
|
|
1210
|
+
}
|
|
1211
|
+
async function finishEmailSignIn(context, type, finish, signIn) {
|
|
1212
|
+
const { user, token, where, noun } = signIn;
|
|
1213
|
+
refuseStale(type, user, token, where, noun);
|
|
1214
|
+
if (!user.active) {
|
|
1215
|
+
throw new UserInactiveError2(`${where}: the user is inactive`, {
|
|
1216
|
+
userId: user.id,
|
|
1217
|
+
userType: type.name
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
if (user.emailVerifiedAt !== null)
|
|
1221
|
+
return finish(user, where);
|
|
1222
|
+
const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
|
|
1223
|
+
await emit(context, "user.emailVerified", proved, proved.updatedAt);
|
|
1224
|
+
return finish(proved, where);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/auth/magic-link.ts
|
|
1228
|
+
function magicLinkFlows(context, type, at, finish) {
|
|
1229
|
+
return {
|
|
1230
|
+
async request(email) {
|
|
1231
|
+
return requestLink(context, type, String(email));
|
|
1232
|
+
},
|
|
1233
|
+
async confirm(token) {
|
|
1234
|
+
return confirmLink(context, type, finish, String(token), at("magicLink.confirm"));
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
async function requestLink(context, type, email) {
|
|
1239
|
+
const record = await signInHolder(context, type, email);
|
|
1240
|
+
if (record === null)
|
|
1241
|
+
return null;
|
|
1242
|
+
const address = String(record.fields[type.email]);
|
|
1243
|
+
const { secret, expiresAt } = await issueOneTime(context, {
|
|
1244
|
+
kind: "magicLink",
|
|
1245
|
+
userId: record.id,
|
|
1246
|
+
address,
|
|
1247
|
+
ttlMs: context.config.tokenTtlMs.magicLink
|
|
1248
|
+
});
|
|
1249
|
+
await keepOnlyLatest(context, record.id, "magicLink", secret);
|
|
1250
|
+
return { token: secret, email: address, expiresAt, user: toUser(record) };
|
|
1251
|
+
}
|
|
1252
|
+
async function confirmLink(context, type, finish, secret, where) {
|
|
1253
|
+
const { token, user } = await redeemEmailToken(context, type, "magicLink", secret, where);
|
|
1254
|
+
return finishEmailSignIn(context, type, finish, {
|
|
1255
|
+
user,
|
|
1256
|
+
token,
|
|
1257
|
+
where,
|
|
1258
|
+
noun: "token"
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// src/auth/derived-keys.ts
|
|
1263
|
+
import { createHmac, hkdfSync } from "node:crypto";
|
|
1264
|
+
var derived = new WeakMap;
|
|
1265
|
+
function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
|
|
1266
|
+
let keys = derived.get(sealer);
|
|
1267
|
+
if (keys === undefined) {
|
|
1268
|
+
keys = new Map;
|
|
1269
|
+
derived.set(sealer, keys);
|
|
1270
|
+
}
|
|
1271
|
+
const name = `${purpose}\x00${keyId}`;
|
|
1272
|
+
let key = keys.get(name);
|
|
1273
|
+
if (key === undefined) {
|
|
1274
|
+
const secret = sealer.keys.get(keyId);
|
|
1275
|
+
if (secret === undefined)
|
|
1276
|
+
return;
|
|
1277
|
+
key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), purpose, 32));
|
|
1278
|
+
keys.set(name, key);
|
|
1279
|
+
}
|
|
1280
|
+
return key;
|
|
1281
|
+
}
|
|
1282
|
+
function keyedHash(key, parts) {
|
|
1283
|
+
return createHmac("sha256", key).update(parts.join("\x00")).digest();
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// src/auth/sign-in-attempts.ts
|
|
1287
|
+
var PURPOSE = "janus/sign-in/attempts/v1";
|
|
1288
|
+
var UNSEALED = Buffer.from(PURPOSE);
|
|
1289
|
+
function counterKey(context) {
|
|
1290
|
+
const sealer = context.config.secondFactor?.sealer;
|
|
1291
|
+
return sealer === undefined ? UNSEALED : derivedKey(sealer, PURPOSE);
|
|
1292
|
+
}
|
|
1293
|
+
var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
|
|
1294
|
+
async function countLogin(context, type, login, where) {
|
|
1295
|
+
const throttle = context.config.signInThrottle;
|
|
1296
|
+
if (throttle === null)
|
|
1297
|
+
return null;
|
|
1298
|
+
const key = counterKey(context);
|
|
1299
|
+
const loginKey = keyed(key, [type.name, login]);
|
|
1300
|
+
return countInWindow(context, {
|
|
1301
|
+
windowMs: throttle.windowMs,
|
|
1302
|
+
spent: "restart",
|
|
1303
|
+
userId: loginKey,
|
|
1304
|
+
linkHash: (window, link) => hashSecret(keyed(key, [loginKey, String(window), String(link)])),
|
|
1305
|
+
vanished: () => new StoreFailure2(`${where}: the store dropped the attempts it had just stored`, { slot: "tokens", operation: "countAttempt" })
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
async function countSignInAttempt(context, type, login, where) {
|
|
1309
|
+
const counted = await countLogin(context, type, login, where);
|
|
1310
|
+
const limit = context.config.signInThrottle?.attempts;
|
|
1311
|
+
if (counted === null || limit === undefined || counted.attempts <= limit) {
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const now = context.clock.now().getTime();
|
|
1315
|
+
throw new CredentialError2("CREDENTIALS_INVALID", `${where}: too many passwords tried at this login — wait for the next window`, {
|
|
1316
|
+
reason: "throttled",
|
|
1317
|
+
userType: type.name,
|
|
1318
|
+
retryAfter: Math.max(1, Math.ceil((counted.endsAt - now) / 1000))
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
async function restartSignInCount(context, type, record, where) {
|
|
1322
|
+
const rule = type.password;
|
|
1323
|
+
const login = rule === null ? undefined : record.fields[rule.login];
|
|
1324
|
+
if (rule === null || typeof login !== "string")
|
|
1325
|
+
return;
|
|
1326
|
+
const counted = await countLogin(context, type, rule.normalize(login), where);
|
|
1327
|
+
if (counted?.link == null)
|
|
1328
|
+
return;
|
|
1329
|
+
await context.store.tokens.consumeToken(counted.link, "secondFactor", context.clock.now());
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1104
1332
|
// src/auth/totp.ts
|
|
1105
|
-
import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
|
|
1333
|
+
import { createHmac as createHmac2, randomBytes as randomBytes4 } from "node:crypto";
|
|
1106
1334
|
var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
|
|
1107
1335
|
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1108
1336
|
function mintTotpSecret() {
|
|
@@ -1145,7 +1373,7 @@ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
|
|
|
1145
1373
|
function codeAt(secret, step) {
|
|
1146
1374
|
const counter = Buffer.alloc(8);
|
|
1147
1375
|
counter.writeBigUInt64BE(BigInt(step));
|
|
1148
|
-
const digest =
|
|
1376
|
+
const digest = createHmac2("sha1", secret).update(counter).digest();
|
|
1149
1377
|
const offset = digest[19] & 15;
|
|
1150
1378
|
const binary = digest.readUInt32BE(offset) & 2147483647;
|
|
1151
1379
|
return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
|
|
@@ -1272,6 +1500,7 @@ async function confirmChallenge(context, type, challenge, code, at) {
|
|
|
1272
1500
|
return refuseCode(context, type, opened, where);
|
|
1273
1501
|
const written = await context.store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
|
|
1274
1502
|
await spendChallenge(context, opened.secret, where);
|
|
1503
|
+
await restartSignInCount(context, type, written, where);
|
|
1275
1504
|
return openSession(context, type, written);
|
|
1276
1505
|
}
|
|
1277
1506
|
|
|
@@ -1283,12 +1512,12 @@ async function recoveryCodesLeftOf(context, type, user, where) {
|
|
|
1283
1512
|
}
|
|
1284
1513
|
|
|
1285
1514
|
// src/auth/second-factor/recovery-codes.ts
|
|
1286
|
-
import {
|
|
1515
|
+
import { randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
1287
1516
|
var RECOVERY_CODES = 10;
|
|
1288
1517
|
var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
1289
1518
|
var LENGTH = 10;
|
|
1290
1519
|
var SHAPE = /^[0-9a-hjkmnp-tv-z]{10}$/;
|
|
1291
|
-
var
|
|
1520
|
+
var PURPOSE2 = "janus/second-factor/recovery-codes/v1";
|
|
1292
1521
|
function mintRecoveryCodes() {
|
|
1293
1522
|
return Array.from({ length: RECOVERY_CODES }, () => {
|
|
1294
1523
|
let code = "";
|
|
@@ -1302,24 +1531,8 @@ function readRecoveryCode(typed) {
|
|
|
1302
1531
|
const code = typed.toLowerCase().replace(/[\s-]/g, "").replace(/o/g, "0").replace(/[il]/g, "1");
|
|
1303
1532
|
return SHAPE.test(code) ? code : null;
|
|
1304
1533
|
}
|
|
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();
|
|
1534
|
+
var macKey = (sealer, keyId) => derivedKey(sealer, PURPOSE2, keyId);
|
|
1535
|
+
var mac = (key, userId, code) => keyedHash(key, [userId, code]);
|
|
1323
1536
|
function hashRecoveryCode(sealer, userId, code) {
|
|
1324
1537
|
const read = readRecoveryCode(code) ?? code;
|
|
1325
1538
|
const key = macKey(sealer, sealer.sealWith);
|
|
@@ -1427,21 +1640,9 @@ function accountOf(type, record, where) {
|
|
|
1427
1640
|
}
|
|
1428
1641
|
|
|
1429
1642
|
// src/auth/second-factor/app-code-attempts.ts
|
|
1430
|
-
import { createHmac as createHmac3, hkdfSync as hkdfSync2 } from "node:crypto";
|
|
1431
1643
|
var APP_CODE_WINDOW_MS = 15 * 60000;
|
|
1432
1644
|
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
|
-
}
|
|
1645
|
+
var PURPOSE3 = "janus/second-factor/regenerate-attempts/v1";
|
|
1445
1646
|
function linkHash(sealer, record, window, link) {
|
|
1446
1647
|
const parts = [
|
|
1447
1648
|
record.id,
|
|
@@ -1449,45 +1650,21 @@ function linkHash(sealer, record, window, link) {
|
|
|
1449
1650
|
String(record.secondFactor?.lastStep ?? ""),
|
|
1450
1651
|
String(link)
|
|
1451
1652
|
];
|
|
1452
|
-
|
|
1653
|
+
const key = derivedKey(sealer, PURPOSE3);
|
|
1654
|
+
return hashSecret(keyedHash(key, parts).toString("base64url"));
|
|
1453
1655
|
}
|
|
1454
1656
|
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",
|
|
1657
|
+
const { attempts } = await countInWindow(context, {
|
|
1658
|
+
windowMs: APP_CODE_WINDOW_MS,
|
|
1659
|
+
spent: "carry",
|
|
1477
1660
|
userId: record.id,
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1661
|
+
linkHash: (window, link) => linkHash(sealer, record, window, link),
|
|
1662
|
+
vanished: () => new StoreConflict2("version", `${where}: the user changed while the code was checked — read it again and retry`, {
|
|
1663
|
+
userId: record.id,
|
|
1664
|
+
expectedVersion: record.version,
|
|
1665
|
+
operation: where
|
|
1666
|
+
})
|
|
1484
1667
|
});
|
|
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
1668
|
if (attempts > CODE_ATTEMPTS) {
|
|
1492
1669
|
throw new TokenError2("CODE_INVALID", `${where}: too many codes tried — wait for the next ${WINDOW_MINUTES}-minute window`, {
|
|
1493
1670
|
operation: where,
|
|
@@ -1558,6 +1735,7 @@ async function recoverWithCode(context, type, challenge, code, at) {
|
|
|
1558
1735
|
}, record.version);
|
|
1559
1736
|
try {
|
|
1560
1737
|
await spendChallenge(context, opened.secret, where);
|
|
1738
|
+
await restartSignInCount(context, type, written, where);
|
|
1561
1739
|
const signedIn = await openSession(context, type, written);
|
|
1562
1740
|
return { ...signedIn, recoveryCodesLeft: recoveryCodes.length };
|
|
1563
1741
|
} finally {
|
|
@@ -1592,8 +1770,8 @@ function signInCodeFlows(context, type, at, finish) {
|
|
|
1592
1770
|
};
|
|
1593
1771
|
}
|
|
1594
1772
|
async function requestCode(context, type, email) {
|
|
1595
|
-
const record = await
|
|
1596
|
-
if (record === null
|
|
1773
|
+
const record = await signInHolder(context, type, email);
|
|
1774
|
+
if (record === null)
|
|
1597
1775
|
return null;
|
|
1598
1776
|
const { secret, code, expiresAt } = await issueCode(context, {
|
|
1599
1777
|
kind: "signInCode",
|
|
@@ -1601,7 +1779,7 @@ async function requestCode(context, type, email) {
|
|
|
1601
1779
|
address: String(record.fields[type.email]),
|
|
1602
1780
|
ttlMs: context.config.tokenTtlMs.signInCode
|
|
1603
1781
|
});
|
|
1604
|
-
await context
|
|
1782
|
+
await keepOnlyLatest(context, record.id, "signInCode", secret);
|
|
1605
1783
|
return {
|
|
1606
1784
|
code,
|
|
1607
1785
|
challenge: secret,
|
|
@@ -1625,18 +1803,12 @@ async function confirmCode(context, type, finish, challenge, code, at) {
|
|
|
1625
1803
|
throw codeInvalid(where, user.id, type.name, attemptsLeft);
|
|
1626
1804
|
}
|
|
1627
1805
|
await spendOneTime(context, secret, "signInCode", where, "challenge");
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
}
|
|
1635
|
-
if (user.emailVerifiedAt !== null)
|
|
1636
|
-
return finish(user, where);
|
|
1637
|
-
const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
|
|
1638
|
-
await emit(context, "user.emailVerified", proved, proved.updatedAt);
|
|
1639
|
-
return finish(proved, where);
|
|
1806
|
+
return finishEmailSignIn(context, type, finish, {
|
|
1807
|
+
user,
|
|
1808
|
+
token,
|
|
1809
|
+
where,
|
|
1810
|
+
noun: "code"
|
|
1811
|
+
});
|
|
1640
1812
|
}
|
|
1641
1813
|
|
|
1642
1814
|
// src/auth/step-up/codes.ts
|
|
@@ -1798,7 +1970,7 @@ async function changePassword(context, type, user, change, options, where) {
|
|
|
1798
1970
|
}
|
|
1799
1971
|
};
|
|
1800
1972
|
});
|
|
1801
|
-
await
|
|
1973
|
+
await passwordChanged(context, written);
|
|
1802
1974
|
return written;
|
|
1803
1975
|
}
|
|
1804
1976
|
|
|
@@ -1840,7 +2012,11 @@ async function signIn(context, type, finish, input, where) {
|
|
|
1840
2012
|
const hasher = requireHasher(context, where);
|
|
1841
2013
|
const login = input?.[rule.login];
|
|
1842
2014
|
const password = input?.password;
|
|
1843
|
-
const
|
|
2015
|
+
const normalized = typeof login === "string" ? rule.normalize(login) : null;
|
|
2016
|
+
if (normalized !== null) {
|
|
2017
|
+
await countSignInAttempt(context, type, normalized, where);
|
|
2018
|
+
}
|
|
2019
|
+
const record = normalized === null ? null : await byLogin(context, type, normalized);
|
|
1844
2020
|
const refuse = (reason) => new CredentialError2("CREDENTIALS_INVALID", `${where}: the login and the password do not match`, { reason, userType: type.name });
|
|
1845
2021
|
if (record === null || record.password === null) {
|
|
1846
2022
|
await hasher.verify(String(password), await context.dummyHash());
|
|
@@ -1855,6 +2031,9 @@ async function signIn(context, type, finish, input, where) {
|
|
|
1855
2031
|
userType: type.name
|
|
1856
2032
|
});
|
|
1857
2033
|
}
|
|
2034
|
+
if (!isActive(record.secondFactor)) {
|
|
2035
|
+
await restartSignInCount(context, type, record, where);
|
|
2036
|
+
}
|
|
1858
2037
|
const verified = await rehashed(context, record, String(password));
|
|
1859
2038
|
const result = await finish(verified, where);
|
|
1860
2039
|
if (!await heldByPassword(context, type, verified, String(password), result, where)) {
|
|
@@ -1889,7 +2068,7 @@ function passwordFlows(context, type, at, finish) {
|
|
|
1889
2068
|
checkPassword(type, password, where);
|
|
1890
2069
|
const hash = await requireHasher(context, where).hash(password);
|
|
1891
2070
|
const written = await writeUser(context, user, type, options, where, (_, now) => ({ password: { hash, updatedAt: now } }));
|
|
1892
|
-
await
|
|
2071
|
+
await passwordChanged(context, written);
|
|
1893
2072
|
return toUser(written);
|
|
1894
2073
|
},
|
|
1895
2074
|
async changePassword(user, change, options) {
|
|
@@ -1923,19 +2102,36 @@ async function deleteUser(context, type, user) {
|
|
|
1923
2102
|
}
|
|
1924
2103
|
|
|
1925
2104
|
// src/auth/users/fields-patch.ts
|
|
2105
|
+
var normalized = (email) => email === null ? null : normalizeEmail(email);
|
|
2106
|
+
function emailChanged(type, before, after) {
|
|
2107
|
+
return normalized(emailOf(type, before)) !== normalized(emailOf(type, after));
|
|
2108
|
+
}
|
|
1926
2109
|
async function fieldsPatch(type, record, patch, where) {
|
|
1927
2110
|
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
2111
|
return {
|
|
1932
2112
|
fields,
|
|
1933
2113
|
logins: loginsOf(type, fields, where),
|
|
1934
2114
|
schemaVersion: type.schemaVersion,
|
|
1935
|
-
...emailChanged ? { emailVerifiedAt: null } : {}
|
|
2115
|
+
...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
|
|
1936
2116
|
};
|
|
1937
2117
|
}
|
|
1938
2118
|
|
|
2119
|
+
// src/auth/users/update.ts
|
|
2120
|
+
async function update(context, type, user, patch, options, where) {
|
|
2121
|
+
const seen = { replaced: null };
|
|
2122
|
+
const written = await writeUser(context, user, type, options, where, (record) => {
|
|
2123
|
+
seen.replaced = record;
|
|
2124
|
+
return fieldsPatch(type, record, patch, where);
|
|
2125
|
+
});
|
|
2126
|
+
const { replaced } = seen;
|
|
2127
|
+
if (replaced !== null && emailChanged(type, replaced.fields, written.fields)) {
|
|
2128
|
+
await emit(context, "user.emailChanged", written, written.updatedAt, {
|
|
2129
|
+
formerEmail: emailOf(type, replaced.fields)
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
return written;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
1939
2135
|
// src/auth/users/record-flows.ts
|
|
1940
2136
|
function recordFlows(context, type, at) {
|
|
1941
2137
|
const { store } = context;
|
|
@@ -1963,9 +2159,7 @@ function recordFlows(context, type, at) {
|
|
|
1963
2159
|
return { items: answer.items.map(toUser), nextCursor: answer.nextCursor };
|
|
1964
2160
|
},
|
|
1965
2161
|
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);
|
|
2162
|
+
return toUser(await update(context, type, user, patch, options, at("update")));
|
|
1969
2163
|
},
|
|
1970
2164
|
async setActive(user, active, options) {
|
|
1971
2165
|
return toUser(await writeUser(context, user, type, options, at("setActive"), () => ({
|
|
@@ -1988,6 +2182,7 @@ function typeApi(context, type) {
|
|
|
1988
2182
|
...passwordFlows(context, type, at, secondFactor.finish),
|
|
1989
2183
|
secondFactor: secondFactor.api,
|
|
1990
2184
|
signInCode,
|
|
2185
|
+
magicLink: magicLinkFlows(context, type, at, secondFactor.finish),
|
|
1991
2186
|
stepUp: stepUpFlows(context, type, at),
|
|
1992
2187
|
...emailFlows(context, type, at)
|
|
1993
2188
|
};
|
|
@@ -2075,5 +2270,5 @@ export {
|
|
|
2075
2270
|
systemClock
|
|
2076
2271
|
};
|
|
2077
2272
|
|
|
2078
|
-
//# debugId=
|
|
2273
|
+
//# debugId=56E4EBD3ED51BF6B64756E2164756E21
|
|
2079
2274
|
//# sourceMappingURL=index.js.map
|