@better-auth/infra 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +8 -2
- package/dist/index.mjs +530 -514
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -101,6 +101,9 @@ interface SecurityOptions {
|
|
|
101
101
|
action?: SecurityAction;
|
|
102
102
|
domainAllowlist?: string[];
|
|
103
103
|
};
|
|
104
|
+
emailNormalization?: {
|
|
105
|
+
enabled?: boolean;
|
|
106
|
+
};
|
|
104
107
|
staleUsers?: {
|
|
105
108
|
enabled: boolean;
|
|
106
109
|
staleDays?: number;
|
|
@@ -181,6 +184,9 @@ declare const sentinel: (options?: SentinelOptions) => {
|
|
|
181
184
|
action?: SecurityAction;
|
|
182
185
|
domainAllowlist?: string[];
|
|
183
186
|
} | undefined;
|
|
187
|
+
emailNormalization: {
|
|
188
|
+
enabled?: boolean;
|
|
189
|
+
} | undefined;
|
|
184
190
|
databaseHooks: {
|
|
185
191
|
user: {
|
|
186
192
|
create: {
|
|
@@ -723,7 +729,7 @@ interface DashIdRow {
|
|
|
723
729
|
id: string;
|
|
724
730
|
}
|
|
725
731
|
//#endregion
|
|
726
|
-
//#region ../../node_modules/.bun/@better-auth+scim@1.6.1+
|
|
732
|
+
//#region ../../node_modules/.bun/@better-auth+scim@1.6.1+f10cf570321458be/node_modules/@better-auth/scim/dist/index.d.mts
|
|
727
733
|
//#region src/types.d.ts
|
|
728
734
|
interface SCIMProvider {
|
|
729
735
|
id: string;
|
|
@@ -4586,7 +4592,7 @@ interface DashCheckUserExistsResponse {
|
|
|
4586
4592
|
* - Normalize googlemail.com to gmail.com
|
|
4587
4593
|
*
|
|
4588
4594
|
* @param email - Raw email to normalize
|
|
4589
|
-
* @param context - Auth context
|
|
4595
|
+
* @param context - Auth context
|
|
4590
4596
|
*/
|
|
4591
4597
|
declare function normalizeEmail(email: string, context: AuthContext): string;
|
|
4592
4598
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -958,8 +958,8 @@ function createIdentificationMiddleware(options) {
|
|
|
958
958
|
return;
|
|
959
959
|
}
|
|
960
960
|
const ipAddress = getClientIpFromRequest(ctx.request, ipConfig?.ipAddressHeaders || null);
|
|
961
|
-
|
|
962
|
-
|
|
961
|
+
if (ipAddress) {
|
|
962
|
+
const countryCode = getCountryCodeFromRequest(ctx.request);
|
|
963
963
|
ctx.context.location = {
|
|
964
964
|
ipAddress,
|
|
965
965
|
countryCode
|
|
@@ -997,502 +997,6 @@ function getCountryCodeFromRequest(request) {
|
|
|
997
997
|
return cc ? cc.toUpperCase() : void 0;
|
|
998
998
|
}
|
|
999
999
|
//#endregion
|
|
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
|
-
]);
|
|
1065
|
-
/**
|
|
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
|
|
1071
|
-
*
|
|
1072
|
-
* @param email - Raw email to normalize
|
|
1073
|
-
* @param context - Auth context with getPlugin (for sentinel policy)
|
|
1074
|
-
*/
|
|
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}`;
|
|
1090
|
-
}
|
|
1091
|
-
function createEmailValidator(options = {}) {
|
|
1092
|
-
const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig = {} } = options;
|
|
1093
|
-
const $api = createFetch({
|
|
1094
|
-
baseURL: apiUrl,
|
|
1095
|
-
headers: { "x-api-key": apiKey }
|
|
1096
|
-
});
|
|
1097
|
-
const $kv = createFetch({
|
|
1098
|
-
baseURL: kvUrl,
|
|
1099
|
-
headers: { "x-api-key": apiKey },
|
|
1100
|
-
timeout: KV_TIMEOUT_MS
|
|
1101
|
-
});
|
|
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;
|
|
1125
|
-
}
|
|
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 || {
|
|
1146
|
-
valid: false,
|
|
1147
|
-
reason: "invalid_format"
|
|
1148
|
-
},
|
|
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
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
} };
|
|
1159
|
-
}
|
|
1160
|
-
/**
|
|
1161
|
-
* Basic local email format validation (fallback)
|
|
1162
|
-
*/
|
|
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
|
-
};
|
|
1185
|
-
};
|
|
1186
|
-
/**
|
|
1187
|
-
* Create email normalization hook (shared between all configurations)
|
|
1188
|
-
*/
|
|
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
|
-
})
|
|
1209
|
-
};
|
|
1210
|
-
}
|
|
1211
|
-
/**
|
|
1212
|
-
* Create email validation hook with configurable validation strategy
|
|
1213
|
-
*/
|
|
1214
|
-
function createEmailValidationHook(validator, onDisposableEmail) {
|
|
1215
|
-
return {
|
|
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
|
|
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)] };
|
|
1290
|
-
}
|
|
1291
|
-
createEmailHooks();
|
|
1292
|
-
//#endregion
|
|
1293
|
-
//#region src/validation/phone.ts
|
|
1294
|
-
/**
|
|
1295
|
-
* Common fake/test phone numbers that should be blocked
|
|
1296
|
-
* These are numbers commonly used in testing, movies, documentation, etc.
|
|
1297
|
-
*/
|
|
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
|
-
]);
|
|
1357
|
-
/**
|
|
1358
|
-
* Patterns that indicate fake/test phone numbers
|
|
1359
|
-
*/
|
|
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/
|
|
1381
|
-
];
|
|
1382
|
-
/**
|
|
1383
|
-
* Invalid area codes / prefixes that indicate test numbers
|
|
1384
|
-
* Key: country code, Value: set of invalid prefixes
|
|
1385
|
-
*/
|
|
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
|
-
};
|
|
1411
|
-
/**
|
|
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
|
|
1416
|
-
*/
|
|
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
|
-
};
|
|
1440
|
-
/**
|
|
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
|
|
1445
|
-
*/
|
|
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"
|
|
1481
|
-
]);
|
|
1482
|
-
const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
|
|
1483
|
-
/**
|
|
1484
|
-
* Better Auth plugin for phone number validation
|
|
1485
|
-
* Validates phone numbers on all phone-related endpoints
|
|
1486
|
-
*/
|
|
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
1000
|
//#region src/sentinel/security.ts
|
|
1497
1001
|
async function hashForFingerprint(input) {
|
|
1498
1002
|
const data = new TextEncoder().encode(input);
|
|
@@ -1504,6 +1008,16 @@ async function sha1Hash(input) {
|
|
|
1504
1008
|
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
|
1505
1009
|
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
1506
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Whether Sentinel should normalize emails for deduplication/sign-in consistency.
|
|
1013
|
+
* If `emailNormalization` is set, it wins; otherwise legacy behavior uses `emailValidation.enabled`.
|
|
1014
|
+
* @internal Not part of the package public API; used by the sentinel plugin and email helpers.
|
|
1015
|
+
*/
|
|
1016
|
+
function isEmailNormalizationEnabled(security) {
|
|
1017
|
+
const explicit = security?.emailNormalization;
|
|
1018
|
+
if (explicit !== void 0) return explicit.enabled !== false;
|
|
1019
|
+
return security?.emailValidation?.enabled !== false;
|
|
1020
|
+
}
|
|
1507
1021
|
function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
1508
1022
|
const resolvedApiUrl = apiUrl || INFRA_API_URL;
|
|
1509
1023
|
const $api = createFetch({
|
|
@@ -1883,6 +1397,501 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1883
1397
|
};
|
|
1884
1398
|
}
|
|
1885
1399
|
//#endregion
|
|
1400
|
+
//#region src/validation/matchers.ts
|
|
1401
|
+
const paths = [
|
|
1402
|
+
"/sign-up/email",
|
|
1403
|
+
"/email-otp/verify-email",
|
|
1404
|
+
"/sign-in/email-otp",
|
|
1405
|
+
"/sign-in/magic-link",
|
|
1406
|
+
"/sign-in/email",
|
|
1407
|
+
"/forget-password/email-otp",
|
|
1408
|
+
"/email-otp/reset-password",
|
|
1409
|
+
"/email-otp/create-verification-otp",
|
|
1410
|
+
"/email-otp/get-verification-otp",
|
|
1411
|
+
"/email-otp/send-verification-otp",
|
|
1412
|
+
"/forget-password",
|
|
1413
|
+
"/request-password-reset",
|
|
1414
|
+
"/send-verification-email",
|
|
1415
|
+
"/change-email"
|
|
1416
|
+
];
|
|
1417
|
+
const all = new Set(paths);
|
|
1418
|
+
new Set(paths.slice(1, 12));
|
|
1419
|
+
/**
|
|
1420
|
+
* Path is one of `[
|
|
1421
|
+
* '/sign-up/email',
|
|
1422
|
+
* '/email-otp/verify-email',
|
|
1423
|
+
* '/sign-in/email-otp',
|
|
1424
|
+
* '/sign-in/magic-link',
|
|
1425
|
+
* '/sign-in/email',
|
|
1426
|
+
* '/forget-password/email-otp',
|
|
1427
|
+
* '/email-otp/reset-password',
|
|
1428
|
+
* '/email-otp/create-verification-otp',
|
|
1429
|
+
* '/email-otp/get-verification-otp',
|
|
1430
|
+
* '/email-otp/send-verification-otp',
|
|
1431
|
+
* '/forget-password',
|
|
1432
|
+
* '/request-password-reset',
|
|
1433
|
+
* '/send-verification-email',
|
|
1434
|
+
* '/change-email'
|
|
1435
|
+
* ]`.
|
|
1436
|
+
* @param context Request context
|
|
1437
|
+
* @param context.path Request path
|
|
1438
|
+
* @returns boolean
|
|
1439
|
+
*/
|
|
1440
|
+
const allEmail = ({ path }) => !!path && all.has(path);
|
|
1441
|
+
//#endregion
|
|
1442
|
+
//#region src/validation/email.ts
|
|
1443
|
+
/**
|
|
1444
|
+
* Gmail-like providers that ignore dots in the local part
|
|
1445
|
+
*/
|
|
1446
|
+
const GMAIL_LIKE_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
|
|
1447
|
+
/**
|
|
1448
|
+
* Providers known to support plus addressing
|
|
1449
|
+
*/
|
|
1450
|
+
const PLUS_ADDRESSING_DOMAINS = new Set([
|
|
1451
|
+
"gmail.com",
|
|
1452
|
+
"googlemail.com",
|
|
1453
|
+
"outlook.com",
|
|
1454
|
+
"hotmail.com",
|
|
1455
|
+
"live.com",
|
|
1456
|
+
"yahoo.com",
|
|
1457
|
+
"icloud.com",
|
|
1458
|
+
"me.com",
|
|
1459
|
+
"mac.com",
|
|
1460
|
+
"protonmail.com",
|
|
1461
|
+
"proton.me",
|
|
1462
|
+
"fastmail.com",
|
|
1463
|
+
"zoho.com"
|
|
1464
|
+
]);
|
|
1465
|
+
/**
|
|
1466
|
+
* Normalize an email address for comparison/deduplication
|
|
1467
|
+
* - Lowercase the entire email
|
|
1468
|
+
* - Remove dots from Gmail-like providers (they ignore dots)
|
|
1469
|
+
* - Remove plus addressing (user+tag@domain → user@domain)
|
|
1470
|
+
* - Normalize googlemail.com to gmail.com
|
|
1471
|
+
*
|
|
1472
|
+
* @param email - Raw email to normalize
|
|
1473
|
+
* @param context - Auth context
|
|
1474
|
+
*/
|
|
1475
|
+
function normalizeEmail(email, context) {
|
|
1476
|
+
if (!email || typeof email !== "string") return email;
|
|
1477
|
+
const mergedOpts = context.options;
|
|
1478
|
+
if (!isEmailNormalizationEnabled(mergedOpts)) return email;
|
|
1479
|
+
const trimmed = email.trim().toLowerCase();
|
|
1480
|
+
const atIndex = trimmed.lastIndexOf("@");
|
|
1481
|
+
if (atIndex === -1) return trimmed;
|
|
1482
|
+
let localPart = trimmed.slice(0, atIndex);
|
|
1483
|
+
let domain = trimmed.slice(atIndex + 1);
|
|
1484
|
+
if (domain === "googlemail.com") domain = "gmail.com";
|
|
1485
|
+
if (PLUS_ADDRESSING_DOMAINS.has(domain)) {
|
|
1486
|
+
const plusIndex = localPart.indexOf("+");
|
|
1487
|
+
if (plusIndex !== -1) localPart = localPart.slice(0, plusIndex);
|
|
1488
|
+
}
|
|
1489
|
+
if (GMAIL_LIKE_DOMAINS.has(domain)) localPart = localPart.replace(/\./g, "");
|
|
1490
|
+
return `${localPart}@${domain}`;
|
|
1491
|
+
}
|
|
1492
|
+
function createEmailValidator(options = {}) {
|
|
1493
|
+
const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, emailValidationOptions = {} } = options;
|
|
1494
|
+
const $api = createFetch({
|
|
1495
|
+
baseURL: apiUrl,
|
|
1496
|
+
headers: { "x-api-key": apiKey }
|
|
1497
|
+
});
|
|
1498
|
+
const $kv = createFetch({
|
|
1499
|
+
baseURL: kvUrl,
|
|
1500
|
+
headers: { "x-api-key": apiKey },
|
|
1501
|
+
timeout: KV_TIMEOUT_MS
|
|
1502
|
+
});
|
|
1503
|
+
/**
|
|
1504
|
+
* Fetch and resolve email validity policy from API with caching
|
|
1505
|
+
* Sends client config to API which merges with user's dashboard settings
|
|
1506
|
+
*/
|
|
1507
|
+
async function fetchPolicy() {
|
|
1508
|
+
try {
|
|
1509
|
+
const { data } = await $api("/security/resolve-policy", {
|
|
1510
|
+
method: "POST",
|
|
1511
|
+
body: {
|
|
1512
|
+
policyId: "email_validity",
|
|
1513
|
+
config: { emailValidation: {
|
|
1514
|
+
enabled: emailValidationOptions.enabled,
|
|
1515
|
+
strictness: emailValidationOptions.strictness,
|
|
1516
|
+
action: emailValidationOptions.action,
|
|
1517
|
+
domainAllowlist: emailValidationOptions.domainAllowlist
|
|
1518
|
+
} }
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
if (data?.policy) return data.policy;
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
logger.warn("[Dash] Failed to fetch email policy, using defaults:", error);
|
|
1524
|
+
}
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
return { async validate(email, checkMx = true) {
|
|
1528
|
+
const trimmed = email.trim();
|
|
1529
|
+
const policy = await fetchPolicy();
|
|
1530
|
+
if (!policy?.enabled) return {
|
|
1531
|
+
valid: true,
|
|
1532
|
+
disposable: false,
|
|
1533
|
+
confidence: "high",
|
|
1534
|
+
policy
|
|
1535
|
+
};
|
|
1536
|
+
try {
|
|
1537
|
+
const { data } = await $kv("/email/validate", {
|
|
1538
|
+
method: "POST",
|
|
1539
|
+
body: {
|
|
1540
|
+
email: trimmed,
|
|
1541
|
+
checkMx,
|
|
1542
|
+
strictness: policy.strictness
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
return {
|
|
1546
|
+
...data || {
|
|
1547
|
+
valid: false,
|
|
1548
|
+
reason: "invalid_format"
|
|
1549
|
+
},
|
|
1550
|
+
policy
|
|
1551
|
+
};
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
logger.warn("[Dash] Email validation API error, falling back to allow:", error);
|
|
1554
|
+
return {
|
|
1555
|
+
valid: true,
|
|
1556
|
+
policy
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
} };
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Basic local email format validation (fallback)
|
|
1563
|
+
*/
|
|
1564
|
+
function isValidEmailFormatLocal(email) {
|
|
1565
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false;
|
|
1566
|
+
if (email.length > 254) return false;
|
|
1567
|
+
const [localPart, domain] = email.split("@");
|
|
1568
|
+
if (!localPart || !domain) return false;
|
|
1569
|
+
if (localPart.length > 64) return false;
|
|
1570
|
+
if (domain.length > 253) return false;
|
|
1571
|
+
return true;
|
|
1572
|
+
}
|
|
1573
|
+
const getEmail = (ctx) => {
|
|
1574
|
+
if (ctx.path === "/change-email") return {
|
|
1575
|
+
email: ctx.body?.newEmail,
|
|
1576
|
+
container: "body",
|
|
1577
|
+
field: "newEmail"
|
|
1578
|
+
};
|
|
1579
|
+
const body = ctx.body;
|
|
1580
|
+
const query = ctx.query;
|
|
1581
|
+
return {
|
|
1582
|
+
email: body?.email ?? query?.email,
|
|
1583
|
+
container: body ? "body" : "query",
|
|
1584
|
+
field: "email"
|
|
1585
|
+
};
|
|
1586
|
+
};
|
|
1587
|
+
/**
|
|
1588
|
+
* Create email normalization hook (shared between all configurations)
|
|
1589
|
+
*/
|
|
1590
|
+
function createEmailNormalizationHook() {
|
|
1591
|
+
return {
|
|
1592
|
+
matcher: allEmail,
|
|
1593
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1594
|
+
const { email, container, field } = getEmail(ctx);
|
|
1595
|
+
if (typeof email !== "string") return;
|
|
1596
|
+
const normalized = normalizeEmail(email, ctx.context);
|
|
1597
|
+
if (normalized === email) return;
|
|
1598
|
+
const data = container === "query" ? {
|
|
1599
|
+
...ctx.query,
|
|
1600
|
+
[field]: normalized
|
|
1601
|
+
} : {
|
|
1602
|
+
...ctx.body,
|
|
1603
|
+
[field]: normalized
|
|
1604
|
+
};
|
|
1605
|
+
return { context: {
|
|
1606
|
+
...ctx,
|
|
1607
|
+
[container]: data
|
|
1608
|
+
} };
|
|
1609
|
+
})
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Create email validation hook with configurable validation strategy
|
|
1614
|
+
*/
|
|
1615
|
+
function createEmailValidationHook(validator, onDisposableEmail) {
|
|
1616
|
+
return {
|
|
1617
|
+
matcher: allEmail,
|
|
1618
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1619
|
+
const { email } = getEmail(ctx);
|
|
1620
|
+
if (typeof email !== "string") return;
|
|
1621
|
+
const trimmed = email.trim();
|
|
1622
|
+
if (!isValidEmailFormatLocal(trimmed)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
|
|
1623
|
+
if (validator) {
|
|
1624
|
+
const result = await validator.validate(trimmed);
|
|
1625
|
+
const policy = result.policy;
|
|
1626
|
+
if (!policy?.enabled) return;
|
|
1627
|
+
if (policy.domainAllowlist?.length) {
|
|
1628
|
+
const domain = trimmed.toLowerCase().split("@")[1];
|
|
1629
|
+
if (domain && policy.domainAllowlist.includes(domain)) return;
|
|
1630
|
+
}
|
|
1631
|
+
const action = policy.action;
|
|
1632
|
+
if (!result.valid) {
|
|
1633
|
+
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) {
|
|
1634
|
+
const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
|
|
1635
|
+
onDisposableEmail({
|
|
1636
|
+
email: trimmed,
|
|
1637
|
+
reason: result.reason || "disposable",
|
|
1638
|
+
confidence: result.confidence,
|
|
1639
|
+
ip,
|
|
1640
|
+
path: ctx.path,
|
|
1641
|
+
action
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
if (action === "allow") return;
|
|
1645
|
+
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" });
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
})
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Create email validation hooks with optional API-backed validation.
|
|
1653
|
+
*
|
|
1654
|
+
* @param options - {@link EmailHooksOptions}
|
|
1655
|
+
*
|
|
1656
|
+
* @example
|
|
1657
|
+
* // Local validation only
|
|
1658
|
+
* createEmailHooks()
|
|
1659
|
+
*
|
|
1660
|
+
* @example
|
|
1661
|
+
* // API-backed validation
|
|
1662
|
+
* createEmailHooks({ useApi: true, apiKey: "your-api-key" })
|
|
1663
|
+
*
|
|
1664
|
+
* @example
|
|
1665
|
+
* // High strictness + API
|
|
1666
|
+
* createEmailHooks({
|
|
1667
|
+
* emailValidationOptions: { strictness: "high" },
|
|
1668
|
+
* useApi: true,
|
|
1669
|
+
* apiKey: "your-api-key",
|
|
1670
|
+
* })
|
|
1671
|
+
*/
|
|
1672
|
+
function createEmailHooks(options = {}) {
|
|
1673
|
+
const { emailValidationOptions, emailNormalizationOptions, useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, onDisposableEmail } = options;
|
|
1674
|
+
const emailConfig = {
|
|
1675
|
+
enabled: true,
|
|
1676
|
+
strictness: "medium",
|
|
1677
|
+
action: "block",
|
|
1678
|
+
...emailValidationOptions
|
|
1679
|
+
};
|
|
1680
|
+
return { before: [...isEmailNormalizationEnabled({
|
|
1681
|
+
emailValidation: emailValidationOptions,
|
|
1682
|
+
emailNormalization: emailNormalizationOptions
|
|
1683
|
+
}) ? [createEmailNormalizationHook()] : [], ...emailConfig.enabled ? [createEmailValidationHook(useApi ? createEmailValidator({
|
|
1684
|
+
apiUrl,
|
|
1685
|
+
kvUrl,
|
|
1686
|
+
apiKey,
|
|
1687
|
+
emailValidationOptions: emailConfig
|
|
1688
|
+
}) : void 0, onDisposableEmail)] : []] };
|
|
1689
|
+
}
|
|
1690
|
+
createEmailHooks();
|
|
1691
|
+
//#endregion
|
|
1692
|
+
//#region src/validation/phone.ts
|
|
1693
|
+
/**
|
|
1694
|
+
* Common fake/test phone numbers that should be blocked
|
|
1695
|
+
* These are numbers commonly used in testing, movies, documentation, etc.
|
|
1696
|
+
*/
|
|
1697
|
+
const INVALID_PHONE_NUMBERS = new Set([
|
|
1698
|
+
"+15550000000",
|
|
1699
|
+
"+15550001111",
|
|
1700
|
+
"+15550001234",
|
|
1701
|
+
"+15551234567",
|
|
1702
|
+
"+15555555555",
|
|
1703
|
+
"+15551111111",
|
|
1704
|
+
"+15550000001",
|
|
1705
|
+
"+15550123456",
|
|
1706
|
+
"+12125551234",
|
|
1707
|
+
"+13105551234",
|
|
1708
|
+
"+14155551234",
|
|
1709
|
+
"+12025551234",
|
|
1710
|
+
"+10000000000",
|
|
1711
|
+
"+11111111111",
|
|
1712
|
+
"+12222222222",
|
|
1713
|
+
"+13333333333",
|
|
1714
|
+
"+14444444444",
|
|
1715
|
+
"+15555555555",
|
|
1716
|
+
"+16666666666",
|
|
1717
|
+
"+17777777777",
|
|
1718
|
+
"+18888888888",
|
|
1719
|
+
"+19999999999",
|
|
1720
|
+
"+11234567890",
|
|
1721
|
+
"+10123456789",
|
|
1722
|
+
"+19876543210",
|
|
1723
|
+
"+441632960000",
|
|
1724
|
+
"+447700900000",
|
|
1725
|
+
"+447700900001",
|
|
1726
|
+
"+447700900123",
|
|
1727
|
+
"+447700900999",
|
|
1728
|
+
"+442079460000",
|
|
1729
|
+
"+442079460123",
|
|
1730
|
+
"+441134960000",
|
|
1731
|
+
"+0000000000",
|
|
1732
|
+
"+1000000000",
|
|
1733
|
+
"+123456789",
|
|
1734
|
+
"+1234567890",
|
|
1735
|
+
"+12345678901",
|
|
1736
|
+
"+0123456789",
|
|
1737
|
+
"+9876543210",
|
|
1738
|
+
"+11111111111",
|
|
1739
|
+
"+99999999999",
|
|
1740
|
+
"+491234567890",
|
|
1741
|
+
"+491111111111",
|
|
1742
|
+
"+33123456789",
|
|
1743
|
+
"+33111111111",
|
|
1744
|
+
"+61123456789",
|
|
1745
|
+
"+61111111111",
|
|
1746
|
+
"+81123456789",
|
|
1747
|
+
"+81111111111",
|
|
1748
|
+
"+19001234567",
|
|
1749
|
+
"+19761234567",
|
|
1750
|
+
"+1911",
|
|
1751
|
+
"+1411",
|
|
1752
|
+
"+1611",
|
|
1753
|
+
"+44999",
|
|
1754
|
+
"+44112"
|
|
1755
|
+
]);
|
|
1756
|
+
/**
|
|
1757
|
+
* Patterns that indicate fake/test phone numbers
|
|
1758
|
+
*/
|
|
1759
|
+
const INVALID_PHONE_PATTERNS = [
|
|
1760
|
+
/^\+\d(\d)\1{6,}$/,
|
|
1761
|
+
/^\+\d*1234567890/,
|
|
1762
|
+
/^\+\d*0123456789/,
|
|
1763
|
+
/^\+\d*9876543210/,
|
|
1764
|
+
/^\+\d*0987654321/,
|
|
1765
|
+
/^\+\d*(12){4,}/,
|
|
1766
|
+
/^\+\d*(21){4,}/,
|
|
1767
|
+
/^\+\d*(00){4,}/,
|
|
1768
|
+
/^\+1\d{3}555\d{4}$/,
|
|
1769
|
+
/^\+\d{1,3}\d{1,5}$/,
|
|
1770
|
+
/^\+\d+0{7,}$/,
|
|
1771
|
+
/^\+\d*147258369/,
|
|
1772
|
+
/^\+\d*258369147/,
|
|
1773
|
+
/^\+\d*369258147/,
|
|
1774
|
+
/^\+\d*789456123/,
|
|
1775
|
+
/^\+\d*123456789/,
|
|
1776
|
+
/^\+\d*1234512345/,
|
|
1777
|
+
/^\+\d*1111122222/,
|
|
1778
|
+
/^\+\d*1212121212/,
|
|
1779
|
+
/^\+\d*1010101010/
|
|
1780
|
+
];
|
|
1781
|
+
/**
|
|
1782
|
+
* Invalid area codes / prefixes that indicate test numbers
|
|
1783
|
+
* Key: country code, Value: set of invalid prefixes
|
|
1784
|
+
*/
|
|
1785
|
+
const INVALID_PREFIXES_BY_COUNTRY = {
|
|
1786
|
+
US: new Set([
|
|
1787
|
+
"555",
|
|
1788
|
+
"000",
|
|
1789
|
+
"111",
|
|
1790
|
+
"911",
|
|
1791
|
+
"411",
|
|
1792
|
+
"611"
|
|
1793
|
+
]),
|
|
1794
|
+
CA: new Set([
|
|
1795
|
+
"555",
|
|
1796
|
+
"000",
|
|
1797
|
+
"911"
|
|
1798
|
+
]),
|
|
1799
|
+
GB: new Set([
|
|
1800
|
+
"7700900",
|
|
1801
|
+
"1632960",
|
|
1802
|
+
"1134960"
|
|
1803
|
+
]),
|
|
1804
|
+
AU: new Set([
|
|
1805
|
+
"0491570",
|
|
1806
|
+
"0491571",
|
|
1807
|
+
"0491572"
|
|
1808
|
+
])
|
|
1809
|
+
};
|
|
1810
|
+
/**
|
|
1811
|
+
* Check if a phone number is a commonly used fake/test number
|
|
1812
|
+
* @param phone - The phone number to check (E.164 format preferred)
|
|
1813
|
+
* @param defaultCountry - Default country code if not included in phone string
|
|
1814
|
+
* @returns true if the phone appears to be fake/test, false if it seems legitimate
|
|
1815
|
+
*/
|
|
1816
|
+
const isFakePhoneNumber = (phone, defaultCountry) => {
|
|
1817
|
+
const parsed = parsePhoneNumberFromString(phone, defaultCountry);
|
|
1818
|
+
if (!parsed) return true;
|
|
1819
|
+
const e164 = parsed.number;
|
|
1820
|
+
const nationalNumber = parsed.nationalNumber;
|
|
1821
|
+
const country = parsed.country;
|
|
1822
|
+
if (INVALID_PHONE_NUMBERS.has(e164)) return true;
|
|
1823
|
+
for (const pattern of INVALID_PHONE_PATTERNS) if (pattern.test(e164)) return true;
|
|
1824
|
+
if (country && INVALID_PREFIXES_BY_COUNTRY[country]) {
|
|
1825
|
+
const prefixes = INVALID_PREFIXES_BY_COUNTRY[country];
|
|
1826
|
+
for (const prefix of prefixes) if (nationalNumber.startsWith(prefix)) return true;
|
|
1827
|
+
}
|
|
1828
|
+
if (/^(\d)\1+$/.test(nationalNumber)) return true;
|
|
1829
|
+
const digits = nationalNumber.split("").map(Number);
|
|
1830
|
+
let isSequential = digits.length >= 6;
|
|
1831
|
+
for (let i = 1; i < digits.length && isSequential; i++) {
|
|
1832
|
+
const current = digits[i];
|
|
1833
|
+
const previous = digits[i - 1];
|
|
1834
|
+
if (current === void 0 || previous === void 0 || current !== previous + 1 && current !== previous - 1) isSequential = false;
|
|
1835
|
+
}
|
|
1836
|
+
if (isSequential) return true;
|
|
1837
|
+
return false;
|
|
1838
|
+
};
|
|
1839
|
+
/**
|
|
1840
|
+
* Validate a phone number format
|
|
1841
|
+
* @param phone - The phone number to validate
|
|
1842
|
+
* @param defaultCountry - Default country code if not included in phone string
|
|
1843
|
+
* @returns true if the phone number is valid
|
|
1844
|
+
*/
|
|
1845
|
+
const isValidPhone = (phone, defaultCountry) => {
|
|
1846
|
+
return isValidPhoneNumber(phone, defaultCountry);
|
|
1847
|
+
};
|
|
1848
|
+
/**
|
|
1849
|
+
* Comprehensive phone number validation
|
|
1850
|
+
* @param phone - The phone number to validate
|
|
1851
|
+
* @param options - Validation options
|
|
1852
|
+
* @returns true if valid, false otherwise
|
|
1853
|
+
*/
|
|
1854
|
+
const validatePhone = (phone, options = {}) => {
|
|
1855
|
+
const { mobileOnly = false, allowedCountries, blockedCountries, blockFakeNumbers = true, blockPremiumRate = true, blockTollFree = false, blockVoip = false, defaultCountry } = options;
|
|
1856
|
+
if (!isValidPhone(phone, defaultCountry)) return false;
|
|
1857
|
+
const parsed = parsePhoneNumberFromString(phone, defaultCountry);
|
|
1858
|
+
if (!parsed) return false;
|
|
1859
|
+
if (blockFakeNumbers && isFakePhoneNumber(phone, defaultCountry)) return false;
|
|
1860
|
+
const country = parsed.country;
|
|
1861
|
+
if (country) {
|
|
1862
|
+
if (allowedCountries && !allowedCountries.includes(country)) return false;
|
|
1863
|
+
if (blockedCountries?.includes(country)) return false;
|
|
1864
|
+
}
|
|
1865
|
+
const phoneType = parsed.getType();
|
|
1866
|
+
if (mobileOnly) {
|
|
1867
|
+
if (phoneType !== "MOBILE" && phoneType !== "FIXED_LINE_OR_MOBILE") return false;
|
|
1868
|
+
}
|
|
1869
|
+
if (blockPremiumRate && phoneType === "PREMIUM_RATE") return false;
|
|
1870
|
+
if (blockTollFree && phoneType === "TOLL_FREE") return false;
|
|
1871
|
+
if (blockVoip && phoneType === "VOIP") return false;
|
|
1872
|
+
return true;
|
|
1873
|
+
};
|
|
1874
|
+
const allPhonePaths = new Set([
|
|
1875
|
+
"/phone-number/send-otp",
|
|
1876
|
+
"/phone-number/verify",
|
|
1877
|
+
"/sign-in/phone-number",
|
|
1878
|
+
"/phone-number/request-password-reset",
|
|
1879
|
+
"/phone-number/reset-password"
|
|
1880
|
+
]);
|
|
1881
|
+
const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
|
|
1882
|
+
/**
|
|
1883
|
+
* Better Auth plugin for phone number validation
|
|
1884
|
+
* Validates phone numbers on all phone-related endpoints
|
|
1885
|
+
*/
|
|
1886
|
+
const phoneValidationHooks = { before: [{
|
|
1887
|
+
matcher: (context) => !!context.path && allPhonePaths.has(context.path),
|
|
1888
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1889
|
+
const phoneNumber = getPhoneNumber(ctx);
|
|
1890
|
+
if (typeof phoneNumber !== "string") return;
|
|
1891
|
+
if (!validatePhone(phoneNumber)) throw new APIError$1("BAD_REQUEST", { message: "Invalid phone number" });
|
|
1892
|
+
})
|
|
1893
|
+
}] };
|
|
1894
|
+
//#endregion
|
|
1886
1895
|
//#region src/sentinel/security-hooks.ts
|
|
1887
1896
|
const ERROR_MESSAGES = {
|
|
1888
1897
|
geo_blocked: "Access from your location is not allowed.",
|
|
@@ -2024,11 +2033,12 @@ const sentinel = (options) => {
|
|
|
2024
2033
|
});
|
|
2025
2034
|
});
|
|
2026
2035
|
const emailHooks = createEmailHooks({
|
|
2036
|
+
emailValidationOptions: opts.security?.emailValidation,
|
|
2037
|
+
emailNormalizationOptions: opts.security?.emailNormalization,
|
|
2027
2038
|
useApi: !!opts.apiKey,
|
|
2028
2039
|
apiKey: opts.apiKey,
|
|
2029
2040
|
apiUrl: opts.apiUrl,
|
|
2030
2041
|
kvUrl: opts.kvUrl,
|
|
2031
|
-
defaultConfig: opts.security?.emailValidation,
|
|
2032
2042
|
onDisposableEmail: (data) => {
|
|
2033
2043
|
const isNoMxRecord = data.reason === "no_mx_records";
|
|
2034
2044
|
const reason = isNoMxRecord ? "no_mx_records" : "disposable_email";
|
|
@@ -2061,6 +2071,7 @@ const sentinel = (options) => {
|
|
|
2061
2071
|
const activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
|
|
2062
2072
|
return { options: {
|
|
2063
2073
|
emailValidation: opts.security?.emailValidation,
|
|
2074
|
+
emailNormalization: opts.security?.emailNormalization,
|
|
2064
2075
|
databaseHooks: {
|
|
2065
2076
|
user: { create: {
|
|
2066
2077
|
async before(user, ctx) {
|
|
@@ -2070,7 +2081,7 @@ const sentinel = (options) => {
|
|
|
2070
2081
|
const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
|
|
2071
2082
|
if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
|
|
2072
2083
|
}
|
|
2073
|
-
if (user.email && typeof user.email === "string" && opts.security
|
|
2084
|
+
if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
|
|
2074
2085
|
...user,
|
|
2075
2086
|
email: normalizeEmail(user.email, ctx.context)
|
|
2076
2087
|
} };
|
|
@@ -2746,7 +2757,7 @@ const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
|
|
|
2746
2757
|
});
|
|
2747
2758
|
//#endregion
|
|
2748
2759
|
//#region src/version.ts
|
|
2749
|
-
const PLUGIN_VERSION = "0.2.
|
|
2760
|
+
const PLUGIN_VERSION = "0.2.3";
|
|
2750
2761
|
//#endregion
|
|
2751
2762
|
//#region src/routes/auth/config.ts
|
|
2752
2763
|
const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
|
|
@@ -2758,23 +2769,28 @@ function isPlainSerializable(value) {
|
|
|
2758
2769
|
if (constructor && constructor.name !== "Object" && constructor.name !== "Array") return false;
|
|
2759
2770
|
return true;
|
|
2760
2771
|
}
|
|
2761
|
-
function sanitizePluginOptions(pluginId, options,
|
|
2772
|
+
function sanitizePluginOptions(pluginId, options, visiting = /* @__PURE__ */ new WeakSet()) {
|
|
2762
2773
|
if (options === null || options === void 0) return options;
|
|
2763
2774
|
if (typeof options === "function") return void 0;
|
|
2764
2775
|
if (typeof options !== "object") return options;
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
+
const obj = options;
|
|
2777
|
+
if (visiting.has(obj)) return void 0;
|
|
2778
|
+
visiting.add(obj);
|
|
2779
|
+
try {
|
|
2780
|
+
const excludeKeys = PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId];
|
|
2781
|
+
if (Array.isArray(options)) return options.map((item) => sanitizePluginOptions(pluginId, item, visiting)).filter((item) => item !== void 0);
|
|
2782
|
+
const result = {};
|
|
2783
|
+
for (const [key, value] of Object.entries(options)) {
|
|
2784
|
+
if (excludeKeys?.has(key)) continue;
|
|
2785
|
+
if (typeof value === "function") continue;
|
|
2786
|
+
if (value !== null && typeof value === "object" && !isPlainSerializable(value)) continue;
|
|
2787
|
+
const sanitized = sanitizePluginOptions(pluginId, value, visiting);
|
|
2788
|
+
if (sanitized !== void 0) result[key] = sanitized;
|
|
2789
|
+
}
|
|
2790
|
+
return result;
|
|
2791
|
+
} finally {
|
|
2792
|
+
visiting.delete(obj);
|
|
2776
2793
|
}
|
|
2777
|
-
return result;
|
|
2778
2794
|
}
|
|
2779
2795
|
function estimateEntropy(str) {
|
|
2780
2796
|
const unique = new Set(str).size;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/infra",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Dashboard and analytics plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"expo-crypto": "^14.0.2",
|
|
71
71
|
"happy-dom": "^20.8.9",
|
|
72
72
|
"msw": "^2.13.0",
|
|
73
|
-
"tsdown": "^0.21.
|
|
73
|
+
"tsdown": "^0.21.8",
|
|
74
74
|
"typescript": "catalog:",
|
|
75
75
|
"zod": "catalog:"
|
|
76
76
|
},
|