@gatwech/auth 1.0.0-beta.20 → 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 (133) 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 +2 -2
  11. package/dist/client/plugins/index.d.mts +47 -47
  12. package/dist/client/plugins/index.mjs +2 -2
  13. package/dist/client/react/index.d.mts +2 -2
  14. package/dist/{client-F67q-Lbi.d.mts → client-B00gkznv.d.mts} +1 -1
  15. package/dist/{client-4QEb0rUr.mjs → client-CMsC6-rl.mjs} +1 -1
  16. package/dist/{client-C_qmyez2.d.mts → client-Da7tq8m6.d.mts} +1 -1
  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-BX7rl07Z.d.mts → index-BECNG13R.d.mts} +13 -13
  22. package/dist/{index-D57DuhZR.d.mts → index-BKy-8t2n.d.mts} +179 -126
  23. package/dist/{index-Df0KhQRf-BEa5tdwJ.d.mts → index-BL_t8cRp-C4QYBsac.d.mts} +56 -17
  24. package/dist/{index-D6ku0rUC.d.mts → index-BWYXZVsV.d.mts} +5 -5
  25. package/dist/{index-DwPAddfE.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-CzpdkTdv.d.mts → index-Bxive_YT.d.mts} +9 -9
  28. package/dist/{index-DGeoJ4gu.d.mts → index-C-IDh5yy.d.mts} +2 -2
  29. package/dist/{index-Dnd8S4S4.d.mts → index-CM1rFdwV.d.mts} +9 -9
  30. package/dist/{index-CNd5jqLl.d.mts → index-CMG4Pnen.d.mts} +4 -4
  31. package/dist/{index-4I1YhYhk.d.mts → index-CTqynuxm.d.mts} +4 -4
  32. package/dist/{index-CP8x1TFq.d.mts → index-CbBQdS-u.d.mts} +6 -6
  33. package/dist/{index-DyXi7S2M.d.mts → index-CpH-1sse.d.mts} +10 -10
  34. package/dist/{index-CC920PPp.d.mts → index-D1OZ2e4m.d.mts} +14 -14
  35. package/dist/{index-CofuaNMl.d.mts → index-Dt2I0G4X.d.mts} +7 -7
  36. package/dist/{index-XfE0WjTN.d.mts → index-IX1to9TB.d.mts} +34 -34
  37. package/dist/{index-KhbNmMmG.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-CBiGsvDd.d.mts → index-WV7VzW2H.d.mts} +8 -8
  40. package/dist/{index-Bml-PCqP.d.mts → index-YeXSxzhX.d.mts} +19 -19
  41. package/dist/{index-BsSKcKgO.d.mts → index-cPt-00xm.d.mts} +6 -6
  42. package/dist/{index-sb-UFLuo.d.mts → index-iRoaTsOj.d.mts} +2 -2
  43. package/dist/{index-DFSRYWFI.d.mts → index-jEkJ2Zfk.d.mts} +6 -6
  44. package/dist/{index-DTkcY7xH.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-DiPqydKG.mjs → sentinel-B4Wo79HK.mjs} +295 -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/index.d.ts +10 -1
  124. package/dist/src/plugins/sentinel/intel.d.ts +8 -1
  125. package/dist/src/plugins/sentinel/memory-cache.d.ts +12 -0
  126. package/dist/src/plugins/sentinel/schema.d.ts +4 -0
  127. package/dist/src/plugins/sentinel/store.d.ts +7 -0
  128. package/dist/src/plugins/sentinel/types.d.ts +29 -1
  129. package/dist/src/test-utils/test-instance.d.ts +25 -25
  130. package/dist/test-utils/index.d.mts +1045 -1045
  131. package/dist/test-utils/index.mjs +7 -7
  132. package/dist/types/index.d.mts +2 -2
  133. 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)
@@ -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
  }
@@ -278,6 +303,41 @@ function travelSpeedKmh(from, to) {
278
303
  return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
279
304
  }
280
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
+
281
341
  //#endregion
282
342
  //#region src/plugins/sentinel/intel.ts
283
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;
@@ -296,9 +356,18 @@ const UNKNOWN = {
296
356
  bot: null,
297
357
  geo: null
298
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;
299
367
  /**
300
368
  * 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.
369
+ * are cached for as long as the backend allows, in secondary storage or, when
370
+ * there is none, in memory.
302
371
  * Returns null verdicts when the backend isn't configured or can't answer.
303
372
  */
304
373
  function createIntelClient(options) {
@@ -312,15 +381,26 @@ function createIntelClient(options) {
312
381
  baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
313
382
  maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
314
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
+ });
315
392
  return {
316
393
  configured,
317
394
  verifiesVisitors: Boolean(kvUrl && apiKey),
318
395
  misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
319
- async verifyVisitor(auth, request) {
396
+ async verifyVisitor(auth, request, limit) {
320
397
  if (!kvUrl || !apiKey) return request.visitorId;
321
398
  const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
322
399
  const cached = await auth.secondaryStorage?.get(cacheKey);
323
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;
324
404
  for (let attempt = 0; attempt <= kv.attempts; attempt++) {
325
405
  if (attempt > 0) {
326
406
  const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
@@ -340,7 +420,9 @@ function createIntelClient(options) {
340
420
  }));
341
421
  if (data && !error) {
342
422
  const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
343
- 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);
344
426
  return visitorId;
345
427
  }
346
428
  }
@@ -352,6 +434,8 @@ function createIntelClient(options) {
352
434
  const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
353
435
  const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
354
436
  if (cached) return cached;
437
+ const remembered = auth.secondaryStorage ? void 0 : requestVerdicts.get(cacheKey);
438
+ if (remembered) return remembered;
355
439
  const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
356
440
  method: "POST",
357
441
  headers: {
@@ -378,7 +462,12 @@ function createIntelClient(options) {
378
462
  bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
379
463
  geo: data.geo ?? null
380
464
  };
381
- 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
+ }
382
471
  return intel;
383
472
  }
384
473
  };
@@ -466,6 +555,10 @@ const schema = {
466
555
  type: "string",
467
556
  required: true
468
557
  },
558
+ path: {
559
+ type: "string",
560
+ required: false
561
+ },
469
562
  ipAddress: {
470
563
  type: "string",
471
564
  required: false
@@ -488,6 +581,35 @@ const schema = {
488
581
  //#endregion
489
582
  //#region src/plugins/sentinel/store.ts
490
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
+ }
491
613
  /**
492
614
  * Counters and challenges, kept in secondary storage when it's configured
493
615
  * and in the database otherwise
@@ -522,45 +644,45 @@ function createSentinelStore(auth) {
522
644
  }), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
523
645
  return count;
524
646
  }
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({
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({
540
664
  model: "sentinelCounter",
541
- where: [{
542
- field: "id",
543
- value: row.id
544
- }],
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
+ ],
545
679
  update: {
546
- count: 1,
547
- 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) } : {}
548
682
  }
549
- });
550
- return 1;
683
+ }) > 0) return expired ? 1 : row.count + 1;
551
684
  }
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;
685
+ throw new Error(`Failed to increment counter ${key}`);
564
686
  },
565
687
  async get(name) {
566
688
  const key = PREFIX + name;
@@ -595,6 +717,7 @@ function createSentinelStore(auth) {
595
717
  await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
596
718
  return;
597
719
  }
720
+ deleteAllExpiredRows(auth);
598
721
  await auth.adapter.create({
599
722
  model: "powChallenge",
600
723
  data: {
@@ -678,11 +801,26 @@ const NEW_PASSWORD_FIELDS = {
678
801
  * towards credential stuffing
679
802
  */
680
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
+ ];
681
811
  /** Account creation that isn't done by the visitor signing up */
682
812
  const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
683
813
  /** Social sign-ups, whose email the provider has already verified */
684
814
  const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
685
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;
686
824
  /**
687
825
  * Sign-ins checked with a password, which can be repeated with a challenge
688
826
  * solution
@@ -732,7 +870,10 @@ const sentinel = (options) => {
732
870
  thresholds: security.freeTrialAbuse.thresholds,
733
871
  maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
734
872
  retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
735
- 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
736
877
  } : null;
737
878
  const botBlocking = resolveToggle(security.botBlocking, "challenge");
738
879
  const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
@@ -766,16 +907,32 @@ const sentinel = (options) => {
766
907
  const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
767
908
  const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
768
909
  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();
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();
773
914
  function clientIp(request, auth) {
774
915
  if (!request) return null;
775
916
  if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
776
917
  return getIp(request, auth.options);
777
918
  }
778
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
+ /**
779
936
  * The visitor id sent by the client, checked with the identification
780
937
  * service when one is configured
781
938
  */
@@ -785,22 +942,23 @@ const sentinel = (options) => {
785
942
  id: null,
786
943
  verified: false
787
944
  });
788
- const existing = visitorResults.get(headers);
789
- if (existing) return existing;
945
+ const cachedVisitor = verifiedVisitors.get(headers);
946
+ if (cachedVisitor) return cachedVisitor;
790
947
  const unchecked = {
791
948
  id: opts.failOpen ? sent : null,
792
949
  verified: false
793
950
  };
794
- const result = intel.verifyVisitor(auth, {
951
+ const store = createSentinelStore(auth);
952
+ const visitor = intel.verifyVisitor(auth, {
795
953
  visitorId: sent,
796
954
  ip,
797
955
  userAgent: headers.get("user-agent")
798
- }).then((verified) => verified === void 0 ? unchecked : {
956
+ }, ip ? visitorVerificationLimit(store, ip) : void 0).then((verified) => verified === void 0 ? unchecked : {
799
957
  id: verified,
800
958
  verified: intel.verifiesVisitors
801
959
  }).catch(() => unchecked);
802
- visitorResults.set(headers, result);
803
- return result;
960
+ verifiedVisitors.set(headers, visitor);
961
+ return visitor;
804
962
  }
805
963
  function relativePath(request, auth) {
806
964
  const pathname = new URL(request.url).pathname;
@@ -809,27 +967,28 @@ const sentinel = (options) => {
809
967
  }
810
968
  /**
811
969
  * Whether the request carries a valid, unused solution to a challenge
812
- * issued to the same IP address and visitor
970
+ * issued to the same IP address and visitor, for the same endpoint
813
971
  */
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;
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;
820
978
  try {
821
- const challenge = await store.consumeChallenge(parsed.nonce);
979
+ const challenge = await store.consumeChallenge(solution.nonce);
822
980
  if (!challenge) return false;
823
981
  if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
824
982
  if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
825
- 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);
826
985
  } catch (e) {
827
986
  auth.logger.error("[sentinel] Failed to verify challenge", e);
828
987
  return false;
829
988
  }
830
989
  })();
831
- challengeResults.set(key, result);
832
- return result;
990
+ acceptedSolutions.set(key, accepted);
991
+ return accepted;
833
992
  }
834
993
  /** Runs a check, applying `failOpen` when it can't complete */
835
994
  async function guard(auth, check) {
@@ -876,12 +1035,12 @@ const sentinel = (options) => {
876
1035
  visitorId: details.visitorId,
877
1036
  identifier: details.identifier,
878
1037
  userId,
879
- 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,
880
1039
  difficulty,
881
1040
  metadata
882
1041
  }));
883
1042
  }
884
- /** The key a request's challenge result is cached under */
1043
+ /** The key a request's accepted solution is cached under */
885
1044
  function challengeKey(ctx, details) {
886
1045
  return ctx.request ?? details.headers ?? ctx;
887
1046
  }
@@ -939,6 +1098,31 @@ const sentinel = (options) => {
939
1098
  if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
940
1099
  return (await intel.lookup(auth, details)).geo;
941
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
+ }
942
1126
  async function lastSignIn(auth, userId) {
943
1127
  const [row] = await auth.adapter.findMany({
944
1128
  model: "sentinelSignIn",
@@ -1062,6 +1246,20 @@ const sentinel = (options) => {
1062
1246
  });
1063
1247
  }));
1064
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
+ }
1065
1263
  if (freeTrialAbuse && details.visitorId) {
1066
1264
  const visitorId = details.visitorId;
1067
1265
  throwForDecision(await guard(ctx.context, async () => {
@@ -1099,9 +1297,10 @@ const sentinel = (options) => {
1099
1297
  const headers = event.headers ?? event.request?.headers;
1100
1298
  const ip = clientIp(event.request ?? headers, context.context);
1101
1299
  const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
1102
- if (!visitorId) return;
1103
1300
  try {
1104
- 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);
1105
1304
  } catch (e) {
1106
1305
  context.context.logger.error("[sentinel] Failed to track sign-up", e);
1107
1306
  }
@@ -1118,34 +1317,37 @@ const sentinel = (options) => {
1118
1317
  },
1119
1318
  async onRequest(request, ctx) {
1120
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;
1121
1323
  const store = createSentinelStore(ctx);
1122
1324
  const event = {
1123
1325
  context: ctx,
1124
1326
  request,
1125
1327
  headers: request.headers,
1126
- path: relativePath(request, ctx)
1328
+ path
1127
1329
  };
1128
1330
  const response = responseForDecision(await guard(ctx, async () => {
1129
1331
  const details = await requestDetails(event);
1130
1332
  const { ip } = details;
1131
- const remote = await intel.lookup(ctx, details);
1333
+ const backendVerdicts = await intel.lookup(ctx, details);
1132
1334
  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;
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;
1136
1338
  }
1137
1339
  }
1138
1340
  if (geoBlocking && ip) {
1139
- 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;
1140
1342
  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;
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;
1143
1345
  }
1144
1346
  }
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;
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;
1149
1351
  }
1150
1352
  }
1151
1353
  return { outcome: "allow" };
@@ -1191,7 +1393,7 @@ const sentinel = (options) => {
1191
1393
  }
1192
1394
  if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
1193
1395
  const keys = attemptKeys(details);
1194
- 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);
1195
1397
  const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
1196
1398
  const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
1197
1399
  const { thresholds } = credentialStuffing;
@@ -1220,12 +1422,12 @@ const sentinel = (options) => {
1220
1422
  if (limit) {
1221
1423
  const count = await store.increment(limit.name, windowSeconds);
1222
1424
  if (count > limit.max) {
1223
- 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", {
1224
1426
  count,
1225
1427
  limit: limit.max,
1226
1428
  windowSeconds
1227
1429
  });
1228
- if (result.outcome !== "allow") return result;
1430
+ if (limitDecision.outcome !== "allow") return limitDecision;
1229
1431
  }
1230
1432
  }
1231
1433
  if (ids.length && isTracked) {
@@ -1402,6 +1604,7 @@ const sentinel = (options) => {
1402
1604
  createdAt: /* @__PURE__ */ new Date()
1403
1605
  }
1404
1606
  });
1607
+ await deleteOlderSignIns(auth, user.id);
1405
1608
  } catch (e) {
1406
1609
  if (e instanceof APIError) throw e;
1407
1610
  auth.logger.error("[sentinel] Failed to track sign-in", e);
@@ -6,4 +6,4 @@ export interface MemoryDB {
6
6
  export interface MemoryAdapterConfig {
7
7
  debugLogs?: DBAdapterDebugLogOption | undefined;
8
8
  }
9
- export declare const memoryAdapter: (db: MemoryDB, config?: MemoryAdapterConfig | undefined) => (options: PayvoAuthOptions) => import("packages/core/dist/index-Df0KhQRf.mjs").nt<PayvoAuthOptions>;
9
+ export declare const memoryAdapter: (db: MemoryDB, config?: MemoryAdapterConfig | undefined) => (options: PayvoAuthOptions) => import("packages/core/dist/index-BL_t8cRp.mjs").nt<PayvoAuthOptions>;