@better-auth/infra 0.2.2 → 0.2.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  import { EMAIL_TEMPLATES, EmailConfig, EmailTemplateId, EmailTemplateVariables, SendBulkEmailsOptions, SendBulkEmailsResult, SendEmailOptions, SendEmailResult, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
2
2
  import * as _$better_auth0 from "better-auth";
3
- import { Account, AuthContext, BetterAuthPlugin, GenericEndpointContext, HookEndpointContext, Session, User } from "better-auth";
3
+ import { Account, AuthContext, BetterAuthPlugin, GenericEndpointContext, Session, User } from "better-auth";
4
4
  import * as _$jose from "jose";
5
5
  import * as zod from "zod";
6
6
  import z$1 from "zod";
@@ -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;
@@ -167,9 +170,6 @@ interface SecurityEvent {
167
170
  }
168
171
  type SecurityEventType = "unknown_device" | "credential_stuffing" | "impossible_travel" | "geo_blocked" | "bot_blocked" | "suspicious_ip_detected" | "velocity_exceeded" | "free_trial_abuse" | "compromised_password" | "stale_account_reactivation";
169
172
  //#endregion
170
- //#region src/validation/matchers.d.ts
171
- type Matcher = (context: HookEndpointContext) => boolean;
172
- //#endregion
173
173
  //#region src/sentinel/sentinel.d.ts
174
174
  declare const sentinel: (options?: SentinelOptions) => {
175
175
  id: "sentinel";
@@ -181,6 +181,9 @@ declare const sentinel: (options?: SentinelOptions) => {
181
181
  action?: SecurityAction;
182
182
  domainAllowlist?: string[];
183
183
  } | undefined;
184
+ emailNormalization: {
185
+ enabled?: boolean;
186
+ } | undefined;
184
187
  databaseHooks: {
185
188
  user: {
186
189
  create: {
@@ -243,7 +246,7 @@ declare const sentinel: (options?: SentinelOptions) => {
243
246
  };
244
247
  hooks: {
245
248
  before: ({
246
- matcher: Matcher;
249
+ matcher: (context: _$better_auth0.HookEndpointContext) => boolean;
247
250
  handler: (inputContext: _$better_call0.MiddlewareInputContext<_$better_call0.MiddlewareOptions>) => Promise<{
248
251
  context: {
249
252
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
@@ -415,7 +418,7 @@ declare const sentinel: (options?: SentinelOptions) => {
415
418
  };
416
419
  } | undefined>;
417
420
  } | {
418
- matcher: Matcher;
421
+ matcher: (context: _$better_auth0.HookEndpointContext) => boolean;
419
422
  handler: (inputContext: _$better_call0.MiddlewareInputContext<_$better_call0.MiddlewareOptions>) => Promise<void>;
420
423
  })[];
421
424
  after: {
@@ -723,7 +726,7 @@ interface DashIdRow {
723
726
  id: string;
724
727
  }
725
728
  //#endregion
726
- //#region ../../node_modules/.bun/@better-auth+scim@1.6.1+27eabf8bb9704597/node_modules/@better-auth/scim/dist/index.d.mts
729
+ //#region ../../node_modules/.bun/@better-auth+scim@1.6.4+f3cd00d7a973e2d8/node_modules/@better-auth/scim/dist/index.d.mts
727
730
  //#region src/types.d.ts
728
731
  interface SCIMProvider {
729
732
  id: string;
@@ -4586,7 +4589,7 @@ interface DashCheckUserExistsResponse {
4586
4589
  * - Normalize googlemail.com to gmail.com
4587
4590
  *
4588
4591
  * @param email - Raw email to normalize
4589
- * @param context - Auth context with getPlugin (for sentinel policy)
4592
+ * @param context - Auth context
4590
4593
  */
4591
4594
  declare function normalizeEmail(email: string, context: AuthContext): string;
4592
4595
  //#endregion
package/dist/index.mjs CHANGED
@@ -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,498 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1883
1397
  };
1884
1398
  }
1885
1399
  //#endregion
1400
+ //#region src/validation/matchers.ts
1401
+ const all = new Set([
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
+ /**
1418
+ * Path is one of `[
1419
+ * '/sign-up/email',
1420
+ * '/email-otp/verify-email',
1421
+ * '/sign-in/email-otp',
1422
+ * '/sign-in/magic-link',
1423
+ * '/sign-in/email',
1424
+ * '/forget-password/email-otp',
1425
+ * '/email-otp/reset-password',
1426
+ * '/email-otp/create-verification-otp',
1427
+ * '/email-otp/get-verification-otp',
1428
+ * '/email-otp/send-verification-otp',
1429
+ * '/forget-password',
1430
+ * '/request-password-reset',
1431
+ * '/send-verification-email',
1432
+ * '/change-email'
1433
+ * ]`.
1434
+ * @param context Request context
1435
+ * @param context.path Request path
1436
+ * @returns boolean
1437
+ */
1438
+ const allEmail = ({ path }) => !!path && all.has(path);
1439
+ //#endregion
1440
+ //#region src/validation/email.ts
1441
+ /**
1442
+ * Gmail-like providers that ignore dots in the local part
1443
+ */
1444
+ const GMAIL_LIKE_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
1445
+ /**
1446
+ * Providers known to support plus addressing
1447
+ */
1448
+ const PLUS_ADDRESSING_DOMAINS = new Set([
1449
+ "gmail.com",
1450
+ "googlemail.com",
1451
+ "outlook.com",
1452
+ "hotmail.com",
1453
+ "live.com",
1454
+ "yahoo.com",
1455
+ "icloud.com",
1456
+ "me.com",
1457
+ "mac.com",
1458
+ "protonmail.com",
1459
+ "proton.me",
1460
+ "fastmail.com",
1461
+ "zoho.com"
1462
+ ]);
1463
+ /**
1464
+ * Normalize an email address for comparison/deduplication
1465
+ * - Lowercase the entire email
1466
+ * - Remove dots from Gmail-like providers (they ignore dots)
1467
+ * - Remove plus addressing (user+tag@domain → user@domain)
1468
+ * - Normalize googlemail.com to gmail.com
1469
+ *
1470
+ * @param email - Raw email to normalize
1471
+ * @param context - Auth context
1472
+ */
1473
+ function normalizeEmail(email, context) {
1474
+ if (!email || typeof email !== "string") return email;
1475
+ const mergedOpts = context.options;
1476
+ if (!isEmailNormalizationEnabled(mergedOpts)) return email;
1477
+ const trimmed = email.trim().toLowerCase();
1478
+ const atIndex = trimmed.lastIndexOf("@");
1479
+ if (atIndex === -1) return trimmed;
1480
+ let localPart = trimmed.slice(0, atIndex);
1481
+ let domain = trimmed.slice(atIndex + 1);
1482
+ if (domain === "googlemail.com") domain = "gmail.com";
1483
+ if (PLUS_ADDRESSING_DOMAINS.has(domain)) {
1484
+ const plusIndex = localPart.indexOf("+");
1485
+ if (plusIndex !== -1) localPart = localPart.slice(0, plusIndex);
1486
+ }
1487
+ if (GMAIL_LIKE_DOMAINS.has(domain)) localPart = localPart.replace(/\./g, "");
1488
+ return `${localPart}@${domain}`;
1489
+ }
1490
+ function createEmailValidator(options = {}) {
1491
+ const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, emailValidationOptions = {} } = options;
1492
+ const $api = createFetch({
1493
+ baseURL: apiUrl,
1494
+ headers: { "x-api-key": apiKey }
1495
+ });
1496
+ const $kv = createFetch({
1497
+ baseURL: kvUrl,
1498
+ headers: { "x-api-key": apiKey },
1499
+ timeout: KV_TIMEOUT_MS
1500
+ });
1501
+ /**
1502
+ * Fetch and resolve email validity policy from API with caching
1503
+ * Sends client config to API which merges with user's dashboard settings
1504
+ */
1505
+ async function fetchPolicy() {
1506
+ try {
1507
+ const { data } = await $api("/security/resolve-policy", {
1508
+ method: "POST",
1509
+ body: {
1510
+ policyId: "email_validity",
1511
+ config: { emailValidation: {
1512
+ enabled: emailValidationOptions.enabled,
1513
+ strictness: emailValidationOptions.strictness,
1514
+ action: emailValidationOptions.action,
1515
+ domainAllowlist: emailValidationOptions.domainAllowlist
1516
+ } }
1517
+ }
1518
+ });
1519
+ if (data?.policy) return data.policy;
1520
+ } catch (error) {
1521
+ logger.warn("[Dash] Failed to fetch email policy, using defaults:", error);
1522
+ }
1523
+ return null;
1524
+ }
1525
+ return { async validate(email, checkMx = true) {
1526
+ const trimmed = email.trim();
1527
+ const policy = await fetchPolicy();
1528
+ if (!policy?.enabled) return {
1529
+ valid: true,
1530
+ disposable: false,
1531
+ confidence: "high",
1532
+ policy
1533
+ };
1534
+ try {
1535
+ const { data } = await $kv("/email/validate", {
1536
+ method: "POST",
1537
+ body: {
1538
+ email: trimmed,
1539
+ checkMx,
1540
+ strictness: policy.strictness
1541
+ }
1542
+ });
1543
+ return {
1544
+ ...data || {
1545
+ valid: false,
1546
+ reason: "invalid_format"
1547
+ },
1548
+ policy
1549
+ };
1550
+ } catch (error) {
1551
+ logger.warn("[Dash] Email validation API error, falling back to allow:", error);
1552
+ return {
1553
+ valid: true,
1554
+ policy
1555
+ };
1556
+ }
1557
+ } };
1558
+ }
1559
+ /**
1560
+ * Basic local email format validation (fallback)
1561
+ */
1562
+ function isValidEmailFormatLocal(email) {
1563
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false;
1564
+ if (email.length > 254) return false;
1565
+ const [localPart, domain] = email.split("@");
1566
+ if (!localPart || !domain) return false;
1567
+ if (localPart.length > 64) return false;
1568
+ if (domain.length > 253) return false;
1569
+ return true;
1570
+ }
1571
+ const getEmail = (ctx) => {
1572
+ if (ctx.path === "/change-email") return {
1573
+ email: ctx.body?.newEmail,
1574
+ container: "body",
1575
+ field: "newEmail"
1576
+ };
1577
+ const body = ctx.body;
1578
+ const query = ctx.query;
1579
+ return {
1580
+ email: body?.email ?? query?.email,
1581
+ container: body ? "body" : "query",
1582
+ field: "email"
1583
+ };
1584
+ };
1585
+ /**
1586
+ * Create email normalization hook (shared between all configurations)
1587
+ */
1588
+ function createEmailNormalizationHook() {
1589
+ return {
1590
+ matcher: allEmail,
1591
+ handler: createAuthMiddleware(async (ctx) => {
1592
+ const { email, container, field } = getEmail(ctx);
1593
+ if (typeof email !== "string") return;
1594
+ const normalized = normalizeEmail(email, ctx.context);
1595
+ if (normalized === email) return;
1596
+ const data = container === "query" ? {
1597
+ ...ctx.query,
1598
+ [field]: normalized
1599
+ } : {
1600
+ ...ctx.body,
1601
+ [field]: normalized
1602
+ };
1603
+ return { context: {
1604
+ ...ctx,
1605
+ [container]: data
1606
+ } };
1607
+ })
1608
+ };
1609
+ }
1610
+ /**
1611
+ * Create email validation hook with configurable validation strategy
1612
+ */
1613
+ function createEmailValidationHook(validator, onDisposableEmail) {
1614
+ return {
1615
+ matcher: allEmail,
1616
+ handler: createAuthMiddleware(async (ctx) => {
1617
+ const { email } = getEmail(ctx);
1618
+ if (typeof email !== "string") return;
1619
+ const trimmed = email.trim();
1620
+ if (!isValidEmailFormatLocal(trimmed)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1621
+ if (validator) {
1622
+ const result = await validator.validate(trimmed);
1623
+ const policy = result.policy;
1624
+ if (!policy?.enabled) return;
1625
+ if (policy.domainAllowlist?.length) {
1626
+ const domain = trimmed.toLowerCase().split("@")[1];
1627
+ if (domain && policy.domainAllowlist.includes(domain)) return;
1628
+ }
1629
+ const action = policy.action;
1630
+ if (!result.valid) {
1631
+ 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) {
1632
+ const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
1633
+ onDisposableEmail({
1634
+ email: trimmed,
1635
+ reason: result.reason || "disposable",
1636
+ confidence: result.confidence,
1637
+ ip,
1638
+ path: ctx.path,
1639
+ action
1640
+ });
1641
+ }
1642
+ if (action === "allow") return;
1643
+ 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" });
1644
+ }
1645
+ }
1646
+ })
1647
+ };
1648
+ }
1649
+ /**
1650
+ * Create email validation hooks with optional API-backed validation.
1651
+ *
1652
+ * @param options - {@link EmailHooksOptions}
1653
+ *
1654
+ * @example
1655
+ * // Local validation only
1656
+ * createEmailHooks()
1657
+ *
1658
+ * @example
1659
+ * // API-backed validation
1660
+ * createEmailHooks({ useApi: true, apiKey: "your-api-key" })
1661
+ *
1662
+ * @example
1663
+ * // High strictness + API
1664
+ * createEmailHooks({
1665
+ * emailValidationOptions: { strictness: "high" },
1666
+ * useApi: true,
1667
+ * apiKey: "your-api-key",
1668
+ * })
1669
+ */
1670
+ function createEmailHooks(options = {}) {
1671
+ const { emailValidationOptions, emailNormalizationOptions, useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, onDisposableEmail } = options;
1672
+ const emailConfig = {
1673
+ enabled: true,
1674
+ strictness: "medium",
1675
+ action: "block",
1676
+ ...emailValidationOptions
1677
+ };
1678
+ return { before: [...isEmailNormalizationEnabled({
1679
+ emailValidation: emailValidationOptions,
1680
+ emailNormalization: emailNormalizationOptions
1681
+ }) ? [createEmailNormalizationHook()] : [], ...emailConfig.enabled ? [createEmailValidationHook(useApi ? createEmailValidator({
1682
+ apiUrl,
1683
+ kvUrl,
1684
+ apiKey,
1685
+ emailValidationOptions: emailConfig
1686
+ }) : void 0, onDisposableEmail)] : []] };
1687
+ }
1688
+ //#endregion
1689
+ //#region src/validation/phone.ts
1690
+ /**
1691
+ * Common fake/test phone numbers that should be blocked
1692
+ * These are numbers commonly used in testing, movies, documentation, etc.
1693
+ */
1694
+ const INVALID_PHONE_NUMBERS = new Set([
1695
+ "+15550000000",
1696
+ "+15550001111",
1697
+ "+15550001234",
1698
+ "+15551234567",
1699
+ "+15555555555",
1700
+ "+15551111111",
1701
+ "+15550000001",
1702
+ "+15550123456",
1703
+ "+12125551234",
1704
+ "+13105551234",
1705
+ "+14155551234",
1706
+ "+12025551234",
1707
+ "+10000000000",
1708
+ "+11111111111",
1709
+ "+12222222222",
1710
+ "+13333333333",
1711
+ "+14444444444",
1712
+ "+15555555555",
1713
+ "+16666666666",
1714
+ "+17777777777",
1715
+ "+18888888888",
1716
+ "+19999999999",
1717
+ "+11234567890",
1718
+ "+10123456789",
1719
+ "+19876543210",
1720
+ "+441632960000",
1721
+ "+447700900000",
1722
+ "+447700900001",
1723
+ "+447700900123",
1724
+ "+447700900999",
1725
+ "+442079460000",
1726
+ "+442079460123",
1727
+ "+441134960000",
1728
+ "+0000000000",
1729
+ "+1000000000",
1730
+ "+123456789",
1731
+ "+1234567890",
1732
+ "+12345678901",
1733
+ "+0123456789",
1734
+ "+9876543210",
1735
+ "+11111111111",
1736
+ "+99999999999",
1737
+ "+491234567890",
1738
+ "+491111111111",
1739
+ "+33123456789",
1740
+ "+33111111111",
1741
+ "+61123456789",
1742
+ "+61111111111",
1743
+ "+81123456789",
1744
+ "+81111111111",
1745
+ "+19001234567",
1746
+ "+19761234567",
1747
+ "+1911",
1748
+ "+1411",
1749
+ "+1611",
1750
+ "+44999",
1751
+ "+44112"
1752
+ ]);
1753
+ /**
1754
+ * Patterns that indicate fake/test phone numbers
1755
+ */
1756
+ const INVALID_PHONE_PATTERNS = [
1757
+ /^\+\d(\d)\1{6,}$/,
1758
+ /^\+\d*1234567890/,
1759
+ /^\+\d*0123456789/,
1760
+ /^\+\d*9876543210/,
1761
+ /^\+\d*0987654321/,
1762
+ /^\+\d*(12){4,}/,
1763
+ /^\+\d*(21){4,}/,
1764
+ /^\+\d*(00){4,}/,
1765
+ /^\+1\d{3}555\d{4}$/,
1766
+ /^\+\d{1,3}\d{1,5}$/,
1767
+ /^\+\d+0{7,}$/,
1768
+ /^\+\d*147258369/,
1769
+ /^\+\d*258369147/,
1770
+ /^\+\d*369258147/,
1771
+ /^\+\d*789456123/,
1772
+ /^\+\d*123456789/,
1773
+ /^\+\d*1234512345/,
1774
+ /^\+\d*1111122222/,
1775
+ /^\+\d*1212121212/,
1776
+ /^\+\d*1010101010/
1777
+ ];
1778
+ /**
1779
+ * Invalid area codes / prefixes that indicate test numbers
1780
+ * Key: country code, Value: set of invalid prefixes
1781
+ */
1782
+ const INVALID_PREFIXES_BY_COUNTRY = {
1783
+ US: new Set([
1784
+ "555",
1785
+ "000",
1786
+ "111",
1787
+ "911",
1788
+ "411",
1789
+ "611"
1790
+ ]),
1791
+ CA: new Set([
1792
+ "555",
1793
+ "000",
1794
+ "911"
1795
+ ]),
1796
+ GB: new Set([
1797
+ "7700900",
1798
+ "1632960",
1799
+ "1134960"
1800
+ ]),
1801
+ AU: new Set([
1802
+ "0491570",
1803
+ "0491571",
1804
+ "0491572"
1805
+ ])
1806
+ };
1807
+ /**
1808
+ * Check if a phone number is a commonly used fake/test number
1809
+ * @param phone - The phone number to check (E.164 format preferred)
1810
+ * @param defaultCountry - Default country code if not included in phone string
1811
+ * @returns true if the phone appears to be fake/test, false if it seems legitimate
1812
+ */
1813
+ const isFakePhoneNumber = (phone, defaultCountry) => {
1814
+ const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1815
+ if (!parsed) return true;
1816
+ const e164 = parsed.number;
1817
+ const nationalNumber = parsed.nationalNumber;
1818
+ const country = parsed.country;
1819
+ if (INVALID_PHONE_NUMBERS.has(e164)) return true;
1820
+ for (const pattern of INVALID_PHONE_PATTERNS) if (pattern.test(e164)) return true;
1821
+ if (country && INVALID_PREFIXES_BY_COUNTRY[country]) {
1822
+ const prefixes = INVALID_PREFIXES_BY_COUNTRY[country];
1823
+ for (const prefix of prefixes) if (nationalNumber.startsWith(prefix)) return true;
1824
+ }
1825
+ if (/^(\d)\1+$/.test(nationalNumber)) return true;
1826
+ const digits = nationalNumber.split("").map(Number);
1827
+ let isSequential = digits.length >= 6;
1828
+ for (let i = 1; i < digits.length && isSequential; i++) {
1829
+ const current = digits[i];
1830
+ const previous = digits[i - 1];
1831
+ if (current === void 0 || previous === void 0 || current !== previous + 1 && current !== previous - 1) isSequential = false;
1832
+ }
1833
+ if (isSequential) return true;
1834
+ return false;
1835
+ };
1836
+ /**
1837
+ * Validate a phone number format
1838
+ * @param phone - The phone number to validate
1839
+ * @param defaultCountry - Default country code if not included in phone string
1840
+ * @returns true if the phone number is valid
1841
+ */
1842
+ const isValidPhone = (phone, defaultCountry) => {
1843
+ return isValidPhoneNumber(phone, defaultCountry);
1844
+ };
1845
+ /**
1846
+ * Comprehensive phone number validation
1847
+ * @param phone - The phone number to validate
1848
+ * @param options - Validation options
1849
+ * @returns true if valid, false otherwise
1850
+ */
1851
+ const validatePhone = (phone, options = {}) => {
1852
+ const { mobileOnly = false, allowedCountries, blockedCountries, blockFakeNumbers = true, blockPremiumRate = true, blockTollFree = false, blockVoip = false, defaultCountry } = options;
1853
+ if (!isValidPhone(phone, defaultCountry)) return false;
1854
+ const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1855
+ if (!parsed) return false;
1856
+ if (blockFakeNumbers && isFakePhoneNumber(phone, defaultCountry)) return false;
1857
+ const country = parsed.country;
1858
+ if (country) {
1859
+ if (allowedCountries && !allowedCountries.includes(country)) return false;
1860
+ if (blockedCountries?.includes(country)) return false;
1861
+ }
1862
+ const phoneType = parsed.getType();
1863
+ if (mobileOnly) {
1864
+ if (phoneType !== "MOBILE" && phoneType !== "FIXED_LINE_OR_MOBILE") return false;
1865
+ }
1866
+ if (blockPremiumRate && phoneType === "PREMIUM_RATE") return false;
1867
+ if (blockTollFree && phoneType === "TOLL_FREE") return false;
1868
+ if (blockVoip && phoneType === "VOIP") return false;
1869
+ return true;
1870
+ };
1871
+ const allPhonePaths = new Set([
1872
+ "/phone-number/send-otp",
1873
+ "/phone-number/verify",
1874
+ "/sign-in/phone-number",
1875
+ "/phone-number/request-password-reset",
1876
+ "/phone-number/reset-password"
1877
+ ]);
1878
+ const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
1879
+ /**
1880
+ * Better Auth plugin for phone number validation
1881
+ * Validates phone numbers on all phone-related endpoints
1882
+ */
1883
+ const phoneValidationHooks = { before: [{
1884
+ matcher: (context) => !!context.path && allPhonePaths.has(context.path),
1885
+ handler: createAuthMiddleware(async (ctx) => {
1886
+ const phoneNumber = getPhoneNumber(ctx);
1887
+ if (typeof phoneNumber !== "string") return;
1888
+ if (!validatePhone(phoneNumber)) throw new APIError$1("BAD_REQUEST", { message: "Invalid phone number" });
1889
+ })
1890
+ }] };
1891
+ //#endregion
1886
1892
  //#region src/sentinel/security-hooks.ts
1887
1893
  const ERROR_MESSAGES = {
1888
1894
  geo_blocked: "Access from your location is not allowed.",
@@ -2024,11 +2030,12 @@ const sentinel = (options) => {
2024
2030
  });
2025
2031
  });
2026
2032
  const emailHooks = createEmailHooks({
2033
+ emailValidationOptions: opts.security?.emailValidation,
2034
+ emailNormalizationOptions: opts.security?.emailNormalization,
2027
2035
  useApi: !!opts.apiKey,
2028
2036
  apiKey: opts.apiKey,
2029
2037
  apiUrl: opts.apiUrl,
2030
2038
  kvUrl: opts.kvUrl,
2031
- defaultConfig: opts.security?.emailValidation,
2032
2039
  onDisposableEmail: (data) => {
2033
2040
  const isNoMxRecord = data.reason === "no_mx_records";
2034
2041
  const reason = isNoMxRecord ? "no_mx_records" : "disposable_email";
@@ -2061,6 +2068,7 @@ const sentinel = (options) => {
2061
2068
  const activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
2062
2069
  return { options: {
2063
2070
  emailValidation: opts.security?.emailValidation,
2071
+ emailNormalization: opts.security?.emailNormalization,
2064
2072
  databaseHooks: {
2065
2073
  user: { create: {
2066
2074
  async before(user, ctx) {
@@ -2070,7 +2078,7 @@ const sentinel = (options) => {
2070
2078
  const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2071
2079
  if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2072
2080
  }
2073
- if (user.email && typeof user.email === "string" && opts.security?.emailValidation?.enabled !== false) return { data: {
2081
+ if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2074
2082
  ...user,
2075
2083
  email: normalizeEmail(user.email, ctx.context)
2076
2084
  } };
@@ -2746,10 +2754,76 @@ const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
2746
2754
  });
2747
2755
  //#endregion
2748
2756
  //#region src/version.ts
2749
- const PLUGIN_VERSION = "0.2.2";
2757
+ const PLUGIN_VERSION = "0.2.4";
2750
2758
  //#endregion
2751
2759
  //#region src/routes/auth/config.ts
2752
2760
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
2761
+ const EXACT_SENSITIVE_STRING_KEYS_LOWER = new Set([...new Set([
2762
+ "accessToken",
2763
+ "apiKey",
2764
+ "apiSecret",
2765
+ "authorization",
2766
+ "authToken",
2767
+ "bearerToken",
2768
+ "clientSecret",
2769
+ "consumerSecret",
2770
+ "encPrivateKey",
2771
+ "encPrivateKeyPass",
2772
+ "encryptionKey",
2773
+ "encryptionSecret",
2774
+ "idToken",
2775
+ "password",
2776
+ "privateKey",
2777
+ "privateKeyPass",
2778
+ "refreshToken",
2779
+ "secret",
2780
+ "secretKey",
2781
+ "signingSecret",
2782
+ "stripeWebhookSecret",
2783
+ "webhookSecret"
2784
+ ])].map((k) => k.toLowerCase()));
2785
+ /**
2786
+ * Case-insensitive suffixes for unknown plugin options
2787
+ * Matching is more permissive to account for unknown plugin options
2788
+ */
2789
+ const SENSITIVE_KEY_SUFFIXES_LOWER = [
2790
+ "secret",
2791
+ "password",
2792
+ "passphrase",
2793
+ "privatekey",
2794
+ "keypass",
2795
+ "apikey",
2796
+ "token",
2797
+ "signingkey",
2798
+ "credentials",
2799
+ "authheader"
2800
+ ];
2801
+ const REDACTED_SIMPLE_STRING = "[REDACTED]";
2802
+ function snakeCaseToCamelCase(key) {
2803
+ return key.replace(/_([a-zA-Z])/g, (_, ch) => ch.toUpperCase());
2804
+ }
2805
+ function keyVariantsForMatching(key) {
2806
+ const trimmed = key.replace(/^[\s._-]+/, "");
2807
+ if (!trimmed) return [key];
2808
+ const out = new Set([key, trimmed]);
2809
+ if (trimmed.includes("_")) out.add(snakeCaseToCamelCase(trimmed));
2810
+ return [...out];
2811
+ }
2812
+ function shouldRedactSensitiveStringKey(key) {
2813
+ const variants = keyVariantsForMatching(key);
2814
+ const compactLower = key.replace(/[\s._-]/g, "").toLowerCase();
2815
+ const forms = new Set([compactLower]);
2816
+ for (const raw of variants) {
2817
+ forms.add(raw);
2818
+ forms.add(raw.toLowerCase());
2819
+ }
2820
+ for (const form of forms) {
2821
+ const fl = form.toLowerCase();
2822
+ if (EXACT_SENSITIVE_STRING_KEYS_LOWER.has(fl)) return true;
2823
+ for (const suffix of SENSITIVE_KEY_SUFFIXES_LOWER) if (fl.endsWith(suffix)) return true;
2824
+ }
2825
+ return false;
2826
+ }
2753
2827
  function isPlainSerializable(value) {
2754
2828
  if (value === null || typeof value !== "object") return true;
2755
2829
  if (Array.isArray(value)) return true;
@@ -2758,22 +2832,28 @@ function isPlainSerializable(value) {
2758
2832
  if (constructor && constructor.name !== "Object" && constructor.name !== "Array") return false;
2759
2833
  return true;
2760
2834
  }
2761
- function sanitizePluginOptions(pluginId, options, visiting = /* @__PURE__ */ new WeakSet()) {
2762
- if (options === null || options === void 0) return options;
2763
- if (typeof options === "function") return void 0;
2764
- if (typeof options !== "object") return options;
2765
- const obj = options;
2835
+ /**
2836
+ * Sanitize any plain JSON-like subtree from Better Auth options
2837
+ */
2838
+ function sanitizeConfig(value, visiting, excludeKeys) {
2839
+ if (value === null || value === void 0) return value;
2840
+ if (typeof value === "function") return void 0;
2841
+ if (typeof value !== "object") return value;
2842
+ const obj = value;
2766
2843
  if (visiting.has(obj)) return void 0;
2767
2844
  visiting.add(obj);
2768
2845
  try {
2769
- const excludeKeys = PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId];
2770
- if (Array.isArray(options)) return options.map((item) => sanitizePluginOptions(pluginId, item, visiting)).filter((item) => item !== void 0);
2846
+ if (Array.isArray(value)) return value.map((item) => sanitizeConfig(item, visiting, excludeKeys)).filter((item) => item !== void 0);
2771
2847
  const result = {};
2772
- for (const [key, value] of Object.entries(options)) {
2848
+ for (const [key, val] of Object.entries(value)) {
2773
2849
  if (excludeKeys?.has(key)) continue;
2774
- if (typeof value === "function") continue;
2775
- if (value !== null && typeof value === "object" && !isPlainSerializable(value)) continue;
2776
- const sanitized = sanitizePluginOptions(pluginId, value, visiting);
2850
+ if (typeof val === "function") continue;
2851
+ if (typeof val === "string" && shouldRedactSensitiveStringKey(key)) {
2852
+ result[key] = REDACTED_SIMPLE_STRING;
2853
+ continue;
2854
+ }
2855
+ if (val !== null && typeof val === "object" && !isPlainSerializable(val)) continue;
2856
+ const sanitized = sanitizeConfig(val, visiting, excludeKeys);
2777
2857
  if (sanitized !== void 0) result[key] = sanitized;
2778
2858
  }
2779
2859
  return result;
@@ -2781,6 +2861,9 @@ function sanitizePluginOptions(pluginId, options, visiting = /* @__PURE__ */ new
2781
2861
  visiting.delete(obj);
2782
2862
  }
2783
2863
  }
2864
+ function sanitizePluginOptions(pluginId, options) {
2865
+ return sanitizeConfig(options, /* @__PURE__ */ new WeakSet(), PLUGIN_OPTIONS_EXCLUDE_KEYS[pluginId]);
2866
+ }
2784
2867
  function estimateEntropy(str) {
2785
2868
  const unique = new Set(str).size;
2786
2869
  if (unique === 0) return 0;
@@ -2796,7 +2879,7 @@ const getConfig = (options) => {
2796
2879
  return {
2797
2880
  version: ctx.context.version || null,
2798
2881
  socialProviders: Object.keys(ctx.context.options.socialProviders || {}),
2799
- emailAndPassword: ctx.context.options.emailAndPassword,
2882
+ emailAndPassword: sanitizeConfig(ctx.context.options.emailAndPassword, /* @__PURE__ */ new WeakSet(), void 0),
2800
2883
  plugins: ctx.context.options.plugins?.map((plugin) => {
2801
2884
  const base = {
2802
2885
  id: plugin.id,
@@ -5413,11 +5496,14 @@ function getSSOPlugin(ctx) {
5413
5496
  const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
5414
5497
  const RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1";
5415
5498
  const SHA1 = "http://www.w3.org/2000/09/xmldsig#sha1";
5499
+ /** @public */
5416
5500
  const DEPRECATED_SIGNATURE_ALGORITHMS = [RSA_SHA1];
5501
+ /** @public */
5417
5502
  const DEPRECATED_DIGEST_ALGORITHMS = [SHA1];
5418
5503
  function validateSAMLMetadataSize(metadataXml, maxSize = DEFAULT_MAX_SAML_METADATA_SIZE) {
5419
5504
  if (new TextEncoder().encode(metadataXml).byteLength > maxSize) throw new Error(`IdP metadata exceeds maximum allowed size (${Math.round(maxSize / 1024)}KB)`);
5420
5505
  }
5506
+ /** @public */
5421
5507
  function extractAlgorithmsFromSAMLMetadata(metadataXml) {
5422
5508
  const signatureAlgorithms = [];
5423
5509
  const digestAlgorithms = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/infra",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Dashboard and analytics plugin for Better Auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -68,9 +68,9 @@
68
68
  "@types/node": "catalog:",
69
69
  "better-auth": "catalog:",
70
70
  "expo-crypto": "^14.0.2",
71
- "happy-dom": "^20.8.9",
72
- "msw": "^2.13.0",
73
- "tsdown": "^0.21.1",
71
+ "happy-dom": "^20.9.0",
72
+ "msw": "^2.13.3",
73
+ "tsdown": "^0.21.9",
74
74
  "typescript": "catalog:",
75
75
  "zod": "catalog:"
76
76
  },