@better-auth/infra 0.2.13 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { n as INFRA_API_URL, r as INFRA_KV_URL } from "./constants-CvriWQVc.mjs";
2
- import { n as createKV, t as createAPI } from "./fetch-DiAhoiKA.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-vBiGTmGf.mjs";
2
+ import { a as createAPI, o as createKV, r as hmacSha256Hex } from "./crypto-DjtrPUlP.mjs";
3
3
  import { EMAIL_TEMPLATES, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
4
4
  import { getCurrentAuthContext } from "@better-auth/core/context";
5
- import { APIError, generateId, getAuthTables, logger, parseState } from "better-auth";
5
+ import { APIError, generateId, getAuthTables, logger } from "better-auth";
6
6
  import { env } from "@better-auth/core/env";
7
7
  import { APIError as APIError$1, createAuthEndpoint, createAuthMiddleware, requestPasswordReset, sendVerificationEmailFn, sessionMiddleware } from "better-auth/api";
8
8
  import { createFetch } from "@better-fetch/fetch";
@@ -10,6 +10,8 @@ import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-j
10
10
  import { createLocalJWKSet, jwtVerify } from "jose";
11
11
  import z$1, { z } from "zod";
12
12
  import { setSessionCookie } from "better-auth/cookies";
13
+ import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";
14
+ import { createOTP } from "@better-auth/utils/otp";
13
15
  //#region src/options.ts
14
16
  function resolveConnectionOptions(options) {
15
17
  return {
@@ -66,6 +68,7 @@ const EVENT_TYPES = {
66
68
  USER_IMPERSONATED: "user_impersonated",
67
69
  USER_IMPERSONATED_STOPPED: "user_impersonated_stopped"
68
70
  };
71
+ const UNKNOWN_USER = "unknown";
69
72
  const routes = {
70
73
  SEND_VERIFICATION_EMAIL: "/send-verification-email",
71
74
  SIGN_IN: "/sign-in",
@@ -75,7 +78,7 @@ const routes = {
75
78
  SIGN_IN_SOCIAL: "/sign-in/social",
76
79
  SIGN_IN_ANONYMOUS: "/sign-in/anonymous",
77
80
  SIGN_IN_SOCIAL_CALLBACK: "/callback/:id",
78
- SIGN_IN_OAUTH_CALLBACK: "/oauth2/callback/:id",
81
+ SIGN_IN_OAUTH_CALLBACK: "/oauth2/callback/:providerId",
79
82
  SIGN_OUT: "/sign-out",
80
83
  SIGN_UP: "/sign-up",
81
84
  SIGN_UP_EMAIL: "/sign-up/email",
@@ -106,7 +109,11 @@ const routes = {
106
109
  API_KEY_CREATE: "/api-key/create",
107
110
  LINK_SOCIAL: "/link-social",
108
111
  DASH_ROUTE: "/dash",
112
+ DASH_ACCEPT_INVITATION: "/dash/accept-invitation",
113
+ DASH_COMPLETE_INVITATION_SOCIAL: "/dash/complete-invitation-social",
109
114
  DASH_UPDATE_USER: "/dash/update-user",
115
+ DASH_SET_PASSWORD: "/dash/set-password",
116
+ DASH_SEND_VERIFICATION_EMAIL: "/dash/send-verification-email",
110
117
  DASH_REVOKE_SESSIONS_ALL: "/dash/sessions/revoke-all",
111
118
  DASH_IMPERSONATE_USER: "/dash/impersonate-user",
112
119
  DASH_BAN_USER: "/dash/ban-user",
@@ -181,34 +188,6 @@ async function getUserById(userId, ctx) {
181
188
  }
182
189
  return user;
183
190
  }
184
- const getUserByIdToken = async (providerId, idToken, ctx) => {
185
- const provider = ctx.context.socialProviders.find((p) => p.id === providerId);
186
- let user = null;
187
- if (provider) try {
188
- user = await provider.getUserInfo(idToken);
189
- } catch (error) {
190
- logger.debug("[Dash] Failed to fetch user info:", error);
191
- }
192
- return user;
193
- };
194
- const getUserByAuthorizationCode = async (providerId, ctx) => {
195
- let userInfo = null;
196
- const provider = ctx.context.socialProviders.find((p) => p.id === providerId);
197
- if (provider) try {
198
- const codeVerifier = (await parseState(ctx)).codeVerifier;
199
- const { code, device_id } = ctx.query ?? {};
200
- const tokens = await provider.validateAuthorizationCode({
201
- code,
202
- codeVerifier,
203
- deviceId: device_id,
204
- redirectURI: `${ctx.context.baseURL}/callback/${provider.id}`
205
- });
206
- userInfo = await provider.getUserInfo({ ...tokens }).then((res) => res?.user);
207
- } catch (error) {
208
- logger.debug("[Dash] Failed to fetch user info:", error);
209
- }
210
- return userInfo;
211
- };
212
191
  //#endregion
213
192
  //#region src/events/core/events-account.ts
214
193
  const initAccountEvents = (tracker) => {
@@ -307,13 +286,22 @@ const stripQuery = (value) => value.split("?")[0] || value;
307
286
  const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
308
287
  const routeToRegex = (route) => {
309
288
  const pattern = escapeRegex(stripQuery(route)).replace(/\/:([^/]+)/g, "/[^/]+");
310
- return new RegExp(`${pattern}(?:$|[/?])`);
289
+ return new RegExp(`^${pattern}(?:$|[/?])`);
311
290
  };
312
291
  const matchesAnyRoute = (path, routes) => {
292
+ if (!path) return false;
313
293
  const cleanPath = stripQuery(path);
314
294
  return routes.some((route) => routeToRegex(route).test(cleanPath));
315
295
  };
316
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
317
305
  //#region src/events/login-methods.ts
318
306
  const OAUTH_CALLBACK_PATHS = [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK];
319
307
  const PATH_TO_METHOD = [
@@ -336,15 +324,58 @@ const PATH_TO_METHOD = [
336
324
  [routes.SIWE_VERIFY, "siwe"]
337
325
  ];
338
326
  const getLoginMethod = (ctx) => {
339
- if (matchesAnyRoute(ctx.path, OAUTH_CALLBACK_PATHS)) {
340
- const id = ctx.params?.id;
341
- if (typeof id === "string" && id && !id.startsWith(":")) return id;
342
- return null;
343
- }
327
+ if (matchesAnyRoute(ctx.path, OAUTH_CALLBACK_PATHS)) return getOAuthCallbackProviderId(ctx) ?? null;
344
328
  for (const [path, method] of PATH_TO_METHOD) if (matchesAnyRoute(ctx.path, [path])) return method;
345
329
  return null;
346
330
  };
347
331
  //#endregion
332
+ //#region src/events/core/oauth-callback-user.ts
333
+ const OAUTH_CALLBACK_USER = Symbol.for("dash.oauthCallbackUser");
334
+ /**
335
+ * Stash OAuth profile on the request context when better-auth calls getUserInfo
336
+ * during the callback, before the authorization code is consumed.
337
+ */
338
+ function instrumentSocialProviders(providers) {
339
+ for (const provider of providers) {
340
+ const originalGetUserInfo = provider.getUserInfo.bind(provider);
341
+ provider.getUserInfo = async (token) => {
342
+ const result = await originalGetUserInfo(token);
343
+ const user = result?.user;
344
+ if (user) try {
345
+ const endpointCtx = await getCurrentAuthContext();
346
+ endpointCtx.context[OAUTH_CALLBACK_USER] = user;
347
+ } catch {}
348
+ return result;
349
+ };
350
+ }
351
+ }
352
+ async function resolveOAuthUser(providerId, ctx) {
353
+ const oauthUser = ctx.context[OAUTH_CALLBACK_USER];
354
+ if (!oauthUser) return null;
355
+ const email = oauthUser.email?.toLowerCase();
356
+ const accountId = oauthUser.id !== void 0 && oauthUser.id !== null ? String(oauthUser.id) : void 0;
357
+ if (email && accountId) try {
358
+ const result = await ctx.context.internalAdapter.findOAuthUser(email, accountId, providerId);
359
+ if (result?.user) return {
360
+ id: result.user.id,
361
+ email: result.user.email,
362
+ name: result.user.name
363
+ };
364
+ } catch (error) {
365
+ logger.debug("[Dash] Failed to find OAuth user:", error);
366
+ }
367
+ if (email) {
368
+ const user = await getUserByEmail(email, ctx);
369
+ if (user) return user;
370
+ }
371
+ if (!email && !oauthUser.name) return null;
372
+ return {
373
+ id: UNKNOWN_USER,
374
+ email: oauthUser.email ?? null,
375
+ name: oauthUser.name ?? "unknown"
376
+ };
377
+ }
378
+ //#endregion
348
379
  //#region src/events/core/events-session.ts
349
380
  const initSessionEvents = (tracker) => {
350
381
  const { trackEvent } = tracker;
@@ -560,7 +591,7 @@ const initSessionEvents = (tracker) => {
560
591
  eventDisplayName: "User sign-in attempt failed",
561
592
  eventData: {
562
593
  userId: user?.id ?? "unknown",
563
- nameName: user?.name ?? "unknown",
594
+ userName: user?.name ?? "unknown",
564
595
  userEmail: ctx.body.email,
565
596
  loginMethod: getLoginMethod(ctx),
566
597
  triggeredBy: user?.id ?? trigger.triggeredBy,
@@ -574,19 +605,19 @@ const initSessionEvents = (tracker) => {
574
605
  };
575
606
  ctx.context.runInBackground(track());
576
607
  };
577
- const trackSocialSignInAttempt = (ctx, trigger, location) => {
608
+ const trackSocialSignInFailure = (ctx, providerId, trigger, location) => {
578
609
  const track = async () => {
579
- const user = await getUserByIdToken(ctx.body.provider, ctx.body.idToken, ctx);
610
+ const user = await resolveOAuthUser(providerId, ctx);
580
611
  trackEvent({
581
- eventKey: user?.user.id.toString() ?? "unknown",
612
+ eventKey: user?.id ?? "unknown",
582
613
  eventType: EVENT_TYPES.USER_SIGN_IN_FAILED,
583
614
  eventDisplayName: "User sign-in attempt failed",
584
615
  eventData: {
585
- userId: user?.user.id.toString() ?? "unknown",
586
- userName: user?.user.name ?? "unknown",
587
- userEmail: user?.user.email ?? "unknown",
616
+ userId: user?.id ?? "unknown",
617
+ userName: user?.name ?? "unknown",
618
+ userEmail: user?.email ?? "unknown",
588
619
  loginMethod: getLoginMethod(ctx),
589
- triggeredBy: user?.user.id ?? trigger.triggeredBy,
620
+ triggeredBy: user && user.id !== "unknown" ? user.id : trigger.triggeredBy,
590
621
  triggerContext: trigger.triggerContext
591
622
  },
592
623
  ipAddress: location?.ipAddress,
@@ -597,28 +628,13 @@ const initSessionEvents = (tracker) => {
597
628
  };
598
629
  ctx.context.runInBackground(track());
599
630
  };
631
+ const trackSocialSignInAttempt = (ctx, trigger, location) => {
632
+ trackSocialSignInFailure(ctx, ctx.body.provider, trigger, location);
633
+ };
600
634
  const trackSocialSignInRedirectionAttempt = (ctx, trigger, location) => {
601
- const track = async () => {
602
- const user = await getUserByAuthorizationCode(tryDecode(ctx.params?.id), ctx);
603
- trackEvent({
604
- eventKey: user?.id.toString() ?? "unknown",
605
- eventType: EVENT_TYPES.USER_SIGN_IN_FAILED,
606
- eventDisplayName: "User sign-in attempt failed",
607
- eventData: {
608
- userId: user?.id.toString() ?? "unknown",
609
- userName: user?.name ?? "unknown",
610
- userEmail: user?.id ?? "unknown",
611
- loginMethod: getLoginMethod(ctx),
612
- triggeredBy: trigger.triggeredBy,
613
- triggerContext: trigger.triggerContext
614
- },
615
- ipAddress: location?.ipAddress,
616
- city: location?.city,
617
- country: location?.country,
618
- countryCode: location?.countryCode
619
- });
620
- };
621
- ctx.context.runInBackground(track());
635
+ const providerId = getOAuthCallbackProviderId(ctx);
636
+ if (!providerId) return;
637
+ trackSocialSignInFailure(ctx, tryDecode(providerId), trigger, location);
622
638
  };
623
639
  return {
624
640
  trackUserSignedIn,
@@ -888,6 +904,27 @@ const initTrackEvents = ($api) => {
888
904
  return { tracker: { trackEvent } };
889
905
  };
890
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
891
928
  //#region src/identification.ts
892
929
  /**
893
930
  * Identification Service
@@ -908,6 +945,20 @@ function cleanupCache() {
908
945
  function maybeCleanup() {
909
946
  if (Date.now() - lastCleanup > CACHE_TTL_MS || identificationCache.size > CACHE_MAX_SIZE) cleanupCache();
910
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
+ }
911
962
  /**
912
963
  * Fetch identification data from durable-kv by requestId
913
964
  */
@@ -915,14 +966,11 @@ async function getIdentification(requestId, $kv) {
915
966
  maybeCleanup();
916
967
  const cached = identificationCache.get(requestId);
917
968
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.data;
918
- const maxRetries = 3;
919
- const retryDelays = [
920
- 50,
921
- 100,
922
- 200
923
- ];
924
- for (let attempt = 0; attempt <= maxRetries; attempt++) try {
925
- 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
+ });
926
974
  if (data && !error) {
927
975
  identificationCache.set(requestId, {
928
976
  data,
@@ -930,24 +978,18 @@ async function getIdentification(requestId, $kv) {
930
978
  });
931
979
  return data;
932
980
  }
933
- const status = error?.status;
934
- if (status === 404 && attempt < maxRetries) {
935
- await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
936
- continue;
937
- }
938
- if (status !== 404) identificationCache.set(requestId, {
981
+ if (error?.status !== 404) identificationCache.set(requestId, {
939
982
  data: null,
940
983
  timestamp: Date.now()
941
984
  });
942
985
  return null;
943
986
  } catch (error) {
944
- if (attempt === maxRetries) {
987
+ if (networkAttempt >= IDENTIFY_GET_RETRY.attempts) {
945
988
  logger.error("[Dash] Failed to fetch identification:", error);
946
989
  return null;
947
990
  }
948
- await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt] || 50));
991
+ await new Promise((resolve) => setTimeout(resolve, identifyGetRetryDelay(networkAttempt)));
949
992
  }
950
- return null;
951
993
  }
952
994
  /**
953
995
  * Extract identification headers from a request
@@ -1004,23 +1046,22 @@ function createIdentificationMiddleware($kv) {
1004
1046
  ctx.context.visitorId = visitorId;
1005
1047
  const ipConfig = ctx.context.options?.advanced?.ipAddress;
1006
1048
  let location;
1049
+ const requestIp = ctx.request ? resolveClientIpFromHeaders(ctx.request.headers, ipConfig?.ipAddressHeaders || null) : void 0;
1050
+ const requestCountryCode = getCountryCodeFromRequest(ctx.request);
1007
1051
  if (ipConfig?.disableIpTracking === true) location = void 0;
1008
1052
  else if (requestId && identification) {
1009
1053
  const loc = getLocation(identification);
1010
1054
  location = {
1011
- ipAddress: identification.ip || void 0,
1055
+ ipAddress: identification.ip || requestIp || void 0,
1012
1056
  city: loc?.city || void 0,
1013
1057
  country: loc?.country?.name || void 0,
1014
- countryCode: loc?.country?.code || void 0
1015
- };
1016
- } else {
1017
- const ipAddress = getClientIpFromRequest(ctx.request, ipConfig?.ipAddressHeaders || null);
1018
- if (ipAddress) location = {
1019
- ipAddress,
1020
- countryCode: getCountryCodeFromRequest(ctx.request)
1058
+ countryCode: loc?.country?.code || requestCountryCode || void 0
1021
1059
  };
1022
- else location = void 0;
1023
- }
1060
+ } else if (requestIp) location = {
1061
+ ipAddress: requestIp,
1062
+ countryCode: requestCountryCode
1063
+ };
1064
+ else location = void 0;
1024
1065
  ctx.context.location = location;
1025
1066
  ctx.context.ip = resolveSecurityIp(identification, location);
1026
1067
  ctx.context.untrustedVisitorId = resolveUntrustedVisitorId(visitorId, ctx.context.ip, headerVisitorId);
@@ -1033,21 +1074,6 @@ function getLocation(identification) {
1033
1074
  if (!identification) return null;
1034
1075
  return identification.location;
1035
1076
  }
1036
- function getClientIpFromRequest(request, ipAddressHeaders) {
1037
- if (!request) return void 0;
1038
- const headers = ipAddressHeaders?.length ? ipAddressHeaders : [
1039
- "cf-connecting-ip",
1040
- "x-forwarded-for",
1041
- "x-real-ip",
1042
- "x-vercel-forwarded-for"
1043
- ];
1044
- for (const headerName of headers) {
1045
- const value = request.headers.get(headerName);
1046
- if (!value) continue;
1047
- const ip = value.split(",")[0]?.trim();
1048
- if (ip) return ip;
1049
- }
1050
- }
1051
1077
  function getCountryCodeFromRequest(request) {
1052
1078
  if (!request) return void 0;
1053
1079
  const cc = request.headers.get("cf-ipcountry") ?? request.headers.get("x-vercel-ip-country");
@@ -1092,9 +1118,12 @@ function emitEvaluation(ctx, trackEvent, options = {}) {
1092
1118
  ev.emitted = true;
1093
1119
  const identification = ctx.context.identification ?? null;
1094
1120
  const visitorId = ctx.context.visitorId ?? null;
1121
+ const untrustedVisitorId = ctx.context.untrustedVisitorId ?? null;
1122
+ const location = ctx.context.location;
1095
1123
  const path = ctx.path ?? "";
1124
+ const ipAddress = location?.ipAddress || identification?.ip || ctx.context.ip || void 0;
1096
1125
  trackEvent({
1097
- eventKey: visitorId || identification?.ip || "unknown",
1126
+ eventKey: visitorId || ipAddress || untrustedVisitorId || "unknown",
1098
1127
  eventType: "security_check",
1099
1128
  eventDisplayName: ev.outcome === "blocked" ? "Security: blocked" : ev.outcome === "challenged" ? "Security: challenged" : "Security: passed",
1100
1129
  eventData: {
@@ -1107,19 +1136,14 @@ function emitEvaluation(ctx, trackEvent, options = {}) {
1107
1136
  userAgent: options.userAgent,
1108
1137
  details: ev.details
1109
1138
  },
1110
- ipAddress: identification?.ip || void 0,
1111
- city: identification?.location?.city || void 0,
1112
- country: identification?.location?.country?.name || void 0,
1113
- countryCode: identification?.location?.country?.code || void 0
1139
+ ipAddress,
1140
+ city: location?.city || identification?.location?.city || void 0,
1141
+ country: location?.country || identification?.location?.country?.name || void 0,
1142
+ countryCode: location?.countryCode || identification?.location?.country?.code || void 0
1114
1143
  });
1115
1144
  }
1116
1145
  //#endregion
1117
1146
  //#region src/sentinel/security.ts
1118
- async function hashForFingerprint(input) {
1119
- const data = new TextEncoder().encode(input);
1120
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1121
- return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1122
- }
1123
1147
  async function sha1Hash(input) {
1124
1148
  const data = new TextEncoder().encode(input);
1125
1149
  const hashBuffer = await crypto.subtle.digest("SHA-1", data);
@@ -1184,15 +1208,20 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1184
1208
  default: return "credential_stuffing";
1185
1209
  }
1186
1210
  },
1187
- async trackFailedAttempt(identifier, visitorId, password, ip) {
1211
+ async trackFailedAttempt(identifier, visitorId, password, ip, requestId = null) {
1188
1212
  try {
1213
+ if (!conn.apiKey) {
1214
+ logger.warn("[Sentinel] Missing apiKey; failed-login password fingerprint is skipped.");
1215
+ return { blocked: false };
1216
+ }
1189
1217
  const data = await $api("/security/track-failed-login", {
1190
1218
  method: "POST",
1191
1219
  body: {
1192
1220
  identifier,
1193
1221
  visitorId,
1194
- passwordHash: await hashForFingerprint(password),
1222
+ passwordHash: await hmacSha256Hex(conn.apiKey, password),
1195
1223
  ip,
1224
+ requestId,
1196
1225
  config: options
1197
1226
  }
1198
1227
  });
@@ -1221,23 +1250,25 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1221
1250
  logger.error("[Dash] Clear failed attempts error:", error);
1222
1251
  }
1223
1252
  },
1224
- async isBlocked(visitorId, ip) {
1253
+ async isBlocked(visitorId, ip, requestId = null) {
1225
1254
  try {
1226
1255
  const params = new URLSearchParams({ visitorId });
1227
1256
  if (ip) params.set("ip", ip);
1257
+ if (requestId) params.set("requestId", requestId);
1228
1258
  return (await $api(`/security/is-blocked?${params.toString()}`, { method: "GET" })).blocked ?? false;
1229
1259
  } catch (error) {
1230
1260
  logger.warn("[Dash] Security is-blocked check failed:", error);
1231
1261
  return false;
1232
1262
  }
1233
1263
  },
1234
- async verifyPoWSolution(visitorId, solution) {
1264
+ async verifyPoWSolution(visitorId, solution, requestId = null) {
1235
1265
  try {
1236
1266
  return await $api("/security/pow/verify", {
1237
1267
  method: "POST",
1238
1268
  body: {
1239
1269
  visitorId,
1240
- solution
1270
+ solution,
1271
+ requestId
1241
1272
  }
1242
1273
  });
1243
1274
  } catch (error) {
@@ -1248,12 +1279,13 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1248
1279
  };
1249
1280
  }
1250
1281
  },
1251
- async generateChallenge(visitorId) {
1282
+ async generateChallenge(visitorId, requestId = null) {
1252
1283
  try {
1253
1284
  return (await $api("/security/pow/generate", {
1254
1285
  method: "POST",
1255
1286
  body: {
1256
1287
  visitorId,
1288
+ requestId,
1257
1289
  difficulty: options.challengeDifficulty
1258
1290
  }
1259
1291
  })).challenge || "";
@@ -1262,7 +1294,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1262
1294
  return "";
1263
1295
  }
1264
1296
  },
1265
- async checkImpossibleTravel(userId, currentLocation, visitorId, ip = null, powSolution) {
1297
+ async checkImpossibleTravel(userId, currentLocation, visitorId, ip = null, powSolution, requestId = null) {
1266
1298
  if (!options.impossibleTravel?.enabled || !currentLocation) return null;
1267
1299
  try {
1268
1300
  const data = await $api("/security/impossible-travel", {
@@ -1270,6 +1302,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1270
1302
  body: {
1271
1303
  userId,
1272
1304
  visitorId,
1305
+ requestId,
1273
1306
  location: currentLocation,
1274
1307
  ip,
1275
1308
  config: options,
@@ -1319,20 +1352,23 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1319
1352
  }
1320
1353
  },
1321
1354
  /**
1322
- * Check if a visitor has exceeded the free trial signup limit.
1355
+ * Atomically reserve a signup slot before user creation.
1323
1356
  */
1324
- async checkFreeTrialAbuse(visitorId) {
1357
+ async reserveFreeTrialSignup(visitorId, reservationId, requestId = null) {
1325
1358
  if (!options.freeTrialAbuse?.enabled) return {
1359
+ reserved: true,
1326
1360
  isAbuse: false,
1327
1361
  accountCount: 0,
1328
1362
  maxAccounts: 0,
1329
1363
  action: "log"
1330
1364
  };
1331
1365
  try {
1332
- const data = await $api("/security/free-trial-abuse/check", {
1366
+ const data = await $api("/security/free-trial-abuse/reserve", {
1333
1367
  method: "POST",
1334
1368
  body: {
1335
1369
  visitorId,
1370
+ reservationId,
1371
+ requestId,
1336
1372
  config: options
1337
1373
  }
1338
1374
  });
@@ -1350,8 +1386,9 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1350
1386
  });
1351
1387
  return data;
1352
1388
  } catch (error) {
1353
- logger.warn("[Dash] Free trial abuse check failed:", error);
1389
+ logger.warn("[Dash] Free trial abuse reserve failed:", error);
1354
1390
  return {
1391
+ reserved: true,
1355
1392
  isAbuse: false,
1356
1393
  accountCount: 0,
1357
1394
  maxAccounts: 0,
@@ -1360,21 +1397,22 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1360
1397
  }
1361
1398
  },
1362
1399
  /**
1363
- * Track a new signup for free trial abuse detection.
1364
- * Stores the userId for auditing purposes.
1400
+ * Confirm a reserved signup slot with the created user id.
1365
1401
  */
1366
- async trackFreeTrialSignup(visitorId, userId) {
1402
+ async confirmFreeTrialSignup(visitorId, reservationId, userId, requestId = null) {
1367
1403
  if (!options.freeTrialAbuse?.enabled) return;
1368
1404
  try {
1369
- await $api("/security/free-trial-abuse/track", {
1405
+ await $api("/security/free-trial-abuse/confirm", {
1370
1406
  method: "POST",
1371
1407
  body: {
1372
1408
  visitorId,
1373
- userId
1409
+ reservationId,
1410
+ userId,
1411
+ requestId
1374
1412
  }
1375
1413
  });
1376
1414
  } catch (error) {
1377
- logger.error("[Dash] Track free trial signup error:", error);
1415
+ logger.error("[Dash] Confirm free trial signup error:", error);
1378
1416
  }
1379
1417
  },
1380
1418
  async checkCompromisedPassword(password) {
@@ -2084,7 +2122,7 @@ async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, sec
2084
2122
  if (verdict.action === "allow") return;
2085
2123
  const reason = verdict.reason || "unknown";
2086
2124
  if (verdict.action === "challenge" && checkCtx.visitorId) {
2087
- const challenge = verdict.challenge || await securityService.generateChallenge(checkCtx.visitorId);
2125
+ const challenge = verdict.challenge || await securityService.generateChallenge(checkCtx.visitorId, checkCtx.identification?.requestId ?? null);
2088
2126
  if (!challenge?.trim()) {
2089
2127
  logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
2090
2128
  return;
@@ -2119,7 +2157,7 @@ async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent,
2119
2157
  recordCheck(hookCtx, "server_security_check");
2120
2158
  await handleSecurityVerdict(await securityService.checkSecurity({
2121
2159
  visitorId: checkCtx.visitorId,
2122
- requestId: checkCtx.identification?.requestId || null,
2160
+ requestId: checkCtx.identification?.requestId ?? checkCtx.requestId ?? null,
2123
2161
  ip: checkCtx.ip,
2124
2162
  path: checkCtx.path,
2125
2163
  identifier: checkCtx.identifier,
@@ -2174,9 +2212,17 @@ const sentinel = (options) => {
2174
2212
  async before(user, ctx) {
2175
2213
  if (!ctx) return;
2176
2214
  const visitorId = ctx.context.visitorId;
2177
- 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
+ }
2178
2222
  recordCheck(ctx, "free_trial_abuse");
2179
- 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);
2180
2226
  if (abuseCheck.isAbuse && abuseCheck.action === "block") {
2181
2227
  setOutcome(ctx, "blocked", "free_trial_abuse", {
2182
2228
  accountCount: abuseCheck.accountCount,
@@ -2194,7 +2240,8 @@ const sentinel = (options) => {
2194
2240
  async after(user, ctx) {
2195
2241
  if (!ctx) return;
2196
2242
  const visitorId = ctx.context.visitorId;
2197
- 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));
2198
2245
  }
2199
2246
  },
2200
2247
  update: { async before(user, ctx) {
@@ -2213,7 +2260,7 @@ const sentinel = (options) => {
2213
2260
  if (session.userId && identification?.location && visitorId) {
2214
2261
  recordCheck(ctx, "impossible_travel");
2215
2262
  const powSolution = ctx.headers?.get?.("X-PoW-Solution") ?? null;
2216
- 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);
2217
2264
  if (travelCheck?.powVerified) recordCheck(ctx, "pow_challenge");
2218
2265
  if (travelCheck?.isImpossible) {
2219
2266
  if (travelCheck.action === "block") {
@@ -2324,7 +2371,11 @@ const sentinel = (options) => {
2324
2371
  routes.SIGN_IN_ANONYMOUS
2325
2372
  ]);
2326
2373
  const isSignUp = matchesAnyRoute(ctx.path, [routes.SIGN_UP_EMAIL]);
2327
- 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
+ ]);
2328
2379
  const isTwoFactor = matchesAnyRoute(ctx.path, [
2329
2380
  routes.TWO_FACTOR_VERIFY_TOTP,
2330
2381
  routes.TWO_FACTOR_VERIFY_BACKUP,
@@ -2346,7 +2397,7 @@ const sentinel = (options) => {
2346
2397
  const powSolution = ctx.headers?.get?.("X-PoW-Solution");
2347
2398
  if (visitorId || ip) {
2348
2399
  recordCheck(ctx, "credential_stuffing");
2349
- if (await securityService.isBlocked(visitorId ?? "", ip)) {
2400
+ if (await securityService.isBlocked(visitorId ?? "", ip, ctx.context.requestId ?? null)) {
2350
2401
  setOutcome(ctx, "blocked", "credential_stuffing", {
2351
2402
  phase: "cooldown_active",
2352
2403
  visitorId: ctx.context.untrustedVisitorId ?? void 0,
@@ -2363,6 +2414,7 @@ const sentinel = (options) => {
2363
2414
  path: ctx.path,
2364
2415
  identifier,
2365
2416
  visitorId,
2417
+ requestId: ctx.context.requestId ?? null,
2366
2418
  identification,
2367
2419
  ip: ctx.context.ip,
2368
2420
  userAgent: ctx.headers?.get?.("user-agent") || ""
@@ -2412,7 +2464,7 @@ const sentinel = (options) => {
2412
2464
  identifier: loginId,
2413
2465
  userAgent: ctx.headers?.get?.("user-agent") || ""
2414
2466
  });
2415
- 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));
2416
2468
  if (isPasswordSignInRoute && !(ctx.context.returned instanceof Error) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
2417
2469
  })
2418
2470
  }]
@@ -2774,11 +2826,133 @@ const initTeamEvents = (tracker) => {
2774
2826
  };
2775
2827
  };
2776
2828
  //#endregion
2777
- //#region src/jwt.ts
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
2952
+ //#region ../utils/dist/crypto/index.mjs
2778
2953
  /**
2779
- * Hash the given value
2780
- * Note: Must match @infra/utils/crypto hash()
2781
- * @param value - The value to hash
2954
+ * One-way hash for storage and lookup. Returns hex-encoded digest.
2955
+ * Use to verify values without ever storing plaintext.
2782
2956
  */
2783
2957
  async function hash(value) {
2784
2958
  const data = new TextEncoder().encode(value);
@@ -2786,6 +2960,32 @@ async function hash(value) {
2786
2960
  const hashArray = new Uint8Array(hashBuffer);
2787
2961
  return Array.from(hashArray).map((b) => b.toString(16).padStart(2, "0")).join("");
2788
2962
  }
2963
+ function hexToBytes(hex) {
2964
+ if (hex.length % 2 !== 0) return null;
2965
+ const bytes = new Uint8Array(hex.length / 2);
2966
+ for (let i = 0; i < bytes.length; i++) {
2967
+ const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
2968
+ if (Number.isNaN(byte)) return null;
2969
+ bytes[i] = byte;
2970
+ }
2971
+ return bytes;
2972
+ }
2973
+ function timingSafeEqualBytes(a, b) {
2974
+ if (a.length !== b.length) return false;
2975
+ let diff = 0;
2976
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
2977
+ return diff === 0;
2978
+ }
2979
+ /** Constant-time comparison for hex-encoded digests (e.g. SHA-256 hashes). */
2980
+ function timingSafeEqualHash(a, b) {
2981
+ if (a.length !== b.length) return false;
2982
+ const aBytes = hexToBytes(a);
2983
+ const bBytes = hexToBytes(b);
2984
+ if (!aBytes || !bBytes || aBytes.length !== bBytes.length) return false;
2985
+ return timingSafeEqualBytes(aBytes, bBytes);
2986
+ }
2987
+ //#endregion
2988
+ //#region src/jwt.ts
2789
2989
  /**
2790
2990
  * Skip JTI check for JWTs issued within this many seconds.
2791
2991
  * This is safe because replay attacks require time for interception.
@@ -2853,9 +3053,8 @@ const jwtMiddleware = (options, schema, getJWT) => {
2853
3053
  });
2854
3054
  throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2855
3055
  }
2856
- const expectedHash = await hash(options.apiKey);
2857
- if (apiKeyHash !== expectedHash) {
2858
- ctx.context.logger.warn("[Dash] API key hash is invalid", apiKeyHash, expectedHash);
3056
+ if (!timingSafeEqualHash(apiKeyHash, await hash(options.apiKey))) {
3057
+ ctx.context.logger.warn("[Dash] API key hash mismatch");
2859
3058
  throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2860
3059
  }
2861
3060
  if (!isRecentlyIssued(payload)) {
@@ -2908,7 +3107,7 @@ const jwtValidateMiddleware = (options) => {
2908
3107
  });
2909
3108
  throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2910
3109
  }
2911
- if (apiKeyHash !== await hash(options.apiKey)) {
3110
+ if (!timingSafeEqualHash(apiKeyHash, await hash(options.apiKey))) {
2912
3111
  ctx.context.logger.warn("[Dash] API key hash mismatch");
2913
3112
  throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2914
3113
  }
@@ -2916,121 +3115,20 @@ const jwtValidateMiddleware = (options) => {
2916
3115
  });
2917
3116
  };
2918
3117
  //#endregion
2919
- //#region src/version.ts
2920
- const PLUGIN_VERSION = "0.2.13";
2921
- //#endregion
2922
- //#region src/routes/auth/config.ts
3118
+ //#region src/lib/redact-plugin-options.ts
2923
3119
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
2924
- const EXACT_SENSITIVE_STRING_KEYS_LOWER = new Set([...new Set([
2925
- "accessToken",
2926
- "apiKey",
2927
- "apiSecret",
2928
- "authorization",
2929
- "authToken",
2930
- "bearerToken",
2931
- "clientSecret",
2932
- "consumerSecret",
2933
- "encPrivateKey",
2934
- "encPrivateKeyPass",
2935
- "encryptionKey",
2936
- "encryptionSecret",
2937
- "idToken",
2938
- "password",
2939
- "privateKey",
2940
- "privateKeyPass",
2941
- "refreshToken",
2942
- "secret",
2943
- "secretKey",
2944
- "signingSecret",
2945
- "stripeWebhookSecret",
2946
- "webhookSecret"
2947
- ])].map((k) => k.toLowerCase()));
2948
- /**
2949
- * Case-insensitive suffixes for unknown plugin options
2950
- * Matching is more permissive to account for unknown plugin options
2951
- */
2952
- const SENSITIVE_KEY_SUFFIXES_LOWER = [
2953
- "secret",
2954
- "password",
2955
- "passphrase",
2956
- "privatekey",
2957
- "keypass",
2958
- "apikey",
2959
- "token",
2960
- "signingkey",
2961
- "credentials",
2962
- "authheader"
2963
- ];
2964
- const REDACTED_SIMPLE_STRING = "[REDACTED]";
2965
- function snakeCaseToCamelCase(key) {
2966
- return key.replace(/_([a-zA-Z])/g, (_, ch) => ch.toUpperCase());
3120
+ function redactPluginOptions(pluginId, options, optionsConfig) {
3121
+ return redact(options, {
3122
+ ...optionsConfig,
3123
+ excludeKeys: PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId]
3124
+ });
2967
3125
  }
2968
- function keyVariantsForMatching(key) {
2969
- const trimmed = key.replace(/^[\s._-]+/, "");
2970
- if (!trimmed) return [key];
2971
- const out = new Set([key, trimmed]);
2972
- if (trimmed.includes("_")) out.add(snakeCaseToCamelCase(trimmed));
2973
- return [...out];
2974
- }
2975
- function shouldRedactSensitiveStringKey(key) {
2976
- const variants = keyVariantsForMatching(key);
2977
- const compactLower = key.replace(/[\s._-]/g, "").toLowerCase();
2978
- const forms = new Set([compactLower]);
2979
- for (const raw of variants) {
2980
- forms.add(raw);
2981
- forms.add(raw.toLowerCase());
2982
- }
2983
- for (const form of forms) {
2984
- const fl = form.toLowerCase();
2985
- if (EXACT_SENSITIVE_STRING_KEYS_LOWER.has(fl)) return true;
2986
- for (const suffix of SENSITIVE_KEY_SUFFIXES_LOWER) if (fl.endsWith(suffix)) return true;
2987
- }
2988
- return false;
2989
- }
2990
- function isPlainSerializable(value) {
2991
- if (value === null || typeof value !== "object") return true;
2992
- if (Array.isArray(value)) return true;
2993
- if (value instanceof Date) return true;
2994
- const constructor = Object.getPrototypeOf(value)?.constructor;
2995
- if (constructor && constructor.name !== "Object" && constructor.name !== "Array") return false;
2996
- return true;
2997
- }
2998
- /**
2999
- * Sanitize any plain JSON-like subtree from Better Auth options
3000
- */
3001
- function sanitizeConfig(value, visiting, excludeKeys) {
3002
- if (value === null || value === void 0) return value;
3003
- if (typeof value === "function") return void 0;
3004
- if (typeof value !== "object") return value;
3005
- const obj = value;
3006
- if (visiting.has(obj)) return void 0;
3007
- visiting.add(obj);
3008
- try {
3009
- if (Array.isArray(value)) return value.map((item) => sanitizeConfig(item, visiting, excludeKeys)).filter((item) => item !== void 0);
3010
- const result = {};
3011
- for (const [key, val] of Object.entries(value)) {
3012
- if (excludeKeys?.has(key)) continue;
3013
- if (typeof val === "function") continue;
3014
- if (typeof val === "string" && shouldRedactSensitiveStringKey(key)) {
3015
- result[key] = REDACTED_SIMPLE_STRING;
3016
- continue;
3017
- }
3018
- if (val !== null && typeof val === "object" && !isPlainSerializable(val)) continue;
3019
- const sanitized = sanitizeConfig(val, visiting, excludeKeys);
3020
- if (sanitized !== void 0) result[key] = sanitized;
3021
- }
3022
- return result;
3023
- } finally {
3024
- visiting.delete(obj);
3025
- }
3026
- }
3027
- function sanitizePluginOptions(pluginId, options) {
3028
- return sanitizeConfig(options, /* @__PURE__ */ new WeakSet(), PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId]);
3029
- }
3030
- function estimateEntropy(str) {
3031
- const unique = new Set(str).size;
3032
- if (unique === 0) return 0;
3033
- return Math.log2(Math.pow(unique, str.length));
3126
+ //#endregion
3127
+ //#region src/routes/auth/config.ts
3128
+ function estimateEntropy(str) {
3129
+ const unique = new Set(str).size;
3130
+ if (unique === 0) return 0;
3131
+ return Math.log2(Math.pow(unique, str.length));
3034
3132
  }
3035
3133
  const getConfig = (options) => {
3036
3134
  return createAuthEndpoint("/dash/config", {
@@ -3042,13 +3140,13 @@ const getConfig = (options) => {
3042
3140
  return {
3043
3141
  version: ctx.context.version || null,
3044
3142
  socialProviders: Object.keys(ctx.context.options.socialProviders || {}),
3045
- emailAndPassword: sanitizeConfig(ctx.context.options.emailAndPassword, /* @__PURE__ */ new WeakSet(), void 0),
3143
+ emailAndPassword: redact(ctx.context.options.emailAndPassword, { skipNonPlainSerializable: true }),
3046
3144
  plugins: ctx.context.options.plugins?.map((plugin) => {
3047
3145
  const base = {
3048
3146
  id: plugin.id,
3049
3147
  schema: plugin.schema,
3050
3148
  version: plugin.version,
3051
- options: sanitizePluginOptions(plugin.id, plugin.options)
3149
+ options: redactPluginOptions(plugin.id, plugin.options, { skipNonPlainSerializable: true })
3052
3150
  };
3053
3151
  if (plugin.id === "dash" && !plugin.version) return {
3054
3152
  ...base,
@@ -3170,6 +3268,31 @@ const getValidate = (options) => {
3170
3268
  });
3171
3269
  };
3172
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
3173
3296
  //#region src/routes/organization-guards.ts
3174
3297
  /** Returns true if organization plugin is enabled. */
3175
3298
  function isOrganizationEnabled(ctx) {
@@ -3231,6 +3354,11 @@ function getScimEndpoint(baseUrl) {
3231
3354
  function getSCIMPlugin(ctx) {
3232
3355
  return ctx.context.getPlugin("scim");
3233
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
+ }
3234
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.";
3235
3363
  function isDuplicateDirectorySyncError(e) {
3236
3364
  if (e instanceof APIError$1) return e.status === "CONFLICT" || /duplicate|already exists|unique constraint/i.test(e.message ?? "");
@@ -3248,7 +3376,7 @@ const listOrganizationDirectories = (options) => {
3248
3376
  }
3249
3377
  const organizationId = tryDecode(ctx.params.id);
3250
3378
  const scimPlugin = getSCIMPlugin(ctx);
3251
- if (!scimPlugin?.endpoints.listSCIMProviderConnections || !scimPlugin?.options?.providerOwnership?.enabled) {
3379
+ if (!scimPlugin?.endpoints.listSCIMProviderConnections || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) {
3252
3380
  ctx.context.logger.warn("[Dash] SCIM plugin not available or provider ownership disabled, returning empty directories list", { organizationId });
3253
3381
  return [];
3254
3382
  }
@@ -3285,7 +3413,7 @@ const createOrganizationDirectory = (options) => {
3285
3413
  requireOrganizationPlugin(ctx);
3286
3414
  const { organizationId } = ctx.context.payload;
3287
3415
  const scimPlugin = getSCIMPlugin(ctx);
3288
- 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" });
3289
3417
  const { providerId, ownerUserId } = ctx.body;
3290
3418
  let scimToken;
3291
3419
  try {
@@ -3320,7 +3448,7 @@ const deleteOrganizationDirectory = (options) => {
3320
3448
  }, async (ctx) => {
3321
3449
  requireOrganizationPlugin(ctx);
3322
3450
  const scimPlugin = getSCIMPlugin(ctx);
3323
- 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" });
3324
3452
  const { organizationId } = ctx.context.payload;
3325
3453
  const { providerId } = ctx.body;
3326
3454
  const ownerUserId = await getScimProviderOwner(ctx, organizationId, providerId);
@@ -3341,7 +3469,7 @@ const regenerateDirectoryToken = (options) => {
3341
3469
  }, async (ctx) => {
3342
3470
  requireOrganizationPlugin(ctx);
3343
3471
  const scimPlugin = getSCIMPlugin(ctx);
3344
- 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" });
3345
3473
  const { organizationId } = ctx.context.payload;
3346
3474
  const { providerId } = ctx.body;
3347
3475
  const ownerUserId = await getScimProviderOwner(ctx, organizationId, providerId);
@@ -3758,6 +3886,36 @@ const executeAdapter = (options) => {
3758
3886
  });
3759
3887
  };
3760
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
3761
3919
  //#region src/routes/invitations/index.ts
3762
3920
  async function verifyPendingInvitation(token, $api, ctx) {
3763
3921
  const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
@@ -3784,6 +3942,9 @@ async function verifyPendingInvitation(token, $api, ctx) {
3784
3942
  function getFallbackRedirect(ctx) {
3785
3943
  return typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
3786
3944
  }
3945
+ function resolveInvitationRedirect(ctx, raw) {
3946
+ return resolveSafeRedirectUrl(raw, getFallbackRedirect(ctx));
3947
+ }
3787
3948
  /**
3788
3949
  * Accept invitation endpoint
3789
3950
  * This is called when a user clicks the invitation link.
@@ -3808,14 +3969,14 @@ const acceptInvitation = (options) => {
3808
3969
  }
3809
3970
  });
3810
3971
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted (existing user)", markError);
3811
- await setSessionCookie(ctx, {
3972
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3812
3973
  session: await ctx.context.internalAdapter.createSession(existingUser.id),
3813
3974
  user: existingUser
3814
3975
  });
3815
- const redirectUrl = invitation.redirectUrl || ctx.context.options.baseURL || "/";
3816
- return ctx.redirect(redirectUrl.toString());
3976
+ const redirectUrl = resolveInvitationRedirect(ctx, invitation.redirectUrl);
3977
+ return ctx.redirect(redirectUrl);
3817
3978
  }
3818
- if (invitation.authMode === "auth") {
3979
+ if (usesPlatformInviteAcceptFlow(invitation.authMode)) {
3819
3980
  const platformUrl = options.apiUrl || INFRA_API_URL;
3820
3981
  const acceptPageUrl = new URL("/invite/accept", platformUrl);
3821
3982
  acceptPageUrl.searchParams.set("token", token);
@@ -3823,13 +3984,15 @@ const acceptInvitation = (options) => {
3823
3984
  acceptPageUrl.searchParams.set("callback", callbackUrl);
3824
3985
  return ctx.redirect(acceptPageUrl.toString());
3825
3986
  }
3826
- const user = await ctx.context.internalAdapter.createUser({
3987
+ const userPayload = {
3827
3988
  email: invitationEmail,
3828
3989
  name: invitation.name || invitation.email.split("@")[0] || "",
3829
3990
  emailVerified: true,
3830
3991
  createdAt: /* @__PURE__ */ new Date(),
3831
3992
  updatedAt: /* @__PURE__ */ new Date()
3832
- });
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);
3833
3996
  const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
3834
3997
  method: "POST",
3835
3998
  body: {
@@ -3838,12 +4001,12 @@ const acceptInvitation = (options) => {
3838
4001
  }
3839
4002
  });
3840
4003
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3841
- await setSessionCookie(ctx, {
4004
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3842
4005
  session: await ctx.context.internalAdapter.createSession(user.id),
3843
4006
  user
3844
4007
  });
3845
- const redirectUrl = invitation.redirectUrl || ctx.context.options.baseURL || "/";
3846
- return ctx.redirect(redirectUrl.toString());
4008
+ const redirectUrl = resolveInvitationRedirect(ctx, invitation.redirectUrl);
4009
+ return ctx.redirect(redirectUrl);
3847
4010
  });
3848
4011
  };
3849
4012
  /**
@@ -3861,7 +4024,6 @@ const completeInvitation = (options) => {
3861
4024
  })
3862
4025
  }, async (ctx) => {
3863
4026
  const { token, password } = ctx.body;
3864
- const fallbackRedirect = getFallbackRedirect(ctx);
3865
4027
  const invitation = await verifyPendingInvitation(token, $api, ctx);
3866
4028
  if (!ctx.context) throw new APIError("BAD_REQUEST", { message: "Context is required" });
3867
4029
  const invitationEmail = normalizeEmail(invitation.email, ctx.context);
@@ -3875,23 +4037,25 @@ const completeInvitation = (options) => {
3875
4037
  }
3876
4038
  });
3877
4039
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3878
- await setSessionCookie(ctx, {
4040
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3879
4041
  session: await ctx.context.internalAdapter.createSession(existingUser.id),
3880
4042
  user: existingUser
3881
4043
  });
3882
4044
  return {
3883
4045
  success: true,
3884
- redirectUrl: invitation.redirectUrl ?? fallbackRedirect
4046
+ redirectUrl: resolveInvitationRedirect(ctx, invitation.redirectUrl)
3885
4047
  };
3886
4048
  }
3887
4049
  if (!password) throw new APIError("BAD_REQUEST", { message: "Password is required to complete this invitation." });
3888
- const user = await ctx.context.internalAdapter.createUser({
4050
+ const userPayload = {
3889
4051
  email: invitationEmail,
3890
4052
  name: invitation.name || invitation.email.split("@")[0] || "",
3891
4053
  emailVerified: true,
3892
4054
  createdAt: /* @__PURE__ */ new Date(),
3893
4055
  updatedAt: /* @__PURE__ */ new Date()
3894
- });
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);
3895
4059
  await ctx.context.internalAdapter.createAccount({
3896
4060
  userId: user.id,
3897
4061
  providerId: "credential",
@@ -3906,13 +4070,13 @@ const completeInvitation = (options) => {
3906
4070
  }
3907
4071
  });
3908
4072
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3909
- await setSessionCookie(ctx, {
4073
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
3910
4074
  session: await ctx.context.internalAdapter.createSession(user.id),
3911
4075
  user
3912
4076
  });
3913
4077
  return {
3914
4078
  success: true,
3915
- redirectUrl: invitation.redirectUrl ?? fallbackRedirect
4079
+ redirectUrl: resolveInvitationRedirect(ctx, invitation.redirectUrl)
3916
4080
  };
3917
4081
  });
3918
4082
  };
@@ -3937,7 +4101,7 @@ const completeInvitationSocial = (options) => {
3937
4101
  }
3938
4102
  });
3939
4103
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3940
- return ctx.redirect((invitation.redirectUrl || getFallbackRedirect(ctx)).toString());
4104
+ return ctx.redirect(resolveInvitationRedirect(ctx, invitation.redirectUrl));
3941
4105
  });
3942
4106
  };
3943
4107
  /**
@@ -4119,11 +4283,15 @@ const cancelInvitation = (options) => {
4119
4283
  }, async (ctx) => {
4120
4284
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
4121
4285
  const { invitationId, organizationId } = ctx.context.payload;
4286
+ if (ctx.body.invitationId !== invitationId) throw ctx.error("BAD_REQUEST", { message: "Invitation ID mismatch" });
4122
4287
  const invitation = await ctx.context.adapter.findOne({
4123
4288
  model: "invitation",
4124
4289
  where: [{
4125
4290
  field: "id",
4126
4291
  value: invitationId
4292
+ }, {
4293
+ field: "organizationId",
4294
+ value: organizationId
4127
4295
  }]
4128
4296
  });
4129
4297
  if (!invitation) throw ctx.error("NOT_FOUND", { message: "Invitation not found" });
@@ -4152,6 +4320,9 @@ const cancelInvitation = (options) => {
4152
4320
  where: [{
4153
4321
  field: "id",
4154
4322
  value: invitationId
4323
+ }, {
4324
+ field: "organizationId",
4325
+ value: organizationId
4155
4326
  }],
4156
4327
  update: { status: "canceled" }
4157
4328
  });
@@ -4177,11 +4348,15 @@ const resendInvitation = (options) => {
4177
4348
  }, async (ctx) => {
4178
4349
  const organizationPlugin = requireOrganizationPlugin(ctx);
4179
4350
  const { invitationId, organizationId } = ctx.context.payload;
4351
+ if (ctx.body.invitationId !== invitationId) throw ctx.error("BAD_REQUEST", { message: "Invitation ID mismatch" });
4180
4352
  const invitation = await ctx.context.adapter.findOne({
4181
4353
  model: "invitation",
4182
4354
  where: [{
4183
4355
  field: "id",
4184
4356
  value: invitationId
4357
+ }, {
4358
+ field: "organizationId",
4359
+ value: organizationId
4185
4360
  }]
4186
4361
  });
4187
4362
  if (!invitation) throw ctx.error("NOT_FOUND", { message: "Invitation not found" });
@@ -4581,6 +4756,8 @@ async function withConcurrency(items, fn, options) {
4581
4756
  if (executing.length > 0) await run();
4582
4757
  return results;
4583
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");
4584
4761
  //#endregion
4585
4762
  //#region src/routes/organizations/schemas.ts
4586
4763
  const DENIED_ORG_WRITE_KEYS = new Set([
@@ -4589,13 +4766,13 @@ const DENIED_ORG_WRITE_KEYS = new Set([
4589
4766
  "updatedAt"
4590
4767
  ]);
4591
4768
  const CREATE_NON_PERSISTED_KEYS = new Set(["defaultTeamName"]);
4592
- const CREATE_REQUEST_ONLY_KEYS = new Set(["userId", "skipDefaultTeam"]);
4593
- const CORE_CREATE_KEYS = [
4769
+ const CREATE_REQUEST_ONLY_KEYS$1 = new Set(["userId", "skipDefaultTeam"]);
4770
+ const CORE_CREATE_KEYS$1 = [
4594
4771
  "name",
4595
4772
  "slug",
4596
4773
  "logo"
4597
4774
  ];
4598
- const CORE_UPDATE_KEYS = [
4775
+ const CORE_UPDATE_KEYS$1 = [
4599
4776
  "name",
4600
4777
  "slug",
4601
4778
  "logo",
@@ -4611,7 +4788,7 @@ function getWritableOrganizationAdditionalFieldNames(orgOptions) {
4611
4788
  }).map(([name]) => name);
4612
4789
  }
4613
4790
  function getWritableOrganizationFieldNames(orgOptions, mode) {
4614
- 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);
4615
4792
  for (const name of getWritableOrganizationAdditionalFieldNames(orgOptions)) keys.add(name);
4616
4793
  return keys;
4617
4794
  }
@@ -4624,7 +4801,7 @@ function pickWritableOrganizationFields(body, orgOptions, mode) {
4624
4801
  const skip = new Set([
4625
4802
  ...DENIED_ORG_WRITE_KEYS,
4626
4803
  ...mode === "create" ? CREATE_NON_PERSISTED_KEYS : [],
4627
- ...mode === "create" ? CREATE_REQUEST_ONLY_KEYS : []
4804
+ ...mode === "create" ? CREATE_REQUEST_ONLY_KEYS$1 : []
4628
4805
  ]);
4629
4806
  const result = {};
4630
4807
  for (const [key, value] of Object.entries(body)) {
@@ -4635,19 +4812,19 @@ function pickWritableOrganizationFields(body, orgOptions, mode) {
4635
4812
  }
4636
4813
  const BaseCreateOrgCoreBodySchema = z$1.object({
4637
4814
  name: z$1.string(),
4638
- slug: z$1.string(),
4815
+ slug: slugSchema,
4639
4816
  logo: z$1.string().optional(),
4640
4817
  defaultTeamName: z$1.string().optional()
4641
4818
  });
4642
4819
  const BaseUpdateOrgCoreBodySchema = z$1.object({
4643
- logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4820
+ logo: optionalSafeUrlSchema,
4644
4821
  name: z$1.string().optional(),
4645
- slug: z$1.string().optional(),
4822
+ slug: slugSchema.optional(),
4646
4823
  metadata: z$1.string().optional()
4647
4824
  });
4648
4825
  const CreateOrganizationBodySchema = BaseCreateOrgCoreBodySchema.catchall(z$1.unknown());
4649
4826
  const UpdateOrganizationBodySchema = BaseUpdateOrgCoreBodySchema.catchall(z$1.unknown());
4650
- function createSchemaForDBField(field) {
4827
+ function createSchemaForDBField$1(field) {
4651
4828
  switch (field.type) {
4652
4829
  case "number": return field.required ? z$1.coerce.number() : z$1.coerce.number().optional();
4653
4830
  case "boolean": return field.required ? z$1.coerce.boolean() : z$1.coerce.boolean().optional();
@@ -4662,13 +4839,13 @@ function createSchemaForDBField(field) {
4662
4839
  function validateWritableCreateOrganizationFields(data, options) {
4663
4840
  const shape = {
4664
4841
  name: z$1.string().min(1),
4665
- slug: z$1.string().min(1),
4842
+ slug: slugSchema,
4666
4843
  logo: z$1.string().optional()
4667
4844
  };
4668
4845
  const additional = getOrganizationAdditionalFields(options);
4669
4846
  for (const [name, field] of Object.entries(additional)) {
4670
4847
  if (field.input === false) continue;
4671
- shape[name] = createSchemaForDBField(field);
4848
+ shape[name] = createSchemaForDBField$1(field);
4672
4849
  }
4673
4850
  const result = z$1.object(shape).strict().safeParse(data);
4674
4851
  if (!result.success) throw result.error;
@@ -4684,14 +4861,14 @@ function validateWritableOrganizationUpdateFields(data, orgOptions) {
4684
4861
  }]);
4685
4862
  const shape = {
4686
4863
  name: z$1.string().min(1).optional(),
4687
- slug: z$1.string().min(1).optional(),
4688
- logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
4864
+ slug: slugSchema.optional(),
4865
+ logo: optionalSafeUrlSchema,
4689
4866
  metadata: z$1.string().optional()
4690
4867
  };
4691
4868
  const additional = getOrganizationAdditionalFields(orgOptions);
4692
4869
  for (const [name, field] of Object.entries(additional)) {
4693
4870
  if (field.input === false) continue;
4694
- shape[name] = createSchemaForDBField(field);
4871
+ shape[name] = createSchemaForDBField$1(field);
4695
4872
  }
4696
4873
  const result = z$1.object(shape).partial().strict().safeParse(data);
4697
4874
  if (!result.success) throw result.error;
@@ -5760,6 +5937,17 @@ const removeTeamMember = (options) => {
5760
5937
  });
5761
5938
  };
5762
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
5763
5951
  //#region src/routes/sessions/index.ts
5764
5952
  const revokeSession = (options) => createAuthEndpoint("/dash/sessions/revoke", {
5765
5953
  method: "POST",
@@ -5797,7 +5985,7 @@ const revokeAllSessions = (options) => createAuthEndpoint("/dash/sessions/revoke
5797
5985
  }, async (ctx) => {
5798
5986
  const { userId } = ctx.body;
5799
5987
  if (!await ctx.context.internalAdapter.findUserById(userId)) throw ctx.error("NOT_FOUND", { message: "User not found" });
5800
- await ctx.context.internalAdapter.deleteUserSessions(userId);
5988
+ await deleteUserSessionsCompat(ctx.context.internalAdapter, userId);
5801
5989
  return ctx.json({ success: true });
5802
5990
  });
5803
5991
  const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revoke-many", {
@@ -5806,7 +5994,7 @@ const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revok
5806
5994
  }, async (ctx) => {
5807
5995
  const { userIds } = ctx.context.payload;
5808
5996
  await withConcurrency(chunkArray(userIds, { batchSize: 50 }), async (chunk) => {
5809
- for (const userId of chunk) await ctx.context.internalAdapter.deleteUserSessions(userId);
5997
+ for (const userId of chunk) await deleteUserSessionsCompat(ctx.context.internalAdapter, userId);
5810
5998
  }, { concurrency: 3 });
5811
5999
  return ctx.json({
5812
6000
  success: true,
@@ -5814,248 +6002,6 @@ const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revok
5814
6002
  });
5815
6003
  });
5816
6004
  //#endregion
5817
- //#region ../utils/dist/ip.mjs
5818
- /**
5819
- * SSRF (Server-Side Request Forgery) Protection
5820
- *
5821
- * IP and URL utilities to validate hostnames and URLs before server-side fetches.
5822
- * Blocks requests to private/reserved networks. Covers IPv4 private ranges,
5823
- * IPv6 private ranges, and IPv4-mapped IPv6 bypass vectors.
5824
- */
5825
- function isPrivateIPv4(a, b) {
5826
- if (a === 10) return true;
5827
- if (a === 172 && b >= 16 && b <= 31) return true;
5828
- if (a === 192 && b === 168) return true;
5829
- if (a === 127) return true;
5830
- if (a === 169 && b === 254) return true;
5831
- if (a === 0) return true;
5832
- if (a === 100 && b >= 64 && b <= 127) return true;
5833
- if (a >= 224) return true;
5834
- return false;
5835
- }
5836
- function parseIPv4Quad(quad) {
5837
- const v4parts = quad.split(".").map(Number);
5838
- if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return [v4parts[0], v4parts[1]];
5839
- return null;
5840
- }
5841
- /**
5842
- * Loopback, RFC1918, link-local, CGNAT, ULA, documentation, NAT64 well-known,
5843
- * IPv4-mapped private, unspecified, and multicast (RFC 4291 section 2.7).
5844
- */
5845
- function ipv6GroupsAreNonPublic(groups) {
5846
- if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
5847
- if (groups.every((g) => g === 0)) return true;
5848
- if ((groups[0] & 65472) === 65152) return true;
5849
- if ((groups[0] & 65024) === 64512) return true;
5850
- if ((groups[0] & 65280) === 65280) return true;
5851
- 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);
5852
- 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);
5853
- if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
5854
- if (groups[0] === 8193 && groups[1] === 3512) return true;
5855
- if (groups[0] === 256 && groups.slice(1, 4).every((g) => g === 0)) return true;
5856
- return false;
5857
- }
5858
- function parseIPv6(addr) {
5859
- const sides = addr.split("::");
5860
- if (sides.length > 2) return null;
5861
- const parseGroups = (s) => s === "" ? [] : s.split(":").map((g) => parseInt(g, 16));
5862
- const left = parseGroups(sides[0]);
5863
- const right = sides.length === 2 ? parseGroups(sides[1]) : [];
5864
- if (left.some(isNaN) || right.some(isNaN)) return null;
5865
- const missing = 8 - left.length - right.length;
5866
- if (sides.length === 2) {
5867
- if (missing < 0) return null;
5868
- return [
5869
- ...left,
5870
- ...Array(missing).fill(0),
5871
- ...right
5872
- ];
5873
- }
5874
- if (left.length !== 8) return null;
5875
- return left;
5876
- }
5877
- /** True when the hostname is an IPv4 or IPv6 literal (not a DNS name). */
5878
- function isIpLiteralHostname(hostname) {
5879
- const bare = hostname.replace(/^\[|\]$/g, "");
5880
- if (parseIPv4Quad(bare)) return true;
5881
- return parseIPv6(bare)?.length === 8;
5882
- }
5883
- /** Returns true if the hostname resolves to a private/reserved IP or is a local hostname. */
5884
- function isPrivateHost(hostname) {
5885
- if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) return true;
5886
- const bare = hostname.replace(/^\[|\]$/g, "");
5887
- const v4 = parseIPv4Quad(bare);
5888
- if (v4) return isPrivateIPv4(v4[0], v4[1]);
5889
- const mappedTail = bare.match(/(?:^|:)([\d.]+)$/);
5890
- if (mappedTail && mappedTail[1].includes(".")) {
5891
- const mappedV4 = parseIPv4Quad(mappedTail[1]);
5892
- if (mappedV4) return isPrivateIPv4(mappedV4[0], mappedV4[1]);
5893
- }
5894
- const groups = parseIPv6(bare);
5895
- if (groups && groups.length === 8) return ipv6GroupsAreNonPublic(groups);
5896
- return false;
5897
- }
5898
- //#endregion
5899
- //#region ../utils/dist/url.mjs
5900
- /**
5901
- * URL helpers: normalization, SSRF-safe parsing/validation, and path/query segments.
5902
- */
5903
- const HTTP_PROTOCOLS = ["http:", "https:"];
5904
- function isAllowedProtocol(protocol, allowedProtocols) {
5905
- return (allowedProtocols?.length ? allowedProtocols : HTTP_PROTOCOLS).includes(protocol);
5906
- }
5907
- function toAllowedOriginSet(allowedOrigins) {
5908
- const set = /* @__PURE__ */ new Set();
5909
- for (const raw of allowedOrigins) try {
5910
- const parsed = new URL(raw.trim());
5911
- if (isAllowedProtocol(parsed.protocol, HTTP_PROTOCOLS)) set.add(parsed.origin);
5912
- } catch {}
5913
- return set;
5914
- }
5915
- function isAllowedOrigin(origin, allowedOrigins) {
5916
- if (!allowedOrigins.length) return false;
5917
- return toAllowedOriginSet(allowedOrigins).has(origin);
5918
- }
5919
- async function resolveHostnameAddresses(hostname) {
5920
- try {
5921
- const dns = await import("node:dns").catch((e) => {
5922
- console.warn("Failed to load node:dns for DNS resolution", e);
5923
- return null;
5924
- });
5925
- if (!dns?.promises?.resolve) return null;
5926
- return await dns.promises.resolve(hostname);
5927
- } catch {
5928
- return null;
5929
- }
5930
- }
5931
- function validateUrlSync(input, options = {}) {
5932
- let parsed;
5933
- if (input instanceof URL) parsed = input;
5934
- else {
5935
- const trimmed = input.trim();
5936
- try {
5937
- parsed = new URL(trimmed);
5938
- } catch {
5939
- return {
5940
- ok: false,
5941
- reason: "invalid_url"
5942
- };
5943
- }
5944
- }
5945
- if (!isAllowedProtocol(parsed.protocol, options.allowedProtocols)) return {
5946
- ok: false,
5947
- reason: "disallowed_protocol",
5948
- url: parsed,
5949
- protocol: parsed.protocol
5950
- };
5951
- if (parsed.username !== "" || parsed.password !== "") return {
5952
- ok: false,
5953
- reason: "embedded_credentials",
5954
- url: parsed
5955
- };
5956
- if (isPrivateHost(parsed.hostname)) return {
5957
- ok: false,
5958
- reason: "private_host",
5959
- url: parsed
5960
- };
5961
- if (options.allowedOrigins?.length) {
5962
- if (!isAllowedOrigin(parsed.origin, options.allowedOrigins)) return {
5963
- ok: false,
5964
- reason: "disallowed_origin",
5965
- url: parsed,
5966
- hostname: parsed.hostname,
5967
- origin: parsed.origin
5968
- };
5969
- }
5970
- return {
5971
- ok: true,
5972
- url: parsed
5973
- };
5974
- }
5975
- /** SSRF checks with DNS resolution when available. */
5976
- async function validateUrl(input, options = {}) {
5977
- const sync = validateUrlSync(input, options);
5978
- if (!sync.ok) return sync;
5979
- if (options.dns === false) return sync;
5980
- const hostname = sync.url.hostname;
5981
- const addresses = await resolveHostnameAddresses(hostname);
5982
- if (!isIpLiteralHostname(hostname) && !addresses?.length) return {
5983
- ok: false,
5984
- reason: "unresolved_host",
5985
- url: sync.url,
5986
- hostname
5987
- };
5988
- if (addresses) {
5989
- for (const addr of addresses) if (isPrivateHost(addr)) return {
5990
- ok: false,
5991
- reason: "private_dns",
5992
- url: sync.url,
5993
- hostname,
5994
- address: addr
5995
- };
5996
- }
5997
- return {
5998
- ok: true,
5999
- url: sync.url,
6000
- ...addresses ? { addresses } : {}
6001
- };
6002
- }
6003
- function safeResolveUrl(raw, baseURL) {
6004
- const trim = raw.trim();
6005
- if (!trim) return null;
6006
- if (trim.startsWith("//") || trim.includes("\\")) return null;
6007
- if (/[\u0000-\u001F\u007F]/.test(trim)) return null;
6008
- try {
6009
- if (trim.startsWith("/")) return new URL(trim, baseURL);
6010
- return new URL(trim);
6011
- } catch {
6012
- return null;
6013
- }
6014
- }
6015
- //#endregion
6016
- //#region src/validation/ssrf.ts
6017
- const REDIRECT_STATUSES = new Set([
6018
- 301,
6019
- 302,
6020
- 303,
6021
- 307,
6022
- 308
6023
- ]);
6024
- const MAX_REDIRECTS = 10;
6025
- var SsrfBlockedError = class extends Error {
6026
- name = "SsrfBlockedError";
6027
- };
6028
- function isSsrfBlockedError(error) {
6029
- return error instanceof SsrfBlockedError;
6030
- }
6031
- /**
6032
- * Fetch with SSRF checks on the initial URL and every redirect target.
6033
- */
6034
- async function $outbound(url, options = {}) {
6035
- const { timeout, signal: callerSignal, ...fetchInit } = options;
6036
- let currentUrl = url;
6037
- for (let hop = 0;; hop++) {
6038
- const result = await validateUrl(currentUrl);
6039
- if (!result.ok) throw new SsrfBlockedError("Invalid or blocked URL");
6040
- const validated = result.url;
6041
- const signals = [];
6042
- if (callerSignal) signals.push(callerSignal);
6043
- if (timeout !== void 0 && timeout > 0) signals.push(AbortSignal.timeout(timeout));
6044
- const response = await fetch(validated.href, {
6045
- ...fetchInit,
6046
- redirect: "manual",
6047
- signal: signals.length === 0 ? void 0 : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
6048
- });
6049
- if (!REDIRECT_STATUSES.has(response.status)) return response;
6050
- if (hop >= MAX_REDIRECTS) throw new SsrfBlockedError("Too many redirects");
6051
- await response.body?.cancel().catch(() => {});
6052
- const location = response.headers.get("location");
6053
- const next = location ? safeResolveUrl(location, validated.href) : null;
6054
- if (!next) throw new SsrfBlockedError("Invalid redirect location");
6055
- currentUrl = next;
6056
- }
6057
- }
6058
- //#endregion
6059
6005
  //#region src/routes/plugin-session.ts
6060
6006
  /**
6061
6007
  * Builds an in-memory session context for calling plugin endpoints
@@ -6115,6 +6061,16 @@ function extractAlgorithmsFromSAMLMetadata(metadataXml) {
6115
6061
  digestAlgorithms
6116
6062
  };
6117
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
+ }
6118
6074
  function validateSAMLMetadataAlgorithms(metadataXml) {
6119
6075
  const warnings = [];
6120
6076
  const { signatureAlgorithms, digestAlgorithms } = extractAlgorithmsFromSAMLMetadata(metadataXml);
@@ -6124,10 +6080,6 @@ function validateSAMLMetadataAlgorithms(metadataXml) {
6124
6080
  }
6125
6081
  //#endregion
6126
6082
  //#region src/routes/sso/index.ts
6127
- let ssoRuntimeModule;
6128
- function loadSsoRuntime() {
6129
- return ssoRuntimeModule ??= import("@better-auth/sso");
6130
- }
6131
6083
  function requireOrganizationAccess(ctx) {
6132
6084
  const orgIdFromUrl = tryDecode(ctx.params.id);
6133
6085
  const orgIdFromToken = ctx.context.payload?.organizationId;
@@ -6156,6 +6108,12 @@ const oidcConfigSchema = z$1.object({
6156
6108
  clientSecret: z$1.string().optional(),
6157
6109
  discoveryUrl: z$1.string().optional(),
6158
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(),
6159
6117
  mapping: z$1.object({
6160
6118
  id: z$1.string().optional(),
6161
6119
  email: z$1.string().optional(),
@@ -6166,21 +6124,8 @@ const oidcConfigSchema = z$1.object({
6166
6124
  }).optional()
6167
6125
  });
6168
6126
  async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6169
- let idpMetadataXml = samlConfig.idpMetadata?.metadata;
6170
- if (!idpMetadataXml && samlConfig.idpMetadata?.metadataUrl) try {
6171
- const metadataResponse = await $outbound(samlConfig.idpMetadata.metadataUrl, {
6172
- method: "GET",
6173
- headers: { Accept: "application/xml, text/xml" },
6174
- timeout: 15e3
6175
- });
6176
- if (!metadataResponse.ok) throw ctx.error("BAD_REQUEST", { message: `Failed to fetch IdP metadata from URL: ${metadataResponse.status} ${metadataResponse.statusText}` });
6177
- idpMetadataXml = await metadataResponse.text();
6178
- } catch (e) {
6179
- if (isSsrfBlockedError(e)) throw ctx.error("BAD_REQUEST", { message: "Invalid or blocked IdP metadata URL" });
6180
- if (e instanceof APIError$1) throw e;
6181
- ctx.context.logger.error("[Dash] Failed to fetch IdP metadata from URL:", e);
6182
- throw ctx.error("BAD_REQUEST", { message: "Failed to fetch IdP metadata from URL" });
6183
- }
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" });
6184
6129
  const metadataAlgorithmWarnings = [];
6185
6130
  if (idpMetadataXml) {
6186
6131
  try {
@@ -6203,14 +6148,22 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6203
6148
  ...samlConfig.cert ? { cert: samlConfig.cert } : {}
6204
6149
  };
6205
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" });
6206
6154
  return {
6207
6155
  config: {
6208
6156
  issuer: samlConfig.entityId ?? `${baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`,
6209
- callbackUrl: `${baseURL}/sso/saml2/sp/acs/${providerId}`,
6210
6157
  idpMetadata,
6211
- spMetadata: {},
6212
- entryPoint: samlConfig.entryPoint ?? "",
6213
- 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
+ },
6214
6167
  ...m ? { mapping: {
6215
6168
  id: m.id ?? "nameID",
6216
6169
  email: m.email ?? "email",
@@ -6224,67 +6177,33 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
6224
6177
  ...metadataAlgorithmWarnings.length > 0 ? { warnings: metadataAlgorithmWarnings } : {}
6225
6178
  };
6226
6179
  }
6227
- async function resolveOIDCConfig(oidcConfig, domain, ctx) {
6228
- let normalizedDomain = domain;
6229
- try {
6230
- if (domain.startsWith("http://") || domain.startsWith("https://")) normalizedDomain = new URL(domain).hostname;
6231
- } catch {
6232
- normalizedDomain = domain;
6233
- }
6234
- const issuerHint = oidcConfig.issuer || `https://${normalizedDomain}`;
6235
- const issuer = issuerHint.startsWith("http") ? issuerHint : `https://${issuerHint}`;
6236
- const sso = await loadSsoRuntime();
6237
- try {
6238
- const hydratedConfig = await sso.discoverOIDCConfig({
6239
- issuer,
6240
- discoveryEndpoint: oidcConfig.discoveryUrl,
6241
- isTrustedOrigin: (url) => {
6242
- try {
6243
- const issuerOrigin = new URL(issuer).origin;
6244
- return new URL(url).origin === issuerOrigin;
6245
- } catch {
6246
- return false;
6247
- }
6248
- }
6249
- });
6250
- const om = oidcConfig.mapping;
6251
- return {
6252
- config: {
6253
- clientId: oidcConfig.clientId,
6254
- clientSecret: oidcConfig.clientSecret,
6255
- issuer: hydratedConfig.issuer,
6256
- discoveryEndpoint: hydratedConfig.discoveryEndpoint,
6257
- authorizationEndpoint: hydratedConfig.authorizationEndpoint,
6258
- tokenEndpoint: hydratedConfig.tokenEndpoint,
6259
- jwksEndpoint: hydratedConfig.jwksEndpoint,
6260
- userInfoEndpoint: hydratedConfig.userInfoEndpoint,
6261
- tokenEndpointAuthentication: hydratedConfig.tokenEndpointAuthentication,
6262
- pkce: true,
6263
- ...om ? { mapping: {
6264
- id: om.id ?? "sub",
6265
- email: om.email ?? "email",
6266
- name: om.name ?? "name",
6267
- emailVerified: om.emailVerified,
6268
- image: om.image,
6269
- extraFields: om.extraFields
6270
- } } : {}
6271
- },
6272
- issuer: hydratedConfig.issuer
6273
- };
6274
- } catch (e) {
6275
- if (e instanceof sso.DiscoveryError) {
6276
- ctx.context.logger.error("[Dash] OIDC discovery failed:", e);
6277
- throw ctx.error("BAD_REQUEST", {
6278
- message: `OIDC discovery failed: ${e.message}`,
6279
- code: e.code
6280
- });
6281
- }
6282
- ctx.context.logger.error("[Dash] OIDC discovery failed:", e);
6283
- throw ctx.error("BAD_REQUEST", {
6284
- message: `OIDC discovery failed: Unable to discover configuration from ${issuer}`,
6285
- code: "OIDC_DISCOVERY_FAILED"
6286
- });
6287
- }
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
+ };
6288
6207
  }
6289
6208
  const listOrganizationSsoProviders = (options) => {
6290
6209
  return createAuthEndpoint("/dash/organization/:id/sso-providers", {
@@ -6442,6 +6361,7 @@ const updateSsoProvider = (options) => {
6442
6361
  try {
6443
6362
  const result = await ssoPlugin.endpoints.updateSSOProvider({
6444
6363
  body: updateBody,
6364
+ params: { providerId },
6445
6365
  context: {
6446
6366
  ...ctx.context,
6447
6367
  ...buildSessionContext(providerUserId)
@@ -6589,6 +6509,7 @@ const deleteSsoProvider = (options) => {
6589
6509
  try {
6590
6510
  await ssoPlugin.endpoints.deleteSSOProvider({
6591
6511
  body: { providerId },
6512
+ params: { providerId },
6592
6513
  context: {
6593
6514
  ...ctx.context,
6594
6515
  ...buildSessionContext(provider.userId)
@@ -6646,6 +6567,64 @@ const markSsoProviderDomainVerified = (options) => {
6646
6567
  });
6647
6568
  };
6648
6569
  //#endregion
6570
+ //#region src/routes/two-factor/backup-codes.ts
6571
+ /** Mirrors better-auth/plugins/two-factor/backup-codes generateBackupCodesFn. */
6572
+ function generateBackupCodesPlain(options) {
6573
+ if (options?.customBackupCodesGenerate) return options.customBackupCodesGenerate();
6574
+ const amount = options?.amount ?? 10;
6575
+ const length = options?.length ?? 10;
6576
+ return Array.from({ length: amount }, () => {
6577
+ const code = generateRandomString(length, "a-z", "0-9", "A-Z");
6578
+ return `${code.slice(0, 5)}-${code.slice(5)}`;
6579
+ });
6580
+ }
6581
+ /** Mirrors better-auth/plugins/two-factor/backup-codes generateBackupCodes. */
6582
+ async function generateBackupCodes$1(secret, options) {
6583
+ const backupCodes = generateBackupCodesPlain(options);
6584
+ const json = JSON.stringify(backupCodes);
6585
+ const storeBackupCodes = options?.storeBackupCodes ?? "encrypted";
6586
+ if (storeBackupCodes === "encrypted") return {
6587
+ backupCodes,
6588
+ encryptedBackupCodes: await symmetricEncrypt({
6589
+ key: secret,
6590
+ data: json
6591
+ })
6592
+ };
6593
+ if (typeof storeBackupCodes === "object" && "encrypt" in storeBackupCodes) return {
6594
+ backupCodes,
6595
+ encryptedBackupCodes: await storeBackupCodes.encrypt(json)
6596
+ };
6597
+ return {
6598
+ backupCodes,
6599
+ encryptedBackupCodes: json
6600
+ };
6601
+ }
6602
+ function resolveBackupCodeOptions(twoFactorPlugin) {
6603
+ return {
6604
+ storeBackupCodes: "encrypted",
6605
+ ...twoFactorPlugin?.options?.backupCodeOptions
6606
+ };
6607
+ }
6608
+ //#endregion
6609
+ //#region src/routes/two-factor/two-factor-options.ts
6610
+ function resolveTotpIssuer(twoFactorPlugin, appName) {
6611
+ const options = twoFactorPlugin?.options;
6612
+ return (options?.totpOptions)?.issuer ?? options?.issuer ?? appName ?? "BetterAuth";
6613
+ }
6614
+ function resolveTotpConfig(twoFactorPlugin) {
6615
+ const totpOptions = twoFactorPlugin?.options?.totpOptions;
6616
+ return {
6617
+ digits: totpOptions?.digits ?? 6,
6618
+ period: totpOptions?.period ?? 30
6619
+ };
6620
+ }
6621
+ function buildTotpUri(params) {
6622
+ return createOTP(params.secret, {
6623
+ digits: params.digits,
6624
+ period: params.period
6625
+ }).url(params.issuer, params.account);
6626
+ }
6627
+ //#endregion
6649
6628
  //#region src/routes/two-factor/index.ts
6650
6629
  const enableTwoFactor = (options) => createAuthEndpoint("/dash/enable-two-factor", {
6651
6630
  method: "POST",
@@ -6654,31 +6633,37 @@ const enableTwoFactor = (options) => createAuthEndpoint("/dash/enable-two-factor
6654
6633
  const { userId } = ctx.context.payload;
6655
6634
  const twoFactorPlugin = ctx.context.getPlugin("two-factor");
6656
6635
  if (!twoFactorPlugin) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication plugin is not enabled" });
6636
+ if (twoFactorPlugin.options?.totpOptions?.disable === true) throw new APIError("BAD_REQUEST", {
6637
+ message: "TOTP is not configured",
6638
+ code: "TOTP_NOT_CONFIGURED"
6639
+ });
6640
+ const twoFactorTable = twoFactorPlugin.options?.twoFactorTable ?? "twoFactor";
6641
+ const user = await ctx.context.internalAdapter.findUserById(userId);
6642
+ if (!user) throw new APIError("NOT_FOUND", { message: "User not found" });
6643
+ if (user.twoFactorEnabled === true) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication is already enabled for this user" });
6657
6644
  if (await ctx.context.adapter.findOne({
6658
- model: "twoFactor",
6645
+ model: twoFactorTable,
6646
+ where: [{
6647
+ field: "userId",
6648
+ value: userId
6649
+ }]
6650
+ })) await ctx.context.adapter.delete({
6651
+ model: twoFactorTable,
6659
6652
  where: [{
6660
6653
  field: "userId",
6661
6654
  value: userId
6662
6655
  }]
6663
- })) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication is already enabled for this user" });
6656
+ });
6664
6657
  const { generateRandomString, symmetricEncrypt } = await import("better-auth/crypto");
6665
- const secret = generateRandomString(32, "A-Z");
6666
- const backupCodeAmount = 10;
6667
- const backupCodeLength = 10;
6668
- const backupCodes = [];
6669
- for (let i = 0; i < backupCodeAmount; i++) backupCodes.push(generateId(backupCodeLength).toUpperCase());
6658
+ const secret = generateRandomString(32);
6659
+ const { backupCodes, encryptedBackupCodes } = await generateBackupCodes$1(ctx.context.secret, resolveBackupCodeOptions(twoFactorPlugin));
6670
6660
  const encryptedSecret = await symmetricEncrypt({
6671
6661
  key: ctx.context.secret,
6672
6662
  data: secret
6673
6663
  });
6674
- const encryptedBackupCodes = await symmetricEncrypt({
6675
- key: ctx.context.secret,
6676
- data: JSON.stringify(backupCodes)
6677
- });
6678
6664
  await ctx.context.adapter.create({
6679
- model: "twoFactor",
6665
+ model: twoFactorTable,
6680
6666
  data: {
6681
- id: generateId(32),
6682
6667
  userId,
6683
6668
  secret: encryptedSecret,
6684
6669
  backupCodes: encryptedBackupCodes
@@ -6688,15 +6673,47 @@ const enableTwoFactor = (options) => createAuthEndpoint("/dash/enable-two-factor
6688
6673
  twoFactorEnabled: true,
6689
6674
  updatedAt: /* @__PURE__ */ new Date()
6690
6675
  });
6691
- const user = await ctx.context.internalAdapter.findUserById(userId);
6692
- const issuer = twoFactorPlugin.options?.issuer || ctx.context.appName || "BetterAuth";
6676
+ const totpConfig = resolveTotpConfig(twoFactorPlugin);
6693
6677
  return {
6694
6678
  success: true,
6695
- totpURI: `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user?.email || userId)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`,
6679
+ totpURI: buildTotpUri({
6680
+ secret,
6681
+ issuer: resolveTotpIssuer(twoFactorPlugin, ctx.context.appName),
6682
+ account: user?.email || userId,
6683
+ digits: totpConfig.digits,
6684
+ period: totpConfig.period
6685
+ }),
6696
6686
  secret,
6697
6687
  backupCodes
6698
6688
  };
6699
6689
  });
6690
+ const completeTwoFactorSetup = (options) => createAuthEndpoint("/dash/complete-two-factor-setup", {
6691
+ method: "POST",
6692
+ use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))]
6693
+ }, async (ctx) => {
6694
+ const { userId } = ctx.context.payload;
6695
+ const twoFactorPlugin = ctx.context.getPlugin("two-factor");
6696
+ if (!twoFactorPlugin) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication plugin is not enabled" });
6697
+ const user = await ctx.context.internalAdapter.findUserById(userId);
6698
+ if (!user) throw new APIError("NOT_FOUND", { message: "User not found" });
6699
+ if (user.twoFactorEnabled === true) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication is already enabled for this user" });
6700
+ const twoFactorTable = twoFactorPlugin.options?.twoFactorTable ?? "twoFactor";
6701
+ if (!await ctx.context.adapter.findOne({
6702
+ model: twoFactorTable,
6703
+ where: [{
6704
+ field: "userId",
6705
+ value: userId
6706
+ }]
6707
+ })) throw new APIError("BAD_REQUEST", {
6708
+ message: "Two-factor authentication setup has not been started",
6709
+ code: "TWO_FACTOR_SETUP_NOT_PENDING"
6710
+ });
6711
+ await ctx.context.internalAdapter.updateUser(userId, {
6712
+ twoFactorEnabled: true,
6713
+ updatedAt: /* @__PURE__ */ new Date()
6714
+ });
6715
+ return { success: true };
6716
+ });
6700
6717
  const viewTwoFactorTotpUri = (options) => createAuthEndpoint("/dash/view-two-factor-totp-uri", {
6701
6718
  method: "POST",
6702
6719
  metadata: { scope: "http" },
@@ -6705,8 +6722,13 @@ const viewTwoFactorTotpUri = (options) => createAuthEndpoint("/dash/view-two-fac
6705
6722
  const { userId } = ctx.context.payload;
6706
6723
  const twoFactorPlugin = ctx.context.getPlugin("two-factor");
6707
6724
  if (!twoFactorPlugin) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication plugin is not enabled" });
6725
+ if (twoFactorPlugin.options?.totpOptions?.disable === true) throw new APIError("BAD_REQUEST", {
6726
+ message: "TOTP is not configured",
6727
+ code: "TOTP_NOT_CONFIGURED"
6728
+ });
6729
+ const twoFactorTable = twoFactorPlugin.options?.twoFactorTable ?? "twoFactor";
6708
6730
  const twoFactorRecord = await ctx.context.adapter.findOne({
6709
- model: "twoFactor",
6731
+ model: twoFactorTable,
6710
6732
  where: [{
6711
6733
  field: "userId",
6712
6734
  value: userId
@@ -6722,8 +6744,14 @@ const viewTwoFactorTotpUri = (options) => createAuthEndpoint("/dash/view-two-fac
6722
6744
  });
6723
6745
  } catch {}
6724
6746
  const user = await ctx.context.internalAdapter.findUserById(userId);
6725
- const issuer = twoFactorPlugin.options?.issuer || ctx.context.appName || "BetterAuth";
6726
- return { totpURI: `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user?.email || userId)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` };
6747
+ const totpConfig = resolveTotpConfig(twoFactorPlugin);
6748
+ return { totpURI: buildTotpUri({
6749
+ secret,
6750
+ issuer: resolveTotpIssuer(twoFactorPlugin, ctx.context.appName),
6751
+ account: user?.email || userId,
6752
+ digits: totpConfig.digits,
6753
+ period: totpConfig.period
6754
+ }) };
6727
6755
  });
6728
6756
  const viewBackupCodes = (options) => createAuthEndpoint("/dash/view-backup-codes", {
6729
6757
  method: "POST",
@@ -6736,9 +6764,11 @@ const disableTwoFactor = (options) => createAuthEndpoint("/dash/disable-two-fact
6736
6764
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))]
6737
6765
  }, async (ctx) => {
6738
6766
  const { userId } = ctx.context.payload;
6739
- if (!ctx.context.getPlugin("two-factor")) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication is not enabled" });
6767
+ const twoFactorPlugin = ctx.context.getPlugin("two-factor");
6768
+ if (!twoFactorPlugin) throw new APIError("BAD_REQUEST", { message: "Two-factor authentication is not enabled" });
6769
+ const twoFactorTable = twoFactorPlugin.options?.twoFactorTable ?? "twoFactor";
6740
6770
  await ctx.context.adapter.delete({
6741
- model: "twoFactor",
6771
+ model: twoFactorTable,
6742
6772
  where: [{
6743
6773
  field: "userId",
6744
6774
  value: userId
@@ -6755,25 +6785,19 @@ const generateBackupCodes = (options) => createAuthEndpoint("/dash/generate-back
6755
6785
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))]
6756
6786
  }, async (ctx) => {
6757
6787
  const { userId } = ctx.context.payload;
6788
+ const twoFactorPlugin = ctx.context.getPlugin("two-factor");
6789
+ const twoFactorTable = twoFactorPlugin?.options?.twoFactorTable ?? "twoFactor";
6758
6790
  const twoFactorRecord = await ctx.context.adapter.findOne({
6759
- model: "twoFactor",
6791
+ model: twoFactorTable,
6760
6792
  where: [{
6761
6793
  field: "userId",
6762
6794
  value: userId
6763
6795
  }]
6764
6796
  });
6765
6797
  if (!twoFactorRecord) throw new APIError("NOT_FOUND", { message: "Two-factor authentication not set up for this user" });
6766
- const backupCodeAmount = 10;
6767
- const backupCodeLength = 10;
6768
- const newBackupCodes = [];
6769
- for (let i = 0; i < backupCodeAmount; i++) newBackupCodes.push(generateId(backupCodeLength).toUpperCase());
6770
- const { symmetricEncrypt } = await import("better-auth/crypto");
6771
- const encryptedCodes = await symmetricEncrypt({
6772
- key: ctx.context.secret,
6773
- data: JSON.stringify(newBackupCodes)
6774
- });
6798
+ const { backupCodes: newBackupCodes, encryptedBackupCodes: encryptedCodes } = await generateBackupCodes$1(ctx.context.secret, resolveBackupCodeOptions(twoFactorPlugin));
6775
6799
  await ctx.context.adapter.update({
6776
- model: "twoFactor",
6800
+ model: twoFactorTable,
6777
6801
  where: [{
6778
6802
  field: "id",
6779
6803
  value: twoFactorRecord.id
@@ -6783,6 +6807,196 @@ const generateBackupCodes = (options) => createAuthEndpoint("/dash/generate-back
6783
6807
  return { backupCodes: newBackupCodes };
6784
6808
  });
6785
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
6834
+ //#region src/routes/two-factor/two-factor-status.ts
6835
+ async function resolveTwoFactorStatus(context, userId, user) {
6836
+ if (!hasPlugin(context, "two-factor")) return;
6837
+ if (user.twoFactorEnabled === true) return "enabled";
6838
+ const twoFactorTable = context.getPlugin("two-factor")?.options?.twoFactorTable ?? "twoFactor";
6839
+ return await context.adapter.findOne({
6840
+ model: twoFactorTable,
6841
+ where: [{
6842
+ field: "userId",
6843
+ value: userId
6844
+ }]
6845
+ }) ? "pending" : "disabled";
6846
+ }
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
6786
7000
  //#region src/routes/users/index.ts
6787
7001
  function parseWhereClause(val) {
6788
7002
  if (!val) return [];
@@ -6946,7 +7160,7 @@ const impersonateUser = (options) => {
6946
7160
  query: z$1.object({ impersonation_token: z$1.string() }),
6947
7161
  use: [jwtMiddleware(options, z$1.object({
6948
7162
  userId: z$1.string(),
6949
- redirectUrl: z$1.url(),
7163
+ redirectUrl: safeUrlSchema,
6950
7164
  impersonatedBy: z$1.string().optional()
6951
7165
  }), async (ctx) => {
6952
7166
  return ctx.query.impersonation_token;
@@ -6974,25 +7188,21 @@ const createUser = (options) => {
6974
7188
  organizationId: z$1.string().optional(),
6975
7189
  organizationRole: z$1.string().optional()
6976
7190
  }))],
6977
- body: z$1.looseObject({
6978
- name: z$1.string(),
6979
- email: z$1.email(),
6980
- image: z$1.string().optional(),
6981
- password: z$1.string().optional(),
6982
- generatePassword: z$1.boolean().optional(),
6983
- emailVerified: z$1.boolean().optional(),
6984
- sendVerificationEmail: z$1.boolean().optional(),
6985
- sendOrganizationInvite: z$1.boolean().optional(),
6986
- organizationRole: z$1.string().optional(),
6987
- organizationId: z$1.string().optional()
6988
- })
7191
+ body: CreateUserBodySchema
6989
7192
  }, async (ctx) => {
6990
- 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
+ }
6991
7201
  const email = normalizeEmail(userData.email, ctx.context);
6992
7202
  if (await ctx.context.internalAdapter.findUserByEmail(email)) throw new APIError("BAD_REQUEST", { message: "User with this email already exist" });
6993
7203
  let password = null;
6994
- if (userData.generatePassword && !userData.password) password = generateId(12);
6995
- 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;
6996
7206
  const userSchema = getAuthTables(ctx.context.options).user;
6997
7207
  for (const field in userSchema.fields || {}) {
6998
7208
  const value = userSchema.fields[field];
@@ -7009,24 +7219,28 @@ const createUser = (options) => {
7009
7219
  }
7010
7220
  }
7011
7221
  }
7012
- const user = await ctx.context.internalAdapter.createUser({
7222
+ const emailVerified = userData.emailVerified;
7223
+ const userPayload = {
7013
7224
  ...userData,
7225
+ name: userData.name,
7014
7226
  email,
7015
- emailVerified: userData.emailVerified,
7227
+ emailVerified,
7016
7228
  createdAt: /* @__PURE__ */ new Date(),
7017
7229
  updatedAt: /* @__PURE__ */ new Date()
7018
- });
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);
7019
7233
  if (password) await ctx.context.internalAdapter.createAccount({
7020
7234
  userId: user.id,
7021
7235
  providerId: "credential",
7022
7236
  accountId: user.id,
7023
7237
  password: await ctx.context.password.hash(password)
7024
7238
  });
7025
- if (userData.sendVerificationEmail && !userData.emailVerified) {
7239
+ if (body.sendVerificationEmail && !emailVerified) {
7026
7240
  if (ctx.context.options.emailVerification?.sendVerificationEmail) await sendVerificationEmailFn(ctx, user);
7027
7241
  }
7028
- const organizationId = ctx.context.payload?.organizationId || userData.organizationId;
7029
- 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;
7030
7244
  if (organizationId) {
7031
7245
  const orgOptions = requireOrganizationPlugin(ctx).options || {};
7032
7246
  const role = organizationRole || "member";
@@ -7136,6 +7350,7 @@ const getUserDetails = (options) => {
7136
7350
  } }
7137
7351
  });
7138
7352
  if (!user) throw ctx.error("NOT_FOUND", { message: "User not found" });
7353
+ const twoFactorStatus = await resolveTwoFactorStatus(ctx.context, userId, user);
7139
7354
  if (minimal) return {
7140
7355
  ...user,
7141
7356
  lastActiveAt: user.lastActiveAt ?? null,
@@ -7143,10 +7358,11 @@ const getUserDetails = (options) => {
7143
7358
  banReason: hasAdminPlugin ? user.banReason ?? null : null,
7144
7359
  banExpires: hasAdminPlugin ? user.banExpires ?? null : null,
7145
7360
  account: [],
7146
- session: []
7361
+ session: [],
7362
+ twoFactorStatus
7147
7363
  };
7148
7364
  const activityTrackingEnabled = !!options.activityTracking?.enabled;
7149
- const sessions = secondaryStorageOnly ? await ctx.context.internalAdapter.listSessions(userId) : user.session || [];
7365
+ const sessions = secondaryStorageOnly ? normalizeDashSessions(await ctx.context.internalAdapter.listSessions(userId)) : user.session || [];
7150
7366
  let lastActiveAt = null;
7151
7367
  if (activityTrackingEnabled) {
7152
7368
  lastActiveAt = user.lastActiveAt ?? null;
@@ -7175,13 +7391,15 @@ const getUserDetails = (options) => {
7175
7391
  updateActivity();
7176
7392
  }
7177
7393
  }
7394
+ const { session: _joinedSessions, ...userWithoutSessions } = user;
7178
7395
  return {
7179
- ...user,
7180
- ...secondaryStorageOnly ? { session: sessions } : {},
7396
+ ...userWithoutSessions,
7397
+ session: sanitizeSessions(sessions),
7181
7398
  lastActiveAt,
7182
7399
  banned: hasAdminPlugin ? user.banned ?? false : false,
7183
7400
  banReason: hasAdminPlugin ? user.banReason ?? null : null,
7184
- banExpires: hasAdminPlugin ? user.banExpires ?? null : null
7401
+ banExpires: hasAdminPlugin ? user.banExpires ?? null : null,
7402
+ twoFactorStatus
7185
7403
  };
7186
7404
  });
7187
7405
  };
@@ -7234,20 +7452,19 @@ const getUserOrganizations = (options) => {
7234
7452
  const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
7235
7453
  method: "POST",
7236
7454
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7237
- body: z$1.looseObject({
7238
- name: z$1.string().optional(),
7239
- email: z$1.email().optional(),
7240
- image: z$1.string().optional(),
7241
- emailVerified: z$1.boolean().optional()
7242
- })
7455
+ body: UpdateUserBodySchema
7243
7456
  }, async (ctx) => {
7244
- const updateData = ctx.body;
7245
7457
  const userId = ctx.context.payload?.userId;
7246
7458
  if (!userId) throw new APIError("FORBIDDEN", { message: "Invalid payload" });
7247
- const filteredData = Object.fromEntries(Object.entries(updateData).filter(([, value]) => value !== void 0));
7248
- 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
+ }
7249
7466
  const user = await ctx.context.internalAdapter.updateUser(userId, {
7250
- ...filteredData,
7467
+ ...updateData,
7251
7468
  updatedAt: /* @__PURE__ */ new Date()
7252
7469
  });
7253
7470
  if (!user) throw new APIError("NOT_FOUND", { message: "User not found" });
@@ -7711,11 +7928,12 @@ const banUser = (options) => createAuthEndpoint("/dash/ban-user", {
7711
7928
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7712
7929
  body: z$1.object({
7713
7930
  banReason: z$1.string().optional(),
7714
- banExpires: z$1.number().optional()
7931
+ banExpires: z$1.number().optional(),
7932
+ deleteAllSessions: z$1.boolean().optional().default(true)
7715
7933
  })
7716
7934
  }, async (ctx) => {
7717
7935
  const { userId } = ctx.context.payload;
7718
- const { banReason, banExpires } = ctx.body;
7936
+ const { banReason, banExpires, deleteAllSessions } = ctx.body;
7719
7937
  if (!await ctx.context.internalAdapter.findUserById(userId)) throw new APIError("NOT_FOUND", { message: "User not found" });
7720
7938
  await ctx.context.internalAdapter.updateUser(userId, {
7721
7939
  banned: true,
@@ -7723,7 +7941,7 @@ const banUser = (options) => createAuthEndpoint("/dash/ban-user", {
7723
7941
  banExpires: banExpires ? new Date(banExpires) : null,
7724
7942
  updatedAt: /* @__PURE__ */ new Date()
7725
7943
  });
7726
- await ctx.context.internalAdapter.deleteUserSessions(userId);
7944
+ if (deleteAllSessions) await deleteUserSessionsCompat(ctx.context.internalAdapter, userId);
7727
7945
  return { success: true };
7728
7946
  });
7729
7947
  const banManyUsers = (options) => {
@@ -7732,11 +7950,12 @@ const banManyUsers = (options) => {
7732
7950
  use: [jwtMiddleware(options, z$1.object({ userIds: z$1.string().array() }))],
7733
7951
  body: z$1.object({
7734
7952
  banReason: z$1.string().optional(),
7735
- banExpires: z$1.number().optional()
7953
+ banExpires: z$1.number().optional(),
7954
+ deleteAllSessions: z$1.boolean().optional().default(true)
7736
7955
  })
7737
7956
  }, async (ctx) => {
7738
7957
  const { userIds } = ctx.context.payload;
7739
- const { banReason, banExpires } = ctx.body;
7958
+ const { banReason, banExpires, deleteAllSessions } = ctx.body;
7740
7959
  const start = performance.now();
7741
7960
  await withConcurrency(chunkArray(userIds, { batchSize: 50 }), async (chunk) => {
7742
7961
  await ctx.context.adapter.updateMany({
@@ -7768,6 +7987,9 @@ const banManyUsers = (options) => {
7768
7987
  select: ["id"]
7769
7988
  })).map((u) => u.id);
7770
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 });
7771
7993
  const end = performance.now();
7772
7994
  console.log(`Time taken to ban ${bannedUserIds.length} users: ${(end - start) / 1e3}s`, skippedUserIds.length > 0 ? `Skipped: ${skippedUserIds.length}` : "");
7773
7995
  return ctx.json({
@@ -7794,7 +8016,7 @@ const unbanUser = (options) => createAuthEndpoint("/dash/unban-user", {
7794
8016
  const sendVerificationEmail = (options) => createAuthEndpoint("/dash/send-verification-email", {
7795
8017
  method: "POST",
7796
8018
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7797
- body: z$1.object({ callbackUrl: z$1.url() })
8019
+ body: z$1.object({ callbackUrl: safeUrlSchema })
7798
8020
  }, async (ctx) => {
7799
8021
  const { userId } = ctx.context.payload;
7800
8022
  const { callbackUrl } = ctx.body;
@@ -7816,7 +8038,7 @@ const sendManyVerificationEmails = (options) => {
7816
8038
  return createAuthEndpoint("/dash/send-many-verification-emails", {
7817
8039
  method: "POST",
7818
8040
  use: [jwtMiddleware(options, z$1.object({ userIds: z$1.string().array() }))],
7819
- body: z$1.object({ callbackUrl: z$1.url() })
8041
+ body: z$1.object({ callbackUrl: safeUrlSchema })
7820
8042
  }, async (ctx) => {
7821
8043
  if (!ctx.context.options.emailVerification?.sendVerificationEmail) throw ctx.error("BAD_REQUEST", { message: "Email verification is not enabled" });
7822
8044
  const { userIds } = ctx.context.payload;
@@ -7876,7 +8098,7 @@ const sendManyVerificationEmails = (options) => {
7876
8098
  const sendResetPasswordEmail = (options) => createAuthEndpoint("/dash/send-reset-password-email", {
7877
8099
  method: "POST",
7878
8100
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))],
7879
- body: z$1.object({ callbackUrl: z$1.url() })
8101
+ body: z$1.object({ callbackUrl: safeUrlSchema })
7880
8102
  }, async (ctx) => {
7881
8103
  const { userId } = ctx.context.payload;
7882
8104
  const user = await ctx.context.internalAdapter.findUserById(userId);
@@ -7968,6 +8190,8 @@ const SMS_TEMPLATES = {
7968
8190
  "two-factor": { variables: {} },
7969
8191
  "sign-in-otp": { variables: {} }
7970
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";
7971
8195
  /**
7972
8196
  * Create an SMS sender instance
7973
8197
  */
@@ -7978,7 +8202,10 @@ function createSMSSender(config) {
7978
8202
  if (!apiKey) logger.warn("[Dash] No API key provided for SMS sending. Set BETTER_AUTH_API_KEY environment variable or pass apiKey in config.");
7979
8203
  const $api = createFetch({
7980
8204
  baseURL: apiUrl,
7981
- headers: { Authorization: `Bearer ${apiKey}` },
8205
+ headers: {
8206
+ "user-agent": INFRA_USER_AGENT,
8207
+ Authorization: `Bearer ${apiKey}`
8208
+ },
7982
8209
  timeout: config?.apiTimeout ?? 3e3
7983
8210
  });
7984
8211
  /**
@@ -7996,7 +8223,12 @@ function createSMSSender(config) {
7996
8223
  to: options.to,
7997
8224
  code: options.code,
7998
8225
  template: options.template
7999
- }
8226
+ },
8227
+ ...options.clientIp ? { headers: {
8228
+ Authorization: `Bearer ${apiKey}`,
8229
+ "user-agent": INFRA_USER_AGENT,
8230
+ [BETTER_AUTH_CLIENT_IP_HEADER]: options.clientIp
8231
+ } } : {}
8000
8232
  });
8001
8233
  if (error) return {
8002
8234
  success: false,
@@ -8084,6 +8316,7 @@ const dash = (options) => {
8084
8316
  options: opts,
8085
8317
  version: PLUGIN_VERSION,
8086
8318
  init(ctx) {
8319
+ instrumentSocialProviders(ctx.socialProviders);
8087
8320
  const organizationPlugin = ctx.getPlugin("organization");
8088
8321
  if (organizationPlugin) {
8089
8322
  const instrumentOrganizationHooks = (organizationPluginOptions) => {
@@ -8313,7 +8546,8 @@ const dash = (options) => {
8313
8546
  routes.CHANGE_PASSWORD,
8314
8547
  routes.SET_PASSWORD,
8315
8548
  routes.RESET_PASSWORD,
8316
- routes.ADMIN_SET_PASSWORD
8549
+ routes.ADMIN_SET_PASSWORD,
8550
+ routes.DASH_SET_PASSWORD
8317
8551
  ])) trackAccountPasswordChange(account, trigger, ctx, location);
8318
8552
  } },
8319
8553
  delete: { async after(account, _ctx) {
@@ -8349,11 +8583,14 @@ const dash = (options) => {
8349
8583
  before: [{
8350
8584
  matcher: (ctx) => {
8351
8585
  if (ctx.request?.method !== "GET") return true;
8352
- const path = new URL(ctx.request.url).pathname;
8353
- return matchesAnyRoute(path, [
8586
+ return matchesAnyRoute(ctx.path, [
8354
8587
  routes.SIGN_IN_SOCIAL_CALLBACK,
8355
8588
  routes.SIGN_IN_OAUTH_CALLBACK,
8356
- routes.DASH_IMPERSONATE_USER
8589
+ routes.DASH_IMPERSONATE_USER,
8590
+ routes.VERIFY_EMAIL,
8591
+ routes.MAGIC_LINK_VERIFY,
8592
+ routes.DASH_ACCEPT_INVITATION,
8593
+ routes.DASH_COMPLETE_INVITATION_SOCIAL
8357
8594
  ]);
8358
8595
  },
8359
8596
  handler: createIdentificationMiddleware($kv)
@@ -8361,8 +8598,7 @@ const dash = (options) => {
8361
8598
  after: [{
8362
8599
  matcher: (ctx) => {
8363
8600
  if (ctx.request?.method !== "GET") return true;
8364
- const path = new URL(ctx.request.url).pathname;
8365
- return matchesAnyRoute(path, [
8601
+ return matchesAnyRoute(ctx.path, [
8366
8602
  routes.SIGN_IN_SOCIAL_CALLBACK,
8367
8603
  routes.SIGN_IN_OAUTH_CALLBACK,
8368
8604
  routes.DASH_IMPERSONATE_USER
@@ -8371,11 +8607,11 @@ const dash = (options) => {
8371
8607
  handler: createAuthMiddleware(async (_ctx) => {
8372
8608
  const ctx = _ctx;
8373
8609
  const trigger = getTriggerInfo(ctx, ctx.context.session?.user.id ?? "unknown");
8374
- 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);
8375
8611
  const body = ctx.body;
8376
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);
8377
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);
8378
- 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);
8379
8615
  const headerRequestId = ctx.request?.headers.get("X-Request-Id");
8380
8616
  if (headerRequestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
8381
8617
  maxAge: 600,
@@ -8470,6 +8706,7 @@ const dash = (options) => {
8470
8706
  dashSendManyVerificationEmails: sendManyVerificationEmails(settings),
8471
8707
  dashSendResetPasswordEmail: sendResetPasswordEmail(settings),
8472
8708
  dashEnableTwoFactor: enableTwoFactor(settings),
8709
+ dashCompleteTwoFactorSetup: completeTwoFactorSetup(settings),
8473
8710
  dashViewTwoFactorTotpUri: viewTwoFactorTotpUri(settings),
8474
8711
  dashViewBackupCodes: viewBackupCodes(settings),
8475
8712
  dashDisableTwoFactor: disableTwoFactor(settings),