@gatwech/auth 1.0.0-beta.19 → 1.0.0-beta.21

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.
Files changed (134) hide show
  1. package/dist/adapters/drizzle-adapter/index.mjs +11 -2
  2. package/dist/adapters/index.d.mts +2 -2
  3. package/dist/adapters/kysely-adapter/index.mjs +1 -1
  4. package/dist/adapters/memory-adapter/index.d.mts +1 -1
  5. package/dist/adapters/memory-adapter/index.mjs +2 -2
  6. package/dist/api/index.d.mts +2 -2
  7. package/dist/api/index.mjs +1 -1
  8. package/dist/{audit-log-DKsUWR8O.mjs → audit-log-DgqHJ-uv.mjs} +132 -63
  9. package/dist/{auth-vfCyIIeV.mjs → auth-BoEA9bN7.mjs} +14 -1
  10. package/dist/client/index.d.mts +18 -18
  11. package/dist/client/plugins/index.d.mts +28 -28
  12. package/dist/client/plugins/index.mjs +2 -2
  13. package/dist/client/react/index.d.mts +16 -16
  14. package/dist/{client-CSOs1YN7.d.mts → client-B00gkznv.d.mts} +1 -1
  15. package/dist/{client-4QEb0rUr.mjs → client-CMsC6-rl.mjs} +1 -1
  16. package/dist/{client-DZHNfxuR.d.mts → client-Da7tq8m6.d.mts} +4 -4
  17. package/dist/cookies/index.d.mts +3 -3
  18. package/dist/db/index.d.mts +2 -2
  19. package/dist/db/index.mjs +1 -1
  20. package/dist/{db-7vHnL1XA.mjs → db-CV59HpEF.mjs} +2 -2
  21. package/dist/{index-mxEUX2Gs.d.mts → index-BECNG13R.d.mts} +13 -13
  22. package/dist/{index-DRM35ir1.d.mts → index-BKy-8t2n.d.mts} +118 -65
  23. package/dist/{index-Df0KhQRf-BEa5tdwJ.d.mts → index-BL_t8cRp-C4QYBsac.d.mts} +56 -17
  24. package/dist/{index-DXVU0HGr.d.mts → index-BWYXZVsV.d.mts} +5 -5
  25. package/dist/{index-Df4SvZaE.d.mts → index-Bhc37y9F.d.mts} +3 -3
  26. package/dist/{index-BNhJglHC.d.mts → index-BjCQ3jmE.d.mts} +119 -119
  27. package/dist/{index-Cv9iMevV.d.mts → index-Bxive_YT.d.mts} +9 -9
  28. package/dist/{index-CgeC2eGC.d.mts → index-C-IDh5yy.d.mts} +2 -2
  29. package/dist/{index-COGe8UJx.d.mts → index-CM1rFdwV.d.mts} +9 -9
  30. package/dist/{index-zBdYQGaS.d.mts → index-CMG4Pnen.d.mts} +6 -6
  31. package/dist/{index-Blmhq6eA.d.mts → index-CTqynuxm.d.mts} +4 -4
  32. package/dist/{index-DS-R7st3.d.mts → index-CbBQdS-u.d.mts} +6 -6
  33. package/dist/{index-Db8jqUq2.d.mts → index-CpH-1sse.d.mts} +10 -10
  34. package/dist/{index-CUknVRZp.d.mts → index-D1OZ2e4m.d.mts} +14 -14
  35. package/dist/{index-CaqpCfE5.d.mts → index-Dt2I0G4X.d.mts} +7 -7
  36. package/dist/{index-EABwA_BH.d.mts → index-IX1to9TB.d.mts} +34 -34
  37. package/dist/{index-CKnPAS5n.d.mts → index-TrOnY3pM.d.mts} +170 -170
  38. package/dist/{index-CsxtcAO0.d.mts → index-VUyESU4p.d.mts} +1 -1
  39. package/dist/{index-CP3FNbOZ.d.mts → index-WV7VzW2H.d.mts} +8 -8
  40. package/dist/{index-xoYsoOiR.d.mts → index-YeXSxzhX.d.mts} +3 -3
  41. package/dist/{index-DG-X1PK5.d.mts → index-cPt-00xm.d.mts} +6 -6
  42. package/dist/{index-BNDRYfTF.d.mts → index-iRoaTsOj.d.mts} +2 -2
  43. package/dist/{index-BAVzFqII.d.mts → index-jEkJ2Zfk.d.mts} +6 -6
  44. package/dist/{index-DyH_CgmX.d.mts → index-oNlp4SvD.d.mts} +10 -10
  45. package/dist/index.d.mts +2 -2
  46. package/dist/index.mjs +3 -3
  47. package/dist/integrations/next-js.d.mts +4 -4
  48. package/dist/integrations/node.d.mts +2 -2
  49. package/dist/integrations/react-start.d.mts +4 -4
  50. package/dist/{kysely-adapter-Bkm1liPW.mjs → kysely-adapter-BidlNTiV.mjs} +4 -2
  51. package/dist/oauth2/index.d.mts +2 -2
  52. package/dist/oauth2/index.mjs +1 -1
  53. package/dist/{organization-CkTKs7bx.mjs → organization-D4QMpChF.mjs} +1 -1
  54. package/dist/plugins/admin/index.d.mts +3 -3
  55. package/dist/plugins/admin/index.mjs +1 -1
  56. package/dist/plugins/anonymous/index.d.mts +3 -3
  57. package/dist/plugins/anonymous/index.mjs +1 -1
  58. package/dist/plugins/audit-log/client.d.mts +24 -24
  59. package/dist/plugins/audit-log/index.d.mts +23 -23
  60. package/dist/plugins/audit-log/index.mjs +2 -2
  61. package/dist/plugins/bearer/index.d.mts +1 -1
  62. package/dist/plugins/captcha/index.d.mts +1 -1
  63. package/dist/plugins/custom-session/index.d.mts +3 -3
  64. package/dist/plugins/custom-session/index.mjs +1 -1
  65. package/dist/plugins/device-authorization/index.d.mts +1 -1
  66. package/dist/plugins/device-authorization/index.mjs +1 -1
  67. package/dist/plugins/email-otp/index.d.mts +2 -2
  68. package/dist/plugins/email-otp/index.mjs +1 -1
  69. package/dist/plugins/generic-oauth/index.d.mts +3 -3
  70. package/dist/plugins/generic-oauth/index.mjs +1 -1
  71. package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
  72. package/dist/plugins/haveibeenpwned/index.mjs +1 -1
  73. package/dist/plugins/index.d.mts +23 -23
  74. package/dist/plugins/index.mjs +5 -5
  75. package/dist/plugins/jwt/index.d.mts +3 -3
  76. package/dist/plugins/jwt/index.mjs +1 -1
  77. package/dist/plugins/magic-link/index.d.mts +1 -1
  78. package/dist/plugins/magic-link/index.mjs +1 -1
  79. package/dist/plugins/multi-session/index.d.mts +2 -2
  80. package/dist/plugins/multi-session/index.mjs +1 -1
  81. package/dist/plugins/oauth-proxy/index.d.mts +1 -1
  82. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  83. package/dist/plugins/one-tap/index.d.mts +1 -1
  84. package/dist/plugins/one-tap/index.mjs +1 -1
  85. package/dist/plugins/one-time-token/index.d.mts +3 -3
  86. package/dist/plugins/one-time-token/index.mjs +5 -5
  87. package/dist/plugins/organization/index.d.mts +3 -3
  88. package/dist/plugins/organization/index.mjs +2 -2
  89. package/dist/plugins/passkey/index.d.mts +3 -3
  90. package/dist/plugins/passkey/index.mjs +1 -1
  91. package/dist/plugins/phone-number/index.d.mts +3 -3
  92. package/dist/plugins/phone-number/index.mjs +1 -1
  93. package/dist/plugins/sentinel/client.d.mts +24 -24
  94. package/dist/plugins/sentinel/client.mjs +2 -2
  95. package/dist/plugins/sentinel/index.d.mts +23 -23
  96. package/dist/plugins/sentinel/index.mjs +3 -3
  97. package/dist/plugins/two-factor/index.d.mts +3 -3
  98. package/dist/plugins/two-factor/index.mjs +1 -1
  99. package/dist/plugins/username/index.d.mts +1 -1
  100. package/dist/plugins/username/index.mjs +1 -1
  101. package/dist/{pow-OK01yPG3.mjs → pow-DvkUopsK.mjs} +17 -1
  102. package/dist/{sentinel-C-D1IHJo.mjs → sentinel-B4Wo79HK.mjs} +326 -92
  103. package/dist/src/adapters/memory-adapter/memory-adapter.d.ts +1 -1
  104. package/dist/src/adapters/prisma-adapter/test/auth.d.ts +1 -1
  105. package/dist/src/adapters/tests/auth-flow.d.ts +3 -3
  106. package/dist/src/adapters/tests/normal.d.ts +10 -2
  107. package/dist/src/adapters/tests/number-id.d.ts +4 -4
  108. package/dist/src/adapters/tests/performance.d.ts +3 -3
  109. package/dist/src/adapters/tests/transactions.d.ts +3 -3
  110. package/dist/src/api/index.d.ts +8 -8
  111. package/dist/src/plugins/admin/admin.d.ts +2 -2
  112. package/dist/src/plugins/api-key/index.d.ts +2 -2
  113. package/dist/src/plugins/audit-log/index.d.ts +5 -14
  114. package/dist/src/plugins/audit-log/remote.d.ts +4 -4
  115. package/dist/src/plugins/audit-log/types.d.ts +22 -0
  116. package/dist/src/plugins/email-otp/index.d.ts +1 -1
  117. package/dist/src/plugins/multi-session/index.d.ts +8 -8
  118. package/dist/src/plugins/organization/routes/crud-access-control.d.ts +8 -8
  119. package/dist/src/plugins/organization/routes/crud-invites.d.ts +46 -46
  120. package/dist/src/plugins/organization/routes/crud-members.d.ts +40 -40
  121. package/dist/src/plugins/organization/routes/crud-org.d.ts +36 -36
  122. package/dist/src/plugins/organization/routes/crud-team.d.ts +50 -50
  123. package/dist/src/plugins/sentinel/email.d.ts +9 -3
  124. package/dist/src/plugins/sentinel/index.d.ts +10 -1
  125. package/dist/src/plugins/sentinel/intel.d.ts +8 -1
  126. package/dist/src/plugins/sentinel/memory-cache.d.ts +12 -0
  127. package/dist/src/plugins/sentinel/schema.d.ts +4 -0
  128. package/dist/src/plugins/sentinel/store.d.ts +7 -0
  129. package/dist/src/plugins/sentinel/types.d.ts +29 -1
  130. package/dist/src/test-utils/test-instance.d.ts +25 -25
  131. package/dist/test-utils/index.d.mts +293 -293
  132. package/dist/test-utils/index.mjs +7 -7
  133. package/dist/types/index.d.mts +2 -2
  134. package/package.json +3 -3
@@ -4,7 +4,7 @@ import { n as deleteSessionCookie } from "./cookies-BuvlgcA-.mjs";
4
4
  import { n as mergeSchema } from "./date-CMr04reD.mjs";
5
5
  import { a as CREDENTIAL_SIGN_IN_PATHS, c as SIGN_IN_PATHS, l as VERIFICATION_PATHS, s as SECURITY_EVENT_TYPES, t as getAuditRecorder } from "./registry-CqG_3Qnx.mjs";
6
6
  import { t as getPasswordBreachCount } from "./haveibeenpwned-CzNda7Tn.mjs";
7
- import { c as verifySolution, i as createNonce, n as POW_REASON_HEADER, o as parsePowHeader, r as POW_SOLUTION_HEADER, t as POW_CHALLENGE_HEADER } from "./pow-OK01yPG3.mjs";
7
+ import { c as verifySolution, i as createNonce, n as POW_REASON_HEADER, o as parsePowHeader, r as POW_SOLUTION_HEADER, t as POW_CHALLENGE_HEADER } from "./pow-DvkUopsK.mjs";
8
8
  import { env } from "@gatwech/core/env";
9
9
  import { PayvoAuthError } from "@gatwech/core/error";
10
10
  import { defineErrorCodes } from "@gatwech/core/utils";
@@ -15,15 +15,20 @@ import { betterFetch } from "@better-fetch/fetch";
15
15
  //#region src/plugins/sentinel/actions.ts
16
16
  const CHALLENGE_TTL_MS = 6e4;
17
17
  /**
18
+ * Challenges an IP address can be issued within one challenge lifetime.
19
+ * Each one is stored, so past this the request is blocked instead.
20
+ */
21
+ const MAX_CHALLENGES_PER_IP = 30;
22
+ /**
18
23
  * Applies a security action: records the event and decides whether the
19
24
  * request continues, gets a proof-of-work challenge or is blocked
20
25
  */
21
26
  async function executeAction(params) {
22
27
  const { auth, event, action, reason } = params;
23
28
  const recorder = getAuditRecorder(auth);
24
- const record = async (eventType, status) => {
29
+ const record = async (eventType, status, metadata = params.metadata) => {
25
30
  if (!recorder) {
26
- auth.logger.warn(`[sentinel] ${reason}: ${status}`, params.metadata);
31
+ auth.logger.warn(`[sentinel] ${reason}: ${status}`, metadata);
27
32
  return;
28
33
  }
29
34
  await recorder(event, {
@@ -36,7 +41,7 @@ async function executeAction(params) {
36
41
  eventData: {
37
42
  reason,
38
43
  action,
39
- ...params.metadata
44
+ ...metadata
40
45
  }
41
46
  });
42
47
  };
@@ -49,12 +54,32 @@ async function executeAction(params) {
49
54
  await record(SECURITY_EVENT_TYPES.ALLOWED_AFTER_CHALLENGE, "success");
50
55
  return { outcome: "allow" };
51
56
  }
57
+ if (params.ipAddress) {
58
+ const challengesIssued = await params.store.increment(`pow-issued:${params.ipAddress}`, CHALLENGE_TTL_MS / 1e3).catch((e) => {
59
+ auth.logger.error("[sentinel] Failed to count challenges", e);
60
+ return 0;
61
+ });
62
+ if (challengesIssued > MAX_CHALLENGES_PER_IP) {
63
+ const metadata = {
64
+ ...params.metadata,
65
+ challengesIssued,
66
+ maxChallenges: MAX_CHALLENGES_PER_IP
67
+ };
68
+ await record(params.eventType, "blocked", metadata);
69
+ await record(SECURITY_EVENT_TYPES.BLOCKED, "blocked", metadata);
70
+ return {
71
+ outcome: "block",
72
+ reason
73
+ };
74
+ }
75
+ }
52
76
  const nonce = createNonce();
53
77
  try {
54
78
  await params.store.saveChallenge({
55
79
  nonce,
56
80
  difficulty: params.difficulty,
57
81
  reason,
82
+ path: event.path ?? null,
58
83
  ipAddress: params.ipAddress,
59
84
  visitorId: params.visitorId,
60
85
  expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS)
@@ -194,23 +219,54 @@ async function isDisposableDomain(domain) {
194
219
  for (let i = 0; i < labels.length - 1; i++) if (isDisposable(labels.slice(i).join("."))) return true;
195
220
  return false;
196
221
  }
222
+ /** How long a mail lookup may take before it counts as unknown */
223
+ const MAIL_LOOKUP_TIMEOUT_MS = 2e3;
224
+ /** The lookup found nothing, as opposed to failing */
225
+ const isMissingRecord = (e) => {
226
+ const code = e.code;
227
+ return code === "ENOTFOUND" || code === "ENODATA" || code === "ENOTIMP";
228
+ };
229
+ /** Whether a domain has an A or AAAA record, null when a lookup failed */
230
+ async function hasAddressRecords(dns, domain) {
231
+ const addressLookups = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
232
+ if (addressLookups.some((lookup) => lookup.status === "fulfilled" && lookup.value.length > 0)) return true;
233
+ return addressLookups.some((lookup) => lookup.status === "rejected" && !isMissingRecord(lookup.reason)) ? null : false;
234
+ }
235
+ async function canReceiveMail(dns, domain) {
236
+ try {
237
+ const mxRecords = await dns.resolveMx(domain);
238
+ if (mxRecords.length === 1 && (mxRecords[0].exchange === "" || mxRecords[0].exchange === ".")) return false;
239
+ if (mxRecords.length > 0) return true;
240
+ } catch (e) {
241
+ const code = e.code;
242
+ if (code === "ENOTFOUND" || code === "ENOTIMP") return false;
243
+ if (!isMissingRecord(e)) return null;
244
+ }
245
+ return hasAddressRecords(dns, domain);
246
+ }
197
247
  /**
198
- * Whether a domain has at least one MX record. Returns null when the lookup
199
- * isn't possible, e.g. on runtimes without DNS access.
248
+ * Whether a domain can receive mail: it has an MX record, or an address
249
+ * record to fall back to when it has none. A null MX record, which marks a
250
+ * domain that accepts no mail, doesn't count.
251
+ *
252
+ * Returns null when that can't be told: runtimes without DNS access, lookup
253
+ * failures, or a lookup that takes longer than `timeoutMs`.
200
254
  */
201
- async function hasMxRecords(domain) {
255
+ async function hasMxRecords(domain, options = {}) {
202
256
  let dns;
203
257
  try {
204
258
  dns = await import("node:dns/promises");
205
259
  } catch {
206
260
  return null;
207
261
  }
262
+ let timer;
263
+ const timeout = new Promise((resolve) => {
264
+ timer = setTimeout(() => resolve(null), options.timeoutMs ?? MAIL_LOOKUP_TIMEOUT_MS);
265
+ });
208
266
  try {
209
- return (await dns.resolveMx(domain)).length > 0;
210
- } catch (e) {
211
- const code = e.code;
212
- if (code === "ENOTFOUND" || code === "ENODATA" || code === "ENOTIMP") return false;
213
- return null;
267
+ return await Promise.race([canReceiveMail(dns, domain), timeout]);
268
+ } finally {
269
+ clearTimeout(timer);
214
270
  }
215
271
  }
216
272
  /**
@@ -247,6 +303,41 @@ function travelSpeedKmh(from, to) {
247
303
  return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
248
304
  }
249
305
 
306
+ //#endregion
307
+ //#region src/plugins/sentinel/memory-cache.ts
308
+ /**
309
+ * A small in-memory cache that forgets entries after a TTL and drops the
310
+ * least recently used one when it's full. Used when there's no secondary
311
+ * storage to share verdicts through.
312
+ */
313
+ function createMemoryCache(options) {
314
+ const entries = /* @__PURE__ */ new Map();
315
+ return {
316
+ get(key) {
317
+ const entry = entries.get(key);
318
+ if (!entry) return void 0;
319
+ if (entry.expiresAt <= Date.now()) {
320
+ entries.delete(key);
321
+ return;
322
+ }
323
+ entries.delete(key);
324
+ entries.set(key, entry);
325
+ return entry.value;
326
+ },
327
+ set(key, value, ttlMs = options.ttlMs) {
328
+ entries.delete(key);
329
+ if (entries.size >= options.maxSize) {
330
+ const oldest = entries.keys().next().value;
331
+ if (oldest !== void 0) entries.delete(oldest);
332
+ }
333
+ entries.set(key, {
334
+ value,
335
+ expiresAt: Date.now() + ttlMs
336
+ });
337
+ }
338
+ };
339
+ }
340
+
250
341
  //#endregion
251
342
  //#region src/plugins/sentinel/intel.ts
252
343
  const BOT_USER_AGENT = /bot|crawl|spider|slurp|headless|phantomjs|selenium|puppeteer|playwright|curl|wget|httpie|python-requests|python-urllib|aiohttp|go-http-client|okhttp|java\/|libwww|scrapy|postman|insomnia/i;
@@ -265,9 +356,18 @@ const UNKNOWN = {
265
356
  bot: null,
266
357
  geo: null
267
358
  };
359
+ /** Verdicts kept in memory when there's no secondary storage */
360
+ const MEMORY_CACHE_SIZE = 5e3;
361
+ /** How long a verified visitor id is remembered */
362
+ const VISITOR_CACHE_SECONDS = 300;
363
+ /** How long an in-memory request verdict lasts when the backend sets none */
364
+ const DEFAULT_INTEL_CACHE_SECONDS = 60;
365
+ /** The longest an in-memory request verdict lasts */
366
+ const MAX_INTEL_CACHE_SECONDS = 300;
268
367
  /**
269
368
  * Asks the hosted backend about a request's IP address and client. Verdicts
270
- * are cached in secondary storage for as long as the backend allows.
369
+ * are cached for as long as the backend allows, in secondary storage or, when
370
+ * there is none, in memory.
271
371
  * Returns null verdicts when the backend isn't configured or can't answer.
272
372
  */
273
373
  function createIntelClient(options) {
@@ -281,15 +381,26 @@ function createIntelClient(options) {
281
381
  baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
282
382
  maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
283
383
  };
384
+ const visitorVerdicts = createMemoryCache({
385
+ maxSize: MEMORY_CACHE_SIZE,
386
+ ttlMs: VISITOR_CACHE_SECONDS * 1e3
387
+ });
388
+ const requestVerdicts = createMemoryCache({
389
+ maxSize: MEMORY_CACHE_SIZE,
390
+ ttlMs: DEFAULT_INTEL_CACHE_SECONDS * 1e3
391
+ });
284
392
  return {
285
393
  configured,
286
394
  verifiesVisitors: Boolean(kvUrl && apiKey),
287
395
  misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
288
- async verifyVisitor(auth, request) {
396
+ async verifyVisitor(auth, request, limit) {
289
397
  if (!kvUrl || !apiKey) return request.visitorId;
290
398
  const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
291
399
  const cached = await auth.secondaryStorage?.get(cacheKey);
292
400
  if (typeof cached === "string") return cached || null;
401
+ const remembered = visitorVerdicts.get(cacheKey);
402
+ if (remembered !== void 0 && !auth.secondaryStorage) return remembered;
403
+ if (limit && !await limit.allowed()) return null;
293
404
  for (let attempt = 0; attempt <= kv.attempts; attempt++) {
294
405
  if (attempt > 0) {
295
406
  const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
@@ -309,7 +420,9 @@ function createIntelClient(options) {
309
420
  }));
310
421
  if (data && !error) {
311
422
  const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
312
- await auth.secondaryStorage?.set(cacheKey, visitorId ?? "", 300);
423
+ if (!visitorId) await limit?.rejected();
424
+ if (auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, visitorId ?? "", VISITOR_CACHE_SECONDS);
425
+ else visitorVerdicts.set(cacheKey, visitorId);
313
426
  return visitorId;
314
427
  }
315
428
  }
@@ -321,6 +434,8 @@ function createIntelClient(options) {
321
434
  const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
322
435
  const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
323
436
  if (cached) return cached;
437
+ const remembered = auth.secondaryStorage ? void 0 : requestVerdicts.get(cacheKey);
438
+ if (remembered) return remembered;
324
439
  const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
325
440
  method: "POST",
326
441
  headers: {
@@ -347,7 +462,12 @@ function createIntelClient(options) {
347
462
  bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
348
463
  geo: data.geo ?? null
349
464
  };
350
- if (data.ttlSeconds && auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
465
+ if (auth.secondaryStorage) {
466
+ if (data.ttlSeconds) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
467
+ } else {
468
+ const ttlSeconds = Math.min(data.ttlSeconds ?? DEFAULT_INTEL_CACHE_SECONDS, MAX_INTEL_CACHE_SECONDS);
469
+ requestVerdicts.set(cacheKey, intel, ttlSeconds * 1e3);
470
+ }
351
471
  return intel;
352
472
  }
353
473
  };
@@ -435,6 +555,10 @@ const schema = {
435
555
  type: "string",
436
556
  required: true
437
557
  },
558
+ path: {
559
+ type: "string",
560
+ required: false
561
+ },
438
562
  ipAddress: {
439
563
  type: "string",
440
564
  required: false
@@ -457,6 +581,35 @@ const schema = {
457
581
  //#endregion
458
582
  //#region src/plugins/sentinel/store.ts
459
583
  const PREFIX = "sentinel:";
584
+ /** How long after expiring a row is deleted */
585
+ const EXPIRED_ROW_GRACE_MS = 6e4;
586
+ /** Tries to increment a counter in the database before giving up */
587
+ const MAX_COUNTER_ATTEMPTS = 20;
588
+ /** When each auth instance last deleted expired rows, keyed by its adapter */
589
+ const lastChecked = /* @__PURE__ */ new WeakMap();
590
+ /**
591
+ * Deletes expired counters and challenges from the database, at most every
592
+ * 10 seconds per auth instance. Secondary storage expires them itself.
593
+ */
594
+ async function deleteAllExpiredRows(auth, byPassLastCheckTime = false) {
595
+ if (auth.secondaryStorage) return;
596
+ const checkedAt = lastChecked.get(auth.adapter);
597
+ if (checkedAt && !byPassLastCheckTime) {
598
+ if (Date.now() - checkedAt.getTime() < 1e4) return;
599
+ }
600
+ lastChecked.set(auth.adapter, /* @__PURE__ */ new Date());
601
+ const expiredBefore = new Date(Date.now() - EXPIRED_ROW_GRACE_MS);
602
+ await Promise.all(["sentinelCounter", "powChallenge"].map((model) => auth.adapter.deleteMany({
603
+ model,
604
+ where: [{
605
+ field: "expiresAt",
606
+ operator: "lt",
607
+ value: expiredBefore
608
+ }]
609
+ }))).catch((error) => {
610
+ auth.logger.error("[sentinel] Failed to delete expired rows:", error);
611
+ });
612
+ }
460
613
  /**
461
614
  * Counters and challenges, kept in secondary storage when it's configured
462
615
  * and in the database otherwise
@@ -491,45 +644,45 @@ function createSentinelStore(auth) {
491
644
  }), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
492
645
  return count;
493
646
  }
494
- let row = await findCounter(key);
495
- if (!row) {
496
- if (await auth.adapter.create({
497
- model: "sentinelCounter",
498
- data: {
499
- key,
500
- count: 1,
501
- expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
502
- }
503
- }).then(() => true, () => false)) return 1;
504
- row = await findCounter(key);
505
- if (!row) throw new Error(`Failed to create counter ${key}`);
506
- }
507
- if (new Date(row.expiresAt).getTime() <= Date.now()) {
508
- await auth.adapter.update({
647
+ deleteAllExpiredRows(auth);
648
+ for (let attempt = 0; attempt < MAX_COUNTER_ATTEMPTS; attempt++) {
649
+ const row = await findCounter(key);
650
+ if (!row) {
651
+ if (await auth.adapter.create({
652
+ model: "sentinelCounter",
653
+ data: {
654
+ key,
655
+ count: 1,
656
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
657
+ }
658
+ }).then(() => true, () => false)) return 1;
659
+ continue;
660
+ }
661
+ const expiresAt = new Date(row.expiresAt);
662
+ const expired = expiresAt.getTime() <= Date.now();
663
+ if (await auth.adapter.updateMany({
509
664
  model: "sentinelCounter",
510
- where: [{
511
- field: "id",
512
- value: row.id
513
- }],
665
+ where: [
666
+ {
667
+ field: "id",
668
+ value: row.id
669
+ },
670
+ {
671
+ field: "count",
672
+ value: row.count
673
+ },
674
+ {
675
+ field: "expiresAt",
676
+ value: expiresAt
677
+ }
678
+ ],
514
679
  update: {
515
- count: 1,
516
- expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
680
+ count: expired ? 1 : row.count + 1,
681
+ ...expired || options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
517
682
  }
518
- });
519
- return 1;
683
+ }) > 0) return expired ? 1 : row.count + 1;
520
684
  }
521
- await auth.adapter.update({
522
- model: "sentinelCounter",
523
- where: [{
524
- field: "id",
525
- value: row.id
526
- }],
527
- update: {
528
- count: row.count + 1,
529
- ...options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
530
- }
531
- });
532
- return row.count + 1;
685
+ throw new Error(`Failed to increment counter ${key}`);
533
686
  },
534
687
  async get(name) {
535
688
  const key = PREFIX + name;
@@ -564,6 +717,7 @@ function createSentinelStore(auth) {
564
717
  await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
565
718
  return;
566
719
  }
720
+ deleteAllExpiredRows(auth);
567
721
  await auth.adapter.create({
568
722
  model: "powChallenge",
569
723
  data: {
@@ -647,11 +801,26 @@ const NEW_PASSWORD_FIELDS = {
647
801
  * towards credential stuffing
648
802
  */
649
803
  const isAttemptPath = (path) => CREDENTIAL_SIGN_IN_PATHS.includes(path) || VERIFICATION_PATHS.includes(path);
804
+ /** Endpoints a bot is blocked on: sign-in, sign-up and password reset */
805
+ const BOT_CHECK_PATHS = [
806
+ ...CREDENTIAL_SIGN_IN_PATHS,
807
+ ...VERIFICATION_PATHS,
808
+ ...SIGN_UP_PATHS,
809
+ ...PASSWORD_RESET_PATHS
810
+ ];
650
811
  /** Account creation that isn't done by the visitor signing up */
651
812
  const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
652
813
  /** Social sign-ups, whose email the provider has already verified */
653
814
  const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
654
815
  const DAY_SECONDS = 1440 * 60;
816
+ /** Window the identification service limits per IP address count over */
817
+ const VISITOR_LIMIT_WINDOW_SECONDS = 60;
818
+ /** Calls to the identification service per IP address in the window */
819
+ const MAX_VERIFICATIONS_PER_IP = 20;
820
+ /** Visitor ids that weren't genuine per IP address in the window */
821
+ const MAX_INVALID_VISITORS_PER_IP = 5;
822
+ /** Sign-ins kept per user for impossible travel, stale and new devices */
823
+ const SIGN_IN_HISTORY_LIMIT = 50;
655
824
  /**
656
825
  * Sign-ins checked with a password, which can be repeated with a challenge
657
826
  * solution
@@ -701,7 +870,10 @@ const sentinel = (options) => {
701
870
  thresholds: security.freeTrialAbuse.thresholds,
702
871
  maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
703
872
  retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
704
- action: security.freeTrialAbuse.action ?? "block"
873
+ action: security.freeTrialAbuse.action ?? "block",
874
+ maxAccountsPerIp: security.freeTrialAbuse.maxAccountsPerIp ?? 10,
875
+ ipWindowSeconds: Math.min(Math.max(security.freeTrialAbuse.ipWindowSeconds ?? DAY_SECONDS, 1), 365 * DAY_SECONDS),
876
+ requireVerifiedVisitor: security.freeTrialAbuse.requireVerifiedVisitor ?? false
705
877
  } : null;
706
878
  const botBlocking = resolveToggle(security.botBlocking, "challenge");
707
879
  const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
@@ -735,16 +907,32 @@ const sentinel = (options) => {
735
907
  const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
736
908
  const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
737
909
  const checksAccountCreation = Boolean(freeTrialAbuse || velocity || emailValidation);
738
- /** Challenge results, so each solution is checked once per request */
739
- const challengeResults = /* @__PURE__ */ new WeakMap();
740
- /** Verified visitor ids, so each request is checked once */
741
- const visitorResults = /* @__PURE__ */ new WeakMap();
910
+ /** Whether each request's solution was accepted, so it's checked once */
911
+ const acceptedSolutions = /* @__PURE__ */ new WeakMap();
912
+ /** Each request's verified visitor, so it's checked once */
913
+ const verifiedVisitors = /* @__PURE__ */ new WeakMap();
742
914
  function clientIp(request, auth) {
743
915
  if (!request) return null;
744
916
  if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
745
917
  return getIp(request, auth.options);
746
918
  }
747
919
  /**
920
+ * Limits the calls to the identification service for one IP address: after
921
+ * too many calls, or too many ids that weren't genuine, its ids go
922
+ * unchecked and aren't trusted
923
+ */
924
+ function visitorVerificationLimit(store, ip) {
925
+ return {
926
+ async allowed() {
927
+ if (await store.get(`visitor-invalid:${ip}`) >= MAX_INVALID_VISITORS_PER_IP) return false;
928
+ return await store.increment(`visitor-verify:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS) <= MAX_VERIFICATIONS_PER_IP;
929
+ },
930
+ async rejected() {
931
+ await store.increment(`visitor-invalid:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS);
932
+ }
933
+ };
934
+ }
935
+ /**
748
936
  * The visitor id sent by the client, checked with the identification
749
937
  * service when one is configured
750
938
  */
@@ -754,22 +942,23 @@ const sentinel = (options) => {
754
942
  id: null,
755
943
  verified: false
756
944
  });
757
- const existing = visitorResults.get(headers);
758
- if (existing) return existing;
945
+ const cachedVisitor = verifiedVisitors.get(headers);
946
+ if (cachedVisitor) return cachedVisitor;
759
947
  const unchecked = {
760
948
  id: opts.failOpen ? sent : null,
761
949
  verified: false
762
950
  };
763
- const result = intel.verifyVisitor(auth, {
951
+ const store = createSentinelStore(auth);
952
+ const visitor = intel.verifyVisitor(auth, {
764
953
  visitorId: sent,
765
954
  ip,
766
955
  userAgent: headers.get("user-agent")
767
- }).then((verified) => verified === void 0 ? unchecked : {
956
+ }, ip ? visitorVerificationLimit(store, ip) : void 0).then((verified) => verified === void 0 ? unchecked : {
768
957
  id: verified,
769
958
  verified: intel.verifiesVisitors
770
959
  }).catch(() => unchecked);
771
- visitorResults.set(headers, result);
772
- return result;
960
+ verifiedVisitors.set(headers, visitor);
961
+ return visitor;
773
962
  }
774
963
  function relativePath(request, auth) {
775
964
  const pathname = new URL(request.url).pathname;
@@ -778,27 +967,28 @@ const sentinel = (options) => {
778
967
  }
779
968
  /**
780
969
  * Whether the request carries a valid, unused solution to a challenge
781
- * issued to the same IP address and visitor
970
+ * issued to the same IP address and visitor, for the same endpoint
782
971
  */
783
- function challengePassed(key, details, store, auth) {
784
- const existing = challengeResults.get(key);
785
- if (existing) return existing;
786
- const result = (async () => {
787
- const parsed = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
788
- if (!parsed) return false;
972
+ function challengePassed(key, details, store, auth, path) {
973
+ const cachedAcceptance = acceptedSolutions.get(key);
974
+ if (cachedAcceptance) return cachedAcceptance;
975
+ const accepted = (async () => {
976
+ const solution = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
977
+ if (!solution) return false;
789
978
  try {
790
- const challenge = await store.consumeChallenge(parsed.nonce);
979
+ const challenge = await store.consumeChallenge(solution.nonce);
791
980
  if (!challenge) return false;
792
981
  if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
793
982
  if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
794
- return await verifySolution(parsed.nonce, parsed.value, challenge.difficulty);
983
+ if (challenge.path && challenge.path !== path) return false;
984
+ return await verifySolution(solution.nonce, solution.value, challenge.difficulty);
795
985
  } catch (e) {
796
986
  auth.logger.error("[sentinel] Failed to verify challenge", e);
797
987
  return false;
798
988
  }
799
989
  })();
800
- challengeResults.set(key, result);
801
- return result;
990
+ acceptedSolutions.set(key, accepted);
991
+ return accepted;
802
992
  }
803
993
  /** Runs a check, applying `failOpen` when it can't complete */
804
994
  async function guard(auth, check) {
@@ -845,12 +1035,12 @@ const sentinel = (options) => {
845
1035
  visitorId: details.visitorId,
846
1036
  identifier: details.identifier,
847
1037
  userId,
848
- challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context) : false,
1038
+ challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path) : false,
849
1039
  difficulty,
850
1040
  metadata
851
1041
  }));
852
1042
  }
853
- /** The key a request's challenge result is cached under */
1043
+ /** The key a request's accepted solution is cached under */
854
1044
  function challengeKey(ctx, details) {
855
1045
  return ctx.request ?? details.headers ?? ctx;
856
1046
  }
@@ -908,6 +1098,31 @@ const sentinel = (options) => {
908
1098
  if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
909
1099
  return (await intel.lookup(auth, details)).geo;
910
1100
  }
1101
+ /** Deletes a user's sign-ins beyond the most recent ones Sentinel keeps */
1102
+ async function deleteOlderSignIns(auth, userId) {
1103
+ const olderSignIns = await auth.adapter.findMany({
1104
+ model: "sentinelSignIn",
1105
+ where: [{
1106
+ field: "userId",
1107
+ value: userId
1108
+ }],
1109
+ sortBy: {
1110
+ field: "createdAt",
1111
+ direction: "desc"
1112
+ },
1113
+ offset: SIGN_IN_HISTORY_LIMIT,
1114
+ limit: 100
1115
+ });
1116
+ if (!olderSignIns.length) return;
1117
+ await auth.adapter.deleteMany({
1118
+ model: "sentinelSignIn",
1119
+ where: [{
1120
+ field: "id",
1121
+ operator: "in",
1122
+ value: olderSignIns.map((signIn) => signIn.id)
1123
+ }]
1124
+ });
1125
+ }
911
1126
  async function lastSignIn(auth, userId) {
912
1127
  const [row] = await auth.adapter.findMany({
913
1128
  model: "sentinelSignIn",
@@ -1031,6 +1246,20 @@ const sentinel = (options) => {
1031
1246
  });
1032
1247
  }));
1033
1248
  }
1249
+ if (freeTrialAbuse) {
1250
+ if (details.ip) {
1251
+ const ip = details.ip;
1252
+ throwForDecision(await guard(ctx.context, async () => {
1253
+ const created = await store.get(`trial:ip:${ip}`);
1254
+ if (created < freeTrialAbuse.maxAccountsPerIp) return { outcome: "allow" };
1255
+ return act(event, store, details, freeTrialAbuse.action, SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "free_trial_abuse", {
1256
+ accounts: created,
1257
+ signal: "ip"
1258
+ });
1259
+ }));
1260
+ }
1261
+ if (freeTrialAbuse.requireVerifiedVisitor && !details.visitorVerified) throwForDecision(await act(event, store, details, "challenge", SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "visitor_not_verified", {}));
1262
+ }
1034
1263
  if (freeTrialAbuse && details.visitorId) {
1035
1264
  const visitorId = details.visitorId;
1036
1265
  throwForDecision(await guard(ctx.context, async () => {
@@ -1068,9 +1297,10 @@ const sentinel = (options) => {
1068
1297
  const headers = event.headers ?? event.request?.headers;
1069
1298
  const ip = clientIp(event.request ?? headers, context.context);
1070
1299
  const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
1071
- if (!visitorId) return;
1072
1300
  try {
1073
- await createSentinelStore(context.context).increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
1301
+ const store = createSentinelStore(context.context);
1302
+ if (visitorId) await store.increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
1303
+ if (ip) await store.increment(`trial:ip:${ip}`, freeTrialAbuse.ipWindowSeconds);
1074
1304
  } catch (e) {
1075
1305
  context.context.logger.error("[sentinel] Failed to track sign-up", e);
1076
1306
  }
@@ -1087,34 +1317,37 @@ const sentinel = (options) => {
1087
1317
  },
1088
1318
  async onRequest(request, ctx) {
1089
1319
  if (!botBlocking && !suspiciousIpBlocking && !geoBlocking) return;
1320
+ const path = relativePath(request, ctx);
1321
+ const checkBot = Boolean(botBlocking) && BOT_CHECK_PATHS.includes(path);
1322
+ if (!checkBot && !suspiciousIpBlocking && !geoBlocking) return;
1090
1323
  const store = createSentinelStore(ctx);
1091
1324
  const event = {
1092
1325
  context: ctx,
1093
1326
  request,
1094
1327
  headers: request.headers,
1095
- path: relativePath(request, ctx)
1328
+ path
1096
1329
  };
1097
1330
  const response = responseForDecision(await guard(ctx, async () => {
1098
1331
  const details = await requestDetails(event);
1099
1332
  const { ip } = details;
1100
- const remote = await intel.lookup(ctx, details);
1333
+ const backendVerdicts = await intel.lookup(ctx, details);
1101
1334
  if (suspiciousIpBlocking && ip) {
1102
- if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : remote.suspiciousIp) {
1103
- const result = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
1104
- if (result.outcome !== "allow") return result;
1335
+ if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : backendVerdicts.suspiciousIp) {
1336
+ const suspiciousIpDecision = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
1337
+ if (suspiciousIpDecision.outcome !== "allow") return suspiciousIpDecision;
1105
1338
  }
1106
1339
  }
1107
1340
  if (geoBlocking && ip) {
1108
- const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : remote.geo)?.country?.toUpperCase() ?? null;
1341
+ const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : backendVerdicts.geo)?.country?.toUpperCase() ?? null;
1109
1342
  if (country ? geoBlocking.allowList ? !geoBlocking.allowList.includes(country) : Boolean(geoBlocking.denyList?.includes(country)) : Boolean(geoBlocking.allowList) && !opts.failOpen) {
1110
- const result = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
1111
- if (result.outcome !== "allow") return result;
1343
+ const geoDecision = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
1344
+ if (geoDecision.outcome !== "allow") return geoDecision;
1112
1345
  }
1113
1346
  }
1114
- if (botBlocking) {
1115
- if (opts.providers?.isBot ? await opts.providers.isBot(request) : remote.bot ?? looksLikeBot(request)) {
1116
- const result = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
1117
- if (result.outcome !== "allow") return result;
1347
+ if (botBlocking && checkBot) {
1348
+ if (opts.providers?.isBot ? await opts.providers.isBot(request) : backendVerdicts.bot ?? looksLikeBot(request)) {
1349
+ const botDecision = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
1350
+ if (botDecision.outcome !== "allow") return botDecision;
1118
1351
  }
1119
1352
  }
1120
1353
  return { outcome: "allow" };
@@ -1160,7 +1393,7 @@ const sentinel = (options) => {
1160
1393
  }
1161
1394
  if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
1162
1395
  const keys = attemptKeys(details);
1163
- const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context);
1396
+ const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path);
1164
1397
  const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
1165
1398
  const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
1166
1399
  const { thresholds } = credentialStuffing;
@@ -1189,12 +1422,12 @@ const sentinel = (options) => {
1189
1422
  if (limit) {
1190
1423
  const count = await store.increment(limit.name, windowSeconds);
1191
1424
  if (count > limit.max) {
1192
- const result = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1425
+ const limitDecision = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1193
1426
  count,
1194
1427
  limit: limit.max,
1195
1428
  windowSeconds
1196
1429
  });
1197
- if (result.outcome !== "allow") return result;
1430
+ if (limitDecision.outcome !== "allow") return limitDecision;
1198
1431
  }
1199
1432
  }
1200
1433
  if (ids.length && isTracked) {
@@ -1371,6 +1604,7 @@ const sentinel = (options) => {
1371
1604
  createdAt: /* @__PURE__ */ new Date()
1372
1605
  }
1373
1606
  });
1607
+ await deleteOlderSignIns(auth, user.id);
1374
1608
  } catch (e) {
1375
1609
  if (e instanceof APIError) throw e;
1376
1610
  auth.logger.error("[sentinel] Failed to track sign-in", e);