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