@luckystack/login 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/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  registerOAuthProviderFactory,
15
15
  registerOAuthProviders,
16
16
  registerUserAdapter
17
- } from "./chunk-CLYMWF3A.js";
17
+ } from "./chunk-CYGFR56I.js";
18
18
  import {
19
19
  activeUsersKeyFor,
20
20
  deleteSession,
@@ -29,10 +29,10 @@ import {
29
29
  revokeUserSessions,
30
30
  saveSession,
31
31
  sessionKeyFor
32
- } from "./chunk-6IGNAMXW.js";
32
+ } from "./chunk-MZNCQ26U.js";
33
33
 
34
34
  // src/index.ts
35
- import { registerSessionProvider } from "@luckystack/core";
35
+ import { registerRedactedLogKeys, registerSessionProvider } from "@luckystack/core";
36
36
 
37
37
  // src/authLockout.ts
38
38
  import {
@@ -515,6 +515,17 @@ var readStringField = (record, key, fallback = "") => {
515
515
  if (typeof value === "boolean") return String(value);
516
516
  return fallback;
517
517
  };
518
+ var readAccountId = (record, key) => {
519
+ const value = record[key];
520
+ if (typeof value === "string" && value !== "") return value;
521
+ if (typeof value === "number" && Number.isSafeInteger(value)) return String(value);
522
+ return void 0;
523
+ };
524
+ var compareStoredAccountId = (stored, accountId) => {
525
+ if (stored === null || stored === void 0 || stored === "") return "unbound";
526
+ const comparable = typeof stored === "string" || typeof stored === "number" || typeof stored === "bigint";
527
+ return comparable && String(stored) === accountId ? "match" : "mismatch";
528
+ };
518
529
  var generateAvatarFallbackColor = () => `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0")}`;
519
530
  var resolveUploadedAvatar = async (userId) => {
520
531
  const filePath = path.join(uploadsFolder(), `${userId}.webp`);
@@ -522,7 +533,13 @@ var resolveUploadedAvatar = async (userId) => {
522
533
  return avatarStat ? `${userId}.webp` : null;
523
534
  };
524
535
  var sanitizeUserForSession = (user) => {
525
- const { password: _password, totpSecret: _totpSecret, recoveryCodes: _recoveryCodes, ...safeUser } = user;
536
+ const {
537
+ password: _password,
538
+ totpSecret: _totpSecret,
539
+ recoveryCodes: _recoveryCodes,
540
+ providerAccountId: _providerAccountId,
541
+ ...safeUser
542
+ } = user;
526
543
  return safeUser;
527
544
  };
528
545
  var emitLoginFailed = (payload) => {
@@ -841,6 +858,7 @@ var exchangeOAuthToken = async (provider, code, codeVerifier) => {
841
858
  }
842
859
  return accessToken;
843
860
  };
861
+ var refuseOAuth = (refusal = "oauth.userResolveFailed", userId) => userId === void 0 ? { refusal } : { refusal, userId };
844
862
  var fetchOAuthProfile = async (provider, accessToken) => {
845
863
  const getUserData = async () => {
846
864
  const response2 = await fetch(provider.userInfoURL, {
@@ -863,9 +881,20 @@ var fetchOAuthProfile = async (provider, accessToken) => {
863
881
  return null;
864
882
  }
865
883
  const userData = asRecord(response);
884
+ let accountId;
885
+ if (provider.accountIdKey) {
886
+ accountId = readAccountId(userData, provider.accountIdKey);
887
+ if (accountId === void 0) {
888
+ getLogger3().warn("oauth: provider userinfo has no stable account id \u2014 refusing the login", {
889
+ provider: provider.name,
890
+ accountIdKey: provider.accountIdKey
891
+ });
892
+ return refuseOAuth("oauth.accountIdMissing");
893
+ }
894
+ }
866
895
  const resolvedName = readStringField(userData, provider.nameKey);
867
896
  const name = resolvedName === "" ? "didnt find a name" : resolvedName;
868
- const emailValue = userData[provider.emailKey];
897
+ const emailValue = provider.alwaysUseGetEmail ? void 0 : userData[provider.emailKey];
869
898
  let email = typeof emailValue === "string" ? emailValue : void 0;
870
899
  let emailVerified = false;
871
900
  if (email && provider.emailVerifiedKey) {
@@ -883,13 +912,14 @@ var fetchOAuthProfile = async (provider, accessToken) => {
883
912
  const avatarValue = provider.avatarKey ? readStringField(userData, provider.avatarKey) : provider.getAvatar ? await provider.getAvatar({ userData, avatarId: typeof avatarId === "string" ? avatarId : "", accessToken }) : "";
884
913
  const avatar = typeof avatarValue === "string" ? avatarValue : "";
885
914
  if (!email && provider.getEmail) {
886
- const selectedEmail = await provider.getEmail(accessToken);
915
+ const selected = await provider.getEmail(accessToken, userData);
916
+ const selectedEmail = typeof selected === "object" ? selected.email : selected;
887
917
  if (!selectedEmail) {
888
918
  getLogger3().warn("oauth: no email found via provider.getEmail", { provider: provider.name });
889
919
  return null;
890
920
  }
891
921
  email = selectedEmail;
892
- emailVerified = true;
922
+ emailVerified = typeof selected === "object" ? selected.verified : true;
893
923
  }
894
924
  if (email !== void 0) {
895
925
  const normalizedEmail = email.trim().toLowerCase();
@@ -899,23 +929,75 @@ var fetchOAuthProfile = async (provider, accessToken) => {
899
929
  }
900
930
  email = normalizedEmail;
901
931
  }
902
- return { email, name, avatar, emailVerified, rawUserData: userData };
932
+ return { email, name, avatar, emailVerified, accountId, rawUserData: userData };
903
933
  };
904
- var findOrCreateOAuthUser = async (provider, profile) => {
905
- const { email, name, avatar, emailVerified } = profile;
906
- if (!email) return null;
907
- const preLoginResult = await dispatchHook("preLogin", { email, provider: provider.name });
934
+ var warnedMissingAccountIdLookup = false;
935
+ var supportsAccountBinding = (adapter) => {
936
+ if (typeof adapter.findByProviderAccountId === "function") return true;
937
+ if (!warnedMissingAccountIdLookup) {
938
+ warnedMissingAccountIdLookup = true;
939
+ getLogger3().warn(
940
+ "[login] the registered UserAdapter does not implement findByProviderAccountId \u2014 OAuth accounts are not bound to the provider id: existing accounts are still matched by verified email only, and an email change at the provider starts a new account. Implement findByProviderAccountId and store providerAccountId to enable binding."
941
+ );
942
+ }
943
+ return false;
944
+ };
945
+ var signInExistingOAuthUser = async (userAdapter, existing) => {
946
+ const uploadedAvatar = await resolveUploadedAvatar(existing.id);
947
+ if (uploadedAvatar) {
948
+ existing.avatar = uploadedAvatar;
949
+ }
950
+ const previousLogin = existing.lastLogin ?? null;
951
+ const nowLogin = /* @__PURE__ */ new Date();
952
+ await tryCatch2(() => userAdapter.update(existing.id, { lastLogin: nowLogin }));
953
+ return {
954
+ user: {
955
+ ...sanitizeUserForSession(existing),
956
+ token: "",
957
+ lastLogin: nowLogin,
958
+ previousLogin
959
+ },
960
+ isNewUser: false
961
+ };
962
+ };
963
+ var passesOAuthPreLogin = async (provider, providerEmail, existing) => {
964
+ const storedEmail = typeof existing?.email === "string" && existing.email !== "" ? existing.email : void 0;
965
+ const preLoginResult = await dispatchHook("preLogin", {
966
+ email: storedEmail ?? providerEmail,
967
+ provider: provider.name,
968
+ ...existing ? { userId: existing.id } : {},
969
+ providerEmail
970
+ });
908
971
  if (preLoginResult.stopped) {
909
972
  getLogger3().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });
910
- return null;
973
+ return false;
911
974
  }
975
+ return true;
976
+ };
977
+ var findOrCreateOAuthUser = async (provider, profile) => {
978
+ const { email, name, avatar, emailVerified, accountId } = profile;
979
+ if (!email) return refuseOAuth();
912
980
  const userAdapter = getUserAdapter();
981
+ const bindingId = accountId !== void 0 && supportsAccountBinding(userAdapter) ? accountId : void 0;
982
+ if (bindingId !== void 0) {
983
+ const [boundError, boundUser] = await tryCatch2(
984
+ async () => await userAdapter.findByProviderAccountId?.({ provider: provider.name, providerAccountId: bindingId }) ?? null
985
+ );
986
+ if (boundError) {
987
+ getLogger3().error("oauth: findByProviderAccountId failed", boundError);
988
+ return refuseOAuth();
989
+ }
990
+ if (boundUser?.id) {
991
+ if (!await passesOAuthPreLogin(provider, email, boundUser)) return refuseOAuth(void 0, boundUser.id);
992
+ return signInExistingOAuthUser(userAdapter, boundUser);
993
+ }
994
+ }
913
995
  const [findError, findResponse] = await tryCatch2(
914
996
  () => resolveUserByEmail(userAdapter, { email, provider: provider.name })
915
997
  );
916
998
  if (findError) {
917
999
  getLogger3().error("oauth: findByEmail failed", findError);
918
- return null;
1000
+ return refuseOAuth();
919
1001
  }
920
1002
  if (findResponse?.id) {
921
1003
  const existingProvider = findResponse.provider ?? null;
@@ -925,40 +1007,56 @@ var findOrCreateOAuthUser = async (provider, profile) => {
925
1007
  "oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)",
926
1008
  { provider: provider.name, existingProvider }
927
1009
  );
928
- return null;
1010
+ return refuseOAuth("oauth.emailUnverified", findResponse.id);
929
1011
  }
930
- const uploadedAvatar = await resolveUploadedAvatar(findResponse.id);
931
- if (uploadedAvatar) {
932
- findResponse.avatar = uploadedAvatar;
1012
+ let bindTo;
1013
+ if (accountId !== void 0 && (!isCrossProviderLink || existingProvider === null)) {
1014
+ const stored = compareStoredAccountId(findResponse.providerAccountId, accountId);
1015
+ if (stored === "mismatch") {
1016
+ getLogger3().warn(
1017
+ "oauth: refusing login \u2014 the account with this email is bound to a different provider account (account-takeover guard)",
1018
+ { provider: provider.name, userId: findResponse.id }
1019
+ );
1020
+ return refuseOAuth("oauth.accountBoundElsewhere", findResponse.id);
1021
+ }
1022
+ if (stored === "unbound" && !isCrossProviderLink) {
1023
+ if (!emailVerified) {
1024
+ getLogger3().warn(
1025
+ "oauth: refusing login \u2014 the account with this email is not bound to a provider id yet and the provider did not verify the email (account-takeover guard)",
1026
+ { provider: provider.name, userId: findResponse.id }
1027
+ );
1028
+ return refuseOAuth("oauth.emailUnverified", findResponse.id);
1029
+ }
1030
+ bindTo = bindingId;
1031
+ }
933
1032
  }
934
- const previousLogin = findResponse.lastLogin ?? null;
935
- const nowLogin = /* @__PURE__ */ new Date();
936
- await tryCatch2(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));
937
- return {
938
- user: {
939
- ...sanitizeUserForSession(findResponse),
940
- token: "",
941
- lastLogin: nowLogin,
942
- previousLogin
943
- },
944
- isNewUser: false
945
- };
1033
+ if (!await passesOAuthPreLogin(provider, email, findResponse)) return refuseOAuth(void 0, findResponse.id);
1034
+ if (bindTo !== void 0) {
1035
+ const providerAccountId = bindTo;
1036
+ const [bindError] = await tryCatch2(() => userAdapter.update(findResponse.id, { providerAccountId }));
1037
+ if (bindError) {
1038
+ getLogger3().error("oauth: binding the account to the provider id failed", bindError);
1039
+ return refuseOAuth(void 0, findResponse.id);
1040
+ }
1041
+ }
1042
+ return signInExistingOAuthUser(userAdapter, findResponse);
946
1043
  }
1044
+ if (!await passesOAuthPreLogin(provider, email)) return refuseOAuth();
947
1045
  if (!emailVerified) {
948
1046
  getLogger3().warn(
949
1047
  "oauth: refusing first-login account creation for an unverified provider email (account-squatting guard)",
950
1048
  { provider: provider.name }
951
1049
  );
952
- return null;
1050
+ return refuseOAuth();
953
1051
  }
954
1052
  if (!getProjectConfig4().auth.allowRegistration) {
955
1053
  getLogger3().warn("oauth: registration disabled \u2014 refusing first-login account creation");
956
- return null;
1054
+ return refuseOAuth();
957
1055
  }
958
1056
  const preRegisterResult = await dispatchHook("preRegister", { email, provider: provider.name, name });
959
1057
  if (preRegisterResult.stopped) {
960
1058
  getLogger3().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });
961
- return null;
1059
+ return refuseOAuth();
962
1060
  }
963
1061
  const createNewUser = async () => userAdapter.create({
964
1062
  email,
@@ -966,14 +1064,15 @@ var findOrCreateOAuthUser = async (provider, profile) => {
966
1064
  name,
967
1065
  avatar,
968
1066
  avatarFallback: generateAvatarFallbackColor(),
969
- language: getProjectConfig4().defaultLanguage
1067
+ language: getProjectConfig4().defaultLanguage,
1068
+ ...bindingId === void 0 ? {} : { providerAccountId: bindingId }
970
1069
  });
971
1070
  const [createError, createResponse] = await tryCatch2(createNewUser);
972
1071
  if (createError) {
973
1072
  getLogger3().error("oauth: user create failed", createError);
974
- return null;
1073
+ return refuseOAuth();
975
1074
  }
976
- if (!createResponse) return null;
1075
+ if (!createResponse) return refuseOAuth();
977
1076
  return {
978
1077
  user: { ...sanitizeUserForSession(createResponse), token: "" },
979
1078
  isNewUser: true
@@ -1075,9 +1174,19 @@ var loginCallback = async (pathname, req, _res, options = {}) => {
1075
1174
  emitLoginFailed({ provider: providerName, reason: "oauth.profileFetchFailed", stage: "oauth" });
1076
1175
  return false;
1077
1176
  }
1177
+ if ("refusal" in profile) {
1178
+ emitLoginFailed({ provider: providerName, reason: profile.refusal, stage: "oauth" });
1179
+ return false;
1180
+ }
1078
1181
  const resolved = await findOrCreateOAuthUser(provider, profile);
1079
- if (!resolved) {
1080
- emitLoginFailed({ provider: providerName, email: profile.email, reason: "oauth.userResolveFailed", stage: "oauth" });
1182
+ if ("refusal" in resolved) {
1183
+ emitLoginFailed({
1184
+ provider: providerName,
1185
+ email: profile.email,
1186
+ ...resolved.userId === void 0 ? {} : { userId: resolved.userId },
1187
+ reason: resolved.refusal,
1188
+ stage: "oauth"
1189
+ });
1081
1190
  return false;
1082
1191
  }
1083
1192
  const newToken = randomBytes(32).toString("hex");
@@ -1137,6 +1246,14 @@ var createPasswordResetToken = async (userId) => {
1137
1246
  var consumePasswordResetToken = async (token) => {
1138
1247
  return consumeOneTimeToken(PWRESET_NAMESPACE, token);
1139
1248
  };
1249
+ var invalidatePasswordResetToken = async (userId) => {
1250
+ const pointerKey = pwresetUserKey(userId);
1251
+ const priorKey = await redis.get(pointerKey);
1252
+ if (priorKey) {
1253
+ await redis.del(priorKey);
1254
+ }
1255
+ await redis.del(pointerKey);
1256
+ };
1140
1257
  var updatePasswordHash = async (userId, plaintext) => {
1141
1258
  const reason = validatePassword(plaintext);
1142
1259
  if (reason) {
@@ -1196,28 +1313,34 @@ var sendPasswordResetEmail = async ({ email, brand }) => {
1196
1313
  });
1197
1314
  return { ok: true };
1198
1315
  }
1199
- const token = await createPasswordResetToken(user.id);
1200
- void dispatchHook2("passwordResetRequested", {
1201
- email,
1202
- matched: true,
1203
- userId: user.id,
1204
- ttlSeconds: config.auth.passwordResetTtlSeconds
1205
- });
1206
- const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1207
- const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;
1208
- const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);
1209
- const result = await sendEmail({
1210
- to: user.email,
1211
- template: "password-reset",
1212
- data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },
1213
- adapterHint: "transactional"
1316
+ const deliverResetEmail = async () => {
1317
+ const token = await createPasswordResetToken(user.id);
1318
+ void dispatchHook2("passwordResetRequested", {
1319
+ email,
1320
+ matched: true,
1321
+ userId: user.id,
1322
+ ttlSeconds: config.auth.passwordResetTtlSeconds
1323
+ });
1324
+ const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1325
+ const resetPath = config.auth.passwordResetPath || "/reset-password";
1326
+ const resetUrl = `${baseUrl}${resetPath}?token=${encodeURIComponent(token)}`;
1327
+ const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);
1328
+ const result = await sendEmail({
1329
+ to: user.email,
1330
+ template: "password-reset",
1331
+ data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },
1332
+ adapterHint: "transactional"
1333
+ });
1334
+ if (result.ok) {
1335
+ getLogger4().info("[forgotPassword] reset email dispatched", { userId: user.id });
1336
+ } else {
1337
+ getLogger4().warn("[forgotPassword] reset email send FAILED", { userId: user.id, reason: result.reason });
1338
+ }
1339
+ };
1340
+ void deliverResetEmail().catch((error) => {
1341
+ getLogger4().error("[forgotPassword] reset email delivery failed", { userId: user.id, error: String(error) });
1214
1342
  });
1215
- if (result.ok) {
1216
- getLogger4().info("[forgotPassword] reset email dispatched", { userId: user.id });
1217
- } else {
1218
- getLogger4().warn("[forgotPassword] reset email send FAILED", { userId: user.id, reason: result.reason });
1219
- }
1220
- return result.ok ? { ok: true } : { ok: false, reason: result.reason };
1343
+ return { ok: true };
1221
1344
  };
1222
1345
 
1223
1346
  // src/emailChange.ts
@@ -1247,6 +1370,14 @@ var consumeEmailChangeToken = async (token) => {
1247
1370
  }
1248
1371
  return parsed;
1249
1372
  };
1373
+ var invalidateEmailChangeToken = async (userId) => {
1374
+ const pointerKey = emailChangeUserKey(userId);
1375
+ const priorKey = await redis2.get(pointerKey);
1376
+ if (priorKey) {
1377
+ await redis2.del(priorKey);
1378
+ }
1379
+ await redis2.del(pointerKey);
1380
+ };
1250
1381
 
1251
1382
  // src/emailChangeNotification.ts
1252
1383
  import { getProjectConfig as getProjectConfig8 } from "@luckystack/core";
@@ -1770,9 +1901,13 @@ var confirmTotpEnrollment = async (user, code) => {
1770
1901
  }
1771
1902
  };
1772
1903
  var disableTwoFactor = async (user, code) => {
1904
+ if (await isTwoFactorAccountLocked(user.id)) return { ok: false, reason: "login.twoFactorLocked" };
1773
1905
  const viaTotp = await verifyTotpForUser(user, code, false);
1774
1906
  const viaRecovery = viaTotp ? false : await consumeRecoveryCode(user, code);
1775
- if (!viaTotp && !viaRecovery) return { ok: false, reason: "login.twoFactorInvalidCode" };
1907
+ if (!viaTotp && !viaRecovery) {
1908
+ await recordTwoFactorAccountFailure(user.id);
1909
+ return { ok: false, reason: "login.twoFactorInvalidCode" };
1910
+ }
1776
1911
  const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, {
1777
1912
  totpSecret: null,
1778
1913
  twoFactorEnabled: false,
@@ -1786,7 +1921,12 @@ var disableTwoFactor = async (user, code) => {
1786
1921
  return { ok: true };
1787
1922
  };
1788
1923
  var regenerateRecoveryCodes = async (user, code) => {
1789
- if (!await verifyTotpForUser(user, code, false)) return { ok: false, reason: "login.twoFactorInvalidCode" };
1924
+ if (await isTwoFactorAccountLocked(user.id)) return { ok: false, reason: "login.twoFactorLocked" };
1925
+ if (!await verifyTotpForUser(user, code, false)) {
1926
+ await recordTwoFactorAccountFailure(user.id);
1927
+ return { ok: false, reason: "login.twoFactorInvalidCode" };
1928
+ }
1929
+ await clearTwoFactorAccountFailures(user.id);
1790
1930
  const { raw, hashes } = generateRecoveryCodes();
1791
1931
  const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, { recoveryCodes: hashes }));
1792
1932
  if (updateError) {
@@ -1879,6 +2019,7 @@ registerSessionProvider({
1879
2019
  logout: ({ token, socket, userId, skipSessionDelete }) => logout({ token, socket, userId: userId ?? null, skipSessionDelete })
1880
2020
  });
1881
2021
  registerAuthLockoutHook();
2022
+ registerRedactedLogKeys(["recoverycode", "totp", "totpcode"]);
1882
2023
  export {
1883
2024
  OAUTH_STATE_COOKIE_NAME,
1884
2025
  PasswordPolicyError,
@@ -1917,6 +2058,8 @@ export {
1917
2058
  githubProvider,
1918
2059
  googleProvider,
1919
2060
  hotp,
2061
+ invalidateEmailChangeToken,
2062
+ invalidatePasswordResetToken,
1920
2063
  isAccountLocked,
1921
2064
  isFullOAuthProvider,
1922
2065
  isUserAdapterRegistered,