@better-auth/infra 0.2.2 → 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 +584 -573
- 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
|
@@ -997,531 +997,45 @@ function getCountryCodeFromRequest(request) {
|
|
|
997
997
|
return cc ? cc.toUpperCase() : void 0;
|
|
998
998
|
}
|
|
999
999
|
//#endregion
|
|
1000
|
-
//#region src/
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
"
|
|
1004
|
-
"
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
"
|
|
1009
|
-
"
|
|
1010
|
-
|
|
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
|
-
]);
|
|
1000
|
+
//#region src/sentinel/security.ts
|
|
1001
|
+
async function hashForFingerprint(input) {
|
|
1002
|
+
const data = new TextEncoder().encode(input);
|
|
1003
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
1004
|
+
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1005
|
+
}
|
|
1006
|
+
async function sha1Hash(input) {
|
|
1007
|
+
const data = new TextEncoder().encode(input);
|
|
1008
|
+
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
|
1009
|
+
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
1010
|
+
}
|
|
1065
1011
|
/**
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
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)
|
|
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.
|
|
1074
1015
|
*/
|
|
1075
|
-
function
|
|
1076
|
-
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
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}`;
|
|
1016
|
+
function isEmailNormalizationEnabled(security) {
|
|
1017
|
+
const explicit = security?.emailNormalization;
|
|
1018
|
+
if (explicit !== void 0) return explicit.enabled !== false;
|
|
1019
|
+
return security?.emailValidation?.enabled !== false;
|
|
1090
1020
|
}
|
|
1091
|
-
function
|
|
1092
|
-
const
|
|
1021
|
+
function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
1022
|
+
const resolvedApiUrl = apiUrl || INFRA_API_URL;
|
|
1093
1023
|
const $api = createFetch({
|
|
1094
|
-
baseURL:
|
|
1095
|
-
headers: { "x-api-key": apiKey }
|
|
1096
|
-
});
|
|
1097
|
-
const $kv = createFetch({
|
|
1098
|
-
baseURL: kvUrl,
|
|
1024
|
+
baseURL: resolvedApiUrl,
|
|
1099
1025
|
headers: { "x-api-key": apiKey },
|
|
1100
|
-
|
|
1026
|
+
throw: true
|
|
1101
1027
|
});
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
|
1028
|
+
const emailSender = createEmailSender({
|
|
1029
|
+
apiUrl: resolvedApiUrl,
|
|
1030
|
+
apiKey
|
|
1031
|
+
});
|
|
1032
|
+
function logEvent(event) {
|
|
1033
|
+
const fullEvent = {
|
|
1034
|
+
...event,
|
|
1035
|
+
timestamp: Date.now()
|
|
1134
1036
|
};
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
//#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("");
|
|
1501
|
-
}
|
|
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;
|
|
1509
|
-
const $api = createFetch({
|
|
1510
|
-
baseURL: resolvedApiUrl,
|
|
1511
|
-
headers: { "x-api-key": apiKey },
|
|
1512
|
-
throw: true
|
|
1513
|
-
});
|
|
1514
|
-
const emailSender = createEmailSender({
|
|
1515
|
-
apiUrl: resolvedApiUrl,
|
|
1516
|
-
apiKey
|
|
1517
|
-
});
|
|
1518
|
-
function logEvent(event) {
|
|
1519
|
-
const fullEvent = {
|
|
1520
|
-
...event,
|
|
1521
|
-
timestamp: Date.now()
|
|
1522
|
-
};
|
|
1523
|
-
if (onSecurityEvent) onSecurityEvent(fullEvent);
|
|
1524
|
-
}
|
|
1037
|
+
if (onSecurityEvent) onSecurityEvent(fullEvent);
|
|
1038
|
+
}
|
|
1525
1039
|
return {
|
|
1526
1040
|
async checkSecurity(request) {
|
|
1527
1041
|
try {
|
|
@@ -1827,61 +1341,556 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1827
1341
|
loginIp: identification?.ip || "Unknown"
|
|
1828
1342
|
}
|
|
1829
1343
|
});
|
|
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
|
|
1344
|
+
if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
|
|
1345
|
+
else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
|
|
1346
|
+
},
|
|
1347
|
+
async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
|
|
1348
|
+
const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
|
|
1349
|
+
dateStyle: "long",
|
|
1350
|
+
timeStyle: "short",
|
|
1351
|
+
timeZone: "UTC"
|
|
1352
|
+
}) + " UTC";
|
|
1353
|
+
const location = identification?.location;
|
|
1354
|
+
const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
|
|
1355
|
+
const browser = identification?.browser;
|
|
1356
|
+
const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
|
|
1357
|
+
const result = await emailSender.send({
|
|
1358
|
+
template: "stale-account-admin",
|
|
1359
|
+
to: adminEmail,
|
|
1360
|
+
variables: {
|
|
1361
|
+
userEmail,
|
|
1362
|
+
userName: userName || "User",
|
|
1363
|
+
userId,
|
|
1364
|
+
appName: appName || "Your App",
|
|
1365
|
+
daysSinceLastActive: String(daysSinceLastActive),
|
|
1366
|
+
loginTime,
|
|
1367
|
+
loginLocation,
|
|
1368
|
+
loginDevice,
|
|
1369
|
+
loginIp: identification?.ip || "Unknown",
|
|
1370
|
+
adminEmail
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
if (result.success) logger.info(`[Dash] Stale account admin notification sent to: ${adminEmail}`);
|
|
1374
|
+
else logger.error(`[Dash] Failed to send stale account admin notification: ${result.error}`);
|
|
1375
|
+
},
|
|
1376
|
+
async checkUnknownDevice(_userId, _visitorId) {
|
|
1377
|
+
return false;
|
|
1378
|
+
},
|
|
1379
|
+
async notifyUnknownDevice(userId, email, identification) {
|
|
1380
|
+
logEvent({
|
|
1381
|
+
type: "unknown_device",
|
|
1382
|
+
userId,
|
|
1383
|
+
visitorId: identification?.visitorId || null,
|
|
1384
|
+
ip: identification?.ip || null,
|
|
1385
|
+
country: identification?.location?.country?.code || null,
|
|
1386
|
+
details: {
|
|
1387
|
+
email,
|
|
1388
|
+
device: identification?.browser.device,
|
|
1389
|
+
os: identification?.browser.os,
|
|
1390
|
+
browser: identification?.browser.name,
|
|
1391
|
+
city: identification?.location?.city,
|
|
1392
|
+
country: identification?.location?.country?.name
|
|
1393
|
+
},
|
|
1394
|
+
action: "logged"
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
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" });
|
|
1857
1646
|
}
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
|
1883
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)] : []] };
|
|
1884
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
|
+
}] };
|
|
1885
1894
|
//#endregion
|
|
1886
1895
|
//#region src/sentinel/security-hooks.ts
|
|
1887
1896
|
const ERROR_MESSAGES = {
|
|
@@ -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"]) };
|
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
|
},
|