@nxgt/janus 0.25.0 → 0.27.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 +22 -5
- package/dist/auth/config/reserved.d.ts +1 -1
- package/dist/auth/config/reserved.d.ts.map +1 -1
- package/dist/auth/email-flows/any-type-change-email.d.ts +18 -0
- package/dist/auth/email-flows/any-type-change-email.d.ts.map +1 -0
- package/dist/auth/email-flows/change-email-token.d.ts +6 -1
- package/dist/auth/email-flows/change-email-token.d.ts.map +1 -1
- package/dist/auth/email-flows/change-email.d.ts +6 -0
- package/dist/auth/email-flows/change-email.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/janus.d.ts.map +1 -1
- package/dist/auth/sign-in-attempts.d.ts +5 -0
- package/dist/auth/sign-in-attempts.d.ts.map +1 -1
- package/dist/auth/types/change-email.d.ts +31 -0
- package/dist/auth/types/change-email.d.ts.map +1 -1
- package/dist/auth/types/index.d.ts +1 -1
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +9 -2
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/types/password.d.ts +8 -2
- package/dist/auth/types/password.d.ts.map +1 -1
- package/dist/auth/users/change-password.d.ts +13 -0
- package/dist/auth/users/change-password.d.ts.map +1 -1
- package/dist/index.js +470 -450
- package/dist/index.js.map +20 -19
- package/docs/README.md +1 -1
- package/docs/guide/email-flows.md +14 -0
- package/docs/guide/passwords.md +8 -2
- package/docs/guide/users.md +3 -3
- package/docs/roadmap.md +11 -27
- package/docs/troubleshooting.md +15 -12
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -155,7 +155,8 @@ var RESERVED_TYPES = [
|
|
|
155
155
|
"getUser",
|
|
156
156
|
"cookie",
|
|
157
157
|
"collectExpired",
|
|
158
|
-
"types"
|
|
158
|
+
"types",
|
|
159
|
+
"changeEmail"
|
|
159
160
|
];
|
|
160
161
|
// src/time/duration.ts
|
|
161
162
|
var UNITS = {
|
|
@@ -749,66 +750,6 @@ async function emit(context, type, user, occurredAt, extras = {}) {
|
|
|
749
750
|
}
|
|
750
751
|
}
|
|
751
752
|
|
|
752
|
-
// src/auth/port/assert-stores.ts
|
|
753
|
-
var REQUIRED = {
|
|
754
|
-
users: [
|
|
755
|
-
"insertUser",
|
|
756
|
-
"findUser",
|
|
757
|
-
"findUserByLogin",
|
|
758
|
-
"listUsers",
|
|
759
|
-
"updateUser",
|
|
760
|
-
"deleteUser"
|
|
761
|
-
],
|
|
762
|
-
sessions: [
|
|
763
|
-
"insertSession",
|
|
764
|
-
"findSessionByTokenHash",
|
|
765
|
-
"extendSession",
|
|
766
|
-
"reauthenticateSession",
|
|
767
|
-
"revokeSession",
|
|
768
|
-
"revokeUserSessions",
|
|
769
|
-
"deleteUserSessions"
|
|
770
|
-
],
|
|
771
|
-
tokens: [
|
|
772
|
-
"insertToken",
|
|
773
|
-
"consumeToken",
|
|
774
|
-
"countAttempt",
|
|
775
|
-
"spendUserTokens",
|
|
776
|
-
"deleteUserTokens"
|
|
777
|
-
]
|
|
778
|
-
};
|
|
779
|
-
function assertStores(stores, where) {
|
|
780
|
-
if (typeof stores !== "object" || stores === null) {
|
|
781
|
-
throw new TypeError(`${where}: store must be an object with users, sessions and tokens`);
|
|
782
|
-
}
|
|
783
|
-
for (const slot of Object.keys(REQUIRED)) {
|
|
784
|
-
const store = stores[slot];
|
|
785
|
-
if (typeof store !== "object" || store === null) {
|
|
786
|
-
throw new TypeError(`${where}: store.${slot} is missing`);
|
|
787
|
-
}
|
|
788
|
-
for (const method of REQUIRED[slot]) {
|
|
789
|
-
if (typeof store[method] !== "function") {
|
|
790
|
-
throw new TypeError(`${where}: store.${slot} has no method ${method}, which the port requires`);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
const slots = stores;
|
|
795
|
-
return {
|
|
796
|
-
collectExpired: optional(slots, "sessions", "deleteExpiredSessions", where),
|
|
797
|
-
collectExpiredTokens: optional(slots, "tokens", "deleteExpiredTokens", where),
|
|
798
|
-
findUsers: optional(slots, "users", "findUsers", where)
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
function optional(stores, slot, method, where) {
|
|
802
|
-
const found = stores[slot][method];
|
|
803
|
-
if (found !== undefined && typeof found !== "function") {
|
|
804
|
-
throw new TypeError(`${where}: store.${slot}.${method} must be a function or absent`);
|
|
805
|
-
}
|
|
806
|
-
return found !== undefined;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// src/auth/devices/token.ts
|
|
810
|
-
import { randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
811
|
-
|
|
812
753
|
// src/auth/derived-keys.ts
|
|
813
754
|
import { createHmac, hkdfSync } from "node:crypto";
|
|
814
755
|
var derived = new WeakMap;
|
|
@@ -833,317 +774,69 @@ function keyedHash(key, parts) {
|
|
|
833
774
|
return createHmac("sha256", key).update(parts.join("\x00")).digest();
|
|
834
775
|
}
|
|
835
776
|
|
|
836
|
-
// src/auth/
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
return keyedHash(key, [userId, deviceId]);
|
|
841
|
-
}
|
|
842
|
-
function signed(sealer, userId, deviceId) {
|
|
843
|
-
const key = derivedKey(sealer, PURPOSE);
|
|
844
|
-
const mac = macOf(key, userId, deviceId).toString("base64url");
|
|
845
|
-
return `d1.${sealer.sealWith}.${deviceId}.${mac}`;
|
|
846
|
-
}
|
|
847
|
-
function mintDeviceToken(sealer, userId) {
|
|
848
|
-
return signed(sealer, userId, randomBytes3(16).toString("base64url"));
|
|
849
|
-
}
|
|
850
|
-
function knownDeviceToken(sealer, userId, presented) {
|
|
851
|
-
const [, keyId, deviceId, mac] = TOKEN.exec(presented) ?? [];
|
|
852
|
-
if (keyId === undefined || deviceId === undefined || mac === undefined) {
|
|
853
|
-
return null;
|
|
854
|
-
}
|
|
855
|
-
const key = derivedKey(sealer, PURPOSE, keyId);
|
|
856
|
-
if (key === undefined)
|
|
857
|
-
return null;
|
|
858
|
-
const expected = macOf(key, userId, deviceId);
|
|
859
|
-
const given = Buffer.from(mac, "base64url");
|
|
860
|
-
if (given.length !== expected.length || !timingSafeEqual2(given, expected)) {
|
|
861
|
-
return null;
|
|
862
|
-
}
|
|
863
|
-
return keyId === sealer.sealWith ? presented : signed(sealer, userId, deviceId);
|
|
777
|
+
// src/auth/counter-key.ts
|
|
778
|
+
function counterKey(context, purpose) {
|
|
779
|
+
const sealer = context.config.secondFactor?.sealer;
|
|
780
|
+
return sealer === undefined ? Buffer.from(purpose) : derivedKey(sealer, purpose);
|
|
864
781
|
}
|
|
782
|
+
var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
|
|
783
|
+
|
|
784
|
+
// src/auth/one-time/codes.ts
|
|
785
|
+
import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
865
786
|
|
|
866
|
-
// src/auth/devices/hint.ts
|
|
867
|
-
var UNTRACKED = Object.freeze({
|
|
868
|
-
newDevice: false,
|
|
869
|
-
deviceToken: null
|
|
870
|
-
});
|
|
871
|
-
function deviceHint(context, options, where) {
|
|
872
|
-
if (options !== undefined && (typeof options !== "object" || options === null)) {
|
|
873
|
-
throw new TypeError(`${where}: options must be { device }, or left out — the token goes under device`);
|
|
874
|
-
}
|
|
875
|
-
const device = options?.device;
|
|
876
|
-
if (device === undefined)
|
|
877
|
-
return;
|
|
878
|
-
if (device !== null && typeof device !== "string") {
|
|
879
|
-
throw new TypeError(`${where}: options.device must be the device token the client holds, or null when it holds none`);
|
|
880
|
-
}
|
|
881
|
-
if (context.config.devices === null) {
|
|
882
|
-
throw new TypeError(`${where}: a device was given, but janus() has no devices — pass devices: { keys }`);
|
|
883
|
-
}
|
|
884
|
-
return device;
|
|
885
|
-
}
|
|
886
|
-
function deviceOutcome(context, userId, hint, signUp) {
|
|
887
|
-
const sealer = context.config.devices?.sealer;
|
|
888
|
-
if (hint === undefined || sealer === undefined)
|
|
889
|
-
return UNTRACKED;
|
|
890
|
-
const known = hint === null || signUp ? null : knownDeviceToken(sealer, userId, hint);
|
|
891
|
-
if (known !== null)
|
|
892
|
-
return { newDevice: false, deviceToken: known };
|
|
893
|
-
return {
|
|
894
|
-
newDevice: !signUp,
|
|
895
|
-
deviceToken: mintDeviceToken(sealer, userId)
|
|
896
|
-
};
|
|
897
|
-
}
|
|
898
|
-
// src/auth/devices/report.ts
|
|
899
|
-
async function reportNewDevice(context, result) {
|
|
900
|
-
if (result.status !== "signedIn" || !result.newDevice)
|
|
901
|
-
return;
|
|
902
|
-
await emit(context, "user.newDeviceSignedIn", result.user, result.session.createdAt, { sessionId: result.session.id });
|
|
903
|
-
}
|
|
904
787
|
// src/auth/secrets.ts
|
|
905
|
-
import { createHash, randomBytes as
|
|
788
|
+
import { createHash, randomBytes as randomBytes3 } from "node:crypto";
|
|
906
789
|
function mintSecret() {
|
|
907
|
-
return
|
|
790
|
+
return randomBytes3(32).toString("base64url");
|
|
908
791
|
}
|
|
909
792
|
function hashSecret(secret) {
|
|
910
793
|
return createHash("sha256").update(secret).digest("hex");
|
|
911
794
|
}
|
|
912
795
|
|
|
913
|
-
// src/auth/
|
|
914
|
-
function
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
const now = context.clock.now();
|
|
920
|
-
const token = mintSecret();
|
|
921
|
-
const record = {
|
|
922
|
-
id: mintId2(now.getTime()),
|
|
923
|
-
tokenHash: hashSecret(token),
|
|
924
|
-
userId: user.id,
|
|
925
|
-
authenticatedAt: now,
|
|
926
|
-
expiresAt: new Date(now.getTime() + type.lifespanMs),
|
|
927
|
-
revokedAt: null,
|
|
928
|
-
createdAt: now
|
|
929
|
-
};
|
|
930
|
-
await context.store.sessions.insertSession(record);
|
|
931
|
-
return {
|
|
932
|
-
status: "signedIn",
|
|
933
|
-
user: toUser(user),
|
|
934
|
-
session: toSession(record),
|
|
935
|
-
token,
|
|
936
|
-
...deviceOutcome(context, user.id, device, signUp)
|
|
937
|
-
};
|
|
796
|
+
// src/auth/one-time/refusals.ts
|
|
797
|
+
function refuseStale(type, user, token, where, noun) {
|
|
798
|
+
const email = emailOf(type, user.fields);
|
|
799
|
+
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
800
|
+
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 });
|
|
801
|
+
}
|
|
938
802
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
return headers.get(name);
|
|
945
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
946
|
-
if (key.toLowerCase() !== name || value === undefined)
|
|
947
|
-
continue;
|
|
948
|
-
return typeof value === "string" ? value : value.join(", ");
|
|
949
|
-
}
|
|
950
|
-
return null;
|
|
951
|
-
};
|
|
952
|
-
const authorization = read("authorization");
|
|
953
|
-
if (authorization !== null) {
|
|
954
|
-
const match = /^bearer(?:\s+(.*))?$/i.exec(authorization.trim());
|
|
955
|
-
if (match)
|
|
956
|
-
return match[1]?.trim() ?? "";
|
|
803
|
+
function refuseUnusable(token, now, where, noun) {
|
|
804
|
+
if (token === null) {
|
|
805
|
+
throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
806
|
+
operation: where
|
|
807
|
+
});
|
|
957
808
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
if (at !== -1 && pair.slice(0, at).trim() === cookieName) {
|
|
966
|
-
return pair.slice(at + 1).trim();
|
|
967
|
-
}
|
|
968
|
-
}
|
|
809
|
+
if (token.spentAt !== null) {
|
|
810
|
+
throw new TokenError2("TOKEN_SPENT", `${where}: the ${noun} was already used`, { operation: where });
|
|
811
|
+
}
|
|
812
|
+
if (token.expiresAt.getTime() <= now.getTime()) {
|
|
813
|
+
throw new TokenError2("TOKEN_EXPIRED", `${where}: the ${noun} has expired`, {
|
|
814
|
+
operation: where
|
|
815
|
+
});
|
|
969
816
|
}
|
|
970
|
-
return null;
|
|
971
817
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
818
|
+
var unknownOneTime = (where, noun) => new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
819
|
+
operation: where
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
// src/auth/one-time/spend.ts
|
|
823
|
+
async function spendOneTime(context, secret, kind, where, noun) {
|
|
824
|
+
const now = context.clock.now();
|
|
825
|
+
const token = await context.store.tokens.consumeToken(hashSecret(secret), kind, now);
|
|
826
|
+
refuseUnusable(token, now, where, noun);
|
|
827
|
+
return token;
|
|
981
828
|
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
const token = presentedToken(request, context.config.cookie.name);
|
|
985
|
-
if (token === null)
|
|
986
|
-
return null;
|
|
987
|
-
const session = await context.store.sessions.findSessionByTokenHash(hashSecret(token));
|
|
988
|
-
const now = context.clock.now().getTime();
|
|
989
|
-
return session === null || session.revokedAt !== null || session.expiresAt.getTime() <= now ? null : { token, session };
|
|
829
|
+
async function burnOneTime(context, secret, kind) {
|
|
830
|
+
await context.store.tokens.consumeToken(hashSecret(secret), kind, context.clock.now());
|
|
990
831
|
}
|
|
991
832
|
|
|
992
|
-
// src/auth/
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
const now = clock.now().getTime();
|
|
1000
|
-
const user = await store.users.findUser(session.userId);
|
|
1001
|
-
if (user === null || !user.active || options?.type !== undefined && user.type !== options.type) {
|
|
1002
|
-
return null;
|
|
1003
|
-
}
|
|
1004
|
-
const type = config.types.get(user.type);
|
|
1005
|
-
if (type === undefined || !renewalDue(type, session, now)) {
|
|
1006
|
-
return {
|
|
1007
|
-
user: toUser(user),
|
|
1008
|
-
session: toSession(session),
|
|
1009
|
-
token,
|
|
1010
|
-
renewed: false
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
const extended = await store.sessions.extendSession(session.id, new Date(now + type.lifespanMs));
|
|
1014
|
-
if (extended === null)
|
|
1015
|
-
return null;
|
|
1016
|
-
return {
|
|
1017
|
-
user: toUser(user),
|
|
1018
|
-
session: toSession(extended),
|
|
1019
|
-
token,
|
|
1020
|
-
renewed: true
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
function renewalDue(type, session, now) {
|
|
1024
|
-
return type.renewAfterMs !== null && type.lifespanMs - (session.expiresAt.getTime() - now) >= type.renewAfterMs;
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
// src/auth/sessions/collect-expired.ts
|
|
1028
|
-
async function collectExpired(context) {
|
|
1029
|
-
const { store, clock, capabilities } = context;
|
|
1030
|
-
const sessions = capabilities.collectExpired ? store.sessions.deleteExpiredSessions : undefined;
|
|
1031
|
-
const tokens = capabilities.collectExpiredTokens ? store.tokens.deleteExpiredTokens : undefined;
|
|
1032
|
-
if (sessions === undefined && tokens === undefined) {
|
|
1033
|
-
throw new UnsupportedError2("collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens — their stores expire records on their own, or implement the methods", { slot: "sessions", operation: "deleteExpiredSessions" });
|
|
1034
|
-
}
|
|
1035
|
-
const before = clock.now();
|
|
1036
|
-
const collected = sessions === undefined ? 0 : await sessions(before);
|
|
1037
|
-
return collected + (tokens === undefined ? 0 : await tokens(before));
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// src/auth/sessions/cookie.ts
|
|
1041
|
-
function cookieOf(context) {
|
|
1042
|
-
const { name, domain, path, sameSite, secure } = context.config.cookie;
|
|
1043
|
-
const attributes = [
|
|
1044
|
-
`Path=${path}`,
|
|
1045
|
-
...domain === null ? [] : [`Domain=${domain}`],
|
|
1046
|
-
"HttpOnly",
|
|
1047
|
-
`SameSite=${sameSite[0]?.toUpperCase()}${sameSite.slice(1)}`,
|
|
1048
|
-
...secure ? ["Secure"] : []
|
|
1049
|
-
].join("; ");
|
|
1050
|
-
return {
|
|
1051
|
-
name,
|
|
1052
|
-
serialize: (token, session) => `${name}=${token}; Expires=${session.expiresAt.toUTCString()}; ${attributes}`,
|
|
1053
|
-
clear: () => `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; ${attributes}`
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// src/auth/sessions/shared-api.ts
|
|
1058
|
-
function sharedApi(context) {
|
|
1059
|
-
const { store, clock, config } = context;
|
|
1060
|
-
return {
|
|
1061
|
-
async authenticate(request, options) {
|
|
1062
|
-
return authenticate(context, request, options);
|
|
1063
|
-
},
|
|
1064
|
-
async signOut(request) {
|
|
1065
|
-
const token = presentedToken(request, config.cookie.name);
|
|
1066
|
-
if (token === null)
|
|
1067
|
-
return false;
|
|
1068
|
-
const session = await store.sessions.findSessionByTokenHash(hashSecret(token));
|
|
1069
|
-
if (session === null)
|
|
1070
|
-
return false;
|
|
1071
|
-
return store.sessions.revokeSession(session.id, clock.now());
|
|
1072
|
-
},
|
|
1073
|
-
async signOutEverywhere(user, options) {
|
|
1074
|
-
const id = idOf(user);
|
|
1075
|
-
if (!isId2(id))
|
|
1076
|
-
return 0;
|
|
1077
|
-
return options?.except === undefined || !isId2(options.except) ? store.sessions.revokeUserSessions(id, clock.now()) : store.sessions.revokeUserSessions(id, clock.now(), options.except);
|
|
1078
|
-
},
|
|
1079
|
-
async findUser(id) {
|
|
1080
|
-
const record = await findRecord(context, id, null);
|
|
1081
|
-
return record === null ? null : toUser(record);
|
|
1082
|
-
},
|
|
1083
|
-
async getUser(id) {
|
|
1084
|
-
return toUser(await getRecord(context, id, null, "getUser"));
|
|
1085
|
-
},
|
|
1086
|
-
cookie: cookieOf(context),
|
|
1087
|
-
async collectExpired() {
|
|
1088
|
-
return collectExpired(context);
|
|
1089
|
-
},
|
|
1090
|
-
types: [...config.types.keys()]
|
|
1091
|
-
};
|
|
1092
|
-
}
|
|
1093
|
-
// src/auth/counter-key.ts
|
|
1094
|
-
function counterKey(context, purpose) {
|
|
1095
|
-
const sealer = context.config.secondFactor?.sealer;
|
|
1096
|
-
return sealer === undefined ? Buffer.from(purpose) : derivedKey(sealer, purpose);
|
|
1097
|
-
}
|
|
1098
|
-
var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
|
|
1099
|
-
|
|
1100
|
-
// src/auth/one-time/codes.ts
|
|
1101
|
-
import { timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
1102
|
-
|
|
1103
|
-
// src/auth/one-time/refusals.ts
|
|
1104
|
-
function refuseStale(type, user, token, where, noun) {
|
|
1105
|
-
const email = emailOf(type, user.fields);
|
|
1106
|
-
if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
|
|
1107
|
-
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 });
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
function refuseUnusable(token, now, where, noun) {
|
|
1111
|
-
if (token === null) {
|
|
1112
|
-
throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
1113
|
-
operation: where
|
|
1114
|
-
});
|
|
1115
|
-
}
|
|
1116
|
-
if (token.spentAt !== null) {
|
|
1117
|
-
throw new TokenError2("TOKEN_SPENT", `${where}: the ${noun} was already used`, { operation: where });
|
|
1118
|
-
}
|
|
1119
|
-
if (token.expiresAt.getTime() <= now.getTime()) {
|
|
1120
|
-
throw new TokenError2("TOKEN_EXPIRED", `${where}: the ${noun} has expired`, {
|
|
1121
|
-
operation: where
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
var unknownOneTime = (where, noun) => new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
|
|
1126
|
-
operation: where
|
|
1127
|
-
});
|
|
1128
|
-
|
|
1129
|
-
// src/auth/one-time/spend.ts
|
|
1130
|
-
async function spendOneTime(context, secret, kind, where, noun) {
|
|
1131
|
-
const now = context.clock.now();
|
|
1132
|
-
const token = await context.store.tokens.consumeToken(hashSecret(secret), kind, now);
|
|
1133
|
-
refuseUnusable(token, now, where, noun);
|
|
1134
|
-
return token;
|
|
1135
|
-
}
|
|
1136
|
-
async function burnOneTime(context, secret, kind) {
|
|
1137
|
-
await context.store.tokens.consumeToken(hashSecret(secret), kind, context.clock.now());
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// src/auth/one-time/codes.ts
|
|
1141
|
-
var CODE_ATTEMPTS = 5;
|
|
1142
|
-
async function countCodeAttempt(context, secret, kind, where, userType) {
|
|
1143
|
-
const token = await context.store.tokens.countAttempt(hashSecret(secret), kind);
|
|
1144
|
-
refuseUnusable(token, context.clock.now(), where, "challenge");
|
|
1145
|
-
if (token.attempts > CODE_ATTEMPTS) {
|
|
1146
|
-
throw codeInvalid(where, token.userId, userType, 0);
|
|
833
|
+
// src/auth/one-time/codes.ts
|
|
834
|
+
var CODE_ATTEMPTS = 5;
|
|
835
|
+
async function countCodeAttempt(context, secret, kind, where, userType) {
|
|
836
|
+
const token = await context.store.tokens.countAttempt(hashSecret(secret), kind);
|
|
837
|
+
refuseUnusable(token, context.clock.now(), where, "challenge");
|
|
838
|
+
if (token.attempts > CODE_ATTEMPTS) {
|
|
839
|
+
throw codeInvalid(where, token.userId, userType, 0);
|
|
1147
840
|
}
|
|
1148
841
|
return { token, attemptsLeft: CODE_ATTEMPTS - token.attempts };
|
|
1149
842
|
}
|
|
@@ -1167,7 +860,7 @@ function codeMatches(token, challenge, code) {
|
|
|
1167
860
|
return false;
|
|
1168
861
|
const given = Buffer.from(hashCode(challenge, code));
|
|
1169
862
|
const held = Buffer.from(token.codeHash);
|
|
1170
|
-
return given.length === held.length &&
|
|
863
|
+
return given.length === held.length && timingSafeEqual2(given, held);
|
|
1171
864
|
}
|
|
1172
865
|
// src/auth/one-time/issue.ts
|
|
1173
866
|
import { randomInt } from "node:crypto";
|
|
@@ -1263,7 +956,7 @@ async function insertLink(links, tokenHash) {
|
|
|
1263
956
|
throw links.counting.vanished();
|
|
1264
957
|
}
|
|
1265
958
|
// src/auth/mail-requests.ts
|
|
1266
|
-
var
|
|
959
|
+
var PURPOSE = "janus/mail/requests/v1";
|
|
1267
960
|
async function countMailByAddress(context, type, flow, email, where, userId) {
|
|
1268
961
|
await countMail(context, type, flow, {
|
|
1269
962
|
address: normalizeEmail(email),
|
|
@@ -1277,7 +970,7 @@ async function countMail(context, type, flow, counted, where) {
|
|
|
1277
970
|
const throttle = context.config.mailThrottle;
|
|
1278
971
|
if (throttle === null)
|
|
1279
972
|
return;
|
|
1280
|
-
const key = counterKey(context,
|
|
973
|
+
const key = counterKey(context, PURPOSE);
|
|
1281
974
|
const subject = "address" in counted ? keyed(key, [flow, "address", counted.address]) : keyed(key, [flow, "user", counted.userId]);
|
|
1282
975
|
const { attempts, endsAt } = await countInWindow(context, {
|
|
1283
976
|
windowMs: throttle.windowMs,
|
|
@@ -1301,110 +994,428 @@ var normalized = (email) => email === null ? null : normalizeEmail(email);
|
|
|
1301
994
|
function emailChanged(type, before, after) {
|
|
1302
995
|
return normalized(emailOf(type, before)) !== normalized(emailOf(type, after));
|
|
1303
996
|
}
|
|
1304
|
-
async function fieldsPatch(type, record, patch, where) {
|
|
1305
|
-
const fields = await validateFields(type, { ...record.fields, ...patch }, where);
|
|
997
|
+
async function fieldsPatch(type, record, patch, where) {
|
|
998
|
+
const fields = await validateFields(type, { ...record.fields, ...patch }, where);
|
|
999
|
+
return {
|
|
1000
|
+
fields,
|
|
1001
|
+
logins: loginsOf(type, fields, where),
|
|
1002
|
+
schemaVersion: type.schemaVersion,
|
|
1003
|
+
...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/auth/email-flows/change-email-token.ts
|
|
1008
|
+
var formerHash = (secret, former) => hashSecret(`janus/changeEmail/former\x00${secret}\x00${normalizeEmail(former ?? "")}`);
|
|
1009
|
+
async function newAddressOf(type, user, email, where) {
|
|
1010
|
+
const fields = await validateFields(type, { ...user.fields, [type.email]: email }, where);
|
|
1011
|
+
const address = emailOf(type, fields);
|
|
1012
|
+
if (address === null) {
|
|
1013
|
+
throw new TypeError(`janus: email "${type.email}" did not name a string in validated ${type.name} fields — the schema must keep the e-mail field a required string`);
|
|
1014
|
+
}
|
|
1015
|
+
if (normalizeEmail(address) === normalizeEmail(emailOf(type, user.fields) ?? "")) {
|
|
1016
|
+
throw new UserInvalidError2(`${where}: the new e-mail is the user's current one — a change moves to another address; verifyEmail proves the current one`, {
|
|
1017
|
+
issues: [
|
|
1018
|
+
{ path: [type.email], message: "is the current e-mail of the user" }
|
|
1019
|
+
],
|
|
1020
|
+
userType: type.name
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
return address;
|
|
1024
|
+
}
|
|
1025
|
+
async function issueChangeEmailToken(context, type, user, address) {
|
|
1026
|
+
const secret = mintSecret();
|
|
1027
|
+
const { expiresAt } = await issueOneTime(context, {
|
|
1028
|
+
kind: "changeEmail",
|
|
1029
|
+
userId: user.id,
|
|
1030
|
+
address,
|
|
1031
|
+
ttlMs: context.config.tokenTtlMs.changeEmail,
|
|
1032
|
+
secret,
|
|
1033
|
+
boundHash: formerHash(secret, emailOf(type, user.fields))
|
|
1034
|
+
});
|
|
1035
|
+
await context.store.tokens.spendUserTokens(user.id, "changeEmail", context.clock.now(), hashSecret(secret));
|
|
1036
|
+
return { token: secret, email: address, expiresAt };
|
|
1037
|
+
}
|
|
1038
|
+
function refuseMoved(type, user, token, secret, where) {
|
|
1039
|
+
if (token.codeHash !== formerHash(secret, emailOf(type, user.fields))) {
|
|
1040
|
+
throw new TokenError2("TOKEN_STALE", `${where}: the token was asked from an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
async function redeemChangeEmailToken(context, type, secret, where) {
|
|
1044
|
+
const token = await spendOneTime(context, secret, "changeEmail", where, "token");
|
|
1045
|
+
const user = await findRecord(context, token.userId, type?.name ?? null);
|
|
1046
|
+
const owner = type ?? context.config.types.get(user?.type ?? "");
|
|
1047
|
+
if (user === null || owner === undefined)
|
|
1048
|
+
throw unknownOneTime(where, "token");
|
|
1049
|
+
if (emailOf(owner, user.fields) === null)
|
|
1050
|
+
throw unknownOneTime(where, "token");
|
|
1051
|
+
refuseMoved(owner, user, token, secret, where);
|
|
1052
|
+
return { token, user, type: owner };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/auth/email-flows/change-email.ts
|
|
1056
|
+
var inactive = (type, user, where) => new UserInactiveError2(`${where}: the user is inactive`, {
|
|
1057
|
+
userId: user.id,
|
|
1058
|
+
userType: type.name
|
|
1059
|
+
});
|
|
1060
|
+
function changeEmailFlows(context, type, at) {
|
|
1061
|
+
return {
|
|
1062
|
+
async send(user, email) {
|
|
1063
|
+
const where = at("changeEmail.send");
|
|
1064
|
+
const record = await getRecord(context, idOf(user), type.name, where);
|
|
1065
|
+
if (!record.active)
|
|
1066
|
+
throw inactive(type, record, where);
|
|
1067
|
+
const address = await newAddressOf(type, record, email, where);
|
|
1068
|
+
await countMailByUser(context, type, "changeEmail", record.id, where);
|
|
1069
|
+
await countMailByAddress(context, type, "changeEmail", address, where, record.id);
|
|
1070
|
+
return issueChangeEmailToken(context, type, record, address);
|
|
1071
|
+
},
|
|
1072
|
+
async confirm(secret) {
|
|
1073
|
+
return confirmChange(context, type, secret, at("changeEmail.confirm"));
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
async function confirmChange(context, asked, secret, where) {
|
|
1078
|
+
const { token, user, type } = await redeemChangeEmailToken(context, asked, secret, where);
|
|
1079
|
+
if (!user.active)
|
|
1080
|
+
throw inactive(type, user, where);
|
|
1081
|
+
const seen = { replaced: null };
|
|
1082
|
+
const written = await writeUser(context, user.id, type, undefined, where, async (record, now) => {
|
|
1083
|
+
if (!record.active)
|
|
1084
|
+
throw inactive(type, record, where);
|
|
1085
|
+
refuseMoved(type, record, token, secret, where);
|
|
1086
|
+
seen.replaced = record;
|
|
1087
|
+
const patch = await fieldsPatch(type, record, { [type.email]: token.address }, where);
|
|
1088
|
+
const proved = record.emailVerifiedAt === null || emailChanged(type, record.fields, patch.fields);
|
|
1089
|
+
return proved ? { ...patch, emailVerifiedAt: now } : patch;
|
|
1090
|
+
});
|
|
1091
|
+
const { replaced } = seen;
|
|
1092
|
+
const moved = replaced !== null && emailChanged(type, replaced.fields, written.fields);
|
|
1093
|
+
if (replaced !== null && moved) {
|
|
1094
|
+
await emit(context, "user.emailChanged", written, written.updatedAt, {
|
|
1095
|
+
formerEmail: emailOf(type, replaced.fields)
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
if (moved || replaced?.emailVerifiedAt === null) {
|
|
1099
|
+
await emit(context, "user.emailVerified", written, written.updatedAt);
|
|
1100
|
+
}
|
|
1101
|
+
return toUser(written);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/auth/email-flows/any-type-change-email.ts
|
|
1105
|
+
function anyTypeChangeEmail(context) {
|
|
1106
|
+
return {
|
|
1107
|
+
changeEmail: {
|
|
1108
|
+
confirm: (secret) => confirmChange(context, null, secret, "changeEmail.confirm")
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// src/auth/port/assert-stores.ts
|
|
1114
|
+
var REQUIRED = {
|
|
1115
|
+
users: [
|
|
1116
|
+
"insertUser",
|
|
1117
|
+
"findUser",
|
|
1118
|
+
"findUserByLogin",
|
|
1119
|
+
"listUsers",
|
|
1120
|
+
"updateUser",
|
|
1121
|
+
"deleteUser"
|
|
1122
|
+
],
|
|
1123
|
+
sessions: [
|
|
1124
|
+
"insertSession",
|
|
1125
|
+
"findSessionByTokenHash",
|
|
1126
|
+
"extendSession",
|
|
1127
|
+
"reauthenticateSession",
|
|
1128
|
+
"revokeSession",
|
|
1129
|
+
"revokeUserSessions",
|
|
1130
|
+
"deleteUserSessions"
|
|
1131
|
+
],
|
|
1132
|
+
tokens: [
|
|
1133
|
+
"insertToken",
|
|
1134
|
+
"consumeToken",
|
|
1135
|
+
"countAttempt",
|
|
1136
|
+
"spendUserTokens",
|
|
1137
|
+
"deleteUserTokens"
|
|
1138
|
+
]
|
|
1139
|
+
};
|
|
1140
|
+
function assertStores(stores, where) {
|
|
1141
|
+
if (typeof stores !== "object" || stores === null) {
|
|
1142
|
+
throw new TypeError(`${where}: store must be an object with users, sessions and tokens`);
|
|
1143
|
+
}
|
|
1144
|
+
for (const slot of Object.keys(REQUIRED)) {
|
|
1145
|
+
const store = stores[slot];
|
|
1146
|
+
if (typeof store !== "object" || store === null) {
|
|
1147
|
+
throw new TypeError(`${where}: store.${slot} is missing`);
|
|
1148
|
+
}
|
|
1149
|
+
for (const method of REQUIRED[slot]) {
|
|
1150
|
+
if (typeof store[method] !== "function") {
|
|
1151
|
+
throw new TypeError(`${where}: store.${slot} has no method ${method}, which the port requires`);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
const slots = stores;
|
|
1156
|
+
return {
|
|
1157
|
+
collectExpired: optional(slots, "sessions", "deleteExpiredSessions", where),
|
|
1158
|
+
collectExpiredTokens: optional(slots, "tokens", "deleteExpiredTokens", where),
|
|
1159
|
+
findUsers: optional(slots, "users", "findUsers", where)
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
function optional(stores, slot, method, where) {
|
|
1163
|
+
const found = stores[slot][method];
|
|
1164
|
+
if (found !== undefined && typeof found !== "function") {
|
|
1165
|
+
throw new TypeError(`${where}: store.${slot}.${method} must be a function or absent`);
|
|
1166
|
+
}
|
|
1167
|
+
return found !== undefined;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/auth/devices/token.ts
|
|
1171
|
+
import { randomBytes as randomBytes4, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
1172
|
+
var PURPOSE2 = "janus/devices/v1";
|
|
1173
|
+
var TOKEN = /^d1\.([A-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{43})$/;
|
|
1174
|
+
function macOf(key, userId, deviceId) {
|
|
1175
|
+
return keyedHash(key, [userId, deviceId]);
|
|
1176
|
+
}
|
|
1177
|
+
function signed(sealer, userId, deviceId) {
|
|
1178
|
+
const key = derivedKey(sealer, PURPOSE2);
|
|
1179
|
+
const mac = macOf(key, userId, deviceId).toString("base64url");
|
|
1180
|
+
return `d1.${sealer.sealWith}.${deviceId}.${mac}`;
|
|
1181
|
+
}
|
|
1182
|
+
function mintDeviceToken(sealer, userId) {
|
|
1183
|
+
return signed(sealer, userId, randomBytes4(16).toString("base64url"));
|
|
1184
|
+
}
|
|
1185
|
+
function knownDeviceToken(sealer, userId, presented) {
|
|
1186
|
+
const [, keyId, deviceId, mac] = TOKEN.exec(presented) ?? [];
|
|
1187
|
+
if (keyId === undefined || deviceId === undefined || mac === undefined) {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
const key = derivedKey(sealer, PURPOSE2, keyId);
|
|
1191
|
+
if (key === undefined)
|
|
1192
|
+
return null;
|
|
1193
|
+
const expected = macOf(key, userId, deviceId);
|
|
1194
|
+
const given = Buffer.from(mac, "base64url");
|
|
1195
|
+
if (given.length !== expected.length || !timingSafeEqual3(given, expected)) {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
return keyId === sealer.sealWith ? presented : signed(sealer, userId, deviceId);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// src/auth/devices/hint.ts
|
|
1202
|
+
var UNTRACKED = Object.freeze({
|
|
1203
|
+
newDevice: false,
|
|
1204
|
+
deviceToken: null
|
|
1205
|
+
});
|
|
1206
|
+
function deviceHint(context, options, where) {
|
|
1207
|
+
if (options !== undefined && (typeof options !== "object" || options === null)) {
|
|
1208
|
+
throw new TypeError(`${where}: options must be { device }, or left out — the token goes under device`);
|
|
1209
|
+
}
|
|
1210
|
+
const device = options?.device;
|
|
1211
|
+
if (device === undefined)
|
|
1212
|
+
return;
|
|
1213
|
+
if (device !== null && typeof device !== "string") {
|
|
1214
|
+
throw new TypeError(`${where}: options.device must be the device token the client holds, or null when it holds none`);
|
|
1215
|
+
}
|
|
1216
|
+
if (context.config.devices === null) {
|
|
1217
|
+
throw new TypeError(`${where}: a device was given, but janus() has no devices — pass devices: { keys }`);
|
|
1218
|
+
}
|
|
1219
|
+
return device;
|
|
1220
|
+
}
|
|
1221
|
+
function deviceOutcome(context, userId, hint, signUp) {
|
|
1222
|
+
const sealer = context.config.devices?.sealer;
|
|
1223
|
+
if (hint === undefined || sealer === undefined)
|
|
1224
|
+
return UNTRACKED;
|
|
1225
|
+
const known = hint === null || signUp ? null : knownDeviceToken(sealer, userId, hint);
|
|
1226
|
+
if (known !== null)
|
|
1227
|
+
return { newDevice: false, deviceToken: known };
|
|
1228
|
+
return {
|
|
1229
|
+
newDevice: !signUp,
|
|
1230
|
+
deviceToken: mintDeviceToken(sealer, userId)
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
// src/auth/devices/report.ts
|
|
1234
|
+
async function reportNewDevice(context, result) {
|
|
1235
|
+
if (result.status !== "signedIn" || !result.newDevice)
|
|
1236
|
+
return;
|
|
1237
|
+
await emit(context, "user.newDeviceSignedIn", result.user, result.session.createdAt, { sessionId: result.session.id });
|
|
1238
|
+
}
|
|
1239
|
+
// src/auth/sessions/open-session.ts
|
|
1240
|
+
function toSession(record) {
|
|
1241
|
+
const { tokenHash: _, ...session } = record;
|
|
1242
|
+
return session;
|
|
1243
|
+
}
|
|
1244
|
+
async function openSession(context, type, user, device, signUp = false) {
|
|
1245
|
+
const now = context.clock.now();
|
|
1246
|
+
const token = mintSecret();
|
|
1247
|
+
const record = {
|
|
1248
|
+
id: mintId2(now.getTime()),
|
|
1249
|
+
tokenHash: hashSecret(token),
|
|
1250
|
+
userId: user.id,
|
|
1251
|
+
authenticatedAt: now,
|
|
1252
|
+
expiresAt: new Date(now.getTime() + type.lifespanMs),
|
|
1253
|
+
revokedAt: null,
|
|
1254
|
+
createdAt: now
|
|
1255
|
+
};
|
|
1256
|
+
await context.store.sessions.insertSession(record);
|
|
1306
1257
|
return {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1258
|
+
status: "signedIn",
|
|
1259
|
+
user: toUser(user),
|
|
1260
|
+
session: toSession(record),
|
|
1261
|
+
token,
|
|
1262
|
+
...deviceOutcome(context, user.id, device, signUp)
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
// src/auth/sessions/presented-token.ts
|
|
1266
|
+
function presentedToken(request, cookieName) {
|
|
1267
|
+
const headers = headersOf(request);
|
|
1268
|
+
const read = (name) => {
|
|
1269
|
+
if (headers instanceof Headers)
|
|
1270
|
+
return headers.get(name);
|
|
1271
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1272
|
+
if (key.toLowerCase() !== name || value === undefined)
|
|
1273
|
+
continue;
|
|
1274
|
+
return typeof value === "string" ? value : value.join(", ");
|
|
1275
|
+
}
|
|
1276
|
+
return null;
|
|
1311
1277
|
};
|
|
1278
|
+
const authorization = read("authorization");
|
|
1279
|
+
if (authorization !== null) {
|
|
1280
|
+
const match = /^bearer(?:\s+(.*))?$/i.exec(authorization.trim());
|
|
1281
|
+
if (match)
|
|
1282
|
+
return match[1]?.trim() ?? "";
|
|
1283
|
+
}
|
|
1284
|
+
const header = read("x-session-token");
|
|
1285
|
+
if (header !== null)
|
|
1286
|
+
return header.trim();
|
|
1287
|
+
const cookie = read("cookie");
|
|
1288
|
+
if (cookie !== null) {
|
|
1289
|
+
for (const pair of cookie.split(";")) {
|
|
1290
|
+
const at = pair.indexOf("=");
|
|
1291
|
+
if (at !== -1 && pair.slice(0, at).trim() === cookieName) {
|
|
1292
|
+
return pair.slice(at + 1).trim();
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
function headersOf(request) {
|
|
1299
|
+
if (request instanceof Headers)
|
|
1300
|
+
return request;
|
|
1301
|
+
const inner = request.headers;
|
|
1302
|
+
if (inner instanceof Headers)
|
|
1303
|
+
return inner;
|
|
1304
|
+
if (typeof inner === "object" && inner !== null)
|
|
1305
|
+
return inner;
|
|
1306
|
+
return request;
|
|
1307
|
+
}
|
|
1308
|
+
// src/auth/sessions/standing.ts
|
|
1309
|
+
async function standingSession(context, request) {
|
|
1310
|
+
const token = presentedToken(request, context.config.cookie.name);
|
|
1311
|
+
if (token === null)
|
|
1312
|
+
return null;
|
|
1313
|
+
const session = await context.store.sessions.findSessionByTokenHash(hashSecret(token));
|
|
1314
|
+
const now = context.clock.now().getTime();
|
|
1315
|
+
return session === null || session.revokedAt !== null || session.expiresAt.getTime() <= now ? null : { token, session };
|
|
1312
1316
|
}
|
|
1313
1317
|
|
|
1314
|
-
// src/auth/
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1318
|
+
// src/auth/sessions/authenticate.ts
|
|
1319
|
+
async function authenticate(context, request, options) {
|
|
1320
|
+
const { store, clock, config } = context;
|
|
1321
|
+
const presented = await standingSession(context, request);
|
|
1322
|
+
if (presented === null)
|
|
1323
|
+
return null;
|
|
1324
|
+
const { token, session } = presented;
|
|
1325
|
+
const now = clock.now().getTime();
|
|
1326
|
+
const user = await store.users.findUser(session.userId);
|
|
1327
|
+
if (user === null || !user.active || options?.type !== undefined && user.type !== options.type) {
|
|
1328
|
+
return null;
|
|
1321
1329
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1330
|
+
const type = config.types.get(user.type);
|
|
1331
|
+
if (type === undefined || !renewalDue(type, session, now)) {
|
|
1332
|
+
return {
|
|
1333
|
+
user: toUser(user),
|
|
1334
|
+
session: toSession(session),
|
|
1335
|
+
token,
|
|
1336
|
+
renewed: false
|
|
1337
|
+
};
|
|
1329
1338
|
}
|
|
1330
|
-
|
|
1339
|
+
const extended = await store.sessions.extendSession(session.id, new Date(now + type.lifespanMs));
|
|
1340
|
+
if (extended === null)
|
|
1341
|
+
return null;
|
|
1342
|
+
return {
|
|
1343
|
+
user: toUser(user),
|
|
1344
|
+
session: toSession(extended),
|
|
1345
|
+
token,
|
|
1346
|
+
renewed: true
|
|
1347
|
+
};
|
|
1331
1348
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
const { expiresAt } = await issueOneTime(context, {
|
|
1335
|
-
kind: "changeEmail",
|
|
1336
|
-
userId: user.id,
|
|
1337
|
-
address,
|
|
1338
|
-
ttlMs: context.config.tokenTtlMs.changeEmail,
|
|
1339
|
-
secret,
|
|
1340
|
-
boundHash: formerHash(secret, emailOf(type, user.fields))
|
|
1341
|
-
});
|
|
1342
|
-
await context.store.tokens.spendUserTokens(user.id, "changeEmail", context.clock.now(), hashSecret(secret));
|
|
1343
|
-
return { token: secret, email: address, expiresAt };
|
|
1349
|
+
function renewalDue(type, session, now) {
|
|
1350
|
+
return type.renewAfterMs !== null && type.lifespanMs - (session.expiresAt.getTime() - now) >= type.renewAfterMs;
|
|
1344
1351
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1352
|
+
|
|
1353
|
+
// src/auth/sessions/collect-expired.ts
|
|
1354
|
+
async function collectExpired(context) {
|
|
1355
|
+
const { store, clock, capabilities } = context;
|
|
1356
|
+
const sessions = capabilities.collectExpired ? store.sessions.deleteExpiredSessions : undefined;
|
|
1357
|
+
const tokens = capabilities.collectExpiredTokens ? store.tokens.deleteExpiredTokens : undefined;
|
|
1358
|
+
if (sessions === undefined && tokens === undefined) {
|
|
1359
|
+
throw new UnsupportedError2("collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens — their stores expire records on their own, or implement the methods", { slot: "sessions", operation: "deleteExpiredSessions" });
|
|
1348
1360
|
}
|
|
1361
|
+
const before = clock.now();
|
|
1362
|
+
const collected = sessions === undefined ? 0 : await sessions(before);
|
|
1363
|
+
return collected + (tokens === undefined ? 0 : await tokens(before));
|
|
1349
1364
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1365
|
+
|
|
1366
|
+
// src/auth/sessions/cookie.ts
|
|
1367
|
+
function cookieOf(context) {
|
|
1368
|
+
const { name, domain, path, sameSite, secure } = context.config.cookie;
|
|
1369
|
+
const attributes = [
|
|
1370
|
+
`Path=${path}`,
|
|
1371
|
+
...domain === null ? [] : [`Domain=${domain}`],
|
|
1372
|
+
"HttpOnly",
|
|
1373
|
+
`SameSite=${sameSite[0]?.toUpperCase()}${sameSite.slice(1)}`,
|
|
1374
|
+
...secure ? ["Secure"] : []
|
|
1375
|
+
].join("; ");
|
|
1376
|
+
return {
|
|
1377
|
+
name,
|
|
1378
|
+
serialize: (token, session) => `${name}=${token}; Expires=${session.expiresAt.toUTCString()}; ${attributes}`,
|
|
1379
|
+
clear: () => `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; ${attributes}`
|
|
1380
|
+
};
|
|
1357
1381
|
}
|
|
1358
1382
|
|
|
1359
|
-
// src/auth/
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
userType: type.name
|
|
1363
|
-
});
|
|
1364
|
-
function changeEmailFlows(context, type, at) {
|
|
1383
|
+
// src/auth/sessions/shared-api.ts
|
|
1384
|
+
function sharedApi(context) {
|
|
1385
|
+
const { store, clock, config } = context;
|
|
1365
1386
|
return {
|
|
1366
|
-
async
|
|
1367
|
-
|
|
1368
|
-
const record = await getRecord(context, idOf(user), type.name, where);
|
|
1369
|
-
if (!record.active)
|
|
1370
|
-
throw inactive(type, record, where);
|
|
1371
|
-
const address = await newAddressOf(type, record, email, where);
|
|
1372
|
-
await countMailByUser(context, type, "changeEmail", record.id, where);
|
|
1373
|
-
await countMailByAddress(context, type, "changeEmail", address, where, record.id);
|
|
1374
|
-
return issueChangeEmailToken(context, type, record, address);
|
|
1387
|
+
async authenticate(request, options) {
|
|
1388
|
+
return authenticate(context, request, options);
|
|
1375
1389
|
},
|
|
1376
|
-
async
|
|
1377
|
-
|
|
1378
|
-
|
|
1390
|
+
async signOut(request) {
|
|
1391
|
+
const token = presentedToken(request, config.cookie.name);
|
|
1392
|
+
if (token === null)
|
|
1393
|
+
return false;
|
|
1394
|
+
const session = await store.sessions.findSessionByTokenHash(hashSecret(token));
|
|
1395
|
+
if (session === null)
|
|
1396
|
+
return false;
|
|
1397
|
+
return store.sessions.revokeSession(session.id, clock.now());
|
|
1398
|
+
},
|
|
1399
|
+
async signOutEverywhere(user, options) {
|
|
1400
|
+
const id = idOf(user);
|
|
1401
|
+
if (!isId2(id))
|
|
1402
|
+
return 0;
|
|
1403
|
+
return options?.except === undefined || !isId2(options.except) ? store.sessions.revokeUserSessions(id, clock.now()) : store.sessions.revokeUserSessions(id, clock.now(), options.except);
|
|
1404
|
+
},
|
|
1405
|
+
async findUser(id) {
|
|
1406
|
+
const record = await findRecord(context, id, null);
|
|
1407
|
+
return record === null ? null : toUser(record);
|
|
1408
|
+
},
|
|
1409
|
+
async getUser(id) {
|
|
1410
|
+
return toUser(await getRecord(context, id, null, "getUser"));
|
|
1411
|
+
},
|
|
1412
|
+
cookie: cookieOf(context),
|
|
1413
|
+
async collectExpired() {
|
|
1414
|
+
return collectExpired(context);
|
|
1415
|
+
},
|
|
1416
|
+
types: [...config.types.keys()]
|
|
1379
1417
|
};
|
|
1380
1418
|
}
|
|
1381
|
-
async function confirmChange(context, type, secret, where) {
|
|
1382
|
-
const { token, user } = await redeemChangeEmailToken(context, type, secret, where);
|
|
1383
|
-
if (!user.active)
|
|
1384
|
-
throw inactive(type, user, where);
|
|
1385
|
-
const seen = { replaced: null };
|
|
1386
|
-
const written = await writeUser(context, user.id, type, undefined, where, async (record, now) => {
|
|
1387
|
-
if (!record.active)
|
|
1388
|
-
throw inactive(type, record, where);
|
|
1389
|
-
refuseMoved(type, record, token, secret, where);
|
|
1390
|
-
seen.replaced = record;
|
|
1391
|
-
const patch = await fieldsPatch(type, record, { [type.email]: token.address }, where);
|
|
1392
|
-
const proved = record.emailVerifiedAt === null || emailChanged(type, record.fields, patch.fields);
|
|
1393
|
-
return proved ? { ...patch, emailVerifiedAt: now } : patch;
|
|
1394
|
-
});
|
|
1395
|
-
const { replaced } = seen;
|
|
1396
|
-
const moved = replaced !== null && emailChanged(type, replaced.fields, written.fields);
|
|
1397
|
-
if (replaced !== null && moved) {
|
|
1398
|
-
await emit(context, "user.emailChanged", written, written.updatedAt, {
|
|
1399
|
-
formerEmail: emailOf(type, replaced.fields)
|
|
1400
|
-
});
|
|
1401
|
-
}
|
|
1402
|
-
if (moved || replaced?.emailVerifiedAt === null) {
|
|
1403
|
-
await emit(context, "user.emailVerified", written, written.updatedAt);
|
|
1404
|
-
}
|
|
1405
|
-
return toUser(written);
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
1419
|
// src/auth/password-written.ts
|
|
1409
1420
|
async function endWhatThePasswordOpened(context, userId) {
|
|
1410
1421
|
const now = context.clock.now();
|
|
@@ -2543,10 +2554,14 @@ function findOrCreateFlows(context, type, at) {
|
|
|
2543
2554
|
|
|
2544
2555
|
// src/auth/users/change-password.ts
|
|
2545
2556
|
async function changePassword(context, type, user, change, options, where) {
|
|
2546
|
-
passwordRule(type, where);
|
|
2557
|
+
const rule = passwordRule(type, where);
|
|
2547
2558
|
checkPassword(type, change?.next, where);
|
|
2548
2559
|
const hasher = requireHasher(context, where);
|
|
2549
2560
|
const written = await writeUser(context, user, type, options, where, async (record, now) => {
|
|
2561
|
+
const login = rule.login in record.fields ? record.fields[rule.login] : undefined;
|
|
2562
|
+
if (typeof login === "string") {
|
|
2563
|
+
await countSignInAttempt(context, type, rule.normalize(login), where);
|
|
2564
|
+
}
|
|
2550
2565
|
if (record.password === null || !await passwordMatches(context, record, change.current, where)) {
|
|
2551
2566
|
throw new CredentialError2("CREDENTIALS_INVALID", `${where}: the current password does not match`, {
|
|
2552
2567
|
reason: record.password === null ? "noPassword" : "wrongPassword",
|
|
@@ -2562,6 +2577,7 @@ async function changePassword(context, type, user, change, options, where) {
|
|
|
2562
2577
|
};
|
|
2563
2578
|
});
|
|
2564
2579
|
await passwordChanged(context, written);
|
|
2580
|
+
await restartSignInCount(context, type, written, where);
|
|
2565
2581
|
return written;
|
|
2566
2582
|
}
|
|
2567
2583
|
|
|
@@ -2820,7 +2836,11 @@ function janus(config) {
|
|
|
2820
2836
|
type.name,
|
|
2821
2837
|
typeApi(context, type)
|
|
2822
2838
|
]));
|
|
2823
|
-
const surface = resolved.single ? { ...shared, ...types["user"] } : {
|
|
2839
|
+
const surface = resolved.single ? { ...shared, ...types["user"] } : {
|
|
2840
|
+
...shared,
|
|
2841
|
+
...types,
|
|
2842
|
+
...anyTypeChangeEmail(context)
|
|
2843
|
+
};
|
|
2824
2844
|
return surface;
|
|
2825
2845
|
}
|
|
2826
2846
|
// src/auth/sessions/fresh.ts
|
|
@@ -2874,5 +2894,5 @@ export {
|
|
|
2874
2894
|
systemClock
|
|
2875
2895
|
};
|
|
2876
2896
|
|
|
2877
|
-
//# debugId=
|
|
2897
|
+
//# debugId=56A97558DCD21E4F64756E2164756E21
|
|
2878
2898
|
//# sourceMappingURL=index.js.map
|