@better-auth/infra 0.2.14 → 0.3.1

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.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-CLYqEwMV.mjs";
2
- import { n as createKV, t as createAPI } from "./fetch-Bl0S3xUi.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-Cjw_ECs7.mjs";
2
+ import { a as createAPI, o as createKV, r as hmacSha256Hex } from "./crypto-DuXBYsfj.mjs";
3
3
  import { EMAIL_TEMPLATES, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
4
4
  import { getCurrentAuthContext } from "@better-auth/core/context";
5
5
  import { APIError, generateId, getAuthTables, logger } from "better-auth";
@@ -78,7 +78,7 @@ const routes = {
78
78
  SIGN_IN_SOCIAL: "/sign-in/social",
79
79
  SIGN_IN_ANONYMOUS: "/sign-in/anonymous",
80
80
  SIGN_IN_SOCIAL_CALLBACK: "/callback/:id",
81
- SIGN_IN_OAUTH_CALLBACK: "/oauth2/callback/:id",
81
+ SIGN_IN_OAUTH_CALLBACK: "/oauth2/callback/:providerId",
82
82
  SIGN_OUT: "/sign-out",
83
83
  SIGN_UP: "/sign-up",
84
84
  SIGN_UP_EMAIL: "/sign-up/email",
@@ -112,6 +112,8 @@ const routes = {
112
112
  DASH_ACCEPT_INVITATION: "/dash/accept-invitation",
113
113
  DASH_COMPLETE_INVITATION_SOCIAL: "/dash/complete-invitation-social",
114
114
  DASH_UPDATE_USER: "/dash/update-user",
115
+ DASH_SET_PASSWORD: "/dash/set-password",
116
+ DASH_SEND_VERIFICATION_EMAIL: "/dash/send-verification-email",
115
117
  DASH_REVOKE_SESSIONS_ALL: "/dash/sessions/revoke-all",
116
118
  DASH_IMPERSONATE_USER: "/dash/impersonate-user",
117
119
  DASH_BAN_USER: "/dash/ban-user",
@@ -284,7 +286,7 @@ const stripQuery = (value) => value.split("?")[0] || value;
284
286
  const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
285
287
  const routeToRegex = (route) => {
286
288
  const pattern = escapeRegex(stripQuery(route)).replace(/\/:([^/]+)/g, "/[^/]+");
287
- return new RegExp(`${pattern}(?:$|[/?])`);
289
+ return new RegExp(`^${pattern}(?:$|[/?])`);
288
290
  };
289
291
  const matchesAnyRoute = (path, routes) => {
290
292
  if (!path) return false;
@@ -292,6 +294,14 @@ const matchesAnyRoute = (path, routes) => {
292
294
  return routes.some((route) => routeToRegex(route).test(cleanPath));
293
295
  };
294
296
  //#endregion
297
+ //#region src/events/oauth-callback-provider.ts
298
+ /** Provider id from social (`/callback/:id`) or generic OAuth (`/oauth2/callback/:providerId`). */
299
+ function getOAuthCallbackProviderId(ctx) {
300
+ const id = ctx.params?.id ?? ctx.params?.providerId;
301
+ if (typeof id !== "string" || !id || id.startsWith(":")) return;
302
+ return id;
303
+ }
304
+ //#endregion
295
305
  //#region src/events/login-methods.ts
296
306
  const OAUTH_CALLBACK_PATHS = [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK];
297
307
  const PATH_TO_METHOD = [
@@ -314,11 +324,7 @@ const PATH_TO_METHOD = [
314
324
  [routes.SIWE_VERIFY, "siwe"]
315
325
  ];
316
326
  const getLoginMethod = (ctx) => {
317
- if (matchesAnyRoute(ctx.path, OAUTH_CALLBACK_PATHS)) {
318
- const id = ctx.params?.id;
319
- if (typeof id === "string" && id && !id.startsWith(":")) return id;
320
- return null;
321
- }
327
+ if (matchesAnyRoute(ctx.path, OAUTH_CALLBACK_PATHS)) return getOAuthCallbackProviderId(ctx) ?? null;
322
328
  for (const [path, method] of PATH_TO_METHOD) if (matchesAnyRoute(ctx.path, [path])) return method;
323
329
  return null;
324
330
  };
@@ -585,7 +591,7 @@ const initSessionEvents = (tracker) => {
585
591
  eventDisplayName: "User sign-in attempt failed",
586
592
  eventData: {
587
593
  userId: user?.id ?? "unknown",
588
- nameName: user?.name ?? "unknown",
594
+ userName: user?.name ?? "unknown",
589
595
  userEmail: ctx.body.email,
590
596
  loginMethod: getLoginMethod(ctx),
591
597
  triggeredBy: user?.id ?? trigger.triggeredBy,
@@ -626,7 +632,9 @@ const initSessionEvents = (tracker) => {
626
632
  trackSocialSignInFailure(ctx, ctx.body.provider, trigger, location);
627
633
  };
628
634
  const trackSocialSignInRedirectionAttempt = (ctx, trigger, location) => {
629
- trackSocialSignInFailure(ctx, tryDecode(ctx.params?.id), trigger, location);
635
+ const providerId = getOAuthCallbackProviderId(ctx);
636
+ if (!providerId) return;
637
+ trackSocialSignInFailure(ctx, tryDecode(providerId), trigger, location);
630
638
  };
631
639
  return {
632
640
  trackUserSignedIn,
@@ -896,6 +904,27 @@ const initTrackEvents = ($api) => {
896
904
  return { tracker: { trackEvent } };
897
905
  };
898
906
  //#endregion
907
+ //#region ../utils/dist/ip.mjs
908
+ /** Platform-set client IP headers (not accepted from clients on the open internet). */
909
+ const DEFAULT_TRUSTED_IP_HEADERS = [
910
+ "cf-connecting-ip",
911
+ "true-client-ip",
912
+ "x-vercel-forwarded-for"
913
+ ];
914
+ /**
915
+ * Resolves client IP from request headers.
916
+ * Defaults to platform-trusted headers only; set `ipAddressHeaders` for self-hosted proxies.
917
+ */
918
+ function resolveClientIpFromHeaders(headers, ipAddressHeaders) {
919
+ const headerNames = ipAddressHeaders?.length ? ipAddressHeaders : [...DEFAULT_TRUSTED_IP_HEADERS];
920
+ for (const headerName of headerNames) {
921
+ const value = headers.get(headerName);
922
+ if (!value) continue;
923
+ const ip = value.split(",")[0]?.trim();
924
+ if (ip) return ip;
925
+ }
926
+ }
927
+ //#endregion
899
928
  //#region src/identification.ts
900
929
  /**
901
930
  * Identification Service
@@ -916,6 +945,20 @@ function cleanupCache() {
916
945
  function maybeCleanup() {
917
946
  if (Date.now() - lastCleanup > CACHE_TTL_MS || identificationCache.size > CACHE_MAX_SIZE) cleanupCache();
918
947
  }
948
+ /** Retries 404 races with exponential backoff (≤1s total wait across retries). */
949
+ const IDENTIFY_GET_RETRY = {
950
+ type: "exponential",
951
+ attempts: 2,
952
+ baseDelay: 400,
953
+ maxDelay: 600,
954
+ shouldRetry(response) {
955
+ if (response === null) return true;
956
+ return response.status === 404;
957
+ }
958
+ };
959
+ function identifyGetRetryDelay(attempt) {
960
+ return Math.min(600, 400 * 2 ** attempt);
961
+ }
919
962
  /**
920
963
  * Fetch identification data from durable-kv by requestId
921
964
  */
@@ -923,14 +966,11 @@ async function getIdentification(requestId, $kv) {
923
966
  maybeCleanup();
924
967
  const cached = identificationCache.get(requestId);
925
968
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.data;
926
- const maxRetries = 3;
927
- const retryDelays = [
928
- 50,
929
- 100,
930
- 200
931
- ];
932
- for (let attempt = 0; attempt <= maxRetries; attempt++) try {
933
- const { data, error } = await $kv(`/identify/${requestId}`, { method: "GET" });
969
+ for (let networkAttempt = 0;; networkAttempt++) try {
970
+ const { data, error } = await $kv(`/identify/${requestId}`, {
971
+ method: "GET",
972
+ retry: IDENTIFY_GET_RETRY
973
+ });
934
974
  if (data && !error) {
935
975
  identificationCache.set(requestId, {
936
976
  data,
@@ -938,24 +978,18 @@ async function getIdentification(requestId, $kv) {
938
978
  });
939
979
  return data;
940
980
  }
941
- const status = error?.status;
942
- if (status === 404 && attempt < maxRetries) {
943
- await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
944
- continue;
945
- }
946
- if (status !== 404) identificationCache.set(requestId, {
981
+ if (error?.status !== 404) identificationCache.set(requestId, {
947
982
  data: null,
948
983
  timestamp: Date.now()
949
984
  });
950
985
  return null;
951
986
  } catch (error) {
952
- if (attempt === maxRetries) {
987
+ if (networkAttempt >= IDENTIFY_GET_RETRY.attempts) {
953
988
  logger.error("[Dash] Failed to fetch identification:", error);
954
989
  return null;
955
990
  }
956
- await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt] || 50));
991
+ await new Promise((resolve) => setTimeout(resolve, identifyGetRetryDelay(networkAttempt)));
957
992
  }
958
- return null;
959
993
  }
960
994
  /**
961
995
  * Extract identification headers from a request
@@ -1012,7 +1046,7 @@ function createIdentificationMiddleware($kv) {
1012
1046
  ctx.context.visitorId = visitorId;
1013
1047
  const ipConfig = ctx.context.options?.advanced?.ipAddress;
1014
1048
  let location;
1015
- const requestIp = getClientIpFromRequest(ctx.request, ipConfig?.ipAddressHeaders || null);
1049
+ const requestIp = ctx.request ? resolveClientIpFromHeaders(ctx.request.headers, ipConfig?.ipAddressHeaders || null) : void 0;
1016
1050
  const requestCountryCode = getCountryCodeFromRequest(ctx.request);
1017
1051
  if (ipConfig?.disableIpTracking === true) location = void 0;
1018
1052
  else if (requestId && identification) {
@@ -1040,21 +1074,6 @@ function getLocation(identification) {
1040
1074
  if (!identification) return null;
1041
1075
  return identification.location;
1042
1076
  }
1043
- function getClientIpFromRequest(request, ipAddressHeaders) {
1044
- if (!request) return void 0;
1045
- const headers = ipAddressHeaders?.length ? ipAddressHeaders : [
1046
- "cf-connecting-ip",
1047
- "x-forwarded-for",
1048
- "x-real-ip",
1049
- "x-vercel-forwarded-for"
1050
- ];
1051
- for (const headerName of headers) {
1052
- const value = request.headers.get(headerName);
1053
- if (!value) continue;
1054
- const ip = value.split(",")[0]?.trim();
1055
- if (ip) return ip;
1056
- }
1057
- }
1058
1077
  function getCountryCodeFromRequest(request) {
1059
1078
  if (!request) return void 0;
1060
1079
  const cc = request.headers.get("cf-ipcountry") ?? request.headers.get("x-vercel-ip-country");
@@ -1099,11 +1118,12 @@ function emitEvaluation(ctx, trackEvent, options = {}) {
1099
1118
  ev.emitted = true;
1100
1119
  const identification = ctx.context.identification ?? null;
1101
1120
  const visitorId = ctx.context.visitorId ?? null;
1121
+ const untrustedVisitorId = ctx.context.untrustedVisitorId ?? null;
1102
1122
  const location = ctx.context.location;
1103
1123
  const path = ctx.path ?? "";
1104
1124
  const ipAddress = location?.ipAddress || identification?.ip || ctx.context.ip || void 0;
1105
1125
  trackEvent({
1106
- eventKey: visitorId || ipAddress || "unknown",
1126
+ eventKey: visitorId || ipAddress || untrustedVisitorId || "unknown",
1107
1127
  eventType: "security_check",
1108
1128
  eventDisplayName: ev.outcome === "blocked" ? "Security: blocked" : ev.outcome === "challenged" ? "Security: challenged" : "Security: passed",
1109
1129
  eventData: {
@@ -1124,11 +1144,6 @@ function emitEvaluation(ctx, trackEvent, options = {}) {
1124
1144
  }
1125
1145
  //#endregion
1126
1146
  //#region src/sentinel/security.ts
1127
- async function hashForFingerprint(input) {
1128
- const data = new TextEncoder().encode(input);
1129
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1130
- return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1131
- }
1132
1147
  async function sha1Hash(input) {
1133
1148
  const data = new TextEncoder().encode(input);
1134
1149
  const hashBuffer = await crypto.subtle.digest("SHA-1", data);
@@ -1193,15 +1208,20 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1193
1208
  default: return "credential_stuffing";
1194
1209
  }
1195
1210
  },
1196
- async trackFailedAttempt(identifier, visitorId, password, ip) {
1211
+ async trackFailedAttempt(identifier, visitorId, password, ip, requestId = null) {
1197
1212
  try {
1213
+ if (!conn.apiKey) {
1214
+ logger.warn("[Sentinel] Missing apiKey; failed-login password fingerprint is skipped.");
1215
+ return { blocked: false };
1216
+ }
1198
1217
  const data = await $api("/security/track-failed-login", {
1199
1218
  method: "POST",
1200
1219
  body: {
1201
1220
  identifier,
1202
1221
  visitorId,
1203
- passwordHash: await hashForFingerprint(password),
1222
+ passwordHash: await hmacSha256Hex(conn.apiKey, password),
1204
1223
  ip,
1224
+ requestId,
1205
1225
  config: options
1206
1226
  }
1207
1227
  });
@@ -1230,23 +1250,25 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1230
1250
  logger.error("[Dash] Clear failed attempts error:", error);
1231
1251
  }
1232
1252
  },
1233
- async isBlocked(visitorId, ip) {
1253
+ async isBlocked(visitorId, ip, requestId = null) {
1234
1254
  try {
1235
1255
  const params = new URLSearchParams({ visitorId });
1236
1256
  if (ip) params.set("ip", ip);
1257
+ if (requestId) params.set("requestId", requestId);
1237
1258
  return (await $api(`/security/is-blocked?${params.toString()}`, { method: "GET" })).blocked ?? false;
1238
1259
  } catch (error) {
1239
1260
  logger.warn("[Dash] Security is-blocked check failed:", error);
1240
1261
  return false;
1241
1262
  }
1242
1263
  },
1243
- async verifyPoWSolution(visitorId, solution) {
1264
+ async verifyPoWSolution(visitorId, solution, requestId = null) {
1244
1265
  try {
1245
1266
  return await $api("/security/pow/verify", {
1246
1267
  method: "POST",
1247
1268
  body: {
1248
1269
  visitorId,
1249
- solution
1270
+ solution,
1271
+ requestId
1250
1272
  }
1251
1273
  });
1252
1274
  } catch (error) {
@@ -1257,12 +1279,13 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1257
1279
  };
1258
1280
  }
1259
1281
  },
1260
- async generateChallenge(visitorId) {
1282
+ async generateChallenge(visitorId, requestId = null) {
1261
1283
  try {
1262
1284
  return (await $api("/security/pow/generate", {
1263
1285
  method: "POST",
1264
1286
  body: {
1265
1287
  visitorId,
1288
+ requestId,
1266
1289
  difficulty: options.challengeDifficulty
1267
1290
  }
1268
1291
  })).challenge || "";
@@ -1271,7 +1294,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1271
1294
  return "";
1272
1295
  }
1273
1296
  },
1274
- async checkImpossibleTravel(userId, currentLocation, visitorId, ip = null, powSolution) {
1297
+ async checkImpossibleTravel(userId, currentLocation, visitorId, ip = null, powSolution, requestId = null) {
1275
1298
  if (!options.impossibleTravel?.enabled || !currentLocation) return null;
1276
1299
  try {
1277
1300
  const data = await $api("/security/impossible-travel", {
@@ -1279,6 +1302,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1279
1302
  body: {
1280
1303
  userId,
1281
1304
  visitorId,
1305
+ requestId,
1282
1306
  location: currentLocation,
1283
1307
  ip,
1284
1308
  config: options,
@@ -1328,20 +1352,23 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1328
1352
  }
1329
1353
  },
1330
1354
  /**
1331
- * Check if a visitor has exceeded the free trial signup limit.
1355
+ * Atomically reserve a signup slot before user creation.
1332
1356
  */
1333
- async checkFreeTrialAbuse(visitorId) {
1357
+ async reserveFreeTrialSignup(visitorId, reservationId, requestId = null) {
1334
1358
  if (!options.freeTrialAbuse?.enabled) return {
1359
+ reserved: true,
1335
1360
  isAbuse: false,
1336
1361
  accountCount: 0,
1337
1362
  maxAccounts: 0,
1338
1363
  action: "log"
1339
1364
  };
1340
1365
  try {
1341
- const data = await $api("/security/free-trial-abuse/check", {
1366
+ const data = await $api("/security/free-trial-abuse/reserve", {
1342
1367
  method: "POST",
1343
1368
  body: {
1344
1369
  visitorId,
1370
+ reservationId,
1371
+ requestId,
1345
1372
  config: options
1346
1373
  }
1347
1374
  });
@@ -1359,8 +1386,9 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1359
1386
  });
1360
1387
  return data;
1361
1388
  } catch (error) {
1362
- logger.warn("[Dash] Free trial abuse check failed:", error);
1389
+ logger.warn("[Dash] Free trial abuse reserve failed:", error);
1363
1390
  return {
1391
+ reserved: true,
1364
1392
  isAbuse: false,
1365
1393
  accountCount: 0,
1366
1394
  maxAccounts: 0,
@@ -1369,21 +1397,22 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1369
1397
  }
1370
1398
  },
1371
1399
  /**
1372
- * Track a new signup for free trial abuse detection.
1373
- * Stores the userId for auditing purposes.
1400
+ * Confirm a reserved signup slot with the created user id.
1374
1401
  */
1375
- async trackFreeTrialSignup(visitorId, userId) {
1402
+ async confirmFreeTrialSignup(visitorId, reservationId, userId, requestId = null) {
1376
1403
  if (!options.freeTrialAbuse?.enabled) return;
1377
1404
  try {
1378
- await $api("/security/free-trial-abuse/track", {
1405
+ await $api("/security/free-trial-abuse/confirm", {
1379
1406
  method: "POST",
1380
1407
  body: {
1381
1408
  visitorId,
1382
- userId
1409
+ reservationId,
1410
+ userId,
1411
+ requestId
1383
1412
  }
1384
1413
  });
1385
1414
  } catch (error) {
1386
- logger.error("[Dash] Track free trial signup error:", error);
1415
+ logger.error("[Dash] Confirm free trial signup error:", error);
1387
1416
  }
1388
1417
  },
1389
1418
  async checkCompromisedPassword(password) {
@@ -2093,7 +2122,7 @@ async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, sec
2093
2122
  if (verdict.action === "allow") return;
2094
2123
  const reason = verdict.reason || "unknown";
2095
2124
  if (verdict.action === "challenge" && checkCtx.visitorId) {
2096
- const challenge = verdict.challenge || await securityService.generateChallenge(checkCtx.visitorId);
2125
+ const challenge = verdict.challenge || await securityService.generateChallenge(checkCtx.visitorId, checkCtx.identification?.requestId ?? null);
2097
2126
  if (!challenge?.trim()) {
2098
2127
  logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
2099
2128
  return;
@@ -2128,7 +2157,7 @@ async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent,
2128
2157
  recordCheck(hookCtx, "server_security_check");
2129
2158
  await handleSecurityVerdict(await securityService.checkSecurity({
2130
2159
  visitorId: checkCtx.visitorId,
2131
- requestId: checkCtx.identification?.requestId || null,
2160
+ requestId: checkCtx.identification?.requestId ?? checkCtx.requestId ?? null,
2132
2161
  ip: checkCtx.ip,
2133
2162
  path: checkCtx.path,
2134
2163
  identifier: checkCtx.identifier,
@@ -2183,9 +2212,17 @@ const sentinel = (options) => {
2183
2212
  async before(user, ctx) {
2184
2213
  if (!ctx) return;
2185
2214
  const visitorId = ctx.context.visitorId;
2186
- if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
2215
+ if (opts.security?.freeTrialAbuse?.enabled) {
2216
+ if (!visitorId) {
2217
+ recordCheck(ctx, "free_trial_abuse");
2218
+ setOutcome(ctx, "blocked", "free_trial_abuse", { reason: "identification_required" });
2219
+ emitEvaluation(ctx, trackEvent);
2220
+ throw new APIError("FORBIDDEN", { message: "Account creation is not allowed without device identification." });
2221
+ }
2187
2222
  recordCheck(ctx, "free_trial_abuse");
2188
- const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2223
+ const reservationId = crypto.randomUUID();
2224
+ ctx.context.freeTrialReservationId = reservationId;
2225
+ const abuseCheck = await securityService.reserveFreeTrialSignup(visitorId, reservationId, ctx.context.requestId ?? null);
2189
2226
  if (abuseCheck.isAbuse && abuseCheck.action === "block") {
2190
2227
  setOutcome(ctx, "blocked", "free_trial_abuse", {
2191
2228
  accountCount: abuseCheck.accountCount,
@@ -2203,7 +2240,8 @@ const sentinel = (options) => {
2203
2240
  async after(user, ctx) {
2204
2241
  if (!ctx) return;
2205
2242
  const visitorId = ctx.context.visitorId;
2206
- if (visitorId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.trackFreeTrialSignup(visitorId, user.id));
2243
+ const reservationId = ctx.context.freeTrialReservationId;
2244
+ if (visitorId && reservationId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.confirmFreeTrialSignup(visitorId, reservationId, user.id, ctx.context.requestId ?? null));
2207
2245
  }
2208
2246
  },
2209
2247
  update: { async before(user, ctx) {
@@ -2222,7 +2260,7 @@ const sentinel = (options) => {
2222
2260
  if (session.userId && identification?.location && visitorId) {
2223
2261
  recordCheck(ctx, "impossible_travel");
2224
2262
  const powSolution = ctx.headers?.get?.("X-PoW-Solution") ?? null;
2225
- const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId, identification.ip, powSolution);
2263
+ const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId, identification.ip, powSolution, identification.requestId);
2226
2264
  if (travelCheck?.powVerified) recordCheck(ctx, "pow_challenge");
2227
2265
  if (travelCheck?.isImpossible) {
2228
2266
  if (travelCheck.action === "block") {
@@ -2333,7 +2371,11 @@ const sentinel = (options) => {
2333
2371
  routes.SIGN_IN_ANONYMOUS
2334
2372
  ]);
2335
2373
  const isSignUp = matchesAnyRoute(ctx.path, [routes.SIGN_UP_EMAIL]);
2336
- const isPasswordReset = matchesAnyRoute(ctx.path, [routes.FORGET_PASSWORD, routes.REQUEST_PASSWORD_RESET]);
2374
+ const isPasswordReset = matchesAnyRoute(ctx.path, [
2375
+ routes.FORGET_PASSWORD,
2376
+ routes.REQUEST_PASSWORD_RESET,
2377
+ routes.RESET_PASSWORD
2378
+ ]);
2337
2379
  const isTwoFactor = matchesAnyRoute(ctx.path, [
2338
2380
  routes.TWO_FACTOR_VERIFY_TOTP,
2339
2381
  routes.TWO_FACTOR_VERIFY_BACKUP,
@@ -2355,7 +2397,7 @@ const sentinel = (options) => {
2355
2397
  const powSolution = ctx.headers?.get?.("X-PoW-Solution");
2356
2398
  if (visitorId || ip) {
2357
2399
  recordCheck(ctx, "credential_stuffing");
2358
- if (await securityService.isBlocked(visitorId ?? "", ip)) {
2400
+ if (await securityService.isBlocked(visitorId ?? "", ip, ctx.context.requestId ?? null)) {
2359
2401
  setOutcome(ctx, "blocked", "credential_stuffing", {
2360
2402
  phase: "cooldown_active",
2361
2403
  visitorId: ctx.context.untrustedVisitorId ?? void 0,
@@ -2372,6 +2414,7 @@ const sentinel = (options) => {
2372
2414
  path: ctx.path,
2373
2415
  identifier,
2374
2416
  visitorId,
2417
+ requestId: ctx.context.requestId ?? null,
2375
2418
  identification,
2376
2419
  ip: ctx.context.ip,
2377
2420
  userAgent: ctx.headers?.get?.("user-agent") || ""
@@ -2421,7 +2464,7 @@ const sentinel = (options) => {
2421
2464
  identifier: loginId,
2422
2465
  userAgent: ctx.headers?.get?.("user-agent") || ""
2423
2466
  });
2424
- if (isPasswordSignInRoute && ctx.context.returned instanceof Error && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip));
2467
+ if (isPasswordSignInRoute && ctx.context.returned instanceof Error && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip, ctx.context.requestId ?? null));
2425
2468
  if (isPasswordSignInRoute && !(ctx.context.returned instanceof Error) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
2426
2469
  })
2427
2470
  }]
@@ -2783,6 +2826,129 @@ const initTeamEvents = (tracker) => {
2783
2826
  };
2784
2827
  };
2785
2828
  //#endregion
2829
+ //#region ../utils/dist/redact.mjs
2830
+ const EXACT_SENSITIVE_STRING_KEYS_LOWER = new Set([...new Set([
2831
+ "accessToken",
2832
+ "apiKey",
2833
+ "apiSecret",
2834
+ "authorization",
2835
+ "authToken",
2836
+ "bearerToken",
2837
+ "backupCodes",
2838
+ "clientSecret",
2839
+ "consumerSecret",
2840
+ "credentialID",
2841
+ "deviceCode",
2842
+ "encPrivateKey",
2843
+ "encPrivateKeyPass",
2844
+ "encryptionKey",
2845
+ "encryptionSecret",
2846
+ "forwardHeaders",
2847
+ "idToken",
2848
+ "oidcConfig",
2849
+ "pass",
2850
+ "passwd",
2851
+ "password",
2852
+ "privateKey",
2853
+ "privateKeyPass",
2854
+ "pwd",
2855
+ "refreshToken",
2856
+ "samlConfig",
2857
+ "secret",
2858
+ "secretAccessKey",
2859
+ "secretKey",
2860
+ "signingSecret",
2861
+ "stripeWebhookSecret",
2862
+ "userCode",
2863
+ "webhookSecret"
2864
+ ])].map((k) => k.toLowerCase()));
2865
+ /**
2866
+ * Case-insensitive suffixes for unknown property names.
2867
+ */
2868
+ const SENSITIVE_KEY_SUFFIXES_LOWER = [
2869
+ "accesskey",
2870
+ "secret",
2871
+ "password",
2872
+ "passphrase",
2873
+ "privatekey",
2874
+ "keypass",
2875
+ "apikey",
2876
+ "token",
2877
+ "signingkey",
2878
+ "credentials",
2879
+ "authheader"
2880
+ ];
2881
+ /**
2882
+ * Embedded secret substrings checked against the normalized (compact, lowercased) key.
2883
+ */
2884
+ const SENSITIVE_KEY_SUBSTRINGS_LOWER = ["secretaccess", "accesskeysecret"];
2885
+ const REDACTED_SIMPLE_STRING = "[REDACTED]";
2886
+ function snakeCaseToCamelCase(key) {
2887
+ return key.replace(/_([a-zA-Z])/g, (_, ch) => ch.toUpperCase());
2888
+ }
2889
+ function keyVariantsForMatching(key) {
2890
+ const trimmed = key.replace(/^[\s._-]+/, "");
2891
+ if (!trimmed) return [key];
2892
+ const out = new Set([key, trimmed]);
2893
+ if (trimmed.includes("_")) out.add(snakeCaseToCamelCase(trimmed));
2894
+ return [...out];
2895
+ }
2896
+ /** Whether a property name should have string values redacted in `redact()`. */
2897
+ function isSensitiveStringKey(key) {
2898
+ const variants = keyVariantsForMatching(key);
2899
+ const compactLower = key.replace(/[\s._-]/g, "").toLowerCase();
2900
+ const forms = new Set([compactLower]);
2901
+ for (const raw of variants) {
2902
+ forms.add(raw);
2903
+ forms.add(raw.toLowerCase());
2904
+ }
2905
+ for (const form of forms) {
2906
+ const fl = form.toLowerCase();
2907
+ if (EXACT_SENSITIVE_STRING_KEYS_LOWER.has(fl)) return true;
2908
+ for (const suffix of SENSITIVE_KEY_SUFFIXES_LOWER) if (fl.endsWith(suffix)) return true;
2909
+ for (const substring of SENSITIVE_KEY_SUBSTRINGS_LOWER) if (compactLower.includes(substring)) return true;
2910
+ }
2911
+ return false;
2912
+ }
2913
+ function isPlainSerializable(value) {
2914
+ if (value === null || typeof value !== "object") return true;
2915
+ if (Array.isArray(value)) return true;
2916
+ if (value instanceof Date) return true;
2917
+ const constructor = Object.getPrototypeOf(value)?.constructor;
2918
+ if (constructor && constructor.name !== "Object" && constructor.name !== "Array") return false;
2919
+ return true;
2920
+ }
2921
+ function redactInner(value, visiting, options) {
2922
+ if (value === null || value === void 0) return value;
2923
+ if (typeof value === "function") return void 0;
2924
+ if (typeof value !== "object") return value;
2925
+ const obj = value;
2926
+ if (visiting.has(obj)) return void 0;
2927
+ visiting.add(obj);
2928
+ try {
2929
+ if (Array.isArray(value)) return value.map((item) => redactInner(item, visiting, options)).filter((item) => item !== void 0);
2930
+ const result = {};
2931
+ for (const [key, val] of Object.entries(value)) {
2932
+ if (options?.excludeKeys?.has(key)) continue;
2933
+ if (typeof val === "function") continue;
2934
+ if (typeof val === "string" && isSensitiveStringKey(key)) {
2935
+ result[key] = REDACTED_SIMPLE_STRING;
2936
+ continue;
2937
+ }
2938
+ if (options?.skipNonPlainSerializable && val !== null && typeof val === "object" && !isPlainSerializable(val)) continue;
2939
+ const redacted = redactInner(val, visiting, options);
2940
+ if (redacted !== void 0) result[key] = redacted;
2941
+ }
2942
+ return result;
2943
+ } finally {
2944
+ visiting.delete(obj);
2945
+ }
2946
+ }
2947
+ /** Recursively redact sensitive string values in a JSON-like object tree. */
2948
+ function redact(value, options) {
2949
+ return redactInner(value, /* @__PURE__ */ new WeakSet(), options);
2950
+ }
2951
+ //#endregion
2786
2952
  //#region ../utils/dist/crypto/index.mjs
2787
2953
  /**
2788
2954
  * One-way hash for storage and lookup. Returns hex-encoded digest.
@@ -2949,114 +3115,16 @@ const jwtValidateMiddleware = (options) => {
2949
3115
  });
2950
3116
  };
2951
3117
  //#endregion
2952
- //#region src/routes/auth/config.ts
3118
+ //#region src/lib/redact-plugin-options.ts
2953
3119
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
2954
- const EXACT_SENSITIVE_STRING_KEYS_LOWER = new Set([...new Set([
2955
- "accessToken",
2956
- "apiKey",
2957
- "apiSecret",
2958
- "authorization",
2959
- "authToken",
2960
- "bearerToken",
2961
- "clientSecret",
2962
- "consumerSecret",
2963
- "encPrivateKey",
2964
- "encPrivateKeyPass",
2965
- "encryptionKey",
2966
- "encryptionSecret",
2967
- "idToken",
2968
- "password",
2969
- "privateKey",
2970
- "privateKeyPass",
2971
- "refreshToken",
2972
- "secret",
2973
- "secretKey",
2974
- "signingSecret",
2975
- "stripeWebhookSecret",
2976
- "webhookSecret"
2977
- ])].map((k) => k.toLowerCase()));
2978
- /**
2979
- * Case-insensitive suffixes for unknown plugin options
2980
- * Matching is more permissive to account for unknown plugin options
2981
- */
2982
- const SENSITIVE_KEY_SUFFIXES_LOWER = [
2983
- "secret",
2984
- "password",
2985
- "passphrase",
2986
- "privatekey",
2987
- "keypass",
2988
- "apikey",
2989
- "token",
2990
- "signingkey",
2991
- "credentials",
2992
- "authheader"
2993
- ];
2994
- const REDACTED_SIMPLE_STRING = "[REDACTED]";
2995
- function snakeCaseToCamelCase(key) {
2996
- return key.replace(/_([a-zA-Z])/g, (_, ch) => ch.toUpperCase());
2997
- }
2998
- function keyVariantsForMatching(key) {
2999
- const trimmed = key.replace(/^[\s._-]+/, "");
3000
- if (!trimmed) return [key];
3001
- const out = new Set([key, trimmed]);
3002
- if (trimmed.includes("_")) out.add(snakeCaseToCamelCase(trimmed));
3003
- return [...out];
3004
- }
3005
- function shouldRedactSensitiveStringKey(key) {
3006
- const variants = keyVariantsForMatching(key);
3007
- const compactLower = key.replace(/[\s._-]/g, "").toLowerCase();
3008
- const forms = new Set([compactLower]);
3009
- for (const raw of variants) {
3010
- forms.add(raw);
3011
- forms.add(raw.toLowerCase());
3012
- }
3013
- for (const form of forms) {
3014
- const fl = form.toLowerCase();
3015
- if (EXACT_SENSITIVE_STRING_KEYS_LOWER.has(fl)) return true;
3016
- for (const suffix of SENSITIVE_KEY_SUFFIXES_LOWER) if (fl.endsWith(suffix)) return true;
3017
- }
3018
- return false;
3019
- }
3020
- function isPlainSerializable(value) {
3021
- if (value === null || typeof value !== "object") return true;
3022
- if (Array.isArray(value)) return true;
3023
- if (value instanceof Date) return true;
3024
- const constructor = Object.getPrototypeOf(value)?.constructor;
3025
- if (constructor && constructor.name !== "Object" && constructor.name !== "Array") return false;
3026
- return true;
3027
- }
3028
- /**
3029
- * Sanitize any plain JSON-like subtree from Better Auth options
3030
- */
3031
- function sanitizeConfig(value, visiting, excludeKeys) {
3032
- if (value === null || value === void 0) return value;
3033
- if (typeof value === "function") return void 0;
3034
- if (typeof value !== "object") return value;
3035
- const obj = value;
3036
- if (visiting.has(obj)) return void 0;
3037
- visiting.add(obj);
3038
- try {
3039
- if (Array.isArray(value)) return value.map((item) => sanitizeConfig(item, visiting, excludeKeys)).filter((item) => item !== void 0);
3040
- const result = {};
3041
- for (const [key, val] of Object.entries(value)) {
3042
- if (excludeKeys?.has(key)) continue;
3043
- if (typeof val === "function") continue;
3044
- if (typeof val === "string" && shouldRedactSensitiveStringKey(key)) {
3045
- result[key] = REDACTED_SIMPLE_STRING;
3046
- continue;
3047
- }
3048
- if (val !== null && typeof val === "object" && !isPlainSerializable(val)) continue;
3049
- const sanitized = sanitizeConfig(val, visiting, excludeKeys);
3050
- if (sanitized !== void 0) result[key] = sanitized;
3051
- }
3052
- return result;
3053
- } finally {
3054
- visiting.delete(obj);
3055
- }
3056
- }
3057
- function sanitizePluginOptions(pluginId, options) {
3058
- return sanitizeConfig(options, /* @__PURE__ */ new WeakSet(), PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId]);
3120
+ function redactPluginOptions(pluginId, options, optionsConfig) {
3121
+ return redact(options, {
3122
+ ...optionsConfig,
3123
+ excludeKeys: PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId]
3124
+ });
3059
3125
  }
3126
+ //#endregion
3127
+ //#region src/routes/auth/config.ts
3060
3128
  function estimateEntropy(str) {
3061
3129
  const unique = new Set(str).size;
3062
3130
  if (unique === 0) return 0;
@@ -3072,13 +3140,13 @@ const getConfig = (options) => {
3072
3140
  return {
3073
3141
  version: ctx.context.version || null,
3074
3142
  socialProviders: Object.keys(ctx.context.options.socialProviders || {}),
3075
- emailAndPassword: sanitizeConfig(ctx.context.options.emailAndPassword, /* @__PURE__ */ new WeakSet(), void 0),
3143
+ emailAndPassword: redact(ctx.context.options.emailAndPassword, { skipNonPlainSerializable: true }),
3076
3144
  plugins: ctx.context.options.plugins?.map((plugin) => {
3077
3145
  const base = {
3078
3146
  id: plugin.id,
3079
3147
  schema: plugin.schema,
3080
3148
  version: plugin.version,
3081
- options: sanitizePluginOptions(plugin.id, plugin.options)
3149
+ options: redactPluginOptions(plugin.id, plugin.options, { skipNonPlainSerializable: true })
3082
3150
  };
3083
3151
  if (plugin.id === "dash" && !plugin.version) return {
3084
3152
  ...base,
@@ -3200,6 +3268,31 @@ const getValidate = (options) => {
3200
3268
  });
3201
3269
  };
3202
3270
  //#endregion
3271
+ //#region src/semver.ts
3272
+ function parseVersion(version) {
3273
+ if (!version) return null;
3274
+ const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
3275
+ if (!match) return null;
3276
+ return [
3277
+ Number(match[1]),
3278
+ Number(match[2]),
3279
+ Number(match[3])
3280
+ ];
3281
+ }
3282
+ /** Compare semver strings (e.g. "1.7.0-beta.8" against "1.7" or "1.7.0"). */
3283
+ function isVersionAtLeast(version, target) {
3284
+ const parsed = parseVersion(version);
3285
+ if (!parsed) return false;
3286
+ const targetParts = target.split(".").map(Number);
3287
+ const major = targetParts[0] ?? 0;
3288
+ const minor = targetParts[1] ?? 0;
3289
+ const patch = targetParts[2] ?? 0;
3290
+ const [vMajor, vMinor, vPatch] = parsed;
3291
+ if (vMajor !== major) return vMajor > major;
3292
+ if (vMinor !== minor) return vMinor > minor;
3293
+ return vPatch >= patch;
3294
+ }
3295
+ //#endregion
3203
3296
  //#region src/routes/organization-guards.ts
3204
3297
  /** Returns true if organization plugin is enabled. */
3205
3298
  function isOrganizationEnabled(ctx) {
@@ -3261,6 +3354,11 @@ function getScimEndpoint(baseUrl) {
3261
3354
  function getSCIMPlugin(ctx) {
3262
3355
  return ctx.context.getPlugin("scim");
3263
3356
  }
3357
+ function isScimDirectorySyncEnabled(scimPlugin, authVersion) {
3358
+ if (!scimPlugin) return false;
3359
+ if (isVersionAtLeast(authVersion, "1.7.0")) return true;
3360
+ return scimPlugin.options?.providerOwnership?.enabled === true;
3361
+ }
3264
3362
  const DIRECTORY_SYNC_DUPLICATE_MESSAGE = "A directory sync connection with this provider ID already exists. Remove the existing connection or choose a different provider ID.";
3265
3363
  function isDuplicateDirectorySyncError(e) {
3266
3364
  if (e instanceof APIError$1) return e.status === "CONFLICT" || /duplicate|already exists|unique constraint/i.test(e.message ?? "");
@@ -3278,7 +3376,7 @@ const listOrganizationDirectories = (options) => {
3278
3376
  }
3279
3377
  const organizationId = tryDecode(ctx.params.id);
3280
3378
  const scimPlugin = getSCIMPlugin(ctx);
3281
- if (!scimPlugin?.endpoints.listSCIMProviderConnections || !scimPlugin?.options?.providerOwnership?.enabled) {
3379
+ if (!scimPlugin?.endpoints.listSCIMProviderConnections || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) {
3282
3380
  ctx.context.logger.warn("[Dash] SCIM plugin not available or provider ownership disabled, returning empty directories list", { organizationId });
3283
3381
  return [];
3284
3382
  }
@@ -3315,7 +3413,7 @@ const createOrganizationDirectory = (options) => {
3315
3413
  requireOrganizationPlugin(ctx);
3316
3414
  const { organizationId } = ctx.context.payload;
3317
3415
  const scimPlugin = getSCIMPlugin(ctx);
3318
- if (!scimPlugin?.endpoints.generateSCIMToken || !scimPlugin?.options?.providerOwnership?.enabled) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3416
+ if (!scimPlugin?.endpoints.generateSCIMToken || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3319
3417
  const { providerId, ownerUserId } = ctx.body;
3320
3418
  let scimToken;
3321
3419
  try {
@@ -3350,7 +3448,7 @@ const deleteOrganizationDirectory = (options) => {
3350
3448
  }, async (ctx) => {
3351
3449
  requireOrganizationPlugin(ctx);
3352
3450
  const scimPlugin = getSCIMPlugin(ctx);
3353
- if (!scimPlugin?.endpoints.deleteSCIMProviderConnection || !scimPlugin?.options?.providerOwnership?.enabled) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3451
+ if (!scimPlugin?.endpoints.deleteSCIMProviderConnection || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3354
3452
  const { organizationId } = ctx.context.payload;
3355
3453
  const { providerId } = ctx.body;
3356
3454
  const ownerUserId = await getScimProviderOwner(ctx, organizationId, providerId);
@@ -3371,7 +3469,7 @@ const regenerateDirectoryToken = (options) => {
3371
3469
  }, async (ctx) => {
3372
3470
  requireOrganizationPlugin(ctx);
3373
3471
  const scimPlugin = getSCIMPlugin(ctx);
3374
- if (!scimPlugin?.endpoints.generateSCIMToken || !scimPlugin?.options?.providerOwnership?.enabled) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3472
+ if (!scimPlugin?.endpoints.generateSCIMToken || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3375
3473
  const { organizationId } = ctx.context.payload;
3376
3474
  const { providerId } = ctx.body;
3377
3475
  const ownerUserId = await getScimProviderOwner(ctx, organizationId, providerId);
@@ -3788,6 +3886,36 @@ const executeAdapter = (options) => {
3788
3886
  });
3789
3887
  };
3790
3888
  //#endregion
3889
+ //#region src/lib/invitation-auth-mode.ts
3890
+ /** Route new users through the platform /invite/accept flow. */
3891
+ function usesPlatformInviteAcceptFlow(authMode) {
3892
+ return authMode === "auth" || authMode === "credential_setup" || authMode === "create_with_session" || authMode === "create_no_session";
3893
+ }
3894
+ /** Whether complete-invitation should mint a session cookie. */
3895
+ function shouldCreateSessionOnInviteComplete(authMode) {
3896
+ return authMode !== "create_no_session";
3897
+ }
3898
+ //#endregion
3899
+ //#region src/lib/safe-url.ts
3900
+ function isSafeHttpUrl(value) {
3901
+ try {
3902
+ const url = new URL(value);
3903
+ return (url.protocol === "http:" || url.protocol === "https:") && url.username === "" && url.password === "";
3904
+ } catch {
3905
+ return false;
3906
+ }
3907
+ }
3908
+ /** http(s) absolute URL without embedded credentials. */
3909
+ const safeUrlSchema = z$1.string().trim().min(1).pipe(z$1.url().refine(isSafeHttpUrl, { message: "URL must be a valid http(s) URL without credentials" })).transform((value) => new URL(value).href);
3910
+ const optionalSafeUrlSchema = z$1.union([safeUrlSchema, z$1.literal("")]).optional();
3911
+ /** Validates http(s) redirect targets at use time; falls back when invalid. */
3912
+ function resolveSafeRedirectUrl(raw, fallback) {
3913
+ if (raw == null || raw.trim() === "") return fallback;
3914
+ const parsed = safeUrlSchema.safeParse(raw);
3915
+ if (!parsed.success) return fallback;
3916
+ return parsed.data;
3917
+ }
3918
+ //#endregion
3791
3919
  //#region src/routes/invitations/index.ts
3792
3920
  async function verifyPendingInvitation(token, $api, ctx) {
3793
3921
  const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
@@ -3814,6 +3942,9 @@ async function verifyPendingInvitation(token, $api, ctx) {
3814
3942
  function getFallbackRedirect(ctx) {
3815
3943
  return typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
3816
3944
  }
3945
+ function resolveInvitationRedirect(ctx, raw) {
3946
+ return resolveSafeRedirectUrl(raw, getFallbackRedirect(ctx));
3947
+ }
3817
3948
  /**
3818
3949
  * Accept invitation endpoint
3819
3950
  * This is called when a user clicks the invitation link.
@@ -3838,14 +3969,14 @@ const acceptInvitation = (options) => {
3838
3969
  }
3839
3970
  });
3840
3971
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted (existing user)", markError);
3841
- await setSessionCookie(ctx, {
3972
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3842
3973
  session: await ctx.context.internalAdapter.createSession(existingUser.id),
3843
3974
  user: existingUser
3844
3975
  });
3845
- const redirectUrl = invitation.redirectUrl || ctx.context.options.baseURL || "/";
3846
- return ctx.redirect(redirectUrl.toString());
3976
+ const redirectUrl = resolveInvitationRedirect(ctx, invitation.redirectUrl);
3977
+ return ctx.redirect(redirectUrl);
3847
3978
  }
3848
- if (invitation.authMode === "auth") {
3979
+ if (usesPlatformInviteAcceptFlow(invitation.authMode)) {
3849
3980
  const platformUrl = options.apiUrl || INFRA_API_URL;
3850
3981
  const acceptPageUrl = new URL("/invite/accept", platformUrl);
3851
3982
  acceptPageUrl.searchParams.set("token", token);
@@ -3853,13 +3984,15 @@ const acceptInvitation = (options) => {
3853
3984
  acceptPageUrl.searchParams.set("callback", callbackUrl);
3854
3985
  return ctx.redirect(acceptPageUrl.toString());
3855
3986
  }
3856
- const user = await ctx.context.internalAdapter.createUser({
3987
+ const userPayload = {
3857
3988
  email: invitationEmail,
3858
3989
  name: invitation.name || invitation.email.split("@")[0] || "",
3859
3990
  emailVerified: true,
3860
3991
  createdAt: /* @__PURE__ */ new Date(),
3861
3992
  updatedAt: /* @__PURE__ */ new Date()
3862
- });
3993
+ };
3994
+ const adapter = ctx.context.internalAdapter;
3995
+ const user = isVersionAtLeast(ctx.context.version, "1.7.0") ? await adapter.createUser(userPayload, { method: "admin" }) : await adapter.createUser(userPayload);
3863
3996
  const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
3864
3997
  method: "POST",
3865
3998
  body: {
@@ -3868,12 +4001,12 @@ const acceptInvitation = (options) => {
3868
4001
  }
3869
4002
  });
3870
4003
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3871
- await setSessionCookie(ctx, {
4004
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3872
4005
  session: await ctx.context.internalAdapter.createSession(user.id),
3873
4006
  user
3874
4007
  });
3875
- const redirectUrl = invitation.redirectUrl || ctx.context.options.baseURL || "/";
3876
- return ctx.redirect(redirectUrl.toString());
4008
+ const redirectUrl = resolveInvitationRedirect(ctx, invitation.redirectUrl);
4009
+ return ctx.redirect(redirectUrl);
3877
4010
  });
3878
4011
  };
3879
4012
  /**
@@ -3891,7 +4024,6 @@ const completeInvitation = (options) => {
3891
4024
  })
3892
4025
  }, async (ctx) => {
3893
4026
  const { token, password } = ctx.body;
3894
- const fallbackRedirect = getFallbackRedirect(ctx);
3895
4027
  const invitation = await verifyPendingInvitation(token, $api, ctx);
3896
4028
  if (!ctx.context) throw new APIError("BAD_REQUEST", { message: "Context is required" });
3897
4029
  const invitationEmail = normalizeEmail(invitation.email, ctx.context);
@@ -3905,23 +4037,25 @@ const completeInvitation = (options) => {
3905
4037
  }
3906
4038
  });
3907
4039
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3908
- await setSessionCookie(ctx, {
4040
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3909
4041
  session: await ctx.context.internalAdapter.createSession(existingUser.id),
3910
4042
  user: existingUser
3911
4043
  });
3912
4044
  return {
3913
4045
  success: true,
3914
- redirectUrl: invitation.redirectUrl ?? fallbackRedirect
4046
+ redirectUrl: resolveInvitationRedirect(ctx, invitation.redirectUrl)
3915
4047
  };
3916
4048
  }
3917
4049
  if (!password) throw new APIError("BAD_REQUEST", { message: "Password is required to complete this invitation." });
3918
- const user = await ctx.context.internalAdapter.createUser({
4050
+ const userPayload = {
3919
4051
  email: invitationEmail,
3920
4052
  name: invitation.name || invitation.email.split("@")[0] || "",
3921
4053
  emailVerified: true,
3922
4054
  createdAt: /* @__PURE__ */ new Date(),
3923
4055
  updatedAt: /* @__PURE__ */ new Date()
3924
- });
4056
+ };
4057
+ const adapter = ctx.context.internalAdapter;
4058
+ const user = isVersionAtLeast(ctx.context.version, "1.7.0") ? await adapter.createUser(userPayload, { method: "admin" }) : await adapter.createUser(userPayload);
3925
4059
  await ctx.context.internalAdapter.createAccount({
3926
4060
  userId: user.id,
3927
4061
  providerId: "credential",
@@ -3936,13 +4070,13 @@ const completeInvitation = (options) => {
3936
4070
  }
3937
4071
  });
3938
4072
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3939
- await setSessionCookie(ctx, {
4073
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3940
4074
  session: await ctx.context.internalAdapter.createSession(user.id),
3941
4075
  user
3942
4076
  });
3943
4077
  return {
3944
4078
  success: true,
3945
- redirectUrl: invitation.redirectUrl ?? fallbackRedirect
4079
+ redirectUrl: resolveInvitationRedirect(ctx, invitation.redirectUrl)
3946
4080
  };
3947
4081
  });
3948
4082
  };
@@ -3967,7 +4101,7 @@ const completeInvitationSocial = (options) => {
3967
4101
  }
3968
4102
  });
3969
4103
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3970
- return ctx.redirect((invitation.redirectUrl || getFallbackRedirect(ctx)).toString());
4104
+ return ctx.redirect(resolveInvitationRedirect(ctx, invitation.redirectUrl));
3971
4105
  });
3972
4106
  };
3973
4107
  /**
@@ -4149,11 +4283,15 @@ const cancelInvitation = (options) => {
4149
4283
  }, async (ctx) => {
4150
4284
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
4151
4285
  const { invitationId, organizationId } = ctx.context.payload;
4286
+ if (ctx.body.invitationId !== invitationId) throw ctx.error("BAD_REQUEST", { message: "Invitation ID mismatch" });
4152
4287
  const invitation = await ctx.context.adapter.findOne({
4153
4288
  model: "invitation",
4154
4289
  where: [{
4155
4290
  field: "id",
4156
4291
  value: invitationId
4292
+ }, {
4293
+ field: "organizationId",
4294
+ value: organizationId
4157
4295
  }]
4158
4296
  });
4159
4297
  if (!invitation) throw ctx.error("NOT_FOUND", { message: "Invitation not found" });
@@ -4182,6 +4320,9 @@ const cancelInvitation = (options) => {
4182
4320
  where: [{
4183
4321
  field: "id",
4184
4322
  value: invitationId
4323
+ }, {
4324
+ field: "organizationId",
4325
+ value: organizationId
4185
4326
  }],
4186
4327
  update: { status: "canceled" }
4187
4328
  });
@@ -4207,11 +4348,15 @@ const resendInvitation = (options) => {
4207
4348
  }, async (ctx) => {
4208
4349
  const organizationPlugin = requireOrganizationPlugin(ctx);
4209
4350
  const { invitationId, organizationId } = ctx.context.payload;
4351
+ if (ctx.body.invitationId !== invitationId) throw ctx.error("BAD_REQUEST", { message: "Invitation ID mismatch" });
4210
4352
  const invitation = await ctx.context.adapter.findOne({
4211
4353
  model: "invitation",
4212
4354
  where: [{
4213
4355
  field: "id",
4214
4356
  value: invitationId
4357
+ }, {
4358
+ field: "organizationId",
4359
+ value: organizationId
4215
4360
  }]
4216
4361
  });
4217
4362
  if (!invitation) throw ctx.error("NOT_FOUND", { message: "Invitation not found" });
@@ -4611,6 +4756,8 @@ async function withConcurrency(items, fn, options) {
4611
4756
  if (executing.length > 0) await run();
4612
4757
  return results;
4613
4758
  }
4759
+ /** Zod schema for validating organization/project slug format. */
4760
+ const slugSchema = z.string().min(1, "Slug is required").regex(/^[a-z0-9-]+$/, "Slug can only contain lowercase letters, numbers, and hyphens");
4614
4761
  //#endregion
4615
4762
  //#region src/routes/organizations/schemas.ts
4616
4763
  const DENIED_ORG_WRITE_KEYS = new Set([
@@ -4619,13 +4766,13 @@ const DENIED_ORG_WRITE_KEYS = new Set([
4619
4766
  "updatedAt"
4620
4767
  ]);
4621
4768
  const CREATE_NON_PERSISTED_KEYS = new Set(["defaultTeamName"]);
4622
- const CREATE_REQUEST_ONLY_KEYS = new Set(["userId", "skipDefaultTeam"]);
4623
- const CORE_CREATE_KEYS = [
4769
+ const CREATE_REQUEST_ONLY_KEYS$1 = new Set(["userId", "skipDefaultTeam"]);
4770
+ const CORE_CREATE_KEYS$1 = [
4624
4771
  "name",
4625
4772
  "slug",
4626
4773
  "logo"
4627
4774
  ];
4628
- const CORE_UPDATE_KEYS = [
4775
+ const CORE_UPDATE_KEYS$1 = [
4629
4776
  "name",
4630
4777
  "slug",
4631
4778
  "logo",
@@ -4641,7 +4788,7 @@ function getWritableOrganizationAdditionalFieldNames(orgOptions) {
4641
4788
  }).map(([name]) => name);
4642
4789
  }
4643
4790
  function getWritableOrganizationFieldNames(orgOptions, mode) {
4644
- const keys = new Set(mode === "create" ? CORE_CREATE_KEYS : CORE_UPDATE_KEYS);
4791
+ const keys = new Set(mode === "create" ? CORE_CREATE_KEYS$1 : CORE_UPDATE_KEYS$1);
4645
4792
  for (const name of getWritableOrganizationAdditionalFieldNames(orgOptions)) keys.add(name);
4646
4793
  return keys;
4647
4794
  }
@@ -4654,7 +4801,7 @@ function pickWritableOrganizationFields(body, orgOptions, mode) {
4654
4801
  const skip = new Set([
4655
4802
  ...DENIED_ORG_WRITE_KEYS,
4656
4803
  ...mode === "create" ? CREATE_NON_PERSISTED_KEYS : [],
4657
- ...mode === "create" ? CREATE_REQUEST_ONLY_KEYS : []
4804
+ ...mode === "create" ? CREATE_REQUEST_ONLY_KEYS$1 : []
4658
4805
  ]);
4659
4806
  const result = {};
4660
4807
  for (const [key, value] of Object.entries(body)) {
@@ -4665,19 +4812,19 @@ function pickWritableOrganizationFields(body, orgOptions, mode) {
4665
4812
  }
4666
4813
  const BaseCreateOrgCoreBodySchema = z$1.object({
4667
4814
  name: z$1.string(),
4668
- slug: z$1.string(),
4815
+ slug: slugSchema,
4669
4816
  logo: z$1.string().optional(),
4670
4817
  defaultTeamName: z$1.string().optional()
4671
4818
  });
4672
4819
  const BaseUpdateOrgCoreBodySchema = z$1.object({
4673
- logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4820
+ logo: optionalSafeUrlSchema,
4674
4821
  name: z$1.string().optional(),
4675
- slug: z$1.string().optional(),
4822
+ slug: slugSchema.optional(),
4676
4823
  metadata: z$1.string().optional()
4677
4824
  });
4678
4825
  const CreateOrganizationBodySchema = BaseCreateOrgCoreBodySchema.catchall(z$1.unknown());
4679
4826
  const UpdateOrganizationBodySchema = BaseUpdateOrgCoreBodySchema.catchall(z$1.unknown());
4680
- function createSchemaForDBField(field) {
4827
+ function createSchemaForDBField$1(field) {
4681
4828
  switch (field.type) {
4682
4829
  case "number": return field.required ? z$1.coerce.number() : z$1.coerce.number().optional();
4683
4830
  case "boolean": return field.required ? z$1.coerce.boolean() : z$1.coerce.boolean().optional();
@@ -4692,13 +4839,13 @@ function createSchemaForDBField(field) {
4692
4839
  function validateWritableCreateOrganizationFields(data, options) {
4693
4840
  const shape = {
4694
4841
  name: z$1.string().min(1),
4695
- slug: z$1.string().min(1),
4842
+ slug: slugSchema,
4696
4843
  logo: z$1.string().optional()
4697
4844
  };
4698
4845
  const additional = getOrganizationAdditionalFields(options);
4699
4846
  for (const [name, field] of Object.entries(additional)) {
4700
4847
  if (field.input === false) continue;
4701
- shape[name] = createSchemaForDBField(field);
4848
+ shape[name] = createSchemaForDBField$1(field);
4702
4849
  }
4703
4850
  const result = z$1.object(shape).strict().safeParse(data);
4704
4851
  if (!result.success) throw result.error;
@@ -4714,14 +4861,14 @@ function validateWritableOrganizationUpdateFields(data, orgOptions) {
4714
4861
  }]);
4715
4862
  const shape = {
4716
4863
  name: z$1.string().min(1).optional(),
4717
- slug: z$1.string().min(1).optional(),
4718
- logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4864
+ slug: slugSchema.optional(),
4865
+ logo: optionalSafeUrlSchema,
4719
4866
  metadata: z$1.string().optional()
4720
4867
  };
4721
4868
  const additional = getOrganizationAdditionalFields(orgOptions);
4722
4869
  for (const [name, field] of Object.entries(additional)) {
4723
4870
  if (field.input === false) continue;
4724
- shape[name] = createSchemaForDBField(field);
4871
+ shape[name] = createSchemaForDBField$1(field);
4725
4872
  }
4726
4873
  const result = z$1.object(shape).partial().strict().safeParse(data);
4727
4874
  if (!result.success) throw result.error;
@@ -5790,6 +5937,17 @@ const removeTeamMember = (options) => {
5790
5937
  });
5791
5938
  };
5792
5939
  //#endregion
5940
+ //#region src/compat/internal-adapter.ts
5941
+ /** Deletes all sessions for a user across better-auth 1.4–1.6 internal adapter APIs. */
5942
+ async function deleteUserSessionsCompat(adapter, userId) {
5943
+ if (typeof adapter.deleteUserSessions === "function") {
5944
+ await adapter.deleteUserSessions(userId);
5945
+ return;
5946
+ }
5947
+ if (typeof adapter.deleteSessions !== "function") throw new Error("Internal adapter does not support deleting user sessions");
5948
+ await adapter.deleteSessions(userId);
5949
+ }
5950
+ //#endregion
5793
5951
  //#region src/routes/sessions/index.ts
5794
5952
  const revokeSession = (options) => createAuthEndpoint("/dash/sessions/revoke", {
5795
5953
  method: "POST",
@@ -5827,7 +5985,7 @@ const revokeAllSessions = (options) => createAuthEndpoint("/dash/sessions/revoke
5827
5985
  }, async (ctx) => {
5828
5986
  const { userId } = ctx.body;
5829
5987
  if (!await ctx.context.internalAdapter.findUserById(userId)) throw ctx.error("NOT_FOUND", { message: "User not found" });
5830
- await ctx.context.internalAdapter.deleteUserSessions(userId);
5988
+ await deleteUserSessionsCompat(ctx.context.internalAdapter, userId);
5831
5989
  return ctx.json({ success: true });
5832
5990
  });
5833
5991
  const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revoke-many", {
@@ -5836,7 +5994,7 @@ const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revok
5836
5994
  }, async (ctx) => {
5837
5995
  const { userIds } = ctx.context.payload;
5838
5996
  await withConcurrency(chunkArray(userIds, { batchSize: 50 }), async (chunk) => {
5839
- for (const userId of chunk) await ctx.context.internalAdapter.deleteUserSessions(userId);
5997
+ for (const userId of chunk) await deleteUserSessionsCompat(ctx.context.internalAdapter, userId);
5840
5998
  }, { concurrency: 3 });
5841
5999
  return ctx.json({
5842
6000
  success: true,
@@ -5844,248 +6002,6 @@ const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revok
5844
6002
  });
5845
6003
  });
5846
6004
  //#endregion
5847
- //#region ../utils/dist/ip.mjs
5848
- /**
5849
- * SSRF (Server-Side Request Forgery) Protection
5850
- *
5851
- * IP and URL utilities to validate hostnames and URLs before server-side fetches.
5852
- * Blocks requests to private/reserved networks. Covers IPv4 private ranges,
5853
- * IPv6 private ranges, and IPv4-mapped IPv6 bypass vectors.
5854
- */
5855
- function isPrivateIPv4(a, b) {
5856
- if (a === 10) return true;
5857
- if (a === 172 && b >= 16 && b <= 31) return true;
5858
- if (a === 192 && b === 168) return true;
5859
- if (a === 127) return true;
5860
- if (a === 169 && b === 254) return true;
5861
- if (a === 0) return true;
5862
- if (a === 100 && b >= 64 && b <= 127) return true;
5863
- if (a >= 224) return true;
5864
- return false;
5865
- }
5866
- function parseIPv4Quad(quad) {
5867
- const v4parts = quad.split(".").map(Number);
5868
- if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return [v4parts[0], v4parts[1]];
5869
- return null;
5870
- }
5871
- /**
5872
- * Loopback, RFC1918, link-local, CGNAT, ULA, documentation, NAT64 well-known,
5873
- * IPv4-mapped private, unspecified, and multicast (RFC 4291 section 2.7).
5874
- */
5875
- function ipv6GroupsAreNonPublic(groups) {
5876
- if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
5877
- if (groups.every((g) => g === 0)) return true;
5878
- if ((groups[0] & 65472) === 65152) return true;
5879
- if ((groups[0] & 65024) === 64512) return true;
5880
- if ((groups[0] & 65280) === 65280) return true;
5881
- if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5882
- if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 65535 && groups[5] === 0) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5883
- if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5884
- if (groups[0] === 8193 && groups[1] === 3512) return true;
5885
- if (groups[0] === 256 && groups.slice(1, 4).every((g) => g === 0)) return true;
5886
- return false;
5887
- }
5888
- function parseIPv6(addr) {
5889
- const sides = addr.split("::");
5890
- if (sides.length > 2) return null;
5891
- const parseGroups = (s) => s === "" ? [] : s.split(":").map((g) => parseInt(g, 16));
5892
- const left = parseGroups(sides[0]);
5893
- const right = sides.length === 2 ? parseGroups(sides[1]) : [];
5894
- if (left.some(isNaN) || right.some(isNaN)) return null;
5895
- const missing = 8 - left.length - right.length;
5896
- if (sides.length === 2) {
5897
- if (missing < 0) return null;
5898
- return [
5899
- ...left,
5900
- ...Array(missing).fill(0),
5901
- ...right
5902
- ];
5903
- }
5904
- if (left.length !== 8) return null;
5905
- return left;
5906
- }
5907
- /** True when the hostname is an IPv4 or IPv6 literal (not a DNS name). */
5908
- function isIpLiteralHostname(hostname) {
5909
- const bare = hostname.replace(/^\[|\]$/g, "");
5910
- if (parseIPv4Quad(bare)) return true;
5911
- return parseIPv6(bare)?.length === 8;
5912
- }
5913
- /** Returns true if the hostname resolves to a private/reserved IP or is a local hostname. */
5914
- function isPrivateHost(hostname) {
5915
- if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) return true;
5916
- const bare = hostname.replace(/^\[|\]$/g, "");
5917
- const v4 = parseIPv4Quad(bare);
5918
- if (v4) return isPrivateIPv4(v4[0], v4[1]);
5919
- const mappedTail = bare.match(/(?:^|:)([\d.]+)$/);
5920
- if (mappedTail && mappedTail[1].includes(".")) {
5921
- const mappedV4 = parseIPv4Quad(mappedTail[1]);
5922
- if (mappedV4) return isPrivateIPv4(mappedV4[0], mappedV4[1]);
5923
- }
5924
- const groups = parseIPv6(bare);
5925
- if (groups && groups.length === 8) return ipv6GroupsAreNonPublic(groups);
5926
- return false;
5927
- }
5928
- //#endregion
5929
- //#region ../utils/dist/url.mjs
5930
- /**
5931
- * URL helpers: normalization, SSRF-safe parsing/validation, and path/query segments.
5932
- */
5933
- const HTTP_PROTOCOLS = ["http:", "https:"];
5934
- function isAllowedProtocol(protocol, allowedProtocols) {
5935
- return (allowedProtocols?.length ? allowedProtocols : HTTP_PROTOCOLS).includes(protocol);
5936
- }
5937
- function toAllowedOriginSet(allowedOrigins) {
5938
- const set = /* @__PURE__ */ new Set();
5939
- for (const raw of allowedOrigins) try {
5940
- const parsed = new URL(raw.trim());
5941
- if (isAllowedProtocol(parsed.protocol, HTTP_PROTOCOLS)) set.add(parsed.origin);
5942
- } catch {}
5943
- return set;
5944
- }
5945
- function isAllowedOrigin(origin, allowedOrigins) {
5946
- if (!allowedOrigins.length) return false;
5947
- return toAllowedOriginSet(allowedOrigins).has(origin);
5948
- }
5949
- async function resolveHostnameAddresses(hostname) {
5950
- try {
5951
- const dns = await import("node:dns").catch((e) => {
5952
- console.warn("Failed to load node:dns for DNS resolution", e);
5953
- return null;
5954
- });
5955
- if (!dns?.promises?.resolve) return null;
5956
- return await dns.promises.resolve(hostname);
5957
- } catch {
5958
- return null;
5959
- }
5960
- }
5961
- function validateUrlSync(input, options = {}) {
5962
- let parsed;
5963
- if (input instanceof URL) parsed = input;
5964
- else {
5965
- const trimmed = input.trim();
5966
- try {
5967
- parsed = new URL(trimmed);
5968
- } catch {
5969
- return {
5970
- ok: false,
5971
- reason: "invalid_url"
5972
- };
5973
- }
5974
- }
5975
- if (!isAllowedProtocol(parsed.protocol, options.allowedProtocols)) return {
5976
- ok: false,
5977
- reason: "disallowed_protocol",
5978
- url: parsed,
5979
- protocol: parsed.protocol
5980
- };
5981
- if (parsed.username !== "" || parsed.password !== "") return {
5982
- ok: false,
5983
- reason: "embedded_credentials",
5984
- url: parsed
5985
- };
5986
- if (isPrivateHost(parsed.hostname)) return {
5987
- ok: false,
5988
- reason: "private_host",
5989
- url: parsed
5990
- };
5991
- if (options.allowedOrigins?.length) {
5992
- if (!isAllowedOrigin(parsed.origin, options.allowedOrigins)) return {
5993
- ok: false,
5994
- reason: "disallowed_origin",
5995
- url: parsed,
5996
- hostname: parsed.hostname,
5997
- origin: parsed.origin
5998
- };
5999
- }
6000
- return {
6001
- ok: true,
6002
- url: parsed
6003
- };
6004
- }
6005
- /** SSRF checks with DNS resolution when available. */
6006
- async function validateUrl(input, options = {}) {
6007
- const sync = validateUrlSync(input, options);
6008
- if (!sync.ok) return sync;
6009
- if (options.dns === false) return sync;
6010
- const hostname = sync.url.hostname;
6011
- const addresses = await resolveHostnameAddresses(hostname);
6012
- if (!isIpLiteralHostname(hostname) && !addresses?.length) return {
6013
- ok: false,
6014
- reason: "unresolved_host",
6015
- url: sync.url,
6016
- hostname
6017
- };
6018
- if (addresses) {
6019
- for (const addr of addresses) if (isPrivateHost(addr)) return {
6020
- ok: false,
6021
- reason: "private_dns",
6022
- url: sync.url,
6023
- hostname,
6024
- address: addr
6025
- };
6026
- }
6027
- return {
6028
- ok: true,
6029
- url: sync.url,
6030
- ...addresses ? { addresses } : {}
6031
- };
6032
- }
6033
- function safeResolveUrl(raw, baseURL) {
6034
- const trim = raw.trim();
6035
- if (!trim) return null;
6036
- if (trim.startsWith("//") || trim.includes("\\")) return null;
6037
- if (/[\u0000-\u001F\u007F]/.test(trim)) return null;
6038
- try {
6039
- if (trim.startsWith("/")) return new URL(trim, baseURL);
6040
- return new URL(trim);
6041
- } catch {
6042
- return null;
6043
- }
6044
- }
6045
- //#endregion
6046
- //#region src/validation/ssrf.ts
6047
- const REDIRECT_STATUSES = new Set([
6048
- 301,
6049
- 302,
6050
- 303,
6051
- 307,
6052
- 308
6053
- ]);
6054
- const MAX_REDIRECTS = 10;
6055
- var SsrfBlockedError = class extends Error {
6056
- name = "SsrfBlockedError";
6057
- };
6058
- function isSsrfBlockedError(error) {
6059
- return error instanceof SsrfBlockedError;
6060
- }
6061
- /**
6062
- * Fetch with SSRF checks on the initial URL and every redirect target.
6063
- */
6064
- async function $outbound(url, options = {}) {
6065
- const { timeout, signal: callerSignal, ...fetchInit } = options;
6066
- let currentUrl = url;
6067
- for (let hop = 0;; hop++) {
6068
- const result = await validateUrl(currentUrl);
6069
- if (!result.ok) throw new SsrfBlockedError("Invalid or blocked URL");
6070
- const validated = result.url;
6071
- const signals = [];
6072
- if (callerSignal) signals.push(callerSignal);
6073
- if (timeout !== void 0 && timeout > 0) signals.push(AbortSignal.timeout(timeout));
6074
- const response = await fetch(validated.href, {
6075
- ...fetchInit,
6076
- redirect: "manual",
6077
- signal: signals.length === 0 ? void 0 : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
6078
- });
6079
- if (!REDIRECT_STATUSES.has(response.status)) return response;
6080
- if (hop >= MAX_REDIRECTS) throw new SsrfBlockedError("Too many redirects");
6081
- await response.body?.cancel().catch(() => {});
6082
- const location = response.headers.get("location");
6083
- const next = location ? safeResolveUrl(location, validated.href) : null;
6084
- if (!next) throw new SsrfBlockedError("Invalid redirect location");
6085
- currentUrl = next;
6086
- }
6087
- }
6088
- //#endregion
6089
6005
  //#region src/routes/plugin-session.ts
6090
6006
  /**
6091
6007
  * Builds an in-memory session context for calling plugin endpoints
@@ -6145,6 +6061,16 @@ function extractAlgorithmsFromSAMLMetadata(metadataXml) {
6145
6061
  digestAlgorithms
6146
6062
  };
6147
6063
  }
6064
+ /** Extract the first IdP SSO URL from SAML metadata XML, if present. */
6065
+ function extractEntryPointFromSAMLMetadata(metadataXml) {
6066
+ const match = metadataXml.match(/<(?:[\w-]+:)?SingleSignOnService\b[^>]*\bLocation=["']([^"']+)["']/i);
6067
+ if (!match?.[1]) return void 0;
6068
+ try {
6069
+ return new URL(match[1]).toString();
6070
+ } catch {
6071
+ return;
6072
+ }
6073
+ }
6148
6074
  function validateSAMLMetadataAlgorithms(metadataXml) {
6149
6075
  const warnings = [];
6150
6076
  const { signatureAlgorithms, digestAlgorithms } = extractAlgorithmsFromSAMLMetadata(metadataXml);
@@ -6154,10 +6080,6 @@ function validateSAMLMetadataAlgorithms(metadataXml) {
6154
6080
  }
6155
6081
  //#endregion
6156
6082
  //#region src/routes/sso/index.ts
6157
- let ssoRuntimeModule;
6158
- function loadSsoRuntime() {
6159
- return ssoRuntimeModule ??= import("@better-auth/sso");
6160
- }
6161
6083
  function requireOrganizationAccess(ctx) {
6162
6084
  const orgIdFromUrl = tryDecode(ctx.params.id);
6163
6085
  const orgIdFromToken = ctx.context.payload?.organizationId;
@@ -6186,6 +6108,12 @@ const oidcConfigSchema = z$1.object({
6186
6108
  clientSecret: z$1.string().optional(),
6187
6109
  discoveryUrl: z$1.string().optional(),
6188
6110
  issuer: z$1.string().optional(),
6111
+ discoveryEndpoint: z$1.string().optional(),
6112
+ authorizationEndpoint: z$1.string().optional(),
6113
+ tokenEndpoint: z$1.string().optional(),
6114
+ jwksEndpoint: z$1.string().optional(),
6115
+ userInfoEndpoint: z$1.string().optional(),
6116
+ tokenEndpointAuthentication: z$1.enum(["client_secret_post", "client_secret_basic"]).optional(),
6189
6117
  mapping: z$1.object({
6190
6118
  id: z$1.string().optional(),
6191
6119
  email: z$1.string().optional(),
@@ -6196,21 +6124,8 @@ const oidcConfigSchema = z$1.object({
6196
6124
  }).optional()
6197
6125
  });
6198
6126
  async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6199
- let idpMetadataXml = samlConfig.idpMetadata?.metadata;
6200
- if (!idpMetadataXml && samlConfig.idpMetadata?.metadataUrl) try {
6201
- const metadataResponse = await $outbound(samlConfig.idpMetadata.metadataUrl, {
6202
- method: "GET",
6203
- headers: { Accept: "application/xml, text/xml" },
6204
- timeout: 15e3
6205
- });
6206
- if (!metadataResponse.ok) throw ctx.error("BAD_REQUEST", { message: `Failed to fetch IdP metadata from URL: ${metadataResponse.status} ${metadataResponse.statusText}` });
6207
- idpMetadataXml = await metadataResponse.text();
6208
- } catch (e) {
6209
- if (isSsrfBlockedError(e)) throw ctx.error("BAD_REQUEST", { message: "Invalid or blocked IdP metadata URL" });
6210
- if (e instanceof APIError$1) throw e;
6211
- ctx.context.logger.error("[Dash] Failed to fetch IdP metadata from URL:", e);
6212
- throw ctx.error("BAD_REQUEST", { message: "Failed to fetch IdP metadata from URL" });
6213
- }
6127
+ const idpMetadataXml = samlConfig.idpMetadata?.metadata;
6128
+ if (samlConfig.idpMetadata?.metadataUrl && !idpMetadataXml) throw ctx.error("BAD_REQUEST", { message: "IdP metadata URL must be resolved before submitting; provide idpMetadata.metadata" });
6214
6129
  const metadataAlgorithmWarnings = [];
6215
6130
  if (idpMetadataXml) {
6216
6131
  try {
@@ -6233,14 +6148,22 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6233
6148
  ...samlConfig.cert ? { cert: samlConfig.cert } : {}
6234
6149
  };
6235
6150
  const m = samlConfig.mapping;
6151
+ const resolvedEntryPoint = samlConfig.entryPoint?.trim() || (idpMetadataXml ? extractEntryPointFromSAMLMetadata(idpMetadataXml) : void 0);
6152
+ const saml17OrNewer = isVersionAtLeast(ctx.context.version, "1.7.0");
6153
+ if (saml17OrNewer && !resolvedEntryPoint) throw ctx.error("BAD_REQUEST", { message: "SAML entry point URL is required; provide entryPoint or IdP metadata with SingleSignOnService Location" });
6236
6154
  return {
6237
6155
  config: {
6238
6156
  issuer: samlConfig.entityId ?? `${baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`,
6239
- callbackUrl: `${baseURL}/sso/saml2/sp/acs/${providerId}`,
6240
6157
  idpMetadata,
6241
- spMetadata: {},
6242
- entryPoint: samlConfig.entryPoint ?? "",
6243
- cert: samlConfig.cert ?? "",
6158
+ ...saml17OrNewer ? {
6159
+ entryPoint: resolvedEntryPoint,
6160
+ ...samlConfig.cert ? { cert: samlConfig.cert } : {}
6161
+ } : {
6162
+ callbackUrl: `${baseURL}/sso/saml2/sp/acs/${providerId}`,
6163
+ spMetadata: {},
6164
+ entryPoint: resolvedEntryPoint ?? "",
6165
+ cert: samlConfig.cert ?? ""
6166
+ },
6244
6167
  ...m ? { mapping: {
6245
6168
  id: m.id ?? "nameID",
6246
6169
  email: m.email ?? "email",
@@ -6254,67 +6177,33 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6254
6177
  ...metadataAlgorithmWarnings.length > 0 ? { warnings: metadataAlgorithmWarnings } : {}
6255
6178
  };
6256
6179
  }
6257
- async function resolveOIDCConfig(oidcConfig, domain, ctx) {
6258
- let normalizedDomain = domain;
6259
- try {
6260
- if (domain.startsWith("http://") || domain.startsWith("https://")) normalizedDomain = new URL(domain).hostname;
6261
- } catch {
6262
- normalizedDomain = domain;
6263
- }
6264
- const issuerHint = oidcConfig.issuer || `https://${normalizedDomain}`;
6265
- const issuer = issuerHint.startsWith("http") ? issuerHint : `https://${issuerHint}`;
6266
- const sso = await loadSsoRuntime();
6267
- try {
6268
- const hydratedConfig = await sso.discoverOIDCConfig({
6269
- issuer,
6270
- discoveryEndpoint: oidcConfig.discoveryUrl,
6271
- isTrustedOrigin: (url) => {
6272
- try {
6273
- const issuerOrigin = new URL(issuer).origin;
6274
- return new URL(url).origin === issuerOrigin;
6275
- } catch {
6276
- return false;
6277
- }
6278
- }
6279
- });
6280
- const om = oidcConfig.mapping;
6281
- return {
6282
- config: {
6283
- clientId: oidcConfig.clientId,
6284
- clientSecret: oidcConfig.clientSecret,
6285
- issuer: hydratedConfig.issuer,
6286
- discoveryEndpoint: hydratedConfig.discoveryEndpoint,
6287
- authorizationEndpoint: hydratedConfig.authorizationEndpoint,
6288
- tokenEndpoint: hydratedConfig.tokenEndpoint,
6289
- jwksEndpoint: hydratedConfig.jwksEndpoint,
6290
- userInfoEndpoint: hydratedConfig.userInfoEndpoint,
6291
- tokenEndpointAuthentication: hydratedConfig.tokenEndpointAuthentication,
6292
- pkce: true,
6293
- ...om ? { mapping: {
6294
- id: om.id ?? "sub",
6295
- email: om.email ?? "email",
6296
- name: om.name ?? "name",
6297
- emailVerified: om.emailVerified,
6298
- image: om.image,
6299
- extraFields: om.extraFields
6300
- } } : {}
6301
- },
6302
- issuer: hydratedConfig.issuer
6303
- };
6304
- } catch (e) {
6305
- if (e instanceof sso.DiscoveryError) {
6306
- ctx.context.logger.error("[Dash] OIDC discovery failed:", e);
6307
- throw ctx.error("BAD_REQUEST", {
6308
- message: `OIDC discovery failed: ${e.message}`,
6309
- code: e.code
6310
- });
6311
- }
6312
- ctx.context.logger.error("[Dash] OIDC discovery failed:", e);
6313
- throw ctx.error("BAD_REQUEST", {
6314
- message: `OIDC discovery failed: Unable to discover configuration from ${issuer}`,
6315
- code: "OIDC_DISCOVERY_FAILED"
6316
- });
6317
- }
6180
+ async function resolveOIDCConfig(oidcConfig, _domain, ctx) {
6181
+ if (!oidcConfig.issuer || !oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint) throw ctx.error("BAD_REQUEST", { message: "OIDC discovery must be resolved before submitting; provide issuer, authorizationEndpoint, tokenEndpoint, and jwksEndpoint" });
6182
+ const om = oidcConfig.mapping;
6183
+ const discoveryEndpoint = oidcConfig.discoveryEndpoint ?? oidcConfig.discoveryUrl ?? oidcConfig.issuer;
6184
+ return {
6185
+ config: {
6186
+ clientId: oidcConfig.clientId,
6187
+ clientSecret: oidcConfig.clientSecret,
6188
+ issuer: oidcConfig.issuer,
6189
+ discoveryEndpoint,
6190
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
6191
+ tokenEndpoint: oidcConfig.tokenEndpoint,
6192
+ jwksEndpoint: oidcConfig.jwksEndpoint,
6193
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
6194
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication,
6195
+ pkce: true,
6196
+ ...om ? { mapping: {
6197
+ id: om.id ?? "sub",
6198
+ email: om.email ?? "email",
6199
+ name: om.name ?? "name",
6200
+ emailVerified: om.emailVerified,
6201
+ image: om.image,
6202
+ extraFields: om.extraFields
6203
+ } } : {}
6204
+ },
6205
+ issuer: oidcConfig.issuer
6206
+ };
6318
6207
  }
6319
6208
  const listOrganizationSsoProviders = (options) => {
6320
6209
  return createAuthEndpoint("/dash/organization/:id/sso-providers", {
@@ -6472,6 +6361,7 @@ const updateSsoProvider = (options) => {
6472
6361
  try {
6473
6362
  const result = await ssoPlugin.endpoints.updateSSOProvider({
6474
6363
  body: updateBody,
6364
+ params: { providerId },
6475
6365
  context: {
6476
6366
  ...ctx.context,
6477
6367
  ...buildSessionContext(providerUserId)
@@ -6619,6 +6509,7 @@ const deleteSsoProvider = (options) => {
6619
6509
  try {
6620
6510
  await ssoPlugin.endpoints.deleteSSOProvider({
6621
6511
  body: { providerId },
6512
+ params: { providerId },
6622
6513
  context: {
6623
6514
  ...ctx.context,
6624
6515
  ...buildSessionContext(provider.userId)
@@ -6916,6 +6807,30 @@ const generateBackupCodes = (options) => createAuthEndpoint("/dash/generate-back
6916
6807
  return { backupCodes: newBackupCodes };
6917
6808
  });
6918
6809
  //#endregion
6810
+ //#region src/compat/sessions.ts
6811
+ /** Secondary-storage sessions on older better-auth builds may omit `id`; dash expects one. */
6812
+ function normalizeDashSessions(sessions) {
6813
+ return sessions.map((session) => {
6814
+ if (session.id) return session;
6815
+ if (session.token) return {
6816
+ ...session,
6817
+ id: session.token
6818
+ };
6819
+ return session;
6820
+ });
6821
+ }
6822
+ //#endregion
6823
+ //#region src/lib/sanitize-sessions.ts
6824
+ /**
6825
+ * Strips bearer tokens from session records before returning them from dash handlers.
6826
+ */
6827
+ function sanitizeSessions(sessions) {
6828
+ return sessions.map((session) => {
6829
+ const { token: _token, ...rest } = session;
6830
+ return rest;
6831
+ });
6832
+ }
6833
+ //#endregion
6919
6834
  //#region src/routes/two-factor/two-factor-status.ts
6920
6835
  async function resolveTwoFactorStatus(context, userId, user) {
6921
6836
  if (!hasPlugin(context, "two-factor")) return;
@@ -6930,6 +6845,158 @@ async function resolveTwoFactorStatus(context, userId, user) {
6930
6845
  }) ? "pending" : "disabled";
6931
6846
  }
6932
6847
  //#endregion
6848
+ //#region src/routes/users/schemas.ts
6849
+ const DENIED_USER_WRITE_KEYS = new Set([
6850
+ "id",
6851
+ "createdAt",
6852
+ "updatedAt"
6853
+ ]);
6854
+ /** Fields that must be changed through dedicated permission-checked endpoints. */
6855
+ const DENIED_SENSITIVE_USER_KEYS = new Set([
6856
+ "banned",
6857
+ "banReason",
6858
+ "banExpires",
6859
+ "role",
6860
+ "password",
6861
+ "twoFactorEnabled",
6862
+ "twoFactorSecret"
6863
+ ]);
6864
+ const CREATE_REQUEST_ONLY_KEYS = new Set([
6865
+ "password",
6866
+ "generatePassword",
6867
+ "sendVerificationEmail",
6868
+ "sendOrganizationInvite",
6869
+ "organizationRole",
6870
+ "organizationId"
6871
+ ]);
6872
+ const CORE_CREATE_KEYS = [
6873
+ "name",
6874
+ "email",
6875
+ "image",
6876
+ "emailVerified"
6877
+ ];
6878
+ const CORE_UPDATE_KEYS = [
6879
+ "name",
6880
+ "email",
6881
+ "image",
6882
+ "emailVerified"
6883
+ ];
6884
+ /**
6885
+ * Mirrors Better Auth's user input schema merge:
6886
+ * `user.additionalFields` plus each plugin's `schema.user` fields.
6887
+ */
6888
+ function getUserInputFields(options) {
6889
+ let schema = { ...options?.user?.additionalFields ?? {} };
6890
+ for (const plugin of options?.plugins ?? []) {
6891
+ const pluginUser = plugin.schema?.user;
6892
+ if (pluginUser?.fields) schema = {
6893
+ ...schema,
6894
+ ...pluginUser.fields
6895
+ };
6896
+ if (pluginUser?.additionalFields) schema = {
6897
+ ...schema,
6898
+ ...pluginUser.additionalFields
6899
+ };
6900
+ }
6901
+ return schema;
6902
+ }
6903
+ function isWritableUserInputField(field) {
6904
+ return field.input !== false && !field.references;
6905
+ }
6906
+ function getWritableUserAdditionalFieldNames(options) {
6907
+ return Object.entries(getUserInputFields(options)).filter(([, field]) => isWritableUserInputField(field)).map(([name]) => name);
6908
+ }
6909
+ function getWritableUserFieldNames(options, mode) {
6910
+ const keys = new Set(mode === "create" ? CORE_CREATE_KEYS : CORE_UPDATE_KEYS);
6911
+ for (const name of getWritableUserAdditionalFieldNames(options)) keys.add(name);
6912
+ return keys;
6913
+ }
6914
+ /**
6915
+ * Returns only user fields that may be persisted on create/update.
6916
+ * Strips unknown keys, denied keys, request-only fields, and non-input/reference additional fields.
6917
+ */
6918
+ function pickWritableUserFields(body, options, mode) {
6919
+ const allowed = getWritableUserFieldNames(options, mode);
6920
+ const skip = new Set([
6921
+ ...DENIED_USER_WRITE_KEYS,
6922
+ ...DENIED_SENSITIVE_USER_KEYS,
6923
+ ...mode === "create" ? CREATE_REQUEST_ONLY_KEYS : []
6924
+ ]);
6925
+ const result = {};
6926
+ for (const [key, value] of Object.entries(body)) {
6927
+ if (skip.has(key) || !allowed.has(key) || value === void 0) continue;
6928
+ result[key] = value;
6929
+ }
6930
+ return result;
6931
+ }
6932
+ const BaseCreateUserCoreBodySchema = z$1.object({
6933
+ name: z$1.string(),
6934
+ email: z$1.email(),
6935
+ image: z$1.string().optional(),
6936
+ password: z$1.string().optional(),
6937
+ generatePassword: z$1.boolean().optional(),
6938
+ emailVerified: z$1.boolean().optional(),
6939
+ sendVerificationEmail: z$1.boolean().optional(),
6940
+ sendOrganizationInvite: z$1.boolean().optional(),
6941
+ organizationRole: z$1.string().optional(),
6942
+ organizationId: z$1.string().optional()
6943
+ });
6944
+ const BaseUpdateUserCoreBodySchema = z$1.object({
6945
+ name: z$1.string().nullable().optional(),
6946
+ email: z$1.email().optional(),
6947
+ image: z$1.string().nullable().optional(),
6948
+ emailVerified: z$1.boolean().optional()
6949
+ });
6950
+ const CreateUserBodySchema = BaseCreateUserCoreBodySchema.catchall(z$1.unknown());
6951
+ const UpdateUserBodySchema = BaseUpdateUserCoreBodySchema.catchall(z$1.unknown());
6952
+ function createSchemaForDBField(field) {
6953
+ switch (field.type) {
6954
+ case "number": return field.required ? z$1.coerce.number() : z$1.coerce.number().optional();
6955
+ case "boolean": return field.required ? z$1.coerce.boolean() : z$1.coerce.boolean().optional();
6956
+ case "date": return field.required ? z$1.union([z$1.string().min(1), z$1.coerce.date()]) : z$1.union([z$1.string(), z$1.coerce.date()]).optional();
6957
+ default: return field.required ? z$1.string().min(1) : z$1.string().optional();
6958
+ }
6959
+ }
6960
+ /**
6961
+ * Validates required core + configured additional fields on create.
6962
+ */
6963
+ function validateWritableCreateUserFields(data, options) {
6964
+ const shape = {
6965
+ name: z$1.string().min(1),
6966
+ email: z$1.email(),
6967
+ image: z$1.string().optional(),
6968
+ emailVerified: z$1.boolean().optional()
6969
+ };
6970
+ for (const [name, field] of Object.entries(getUserInputFields(options))) {
6971
+ if (!isWritableUserInputField(field)) continue;
6972
+ shape[name] = createSchemaForDBField(field);
6973
+ }
6974
+ const result = z$1.object(shape).strict().safeParse(data);
6975
+ if (!result.success) throw result.error;
6976
+ }
6977
+ /**
6978
+ * Ensures at least one field is present on update and validates additional field types.
6979
+ */
6980
+ function validateWritableUserUpdateFields(data, options) {
6981
+ if (Object.keys(data).length === 0) throw new z$1.ZodError([{
6982
+ code: "custom",
6983
+ message: "No valid fields to update",
6984
+ path: []
6985
+ }]);
6986
+ const shape = {
6987
+ name: z$1.string().nullable().optional(),
6988
+ email: z$1.email().optional(),
6989
+ image: z$1.string().nullable().optional(),
6990
+ emailVerified: z$1.boolean().optional()
6991
+ };
6992
+ for (const [name, field] of Object.entries(getUserInputFields(options))) {
6993
+ if (!isWritableUserInputField(field)) continue;
6994
+ shape[name] = createSchemaForDBField(field);
6995
+ }
6996
+ const result = z$1.object(shape).partial().strict().safeParse(data);
6997
+ if (!result.success) throw result.error;
6998
+ }
6999
+ //#endregion
6933
7000
  //#region src/routes/users/index.ts
6934
7001
  function parseWhereClause(val) {
6935
7002
  if (!val) return [];
@@ -7093,7 +7160,7 @@ const impersonateUser = (options) => {
7093
7160
  query: z$1.object({ impersonation_token: z$1.string() }),
7094
7161
  use: [jwtMiddleware(options, z$1.object({
7095
7162
  userId: z$1.string(),
7096
- redirectUrl: z$1.url(),
7163
+ redirectUrl: safeUrlSchema,
7097
7164
  impersonatedBy: z$1.string().optional()
7098
7165
  }), async (ctx) => {
7099
7166
  return ctx.query.impersonation_token;
@@ -7121,25 +7188,21 @@ const createUser = (options) => {
7121
7188
  organizationId: z$1.string().optional(),
7122
7189
  organizationRole: z$1.string().optional()
7123
7190
  }))],
7124
- body: z$1.looseObject({
7125
- name: z$1.string(),
7126
- email: z$1.email(),
7127
- image: z$1.string().optional(),
7128
- password: z$1.string().optional(),
7129
- generatePassword: z$1.boolean().optional(),
7130
- emailVerified: z$1.boolean().optional(),
7131
- sendVerificationEmail: z$1.boolean().optional(),
7132
- sendOrganizationInvite: z$1.boolean().optional(),
7133
- organizationRole: z$1.string().optional(),
7134
- organizationId: z$1.string().optional()
7135
- })
7191
+ body: CreateUserBodySchema
7136
7192
  }, async (ctx) => {
7137
- const userData = ctx.body;
7193
+ const body = ctx.body;
7194
+ const userData = pickWritableUserFields(body, ctx.context.options, "create");
7195
+ try {
7196
+ validateWritableCreateUserFields(userData, ctx.context.options);
7197
+ } catch (error) {
7198
+ if (error instanceof z$1.ZodError) throw new APIError("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid user data" });
7199
+ throw error;
7200
+ }
7138
7201
  const email = normalizeEmail(userData.email, ctx.context);
7139
7202
  if (await ctx.context.internalAdapter.findUserByEmail(email)) throw new APIError("BAD_REQUEST", { message: "User with this email already exist" });
7140
7203
  let password = null;
7141
- if (userData.generatePassword && !userData.password) password = generateId(12);
7142
- else if (userData.password && userData.password.trim() !== "") password = userData.password;
7204
+ if (body.generatePassword && !body.password) password = generateId(12);
7205
+ else if (body.password && typeof body.password === "string" && body.password.trim() !== "") password = body.password;
7143
7206
  const userSchema = getAuthTables(ctx.context.options).user;
7144
7207
  for (const field in userSchema.fields || {}) {
7145
7208
  const value = userSchema.fields[field];
@@ -7156,24 +7219,28 @@ const createUser = (options) => {
7156
7219
  }
7157
7220
  }
7158
7221
  }
7159
- const user = await ctx.context.internalAdapter.createUser({
7222
+ const emailVerified = userData.emailVerified;
7223
+ const userPayload = {
7160
7224
  ...userData,
7225
+ name: userData.name,
7161
7226
  email,
7162
- emailVerified: userData.emailVerified,
7227
+ emailVerified,
7163
7228
  createdAt: /* @__PURE__ */ new Date(),
7164
7229
  updatedAt: /* @__PURE__ */ new Date()
7165
- });
7230
+ };
7231
+ const adapter = ctx.context.internalAdapter;
7232
+ const user = isVersionAtLeast(ctx.context.version, "1.7.0") ? await adapter.createUser(userPayload, { method: "admin" }) : await adapter.createUser(userPayload);
7166
7233
  if (password) await ctx.context.internalAdapter.createAccount({
7167
7234
  userId: user.id,
7168
7235
  providerId: "credential",
7169
7236
  accountId: user.id,
7170
7237
  password: await ctx.context.password.hash(password)
7171
7238
  });
7172
- if (userData.sendVerificationEmail && !userData.emailVerified) {
7239
+ if (body.sendVerificationEmail && !emailVerified) {
7173
7240
  if (ctx.context.options.emailVerification?.sendVerificationEmail) await sendVerificationEmailFn(ctx, user);
7174
7241
  }
7175
- const organizationId = ctx.context.payload?.organizationId || userData.organizationId;
7176
- const organizationRole = ctx.context.payload?.organizationRole || userData.organizationRole;
7242
+ const organizationId = ctx.context.payload?.organizationId || body.organizationId;
7243
+ const organizationRole = ctx.context.payload?.organizationRole || body.organizationRole;
7177
7244
  if (organizationId) {
7178
7245
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
7179
7246
  const role = organizationRole || "member";
@@ -7295,7 +7362,7 @@ const getUserDetails = (options) => {
7295
7362
  twoFactorStatus
7296
7363
  };
7297
7364
  const activityTrackingEnabled = !!options.activityTracking?.enabled;
7298
- const sessions = secondaryStorageOnly ? await ctx.context.internalAdapter.listSessions(userId) : user.session || [];
7365
+ const sessions = secondaryStorageOnly ? normalizeDashSessions(await ctx.context.internalAdapter.listSessions(userId)) : user.session || [];
7299
7366
  let lastActiveAt = null;
7300
7367
  if (activityTrackingEnabled) {
7301
7368
  lastActiveAt = user.lastActiveAt ?? null;
@@ -7324,9 +7391,10 @@ const getUserDetails = (options) => {
7324
7391
  updateActivity();
7325
7392
  }
7326
7393
  }
7394
+ const { session: _joinedSessions, ...userWithoutSessions } = user;
7327
7395
  return {
7328
- ...user,
7329
- ...secondaryStorageOnly ? { session: sessions } : {},
7396
+ ...userWithoutSessions,
7397
+ session: sanitizeSessions(sessions),
7330
7398
  lastActiveAt,
7331
7399
  banned: hasAdminPlugin ? user.banned ?? false : false,
7332
7400
  banReason: hasAdminPlugin ? user.banReason ?? null : null,
@@ -7384,20 +7452,19 @@ const getUserOrganizations = (options) => {
7384
7452
  const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
7385
7453
  method: "POST",
7386
7454
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7387
- body: z$1.looseObject({
7388
- name: z$1.string().optional(),
7389
- email: z$1.email().optional(),
7390
- image: z$1.string().optional(),
7391
- emailVerified: z$1.boolean().optional()
7392
- })
7455
+ body: UpdateUserBodySchema
7393
7456
  }, async (ctx) => {
7394
- const updateData = ctx.body;
7395
7457
  const userId = ctx.context.payload?.userId;
7396
7458
  if (!userId) throw new APIError("FORBIDDEN", { message: "Invalid payload" });
7397
- const filteredData = Object.fromEntries(Object.entries(updateData).filter(([, value]) => value !== void 0));
7398
- if (Object.keys(filteredData).length === 0) throw new APIError("BAD_REQUEST", { message: "No valid fields to update" });
7459
+ const updateData = pickWritableUserFields(ctx.body, ctx.context.options, "update");
7460
+ try {
7461
+ validateWritableUserUpdateFields(updateData, ctx.context.options);
7462
+ } catch (error) {
7463
+ if (error instanceof z$1.ZodError) throw new APIError("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid user data" });
7464
+ throw error;
7465
+ }
7399
7466
  const user = await ctx.context.internalAdapter.updateUser(userId, {
7400
- ...filteredData,
7467
+ ...updateData,
7401
7468
  updatedAt: /* @__PURE__ */ new Date()
7402
7469
  });
7403
7470
  if (!user) throw new APIError("NOT_FOUND", { message: "User not found" });
@@ -7861,11 +7928,12 @@ const banUser = (options) => createAuthEndpoint("/dash/ban-user", {
7861
7928
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7862
7929
  body: z$1.object({
7863
7930
  banReason: z$1.string().optional(),
7864
- banExpires: z$1.number().optional()
7931
+ banExpires: z$1.number().optional(),
7932
+ deleteAllSessions: z$1.boolean().optional().default(true)
7865
7933
  })
7866
7934
  }, async (ctx) => {
7867
7935
  const { userId } = ctx.context.payload;
7868
- const { banReason, banExpires } = ctx.body;
7936
+ const { banReason, banExpires, deleteAllSessions } = ctx.body;
7869
7937
  if (!await ctx.context.internalAdapter.findUserById(userId)) throw new APIError("NOT_FOUND", { message: "User not found" });
7870
7938
  await ctx.context.internalAdapter.updateUser(userId, {
7871
7939
  banned: true,
@@ -7873,7 +7941,7 @@ const banUser = (options) => createAuthEndpoint("/dash/ban-user", {
7873
7941
  banExpires: banExpires ? new Date(banExpires) : null,
7874
7942
  updatedAt: /* @__PURE__ */ new Date()
7875
7943
  });
7876
- await ctx.context.internalAdapter.deleteUserSessions(userId);
7944
+ if (deleteAllSessions) await deleteUserSessionsCompat(ctx.context.internalAdapter, userId);
7877
7945
  return { success: true };
7878
7946
  });
7879
7947
  const banManyUsers = (options) => {
@@ -7882,11 +7950,12 @@ const banManyUsers = (options) => {
7882
7950
  use: [jwtMiddleware(options, z$1.object({ userIds: z$1.string().array() }))],
7883
7951
  body: z$1.object({
7884
7952
  banReason: z$1.string().optional(),
7885
- banExpires: z$1.number().optional()
7953
+ banExpires: z$1.number().optional(),
7954
+ deleteAllSessions: z$1.boolean().optional().default(true)
7886
7955
  })
7887
7956
  }, async (ctx) => {
7888
7957
  const { userIds } = ctx.context.payload;
7889
- const { banReason, banExpires } = ctx.body;
7958
+ const { banReason, banExpires, deleteAllSessions } = ctx.body;
7890
7959
  const start = performance.now();
7891
7960
  await withConcurrency(chunkArray(userIds, { batchSize: 50 }), async (chunk) => {
7892
7961
  await ctx.context.adapter.updateMany({
@@ -7918,6 +7987,9 @@ const banManyUsers = (options) => {
7918
7987
  select: ["id"]
7919
7988
  })).map((u) => u.id);
7920
7989
  const bannedUserIds = userIds.filter((id) => !skippedUserIds.includes(id));
7990
+ if (deleteAllSessions && bannedUserIds.length > 0) await withConcurrency(chunkArray(bannedUserIds, { batchSize: 50 }), async (chunk) => {
7991
+ for (const userId of chunk) await deleteUserSessionsCompat(ctx.context.internalAdapter, userId);
7992
+ }, { concurrency: 3 });
7921
7993
  const end = performance.now();
7922
7994
  console.log(`Time taken to ban ${bannedUserIds.length} users: ${(end - start) / 1e3}s`, skippedUserIds.length > 0 ? `Skipped: ${skippedUserIds.length}` : "");
7923
7995
  return ctx.json({
@@ -7944,7 +8016,7 @@ const unbanUser = (options) => createAuthEndpoint("/dash/unban-user", {
7944
8016
  const sendVerificationEmail = (options) => createAuthEndpoint("/dash/send-verification-email", {
7945
8017
  method: "POST",
7946
8018
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7947
- body: z$1.object({ callbackUrl: z$1.url() })
8019
+ body: z$1.object({ callbackUrl: safeUrlSchema })
7948
8020
  }, async (ctx) => {
7949
8021
  const { userId } = ctx.context.payload;
7950
8022
  const { callbackUrl } = ctx.body;
@@ -7966,7 +8038,7 @@ const sendManyVerificationEmails = (options) => {
7966
8038
  return createAuthEndpoint("/dash/send-many-verification-emails", {
7967
8039
  method: "POST",
7968
8040
  use: [jwtMiddleware(options, z$1.object({ userIds: z$1.string().array() }))],
7969
- body: z$1.object({ callbackUrl: z$1.url() })
8041
+ body: z$1.object({ callbackUrl: safeUrlSchema })
7970
8042
  }, async (ctx) => {
7971
8043
  if (!ctx.context.options.emailVerification?.sendVerificationEmail) throw ctx.error("BAD_REQUEST", { message: "Email verification is not enabled" });
7972
8044
  const { userIds } = ctx.context.payload;
@@ -8026,7 +8098,7 @@ const sendManyVerificationEmails = (options) => {
8026
8098
  const sendResetPasswordEmail = (options) => createAuthEndpoint("/dash/send-reset-password-email", {
8027
8099
  method: "POST",
8028
8100
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
8029
- body: z$1.object({ callbackUrl: z$1.url() })
8101
+ body: z$1.object({ callbackUrl: safeUrlSchema })
8030
8102
  }, async (ctx) => {
8031
8103
  const { userId } = ctx.context.payload;
8032
8104
  const user = await ctx.context.internalAdapter.findUserById(userId);
@@ -8118,6 +8190,8 @@ const SMS_TEMPLATES = {
8118
8190
  "two-factor": { variables: {} },
8119
8191
  "sign-in-otp": { variables: {} }
8120
8192
  };
8193
+ /** End-user IP forwarded to v1 SMS for abuse limits on server-to-server calls. */
8194
+ const BETTER_AUTH_CLIENT_IP_HEADER = "x-better-auth-client-ip";
8121
8195
  /**
8122
8196
  * Create an SMS sender instance
8123
8197
  */
@@ -8149,7 +8223,12 @@ function createSMSSender(config) {
8149
8223
  to: options.to,
8150
8224
  code: options.code,
8151
8225
  template: options.template
8152
- }
8226
+ },
8227
+ ...options.clientIp ? { headers: {
8228
+ Authorization: `Bearer ${apiKey}`,
8229
+ "user-agent": INFRA_USER_AGENT,
8230
+ [BETTER_AUTH_CLIENT_IP_HEADER]: options.clientIp
8231
+ } } : {}
8153
8232
  });
8154
8233
  if (error) return {
8155
8234
  success: false,
@@ -8467,7 +8546,8 @@ const dash = (options) => {
8467
8546
  routes.CHANGE_PASSWORD,
8468
8547
  routes.SET_PASSWORD,
8469
8548
  routes.RESET_PASSWORD,
8470
- routes.ADMIN_SET_PASSWORD
8549
+ routes.ADMIN_SET_PASSWORD,
8550
+ routes.DASH_SET_PASSWORD
8471
8551
  ])) trackAccountPasswordChange(account, trigger, ctx, location);
8472
8552
  } },
8473
8553
  delete: { async after(account, _ctx) {
@@ -8518,8 +8598,7 @@ const dash = (options) => {
8518
8598
  after: [{
8519
8599
  matcher: (ctx) => {
8520
8600
  if (ctx.request?.method !== "GET") return true;
8521
- const path = new URL(ctx.request.url).pathname;
8522
- return matchesAnyRoute(path, [
8601
+ return matchesAnyRoute(ctx.path, [
8523
8602
  routes.SIGN_IN_SOCIAL_CALLBACK,
8524
8603
  routes.SIGN_IN_OAUTH_CALLBACK,
8525
8604
  routes.DASH_IMPERSONATE_USER
@@ -8528,11 +8607,11 @@ const dash = (options) => {
8528
8607
  handler: createAuthMiddleware(async (_ctx) => {
8529
8608
  const ctx = _ctx;
8530
8609
  const trigger = getTriggerInfo(ctx, ctx.context.session?.user.id ?? "unknown");
8531
- if (matchesAnyRoute(ctx.path, [routes.SEND_VERIFICATION_EMAIL]) && ctx.context.session && !(ctx.context.returned instanceof Error)) trackEmailVerificationSent(ctx.context.session.session, ctx.context.session.user, trigger, ctx.context.location);
8610
+ if (matchesAnyRoute(ctx.path, [routes.SEND_VERIFICATION_EMAIL, routes.DASH_SEND_VERIFICATION_EMAIL]) && ctx.context.session && !(ctx.context.returned instanceof Error)) trackEmailVerificationSent(ctx.context.session.session, ctx.context.session.user, trigger, ctx.context.location);
8532
8611
  const body = ctx.body;
8533
8612
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email) trackEmailSignInAttempt(ctx, trigger, ctx.context.location);
8534
8613
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL]) && ctx.context.returned instanceof Error && ctx.body.provider && ctx.body.idToken) trackSocialSignInAttempt(ctx, trigger, ctx.context.location);
8535
- if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL_CALLBACK]) && ctx.request?.method === "GET" && ctx.context.returned instanceof Error && !ctx.context.newSession) trackSocialSignInRedirectionAttempt(ctx, trigger, ctx.context.location);
8614
+ if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK]) && ctx.request?.method === "GET" && !ctx.context.newSession) trackSocialSignInRedirectionAttempt(ctx, trigger, ctx.context.location);
8536
8615
  const headerRequestId = ctx.request?.headers.get("X-Request-Id");
8537
8616
  if (headerRequestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
8538
8617
  maxAge: 600,