@nxgt/janus 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -15
- package/dist/auth/config.d.ts +3 -0
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +6 -0
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +59 -6
- package/dist/auth/one-time.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts +0 -6
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/factor.d.ts +1 -4
- package/dist/auth/second-factor/factor.d.ts.map +1 -1
- package/dist/auth/second-factor/flows.d.ts +7 -3
- package/dist/auth/second-factor/flows.d.ts.map +1 -1
- package/dist/auth/second-factor/lifecycle.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +17 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -0
- package/dist/auth/types.d.ts +50 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts +2 -2
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/index.js +115 -35
- package/dist/index.js.map +12 -11
- package/docs/README.md +1 -0
- package/docs/guide/email-flows.md +4 -0
- package/docs/guide/errors.md +3 -3
- package/docs/guide/second-factor.md +4 -2
- package/docs/guide/sign-in-code.md +471 -0
- package/docs/guide/users.md +2 -1
- package/docs/guide/vocabulary.md +6 -6
- package/docs/roadmap.md +16 -9
- package/docs/troubleshooting.md +164 -10
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -278,7 +278,8 @@ function resolveConfig(config, where) {
|
|
|
278
278
|
types,
|
|
279
279
|
tokenTtlMs: {
|
|
280
280
|
verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
|
|
281
|
-
resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`)
|
|
281
|
+
resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
|
|
282
|
+
signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`)
|
|
282
283
|
},
|
|
283
284
|
cookie: resolveCookie(config.cookie ?? {}, where),
|
|
284
285
|
secondFactor: resolveSecondFactor(config.secondFactor, where)
|
|
@@ -579,6 +580,14 @@ async function writeUser(context, user, type, options, where, patchOf) {
|
|
|
579
580
|
const patch = await patchOf(record, now);
|
|
580
581
|
return context.store.users.updateUser(record.id, { ...patch, updatedAt: now }, record.version);
|
|
581
582
|
}
|
|
583
|
+
async function holderOfEmail(context, type, email) {
|
|
584
|
+
const wanted = normalizeEmail(email);
|
|
585
|
+
if (!isStorable(wanted))
|
|
586
|
+
return null;
|
|
587
|
+
const record = await context.store.users.findUserByLogin(type.name, wanted);
|
|
588
|
+
const held = record === null ? null : emailOf(type, record.fields);
|
|
589
|
+
return held !== null && normalizeEmail(held) === wanted ? record : null;
|
|
590
|
+
}
|
|
582
591
|
|
|
583
592
|
// src/auth/port/assert-stores.ts
|
|
584
593
|
var REQUIRED = {
|
|
@@ -782,6 +791,30 @@ function headersOf(request) {
|
|
|
782
791
|
}
|
|
783
792
|
|
|
784
793
|
// src/auth/one-time.ts
|
|
794
|
+
import { randomInt, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
795
|
+
var CODE_ATTEMPTS = 5;
|
|
796
|
+
async function countCodeAttempt(context, secret, kind, where, userType) {
|
|
797
|
+
const token = await context.store.tokens.countAttempt(hashSecret(secret), kind);
|
|
798
|
+
refuseUnusable(token, context.clock.now(), where, "challenge");
|
|
799
|
+
if (token.attempts > CODE_ATTEMPTS) {
|
|
800
|
+
throw codeInvalid(where, token.userId, userType, 0);
|
|
801
|
+
}
|
|
802
|
+
return { token, attemptsLeft: CODE_ATTEMPTS - token.attempts };
|
|
803
|
+
}
|
|
804
|
+
function codeInvalid(where, userId, userType, attemptsLeft) {
|
|
805
|
+
return new TokenError2("CODE_INVALID", `${where}: the code does not match, or was already used`, {
|
|
806
|
+
operation: where,
|
|
807
|
+
userId,
|
|
808
|
+
userType,
|
|
809
|
+
...attemptsLeft === undefined ? {} : { attemptsLeft }
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
function refuseStale(type, user, token, where, noun) {
|
|
813
|
+
const email = emailOf(type, user.fields);
|
|
814
|
+
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
815
|
+
throw new TokenError2("TOKEN_STALE", `${where}: the ${noun} was sent to an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
785
818
|
function refuseUnusable(token, now, where, noun) {
|
|
786
819
|
if (token === null) {
|
|
787
820
|
throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
@@ -806,7 +839,25 @@ async function spendOneTime(context, secret, kind, where, noun) {
|
|
|
806
839
|
refuseUnusable(token, now, where, noun);
|
|
807
840
|
return token;
|
|
808
841
|
}
|
|
842
|
+
async function burnOneTime(context, secret, kind) {
|
|
843
|
+
await context.store.tokens.consumeToken(hashSecret(secret), kind, context.clock.now());
|
|
844
|
+
}
|
|
845
|
+
var hashCode = (challenge, code) => hashSecret(`${challenge}${code}`);
|
|
846
|
+
function codeMatches(token, challenge, code) {
|
|
847
|
+
if (token.codeHash === null || !/^\d{6}$/.test(code))
|
|
848
|
+
return false;
|
|
849
|
+
const given = Buffer.from(hashCode(challenge, code));
|
|
850
|
+
const held = Buffer.from(token.codeHash);
|
|
851
|
+
return given.length === held.length && timingSafeEqual2(given, held);
|
|
852
|
+
}
|
|
809
853
|
async function issueOneTime(context, token) {
|
|
854
|
+
return insertOneTime(context, token, null);
|
|
855
|
+
}
|
|
856
|
+
async function issueCode(context, token) {
|
|
857
|
+
const code = String(randomInt(1e6)).padStart(6, "0");
|
|
858
|
+
return { ...await insertOneTime(context, token, code), code };
|
|
859
|
+
}
|
|
860
|
+
async function insertOneTime(context, token, code) {
|
|
810
861
|
const now = context.clock.now();
|
|
811
862
|
const secret = mintSecret();
|
|
812
863
|
const expiresAt = new Date(now.getTime() + token.ttlMs);
|
|
@@ -815,7 +866,7 @@ async function issueOneTime(context, token) {
|
|
|
815
866
|
kind: token.kind,
|
|
816
867
|
userId: token.userId,
|
|
817
868
|
address: token.address,
|
|
818
|
-
codeHash: null,
|
|
869
|
+
codeHash: code === null ? null : hashCode(secret, code),
|
|
819
870
|
attempts: 0,
|
|
820
871
|
expiresAt,
|
|
821
872
|
spentAt: null,
|
|
@@ -925,17 +976,8 @@ function acceptCode(configured, record, factor, code, now, where) {
|
|
|
925
976
|
secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
|
|
926
977
|
};
|
|
927
978
|
}
|
|
928
|
-
function codeInvalid(type, where, userId, attemptsLeft) {
|
|
929
|
-
return new TokenError2("CODE_INVALID", `${where}: the code does not match, or was already used`, {
|
|
930
|
-
operation: where,
|
|
931
|
-
userId,
|
|
932
|
-
userType: type.name,
|
|
933
|
-
...attemptsLeft === undefined ? {} : { attemptsLeft }
|
|
934
|
-
});
|
|
935
|
-
}
|
|
936
979
|
|
|
937
980
|
// src/auth/second-factor/challenge.ts
|
|
938
|
-
var CHALLENGE_ATTEMPTS = 5;
|
|
939
981
|
function challengeFlows(context, type, at) {
|
|
940
982
|
const { store, clock } = context;
|
|
941
983
|
const spend = (challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
|
|
@@ -954,12 +996,7 @@ function challengeFlows(context, type, at) {
|
|
|
954
996
|
const where = at("secondFactor.confirm");
|
|
955
997
|
const configured = requireSettings(context, where, "a second factor is being confirmed");
|
|
956
998
|
const secret = String(challenge);
|
|
957
|
-
const token = await
|
|
958
|
-
refuseUnusable(token, clock.now(), where, "challenge");
|
|
959
|
-
if (token.attempts > CHALLENGE_ATTEMPTS) {
|
|
960
|
-
throw codeInvalid(type, where, token.userId, 0);
|
|
961
|
-
}
|
|
962
|
-
const attemptsLeft = CHALLENGE_ATTEMPTS - token.attempts;
|
|
999
|
+
const { token, attemptsLeft } = await countCodeAttempt(context, secret, "secondFactor", where, type.name);
|
|
963
1000
|
const record = await findRecord(context, token.userId, type.name);
|
|
964
1001
|
if (record === null)
|
|
965
1002
|
throw unknownOneTime(where, "challenge");
|
|
@@ -977,9 +1014,10 @@ function challengeFlows(context, type, at) {
|
|
|
977
1014
|
const now = clock.now();
|
|
978
1015
|
const accepted = acceptCode(configured, record, record.secondFactor, String(code), now, where);
|
|
979
1016
|
if (accepted === null) {
|
|
980
|
-
if (attemptsLeft === 0)
|
|
981
|
-
await
|
|
982
|
-
|
|
1017
|
+
if (attemptsLeft === 0) {
|
|
1018
|
+
await burnOneTime(context, secret, "secondFactor");
|
|
1019
|
+
}
|
|
1020
|
+
throw codeInvalid(where, record.id, type.name, attemptsLeft);
|
|
983
1021
|
}
|
|
984
1022
|
const written = await store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
|
|
985
1023
|
await spend(secret, where);
|
|
@@ -1038,7 +1076,7 @@ function lifecycleFlows(context, type, at) {
|
|
|
1038
1076
|
}
|
|
1039
1077
|
const accepted = acceptCode(configured, record, factor, String(code), now, where);
|
|
1040
1078
|
if (accepted === null)
|
|
1041
|
-
throw codeInvalid(
|
|
1079
|
+
throw codeInvalid(where, record.id, type.name);
|
|
1042
1080
|
return { secondFactor: { ...accepted, confirmedAt: now } };
|
|
1043
1081
|
});
|
|
1044
1082
|
return toUser(written);
|
|
@@ -1061,8 +1099,55 @@ function secondFactorFlows(context, type, at) {
|
|
|
1061
1099
|
};
|
|
1062
1100
|
return {
|
|
1063
1101
|
api,
|
|
1064
|
-
|
|
1065
|
-
|
|
1102
|
+
finish: (record, where) => isActive(record.secondFactor) ? challenges.issue(record, where) : openSession(context, type, record)
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/auth/sign-in-code.ts
|
|
1107
|
+
function signInCodeFlows(context, type, at, finish) {
|
|
1108
|
+
return {
|
|
1109
|
+
async request(email) {
|
|
1110
|
+
const record = await holderOfEmail(context, type, String(email));
|
|
1111
|
+
if (record === null || !record.active)
|
|
1112
|
+
return null;
|
|
1113
|
+
const { secret, code, expiresAt } = await issueCode(context, {
|
|
1114
|
+
kind: "signInCode",
|
|
1115
|
+
userId: record.id,
|
|
1116
|
+
address: String(record.fields[type.email]),
|
|
1117
|
+
ttlMs: context.config.tokenTtlMs.signInCode
|
|
1118
|
+
});
|
|
1119
|
+
return {
|
|
1120
|
+
code,
|
|
1121
|
+
challenge: secret,
|
|
1122
|
+
email: String(record.fields[type.email]),
|
|
1123
|
+
expiresAt,
|
|
1124
|
+
user: toUser(record)
|
|
1125
|
+
};
|
|
1126
|
+
},
|
|
1127
|
+
async confirm(challenge, code) {
|
|
1128
|
+
const where = at("signInCode.confirm");
|
|
1129
|
+
const secret = String(challenge);
|
|
1130
|
+
const { token, attemptsLeft } = await countCodeAttempt(context, secret, "signInCode", where, type.name);
|
|
1131
|
+
const user = await findRecord(context, token.userId, type.name);
|
|
1132
|
+
if (user === null)
|
|
1133
|
+
throw unknownOneTime(where, "challenge");
|
|
1134
|
+
if (!codeMatches(token, secret, String(code))) {
|
|
1135
|
+
if (token.attempts === CODE_ATTEMPTS) {
|
|
1136
|
+
await burnOneTime(context, secret, "signInCode");
|
|
1137
|
+
}
|
|
1138
|
+
throw codeInvalid(where, user.id, type.name, attemptsLeft);
|
|
1139
|
+
}
|
|
1140
|
+
await spendOneTime(context, secret, "signInCode", where, "challenge");
|
|
1141
|
+
refuseStale(type, user, token, where, "code");
|
|
1142
|
+
if (!user.active) {
|
|
1143
|
+
throw new UserInactiveError2(`${where}: the user is inactive`, {
|
|
1144
|
+
userId: user.id,
|
|
1145
|
+
userType: type.name
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
const proved = user.emailVerifiedAt === null ? await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now })) : user;
|
|
1149
|
+
return finish(proved, where);
|
|
1150
|
+
}
|
|
1066
1151
|
};
|
|
1067
1152
|
}
|
|
1068
1153
|
|
|
@@ -1071,6 +1156,7 @@ function typeApi(context, type) {
|
|
|
1071
1156
|
const { store, clock } = context;
|
|
1072
1157
|
const at = (operation) => context.config.single ? operation : `${type.name}.${operation}`;
|
|
1073
1158
|
const secondFactor = secondFactorFlows(context, type, at);
|
|
1159
|
+
const signInCode = signInCodeFlows(context, type, at, secondFactor.finish);
|
|
1074
1160
|
const byLogin = async (login) => isStorable(login) ? store.users.findUserByLogin(type.name, login) : null;
|
|
1075
1161
|
const insert = async (input, where) => {
|
|
1076
1162
|
const { password, active, ...rest } = input ?? {};
|
|
@@ -1104,10 +1190,7 @@ function typeApi(context, type) {
|
|
|
1104
1190
|
const user = await findRecord(context, token.userId, type.name);
|
|
1105
1191
|
if (user === null)
|
|
1106
1192
|
throw unknownOneTime(where, "token");
|
|
1107
|
-
|
|
1108
|
-
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
1109
|
-
throw new TokenError2("TOKEN_STALE", `${where}: the token was sent to an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
|
|
1110
|
-
}
|
|
1193
|
+
refuseStale(type, user, token, where, "token");
|
|
1111
1194
|
return { token, user };
|
|
1112
1195
|
};
|
|
1113
1196
|
const issue = async (kind, user, where) => {
|
|
@@ -1220,8 +1303,7 @@ function typeApi(context, type) {
|
|
|
1220
1303
|
userType: type.name
|
|
1221
1304
|
});
|
|
1222
1305
|
}
|
|
1223
|
-
|
|
1224
|
-
return secondFactor.required(signedIn) ? secondFactor.challenge(signedIn, where) : openSession(context, type, signedIn);
|
|
1306
|
+
return secondFactor.finish(await rehashed(context, record, String(password)), where);
|
|
1225
1307
|
},
|
|
1226
1308
|
async findByLogin(login) {
|
|
1227
1309
|
const rule = passwordRule(at("findByLogin"));
|
|
@@ -1259,6 +1341,7 @@ function typeApi(context, type) {
|
|
|
1259
1341
|
}));
|
|
1260
1342
|
},
|
|
1261
1343
|
secondFactor: secondFactor.api,
|
|
1344
|
+
signInCode,
|
|
1262
1345
|
verifyEmail: {
|
|
1263
1346
|
async send(user) {
|
|
1264
1347
|
const where = at("verifyEmail.send");
|
|
@@ -1277,12 +1360,9 @@ function typeApi(context, type) {
|
|
|
1277
1360
|
async request(email) {
|
|
1278
1361
|
const where = at("resetPassword.request");
|
|
1279
1362
|
passwordRule(where);
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
const held = record === null ? null : emailOf(type, record.fields);
|
|
1283
|
-
if (record === null || held === null || normalizeEmail(held) !== wanted) {
|
|
1363
|
+
const record = await holderOfEmail(context, type, String(email));
|
|
1364
|
+
if (record === null)
|
|
1284
1365
|
return null;
|
|
1285
|
-
}
|
|
1286
1366
|
const issued = await issue("resetPassword", record, where);
|
|
1287
1367
|
return { ...issued, user: toUser(record) };
|
|
1288
1368
|
},
|
|
@@ -1375,5 +1455,5 @@ export {
|
|
|
1375
1455
|
systemClock
|
|
1376
1456
|
};
|
|
1377
1457
|
|
|
1378
|
-
//# debugId=
|
|
1458
|
+
//# debugId=9A2F9DDAD478673A64756E2164756E21
|
|
1379
1459
|
//# sourceMappingURL=index.js.map
|