@better-auth/infra 0.1.13 → 0.2.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/README.md +44 -0
- package/dist/client.d.mts +7 -73
- package/dist/client.mjs +9 -95
- package/dist/dash-client-hJHp7l_X.d.mts +72 -0
- package/dist/index.d.mts +867 -1090
- package/dist/index.mjs +1174 -1094
- package/dist/native.d.mts +18 -0
- package/dist/native.mjs +292 -0
- package/dist/pow-BUuN_EKw.mjs +131 -0
- package/package.json +34 -7
package/dist/index.mjs
CHANGED
|
@@ -9,7 +9,6 @@ import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-j
|
|
|
9
9
|
import { createLocalJWKSet, jwtVerify } from "jose";
|
|
10
10
|
import z$1, { z } from "zod";
|
|
11
11
|
import { setSessionCookie } from "better-auth/cookies";
|
|
12
|
-
import { DEFAULT_MAX_SAML_METADATA_SIZE, DigestAlgorithm, DiscoveryError, SignatureAlgorithm, discoverOIDCConfig } from "@better-auth/sso";
|
|
13
12
|
//#region src/options.ts
|
|
14
13
|
function resolveConnectionOptions(options) {
|
|
15
14
|
return {
|
|
@@ -128,6 +127,11 @@ const ORGANIZATION_EVENT_TYPES = {
|
|
|
128
127
|
ORGANIZATION_TEAM_MEMBER_ADDED: "organization_team_member_added",
|
|
129
128
|
ORGANIZATION_TEAM_MEMBER_REMOVED: "organization_team_member_removed"
|
|
130
129
|
};
|
|
130
|
+
/** All audit event type string constants (user + organization). */
|
|
131
|
+
const USER_EVENT_TYPES = {
|
|
132
|
+
...EVENT_TYPES,
|
|
133
|
+
...ORGANIZATION_EVENT_TYPES
|
|
134
|
+
};
|
|
131
135
|
//#endregion
|
|
132
136
|
//#region src/events/core/adapter.ts
|
|
133
137
|
const getUserByEmail = async (email, ctx) => {
|
|
@@ -993,1020 +997,1011 @@ function getCountryCodeFromRequest(request) {
|
|
|
993
997
|
return cc ? cc.toUpperCase() : void 0;
|
|
994
998
|
}
|
|
995
999
|
//#endregion
|
|
996
|
-
//#region src/
|
|
1000
|
+
//#region src/validation/matchers.ts
|
|
1001
|
+
const paths = [
|
|
1002
|
+
"/sign-up/email",
|
|
1003
|
+
"/email-otp/verify-email",
|
|
1004
|
+
"/sign-in/email-otp",
|
|
1005
|
+
"/sign-in/magic-link",
|
|
1006
|
+
"/sign-in/email",
|
|
1007
|
+
"/forget-password/email-otp",
|
|
1008
|
+
"/email-otp/reset-password",
|
|
1009
|
+
"/email-otp/create-verification-otp",
|
|
1010
|
+
"/email-otp/get-verification-otp",
|
|
1011
|
+
"/email-otp/send-verification-otp",
|
|
1012
|
+
"/forget-password",
|
|
1013
|
+
"/request-password-reset",
|
|
1014
|
+
"/send-verification-email",
|
|
1015
|
+
"/change-email"
|
|
1016
|
+
];
|
|
1017
|
+
const all = new Set(paths);
|
|
1018
|
+
new Set(paths.slice(1, 12));
|
|
1019
|
+
/**
|
|
1020
|
+
* Path is one of `[
|
|
1021
|
+
* '/sign-up/email',
|
|
1022
|
+
* '/email-otp/verify-email',
|
|
1023
|
+
* '/sign-in/email-otp',
|
|
1024
|
+
* '/sign-in/magic-link',
|
|
1025
|
+
* '/sign-in/email',
|
|
1026
|
+
* '/forget-password/email-otp',
|
|
1027
|
+
* '/email-otp/reset-password',
|
|
1028
|
+
* '/email-otp/create-verification-otp',
|
|
1029
|
+
* '/email-otp/get-verification-otp',
|
|
1030
|
+
* '/email-otp/send-verification-otp',
|
|
1031
|
+
* '/forget-password',
|
|
1032
|
+
* '/request-password-reset',
|
|
1033
|
+
* '/send-verification-email',
|
|
1034
|
+
* '/change-email'
|
|
1035
|
+
* ]`.
|
|
1036
|
+
* @param context Request context
|
|
1037
|
+
* @param context.path Request path
|
|
1038
|
+
* @returns boolean
|
|
1039
|
+
*/
|
|
1040
|
+
const allEmail = ({ path }) => !!path && all.has(path);
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/validation/email.ts
|
|
1043
|
+
/**
|
|
1044
|
+
* Gmail-like providers that ignore dots in the local part
|
|
1045
|
+
*/
|
|
1046
|
+
const GMAIL_LIKE_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
|
|
1047
|
+
/**
|
|
1048
|
+
* Providers known to support plus addressing
|
|
1049
|
+
*/
|
|
1050
|
+
const PLUS_ADDRESSING_DOMAINS = new Set([
|
|
1051
|
+
"gmail.com",
|
|
1052
|
+
"googlemail.com",
|
|
1053
|
+
"outlook.com",
|
|
1054
|
+
"hotmail.com",
|
|
1055
|
+
"live.com",
|
|
1056
|
+
"yahoo.com",
|
|
1057
|
+
"icloud.com",
|
|
1058
|
+
"me.com",
|
|
1059
|
+
"mac.com",
|
|
1060
|
+
"protonmail.com",
|
|
1061
|
+
"proton.me",
|
|
1062
|
+
"fastmail.com",
|
|
1063
|
+
"zoho.com"
|
|
1064
|
+
]);
|
|
997
1065
|
/**
|
|
998
|
-
*
|
|
1066
|
+
* Normalize an email address for comparison/deduplication
|
|
1067
|
+
* - Lowercase the entire email
|
|
1068
|
+
* - Remove dots from Gmail-like providers (they ignore dots)
|
|
1069
|
+
* - Remove plus addressing (user+tag@domain → user@domain)
|
|
1070
|
+
* - Normalize googlemail.com to gmail.com
|
|
999
1071
|
*
|
|
1000
|
-
*
|
|
1072
|
+
* @param email - Raw email to normalize
|
|
1073
|
+
* @param context - Auth context with getPlugin (for sentinel policy)
|
|
1001
1074
|
*/
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1075
|
+
function normalizeEmail(email, context) {
|
|
1076
|
+
if (!email || typeof email !== "string") return email;
|
|
1077
|
+
if ((context.getPlugin?.("sentinel"))?.options?.emailValidation?.enabled === false) return email;
|
|
1078
|
+
const trimmed = email.trim().toLowerCase();
|
|
1079
|
+
const atIndex = trimmed.lastIndexOf("@");
|
|
1080
|
+
if (atIndex === -1) return trimmed;
|
|
1081
|
+
let localPart = trimmed.slice(0, atIndex);
|
|
1082
|
+
let domain = trimmed.slice(atIndex + 1);
|
|
1083
|
+
if (domain === "googlemail.com") domain = "gmail.com";
|
|
1084
|
+
if (PLUS_ADDRESSING_DOMAINS.has(domain)) {
|
|
1085
|
+
const plusIndex = localPart.indexOf("+");
|
|
1086
|
+
if (plusIndex !== -1) localPart = localPart.slice(0, plusIndex);
|
|
1087
|
+
}
|
|
1088
|
+
if (GMAIL_LIKE_DOMAINS.has(domain)) localPart = localPart.replace(/\./g, "");
|
|
1089
|
+
return `${localPart}@${domain}`;
|
|
1011
1090
|
}
|
|
1012
|
-
function
|
|
1013
|
-
const
|
|
1091
|
+
function createEmailValidator(options = {}) {
|
|
1092
|
+
const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig = {} } = options;
|
|
1014
1093
|
const $api = createFetch({
|
|
1015
|
-
baseURL:
|
|
1016
|
-
headers: { "x-api-key": apiKey }
|
|
1017
|
-
throw: true
|
|
1094
|
+
baseURL: apiUrl,
|
|
1095
|
+
headers: { "x-api-key": apiKey }
|
|
1018
1096
|
});
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
apiKey
|
|
1097
|
+
const $kv = createFetch({
|
|
1098
|
+
baseURL: kvUrl,
|
|
1099
|
+
headers: { "x-api-key": apiKey },
|
|
1100
|
+
timeout: KV_TIMEOUT_MS
|
|
1022
1101
|
});
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1102
|
+
/**
|
|
1103
|
+
* Fetch and resolve email validity policy from API with caching
|
|
1104
|
+
* Sends client config to API which merges with user's dashboard settings
|
|
1105
|
+
*/
|
|
1106
|
+
async function fetchPolicy() {
|
|
1107
|
+
try {
|
|
1108
|
+
const { data } = await $api("/security/resolve-policy", {
|
|
1109
|
+
method: "POST",
|
|
1110
|
+
body: {
|
|
1111
|
+
policyId: "email_validity",
|
|
1112
|
+
config: { emailValidation: {
|
|
1113
|
+
enabled: defaultConfig.enabled,
|
|
1114
|
+
strictness: defaultConfig.strictness,
|
|
1115
|
+
action: defaultConfig.action,
|
|
1116
|
+
domainAllowlist: defaultConfig.domainAllowlist
|
|
1117
|
+
} }
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
if (data?.policy) return data.policy;
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
logger.warn("[Dash] Failed to fetch email policy, using defaults:", error);
|
|
1123
|
+
}
|
|
1124
|
+
return null;
|
|
1029
1125
|
}
|
|
1030
|
-
return {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
} catch (error) {
|
|
1051
|
-
logger.error("[Dash] Security check failed:", error);
|
|
1052
|
-
return { action: "allow" };
|
|
1053
|
-
}
|
|
1054
|
-
},
|
|
1055
|
-
mapReasonToEventType(reason) {
|
|
1056
|
-
switch (reason) {
|
|
1057
|
-
case "geo_blocked": return "geo_blocked";
|
|
1058
|
-
case "bot_detected": return "bot_blocked";
|
|
1059
|
-
case "suspicious_ip_detected": return "suspicious_ip_detected";
|
|
1060
|
-
case "rate_limited": return "velocity_exceeded";
|
|
1061
|
-
case "credential_stuffing_cooldown": return "credential_stuffing";
|
|
1062
|
-
default: return "credential_stuffing";
|
|
1063
|
-
}
|
|
1064
|
-
},
|
|
1065
|
-
async trackFailedAttempt(identifier, visitorId, password, ip) {
|
|
1066
|
-
try {
|
|
1067
|
-
const data = await $api("/security/track-failed-login", {
|
|
1068
|
-
method: "POST",
|
|
1069
|
-
body: {
|
|
1070
|
-
identifier,
|
|
1071
|
-
visitorId,
|
|
1072
|
-
passwordHash: await hashForFingerprint(password),
|
|
1073
|
-
ip,
|
|
1074
|
-
config: options
|
|
1075
|
-
}
|
|
1076
|
-
});
|
|
1077
|
-
if (data.blocked || data.challenged) logEvent({
|
|
1078
|
-
type: "credential_stuffing",
|
|
1079
|
-
userId: null,
|
|
1080
|
-
visitorId,
|
|
1081
|
-
ip,
|
|
1082
|
-
country: null,
|
|
1083
|
-
details: data.details || { reason: data.reason },
|
|
1084
|
-
action: data.blocked ? "blocked" : "challenged"
|
|
1085
|
-
});
|
|
1086
|
-
return data;
|
|
1087
|
-
} catch (error) {
|
|
1088
|
-
logger.error("[Dash] Track failed attempt error:", error);
|
|
1089
|
-
return { blocked: false };
|
|
1090
|
-
}
|
|
1091
|
-
},
|
|
1092
|
-
async clearFailedAttempts(identifier) {
|
|
1093
|
-
try {
|
|
1094
|
-
await $api("/security/clear-failed-attempts", {
|
|
1095
|
-
method: "POST",
|
|
1096
|
-
body: { identifier }
|
|
1097
|
-
});
|
|
1098
|
-
} catch (error) {
|
|
1099
|
-
logger.error("[Dash] Clear failed attempts error:", error);
|
|
1100
|
-
}
|
|
1101
|
-
},
|
|
1102
|
-
async isBlocked(visitorId) {
|
|
1103
|
-
try {
|
|
1104
|
-
return (await $api(`/security/is-blocked?visitorId=${encodeURIComponent(visitorId)}`, { method: "GET" })).blocked ?? false;
|
|
1105
|
-
} catch (error) {
|
|
1106
|
-
logger.warn("[Dash] Security is-blocked check failed:", error);
|
|
1107
|
-
return false;
|
|
1108
|
-
}
|
|
1109
|
-
},
|
|
1110
|
-
async verifyPoWSolution(visitorId, solution) {
|
|
1111
|
-
try {
|
|
1112
|
-
return await $api("/security/pow/verify", {
|
|
1113
|
-
method: "POST",
|
|
1114
|
-
body: {
|
|
1115
|
-
visitorId,
|
|
1116
|
-
solution
|
|
1117
|
-
}
|
|
1118
|
-
});
|
|
1119
|
-
} catch (error) {
|
|
1120
|
-
logger.warn("[Dash] PoW verify failed:", error);
|
|
1121
|
-
return {
|
|
1126
|
+
return { async validate(email, checkMx = true) {
|
|
1127
|
+
const trimmed = email.trim();
|
|
1128
|
+
const policy = await fetchPolicy();
|
|
1129
|
+
if (!policy?.enabled) return {
|
|
1130
|
+
valid: true,
|
|
1131
|
+
disposable: false,
|
|
1132
|
+
confidence: "high",
|
|
1133
|
+
policy
|
|
1134
|
+
};
|
|
1135
|
+
try {
|
|
1136
|
+
const { data } = await $kv("/email/validate", {
|
|
1137
|
+
method: "POST",
|
|
1138
|
+
body: {
|
|
1139
|
+
email: trimmed,
|
|
1140
|
+
checkMx,
|
|
1141
|
+
strictness: policy.strictness
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
return {
|
|
1145
|
+
...data || {
|
|
1122
1146
|
valid: false,
|
|
1123
|
-
reason: "
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
},
|
|
1127
|
-
async generateChallenge(visitorId) {
|
|
1128
|
-
try {
|
|
1129
|
-
return (await $api("/security/pow/generate", {
|
|
1130
|
-
method: "POST",
|
|
1131
|
-
body: {
|
|
1132
|
-
visitorId,
|
|
1133
|
-
difficulty: options.challengeDifficulty
|
|
1134
|
-
}
|
|
1135
|
-
})).challenge || "";
|
|
1136
|
-
} catch (error) {
|
|
1137
|
-
logger.warn("[Dash] PoW generate challenge failed:", error);
|
|
1138
|
-
return "";
|
|
1139
|
-
}
|
|
1140
|
-
},
|
|
1141
|
-
async checkImpossibleTravel(userId, currentLocation, visitorId) {
|
|
1142
|
-
if (!options.impossibleTravel?.enabled || !currentLocation) return null;
|
|
1143
|
-
try {
|
|
1144
|
-
const data = await $api("/security/impossible-travel", {
|
|
1145
|
-
method: "POST",
|
|
1146
|
-
body: {
|
|
1147
|
-
userId,
|
|
1148
|
-
visitorId,
|
|
1149
|
-
location: currentLocation,
|
|
1150
|
-
config: options
|
|
1151
|
-
}
|
|
1152
|
-
});
|
|
1153
|
-
if (data.isImpossible) {
|
|
1154
|
-
const actionTaken = data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged";
|
|
1155
|
-
logEvent({
|
|
1156
|
-
type: "impossible_travel",
|
|
1157
|
-
userId,
|
|
1158
|
-
visitorId: visitorId || null,
|
|
1159
|
-
ip: null,
|
|
1160
|
-
country: currentLocation.country?.code || null,
|
|
1161
|
-
details: {
|
|
1162
|
-
from: data.from,
|
|
1163
|
-
to: data.to,
|
|
1164
|
-
distance: data.distance,
|
|
1165
|
-
speedRequired: data.speedRequired,
|
|
1166
|
-
action: data.action
|
|
1167
|
-
},
|
|
1168
|
-
action: actionTaken
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
return data;
|
|
1172
|
-
} catch (error) {
|
|
1173
|
-
logger.warn("[Dash] Impossible travel check failed:", error);
|
|
1174
|
-
return null;
|
|
1175
|
-
}
|
|
1176
|
-
},
|
|
1177
|
-
async storeLastLocation(userId, location) {
|
|
1178
|
-
if (!location) return;
|
|
1179
|
-
try {
|
|
1180
|
-
await $api("/security/store-last-login", {
|
|
1181
|
-
method: "POST",
|
|
1182
|
-
body: {
|
|
1183
|
-
userId,
|
|
1184
|
-
location
|
|
1185
|
-
}
|
|
1186
|
-
});
|
|
1187
|
-
} catch (error) {
|
|
1188
|
-
logger.error("[Dash] Store last location error:", error);
|
|
1189
|
-
}
|
|
1190
|
-
},
|
|
1191
|
-
async checkFreeTrialAbuse(visitorId) {
|
|
1192
|
-
if (!options.freeTrialAbuse?.enabled) return {
|
|
1193
|
-
isAbuse: false,
|
|
1194
|
-
accountCount: 0,
|
|
1195
|
-
maxAccounts: 0,
|
|
1196
|
-
action: "log"
|
|
1197
|
-
};
|
|
1198
|
-
try {
|
|
1199
|
-
const data = await $api("/security/free-trial-abuse/check", {
|
|
1200
|
-
method: "POST",
|
|
1201
|
-
body: {
|
|
1202
|
-
visitorId,
|
|
1203
|
-
config: options
|
|
1204
|
-
}
|
|
1205
|
-
});
|
|
1206
|
-
if (data.isAbuse) logEvent({
|
|
1207
|
-
type: "free_trial_abuse",
|
|
1208
|
-
userId: null,
|
|
1209
|
-
visitorId,
|
|
1210
|
-
ip: null,
|
|
1211
|
-
country: null,
|
|
1212
|
-
details: {
|
|
1213
|
-
accountCount: data.accountCount,
|
|
1214
|
-
maxAccounts: data.maxAccounts
|
|
1215
|
-
},
|
|
1216
|
-
action: data.action === "block" ? "blocked" : "logged"
|
|
1217
|
-
});
|
|
1218
|
-
return data;
|
|
1219
|
-
} catch (error) {
|
|
1220
|
-
logger.warn("[Dash] Free trial abuse check failed:", error);
|
|
1221
|
-
return {
|
|
1222
|
-
isAbuse: false,
|
|
1223
|
-
accountCount: 0,
|
|
1224
|
-
maxAccounts: 0,
|
|
1225
|
-
action: "log"
|
|
1226
|
-
};
|
|
1227
|
-
}
|
|
1228
|
-
},
|
|
1229
|
-
async trackFreeTrialSignup(visitorId, userId) {
|
|
1230
|
-
if (!options.freeTrialAbuse?.enabled) return;
|
|
1231
|
-
try {
|
|
1232
|
-
await $api("/security/free-trial-abuse/track", {
|
|
1233
|
-
method: "POST",
|
|
1234
|
-
body: {
|
|
1235
|
-
visitorId,
|
|
1236
|
-
userId
|
|
1237
|
-
}
|
|
1238
|
-
});
|
|
1239
|
-
} catch (error) {
|
|
1240
|
-
logger.error("[Dash] Track free trial signup error:", error);
|
|
1241
|
-
}
|
|
1242
|
-
},
|
|
1243
|
-
async checkCompromisedPassword(password) {
|
|
1244
|
-
try {
|
|
1245
|
-
const hash = await sha1Hash(password);
|
|
1246
|
-
const prefix = hash.substring(0, 5);
|
|
1247
|
-
const suffix = hash.substring(5);
|
|
1248
|
-
const data = await $api("/security/breached-passwords", {
|
|
1249
|
-
method: "POST",
|
|
1250
|
-
body: {
|
|
1251
|
-
passwordPrefix: prefix,
|
|
1252
|
-
config: options
|
|
1253
|
-
}
|
|
1254
|
-
});
|
|
1255
|
-
if (!data.enabled) return { compromised: false };
|
|
1256
|
-
const breachCount = (data.suffixes || {})[suffix] || 0;
|
|
1257
|
-
const minBreachCount = data.minBreachCount ?? 1;
|
|
1258
|
-
const action = data.action || "block";
|
|
1259
|
-
const compromised = breachCount >= minBreachCount;
|
|
1260
|
-
if (compromised) logEvent({
|
|
1261
|
-
type: "compromised_password",
|
|
1262
|
-
userId: null,
|
|
1263
|
-
visitorId: null,
|
|
1264
|
-
ip: null,
|
|
1265
|
-
country: null,
|
|
1266
|
-
details: { breachCount },
|
|
1267
|
-
action: action === "block" ? "blocked" : action === "challenge" ? "challenged" : "logged"
|
|
1268
|
-
});
|
|
1269
|
-
return {
|
|
1270
|
-
compromised,
|
|
1271
|
-
breachCount: breachCount > 0 ? breachCount : void 0,
|
|
1272
|
-
action: compromised ? action : void 0
|
|
1273
|
-
};
|
|
1274
|
-
} catch (error) {
|
|
1275
|
-
logger.error("[Dash] Compromised password check error:", error);
|
|
1276
|
-
return { compromised: false };
|
|
1277
|
-
}
|
|
1278
|
-
},
|
|
1279
|
-
async checkStaleUser(userId, lastActiveAt) {
|
|
1280
|
-
if (!options.staleUsers?.enabled) return { isStale: false };
|
|
1281
|
-
try {
|
|
1282
|
-
const data = await $api("/security/stale-user", {
|
|
1283
|
-
method: "POST",
|
|
1284
|
-
body: {
|
|
1285
|
-
userId,
|
|
1286
|
-
lastActiveAt: lastActiveAt instanceof Date ? lastActiveAt.toISOString() : lastActiveAt,
|
|
1287
|
-
config: options
|
|
1288
|
-
}
|
|
1289
|
-
});
|
|
1290
|
-
if (data.isStale) logEvent({
|
|
1291
|
-
type: "stale_account_reactivation",
|
|
1292
|
-
userId,
|
|
1293
|
-
visitorId: null,
|
|
1294
|
-
ip: null,
|
|
1295
|
-
country: null,
|
|
1296
|
-
details: {
|
|
1297
|
-
daysSinceLastActive: data.daysSinceLastActive,
|
|
1298
|
-
staleDays: data.staleDays,
|
|
1299
|
-
lastActiveAt: data.lastActiveAt,
|
|
1300
|
-
notifyUser: data.notifyUser,
|
|
1301
|
-
notifyAdmin: data.notifyAdmin
|
|
1302
|
-
},
|
|
1303
|
-
action: data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged"
|
|
1304
|
-
});
|
|
1305
|
-
return data;
|
|
1306
|
-
} catch (error) {
|
|
1307
|
-
logger.error("[Dash] Stale user check error:", error);
|
|
1308
|
-
return { isStale: false };
|
|
1309
|
-
}
|
|
1310
|
-
},
|
|
1311
|
-
async notifyStaleAccountUser(userEmail, userName, daysSinceLastActive, identification, appName) {
|
|
1312
|
-
const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
|
|
1313
|
-
dateStyle: "long",
|
|
1314
|
-
timeStyle: "short",
|
|
1315
|
-
timeZone: "UTC"
|
|
1316
|
-
}) + " UTC";
|
|
1317
|
-
const location = identification?.location;
|
|
1318
|
-
const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
|
|
1319
|
-
const browser = identification?.browser;
|
|
1320
|
-
const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
|
|
1321
|
-
const result = await emailSender.send({
|
|
1322
|
-
template: "stale-account-user",
|
|
1323
|
-
to: userEmail,
|
|
1324
|
-
variables: {
|
|
1325
|
-
userEmail,
|
|
1326
|
-
userName: userName || "User",
|
|
1327
|
-
appName: appName || "Your App",
|
|
1328
|
-
daysSinceLastActive: String(daysSinceLastActive),
|
|
1329
|
-
loginTime,
|
|
1330
|
-
loginLocation,
|
|
1331
|
-
loginDevice,
|
|
1332
|
-
loginIp: identification?.ip || "Unknown"
|
|
1333
|
-
}
|
|
1334
|
-
});
|
|
1335
|
-
if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
|
|
1336
|
-
else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
|
|
1337
|
-
},
|
|
1338
|
-
async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
|
|
1339
|
-
const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
|
|
1340
|
-
dateStyle: "long",
|
|
1341
|
-
timeStyle: "short",
|
|
1342
|
-
timeZone: "UTC"
|
|
1343
|
-
}) + " UTC";
|
|
1344
|
-
const location = identification?.location;
|
|
1345
|
-
const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
|
|
1346
|
-
const browser = identification?.browser;
|
|
1347
|
-
const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
|
|
1348
|
-
const result = await emailSender.send({
|
|
1349
|
-
template: "stale-account-admin",
|
|
1350
|
-
to: adminEmail,
|
|
1351
|
-
variables: {
|
|
1352
|
-
userEmail,
|
|
1353
|
-
userName: userName || "User",
|
|
1354
|
-
userId,
|
|
1355
|
-
appName: appName || "Your App",
|
|
1356
|
-
daysSinceLastActive: String(daysSinceLastActive),
|
|
1357
|
-
loginTime,
|
|
1358
|
-
loginLocation,
|
|
1359
|
-
loginDevice,
|
|
1360
|
-
loginIp: identification?.ip || "Unknown",
|
|
1361
|
-
adminEmail
|
|
1362
|
-
}
|
|
1363
|
-
});
|
|
1364
|
-
if (result.success) logger.info(`[Dash] Stale account admin notification sent to: ${adminEmail}`);
|
|
1365
|
-
else logger.error(`[Dash] Failed to send stale account admin notification: ${result.error}`);
|
|
1366
|
-
},
|
|
1367
|
-
async checkUnknownDevice(_userId, _visitorId) {
|
|
1368
|
-
return false;
|
|
1369
|
-
},
|
|
1370
|
-
async notifyUnknownDevice(userId, email, identification) {
|
|
1371
|
-
logEvent({
|
|
1372
|
-
type: "unknown_device",
|
|
1373
|
-
userId,
|
|
1374
|
-
visitorId: identification?.visitorId || null,
|
|
1375
|
-
ip: identification?.ip || null,
|
|
1376
|
-
country: identification?.location?.country?.code || null,
|
|
1377
|
-
details: {
|
|
1378
|
-
email,
|
|
1379
|
-
device: identification?.browser.device,
|
|
1380
|
-
os: identification?.browser.os,
|
|
1381
|
-
browser: identification?.browser.name,
|
|
1382
|
-
city: identification?.location?.city,
|
|
1383
|
-
country: identification?.location?.country?.name
|
|
1147
|
+
reason: "invalid_format"
|
|
1384
1148
|
},
|
|
1385
|
-
|
|
1386
|
-
}
|
|
1149
|
+
policy
|
|
1150
|
+
};
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
logger.warn("[Dash] Email validation API error, falling back to allow:", error);
|
|
1153
|
+
return {
|
|
1154
|
+
valid: true,
|
|
1155
|
+
policy
|
|
1156
|
+
};
|
|
1387
1157
|
}
|
|
1388
|
-
};
|
|
1158
|
+
} };
|
|
1389
1159
|
}
|
|
1390
|
-
//#endregion
|
|
1391
|
-
//#region src/security-hooks.ts
|
|
1392
1160
|
/**
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
* Consolidated security check logic for the sentinel plugin.
|
|
1396
|
-
* This module extracts the repetitive security check patterns into reusable functions.
|
|
1161
|
+
* Basic local email format validation (fallback)
|
|
1397
1162
|
*/
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
challenge: "Security: velocity challenge",
|
|
1421
|
-
block: "Security: velocity exceeded"
|
|
1422
|
-
},
|
|
1423
|
-
compromised_password: {
|
|
1424
|
-
challenge: "Security: breached password warning",
|
|
1425
|
-
block: "Security: breached password blocked"
|
|
1426
|
-
},
|
|
1427
|
-
impossible_travel: {
|
|
1428
|
-
challenge: "Security: impossible travel challenge",
|
|
1429
|
-
block: "Security: impossible travel blocked"
|
|
1430
|
-
}
|
|
1163
|
+
function isValidEmailFormatLocal(email) {
|
|
1164
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false;
|
|
1165
|
+
if (email.length > 254) return false;
|
|
1166
|
+
const [localPart, domain] = email.split("@");
|
|
1167
|
+
if (!localPart || !domain) return false;
|
|
1168
|
+
if (localPart.length > 64) return false;
|
|
1169
|
+
if (domain.length > 253) return false;
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
const getEmail = (ctx) => {
|
|
1173
|
+
if (ctx.path === "/change-email") return {
|
|
1174
|
+
email: ctx.body?.newEmail,
|
|
1175
|
+
container: "body",
|
|
1176
|
+
field: "newEmail"
|
|
1177
|
+
};
|
|
1178
|
+
const body = ctx.body;
|
|
1179
|
+
const query = ctx.query;
|
|
1180
|
+
return {
|
|
1181
|
+
email: body?.email ?? query?.email,
|
|
1182
|
+
container: body ? "body" : "query",
|
|
1183
|
+
field: "email"
|
|
1184
|
+
};
|
|
1431
1185
|
};
|
|
1432
1186
|
/**
|
|
1433
|
-
*
|
|
1187
|
+
* Create email normalization hook (shared between all configurations)
|
|
1434
1188
|
*/
|
|
1435
|
-
function
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1189
|
+
function createEmailNormalizationHook() {
|
|
1190
|
+
return {
|
|
1191
|
+
matcher: allEmail,
|
|
1192
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1193
|
+
const { email, container, field } = getEmail(ctx);
|
|
1194
|
+
if (typeof email !== "string") return;
|
|
1195
|
+
const normalized = normalizeEmail(email, ctx.context);
|
|
1196
|
+
if (normalized === email) return;
|
|
1197
|
+
const data = container === "query" ? {
|
|
1198
|
+
...ctx.query,
|
|
1199
|
+
[field]: normalized
|
|
1200
|
+
} : {
|
|
1201
|
+
...ctx.body,
|
|
1202
|
+
[field]: normalized
|
|
1203
|
+
};
|
|
1204
|
+
return { context: {
|
|
1205
|
+
...ctx,
|
|
1206
|
+
[container]: data
|
|
1207
|
+
} };
|
|
1208
|
+
})
|
|
1443
1209
|
};
|
|
1444
|
-
throw error;
|
|
1445
1210
|
}
|
|
1446
1211
|
/**
|
|
1447
|
-
*
|
|
1212
|
+
* Create email validation hook with configurable validation strategy
|
|
1448
1213
|
*/
|
|
1449
|
-
function
|
|
1450
|
-
const { visitorId, identification, path, identifier, userAgent } = ctx;
|
|
1451
|
-
const countryCode = identification?.location?.country?.code || void 0;
|
|
1214
|
+
function createEmailValidationHook(validator, onDisposableEmail) {
|
|
1452
1215
|
return {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1216
|
+
matcher: allEmail,
|
|
1217
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1218
|
+
const { email } = getEmail(ctx);
|
|
1219
|
+
if (typeof email !== "string") return;
|
|
1220
|
+
const trimmed = email.trim();
|
|
1221
|
+
if (!isValidEmailFormatLocal(trimmed)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
|
|
1222
|
+
if (validator) {
|
|
1223
|
+
const result = await validator.validate(trimmed);
|
|
1224
|
+
const policy = result.policy;
|
|
1225
|
+
if (!policy?.enabled) return;
|
|
1226
|
+
if (policy.domainAllowlist?.length) {
|
|
1227
|
+
const domain = trimmed.toLowerCase().split("@")[1];
|
|
1228
|
+
if (domain && policy.domainAllowlist.includes(domain)) return;
|
|
1229
|
+
}
|
|
1230
|
+
const action = policy.action;
|
|
1231
|
+
if (!result.valid) {
|
|
1232
|
+
if ((result.disposable || result.reason === "no_mx_records" || result.reason === "blocklist" || result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short") && onDisposableEmail) {
|
|
1233
|
+
const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
|
|
1234
|
+
onDisposableEmail({
|
|
1235
|
+
email: trimmed,
|
|
1236
|
+
reason: result.reason || "disposable",
|
|
1237
|
+
confidence: result.confidence,
|
|
1238
|
+
ip,
|
|
1239
|
+
path: ctx.path,
|
|
1240
|
+
action
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
if (action === "allow") return;
|
|
1244
|
+
throw new APIError$1("BAD_REQUEST", { message: result.reason === "no_mx_records" ? "This email domain cannot receive emails" : result.disposable || result.reason === "blocklist" ? "Disposable email addresses are not allowed" : result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short" ? "This email address appears to be invalid" : "Invalid email" });
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
})
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Create email validation hooks with optional API-backed validation
|
|
1252
|
+
*
|
|
1253
|
+
* @param options - Configuration options
|
|
1254
|
+
* @param options.enabled - Enable email validation (default: true)
|
|
1255
|
+
* @param options.useApi - Use API-backed validation (requires apiKey)
|
|
1256
|
+
* @param options.apiKey - API key for remote validation
|
|
1257
|
+
* @param options.apiUrl - API URL for policy fetching (defaults to INFRA_API_URL)
|
|
1258
|
+
* @param options.kvUrl - KV URL for email validation (defaults to INFRA_KV_URL)
|
|
1259
|
+
* @param options.strictness - Default strictness level: 'low', 'medium' (default), or 'high'
|
|
1260
|
+
* @param options.action - Default action when invalid: 'allow', 'block' (default), or 'challenge'
|
|
1261
|
+
*
|
|
1262
|
+
* @example
|
|
1263
|
+
* // Local validation only
|
|
1264
|
+
* createEmailHooks()
|
|
1265
|
+
*
|
|
1266
|
+
* @example
|
|
1267
|
+
* // API-backed validation
|
|
1268
|
+
* createEmailHooks({ useApi: true, apiKey: "your-api-key" })
|
|
1269
|
+
*
|
|
1270
|
+
* @example
|
|
1271
|
+
* // API-backed validation with high strictness default
|
|
1272
|
+
* createEmailHooks({ useApi: true, apiKey: "your-api-key", strictness: "high" })
|
|
1273
|
+
*/
|
|
1274
|
+
function createEmailHooks(options = {}) {
|
|
1275
|
+
const { useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig, onDisposableEmail } = options;
|
|
1276
|
+
const emailConfig = {
|
|
1277
|
+
enabled: true,
|
|
1278
|
+
strictness: "medium",
|
|
1279
|
+
action: "block",
|
|
1280
|
+
...defaultConfig
|
|
1470
1281
|
};
|
|
1282
|
+
if (!emailConfig.enabled) return { before: [] };
|
|
1283
|
+
const validator = useApi ? createEmailValidator({
|
|
1284
|
+
apiUrl,
|
|
1285
|
+
kvUrl,
|
|
1286
|
+
apiKey,
|
|
1287
|
+
defaultConfig: emailConfig
|
|
1288
|
+
}) : void 0;
|
|
1289
|
+
return { before: [createEmailNormalizationHook(), createEmailValidationHook(validator, onDisposableEmail)] };
|
|
1471
1290
|
}
|
|
1291
|
+
createEmailHooks();
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/validation/phone.ts
|
|
1472
1294
|
/**
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
* @param verdict - The security verdict from the security service
|
|
1476
|
-
* @param ctx - Security check context with request information
|
|
1477
|
-
* @param trackEvent - Function to track security events
|
|
1478
|
-
* @param securityService - Security service for generating challenges
|
|
1479
|
-
* @returns True if the request should be blocked
|
|
1295
|
+
* Common fake/test phone numbers that should be blocked
|
|
1296
|
+
* These are numbers commonly used in testing, movies, documentation, etc.
|
|
1480
1297
|
*/
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1298
|
+
const INVALID_PHONE_NUMBERS = new Set([
|
|
1299
|
+
"+15550000000",
|
|
1300
|
+
"+15550001111",
|
|
1301
|
+
"+15550001234",
|
|
1302
|
+
"+15551234567",
|
|
1303
|
+
"+15555555555",
|
|
1304
|
+
"+15551111111",
|
|
1305
|
+
"+15550000001",
|
|
1306
|
+
"+15550123456",
|
|
1307
|
+
"+12125551234",
|
|
1308
|
+
"+13105551234",
|
|
1309
|
+
"+14155551234",
|
|
1310
|
+
"+12025551234",
|
|
1311
|
+
"+10000000000",
|
|
1312
|
+
"+11111111111",
|
|
1313
|
+
"+12222222222",
|
|
1314
|
+
"+13333333333",
|
|
1315
|
+
"+14444444444",
|
|
1316
|
+
"+15555555555",
|
|
1317
|
+
"+16666666666",
|
|
1318
|
+
"+17777777777",
|
|
1319
|
+
"+18888888888",
|
|
1320
|
+
"+19999999999",
|
|
1321
|
+
"+11234567890",
|
|
1322
|
+
"+10123456789",
|
|
1323
|
+
"+19876543210",
|
|
1324
|
+
"+441632960000",
|
|
1325
|
+
"+447700900000",
|
|
1326
|
+
"+447700900001",
|
|
1327
|
+
"+447700900123",
|
|
1328
|
+
"+447700900999",
|
|
1329
|
+
"+442079460000",
|
|
1330
|
+
"+442079460123",
|
|
1331
|
+
"+441134960000",
|
|
1332
|
+
"+0000000000",
|
|
1333
|
+
"+1000000000",
|
|
1334
|
+
"+123456789",
|
|
1335
|
+
"+1234567890",
|
|
1336
|
+
"+12345678901",
|
|
1337
|
+
"+0123456789",
|
|
1338
|
+
"+9876543210",
|
|
1339
|
+
"+11111111111",
|
|
1340
|
+
"+99999999999",
|
|
1341
|
+
"+491234567890",
|
|
1342
|
+
"+491111111111",
|
|
1343
|
+
"+33123456789",
|
|
1344
|
+
"+33111111111",
|
|
1345
|
+
"+61123456789",
|
|
1346
|
+
"+61111111111",
|
|
1347
|
+
"+81123456789",
|
|
1348
|
+
"+81111111111",
|
|
1349
|
+
"+19001234567",
|
|
1350
|
+
"+19761234567",
|
|
1351
|
+
"+1911",
|
|
1352
|
+
"+1411",
|
|
1353
|
+
"+1611",
|
|
1354
|
+
"+44999",
|
|
1355
|
+
"+44112"
|
|
1356
|
+
]);
|
|
1498
1357
|
/**
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1501
|
-
* This replaces the multiple individual check calls with a single API call
|
|
1502
|
-
* that handles all security checks server-side.
|
|
1358
|
+
* Patterns that indicate fake/test phone numbers
|
|
1503
1359
|
*/
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
"/email-otp/get-verification-otp",
|
|
1526
|
-
"/email-otp/send-verification-otp",
|
|
1527
|
-
"/forget-password",
|
|
1528
|
-
"/request-password-reset",
|
|
1529
|
-
"/send-verification-email",
|
|
1530
|
-
"/change-email"
|
|
1360
|
+
const INVALID_PHONE_PATTERNS = [
|
|
1361
|
+
/^\+\d(\d)\1{6,}$/,
|
|
1362
|
+
/^\+\d*1234567890/,
|
|
1363
|
+
/^\+\d*0123456789/,
|
|
1364
|
+
/^\+\d*9876543210/,
|
|
1365
|
+
/^\+\d*0987654321/,
|
|
1366
|
+
/^\+\d*(12){4,}/,
|
|
1367
|
+
/^\+\d*(21){4,}/,
|
|
1368
|
+
/^\+\d*(00){4,}/,
|
|
1369
|
+
/^\+1\d{3}555\d{4}$/,
|
|
1370
|
+
/^\+\d{1,3}\d{1,5}$/,
|
|
1371
|
+
/^\+\d+0{7,}$/,
|
|
1372
|
+
/^\+\d*147258369/,
|
|
1373
|
+
/^\+\d*258369147/,
|
|
1374
|
+
/^\+\d*369258147/,
|
|
1375
|
+
/^\+\d*789456123/,
|
|
1376
|
+
/^\+\d*123456789/,
|
|
1377
|
+
/^\+\d*1234512345/,
|
|
1378
|
+
/^\+\d*1111122222/,
|
|
1379
|
+
/^\+\d*1212121212/,
|
|
1380
|
+
/^\+\d*1010101010/
|
|
1531
1381
|
];
|
|
1532
|
-
const all = new Set(paths);
|
|
1533
|
-
new Set(paths.slice(1, 12));
|
|
1534
1382
|
/**
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
* '/email-otp/verify-email',
|
|
1538
|
-
* '/sign-in/email-otp',
|
|
1539
|
-
* '/sign-in/magic-link',
|
|
1540
|
-
* '/sign-in/email',
|
|
1541
|
-
* '/forget-password/email-otp',
|
|
1542
|
-
* '/email-otp/reset-password',
|
|
1543
|
-
* '/email-otp/create-verification-otp',
|
|
1544
|
-
* '/email-otp/get-verification-otp',
|
|
1545
|
-
* '/email-otp/send-verification-otp',
|
|
1546
|
-
* '/forget-password',
|
|
1547
|
-
* '/request-password-reset',
|
|
1548
|
-
* '/send-verification-email',
|
|
1549
|
-
* '/change-email'
|
|
1550
|
-
* ]`.
|
|
1551
|
-
* @param context Request context
|
|
1552
|
-
* @param context.path Request path
|
|
1553
|
-
* @returns boolean
|
|
1383
|
+
* Invalid area codes / prefixes that indicate test numbers
|
|
1384
|
+
* Key: country code, Value: set of invalid prefixes
|
|
1554
1385
|
*/
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1557
|
-
|
|
1386
|
+
const INVALID_PREFIXES_BY_COUNTRY = {
|
|
1387
|
+
US: new Set([
|
|
1388
|
+
"555",
|
|
1389
|
+
"000",
|
|
1390
|
+
"111",
|
|
1391
|
+
"911",
|
|
1392
|
+
"411",
|
|
1393
|
+
"611"
|
|
1394
|
+
]),
|
|
1395
|
+
CA: new Set([
|
|
1396
|
+
"555",
|
|
1397
|
+
"000",
|
|
1398
|
+
"911"
|
|
1399
|
+
]),
|
|
1400
|
+
GB: new Set([
|
|
1401
|
+
"7700900",
|
|
1402
|
+
"1632960",
|
|
1403
|
+
"1134960"
|
|
1404
|
+
]),
|
|
1405
|
+
AU: new Set([
|
|
1406
|
+
"0491570",
|
|
1407
|
+
"0491571",
|
|
1408
|
+
"0491572"
|
|
1409
|
+
])
|
|
1410
|
+
};
|
|
1558
1411
|
/**
|
|
1559
|
-
*
|
|
1412
|
+
* Check if a phone number is a commonly used fake/test number
|
|
1413
|
+
* @param phone - The phone number to check (E.164 format preferred)
|
|
1414
|
+
* @param defaultCountry - Default country code if not included in phone string
|
|
1415
|
+
* @returns true if the phone appears to be fake/test, false if it seems legitimate
|
|
1560
1416
|
*/
|
|
1561
|
-
const
|
|
1417
|
+
const isFakePhoneNumber = (phone, defaultCountry) => {
|
|
1418
|
+
const parsed = parsePhoneNumberFromString(phone, defaultCountry);
|
|
1419
|
+
if (!parsed) return true;
|
|
1420
|
+
const e164 = parsed.number;
|
|
1421
|
+
const nationalNumber = parsed.nationalNumber;
|
|
1422
|
+
const country = parsed.country;
|
|
1423
|
+
if (INVALID_PHONE_NUMBERS.has(e164)) return true;
|
|
1424
|
+
for (const pattern of INVALID_PHONE_PATTERNS) if (pattern.test(e164)) return true;
|
|
1425
|
+
if (country && INVALID_PREFIXES_BY_COUNTRY[country]) {
|
|
1426
|
+
const prefixes = INVALID_PREFIXES_BY_COUNTRY[country];
|
|
1427
|
+
for (const prefix of prefixes) if (nationalNumber.startsWith(prefix)) return true;
|
|
1428
|
+
}
|
|
1429
|
+
if (/^(\d)\1+$/.test(nationalNumber)) return true;
|
|
1430
|
+
const digits = nationalNumber.split("").map(Number);
|
|
1431
|
+
let isSequential = digits.length >= 6;
|
|
1432
|
+
for (let i = 1; i < digits.length && isSequential; i++) {
|
|
1433
|
+
const current = digits[i];
|
|
1434
|
+
const previous = digits[i - 1];
|
|
1435
|
+
if (current === void 0 || previous === void 0 || current !== previous + 1 && current !== previous - 1) isSequential = false;
|
|
1436
|
+
}
|
|
1437
|
+
if (isSequential) return true;
|
|
1438
|
+
return false;
|
|
1439
|
+
};
|
|
1562
1440
|
/**
|
|
1563
|
-
*
|
|
1441
|
+
* Validate a phone number format
|
|
1442
|
+
* @param phone - The phone number to validate
|
|
1443
|
+
* @param defaultCountry - Default country code if not included in phone string
|
|
1444
|
+
* @returns true if the phone number is valid
|
|
1564
1445
|
*/
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1446
|
+
const isValidPhone = (phone, defaultCountry) => {
|
|
1447
|
+
return isValidPhoneNumber(phone, defaultCountry);
|
|
1448
|
+
};
|
|
1449
|
+
/**
|
|
1450
|
+
* Comprehensive phone number validation
|
|
1451
|
+
* @param phone - The phone number to validate
|
|
1452
|
+
* @param options - Validation options
|
|
1453
|
+
* @returns true if valid, false otherwise
|
|
1454
|
+
*/
|
|
1455
|
+
const validatePhone = (phone, options = {}) => {
|
|
1456
|
+
const { mobileOnly = false, allowedCountries, blockedCountries, blockFakeNumbers = true, blockPremiumRate = true, blockTollFree = false, blockVoip = false, defaultCountry } = options;
|
|
1457
|
+
if (!isValidPhone(phone, defaultCountry)) return false;
|
|
1458
|
+
const parsed = parsePhoneNumberFromString(phone, defaultCountry);
|
|
1459
|
+
if (!parsed) return false;
|
|
1460
|
+
if (blockFakeNumbers && isFakePhoneNumber(phone, defaultCountry)) return false;
|
|
1461
|
+
const country = parsed.country;
|
|
1462
|
+
if (country) {
|
|
1463
|
+
if (allowedCountries && !allowedCountries.includes(country)) return false;
|
|
1464
|
+
if (blockedCountries?.includes(country)) return false;
|
|
1465
|
+
}
|
|
1466
|
+
const phoneType = parsed.getType();
|
|
1467
|
+
if (mobileOnly) {
|
|
1468
|
+
if (phoneType !== "MOBILE" && phoneType !== "FIXED_LINE_OR_MOBILE") return false;
|
|
1469
|
+
}
|
|
1470
|
+
if (blockPremiumRate && phoneType === "PREMIUM_RATE") return false;
|
|
1471
|
+
if (blockTollFree && phoneType === "TOLL_FREE") return false;
|
|
1472
|
+
if (blockVoip && phoneType === "VOIP") return false;
|
|
1473
|
+
return true;
|
|
1474
|
+
};
|
|
1475
|
+
const allPhonePaths = new Set([
|
|
1476
|
+
"/phone-number/send-otp",
|
|
1477
|
+
"/phone-number/verify",
|
|
1478
|
+
"/sign-in/phone-number",
|
|
1479
|
+
"/phone-number/request-password-reset",
|
|
1480
|
+
"/phone-number/reset-password"
|
|
1579
1481
|
]);
|
|
1482
|
+
const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
|
|
1580
1483
|
/**
|
|
1581
|
-
*
|
|
1582
|
-
*
|
|
1583
|
-
* - Remove dots from Gmail-like providers (they ignore dots)
|
|
1584
|
-
* - Remove plus addressing (user+tag@domain → user@domain)
|
|
1585
|
-
* - Normalize googlemail.com to gmail.com
|
|
1586
|
-
*
|
|
1587
|
-
* @param email - Raw email to normalize
|
|
1588
|
-
* @param context - Auth context with getPlugin (for sentinel policy). Pass undefined when context unavailable (e.g. server, hooks).
|
|
1484
|
+
* Better Auth plugin for phone number validation
|
|
1485
|
+
* Validates phone numbers on all phone-related endpoints
|
|
1589
1486
|
*/
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
return `${localPart}@${domain}`;
|
|
1487
|
+
const phoneValidationHooks = { before: [{
|
|
1488
|
+
matcher: (context) => !!context.path && allPhonePaths.has(context.path),
|
|
1489
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1490
|
+
const phoneNumber = getPhoneNumber(ctx);
|
|
1491
|
+
if (typeof phoneNumber !== "string") return;
|
|
1492
|
+
if (!validatePhone(phoneNumber)) throw new APIError$1("BAD_REQUEST", { message: "Invalid phone number" });
|
|
1493
|
+
})
|
|
1494
|
+
}] };
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/sentinel/security.ts
|
|
1497
|
+
async function hashForFingerprint(input) {
|
|
1498
|
+
const data = new TextEncoder().encode(input);
|
|
1499
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
1500
|
+
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1605
1501
|
}
|
|
1606
|
-
function
|
|
1607
|
-
const
|
|
1502
|
+
async function sha1Hash(input) {
|
|
1503
|
+
const data = new TextEncoder().encode(input);
|
|
1504
|
+
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
|
1505
|
+
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
1506
|
+
}
|
|
1507
|
+
function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
1508
|
+
const resolvedApiUrl = apiUrl || INFRA_API_URL;
|
|
1608
1509
|
const $api = createFetch({
|
|
1609
|
-
baseURL:
|
|
1610
|
-
headers: { "x-api-key": apiKey }
|
|
1611
|
-
});
|
|
1612
|
-
const $kv = createFetch({
|
|
1613
|
-
baseURL: kvUrl,
|
|
1510
|
+
baseURL: resolvedApiUrl,
|
|
1614
1511
|
headers: { "x-api-key": apiKey },
|
|
1615
|
-
|
|
1512
|
+
throw: true
|
|
1616
1513
|
});
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
body: {
|
|
1626
|
-
policyId: "email_validity",
|
|
1627
|
-
config: { emailValidation: {
|
|
1628
|
-
enabled: defaultConfig.enabled,
|
|
1629
|
-
strictness: defaultConfig.strictness,
|
|
1630
|
-
action: defaultConfig.action,
|
|
1631
|
-
domainAllowlist: defaultConfig.domainAllowlist
|
|
1632
|
-
} }
|
|
1633
|
-
}
|
|
1634
|
-
});
|
|
1635
|
-
if (data?.policy) return data.policy;
|
|
1636
|
-
} catch (error) {
|
|
1637
|
-
logger.warn("[Dash] Failed to fetch email policy, using defaults:", error);
|
|
1638
|
-
}
|
|
1639
|
-
return null;
|
|
1640
|
-
}
|
|
1641
|
-
return { async validate(email, checkMx = true) {
|
|
1642
|
-
const policy = await fetchPolicy();
|
|
1643
|
-
if (!policy?.enabled) return {
|
|
1644
|
-
valid: true,
|
|
1645
|
-
disposable: false,
|
|
1646
|
-
confidence: "high",
|
|
1647
|
-
policy
|
|
1514
|
+
const emailSender = createEmailSender({
|
|
1515
|
+
apiUrl: resolvedApiUrl,
|
|
1516
|
+
apiKey
|
|
1517
|
+
});
|
|
1518
|
+
function logEvent(event) {
|
|
1519
|
+
const fullEvent = {
|
|
1520
|
+
...event,
|
|
1521
|
+
timestamp: Date.now()
|
|
1648
1522
|
};
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1523
|
+
if (onSecurityEvent) onSecurityEvent(fullEvent);
|
|
1524
|
+
}
|
|
1525
|
+
return {
|
|
1526
|
+
async checkSecurity(request) {
|
|
1527
|
+
try {
|
|
1528
|
+
const data = await $api("/security/check", {
|
|
1529
|
+
method: "POST",
|
|
1530
|
+
body: {
|
|
1531
|
+
...request,
|
|
1532
|
+
config: options
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
if (data.action !== "allow") logEvent({
|
|
1536
|
+
type: this.mapReasonToEventType(data.reason),
|
|
1537
|
+
userId: null,
|
|
1538
|
+
visitorId: request.visitorId,
|
|
1539
|
+
ip: request.ip,
|
|
1540
|
+
country: null,
|
|
1541
|
+
details: data.details || { reason: data.reason },
|
|
1542
|
+
action: data.action === "block" ? "blocked" : "challenged"
|
|
1543
|
+
});
|
|
1544
|
+
return data;
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
logger.error("[Dash] Security check failed:", error);
|
|
1547
|
+
return { action: "allow" };
|
|
1548
|
+
}
|
|
1549
|
+
},
|
|
1550
|
+
mapReasonToEventType(reason) {
|
|
1551
|
+
switch (reason) {
|
|
1552
|
+
case "geo_blocked": return "geo_blocked";
|
|
1553
|
+
case "bot_detected": return "bot_blocked";
|
|
1554
|
+
case "suspicious_ip_detected": return "suspicious_ip_detected";
|
|
1555
|
+
case "rate_limited": return "velocity_exceeded";
|
|
1556
|
+
case "credential_stuffing_cooldown": return "credential_stuffing";
|
|
1557
|
+
default: return "credential_stuffing";
|
|
1558
|
+
}
|
|
1559
|
+
},
|
|
1560
|
+
async trackFailedAttempt(identifier, visitorId, password, ip) {
|
|
1561
|
+
try {
|
|
1562
|
+
const data = await $api("/security/track-failed-login", {
|
|
1563
|
+
method: "POST",
|
|
1564
|
+
body: {
|
|
1565
|
+
identifier,
|
|
1566
|
+
visitorId,
|
|
1567
|
+
passwordHash: await hashForFingerprint(password),
|
|
1568
|
+
ip,
|
|
1569
|
+
config: options
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
if (data.blocked || data.challenged) logEvent({
|
|
1573
|
+
type: "credential_stuffing",
|
|
1574
|
+
userId: null,
|
|
1575
|
+
visitorId,
|
|
1576
|
+
ip,
|
|
1577
|
+
country: null,
|
|
1578
|
+
details: data.details || { reason: data.reason },
|
|
1579
|
+
action: data.blocked ? "blocked" : "challenged"
|
|
1580
|
+
});
|
|
1581
|
+
return data;
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
logger.error("[Dash] Track failed attempt error:", error);
|
|
1584
|
+
return { blocked: false };
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
async clearFailedAttempts(identifier) {
|
|
1588
|
+
try {
|
|
1589
|
+
await $api("/security/clear-failed-attempts", {
|
|
1590
|
+
method: "POST",
|
|
1591
|
+
body: { identifier }
|
|
1592
|
+
});
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
logger.error("[Dash] Clear failed attempts error:", error);
|
|
1595
|
+
}
|
|
1596
|
+
},
|
|
1597
|
+
async isBlocked(visitorId) {
|
|
1598
|
+
try {
|
|
1599
|
+
return (await $api(`/security/is-blocked?visitorId=${encodeURIComponent(visitorId)}`, { method: "GET" })).blocked ?? false;
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
logger.warn("[Dash] Security is-blocked check failed:", error);
|
|
1602
|
+
return false;
|
|
1603
|
+
}
|
|
1604
|
+
},
|
|
1605
|
+
async verifyPoWSolution(visitorId, solution) {
|
|
1606
|
+
try {
|
|
1607
|
+
return await $api("/security/pow/verify", {
|
|
1608
|
+
method: "POST",
|
|
1609
|
+
body: {
|
|
1610
|
+
visitorId,
|
|
1611
|
+
solution
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
logger.warn("[Dash] PoW verify failed:", error);
|
|
1616
|
+
return {
|
|
1617
|
+
valid: false,
|
|
1618
|
+
reason: "error"
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
async generateChallenge(visitorId) {
|
|
1623
|
+
try {
|
|
1624
|
+
return (await $api("/security/pow/generate", {
|
|
1625
|
+
method: "POST",
|
|
1626
|
+
body: {
|
|
1627
|
+
visitorId,
|
|
1628
|
+
difficulty: options.challengeDifficulty
|
|
1629
|
+
}
|
|
1630
|
+
})).challenge || "";
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
logger.warn("[Dash] PoW generate challenge failed:", error);
|
|
1633
|
+
return "";
|
|
1634
|
+
}
|
|
1635
|
+
},
|
|
1636
|
+
async checkImpossibleTravel(userId, currentLocation, visitorId) {
|
|
1637
|
+
if (!options.impossibleTravel?.enabled || !currentLocation) return null;
|
|
1638
|
+
try {
|
|
1639
|
+
const data = await $api("/security/impossible-travel", {
|
|
1640
|
+
method: "POST",
|
|
1641
|
+
body: {
|
|
1642
|
+
userId,
|
|
1643
|
+
visitorId,
|
|
1644
|
+
location: currentLocation,
|
|
1645
|
+
config: options
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
if (data.isImpossible) {
|
|
1649
|
+
const actionTaken = data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged";
|
|
1650
|
+
logEvent({
|
|
1651
|
+
type: "impossible_travel",
|
|
1652
|
+
userId,
|
|
1653
|
+
visitorId: visitorId || null,
|
|
1654
|
+
ip: null,
|
|
1655
|
+
country: currentLocation.country?.code || null,
|
|
1656
|
+
details: {
|
|
1657
|
+
from: data.from,
|
|
1658
|
+
to: data.to,
|
|
1659
|
+
distance: data.distance,
|
|
1660
|
+
speedRequired: data.speedRequired,
|
|
1661
|
+
action: data.action
|
|
1662
|
+
},
|
|
1663
|
+
action: actionTaken
|
|
1664
|
+
});
|
|
1656
1665
|
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
if (domain.length > 253) return false;
|
|
1684
|
-
return true;
|
|
1685
|
-
}
|
|
1686
|
-
const getEmail = (ctx) => {
|
|
1687
|
-
if (ctx.path === "/change-email") return {
|
|
1688
|
-
email: ctx.body?.newEmail,
|
|
1689
|
-
container: "body",
|
|
1690
|
-
field: "newEmail"
|
|
1691
|
-
};
|
|
1692
|
-
const body = ctx.body;
|
|
1693
|
-
const query = ctx.query;
|
|
1694
|
-
return {
|
|
1695
|
-
email: body?.email ?? query?.email,
|
|
1696
|
-
container: body ? "body" : "query",
|
|
1697
|
-
field: "email"
|
|
1698
|
-
};
|
|
1699
|
-
};
|
|
1700
|
-
/**
|
|
1701
|
-
* Create email normalization hook (shared between all configurations)
|
|
1702
|
-
*/
|
|
1703
|
-
function createEmailNormalizationHook() {
|
|
1704
|
-
return {
|
|
1705
|
-
matcher: allEmail,
|
|
1706
|
-
handler: createAuthMiddleware(async (ctx) => {
|
|
1707
|
-
const { email, container, field } = getEmail(ctx);
|
|
1708
|
-
if (typeof email !== "string") return;
|
|
1709
|
-
const normalized = normalizeEmail(email, ctx.context);
|
|
1710
|
-
if (normalized === email) return;
|
|
1711
|
-
const data = container === "query" ? {
|
|
1712
|
-
...ctx.query,
|
|
1713
|
-
[field]: normalized
|
|
1714
|
-
} : {
|
|
1715
|
-
...ctx.body,
|
|
1716
|
-
[field]: normalized
|
|
1666
|
+
return data;
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
logger.warn("[Dash] Impossible travel check failed:", error);
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
},
|
|
1672
|
+
async storeLastLocation(userId, location) {
|
|
1673
|
+
if (!location) return;
|
|
1674
|
+
try {
|
|
1675
|
+
await $api("/security/store-last-login", {
|
|
1676
|
+
method: "POST",
|
|
1677
|
+
body: {
|
|
1678
|
+
userId,
|
|
1679
|
+
location
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
logger.error("[Dash] Store last location error:", error);
|
|
1684
|
+
}
|
|
1685
|
+
},
|
|
1686
|
+
async checkFreeTrialAbuse(visitorId) {
|
|
1687
|
+
if (!options.freeTrialAbuse?.enabled) return {
|
|
1688
|
+
isAbuse: false,
|
|
1689
|
+
accountCount: 0,
|
|
1690
|
+
maxAccounts: 0,
|
|
1691
|
+
action: "log"
|
|
1717
1692
|
};
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
}
|
|
1725
|
-
/**
|
|
1726
|
-
* Create email validation hook with configurable validation strategy
|
|
1727
|
-
*/
|
|
1728
|
-
function createEmailValidationHook(validator, onDisposableEmail) {
|
|
1729
|
-
return {
|
|
1730
|
-
matcher: allEmail,
|
|
1731
|
-
handler: createAuthMiddleware(async (ctx) => {
|
|
1732
|
-
const { email } = getEmail(ctx);
|
|
1733
|
-
if (typeof email !== "string") return;
|
|
1734
|
-
if (!isValidEmailFormatLocal(email)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
|
|
1735
|
-
if (validator) {
|
|
1736
|
-
const result = await validator.validate(email);
|
|
1737
|
-
const policy = result.policy;
|
|
1738
|
-
if (!policy?.enabled) return;
|
|
1739
|
-
if (policy.domainAllowlist?.length) {
|
|
1740
|
-
const domain = email.toLowerCase().trim().split("@")[1];
|
|
1741
|
-
if (domain && policy.domainAllowlist.includes(domain)) return;
|
|
1742
|
-
}
|
|
1743
|
-
const action = policy.action;
|
|
1744
|
-
if (!result.valid) {
|
|
1745
|
-
if ((result.disposable || result.reason === "no_mx_records" || result.reason === "blocklist" || result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short") && onDisposableEmail) {
|
|
1746
|
-
const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
|
|
1747
|
-
onDisposableEmail({
|
|
1748
|
-
email,
|
|
1749
|
-
reason: result.reason || "disposable",
|
|
1750
|
-
confidence: result.confidence,
|
|
1751
|
-
ip,
|
|
1752
|
-
path: ctx.path,
|
|
1753
|
-
action
|
|
1754
|
-
});
|
|
1693
|
+
try {
|
|
1694
|
+
const data = await $api("/security/free-trial-abuse/check", {
|
|
1695
|
+
method: "POST",
|
|
1696
|
+
body: {
|
|
1697
|
+
visitorId,
|
|
1698
|
+
config: options
|
|
1755
1699
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1700
|
+
});
|
|
1701
|
+
if (data.isAbuse) logEvent({
|
|
1702
|
+
type: "free_trial_abuse",
|
|
1703
|
+
userId: null,
|
|
1704
|
+
visitorId,
|
|
1705
|
+
ip: null,
|
|
1706
|
+
country: null,
|
|
1707
|
+
details: {
|
|
1708
|
+
accountCount: data.accountCount,
|
|
1709
|
+
maxAccounts: data.maxAccounts
|
|
1710
|
+
},
|
|
1711
|
+
action: data.action === "block" ? "blocked" : "logged"
|
|
1712
|
+
});
|
|
1713
|
+
return data;
|
|
1714
|
+
} catch (error) {
|
|
1715
|
+
logger.warn("[Dash] Free trial abuse check failed:", error);
|
|
1716
|
+
return {
|
|
1717
|
+
isAbuse: false,
|
|
1718
|
+
accountCount: 0,
|
|
1719
|
+
maxAccounts: 0,
|
|
1720
|
+
action: "log"
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
},
|
|
1724
|
+
async trackFreeTrialSignup(visitorId, userId) {
|
|
1725
|
+
if (!options.freeTrialAbuse?.enabled) return;
|
|
1726
|
+
try {
|
|
1727
|
+
await $api("/security/free-trial-abuse/track", {
|
|
1728
|
+
method: "POST",
|
|
1729
|
+
body: {
|
|
1730
|
+
visitorId,
|
|
1731
|
+
userId
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
logger.error("[Dash] Track free trial signup error:", error);
|
|
1736
|
+
}
|
|
1737
|
+
},
|
|
1738
|
+
async checkCompromisedPassword(password) {
|
|
1739
|
+
try {
|
|
1740
|
+
const hash = await sha1Hash(password);
|
|
1741
|
+
const prefix = hash.substring(0, 5);
|
|
1742
|
+
const suffix = hash.substring(5);
|
|
1743
|
+
const data = await $api("/security/breached-passwords", {
|
|
1744
|
+
method: "POST",
|
|
1745
|
+
body: {
|
|
1746
|
+
passwordPrefix: prefix,
|
|
1747
|
+
config: options
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
if (!data.enabled) return { compromised: false };
|
|
1751
|
+
const breachCount = (data.suffixes || {})[suffix] || 0;
|
|
1752
|
+
const minBreachCount = data.minBreachCount ?? 1;
|
|
1753
|
+
const action = data.action || "block";
|
|
1754
|
+
const compromised = breachCount >= minBreachCount;
|
|
1755
|
+
if (compromised) logEvent({
|
|
1756
|
+
type: "compromised_password",
|
|
1757
|
+
userId: null,
|
|
1758
|
+
visitorId: null,
|
|
1759
|
+
ip: null,
|
|
1760
|
+
country: null,
|
|
1761
|
+
details: { breachCount },
|
|
1762
|
+
action: action === "block" ? "blocked" : action === "challenge" ? "challenged" : "logged"
|
|
1763
|
+
});
|
|
1764
|
+
return {
|
|
1765
|
+
compromised,
|
|
1766
|
+
breachCount: breachCount > 0 ? breachCount : void 0,
|
|
1767
|
+
action: compromised ? action : void 0
|
|
1768
|
+
};
|
|
1769
|
+
} catch (error) {
|
|
1770
|
+
logger.error("[Dash] Compromised password check error:", error);
|
|
1771
|
+
return { compromised: false };
|
|
1772
|
+
}
|
|
1773
|
+
},
|
|
1774
|
+
async checkStaleUser(userId, lastActiveAt) {
|
|
1775
|
+
if (!options.staleUsers?.enabled) return { isStale: false };
|
|
1776
|
+
try {
|
|
1777
|
+
const data = await $api("/security/stale-user", {
|
|
1778
|
+
method: "POST",
|
|
1779
|
+
body: {
|
|
1780
|
+
userId,
|
|
1781
|
+
lastActiveAt: lastActiveAt instanceof Date ? lastActiveAt.toISOString() : lastActiveAt,
|
|
1782
|
+
config: options
|
|
1783
|
+
}
|
|
1784
|
+
});
|
|
1785
|
+
if (data.isStale) logEvent({
|
|
1786
|
+
type: "stale_account_reactivation",
|
|
1787
|
+
userId,
|
|
1788
|
+
visitorId: null,
|
|
1789
|
+
ip: null,
|
|
1790
|
+
country: null,
|
|
1791
|
+
details: {
|
|
1792
|
+
daysSinceLastActive: data.daysSinceLastActive,
|
|
1793
|
+
staleDays: data.staleDays,
|
|
1794
|
+
lastActiveAt: data.lastActiveAt,
|
|
1795
|
+
notifyUser: data.notifyUser,
|
|
1796
|
+
notifyAdmin: data.notifyAdmin
|
|
1797
|
+
},
|
|
1798
|
+
action: data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged"
|
|
1799
|
+
});
|
|
1800
|
+
return data;
|
|
1801
|
+
} catch (error) {
|
|
1802
|
+
logger.error("[Dash] Stale user check error:", error);
|
|
1803
|
+
return { isStale: false };
|
|
1804
|
+
}
|
|
1805
|
+
},
|
|
1806
|
+
async notifyStaleAccountUser(userEmail, userName, daysSinceLastActive, identification, appName) {
|
|
1807
|
+
const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
|
|
1808
|
+
dateStyle: "long",
|
|
1809
|
+
timeStyle: "short",
|
|
1810
|
+
timeZone: "UTC"
|
|
1811
|
+
}) + " UTC";
|
|
1812
|
+
const location = identification?.location;
|
|
1813
|
+
const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
|
|
1814
|
+
const browser = identification?.browser;
|
|
1815
|
+
const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
|
|
1816
|
+
const result = await emailSender.send({
|
|
1817
|
+
template: "stale-account-user",
|
|
1818
|
+
to: userEmail,
|
|
1819
|
+
variables: {
|
|
1820
|
+
userEmail,
|
|
1821
|
+
userName: userName || "User",
|
|
1822
|
+
appName: appName || "Your App",
|
|
1823
|
+
daysSinceLastActive: String(daysSinceLastActive),
|
|
1824
|
+
loginTime,
|
|
1825
|
+
loginLocation,
|
|
1826
|
+
loginDevice,
|
|
1827
|
+
loginIp: identification?.ip || "Unknown"
|
|
1758
1828
|
}
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1829
|
+
});
|
|
1830
|
+
if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
|
|
1831
|
+
else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
|
|
1832
|
+
},
|
|
1833
|
+
async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
|
|
1834
|
+
const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
|
|
1835
|
+
dateStyle: "long",
|
|
1836
|
+
timeStyle: "short",
|
|
1837
|
+
timeZone: "UTC"
|
|
1838
|
+
}) + " UTC";
|
|
1839
|
+
const location = identification?.location;
|
|
1840
|
+
const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
|
|
1841
|
+
const browser = identification?.browser;
|
|
1842
|
+
const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
|
|
1843
|
+
const result = await emailSender.send({
|
|
1844
|
+
template: "stale-account-admin",
|
|
1845
|
+
to: adminEmail,
|
|
1846
|
+
variables: {
|
|
1847
|
+
userEmail,
|
|
1848
|
+
userName: userName || "User",
|
|
1849
|
+
userId,
|
|
1850
|
+
appName: appName || "Your App",
|
|
1851
|
+
daysSinceLastActive: String(daysSinceLastActive),
|
|
1852
|
+
loginTime,
|
|
1853
|
+
loginLocation,
|
|
1854
|
+
loginDevice,
|
|
1855
|
+
loginIp: identification?.ip || "Unknown",
|
|
1856
|
+
adminEmail
|
|
1857
|
+
}
|
|
1858
|
+
});
|
|
1859
|
+
if (result.success) logger.info(`[Dash] Stale account admin notification sent to: ${adminEmail}`);
|
|
1860
|
+
else logger.error(`[Dash] Failed to send stale account admin notification: ${result.error}`);
|
|
1861
|
+
},
|
|
1862
|
+
async checkUnknownDevice(_userId, _visitorId) {
|
|
1863
|
+
return false;
|
|
1864
|
+
},
|
|
1865
|
+
async notifyUnknownDevice(userId, email, identification) {
|
|
1866
|
+
logEvent({
|
|
1867
|
+
type: "unknown_device",
|
|
1868
|
+
userId,
|
|
1869
|
+
visitorId: identification?.visitorId || null,
|
|
1870
|
+
ip: identification?.ip || null,
|
|
1871
|
+
country: identification?.location?.country?.code || null,
|
|
1872
|
+
details: {
|
|
1873
|
+
email,
|
|
1874
|
+
device: identification?.browser.device,
|
|
1875
|
+
os: identification?.browser.os,
|
|
1876
|
+
browser: identification?.browser.name,
|
|
1877
|
+
city: identification?.location?.city,
|
|
1878
|
+
country: identification?.location?.country?.name
|
|
1879
|
+
},
|
|
1880
|
+
action: "logged"
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1794
1883
|
};
|
|
1795
|
-
if (!emailConfig.enabled) return { before: [] };
|
|
1796
|
-
const validator = useApi ? createEmailValidator({
|
|
1797
|
-
apiUrl,
|
|
1798
|
-
kvUrl,
|
|
1799
|
-
apiKey,
|
|
1800
|
-
defaultConfig: emailConfig
|
|
1801
|
-
}) : void 0;
|
|
1802
|
-
return { before: [createEmailNormalizationHook(), createEmailValidationHook(validator, onDisposableEmail)] };
|
|
1803
1884
|
}
|
|
1804
|
-
createEmailHooks();
|
|
1805
1885
|
//#endregion
|
|
1806
|
-
//#region src/
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
"
|
|
1813
|
-
"
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
"+447700900123",
|
|
1841
|
-
"+447700900999",
|
|
1842
|
-
"+442079460000",
|
|
1843
|
-
"+442079460123",
|
|
1844
|
-
"+441134960000",
|
|
1845
|
-
"+0000000000",
|
|
1846
|
-
"+1000000000",
|
|
1847
|
-
"+123456789",
|
|
1848
|
-
"+1234567890",
|
|
1849
|
-
"+12345678901",
|
|
1850
|
-
"+0123456789",
|
|
1851
|
-
"+9876543210",
|
|
1852
|
-
"+11111111111",
|
|
1853
|
-
"+99999999999",
|
|
1854
|
-
"+491234567890",
|
|
1855
|
-
"+491111111111",
|
|
1856
|
-
"+33123456789",
|
|
1857
|
-
"+33111111111",
|
|
1858
|
-
"+61123456789",
|
|
1859
|
-
"+61111111111",
|
|
1860
|
-
"+81123456789",
|
|
1861
|
-
"+81111111111",
|
|
1862
|
-
"+19001234567",
|
|
1863
|
-
"+19761234567",
|
|
1864
|
-
"+1911",
|
|
1865
|
-
"+1411",
|
|
1866
|
-
"+1611",
|
|
1867
|
-
"+44999",
|
|
1868
|
-
"+44112"
|
|
1869
|
-
]);
|
|
1870
|
-
/**
|
|
1871
|
-
* Patterns that indicate fake/test phone numbers
|
|
1872
|
-
*/
|
|
1873
|
-
const INVALID_PHONE_PATTERNS = [
|
|
1874
|
-
/^\+\d(\d)\1{6,}$/,
|
|
1875
|
-
/^\+\d*1234567890/,
|
|
1876
|
-
/^\+\d*0123456789/,
|
|
1877
|
-
/^\+\d*9876543210/,
|
|
1878
|
-
/^\+\d*0987654321/,
|
|
1879
|
-
/^\+\d*(12){4,}/,
|
|
1880
|
-
/^\+\d*(21){4,}/,
|
|
1881
|
-
/^\+\d*(00){4,}/,
|
|
1882
|
-
/^\+1\d{3}555\d{4}$/,
|
|
1883
|
-
/^\+\d{1,3}\d{1,5}$/,
|
|
1884
|
-
/^\+\d+0{7,}$/,
|
|
1885
|
-
/^\+\d*147258369/,
|
|
1886
|
-
/^\+\d*258369147/,
|
|
1887
|
-
/^\+\d*369258147/,
|
|
1888
|
-
/^\+\d*789456123/,
|
|
1889
|
-
/^\+\d*123456789/,
|
|
1890
|
-
/^\+\d*1234512345/,
|
|
1891
|
-
/^\+\d*1111122222/,
|
|
1892
|
-
/^\+\d*1212121212/,
|
|
1893
|
-
/^\+\d*1010101010/
|
|
1894
|
-
];
|
|
1895
|
-
/**
|
|
1896
|
-
* Invalid area codes / prefixes that indicate test numbers
|
|
1897
|
-
* Key: country code, Value: set of invalid prefixes
|
|
1898
|
-
*/
|
|
1899
|
-
const INVALID_PREFIXES_BY_COUNTRY = {
|
|
1900
|
-
US: new Set([
|
|
1901
|
-
"555",
|
|
1902
|
-
"000",
|
|
1903
|
-
"111",
|
|
1904
|
-
"911",
|
|
1905
|
-
"411",
|
|
1906
|
-
"611"
|
|
1907
|
-
]),
|
|
1908
|
-
CA: new Set([
|
|
1909
|
-
"555",
|
|
1910
|
-
"000",
|
|
1911
|
-
"911"
|
|
1912
|
-
]),
|
|
1913
|
-
GB: new Set([
|
|
1914
|
-
"7700900",
|
|
1915
|
-
"1632960",
|
|
1916
|
-
"1134960"
|
|
1917
|
-
]),
|
|
1918
|
-
AU: new Set([
|
|
1919
|
-
"0491570",
|
|
1920
|
-
"0491571",
|
|
1921
|
-
"0491572"
|
|
1922
|
-
])
|
|
1886
|
+
//#region src/sentinel/security-hooks.ts
|
|
1887
|
+
const ERROR_MESSAGES = {
|
|
1888
|
+
geo_blocked: "Access from your location is not allowed.",
|
|
1889
|
+
bot_detected: "Automated access is not allowed.",
|
|
1890
|
+
suspicious_ip_detected: "Anonymous connections (VPN, proxy, Tor) are not allowed.",
|
|
1891
|
+
rate_limited: "Too many attempts. Please try again later.",
|
|
1892
|
+
compromised_password: "This password has been found in data breaches. Please choose a different password.",
|
|
1893
|
+
impossible_travel: "Login blocked due to suspicious location change."
|
|
1894
|
+
};
|
|
1895
|
+
const DISPLAY_NAMES = {
|
|
1896
|
+
geo_blocked: {
|
|
1897
|
+
challenge: "Security: geo challenge",
|
|
1898
|
+
block: "Security: geo blocked"
|
|
1899
|
+
},
|
|
1900
|
+
bot_detected: {
|
|
1901
|
+
challenge: "Security: bot challenge",
|
|
1902
|
+
block: "Security: bot blocked"
|
|
1903
|
+
},
|
|
1904
|
+
suspicious_ip_detected: {
|
|
1905
|
+
challenge: "Security: anonymous IP challenge",
|
|
1906
|
+
block: "Security: anonymous IP blocked"
|
|
1907
|
+
},
|
|
1908
|
+
rate_limited: {
|
|
1909
|
+
challenge: "Security: velocity challenge",
|
|
1910
|
+
block: "Security: velocity exceeded"
|
|
1911
|
+
},
|
|
1912
|
+
compromised_password: {
|
|
1913
|
+
challenge: "Security: breached password warning",
|
|
1914
|
+
block: "Security: breached password blocked"
|
|
1915
|
+
},
|
|
1916
|
+
impossible_travel: {
|
|
1917
|
+
challenge: "Security: impossible travel challenge",
|
|
1918
|
+
block: "Security: impossible travel blocked"
|
|
1919
|
+
}
|
|
1923
1920
|
};
|
|
1924
1921
|
/**
|
|
1925
|
-
*
|
|
1926
|
-
* @param phone - The phone number to check (E.164 format preferred)
|
|
1927
|
-
* @param defaultCountry - Default country code if not included in phone string
|
|
1928
|
-
* @returns true if the phone appears to be fake/test, false if it seems legitimate
|
|
1922
|
+
* Throw a challenge error with appropriate headers
|
|
1929
1923
|
*/
|
|
1930
|
-
|
|
1931
|
-
const
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
}
|
|
1942
|
-
if (/^(\d)\1+$/.test(nationalNumber)) return true;
|
|
1943
|
-
const digits = nationalNumber.split("").map(Number);
|
|
1944
|
-
let isSequential = digits.length >= 6;
|
|
1945
|
-
for (let i = 1; i < digits.length && isSequential; i++) {
|
|
1946
|
-
const current = digits[i];
|
|
1947
|
-
const previous = digits[i - 1];
|
|
1948
|
-
if (current === void 0 || previous === void 0 || current !== previous + 1 && current !== previous - 1) isSequential = false;
|
|
1949
|
-
}
|
|
1950
|
-
if (isSequential) return true;
|
|
1951
|
-
return false;
|
|
1952
|
-
};
|
|
1924
|
+
function throwChallengeError(challenge, reason, message = "Please complete a security check to continue.") {
|
|
1925
|
+
const error = new APIError("LOCKED", {
|
|
1926
|
+
message,
|
|
1927
|
+
code: "POW_CHALLENGE_REQUIRED"
|
|
1928
|
+
});
|
|
1929
|
+
error.headers = {
|
|
1930
|
+
"X-PoW-Challenge": challenge,
|
|
1931
|
+
"X-PoW-Reason": reason
|
|
1932
|
+
};
|
|
1933
|
+
throw error;
|
|
1934
|
+
}
|
|
1953
1935
|
/**
|
|
1954
|
-
*
|
|
1955
|
-
* @param phone - The phone number to validate
|
|
1956
|
-
* @param defaultCountry - Default country code if not included in phone string
|
|
1957
|
-
* @returns true if the phone number is valid
|
|
1936
|
+
* Build common event data for security tracking
|
|
1958
1937
|
*/
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1938
|
+
function buildEventData(ctx, action, reason, confidence = 1, extraData) {
|
|
1939
|
+
const { visitorId, identification, path, identifier, userAgent } = ctx;
|
|
1940
|
+
const countryCode = identification?.location?.country?.code || void 0;
|
|
1941
|
+
return {
|
|
1942
|
+
eventKey: visitorId || identification?.ip || "unknown",
|
|
1943
|
+
eventType: action === "challenged" ? "security_challenged" : "security_blocked",
|
|
1944
|
+
eventDisplayName: DISPLAY_NAMES[reason]?.[action === "challenged" ? "challenge" : "block"] || `Security: ${reason}`,
|
|
1945
|
+
eventData: {
|
|
1946
|
+
action,
|
|
1947
|
+
reason,
|
|
1948
|
+
visitorId: visitorId || "",
|
|
1949
|
+
path,
|
|
1950
|
+
userAgent,
|
|
1951
|
+
identifier,
|
|
1952
|
+
confidence,
|
|
1953
|
+
...extraData
|
|
1954
|
+
},
|
|
1955
|
+
ipAddress: identification?.ip || void 0,
|
|
1956
|
+
city: identification?.location?.city || void 0,
|
|
1957
|
+
country: identification?.location?.country?.name || void 0,
|
|
1958
|
+
countryCode
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1962
1961
|
/**
|
|
1963
|
-
*
|
|
1964
|
-
*
|
|
1965
|
-
* @param
|
|
1966
|
-
* @
|
|
1962
|
+
* Handle a security check result by tracking events and throwing appropriate errors
|
|
1963
|
+
*
|
|
1964
|
+
* @param verdict - The security verdict from the security service
|
|
1965
|
+
* @param ctx - Security check context with request information
|
|
1966
|
+
* @param trackEvent - Function to track security events
|
|
1967
|
+
* @param securityService - Security service for generating challenges
|
|
1968
|
+
* @returns True if the request should be blocked
|
|
1967
1969
|
*/
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
const
|
|
1972
|
-
if (
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
if (
|
|
1981
|
-
|
|
1970
|
+
async function handleSecurityVerdict(verdict, ctx, trackEvent, securityService) {
|
|
1971
|
+
if (verdict.action === "allow") return;
|
|
1972
|
+
const reason = verdict.reason || "unknown";
|
|
1973
|
+
const confidence = 1;
|
|
1974
|
+
if (verdict.action === "challenge" && ctx.visitorId) {
|
|
1975
|
+
const challenge = verdict.challenge || await securityService.generateChallenge(ctx.visitorId);
|
|
1976
|
+
if (!challenge?.trim()) {
|
|
1977
|
+
logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
trackEvent(buildEventData(ctx, "challenged", reason, confidence, verdict.details));
|
|
1981
|
+
throwChallengeError(challenge, reason, "Please complete a security check to continue.");
|
|
1982
|
+
} else if (verdict.action === "block") {
|
|
1983
|
+
trackEvent(buildEventData(ctx, "blocked", reason, confidence, verdict.details));
|
|
1984
|
+
throw new APIError("FORBIDDEN", { message: ERROR_MESSAGES[reason] || "Access denied." });
|
|
1982
1985
|
}
|
|
1983
|
-
|
|
1984
|
-
if (blockTollFree && phoneType === "TOLL_FREE") return false;
|
|
1985
|
-
if (blockVoip && phoneType === "VOIP") return false;
|
|
1986
|
-
return true;
|
|
1987
|
-
};
|
|
1988
|
-
const allPhonePaths = new Set([
|
|
1989
|
-
"/phone-number/send-otp",
|
|
1990
|
-
"/phone-number/verify",
|
|
1991
|
-
"/sign-in/phone-number",
|
|
1992
|
-
"/phone-number/request-password-reset",
|
|
1993
|
-
"/phone-number/reset-password"
|
|
1994
|
-
]);
|
|
1995
|
-
const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
|
|
1986
|
+
}
|
|
1996
1987
|
/**
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1988
|
+
* Run all security checks using the consolidated checkSecurity API
|
|
1989
|
+
*
|
|
1990
|
+
* This replaces the multiple individual check calls with a single API call
|
|
1991
|
+
* that handles all security checks server-side.
|
|
1999
1992
|
*/
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
1993
|
+
async function runSecurityChecks(ctx, securityService, trackEvent, powVerified) {
|
|
1994
|
+
if (powVerified) return;
|
|
1995
|
+
await handleSecurityVerdict(await securityService.checkSecurity({
|
|
1996
|
+
visitorId: ctx.visitorId,
|
|
1997
|
+
requestId: ctx.identification?.requestId || null,
|
|
1998
|
+
ip: ctx.identification?.ip || null,
|
|
1999
|
+
path: ctx.path,
|
|
2000
|
+
identifier: ctx.identifier
|
|
2001
|
+
}), ctx, trackEvent, securityService);
|
|
2002
|
+
}
|
|
2008
2003
|
//#endregion
|
|
2009
|
-
//#region src/sentinel.ts
|
|
2004
|
+
//#region src/sentinel/sentinel.ts
|
|
2010
2005
|
const sentinel = (options) => {
|
|
2011
2006
|
const opts = resolveSentinelOptions(options);
|
|
2012
2007
|
const { tracker } = initTrackEvents(opts);
|
|
@@ -2062,7 +2057,8 @@ const sentinel = (options) => {
|
|
|
2062
2057
|
});
|
|
2063
2058
|
return {
|
|
2064
2059
|
id: "sentinel",
|
|
2065
|
-
init() {
|
|
2060
|
+
init(ctx) {
|
|
2061
|
+
const activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
|
|
2066
2062
|
return { options: {
|
|
2067
2063
|
emailValidation: opts.security?.emailValidation,
|
|
2068
2064
|
databaseHooks: {
|
|
@@ -2074,7 +2070,7 @@ const sentinel = (options) => {
|
|
|
2074
2070
|
const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
|
|
2075
2071
|
if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
|
|
2076
2072
|
}
|
|
2077
|
-
if (user.email && typeof user.email === "string") return { data: {
|
|
2073
|
+
if (user.email && typeof user.email === "string" && opts.security?.emailValidation?.enabled !== false) return { data: {
|
|
2078
2074
|
...user,
|
|
2079
2075
|
email: normalizeEmail(user.email, ctx.context)
|
|
2080
2076
|
} };
|
|
@@ -2103,14 +2099,15 @@ const sentinel = (options) => {
|
|
|
2103
2099
|
const visitorId = ctx.context.visitorId;
|
|
2104
2100
|
const identification = ctx.context.identification;
|
|
2105
2101
|
let user = null;
|
|
2102
|
+
const userSelect = [
|
|
2103
|
+
"email",
|
|
2104
|
+
"name",
|
|
2105
|
+
...activityTrackingEnabled ? ["lastActiveAt"] : []
|
|
2106
|
+
];
|
|
2106
2107
|
try {
|
|
2107
2108
|
user = await ctx.context.adapter.findOne({
|
|
2108
2109
|
model: "user",
|
|
2109
|
-
select: [
|
|
2110
|
-
"email",
|
|
2111
|
-
"name",
|
|
2112
|
-
"lastActiveAt"
|
|
2113
|
-
],
|
|
2110
|
+
select: [...userSelect],
|
|
2114
2111
|
where: [{
|
|
2115
2112
|
field: "id",
|
|
2116
2113
|
value: session.userId
|
|
@@ -2123,7 +2120,8 @@ const sentinel = (options) => {
|
|
|
2123
2120
|
if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
|
|
2124
2121
|
}
|
|
2125
2122
|
if (opts.security?.staleUsers?.enabled && user) {
|
|
2126
|
-
const
|
|
2123
|
+
const lastActiveAtForStale = activityTrackingEnabled ? user.lastActiveAt ?? null : null;
|
|
2124
|
+
const staleCheck = await securityService.checkStaleUser(session.userId, lastActiveAtForStale);
|
|
2127
2125
|
if (staleCheck.isStale) {
|
|
2128
2126
|
const staleOpts = opts.security.staleUsers;
|
|
2129
2127
|
const notificationPromises = [];
|
|
@@ -2747,6 +2745,9 @@ const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
|
|
|
2747
2745
|
return { payload };
|
|
2748
2746
|
});
|
|
2749
2747
|
//#endregion
|
|
2748
|
+
//#region src/version.ts
|
|
2749
|
+
const PLUGIN_VERSION = "0.2.0";
|
|
2750
|
+
//#endregion
|
|
2750
2751
|
//#region src/routes/auth/config.ts
|
|
2751
2752
|
const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
|
|
2752
2753
|
function isPlainSerializable(value) {
|
|
@@ -2792,11 +2793,17 @@ const getConfig = (options) => {
|
|
|
2792
2793
|
socialProviders: Object.keys(ctx.context.options.socialProviders || {}),
|
|
2793
2794
|
emailAndPassword: ctx.context.options.emailAndPassword,
|
|
2794
2795
|
plugins: ctx.context.options.plugins?.map((plugin) => {
|
|
2795
|
-
|
|
2796
|
+
const base = {
|
|
2796
2797
|
id: plugin.id,
|
|
2797
2798
|
schema: plugin.schema,
|
|
2799
|
+
version: plugin.version,
|
|
2798
2800
|
options: sanitizePluginOptions(plugin.id, plugin.options)
|
|
2799
2801
|
};
|
|
2802
|
+
if (plugin.id === "dash" && !plugin.version) return {
|
|
2803
|
+
...base,
|
|
2804
|
+
version: PLUGIN_VERSION
|
|
2805
|
+
};
|
|
2806
|
+
return base;
|
|
2800
2807
|
}) ?? [],
|
|
2801
2808
|
organization: {
|
|
2802
2809
|
sendInvitationEmailEnabled: !!organizationPlugin?.options?.sendInvitationEmail,
|
|
@@ -3092,13 +3099,6 @@ const regenerateDirectoryToken = (options) => {
|
|
|
3092
3099
|
};
|
|
3093
3100
|
//#endregion
|
|
3094
3101
|
//#region src/routes/events/index.ts
|
|
3095
|
-
/**
|
|
3096
|
-
* All available event types that can be returned in audit logs
|
|
3097
|
-
*/
|
|
3098
|
-
const USER_EVENT_TYPES = {
|
|
3099
|
-
...EVENT_TYPES,
|
|
3100
|
-
...ORGANIZATION_EVENT_TYPES
|
|
3101
|
-
};
|
|
3102
3102
|
function transformEvent(raw) {
|
|
3103
3103
|
const location = raw.ipAddress || raw.city || raw.country || raw.countryCode ? {
|
|
3104
3104
|
ipAddress: raw.ipAddress,
|
|
@@ -3498,6 +3498,7 @@ const completeInvitation = (options) => {
|
|
|
3498
3498
|
})
|
|
3499
3499
|
}, async (ctx) => {
|
|
3500
3500
|
const { token, password, providerId, providerAccountId, accessToken, refreshToken } = ctx.body;
|
|
3501
|
+
const fallbackRedirect = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
|
|
3501
3502
|
const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
|
|
3502
3503
|
method: "POST",
|
|
3503
3504
|
body: { token }
|
|
@@ -3525,7 +3526,7 @@ const completeInvitation = (options) => {
|
|
|
3525
3526
|
});
|
|
3526
3527
|
return {
|
|
3527
3528
|
success: true,
|
|
3528
|
-
redirectUrl: invitation.redirectUrl
|
|
3529
|
+
redirectUrl: invitation.redirectUrl ?? fallbackRedirect
|
|
3529
3530
|
};
|
|
3530
3531
|
}
|
|
3531
3532
|
const user = await ctx.context.internalAdapter.createUser({
|
|
@@ -3562,7 +3563,7 @@ const completeInvitation = (options) => {
|
|
|
3562
3563
|
});
|
|
3563
3564
|
return {
|
|
3564
3565
|
success: true,
|
|
3565
|
-
redirectUrl: invitation.redirectUrl
|
|
3566
|
+
redirectUrl: invitation.redirectUrl ?? fallbackRedirect
|
|
3566
3567
|
};
|
|
3567
3568
|
});
|
|
3568
3569
|
};
|
|
@@ -3710,7 +3711,7 @@ const checkUserByEmail = (options) => {
|
|
|
3710
3711
|
id: user.id,
|
|
3711
3712
|
name: user.name,
|
|
3712
3713
|
email: user.email,
|
|
3713
|
-
image: user.image
|
|
3714
|
+
image: user.image ?? null
|
|
3714
3715
|
},
|
|
3715
3716
|
isAlreadyMember: !!existingMember
|
|
3716
3717
|
};
|
|
@@ -3861,26 +3862,31 @@ const listOrganizationMembers = (options) => {
|
|
|
3861
3862
|
if (inviter && inv.inviterId) inviterById.set(inv.inviterId, inviter);
|
|
3862
3863
|
}
|
|
3863
3864
|
const invitationByEmail = new Map(invitations.map((i) => [i.email.toLowerCase(), i]));
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
const
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
} : null,
|
|
3876
|
-
invitedBy: inviter ? {
|
|
3877
|
-
id: inviter.id,
|
|
3878
|
-
name: inviter.name,
|
|
3879
|
-
email: inviter.email,
|
|
3880
|
-
image: inviter.image || null
|
|
3881
|
-
} : null
|
|
3865
|
+
const result = [];
|
|
3866
|
+
for (const m of members) {
|
|
3867
|
+
const joinedUser = Array.isArray(m.user) ? m.user[0] : m.user;
|
|
3868
|
+
if (!joinedUser) continue;
|
|
3869
|
+
const invitation = invitationByEmail.get(joinedUser.email.toLowerCase());
|
|
3870
|
+
const inviter = invitation ? inviterById.get(invitation.inviterId) : void 0;
|
|
3871
|
+
const user = {
|
|
3872
|
+
id: joinedUser.id,
|
|
3873
|
+
email: joinedUser.email,
|
|
3874
|
+
name: joinedUser.name,
|
|
3875
|
+
image: joinedUser.image ?? null
|
|
3882
3876
|
};
|
|
3883
|
-
|
|
3877
|
+
const invitedBy = inviter ? {
|
|
3878
|
+
id: inviter.id,
|
|
3879
|
+
name: inviter.name,
|
|
3880
|
+
email: inviter.email,
|
|
3881
|
+
image: inviter.image ?? null
|
|
3882
|
+
} : null;
|
|
3883
|
+
result.push({
|
|
3884
|
+
...m,
|
|
3885
|
+
user,
|
|
3886
|
+
invitedBy
|
|
3887
|
+
});
|
|
3888
|
+
}
|
|
3889
|
+
return result;
|
|
3884
3890
|
});
|
|
3885
3891
|
};
|
|
3886
3892
|
const addMember = (options) => {
|
|
@@ -4144,6 +4150,16 @@ const exportFactory = (input, options) => async (ctx) => {
|
|
|
4144
4150
|
//#endregion
|
|
4145
4151
|
//#region src/helper.ts
|
|
4146
4152
|
/**
|
|
4153
|
+
* Returns true when sessions live exclusively in secondary storage
|
|
4154
|
+
* (i.e. the user has configured secondaryStorage but has NOT enabled
|
|
4155
|
+
* storeSessionInDatabase). In this case DB queries against the session
|
|
4156
|
+
* model will return empty results and we must go through the
|
|
4157
|
+
* internalAdapter instead.
|
|
4158
|
+
*/
|
|
4159
|
+
function isSessionInSecondaryStorageOnly(context) {
|
|
4160
|
+
return !!context.options.secondaryStorage && !context.options.session?.storeSessionInDatabase;
|
|
4161
|
+
}
|
|
4162
|
+
/**
|
|
4147
4163
|
* Checks whether a plugin is registered by its ID.
|
|
4148
4164
|
* Prefers the native `hasPlugin` when available, otherwise
|
|
4149
4165
|
* falls back to scanning `options.plugins`.
|
|
@@ -4200,14 +4216,16 @@ const listOrganizations = (options) => {
|
|
|
4200
4216
|
endDate: z$1.date().or(z$1.string().transform((val) => new Date(val))).optional()
|
|
4201
4217
|
}).optional()
|
|
4202
4218
|
}, async (ctx) => {
|
|
4219
|
+
const { limit = 10, offset = 0, sortBy = "createdAt", sortOrder = "desc", search, filterMembers } = ctx.query || {};
|
|
4203
4220
|
if (!isOrganizationEnabled(ctx)) {
|
|
4204
4221
|
ctx.context.logger.warn("[Dash] Organization plugin not enabled, returning empty organizations list");
|
|
4205
4222
|
return {
|
|
4206
4223
|
organizations: [],
|
|
4207
|
-
total: 0
|
|
4224
|
+
total: 0,
|
|
4225
|
+
offset,
|
|
4226
|
+
limit
|
|
4208
4227
|
};
|
|
4209
4228
|
}
|
|
4210
|
-
const { limit = 10, offset = 0, sortBy = "createdAt", sortOrder = "desc", search, filterMembers } = ctx.query || {};
|
|
4211
4229
|
const where = [];
|
|
4212
4230
|
if (search && search.trim().length > 0) {
|
|
4213
4231
|
const searchTerm = search.trim();
|
|
@@ -4215,11 +4233,13 @@ const listOrganizations = (options) => {
|
|
|
4215
4233
|
field: "name",
|
|
4216
4234
|
value: searchTerm,
|
|
4217
4235
|
operator: "starts_with",
|
|
4236
|
+
mode: "insensitive",
|
|
4218
4237
|
connector: "OR"
|
|
4219
4238
|
}, {
|
|
4220
4239
|
field: "slug",
|
|
4221
4240
|
value: searchTerm,
|
|
4222
4241
|
operator: "starts_with",
|
|
4242
|
+
mode: "insensitive",
|
|
4223
4243
|
connector: "OR"
|
|
4224
4244
|
});
|
|
4225
4245
|
}
|
|
@@ -4235,7 +4255,7 @@ const listOrganizations = (options) => {
|
|
|
4235
4255
|
});
|
|
4236
4256
|
const needsInMemoryProcessing = sortBy === "members" || !!filterMembers;
|
|
4237
4257
|
const dbSortBy = sortBy === "members" ? "createdAt" : sortBy;
|
|
4238
|
-
const fetchLimit = needsInMemoryProcessing ?
|
|
4258
|
+
const fetchLimit = needsInMemoryProcessing ? 2500 : limit;
|
|
4239
4259
|
const fetchOffset = needsInMemoryProcessing ? 0 : offset;
|
|
4240
4260
|
const [organizations, initialTotal] = await Promise.all([ctx.context.adapter.findMany({
|
|
4241
4261
|
model: "organization",
|
|
@@ -4356,7 +4376,7 @@ const getOrganizationOptions = (options) => {
|
|
|
4356
4376
|
}, async (ctx) => {
|
|
4357
4377
|
const organizationPlugin = ctx.context.getPlugin("organization");
|
|
4358
4378
|
if (!organizationPlugin) return { teamsEnabled: false };
|
|
4359
|
-
return { teamsEnabled: organizationPlugin.options?.teams?.enabled && organizationPlugin.options.teams.defaultTeam?.enabled !== false };
|
|
4379
|
+
return { teamsEnabled: Boolean(organizationPlugin.options?.teams?.enabled && organizationPlugin.options.teams.defaultTeam?.enabled !== false) };
|
|
4360
4380
|
});
|
|
4361
4381
|
};
|
|
4362
4382
|
const getOrganization = (options) => {
|
|
@@ -4844,6 +4864,7 @@ const updateTeam = (options) => {
|
|
|
4844
4864
|
}],
|
|
4845
4865
|
update: updateData
|
|
4846
4866
|
});
|
|
4867
|
+
if (!team) throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to update team" });
|
|
4847
4868
|
if (orgOptions?.organizationHooks?.afterUpdateTeam) await orgOptions.organizationHooks.afterUpdateTeam({
|
|
4848
4869
|
team,
|
|
4849
4870
|
user,
|
|
@@ -5043,7 +5064,7 @@ const listTeamMembers = (options) => {
|
|
|
5043
5064
|
id: user.id,
|
|
5044
5065
|
name: user.name,
|
|
5045
5066
|
email: user.email,
|
|
5046
|
-
image: user.image
|
|
5067
|
+
image: user.image ?? null
|
|
5047
5068
|
} : null
|
|
5048
5069
|
};
|
|
5049
5070
|
});
|
|
@@ -5232,43 +5253,6 @@ const removeTeamMember = (options) => {
|
|
|
5232
5253
|
};
|
|
5233
5254
|
//#endregion
|
|
5234
5255
|
//#region src/routes/sessions/index.ts
|
|
5235
|
-
const listAllSessions = (options) => {
|
|
5236
|
-
return createAuthEndpoint("/dash/list-all-sessions", {
|
|
5237
|
-
method: "GET",
|
|
5238
|
-
use: [jwtMiddleware(options)],
|
|
5239
|
-
query: z$1.object({
|
|
5240
|
-
limit: z$1.number().optional(),
|
|
5241
|
-
offset: z$1.number().optional()
|
|
5242
|
-
}).optional()
|
|
5243
|
-
}, async (ctx) => {
|
|
5244
|
-
const sessionsCount = await ctx.context.adapter.count({ model: "session" });
|
|
5245
|
-
const limit = ctx.query?.limit || sessionsCount;
|
|
5246
|
-
const offset = ctx.query?.offset || 0;
|
|
5247
|
-
const sessions = await ctx.context.adapter.findMany({
|
|
5248
|
-
model: "session",
|
|
5249
|
-
limit,
|
|
5250
|
-
offset,
|
|
5251
|
-
sortBy: {
|
|
5252
|
-
field: "createdAt",
|
|
5253
|
-
direction: "desc"
|
|
5254
|
-
},
|
|
5255
|
-
join: { user: true }
|
|
5256
|
-
});
|
|
5257
|
-
const userMap = /* @__PURE__ */ new Map();
|
|
5258
|
-
for (const s of sessions) {
|
|
5259
|
-
const user = Array.isArray(s.user) ? s.user[0] : s.user;
|
|
5260
|
-
if (!user) continue;
|
|
5261
|
-
const { user: _u, ...sessionData } = s;
|
|
5262
|
-
const session = sessionData;
|
|
5263
|
-
if (!userMap.has(user.id)) userMap.set(user.id, {
|
|
5264
|
-
...user,
|
|
5265
|
-
sessions: []
|
|
5266
|
-
});
|
|
5267
|
-
userMap.get(user.id).sessions.push(session);
|
|
5268
|
-
}
|
|
5269
|
-
return Array.from(userMap.values());
|
|
5270
|
-
});
|
|
5271
|
-
};
|
|
5272
5256
|
const revokeSession = (options) => createAuthEndpoint("/dash/sessions/revoke", {
|
|
5273
5257
|
method: "POST",
|
|
5274
5258
|
use: [jwtMiddleware(options)],
|
|
@@ -5276,18 +5260,26 @@ const revokeSession = (options) => createAuthEndpoint("/dash/sessions/revoke", {
|
|
|
5276
5260
|
}, async (ctx) => {
|
|
5277
5261
|
const { sessionId, userId } = ctx.context.payload;
|
|
5278
5262
|
if (!sessionId || !userId) throw ctx.error("FORBIDDEN", { message: "Invalid payload" });
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5263
|
+
let sessionToken;
|
|
5264
|
+
if (isSessionInSecondaryStorageOnly(ctx.context)) {
|
|
5265
|
+
const match = (await ctx.context.internalAdapter.listSessions(userId)).find((s) => s.id === sessionId || s.token === sessionId);
|
|
5266
|
+
if (!match) throw ctx.error("NOT_FOUND", { message: "Session not found" });
|
|
5267
|
+
sessionToken = match.token;
|
|
5268
|
+
} else {
|
|
5269
|
+
const session = await ctx.context.adapter.findOne({
|
|
5270
|
+
model: "session",
|
|
5271
|
+
where: [{
|
|
5272
|
+
field: "id",
|
|
5273
|
+
value: sessionId
|
|
5274
|
+
}, {
|
|
5275
|
+
field: "userId",
|
|
5276
|
+
value: userId
|
|
5277
|
+
}]
|
|
5278
|
+
});
|
|
5279
|
+
if (!session) throw ctx.error("NOT_FOUND", { message: "Session not found" });
|
|
5280
|
+
sessionToken = session.token;
|
|
5281
|
+
}
|
|
5282
|
+
await ctx.context.internalAdapter.deleteSession(sessionToken);
|
|
5291
5283
|
return ctx.json({ success: true });
|
|
5292
5284
|
});
|
|
5293
5285
|
const revokeAllSessions = (options) => createAuthEndpoint("/dash/sessions/revoke-all", {
|
|
@@ -5408,8 +5400,16 @@ function getSSOPlugin(ctx) {
|
|
|
5408
5400
|
}
|
|
5409
5401
|
//#endregion
|
|
5410
5402
|
//#region src/routes/sso-validation.ts
|
|
5411
|
-
|
|
5412
|
-
|
|
5403
|
+
/**
|
|
5404
|
+
* SAML metadata limits and algorithm URIs aligned with @better-auth/sso
|
|
5405
|
+
* (see packages/sso dist constants). Inlined so consumers (e.g. Metro) never
|
|
5406
|
+
* statically pull @better-auth/sso for validation-only code paths.
|
|
5407
|
+
*/
|
|
5408
|
+
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
5409
|
+
const RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1";
|
|
5410
|
+
const SHA1 = "http://www.w3.org/2000/09/xmldsig#sha1";
|
|
5411
|
+
const DEPRECATED_SIGNATURE_ALGORITHMS = [RSA_SHA1];
|
|
5412
|
+
const DEPRECATED_DIGEST_ALGORITHMS = [SHA1];
|
|
5413
5413
|
function validateSAMLMetadataSize(metadataXml, maxSize = DEFAULT_MAX_SAML_METADATA_SIZE) {
|
|
5414
5414
|
if (new TextEncoder().encode(metadataXml).byteLength > maxSize) throw new Error(`IdP metadata exceeds maximum allowed size (${Math.round(maxSize / 1024)}KB)`);
|
|
5415
5415
|
}
|
|
@@ -5437,6 +5437,10 @@ function validateSAMLMetadataAlgorithms(metadataXml) {
|
|
|
5437
5437
|
}
|
|
5438
5438
|
//#endregion
|
|
5439
5439
|
//#region src/routes/sso/index.ts
|
|
5440
|
+
let ssoRuntimeModule;
|
|
5441
|
+
function loadSsoRuntime() {
|
|
5442
|
+
return ssoRuntimeModule ??= import("@better-auth/sso");
|
|
5443
|
+
}
|
|
5440
5444
|
function requireOrganizationAccess(ctx) {
|
|
5441
5445
|
const orgIdFromUrl = tryDecode(ctx.params.id);
|
|
5442
5446
|
const orgIdFromToken = ctx.context.payload?.organizationId;
|
|
@@ -5509,6 +5513,7 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
|
|
|
5509
5513
|
}] } : {},
|
|
5510
5514
|
...samlConfig.cert ? { cert: samlConfig.cert } : {}
|
|
5511
5515
|
};
|
|
5516
|
+
const m = samlConfig.mapping;
|
|
5512
5517
|
return {
|
|
5513
5518
|
config: {
|
|
5514
5519
|
issuer: samlConfig.entityId ?? `${baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`,
|
|
@@ -5517,7 +5522,15 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
|
|
|
5517
5522
|
spMetadata: {},
|
|
5518
5523
|
entryPoint: samlConfig.entryPoint ?? "",
|
|
5519
5524
|
cert: samlConfig.cert ?? "",
|
|
5520
|
-
...
|
|
5525
|
+
...m ? { mapping: {
|
|
5526
|
+
id: m.id ?? "nameID",
|
|
5527
|
+
email: m.email ?? "email",
|
|
5528
|
+
name: m.name ?? "name",
|
|
5529
|
+
emailVerified: m.emailVerified,
|
|
5530
|
+
firstName: m.firstName,
|
|
5531
|
+
lastName: m.lastName,
|
|
5532
|
+
extraFields: m.extraFields
|
|
5533
|
+
} } : {}
|
|
5521
5534
|
},
|
|
5522
5535
|
...metadataAlgorithmWarnings.length > 0 ? { warnings: metadataAlgorithmWarnings } : {}
|
|
5523
5536
|
};
|
|
@@ -5531,8 +5544,9 @@ async function resolveOIDCConfig(oidcConfig, domain, ctx) {
|
|
|
5531
5544
|
}
|
|
5532
5545
|
const issuerHint = oidcConfig.issuer || `https://${normalizedDomain}`;
|
|
5533
5546
|
const issuer = issuerHint.startsWith("http") ? issuerHint : `https://${issuerHint}`;
|
|
5547
|
+
const sso = await loadSsoRuntime();
|
|
5534
5548
|
try {
|
|
5535
|
-
const hydratedConfig = await discoverOIDCConfig({
|
|
5549
|
+
const hydratedConfig = await sso.discoverOIDCConfig({
|
|
5536
5550
|
issuer,
|
|
5537
5551
|
discoveryEndpoint: oidcConfig.discoveryUrl,
|
|
5538
5552
|
isTrustedOrigin: (url) => {
|
|
@@ -5544,6 +5558,7 @@ async function resolveOIDCConfig(oidcConfig, domain, ctx) {
|
|
|
5544
5558
|
}
|
|
5545
5559
|
}
|
|
5546
5560
|
});
|
|
5561
|
+
const om = oidcConfig.mapping;
|
|
5547
5562
|
return {
|
|
5548
5563
|
config: {
|
|
5549
5564
|
clientId: oidcConfig.clientId,
|
|
@@ -5556,12 +5571,19 @@ async function resolveOIDCConfig(oidcConfig, domain, ctx) {
|
|
|
5556
5571
|
userInfoEndpoint: hydratedConfig.userInfoEndpoint,
|
|
5557
5572
|
tokenEndpointAuthentication: hydratedConfig.tokenEndpointAuthentication,
|
|
5558
5573
|
pkce: true,
|
|
5559
|
-
...
|
|
5574
|
+
...om ? { mapping: {
|
|
5575
|
+
id: om.id ?? "sub",
|
|
5576
|
+
email: om.email ?? "email",
|
|
5577
|
+
name: om.name ?? "name",
|
|
5578
|
+
emailVerified: om.emailVerified,
|
|
5579
|
+
image: om.image,
|
|
5580
|
+
extraFields: om.extraFields
|
|
5581
|
+
} } : {}
|
|
5560
5582
|
},
|
|
5561
5583
|
issuer: hydratedConfig.issuer
|
|
5562
5584
|
};
|
|
5563
5585
|
} catch (e) {
|
|
5564
|
-
if (e instanceof DiscoveryError) {
|
|
5586
|
+
if (e instanceof sso.DiscoveryError) {
|
|
5565
5587
|
ctx.context.logger.error("[Dash] OIDC discovery failed:", e);
|
|
5566
5588
|
throw ctx.error("BAD_REQUEST", {
|
|
5567
5589
|
message: `OIDC discovery failed: ${e.message}`,
|
|
@@ -5648,6 +5670,8 @@ const createSsoProvider = (options) => {
|
|
|
5648
5670
|
...buildSessionContext(userId)
|
|
5649
5671
|
}
|
|
5650
5672
|
});
|
|
5673
|
+
let verificationToken = null;
|
|
5674
|
+
if ("domainVerificationToken" in result && typeof result.domainVerificationToken === "string") verificationToken = result.domainVerificationToken;
|
|
5651
5675
|
return {
|
|
5652
5676
|
success: true,
|
|
5653
5677
|
provider: {
|
|
@@ -5657,7 +5681,7 @@ const createSsoProvider = (options) => {
|
|
|
5657
5681
|
},
|
|
5658
5682
|
domainVerification: {
|
|
5659
5683
|
txtRecordName: `better-auth-token-${providerId}`,
|
|
5660
|
-
verificationToken
|
|
5684
|
+
verificationToken
|
|
5661
5685
|
}
|
|
5662
5686
|
};
|
|
5663
5687
|
} catch (e) {
|
|
@@ -5758,7 +5782,9 @@ const requestSsoVerificationToken = (options) => {
|
|
|
5758
5782
|
requireOrganizationPlugin(ctx);
|
|
5759
5783
|
requireOrganizationAccess(ctx);
|
|
5760
5784
|
const ssoPlugin = getSSOPlugin(ctx);
|
|
5761
|
-
if (!ssoPlugin
|
|
5785
|
+
if (!ssoPlugin || !ssoPlugin.options?.domainVerification?.enabled) throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
|
|
5786
|
+
const endpoints = ssoPlugin.endpoints;
|
|
5787
|
+
if (typeof endpoints.requestDomainVerification !== "function") throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
|
|
5762
5788
|
const organizationId = tryDecode(ctx.params.id);
|
|
5763
5789
|
const { providerId } = ctx.body;
|
|
5764
5790
|
const provider = await ctx.context.adapter.findOne({
|
|
@@ -5774,7 +5800,7 @@ const requestSsoVerificationToken = (options) => {
|
|
|
5774
5800
|
if (!provider) throw ctx.error("NOT_FOUND", { message: "SSO provider not found" });
|
|
5775
5801
|
const txtRecordName = `${ssoPlugin.options?.domainVerification?.tokenPrefix || "better-auth-token"}-${provider.providerId}`;
|
|
5776
5802
|
try {
|
|
5777
|
-
const result = await
|
|
5803
|
+
const result = await endpoints.requestDomainVerification({
|
|
5778
5804
|
body: { providerId },
|
|
5779
5805
|
context: {
|
|
5780
5806
|
...ctx.context,
|
|
@@ -5785,7 +5811,7 @@ const requestSsoVerificationToken = (options) => {
|
|
|
5785
5811
|
success: true,
|
|
5786
5812
|
providerId: provider.providerId,
|
|
5787
5813
|
domain: provider.domain,
|
|
5788
|
-
verificationToken: result.domainVerificationToken,
|
|
5814
|
+
verificationToken: result.domainVerificationToken ?? "",
|
|
5789
5815
|
txtRecordName
|
|
5790
5816
|
};
|
|
5791
5817
|
} catch (e) {
|
|
@@ -5804,7 +5830,9 @@ const verifySsoProviderDomain = (options) => {
|
|
|
5804
5830
|
requireOrganizationPlugin(ctx);
|
|
5805
5831
|
requireOrganizationAccess(ctx);
|
|
5806
5832
|
const ssoPlugin = getSSOPlugin(ctx);
|
|
5807
|
-
if (!ssoPlugin
|
|
5833
|
+
if (!ssoPlugin || !ssoPlugin.options?.domainVerification?.enabled) throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
|
|
5834
|
+
const dvEndpoints = ssoPlugin.endpoints;
|
|
5835
|
+
if (typeof dvEndpoints.verifyDomain !== "function") throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
|
|
5808
5836
|
const organizationId = tryDecode(ctx.params.id);
|
|
5809
5837
|
const { providerId } = ctx.body;
|
|
5810
5838
|
const provider = await ctx.context.adapter.findOne({
|
|
@@ -5819,7 +5847,7 @@ const verifySsoProviderDomain = (options) => {
|
|
|
5819
5847
|
});
|
|
5820
5848
|
if (!provider) throw ctx.error("NOT_FOUND", { message: "SSO provider not found" });
|
|
5821
5849
|
try {
|
|
5822
|
-
await
|
|
5850
|
+
await dvEndpoints.verifyDomain({
|
|
5823
5851
|
body: { providerId },
|
|
5824
5852
|
context: {
|
|
5825
5853
|
...ctx.context,
|
|
@@ -6434,6 +6462,7 @@ const getUserDetails = (options) => {
|
|
|
6434
6462
|
const { userId } = ctx.context.payload;
|
|
6435
6463
|
const minimal = !!ctx.query?.minimal;
|
|
6436
6464
|
const hasAdminPlugin = hasPlugin(ctx.context, "admin");
|
|
6465
|
+
const secondaryStorageOnly = isSessionInSecondaryStorageOnly(ctx.context);
|
|
6437
6466
|
const user = await ctx.context.adapter.findOne({
|
|
6438
6467
|
model: "user",
|
|
6439
6468
|
where: [{
|
|
@@ -6442,7 +6471,7 @@ const getUserDetails = (options) => {
|
|
|
6442
6471
|
}],
|
|
6443
6472
|
...minimal ? {} : { join: {
|
|
6444
6473
|
account: true,
|
|
6445
|
-
session: true
|
|
6474
|
+
...secondaryStorageOnly ? {} : { session: true }
|
|
6446
6475
|
} }
|
|
6447
6476
|
});
|
|
6448
6477
|
if (!user) throw ctx.error("NOT_FOUND", { message: "User not found" });
|
|
@@ -6456,10 +6485,10 @@ const getUserDetails = (options) => {
|
|
|
6456
6485
|
session: []
|
|
6457
6486
|
};
|
|
6458
6487
|
const activityTrackingEnabled = !!options.activityTracking?.enabled;
|
|
6459
|
-
const sessions = user.session || [];
|
|
6488
|
+
const sessions = secondaryStorageOnly ? await ctx.context.internalAdapter.listSessions(userId) : user.session || [];
|
|
6460
6489
|
let lastActiveAt = null;
|
|
6461
6490
|
if (activityTrackingEnabled) {
|
|
6462
|
-
lastActiveAt = user.lastActiveAt;
|
|
6491
|
+
lastActiveAt = user.lastActiveAt ?? null;
|
|
6463
6492
|
let shouldUpdateLastActiveAt = false;
|
|
6464
6493
|
if (sessions.length > 0) {
|
|
6465
6494
|
const mostRecentSession = [...sessions].sort((a, b) => {
|
|
@@ -6487,6 +6516,7 @@ const getUserDetails = (options) => {
|
|
|
6487
6516
|
}
|
|
6488
6517
|
return {
|
|
6489
6518
|
...user,
|
|
6519
|
+
...secondaryStorageOnly ? { session: sessions } : {},
|
|
6490
6520
|
lastActiveAt,
|
|
6491
6521
|
banned: hasAdminPlugin ? user.banned ?? false : false,
|
|
6492
6522
|
banReason: hasAdminPlugin ? user.banReason ?? null : null,
|
|
@@ -6537,7 +6567,7 @@ const getUserOrganizations = (options) => {
|
|
|
6537
6567
|
role: m.role,
|
|
6538
6568
|
teams: teams.filter((team) => team.organizationId === organization.id)
|
|
6539
6569
|
};
|
|
6540
|
-
}).filter(
|
|
6570
|
+
}).filter((row) => row != null) };
|
|
6541
6571
|
});
|
|
6542
6572
|
};
|
|
6543
6573
|
const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
|
|
@@ -6562,8 +6592,8 @@ const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
|
|
|
6562
6592
|
if (!user) throw new APIError("NOT_FOUND", { message: "User not found" });
|
|
6563
6593
|
return user;
|
|
6564
6594
|
});
|
|
6565
|
-
async function countUniqueActiveUsers(
|
|
6566
|
-
const field = activityTrackingEnabled ? "lastActiveAt" : "updatedAt";
|
|
6595
|
+
async function countUniqueActiveUsers(ctx, range, options) {
|
|
6596
|
+
const field = options.activityTrackingEnabled ? "lastActiveAt" : "updatedAt";
|
|
6567
6597
|
const where = [{
|
|
6568
6598
|
field,
|
|
6569
6599
|
operator: "gte",
|
|
@@ -6574,11 +6604,31 @@ async function countUniqueActiveUsers(adapter, range, activityTrackingEnabled) {
|
|
|
6574
6604
|
operator: "lt",
|
|
6575
6605
|
value: range.to
|
|
6576
6606
|
});
|
|
6577
|
-
if (activityTrackingEnabled) return adapter.count({
|
|
6607
|
+
if (options.activityTrackingEnabled) return ctx.context.adapter.count({
|
|
6578
6608
|
model: "user",
|
|
6579
6609
|
where
|
|
6580
6610
|
});
|
|
6581
|
-
|
|
6611
|
+
if (options.storeInSecondaryStorageOnly) {
|
|
6612
|
+
const users = await ctx.context.adapter.findMany({
|
|
6613
|
+
model: "user",
|
|
6614
|
+
select: ["id"]
|
|
6615
|
+
});
|
|
6616
|
+
const fromMs = range.from.getTime();
|
|
6617
|
+
const toMs = range.to?.getTime();
|
|
6618
|
+
const activeUserIds = /* @__PURE__ */ new Set();
|
|
6619
|
+
await withConcurrency(users, async (user) => {
|
|
6620
|
+
const sessions = await ctx.context.internalAdapter.listSessions(user.id);
|
|
6621
|
+
for (const session of sessions) {
|
|
6622
|
+
const updatedAt = new Date(session.updatedAt).getTime();
|
|
6623
|
+
if (updatedAt >= fromMs && (toMs === void 0 || updatedAt < toMs)) {
|
|
6624
|
+
activeUserIds.add(user.id);
|
|
6625
|
+
break;
|
|
6626
|
+
}
|
|
6627
|
+
}
|
|
6628
|
+
}, { concurrency: 50 });
|
|
6629
|
+
return activeUserIds.size;
|
|
6630
|
+
}
|
|
6631
|
+
const sessions = await ctx.context.adapter.findMany({
|
|
6582
6632
|
model: "session",
|
|
6583
6633
|
select: ["userId"],
|
|
6584
6634
|
where
|
|
@@ -6597,6 +6647,7 @@ const getUserStats = (options) => createAuthEndpoint("/dash/user-stats", {
|
|
|
6597
6647
|
const oneMonthAgo = /* @__PURE__ */ new Date(now.getTime() - 720 * 60 * 60 * 1e3);
|
|
6598
6648
|
const twoMonthsAgo = /* @__PURE__ */ new Date(now.getTime() - 1440 * 60 * 60 * 1e3);
|
|
6599
6649
|
const activityTrackingEnabled = !!options.activityTracking?.enabled;
|
|
6650
|
+
const storeInSecondaryStorageOnly = isSessionInSecondaryStorageOnly(ctx.context);
|
|
6600
6651
|
const [dailyCount, previousDailyCount, weeklyCount, previousWeeklyCount, monthlyCount, previousMonthlyCount, totalCount, dailyActiveCount, previousDailyActiveCount, weeklyActiveCount, previousWeeklyActiveCount, monthlyActiveCount, previousMonthlyActiveCount] = await Promise.all([
|
|
6601
6652
|
ctx.context.adapter.count({
|
|
6602
6653
|
model: "user",
|
|
@@ -6659,21 +6710,39 @@ const getUserStats = (options) => createAuthEndpoint("/dash/user-stats", {
|
|
|
6659
6710
|
}]
|
|
6660
6711
|
}),
|
|
6661
6712
|
ctx.context.adapter.count({ model: "user" }),
|
|
6662
|
-
countUniqueActiveUsers(ctx
|
|
6663
|
-
|
|
6713
|
+
countUniqueActiveUsers(ctx, { from: oneDayAgo }, {
|
|
6714
|
+
storeInSecondaryStorageOnly,
|
|
6715
|
+
activityTrackingEnabled
|
|
6716
|
+
}),
|
|
6717
|
+
countUniqueActiveUsers(ctx, {
|
|
6664
6718
|
from: twoDaysAgo,
|
|
6665
6719
|
to: oneDayAgo
|
|
6666
|
-
},
|
|
6667
|
-
|
|
6668
|
-
|
|
6720
|
+
}, {
|
|
6721
|
+
storeInSecondaryStorageOnly,
|
|
6722
|
+
activityTrackingEnabled
|
|
6723
|
+
}),
|
|
6724
|
+
countUniqueActiveUsers(ctx, { from: oneWeekAgo }, {
|
|
6725
|
+
storeInSecondaryStorageOnly,
|
|
6726
|
+
activityTrackingEnabled
|
|
6727
|
+
}),
|
|
6728
|
+
countUniqueActiveUsers(ctx, {
|
|
6669
6729
|
from: twoWeeksAgo,
|
|
6670
6730
|
to: oneWeekAgo
|
|
6671
|
-
},
|
|
6672
|
-
|
|
6673
|
-
|
|
6731
|
+
}, {
|
|
6732
|
+
storeInSecondaryStorageOnly,
|
|
6733
|
+
activityTrackingEnabled
|
|
6734
|
+
}),
|
|
6735
|
+
countUniqueActiveUsers(ctx, { from: oneMonthAgo }, {
|
|
6736
|
+
storeInSecondaryStorageOnly,
|
|
6737
|
+
activityTrackingEnabled
|
|
6738
|
+
}),
|
|
6739
|
+
countUniqueActiveUsers(ctx, {
|
|
6674
6740
|
from: twoMonthsAgo,
|
|
6675
6741
|
to: oneMonthAgo
|
|
6676
|
-
},
|
|
6742
|
+
}, {
|
|
6743
|
+
storeInSecondaryStorageOnly,
|
|
6744
|
+
activityTrackingEnabled
|
|
6745
|
+
})
|
|
6677
6746
|
]);
|
|
6678
6747
|
const calculatePercentage = (current, previous) => {
|
|
6679
6748
|
if (previous === 0) return current > 0 ? 100 : 0;
|
|
@@ -6721,6 +6790,7 @@ const getUserGraphData = (options) => createAuthEndpoint("/dash/user-graph-data"
|
|
|
6721
6790
|
const { period } = ctx.query;
|
|
6722
6791
|
const now = /* @__PURE__ */ new Date();
|
|
6723
6792
|
const activityTrackingEnabled = !!options.activityTracking?.enabled;
|
|
6793
|
+
const storeInSecondaryStorageOnly = isSessionInSecondaryStorageOnly(ctx.context);
|
|
6724
6794
|
const intervals = period === "daily" ? 7 : period === "weekly" ? 8 : 6;
|
|
6725
6795
|
const msPerInterval = period === "daily" ? 1440 * 60 * 1e3 : period === "weekly" ? 10080 * 60 * 1e3 : 720 * 60 * 60 * 1e3;
|
|
6726
6796
|
const intervalData = [];
|
|
@@ -6761,10 +6831,13 @@ const getUserGraphData = (options) => createAuthEndpoint("/dash/user-graph-data"
|
|
|
6761
6831
|
value: interval.endDate
|
|
6762
6832
|
}]
|
|
6763
6833
|
}),
|
|
6764
|
-
countUniqueActiveUsers(ctx
|
|
6834
|
+
countUniqueActiveUsers(ctx, {
|
|
6765
6835
|
from: interval.startDate,
|
|
6766
6836
|
to: interval.endDate
|
|
6767
|
-
},
|
|
6837
|
+
}, {
|
|
6838
|
+
storeInSecondaryStorageOnly,
|
|
6839
|
+
activityTrackingEnabled
|
|
6840
|
+
})
|
|
6768
6841
|
]);
|
|
6769
6842
|
const results = await Promise.all(allQueries);
|
|
6770
6843
|
return {
|
|
@@ -6803,6 +6876,7 @@ const getUserRetentionData = (options) => createAuthEndpoint("/dash/user-retenti
|
|
|
6803
6876
|
*/
|
|
6804
6877
|
const { period } = ctx.query;
|
|
6805
6878
|
const activityTrackingEnabled = !!options.activityTracking?.enabled;
|
|
6879
|
+
const secondaryOnly = isSessionInSecondaryStorageOnly(ctx.context);
|
|
6806
6880
|
const now = /* @__PURE__ */ new Date();
|
|
6807
6881
|
const startOfUtcDay = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
6808
6882
|
const startOfUtcMonth = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
|
@@ -6884,7 +6958,22 @@ const getUserRetentionData = (options) => createAuthEndpoint("/dash/user-retenti
|
|
|
6884
6958
|
ctx.context.logger.warn("[Dash] Failed to count retained users by lastActiveAt:", error);
|
|
6885
6959
|
return 0;
|
|
6886
6960
|
});
|
|
6887
|
-
else {
|
|
6961
|
+
else if (secondaryOnly) {
|
|
6962
|
+
const activeStartMs = activeStart.getTime();
|
|
6963
|
+
const activeEndMs = activeEnd.getTime();
|
|
6964
|
+
const retainedUsers = /* @__PURE__ */ new Set();
|
|
6965
|
+
await withConcurrency(cohortUserIds, async (userId) => {
|
|
6966
|
+
const sessions = await ctx.context.internalAdapter.listSessions(userId);
|
|
6967
|
+
for (const session of sessions) {
|
|
6968
|
+
const updatedAt = new Date(session.updatedAt).getTime();
|
|
6969
|
+
if (updatedAt >= activeStartMs && updatedAt < activeEndMs) {
|
|
6970
|
+
retainedUsers.add(userId);
|
|
6971
|
+
break;
|
|
6972
|
+
}
|
|
6973
|
+
}
|
|
6974
|
+
}, { concurrency: 50 });
|
|
6975
|
+
retained = retainedUsers.size;
|
|
6976
|
+
} else {
|
|
6888
6977
|
const sessionsInActiveWindow = await ctx.context.adapter.findMany({
|
|
6889
6978
|
model: "session",
|
|
6890
6979
|
select: ["userId"],
|
|
@@ -6946,20 +7035,7 @@ const banUser = (options) => createAuthEndpoint("/dash/ban-user", {
|
|
|
6946
7035
|
banExpires: banExpires ? new Date(banExpires) : null,
|
|
6947
7036
|
updatedAt: /* @__PURE__ */ new Date()
|
|
6948
7037
|
});
|
|
6949
|
-
|
|
6950
|
-
model: "session",
|
|
6951
|
-
where: [{
|
|
6952
|
-
field: "userId",
|
|
6953
|
-
value: userId
|
|
6954
|
-
}]
|
|
6955
|
-
});
|
|
6956
|
-
for (const session of sessions) await ctx.context.adapter.delete({
|
|
6957
|
-
model: "session",
|
|
6958
|
-
where: [{
|
|
6959
|
-
field: "id",
|
|
6960
|
-
value: session.id
|
|
6961
|
-
}]
|
|
6962
|
-
});
|
|
7038
|
+
await ctx.context.internalAdapter.deleteSessions(userId);
|
|
6963
7039
|
return { success: true };
|
|
6964
7040
|
});
|
|
6965
7041
|
const banManyUsers = (options) => {
|
|
@@ -7293,6 +7369,8 @@ const dash = (options) => {
|
|
|
7293
7369
|
const { trackOrganizationMemberInvited, trackOrganizationMemberInviteAccepted, trackOrganizationMemberInviteCanceled, trackOrganizationMemberInviteRejected } = initInvitationEvents(tracker);
|
|
7294
7370
|
return {
|
|
7295
7371
|
id: "dash",
|
|
7372
|
+
options: opts,
|
|
7373
|
+
version: PLUGIN_VERSION,
|
|
7296
7374
|
init(ctx) {
|
|
7297
7375
|
const organizationPlugin = ctx.getPlugin("organization");
|
|
7298
7376
|
if (organizationPlugin) {
|
|
@@ -7645,7 +7723,6 @@ const dash = (options) => {
|
|
|
7645
7723
|
updateDashUser: updateUser(opts),
|
|
7646
7724
|
setDashPassword: setPassword(opts),
|
|
7647
7725
|
unlinkDashAccount: unlinkAccount(opts),
|
|
7648
|
-
listAllDashSessions: listAllSessions(opts),
|
|
7649
7726
|
dashRevokeSession: revokeSession(opts),
|
|
7650
7727
|
dashRevokeAllSessions: revokeAllSessions(opts),
|
|
7651
7728
|
dashRevokeManySessions: revokeManySessions(opts),
|
|
@@ -7689,7 +7766,10 @@ const dash = (options) => {
|
|
|
7689
7766
|
regenerateDashDirectoryToken: regenerateDirectoryToken(opts),
|
|
7690
7767
|
dashExecuteAdapter: executeAdapter(opts)
|
|
7691
7768
|
},
|
|
7692
|
-
schema: opts.activityTracking?.enabled ? { user: { fields: { lastActiveAt: {
|
|
7769
|
+
schema: opts.activityTracking?.enabled ? { user: { fields: { lastActiveAt: {
|
|
7770
|
+
type: "date",
|
|
7771
|
+
required: false
|
|
7772
|
+
} } } } : {}
|
|
7693
7773
|
};
|
|
7694
7774
|
};
|
|
7695
7775
|
//#endregion
|