@gatwech/auth 1.0.0-beta.20 → 1.0.0-beta.22

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 (148) 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/adapters/prisma-adapter/index.mjs +1 -1
  7. package/dist/{admin-BzUJL6tr.mjs → admin-rd-xHhnP.mjs} +1 -1
  8. package/dist/{anonymous-BjlTk4gl.mjs → anonymous-qSasVeIe.mjs} +1 -1
  9. package/dist/api/index.d.mts +2 -2
  10. package/dist/api/index.mjs +2 -2
  11. package/dist/{api-Dk8Yj_J5.mjs → api-BV65Y-Rk.mjs} +5 -0
  12. package/dist/{audit-log-DKsUWR8O.mjs → audit-log-DgqHJ-uv.mjs} +132 -63
  13. package/dist/{auth-vfCyIIeV.mjs → auth-DtOjarwu.mjs} +15 -2
  14. package/dist/client/index.d.mts +2 -2
  15. package/dist/client/plugins/index.d.mts +28 -28
  16. package/dist/client/plugins/index.mjs +2 -2
  17. package/dist/client/react/index.d.mts +2 -2
  18. package/dist/{client-4QEb0rUr.mjs → client-CMsC6-rl.mjs} +1 -1
  19. package/dist/{client-C_qmyez2.d.mts → client-DO9MgP_0.d.mts} +1 -1
  20. package/dist/{client-F67q-Lbi.d.mts → client-ukvqpcbI.d.mts} +1 -1
  21. package/dist/cookies/index.d.mts +3 -3
  22. package/dist/db/index.d.mts +2 -2
  23. package/dist/db/index.mjs +1 -1
  24. package/dist/{db-7vHnL1XA.mjs → db-CV59HpEF.mjs} +2 -2
  25. package/dist/{email-otp-Dw0DypjQ.mjs → email-otp-BmgAht1S.mjs} +1 -1
  26. package/dist/{generic-oauth-DFj27JY8.mjs → generic-oauth-C5_AXj2z.mjs} +1 -1
  27. package/dist/{haveibeenpwned-CzNda7Tn.mjs → haveibeenpwned-_3a5amT-.mjs} +1 -1
  28. package/dist/{index-D57DuhZR.d.mts → index-AiKdOFSd.d.mts} +116 -64
  29. package/dist/{index-Bml-PCqP.d.mts → index-B4hJVVRn.d.mts} +19 -19
  30. package/dist/{index-DwPAddfE.d.mts → index-BC0aAKsT.d.mts} +3 -3
  31. package/dist/{index-Df0KhQRf-BEa5tdwJ.d.mts → index-BL_t8cRp-C4QYBsac.d.mts} +56 -17
  32. package/dist/{index-DyXi7S2M.d.mts → index-BfLofiXO.d.mts} +10 -10
  33. package/dist/{index-CP8x1TFq.d.mts → index-BzzhaGLe.d.mts} +6 -6
  34. package/dist/{index-DFSRYWFI.d.mts → index-CBGNX7W1.d.mts} +6 -6
  35. package/dist/{index-CofuaNMl.d.mts → index-CRNh-5Tc.d.mts} +7 -7
  36. package/dist/{index-CNd5jqLl.d.mts → index-CRiKKdkb.d.mts} +6 -6
  37. package/dist/{index-CsxtcAO0.d.mts → index-CTSeVJJB.d.mts} +1 -1
  38. package/dist/{index-sb-UFLuo.d.mts → index-CxhQu1HM.d.mts} +2 -2
  39. package/dist/{index-Dnd8S4S4.d.mts → index-D-rjuF7p.d.mts} +9 -9
  40. package/dist/{index-XfE0WjTN.d.mts → index-D3uQsW8T.d.mts} +34 -34
  41. package/dist/{index-4I1YhYhk.d.mts → index-DKirx_GW.d.mts} +4 -4
  42. package/dist/{index-KhbNmMmG.d.mts → index-DSoiUc1C.d.mts} +167 -167
  43. package/dist/{index-DTkcY7xH.d.mts → index-Dkm27DLN.d.mts} +10 -10
  44. package/dist/{index-BsSKcKgO.d.mts → index-Dl0bD1_6.d.mts} +6 -6
  45. package/dist/{index-DGeoJ4gu.d.mts → index-DlS-HqZR.d.mts} +2 -2
  46. package/dist/{index-BNhJglHC.d.mts → index-DngQlUSI.d.mts} +114 -114
  47. package/dist/{index-CC920PPp.d.mts → index-Do8AV4PF.d.mts} +14 -14
  48. package/dist/{index-CBiGsvDd.d.mts → index-SYRh5FW7.d.mts} +8 -8
  49. package/dist/{index-BX7rl07Z.d.mts → index-eOfQxlQV.d.mts} +13 -13
  50. package/dist/{index-D6ku0rUC.d.mts → index-hP8gvyp-.d.mts} +5 -5
  51. package/dist/{index-CzpdkTdv.d.mts → index-pdl9MzQu.d.mts} +9 -9
  52. package/dist/index.d.mts +2 -2
  53. package/dist/index.mjs +4 -4
  54. package/dist/integrations/next-js.d.mts +4 -4
  55. package/dist/integrations/node.d.mts +2 -2
  56. package/dist/integrations/react-start.d.mts +4 -4
  57. package/dist/{jwt-DBN2mu0r.mjs → jwt-Czv-v097.mjs} +1 -1
  58. package/dist/{kysely-adapter-Bkm1liPW.mjs → kysely-adapter-BidlNTiV.mjs} +4 -2
  59. package/dist/{magic-link-D9Em1aOR.mjs → magic-link-CJgE0Ngn.mjs} +1 -1
  60. package/dist/{multi-session-Dno_8DxC.mjs → multi-session-bveCXtQW.mjs} +1 -1
  61. package/dist/{oauth-proxy-DW-zt8wv.mjs → oauth-proxy-69n1xA8L.mjs} +1 -1
  62. package/dist/oauth2/index.d.mts +2 -2
  63. package/dist/oauth2/index.mjs +2 -2
  64. package/dist/{one-tap-CodeyQb8.mjs → one-tap-Cm1eHuqi.mjs} +1 -1
  65. package/dist/{organization-CkTKs7bx.mjs → organization-BK3ks97y.mjs} +2 -2
  66. package/dist/plugins/admin/index.d.mts +3 -3
  67. package/dist/plugins/admin/index.mjs +3 -3
  68. package/dist/plugins/anonymous/index.d.mts +3 -3
  69. package/dist/plugins/anonymous/index.mjs +3 -3
  70. package/dist/plugins/audit-log/client.d.mts +24 -24
  71. package/dist/plugins/audit-log/index.d.mts +23 -23
  72. package/dist/plugins/audit-log/index.mjs +3 -3
  73. package/dist/plugins/bearer/index.d.mts +1 -1
  74. package/dist/plugins/captcha/index.d.mts +1 -1
  75. package/dist/plugins/custom-session/index.d.mts +3 -3
  76. package/dist/plugins/custom-session/index.mjs +2 -2
  77. package/dist/plugins/device-authorization/index.d.mts +1 -1
  78. package/dist/plugins/device-authorization/index.mjs +1 -1
  79. package/dist/plugins/email-otp/index.d.mts +2 -2
  80. package/dist/plugins/email-otp/index.mjs +3 -3
  81. package/dist/plugins/generic-oauth/index.d.mts +3 -3
  82. package/dist/plugins/generic-oauth/index.mjs +3 -3
  83. package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
  84. package/dist/plugins/haveibeenpwned/index.mjs +3 -3
  85. package/dist/plugins/index.d.mts +23 -23
  86. package/dist/plugins/index.mjs +18 -18
  87. package/dist/plugins/jwt/index.d.mts +3 -3
  88. package/dist/plugins/jwt/index.mjs +3 -3
  89. package/dist/plugins/magic-link/index.d.mts +1 -1
  90. package/dist/plugins/magic-link/index.mjs +3 -3
  91. package/dist/plugins/multi-session/index.d.mts +2 -2
  92. package/dist/plugins/multi-session/index.mjs +3 -3
  93. package/dist/plugins/oauth-proxy/index.d.mts +1 -1
  94. package/dist/plugins/oauth-proxy/index.mjs +3 -3
  95. package/dist/plugins/one-tap/index.d.mts +1 -1
  96. package/dist/plugins/one-tap/index.mjs +3 -3
  97. package/dist/plugins/one-time-token/index.d.mts +3 -3
  98. package/dist/plugins/one-time-token/index.mjs +18 -18
  99. package/dist/plugins/organization/index.d.mts +3 -3
  100. package/dist/plugins/organization/index.mjs +3 -3
  101. package/dist/plugins/passkey/index.d.mts +3 -3
  102. package/dist/plugins/passkey/index.mjs +2 -2
  103. package/dist/plugins/phone-number/index.d.mts +3 -3
  104. package/dist/plugins/phone-number/index.mjs +2 -2
  105. package/dist/plugins/sentinel/client.d.mts +24 -24
  106. package/dist/plugins/sentinel/client.mjs +2 -2
  107. package/dist/plugins/sentinel/index.d.mts +23 -23
  108. package/dist/plugins/sentinel/index.mjs +5 -5
  109. package/dist/plugins/two-factor/index.d.mts +3 -3
  110. package/dist/plugins/two-factor/index.mjs +2 -2
  111. package/dist/plugins/username/index.d.mts +1 -1
  112. package/dist/plugins/username/index.mjs +3 -3
  113. package/dist/{plugins-yF8CFUDR.mjs → plugins-RSPSU3ro.mjs} +1 -1
  114. package/dist/{pow-OK01yPG3.mjs → pow-DvkUopsK.mjs} +17 -1
  115. package/dist/{sentinel-DiPqydKG.mjs → sentinel-B-2jmmZh.mjs} +325 -98
  116. package/dist/src/adapters/memory-adapter/memory-adapter.d.ts +1 -1
  117. package/dist/src/adapters/prisma-adapter/test/auth.d.ts +1 -1
  118. package/dist/src/adapters/tests/auth-flow.d.ts +3 -3
  119. package/dist/src/adapters/tests/normal.d.ts +10 -2
  120. package/dist/src/adapters/tests/number-id.d.ts +4 -4
  121. package/dist/src/adapters/tests/performance.d.ts +3 -3
  122. package/dist/src/adapters/tests/transactions.d.ts +3 -3
  123. package/dist/src/api/index.d.ts +8 -8
  124. package/dist/src/plugins/admin/admin.d.ts +2 -2
  125. package/dist/src/plugins/api-key/index.d.ts +2 -2
  126. package/dist/src/plugins/audit-log/index.d.ts +5 -14
  127. package/dist/src/plugins/audit-log/remote.d.ts +4 -4
  128. package/dist/src/plugins/audit-log/types.d.ts +22 -0
  129. package/dist/src/plugins/email-otp/index.d.ts +1 -1
  130. package/dist/src/plugins/multi-session/index.d.ts +8 -8
  131. package/dist/src/plugins/organization/routes/crud-access-control.d.ts +8 -8
  132. package/dist/src/plugins/organization/routes/crud-invites.d.ts +46 -46
  133. package/dist/src/plugins/organization/routes/crud-members.d.ts +40 -40
  134. package/dist/src/plugins/organization/routes/crud-org.d.ts +36 -36
  135. package/dist/src/plugins/organization/routes/crud-team.d.ts +50 -50
  136. package/dist/src/plugins/sentinel/geo.d.ts +11 -0
  137. package/dist/src/plugins/sentinel/index.d.ts +7 -0
  138. package/dist/src/plugins/sentinel/intel.d.ts +8 -1
  139. package/dist/src/plugins/sentinel/memory-cache.d.ts +12 -0
  140. package/dist/src/plugins/sentinel/schema.d.ts +4 -0
  141. package/dist/src/plugins/sentinel/store.d.ts +7 -0
  142. package/dist/src/plugins/sentinel/types.d.ts +29 -1
  143. package/dist/src/test-utils/test-instance.d.ts +25 -25
  144. package/dist/test-utils/index.d.mts +277 -277
  145. package/dist/test-utils/index.mjs +20 -20
  146. package/dist/types/index.d.mts +2 -2
  147. package/dist/{username-BHNvcvsY.mjs → username-DCp6yugu.mjs} +1 -1
  148. package/package.json +3 -3
@@ -3,8 +3,8 @@ import { t as safeJSONParse } from "./json-D0TBLy4q.mjs";
3
3
  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
- 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";
6
+ import { t as getPasswordBreachCount } from "./haveibeenpwned-_3a5amT-.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)
@@ -203,15 +228,15 @@ const isMissingRecord = (e) => {
203
228
  };
204
229
  /** Whether a domain has an A or AAAA record, null when a lookup failed */
205
230
  async function hasAddressRecords(dns, domain) {
206
- const results = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
207
- if (results.some((r) => r.status === "fulfilled" && r.value.length > 0)) return true;
208
- return results.some((r) => r.status === "rejected" && !isMissingRecord(r.reason)) ? null : false;
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;
209
234
  }
210
- async function lookupMail(dns, domain) {
235
+ async function canReceiveMail(dns, domain) {
211
236
  try {
212
- const records = await dns.resolveMx(domain);
213
- if (records.length === 1 && (records[0].exchange === "" || records[0].exchange === ".")) return false;
214
- if (records.length > 0) return true;
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;
215
240
  } catch (e) {
216
241
  const code = e.code;
217
242
  if (code === "ENOTFOUND" || code === "ENOTIMP") return false;
@@ -239,7 +264,7 @@ async function hasMxRecords(domain, options = {}) {
239
264
  timer = setTimeout(() => resolve(null), options.timeoutMs ?? MAIL_LOOKUP_TIMEOUT_MS);
240
265
  });
241
266
  try {
242
- return await Promise.race([lookupMail(dns, domain), timeout]);
267
+ return await Promise.race([canReceiveMail(dns, domain), timeout]);
243
268
  } finally {
244
269
  clearTimeout(timer);
245
270
  }
@@ -260,6 +285,28 @@ function looksGenerated(email) {
260
285
  //#region src/plugins/sentinel/geo.ts
261
286
  const EARTH_RADIUS_KM = 6371;
262
287
  const toRadians = (degrees) => degrees * Math.PI / 180;
288
+ /** Stored coordinates are whole numbers of millionths of a degree */
289
+ const COORDINATE_SCALE = 1e6;
290
+ /** The largest whole-degree value, which tells an older, unscaled row apart */
291
+ const MAX_DEGREES = 360;
292
+ /**
293
+ * A coordinate as stored. The database column is an integer (the schema has
294
+ * no float type), so degrees are scaled to millionths of a degree, which is
295
+ * about 11 cm.
296
+ */
297
+ function toStoredCoordinate(degrees) {
298
+ if (degrees == null) return null;
299
+ const scaled = Math.round(degrees * COORDINATE_SCALE);
300
+ return scaled !== 0 && Math.abs(scaled) <= MAX_DEGREES ? Math.sign(scaled) * (MAX_DEGREES + 1) : scaled;
301
+ }
302
+ /**
303
+ * A stored coordinate in degrees. Rows written before coordinates were scaled
304
+ * hold whole degrees, which are never more than 360 in size.
305
+ */
306
+ function fromStoredCoordinate(stored) {
307
+ if (stored == null) return null;
308
+ return Math.abs(stored) <= MAX_DEGREES ? stored : stored / COORDINATE_SCALE;
309
+ }
263
310
  /** Great-circle distance between two coordinates, in kilometres */
264
311
  function haversineDistanceKm(from, to) {
265
312
  const dLat = toRadians(to.latitude - from.latitude);
@@ -278,6 +325,41 @@ function travelSpeedKmh(from, to) {
278
325
  return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
279
326
  }
280
327
 
328
+ //#endregion
329
+ //#region src/plugins/sentinel/memory-cache.ts
330
+ /**
331
+ * A small in-memory cache that forgets entries after a TTL and drops the
332
+ * least recently used one when it's full. Used when there's no secondary
333
+ * storage to share verdicts through.
334
+ */
335
+ function createMemoryCache(options) {
336
+ const entries = /* @__PURE__ */ new Map();
337
+ return {
338
+ get(key) {
339
+ const entry = entries.get(key);
340
+ if (!entry) return void 0;
341
+ if (entry.expiresAt <= Date.now()) {
342
+ entries.delete(key);
343
+ return;
344
+ }
345
+ entries.delete(key);
346
+ entries.set(key, entry);
347
+ return entry.value;
348
+ },
349
+ set(key, value, ttlMs = options.ttlMs) {
350
+ entries.delete(key);
351
+ if (entries.size >= options.maxSize) {
352
+ const oldest = entries.keys().next().value;
353
+ if (oldest !== void 0) entries.delete(oldest);
354
+ }
355
+ entries.set(key, {
356
+ value,
357
+ expiresAt: Date.now() + ttlMs
358
+ });
359
+ }
360
+ };
361
+ }
362
+
281
363
  //#endregion
282
364
  //#region src/plugins/sentinel/intel.ts
283
365
  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;
@@ -296,9 +378,18 @@ const UNKNOWN = {
296
378
  bot: null,
297
379
  geo: null
298
380
  };
381
+ /** Verdicts kept in memory when there's no secondary storage */
382
+ const MEMORY_CACHE_SIZE = 5e3;
383
+ /** How long a verified visitor id is remembered */
384
+ const VISITOR_CACHE_SECONDS = 300;
385
+ /** How long an in-memory request verdict lasts when the backend sets none */
386
+ const DEFAULT_INTEL_CACHE_SECONDS = 60;
387
+ /** The longest an in-memory request verdict lasts */
388
+ const MAX_INTEL_CACHE_SECONDS = 300;
299
389
  /**
300
390
  * Asks the hosted backend about a request's IP address and client. Verdicts
301
- * are cached in secondary storage for as long as the backend allows.
391
+ * are cached for as long as the backend allows, in secondary storage or, when
392
+ * there is none, in memory.
302
393
  * Returns null verdicts when the backend isn't configured or can't answer.
303
394
  */
304
395
  function createIntelClient(options) {
@@ -312,15 +403,26 @@ function createIntelClient(options) {
312
403
  baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
313
404
  maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
314
405
  };
406
+ const visitorVerdicts = createMemoryCache({
407
+ maxSize: MEMORY_CACHE_SIZE,
408
+ ttlMs: VISITOR_CACHE_SECONDS * 1e3
409
+ });
410
+ const requestVerdicts = createMemoryCache({
411
+ maxSize: MEMORY_CACHE_SIZE,
412
+ ttlMs: DEFAULT_INTEL_CACHE_SECONDS * 1e3
413
+ });
315
414
  return {
316
415
  configured,
317
416
  verifiesVisitors: Boolean(kvUrl && apiKey),
318
417
  misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
319
- async verifyVisitor(auth, request) {
418
+ async verifyVisitor(auth, request, limit) {
320
419
  if (!kvUrl || !apiKey) return request.visitorId;
321
420
  const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
322
421
  const cached = await auth.secondaryStorage?.get(cacheKey);
323
422
  if (typeof cached === "string") return cached || null;
423
+ const remembered = visitorVerdicts.get(cacheKey);
424
+ if (remembered !== void 0 && !auth.secondaryStorage) return remembered;
425
+ if (limit && !await limit.allowed()) return null;
324
426
  for (let attempt = 0; attempt <= kv.attempts; attempt++) {
325
427
  if (attempt > 0) {
326
428
  const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
@@ -340,7 +442,9 @@ function createIntelClient(options) {
340
442
  }));
341
443
  if (data && !error) {
342
444
  const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
343
- await auth.secondaryStorage?.set(cacheKey, visitorId ?? "", 300);
445
+ if (!visitorId) await limit?.rejected();
446
+ if (auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, visitorId ?? "", VISITOR_CACHE_SECONDS);
447
+ else visitorVerdicts.set(cacheKey, visitorId);
344
448
  return visitorId;
345
449
  }
346
450
  }
@@ -352,6 +456,8 @@ function createIntelClient(options) {
352
456
  const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
353
457
  const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
354
458
  if (cached) return cached;
459
+ const remembered = auth.secondaryStorage ? void 0 : requestVerdicts.get(cacheKey);
460
+ if (remembered) return remembered;
355
461
  const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
356
462
  method: "POST",
357
463
  headers: {
@@ -378,7 +484,12 @@ function createIntelClient(options) {
378
484
  bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
379
485
  geo: data.geo ?? null
380
486
  };
381
- if (data.ttlSeconds && auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
487
+ if (auth.secondaryStorage) {
488
+ if (data.ttlSeconds) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
489
+ } else {
490
+ const ttlSeconds = Math.min(data.ttlSeconds ?? DEFAULT_INTEL_CACHE_SECONDS, MAX_INTEL_CACHE_SECONDS);
491
+ requestVerdicts.set(cacheKey, intel, ttlSeconds * 1e3);
492
+ }
382
493
  return intel;
383
494
  }
384
495
  };
@@ -466,6 +577,10 @@ const schema = {
466
577
  type: "string",
467
578
  required: true
468
579
  },
580
+ path: {
581
+ type: "string",
582
+ required: false
583
+ },
469
584
  ipAddress: {
470
585
  type: "string",
471
586
  required: false
@@ -488,6 +603,35 @@ const schema = {
488
603
  //#endregion
489
604
  //#region src/plugins/sentinel/store.ts
490
605
  const PREFIX = "sentinel:";
606
+ /** How long after expiring a row is deleted */
607
+ const EXPIRED_ROW_GRACE_MS = 6e4;
608
+ /** Tries to increment a counter in the database before giving up */
609
+ const MAX_COUNTER_ATTEMPTS = 20;
610
+ /** When each auth instance last deleted expired rows, keyed by its adapter */
611
+ const lastChecked = /* @__PURE__ */ new WeakMap();
612
+ /**
613
+ * Deletes expired counters and challenges from the database, at most every
614
+ * 10 seconds per auth instance. Secondary storage expires them itself.
615
+ */
616
+ async function deleteAllExpiredRows(auth, byPassLastCheckTime = false) {
617
+ if (auth.secondaryStorage) return;
618
+ const checkedAt = lastChecked.get(auth.adapter);
619
+ if (checkedAt && !byPassLastCheckTime) {
620
+ if (Date.now() - checkedAt.getTime() < 1e4) return;
621
+ }
622
+ lastChecked.set(auth.adapter, /* @__PURE__ */ new Date());
623
+ const expiredBefore = new Date(Date.now() - EXPIRED_ROW_GRACE_MS);
624
+ await Promise.all(["sentinelCounter", "powChallenge"].map((model) => auth.adapter.deleteMany({
625
+ model,
626
+ where: [{
627
+ field: "expiresAt",
628
+ operator: "lt",
629
+ value: expiredBefore
630
+ }]
631
+ }))).catch((error) => {
632
+ auth.logger.error("[sentinel] Failed to delete expired rows:", error);
633
+ });
634
+ }
491
635
  /**
492
636
  * Counters and challenges, kept in secondary storage when it's configured
493
637
  * and in the database otherwise
@@ -522,45 +666,45 @@ function createSentinelStore(auth) {
522
666
  }), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
523
667
  return count;
524
668
  }
525
- let row = await findCounter(key);
526
- if (!row) {
527
- if (await auth.adapter.create({
528
- model: "sentinelCounter",
529
- data: {
530
- key,
531
- count: 1,
532
- expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
533
- }
534
- }).then(() => true, () => false)) return 1;
535
- row = await findCounter(key);
536
- if (!row) throw new Error(`Failed to create counter ${key}`);
537
- }
538
- if (new Date(row.expiresAt).getTime() <= Date.now()) {
539
- await auth.adapter.update({
669
+ deleteAllExpiredRows(auth);
670
+ for (let attempt = 0; attempt < MAX_COUNTER_ATTEMPTS; attempt++) {
671
+ const row = await findCounter(key);
672
+ if (!row) {
673
+ if (await auth.adapter.create({
674
+ model: "sentinelCounter",
675
+ data: {
676
+ key,
677
+ count: 1,
678
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
679
+ }
680
+ }).then(() => true, () => false)) return 1;
681
+ continue;
682
+ }
683
+ const expiresAt = new Date(row.expiresAt);
684
+ const expired = expiresAt.getTime() <= Date.now();
685
+ if (await auth.adapter.updateMany({
540
686
  model: "sentinelCounter",
541
- where: [{
542
- field: "id",
543
- value: row.id
544
- }],
687
+ where: [
688
+ {
689
+ field: "id",
690
+ value: row.id
691
+ },
692
+ {
693
+ field: "count",
694
+ value: row.count
695
+ },
696
+ {
697
+ field: "expiresAt",
698
+ value: expiresAt
699
+ }
700
+ ],
545
701
  update: {
546
- count: 1,
547
- expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
702
+ count: expired ? 1 : row.count + 1,
703
+ ...expired || options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
548
704
  }
549
- });
550
- return 1;
705
+ }) > 0) return expired ? 1 : row.count + 1;
551
706
  }
552
- await auth.adapter.update({
553
- model: "sentinelCounter",
554
- where: [{
555
- field: "id",
556
- value: row.id
557
- }],
558
- update: {
559
- count: row.count + 1,
560
- ...options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
561
- }
562
- });
563
- return row.count + 1;
707
+ throw new Error(`Failed to increment counter ${key}`);
564
708
  },
565
709
  async get(name) {
566
710
  const key = PREFIX + name;
@@ -595,6 +739,7 @@ function createSentinelStore(auth) {
595
739
  await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
596
740
  return;
597
741
  }
742
+ deleteAllExpiredRows(auth);
598
743
  await auth.adapter.create({
599
744
  model: "powChallenge",
600
745
  data: {
@@ -678,11 +823,26 @@ const NEW_PASSWORD_FIELDS = {
678
823
  * towards credential stuffing
679
824
  */
680
825
  const isAttemptPath = (path) => CREDENTIAL_SIGN_IN_PATHS.includes(path) || VERIFICATION_PATHS.includes(path);
826
+ /** Endpoints a bot is blocked on: sign-in, sign-up and password reset */
827
+ const BOT_CHECK_PATHS = [
828
+ ...CREDENTIAL_SIGN_IN_PATHS,
829
+ ...VERIFICATION_PATHS,
830
+ ...SIGN_UP_PATHS,
831
+ ...PASSWORD_RESET_PATHS
832
+ ];
681
833
  /** Account creation that isn't done by the visitor signing up */
682
834
  const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
683
835
  /** Social sign-ups, whose email the provider has already verified */
684
836
  const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
685
837
  const DAY_SECONDS = 1440 * 60;
838
+ /** Window the identification service limits per IP address count over */
839
+ const VISITOR_LIMIT_WINDOW_SECONDS = 60;
840
+ /** Calls to the identification service per IP address in the window */
841
+ const MAX_VERIFICATIONS_PER_IP = 20;
842
+ /** Visitor ids that weren't genuine per IP address in the window */
843
+ const MAX_INVALID_VISITORS_PER_IP = 5;
844
+ /** Sign-ins kept per user for impossible travel, stale and new devices */
845
+ const SIGN_IN_HISTORY_LIMIT = 50;
686
846
  /**
687
847
  * Sign-ins checked with a password, which can be repeated with a challenge
688
848
  * solution
@@ -732,7 +892,10 @@ const sentinel = (options) => {
732
892
  thresholds: security.freeTrialAbuse.thresholds,
733
893
  maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
734
894
  retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
735
- action: security.freeTrialAbuse.action ?? "block"
895
+ action: security.freeTrialAbuse.action ?? "block",
896
+ maxAccountsPerIp: security.freeTrialAbuse.maxAccountsPerIp ?? 10,
897
+ ipWindowSeconds: Math.min(Math.max(security.freeTrialAbuse.ipWindowSeconds ?? DAY_SECONDS, 1), 365 * DAY_SECONDS),
898
+ requireVerifiedVisitor: security.freeTrialAbuse.requireVerifiedVisitor ?? false
736
899
  } : null;
737
900
  const botBlocking = resolveToggle(security.botBlocking, "challenge");
738
901
  const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
@@ -766,16 +929,32 @@ const sentinel = (options) => {
766
929
  const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
767
930
  const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
768
931
  const checksAccountCreation = Boolean(freeTrialAbuse || velocity || emailValidation);
769
- /** Challenge results, so each solution is checked once per request */
770
- const challengeResults = /* @__PURE__ */ new WeakMap();
771
- /** Verified visitor ids, so each request is checked once */
772
- const visitorResults = /* @__PURE__ */ new WeakMap();
932
+ /** Whether each request's solution was accepted, so it's checked once */
933
+ const acceptedSolutions = /* @__PURE__ */ new WeakMap();
934
+ /** Each request's verified visitor, so it's checked once */
935
+ const verifiedVisitors = /* @__PURE__ */ new WeakMap();
773
936
  function clientIp(request, auth) {
774
937
  if (!request) return null;
775
938
  if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
776
939
  return getIp(request, auth.options);
777
940
  }
778
941
  /**
942
+ * Limits the calls to the identification service for one IP address: after
943
+ * too many calls, or too many ids that weren't genuine, its ids go
944
+ * unchecked and aren't trusted
945
+ */
946
+ function visitorVerificationLimit(store, ip) {
947
+ return {
948
+ async allowed() {
949
+ if (await store.get(`visitor-invalid:${ip}`) >= MAX_INVALID_VISITORS_PER_IP) return false;
950
+ return await store.increment(`visitor-verify:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS) <= MAX_VERIFICATIONS_PER_IP;
951
+ },
952
+ async rejected() {
953
+ await store.increment(`visitor-invalid:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS);
954
+ }
955
+ };
956
+ }
957
+ /**
779
958
  * The visitor id sent by the client, checked with the identification
780
959
  * service when one is configured
781
960
  */
@@ -785,22 +964,23 @@ const sentinel = (options) => {
785
964
  id: null,
786
965
  verified: false
787
966
  });
788
- const existing = visitorResults.get(headers);
789
- if (existing) return existing;
967
+ const cachedVisitor = verifiedVisitors.get(headers);
968
+ if (cachedVisitor) return cachedVisitor;
790
969
  const unchecked = {
791
970
  id: opts.failOpen ? sent : null,
792
971
  verified: false
793
972
  };
794
- const result = intel.verifyVisitor(auth, {
973
+ const store = createSentinelStore(auth);
974
+ const visitor = intel.verifyVisitor(auth, {
795
975
  visitorId: sent,
796
976
  ip,
797
977
  userAgent: headers.get("user-agent")
798
- }).then((verified) => verified === void 0 ? unchecked : {
978
+ }, ip ? visitorVerificationLimit(store, ip) : void 0).then((verified) => verified === void 0 ? unchecked : {
799
979
  id: verified,
800
980
  verified: intel.verifiesVisitors
801
981
  }).catch(() => unchecked);
802
- visitorResults.set(headers, result);
803
- return result;
982
+ verifiedVisitors.set(headers, visitor);
983
+ return visitor;
804
984
  }
805
985
  function relativePath(request, auth) {
806
986
  const pathname = new URL(request.url).pathname;
@@ -809,27 +989,28 @@ const sentinel = (options) => {
809
989
  }
810
990
  /**
811
991
  * Whether the request carries a valid, unused solution to a challenge
812
- * issued to the same IP address and visitor
992
+ * issued to the same IP address and visitor, for the same endpoint
813
993
  */
814
- function challengePassed(key, details, store, auth) {
815
- const existing = challengeResults.get(key);
816
- if (existing) return existing;
817
- const result = (async () => {
818
- const parsed = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
819
- if (!parsed) return false;
994
+ function challengePassed(key, details, store, auth, path) {
995
+ const cachedAcceptance = acceptedSolutions.get(key);
996
+ if (cachedAcceptance) return cachedAcceptance;
997
+ const accepted = (async () => {
998
+ const solution = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
999
+ if (!solution) return false;
820
1000
  try {
821
- const challenge = await store.consumeChallenge(parsed.nonce);
1001
+ const challenge = await store.consumeChallenge(solution.nonce);
822
1002
  if (!challenge) return false;
823
1003
  if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
824
1004
  if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
825
- return await verifySolution(parsed.nonce, parsed.value, challenge.difficulty);
1005
+ if (challenge.path && challenge.path !== path) return false;
1006
+ return await verifySolution(solution.nonce, solution.value, challenge.difficulty);
826
1007
  } catch (e) {
827
1008
  auth.logger.error("[sentinel] Failed to verify challenge", e);
828
1009
  return false;
829
1010
  }
830
1011
  })();
831
- challengeResults.set(key, result);
832
- return result;
1012
+ acceptedSolutions.set(key, accepted);
1013
+ return accepted;
833
1014
  }
834
1015
  /** Runs a check, applying `failOpen` when it can't complete */
835
1016
  async function guard(auth, check) {
@@ -876,12 +1057,12 @@ const sentinel = (options) => {
876
1057
  visitorId: details.visitorId,
877
1058
  identifier: details.identifier,
878
1059
  userId,
879
- challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context) : false,
1060
+ challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path) : false,
880
1061
  difficulty,
881
1062
  metadata
882
1063
  }));
883
1064
  }
884
- /** The key a request's challenge result is cached under */
1065
+ /** The key a request's accepted solution is cached under */
885
1066
  function challengeKey(ctx, details) {
886
1067
  return ctx.request ?? details.headers ?? ctx;
887
1068
  }
@@ -939,6 +1120,31 @@ const sentinel = (options) => {
939
1120
  if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
940
1121
  return (await intel.lookup(auth, details)).geo;
941
1122
  }
1123
+ /** Deletes a user's sign-ins beyond the most recent ones Sentinel keeps */
1124
+ async function deleteOlderSignIns(auth, userId) {
1125
+ const olderSignIns = await auth.adapter.findMany({
1126
+ model: "sentinelSignIn",
1127
+ where: [{
1128
+ field: "userId",
1129
+ value: userId
1130
+ }],
1131
+ sortBy: {
1132
+ field: "createdAt",
1133
+ direction: "desc"
1134
+ },
1135
+ offset: SIGN_IN_HISTORY_LIMIT,
1136
+ limit: 100
1137
+ });
1138
+ if (!olderSignIns.length) return;
1139
+ await auth.adapter.deleteMany({
1140
+ model: "sentinelSignIn",
1141
+ where: [{
1142
+ field: "id",
1143
+ operator: "in",
1144
+ value: olderSignIns.map((signIn) => signIn.id)
1145
+ }]
1146
+ });
1147
+ }
942
1148
  async function lastSignIn(auth, userId) {
943
1149
  const [row] = await auth.adapter.findMany({
944
1150
  model: "sentinelSignIn",
@@ -959,10 +1165,12 @@ const sentinel = (options) => {
959
1165
  }
960
1166
  /** The travel speed from the previous sign-in, when both have coordinates */
961
1167
  function travel(previous, geo) {
962
- if (previous?.latitude == null || previous.longitude == null || geo?.latitude == null || geo.longitude == null) return null;
1168
+ const previousLatitude = fromStoredCoordinate(previous?.latitude);
1169
+ const previousLongitude = fromStoredCoordinate(previous?.longitude);
1170
+ if (!previous || previousLatitude == null || previousLongitude == null || geo?.latitude == null || geo.longitude == null) return null;
963
1171
  const speedKmh = travelSpeedKmh({
964
- latitude: previous.latitude,
965
- longitude: previous.longitude,
1172
+ latitude: previousLatitude,
1173
+ longitude: previousLongitude,
966
1174
  at: new Date(previous.createdAt)
967
1175
  }, {
968
1176
  latitude: geo.latitude,
@@ -1062,6 +1270,20 @@ const sentinel = (options) => {
1062
1270
  });
1063
1271
  }));
1064
1272
  }
1273
+ if (freeTrialAbuse) {
1274
+ if (details.ip) {
1275
+ const ip = details.ip;
1276
+ throwForDecision(await guard(ctx.context, async () => {
1277
+ const created = await store.get(`trial:ip:${ip}`);
1278
+ if (created < freeTrialAbuse.maxAccountsPerIp) return { outcome: "allow" };
1279
+ return act(event, store, details, freeTrialAbuse.action, SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "free_trial_abuse", {
1280
+ accounts: created,
1281
+ signal: "ip"
1282
+ });
1283
+ }));
1284
+ }
1285
+ if (freeTrialAbuse.requireVerifiedVisitor && !details.visitorVerified) throwForDecision(await act(event, store, details, "challenge", SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "visitor_not_verified", {}));
1286
+ }
1065
1287
  if (freeTrialAbuse && details.visitorId) {
1066
1288
  const visitorId = details.visitorId;
1067
1289
  throwForDecision(await guard(ctx.context, async () => {
@@ -1099,9 +1321,10 @@ const sentinel = (options) => {
1099
1321
  const headers = event.headers ?? event.request?.headers;
1100
1322
  const ip = clientIp(event.request ?? headers, context.context);
1101
1323
  const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
1102
- if (!visitorId) return;
1103
1324
  try {
1104
- await createSentinelStore(context.context).increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
1325
+ const store = createSentinelStore(context.context);
1326
+ if (visitorId) await store.increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
1327
+ if (ip) await store.increment(`trial:ip:${ip}`, freeTrialAbuse.ipWindowSeconds);
1105
1328
  } catch (e) {
1106
1329
  context.context.logger.error("[sentinel] Failed to track sign-up", e);
1107
1330
  }
@@ -1118,34 +1341,37 @@ const sentinel = (options) => {
1118
1341
  },
1119
1342
  async onRequest(request, ctx) {
1120
1343
  if (!botBlocking && !suspiciousIpBlocking && !geoBlocking) return;
1344
+ const path = relativePath(request, ctx);
1345
+ const checkBot = Boolean(botBlocking) && BOT_CHECK_PATHS.includes(path);
1346
+ if (!checkBot && !suspiciousIpBlocking && !geoBlocking) return;
1121
1347
  const store = createSentinelStore(ctx);
1122
1348
  const event = {
1123
1349
  context: ctx,
1124
1350
  request,
1125
1351
  headers: request.headers,
1126
- path: relativePath(request, ctx)
1352
+ path
1127
1353
  };
1128
1354
  const response = responseForDecision(await guard(ctx, async () => {
1129
1355
  const details = await requestDetails(event);
1130
1356
  const { ip } = details;
1131
- const remote = await intel.lookup(ctx, details);
1357
+ const backendVerdicts = await intel.lookup(ctx, details);
1132
1358
  if (suspiciousIpBlocking && ip) {
1133
- if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : remote.suspiciousIp) {
1134
- const result = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
1135
- if (result.outcome !== "allow") return result;
1359
+ if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : backendVerdicts.suspiciousIp) {
1360
+ const suspiciousIpDecision = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
1361
+ if (suspiciousIpDecision.outcome !== "allow") return suspiciousIpDecision;
1136
1362
  }
1137
1363
  }
1138
1364
  if (geoBlocking && ip) {
1139
- const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : remote.geo)?.country?.toUpperCase() ?? null;
1365
+ const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : backendVerdicts.geo)?.country?.toUpperCase() ?? null;
1140
1366
  if (country ? geoBlocking.allowList ? !geoBlocking.allowList.includes(country) : Boolean(geoBlocking.denyList?.includes(country)) : Boolean(geoBlocking.allowList) && !opts.failOpen) {
1141
- const result = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
1142
- if (result.outcome !== "allow") return result;
1367
+ const geoDecision = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
1368
+ if (geoDecision.outcome !== "allow") return geoDecision;
1143
1369
  }
1144
1370
  }
1145
- if (botBlocking) {
1146
- if (opts.providers?.isBot ? await opts.providers.isBot(request) : remote.bot ?? looksLikeBot(request)) {
1147
- const result = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
1148
- if (result.outcome !== "allow") return result;
1371
+ if (botBlocking && checkBot) {
1372
+ if (opts.providers?.isBot ? await opts.providers.isBot(request) : backendVerdicts.bot ?? looksLikeBot(request)) {
1373
+ const botDecision = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
1374
+ if (botDecision.outcome !== "allow") return botDecision;
1149
1375
  }
1150
1376
  }
1151
1377
  return { outcome: "allow" };
@@ -1191,7 +1417,7 @@ const sentinel = (options) => {
1191
1417
  }
1192
1418
  if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
1193
1419
  const keys = attemptKeys(details);
1194
- const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context);
1420
+ const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path);
1195
1421
  const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
1196
1422
  const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
1197
1423
  const { thresholds } = credentialStuffing;
@@ -1220,12 +1446,12 @@ const sentinel = (options) => {
1220
1446
  if (limit) {
1221
1447
  const count = await store.increment(limit.name, windowSeconds);
1222
1448
  if (count > limit.max) {
1223
- const result = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1449
+ const limitDecision = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1224
1450
  count,
1225
1451
  limit: limit.max,
1226
1452
  windowSeconds
1227
1453
  });
1228
- if (result.outcome !== "allow") return result;
1454
+ if (limitDecision.outcome !== "allow") return limitDecision;
1229
1455
  }
1230
1456
  }
1231
1457
  if (ids.length && isTracked) {
@@ -1397,11 +1623,12 @@ const sentinel = (options) => {
1397
1623
  userAgent: details.userAgent,
1398
1624
  country: geo?.country ?? null,
1399
1625
  city: geo?.city ?? null,
1400
- latitude: geo?.latitude ?? null,
1401
- longitude: geo?.longitude ?? null,
1626
+ latitude: toStoredCoordinate(geo?.latitude),
1627
+ longitude: toStoredCoordinate(geo?.longitude),
1402
1628
  createdAt: /* @__PURE__ */ new Date()
1403
1629
  }
1404
1630
  });
1631
+ await deleteOlderSignIns(auth, user.id);
1405
1632
  } catch (e) {
1406
1633
  if (e instanceof APIError) throw e;
1407
1634
  auth.logger.error("[sentinel] Failed to track sign-in", e);