@luckystack/login 0.15.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-ML3OQ6R4.js";
17
+ } from "./chunk-CYGFR56I.js";
18
18
  import {
19
19
  activeUsersKeyFor,
20
20
  deleteSession,
@@ -29,7 +29,7 @@ import {
29
29
  revokeUserSessions,
30
30
  saveSession,
31
31
  sessionKeyFor
32
- } from "./chunk-S7TST3X6.js";
32
+ } from "./chunk-MZNCQ26U.js";
33
33
 
34
34
  // src/index.ts
35
35
  import { registerRedactedLogKeys, registerSessionProvider } from "@luckystack/core";
@@ -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,6 +881,17 @@ 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
897
  const emailValue = provider.alwaysUseGetEmail ? void 0 : userData[provider.emailKey];
@@ -900,23 +929,75 @@ var fetchOAuthProfile = async (provider, accessToken) => {
900
929
  }
901
930
  email = normalizedEmail;
902
931
  }
903
- return { email, name, avatar, emailVerified, rawUserData: userData };
932
+ return { email, name, avatar, emailVerified, accountId, rawUserData: userData };
904
933
  };
905
- var findOrCreateOAuthUser = async (provider, profile) => {
906
- const { email, name, avatar, emailVerified } = profile;
907
- if (!email) return null;
908
- 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
+ });
909
971
  if (preLoginResult.stopped) {
910
972
  getLogger3().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });
911
- return null;
973
+ return false;
912
974
  }
975
+ return true;
976
+ };
977
+ var findOrCreateOAuthUser = async (provider, profile) => {
978
+ const { email, name, avatar, emailVerified, accountId } = profile;
979
+ if (!email) return refuseOAuth();
913
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
+ }
914
995
  const [findError, findResponse] = await tryCatch2(
915
996
  () => resolveUserByEmail(userAdapter, { email, provider: provider.name })
916
997
  );
917
998
  if (findError) {
918
999
  getLogger3().error("oauth: findByEmail failed", findError);
919
- return null;
1000
+ return refuseOAuth();
920
1001
  }
921
1002
  if (findResponse?.id) {
922
1003
  const existingProvider = findResponse.provider ?? null;
@@ -926,40 +1007,56 @@ var findOrCreateOAuthUser = async (provider, profile) => {
926
1007
  "oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)",
927
1008
  { provider: provider.name, existingProvider }
928
1009
  );
929
- return null;
1010
+ return refuseOAuth("oauth.emailUnverified", findResponse.id);
1011
+ }
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
+ }
930
1032
  }
931
- const uploadedAvatar = await resolveUploadedAvatar(findResponse.id);
932
- if (uploadedAvatar) {
933
- findResponse.avatar = uploadedAvatar;
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
+ }
934
1041
  }
935
- const previousLogin = findResponse.lastLogin ?? null;
936
- const nowLogin = /* @__PURE__ */ new Date();
937
- await tryCatch2(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));
938
- return {
939
- user: {
940
- ...sanitizeUserForSession(findResponse),
941
- token: "",
942
- lastLogin: nowLogin,
943
- previousLogin
944
- },
945
- isNewUser: false
946
- };
1042
+ return signInExistingOAuthUser(userAdapter, findResponse);
947
1043
  }
1044
+ if (!await passesOAuthPreLogin(provider, email)) return refuseOAuth();
948
1045
  if (!emailVerified) {
949
1046
  getLogger3().warn(
950
1047
  "oauth: refusing first-login account creation for an unverified provider email (account-squatting guard)",
951
1048
  { provider: provider.name }
952
1049
  );
953
- return null;
1050
+ return refuseOAuth();
954
1051
  }
955
1052
  if (!getProjectConfig4().auth.allowRegistration) {
956
1053
  getLogger3().warn("oauth: registration disabled \u2014 refusing first-login account creation");
957
- return null;
1054
+ return refuseOAuth();
958
1055
  }
959
1056
  const preRegisterResult = await dispatchHook("preRegister", { email, provider: provider.name, name });
960
1057
  if (preRegisterResult.stopped) {
961
1058
  getLogger3().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });
962
- return null;
1059
+ return refuseOAuth();
963
1060
  }
964
1061
  const createNewUser = async () => userAdapter.create({
965
1062
  email,
@@ -967,14 +1064,15 @@ var findOrCreateOAuthUser = async (provider, profile) => {
967
1064
  name,
968
1065
  avatar,
969
1066
  avatarFallback: generateAvatarFallbackColor(),
970
- language: getProjectConfig4().defaultLanguage
1067
+ language: getProjectConfig4().defaultLanguage,
1068
+ ...bindingId === void 0 ? {} : { providerAccountId: bindingId }
971
1069
  });
972
1070
  const [createError, createResponse] = await tryCatch2(createNewUser);
973
1071
  if (createError) {
974
1072
  getLogger3().error("oauth: user create failed", createError);
975
- return null;
1073
+ return refuseOAuth();
976
1074
  }
977
- if (!createResponse) return null;
1075
+ if (!createResponse) return refuseOAuth();
978
1076
  return {
979
1077
  user: { ...sanitizeUserForSession(createResponse), token: "" },
980
1078
  isNewUser: true
@@ -1076,9 +1174,19 @@ var loginCallback = async (pathname, req, _res, options = {}) => {
1076
1174
  emitLoginFailed({ provider: providerName, reason: "oauth.profileFetchFailed", stage: "oauth" });
1077
1175
  return false;
1078
1176
  }
1177
+ if ("refusal" in profile) {
1178
+ emitLoginFailed({ provider: providerName, reason: profile.refusal, stage: "oauth" });
1179
+ return false;
1180
+ }
1079
1181
  const resolved = await findOrCreateOAuthUser(provider, profile);
1080
- if (!resolved) {
1081
- 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
+ });
1082
1190
  return false;
1083
1191
  }
1084
1192
  const newToken = randomBytes(32).toString("hex");