@nxgt/janus 0.14.0 → 0.16.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 +109 -20
- package/dist/auth/config/janus-config.d.ts +2 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolved-config.d.ts +1 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/email-sign-in.d.ts +45 -0
- package/dist/auth/email-sign-in.d.ts.map +1 -0
- package/dist/auth/events.d.ts +10 -4
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/first-proof.d.ts +37 -0
- package/dist/auth/first-proof.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -1
- 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/port/types/tokens.d.ts +6 -3
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- 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 +7 -4
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- 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 +50 -0
- package/dist/auth/types/magic-link.d.ts.map +1 -0
- 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/type-api.d.ts +2 -1
- package/dist/auth/users/type-api.d.ts.map +1 -1
- 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 +23 -1
- package/dist/conformance/index.js.map +3 -3
- package/dist/index.js +173 -88
- package/dist/index.js.map +12 -9
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +11 -7
- package/docs/guide/email-flows.md +14 -8
- package/docs/guide/errors.md +2 -2
- package/docs/guide/events.md +5 -5
- package/docs/guide/magic-link.md +442 -0
- package/docs/guide/passwords.md +5 -4
- package/docs/guide/second-factor.md +28 -2
- package/docs/guide/sessions.md +4 -1
- package/docs/guide/sign-in-code.md +20 -7
- package/docs/guide/users.md +10 -6
- package/docs/guide/vocabulary.md +6 -5
- package/docs/roadmap.md +18 -12
- package/docs/troubleshooting.md +178 -12
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -407,6 +407,7 @@ function resolveTokenTtls(config, where) {
|
|
|
407
407
|
verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
|
|
408
408
|
resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
|
|
409
409
|
signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`),
|
|
410
|
+
magicLink: parseDuration(config.tokens?.magicLink ?? "15m", `${where}: tokens.magicLink`),
|
|
410
411
|
stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`)
|
|
411
412
|
};
|
|
412
413
|
}
|
|
@@ -1199,78 +1200,8 @@ function emailFlows(context, type, at) {
|
|
|
1199
1200
|
resetPassword: resetPasswordFlows(context, type, at)
|
|
1200
1201
|
};
|
|
1201
1202
|
}
|
|
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
|
-
|
|
1272
1203
|
// src/auth/totp.ts
|
|
1273
|
-
import { createHmac
|
|
1204
|
+
import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
|
|
1274
1205
|
var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
|
|
1275
1206
|
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1276
1207
|
function mintTotpSecret() {
|
|
@@ -1313,7 +1244,7 @@ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
|
|
|
1313
1244
|
function codeAt(secret, step) {
|
|
1314
1245
|
const counter = Buffer.alloc(8);
|
|
1315
1246
|
counter.writeBigUInt64BE(BigInt(step));
|
|
1316
|
-
const digest =
|
|
1247
|
+
const digest = createHmac("sha1", secret).update(counter).digest();
|
|
1317
1248
|
const offset = digest[19] & 15;
|
|
1318
1249
|
const binary = digest.readUInt32BE(offset) & 2147483647;
|
|
1319
1250
|
return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
|
|
@@ -1371,6 +1302,165 @@ function acceptCode(configured, record, factor, code, now, where) {
|
|
|
1371
1302
|
};
|
|
1372
1303
|
}
|
|
1373
1304
|
|
|
1305
|
+
// src/auth/first-proof.ts
|
|
1306
|
+
async function proveFirstEmail(context, type, user, where) {
|
|
1307
|
+
await endWhatTheSquatterOpened(context, user.id);
|
|
1308
|
+
let dropped = false;
|
|
1309
|
+
let disabled = false;
|
|
1310
|
+
const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (record, now) => {
|
|
1311
|
+
dropped = record.password !== null;
|
|
1312
|
+
disabled = isActive(record.secondFactor);
|
|
1313
|
+
return {
|
|
1314
|
+
emailVerifiedAt: now,
|
|
1315
|
+
...dropped ? { password: null } : {},
|
|
1316
|
+
...record.secondFactor === null ? {} : { secondFactor: null }
|
|
1317
|
+
};
|
|
1318
|
+
});
|
|
1319
|
+
try {
|
|
1320
|
+
await endWhatTheSquatterOpened(context, proved.id);
|
|
1321
|
+
} finally {
|
|
1322
|
+
await emit(context, "user.emailVerified", proved, proved.updatedAt);
|
|
1323
|
+
if (dropped) {
|
|
1324
|
+
await emit(context, "user.passwordChanged", proved, proved.updatedAt);
|
|
1325
|
+
}
|
|
1326
|
+
if (disabled) {
|
|
1327
|
+
await emit(context, "user.secondFactorDisabled", proved, proved.updatedAt);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return proved;
|
|
1331
|
+
}
|
|
1332
|
+
async function endWhatTheSquatterOpened(context, userId) {
|
|
1333
|
+
await context.store.sessions.revokeUserSessions(userId, context.clock.now());
|
|
1334
|
+
await endWhatThePasswordOpened(context, userId);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/auth/email-sign-in.ts
|
|
1338
|
+
async function signInHolder(context, type, email) {
|
|
1339
|
+
const record = await holderOfEmail(context, type, email);
|
|
1340
|
+
return record === null || !record.active ? null : record;
|
|
1341
|
+
}
|
|
1342
|
+
async function keepOnlyLatest(context, userId, kind, secret) {
|
|
1343
|
+
await context.store.tokens.spendUserTokens(userId, kind, context.clock.now(), hashSecret(secret));
|
|
1344
|
+
}
|
|
1345
|
+
async function finishEmailSignIn(context, type, finish, signIn) {
|
|
1346
|
+
const { user, token, where, noun } = signIn;
|
|
1347
|
+
refuseStale(type, user, token, where, noun);
|
|
1348
|
+
if (!user.active) {
|
|
1349
|
+
throw new UserInactiveError2(`${where}: the user is inactive`, {
|
|
1350
|
+
userId: user.id,
|
|
1351
|
+
userType: type.name
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
if (user.emailVerifiedAt !== null)
|
|
1355
|
+
return finish(user, where);
|
|
1356
|
+
return finish(await proveFirstEmail(context, type, user, where), where);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// src/auth/magic-link.ts
|
|
1360
|
+
function magicLinkFlows(context, type, at, finish) {
|
|
1361
|
+
return {
|
|
1362
|
+
async request(email) {
|
|
1363
|
+
return requestLink(context, type, String(email));
|
|
1364
|
+
},
|
|
1365
|
+
async confirm(token) {
|
|
1366
|
+
return confirmLink(context, type, finish, String(token), at("magicLink.confirm"));
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
async function requestLink(context, type, email) {
|
|
1371
|
+
const record = await signInHolder(context, type, email);
|
|
1372
|
+
if (record === null)
|
|
1373
|
+
return null;
|
|
1374
|
+
const address = String(record.fields[type.email]);
|
|
1375
|
+
const { secret, expiresAt } = await issueOneTime(context, {
|
|
1376
|
+
kind: "magicLink",
|
|
1377
|
+
userId: record.id,
|
|
1378
|
+
address,
|
|
1379
|
+
ttlMs: context.config.tokenTtlMs.magicLink
|
|
1380
|
+
});
|
|
1381
|
+
await keepOnlyLatest(context, record.id, "magicLink", secret);
|
|
1382
|
+
return { token: secret, email: address, expiresAt, user: toUser(record) };
|
|
1383
|
+
}
|
|
1384
|
+
async function confirmLink(context, type, finish, secret, where) {
|
|
1385
|
+
const { token, user } = await redeemEmailToken(context, type, "magicLink", secret, where);
|
|
1386
|
+
return finishEmailSignIn(context, type, finish, {
|
|
1387
|
+
user,
|
|
1388
|
+
token,
|
|
1389
|
+
where,
|
|
1390
|
+
noun: "token"
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// src/auth/derived-keys.ts
|
|
1395
|
+
import { createHmac as createHmac2, hkdfSync } from "node:crypto";
|
|
1396
|
+
var derived = new WeakMap;
|
|
1397
|
+
function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
|
|
1398
|
+
let keys = derived.get(sealer);
|
|
1399
|
+
if (keys === undefined) {
|
|
1400
|
+
keys = new Map;
|
|
1401
|
+
derived.set(sealer, keys);
|
|
1402
|
+
}
|
|
1403
|
+
const name = `${purpose}\x00${keyId}`;
|
|
1404
|
+
let key = keys.get(name);
|
|
1405
|
+
if (key === undefined) {
|
|
1406
|
+
const secret = sealer.keys.get(keyId);
|
|
1407
|
+
if (secret === undefined)
|
|
1408
|
+
return;
|
|
1409
|
+
key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), purpose, 32));
|
|
1410
|
+
keys.set(name, key);
|
|
1411
|
+
}
|
|
1412
|
+
return key;
|
|
1413
|
+
}
|
|
1414
|
+
function keyedHash(key, parts) {
|
|
1415
|
+
return createHmac2("sha256", key).update(parts.join("\x00")).digest();
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// src/auth/sign-in-attempts.ts
|
|
1419
|
+
var PURPOSE = "janus/sign-in/attempts/v1";
|
|
1420
|
+
var UNSEALED = Buffer.from(PURPOSE);
|
|
1421
|
+
function counterKey(context) {
|
|
1422
|
+
const sealer = context.config.secondFactor?.sealer;
|
|
1423
|
+
return sealer === undefined ? UNSEALED : derivedKey(sealer, PURPOSE);
|
|
1424
|
+
}
|
|
1425
|
+
var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
|
|
1426
|
+
async function countLogin(context, type, login, where) {
|
|
1427
|
+
const throttle = context.config.signInThrottle;
|
|
1428
|
+
if (throttle === null)
|
|
1429
|
+
return null;
|
|
1430
|
+
const key = counterKey(context);
|
|
1431
|
+
const loginKey = keyed(key, [type.name, login]);
|
|
1432
|
+
return countInWindow(context, {
|
|
1433
|
+
windowMs: throttle.windowMs,
|
|
1434
|
+
spent: "restart",
|
|
1435
|
+
userId: loginKey,
|
|
1436
|
+
linkHash: (window, link) => hashSecret(keyed(key, [loginKey, String(window), String(link)])),
|
|
1437
|
+
vanished: () => new StoreFailure2(`${where}: the store dropped the attempts it had just stored`, { slot: "tokens", operation: "countAttempt" })
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
async function countSignInAttempt(context, type, login, where) {
|
|
1441
|
+
const counted = await countLogin(context, type, login, where);
|
|
1442
|
+
const limit = context.config.signInThrottle?.attempts;
|
|
1443
|
+
if (counted === null || limit === undefined || counted.attempts <= limit) {
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const now = context.clock.now().getTime();
|
|
1447
|
+
throw new CredentialError2("CREDENTIALS_INVALID", `${where}: too many passwords tried at this login — wait for the next window`, {
|
|
1448
|
+
reason: "throttled",
|
|
1449
|
+
userType: type.name,
|
|
1450
|
+
retryAfter: Math.max(1, Math.ceil((counted.endsAt - now) / 1000))
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
async function restartSignInCount(context, type, record, where) {
|
|
1454
|
+
const rule = type.password;
|
|
1455
|
+
const login = rule === null ? undefined : record.fields[rule.login];
|
|
1456
|
+
if (rule === null || typeof login !== "string")
|
|
1457
|
+
return;
|
|
1458
|
+
const counted = await countLogin(context, type, rule.normalize(login), where);
|
|
1459
|
+
if (counted?.link == null)
|
|
1460
|
+
return;
|
|
1461
|
+
await context.store.tokens.consumeToken(counted.link, "secondFactor", context.clock.now());
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1374
1464
|
// src/auth/second-factor/redeem.ts
|
|
1375
1465
|
var spendChallenge = (context, challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
|
|
1376
1466
|
async function openChallenge(context, type, challenge, where) {
|
|
@@ -1710,8 +1800,8 @@ function signInCodeFlows(context, type, at, finish) {
|
|
|
1710
1800
|
};
|
|
1711
1801
|
}
|
|
1712
1802
|
async function requestCode(context, type, email) {
|
|
1713
|
-
const record = await
|
|
1714
|
-
if (record === null
|
|
1803
|
+
const record = await signInHolder(context, type, email);
|
|
1804
|
+
if (record === null)
|
|
1715
1805
|
return null;
|
|
1716
1806
|
const { secret, code, expiresAt } = await issueCode(context, {
|
|
1717
1807
|
kind: "signInCode",
|
|
@@ -1719,7 +1809,7 @@ async function requestCode(context, type, email) {
|
|
|
1719
1809
|
address: String(record.fields[type.email]),
|
|
1720
1810
|
ttlMs: context.config.tokenTtlMs.signInCode
|
|
1721
1811
|
});
|
|
1722
|
-
await context
|
|
1812
|
+
await keepOnlyLatest(context, record.id, "signInCode", secret);
|
|
1723
1813
|
return {
|
|
1724
1814
|
code,
|
|
1725
1815
|
challenge: secret,
|
|
@@ -1743,18 +1833,12 @@ async function confirmCode(context, type, finish, challenge, code, at) {
|
|
|
1743
1833
|
throw codeInvalid(where, user.id, type.name, attemptsLeft);
|
|
1744
1834
|
}
|
|
1745
1835
|
await spendOneTime(context, secret, "signInCode", where, "challenge");
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
}
|
|
1753
|
-
if (user.emailVerifiedAt !== null)
|
|
1754
|
-
return finish(user, where);
|
|
1755
|
-
const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
|
|
1756
|
-
await emit(context, "user.emailVerified", proved, proved.updatedAt);
|
|
1757
|
-
return finish(proved, where);
|
|
1836
|
+
return finishEmailSignIn(context, type, finish, {
|
|
1837
|
+
user,
|
|
1838
|
+
token,
|
|
1839
|
+
where,
|
|
1840
|
+
noun: "code"
|
|
1841
|
+
});
|
|
1758
1842
|
}
|
|
1759
1843
|
|
|
1760
1844
|
// src/auth/step-up/codes.ts
|
|
@@ -2128,6 +2212,7 @@ function typeApi(context, type) {
|
|
|
2128
2212
|
...passwordFlows(context, type, at, secondFactor.finish),
|
|
2129
2213
|
secondFactor: secondFactor.api,
|
|
2130
2214
|
signInCode,
|
|
2215
|
+
magicLink: magicLinkFlows(context, type, at, secondFactor.finish),
|
|
2131
2216
|
stepUp: stepUpFlows(context, type, at),
|
|
2132
2217
|
...emailFlows(context, type, at)
|
|
2133
2218
|
};
|
|
@@ -2215,5 +2300,5 @@ export {
|
|
|
2215
2300
|
systemClock
|
|
2216
2301
|
};
|
|
2217
2302
|
|
|
2218
|
-
//# debugId=
|
|
2303
|
+
//# debugId=BC1254710D035B0264756E2164756E21
|
|
2219
2304
|
//# sourceMappingURL=index.js.map
|