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

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 (224) hide show
  1. package/README.md +8 -14
  2. package/dist/{access-C6O_osEz.mjs → access-CrwEFPHE.mjs} +1 -1
  3. package/dist/{access-DT1f6l5k.mjs → access-bhklJd7m.mjs} +1 -1
  4. package/dist/{adapter-factory-BZ2Qiqpn.mjs → adapter-factory-xbLUJt85.mjs} +2 -2
  5. package/dist/adapters/drizzle-adapter/index.mjs +6 -5
  6. package/dist/adapters/index.d.mts +5 -5
  7. package/dist/adapters/index.mjs +6 -5
  8. package/dist/adapters/kysely-adapter/index.d.mts +1 -1
  9. package/dist/adapters/kysely-adapter/index.mjs +8 -7
  10. package/dist/adapters/memory-adapter/index.d.mts +1 -1
  11. package/dist/adapters/memory-adapter/index.mjs +6 -5
  12. package/dist/adapters/mongodb-adapter/index.mjs +6 -5
  13. package/dist/adapters/prisma-adapter/index.mjs +6 -5
  14. package/dist/adapters/test.mjs +4 -3
  15. package/dist/{admin-Bcq53Nwj.mjs → admin-BzUJL6tr.mjs} +6 -6
  16. package/dist/{anonymous-CRt1imBW.mjs → anonymous-BjlTk4gl.mjs} +5 -5
  17. package/dist/api/index.d.mts +5 -5
  18. package/dist/api/index.mjs +12 -11
  19. package/dist/{api-0pR_I-Nx.mjs → api-Dk8Yj_J5.mjs} +15 -5
  20. package/dist/audit-log-DKsUWR8O.mjs +772 -0
  21. package/dist/{auth-CBUoBX6o.mjs → auth-vfCyIIeV.mjs} +8 -8
  22. package/dist/{bearer-Djf_awfo.mjs → bearer-DvQLM00l.mjs} +2 -2
  23. package/dist/client/index.d.mts +21 -21
  24. package/dist/client/index.mjs +4 -4
  25. package/dist/client/plugins/index.d.mts +53 -45
  26. package/dist/client/plugins/index.mjs +16 -12
  27. package/dist/client/react/index.d.mts +19 -19
  28. package/dist/client/react/index.mjs +2 -2
  29. package/dist/client-4QEb0rUr.mjs +95 -0
  30. package/dist/client-CSOs1YN7.d.mts +14 -0
  31. package/dist/client-DZHNfxuR.d.mts +75 -0
  32. package/dist/client-DzOqXJUm.mjs +15 -0
  33. package/dist/{client-aExCkfwm.mjs → client-xga7yFIX.mjs} +2 -2
  34. package/dist/cookies/index.d.mts +6 -6
  35. package/dist/cookies/index.mjs +2 -2
  36. package/dist/{cookies-cJObLwn1.mjs → cookies-BuvlgcA-.mjs} +1 -1
  37. package/dist/crypto/index.mjs +2 -1
  38. package/dist/{crypto-Bb42eMrH.mjs → crypto-CCyta8xj.mjs} +1 -6
  39. package/dist/{custom-session-L3t7ecsd.mjs → custom-session-DN_-pfi_.mjs} +2 -2
  40. package/dist/db/index.d.mts +5 -5
  41. package/dist/db/index.mjs +9 -8
  42. package/dist/{db-BrstmDhc.mjs → db-7vHnL1XA.mjs} +15 -9
  43. package/dist/{device-authorization-V4s3h4Mj.mjs → device-authorization-BKnNhlsz.mjs} +4 -4
  44. package/dist/{dialect-CeddeGHf.mjs → dialect-HsWUfpYY.mjs} +2 -2
  45. package/dist/{email-otp-BtDHSw14.mjs → email-otp-Dw0DypjQ.mjs} +7 -6
  46. package/dist/{generic-oauth-CL5X27ZI.mjs → generic-oauth-DFj27JY8.mjs} +4 -4
  47. package/dist/{has-permission-BpAr7uCn.mjs → has-permission-Bvg8de8g.mjs} +1 -1
  48. package/dist/{haveibeenpwned-cMpJvmzI.mjs → haveibeenpwned-CzNda7Tn.mjs} +28 -15
  49. package/dist/{client-CNY0tn9g.d.mts → index-BAVzFqII.d.mts} +21 -21
  50. package/dist/{index-DaBcikv9.d.mts → index-BNDRYfTF.d.mts} +2 -2
  51. package/dist/{index-Cxi7TbLE.d.mts → index-BNhJglHC.d.mts} +122 -122
  52. package/dist/{index-BYMI9r_d.d.mts → index-Blmhq6eA.d.mts} +4 -4
  53. package/dist/{index-BHoQlf7a.d.mts → index-CKnPAS5n.d.mts} +178 -178
  54. package/dist/{index-34ONrP8l.d.mts → index-COGe8UJx.d.mts} +11 -11
  55. package/dist/{index-Buhn3hkR.d.mts → index-CP3FNbOZ.d.mts} +8 -8
  56. package/dist/{index-D_k662QG.d.mts → index-CUknVRZp.d.mts} +14 -14
  57. package/dist/{index-DKNNV1kb.d.mts → index-CaqpCfE5.d.mts} +8 -8
  58. package/dist/{index-BBm8nQ9Q.d.mts → index-CgeC2eGC.d.mts} +2 -2
  59. package/dist/{index-DR0xXno9.d.mts → index-CsxtcAO0.d.mts} +1 -1
  60. package/dist/{index-CuFzhoS6.d.mts → index-Cv9iMevV.d.mts} +10 -10
  61. package/dist/{index-C7fQuk-V.d.mts → index-CxlWnL9c.d.mts} +1 -1
  62. package/dist/{index-zjjNBRY2.d.mts → index-DG-X1PK5.d.mts} +6 -6
  63. package/dist/index-DRM35ir1.d.mts +3769 -0
  64. package/dist/{index-BqhKCbPH.d.mts → index-DS-R7st3.d.mts} +6 -6
  65. package/dist/{index-C-u1h_WX.d.mts → index-DXVU0HGr.d.mts} +5 -5
  66. package/dist/{index-DoXZYQpq.d.mts → index-Db8jqUq2.d.mts} +10 -10
  67. package/dist/{index-65G5U5DB-DSdjzreF.d.mts → index-Df0KhQRf-BEa5tdwJ.d.mts} +3 -2
  68. package/dist/{index-B-XKqFvY.d.mts → index-Df4SvZaE.d.mts} +11 -4
  69. package/dist/{index-G6V0Z_Mn.d.mts → index-DyH_CgmX.d.mts} +10 -10
  70. package/dist/{admin-DtfO1PLg.d.mts → index-EABwA_BH.d.mts} +36 -36
  71. package/dist/{index-z0KSvE5i.d.mts → index-QmocqGM-.d.mts} +1 -1
  72. package/dist/{index-DlaMq1nb.d.mts → index-mxEUX2Gs.d.mts} +14 -14
  73. package/dist/{client-BOX93C3d.d.mts → index-xoYsoOiR.d.mts} +52 -52
  74. package/dist/{index-B3n8e37d.d.mts → index-zBdYQGaS.d.mts} +7 -7
  75. package/dist/index.d.mts +5 -5
  76. package/dist/index.mjs +16 -15
  77. package/dist/integrations/next-js.mjs +2 -2
  78. package/dist/integrations/node.d.mts +5 -5
  79. package/dist/integrations/react-start.d.mts +4 -4
  80. package/dist/integrations/react-start.mjs +2 -2
  81. package/dist/{jwt-lHV4QHpD.mjs → jwt-DBN2mu0r.mjs} +4 -4
  82. package/dist/{kysely-adapter-DpzYO8Yf.mjs → kysely-adapter-Bkm1liPW.mjs} +1 -1
  83. package/dist/{magic-link-Bk-ueorR.mjs → magic-link-D9Em1aOR.mjs} +3 -3
  84. package/dist/{multi-session-DRrLKrg9.mjs → multi-session-Dno_8DxC.mjs} +5 -5
  85. package/dist/{oauth-proxy-D1sWh9B_.mjs → oauth-proxy-DW-zt8wv.mjs} +2 -2
  86. package/dist/oauth2/index.d.mts +5 -5
  87. package/dist/oauth2/index.mjs +12 -11
  88. package/dist/{one-tap-DMswkTEc.mjs → one-tap-CodeyQb8.mjs} +2 -2
  89. package/dist/{organization-BArHO_5v.mjs → organization-CkTKs7bx.mjs} +9 -9
  90. package/dist/{passkey-CcD4CnhM.mjs → passkey-JNQTz5a0.mjs} +5 -5
  91. package/dist/{phone-number-rHDIlh-2.mjs → phone-number-Dqz8aeyF.mjs} +4 -4
  92. package/dist/plugins/access/index.d.mts +2 -2
  93. package/dist/plugins/access/index.mjs +1 -1
  94. package/dist/plugins/admin/access/index.d.mts +2 -2
  95. package/dist/plugins/admin/access/index.mjs +2 -2
  96. package/dist/plugins/admin/index.d.mts +7 -8
  97. package/dist/plugins/admin/index.mjs +17 -16
  98. package/dist/plugins/anonymous/index.d.mts +6 -6
  99. package/dist/plugins/anonymous/index.mjs +13 -12
  100. package/dist/plugins/audit-log/client.d.mts +30 -0
  101. package/dist/plugins/audit-log/client.mjs +3 -0
  102. package/dist/plugins/audit-log/index.d.mts +29 -0
  103. package/dist/plugins/audit-log/index.mjs +20 -0
  104. package/dist/plugins/bearer/index.d.mts +1 -1
  105. package/dist/plugins/bearer/index.mjs +3 -3
  106. package/dist/plugins/captcha/index.d.mts +1 -1
  107. package/dist/plugins/captcha/index.mjs +1 -1
  108. package/dist/plugins/custom-session/index.d.mts +6 -6
  109. package/dist/plugins/custom-session/index.mjs +14 -13
  110. package/dist/plugins/device-authorization/index.d.mts +3 -3
  111. package/dist/plugins/device-authorization/index.mjs +13 -12
  112. package/dist/plugins/email-otp/index.d.mts +2 -2
  113. package/dist/plugins/email-otp/index.mjs +14 -13
  114. package/dist/plugins/generic-oauth/index.d.mts +6 -6
  115. package/dist/plugins/generic-oauth/index.mjs +13 -12
  116. package/dist/plugins/haveibeenpwned/index.d.mts +2 -2
  117. package/dist/plugins/haveibeenpwned/index.mjs +14 -13
  118. package/dist/plugins/index.d.mts +29 -139
  119. package/dist/plugins/index.mjs +47 -42
  120. package/dist/plugins/jwt/index.d.mts +6 -6
  121. package/dist/plugins/jwt/index.mjs +13 -12
  122. package/dist/plugins/magic-link/index.d.mts +1 -1
  123. package/dist/plugins/magic-link/index.mjs +13 -12
  124. package/dist/plugins/multi-session/index.d.mts +2 -2
  125. package/dist/plugins/multi-session/index.mjs +13 -12
  126. package/dist/plugins/oauth-proxy/index.d.mts +1 -1
  127. package/dist/plugins/oauth-proxy/index.mjs +13 -12
  128. package/dist/plugins/one-tap/index.d.mts +1 -1
  129. package/dist/plugins/one-tap/index.mjs +13 -12
  130. package/dist/plugins/one-time-token/index.d.mts +6 -6
  131. package/dist/plugins/one-time-token/index.mjs +46 -41
  132. package/dist/plugins/organization/access/index.d.mts +3 -3
  133. package/dist/plugins/organization/access/index.mjs +2 -2
  134. package/dist/plugins/organization/index.d.mts +8 -8
  135. package/dist/plugins/organization/index.mjs +17 -16
  136. package/dist/plugins/passkey/index.d.mts +6 -6
  137. package/dist/plugins/passkey/index.mjs +13 -12
  138. package/dist/plugins/phone-number/index.d.mts +6 -6
  139. package/dist/plugins/phone-number/index.mjs +13 -12
  140. package/dist/plugins/sentinel/client.d.mts +30 -0
  141. package/dist/plugins/sentinel/client.mjs +5 -0
  142. package/dist/plugins/sentinel/index.d.mts +29 -0
  143. package/dist/plugins/sentinel/index.mjs +22 -0
  144. package/dist/plugins/two-factor/index.d.mts +6 -6
  145. package/dist/plugins/two-factor/index.mjs +15 -14
  146. package/dist/plugins/username/index.d.mts +3 -3
  147. package/dist/plugins/username/index.mjs +13 -12
  148. package/dist/{plugins-hFZT3hmK.d.mts → plugins-Dy_FrD4Y.d.mts} +1 -1
  149. package/dist/{plugins-3jfNste3.mjs → plugins-yF8CFUDR.mjs} +11 -8
  150. package/dist/pow-OK01yPG3.mjs +61 -0
  151. package/dist/{proxy-CL9PZXJA.mjs → proxy-C_KXfOhk.mjs} +1 -1
  152. package/dist/random-32E3cU5o.mjs +7 -0
  153. package/dist/registry-CqG_3Qnx.mjs +194 -0
  154. package/dist/sentinel-C-D1IHJo.mjs +1395 -0
  155. package/dist/{session-Dgp133Yt.mjs → session-BB3oqApE.mjs} +2 -2
  156. package/dist/src/adapters/memory-adapter/memory-adapter.d.ts +1 -1
  157. package/dist/src/adapters/prisma-adapter/test/auth.d.ts +1 -1
  158. package/dist/src/adapters/tests/auth-flow.d.ts +3 -3
  159. package/dist/src/adapters/tests/normal.d.ts +1 -1
  160. package/dist/src/adapters/tests/number-id.d.ts +3 -3
  161. package/dist/src/adapters/tests/performance.d.ts +3 -3
  162. package/dist/src/adapters/tests/transactions.d.ts +3 -3
  163. package/dist/src/api/index.d.ts +8 -8
  164. package/dist/src/client/plugins/index.d.ts +2 -0
  165. package/dist/src/plugins/admin/admin.d.ts +2 -2
  166. package/dist/src/plugins/api-key/index.d.ts +2 -2
  167. package/dist/src/plugins/audit-log/client.d.ts +10 -0
  168. package/dist/src/plugins/audit-log/events.d.ts +90 -0
  169. package/dist/src/plugins/audit-log/index.d.ts +614 -0
  170. package/dist/src/plugins/audit-log/registry.d.ts +13 -0
  171. package/dist/src/plugins/audit-log/remote.d.ts +34 -0
  172. package/dist/src/plugins/audit-log/schema.d.ts +50 -0
  173. package/dist/src/plugins/audit-log/types.d.ts +117 -0
  174. package/dist/src/plugins/email-otp/index.d.ts +1 -1
  175. package/dist/src/plugins/haveibeenpwned/index.d.ts +6 -0
  176. package/dist/src/plugins/index.d.ts +2 -0
  177. package/dist/src/plugins/multi-session/index.d.ts +8 -8
  178. package/dist/src/plugins/organization/organization.d.ts +5 -5
  179. package/dist/src/plugins/organization/routes/crud-access-control.d.ts +8 -8
  180. package/dist/src/plugins/organization/routes/crud-invites.d.ts +46 -46
  181. package/dist/src/plugins/organization/routes/crud-members.d.ts +40 -40
  182. package/dist/src/plugins/organization/routes/crud-org.d.ts +36 -36
  183. package/dist/src/plugins/organization/routes/crud-team.d.ts +50 -50
  184. package/dist/src/plugins/sentinel/actions.d.ts +64 -0
  185. package/dist/src/plugins/sentinel/client.d.ts +67 -0
  186. package/dist/src/plugins/sentinel/email.d.ts +22 -0
  187. package/dist/src/plugins/sentinel/geo.d.ts +22 -0
  188. package/dist/src/plugins/sentinel/index.d.ts +326 -0
  189. package/dist/src/plugins/sentinel/intel.d.ts +45 -0
  190. package/dist/src/plugins/sentinel/pow.d.ts +21 -0
  191. package/dist/src/plugins/sentinel/schema.d.ts +110 -0
  192. package/dist/src/plugins/sentinel/store.d.ts +37 -0
  193. package/dist/src/plugins/sentinel/types.d.ts +412 -0
  194. package/dist/src/test-utils/memory-secondary-storage.d.ts +16 -0
  195. package/dist/src/test-utils/password.d.ts +12 -0
  196. package/dist/src/test-utils/test-instance.d.ts +25 -25
  197. package/dist/test-utils/index.d.mts +1048 -1048
  198. package/dist/test-utils/index.mjs +53 -48
  199. package/dist/{two-factor-ClUXxom2.mjs → two-factor-CDVjxx4f.mjs} +6 -5
  200. package/dist/types/index.d.mts +5 -5
  201. package/dist/types/index.mjs +1 -1
  202. package/dist/{username-DiGklOGl.mjs → username-BHNvcvsY.mjs} +3 -3
  203. package/dist/{utils-SOMB4Px5.mjs → utils-B03WRaGi.mjs} +2 -1
  204. package/package.json +36 -3
  205. package/dist/index-CM-CFWwr.d.mts +0 -1919
  206. package/dist/index-DB7t8whN.d.mts +0 -1
  207. /package/dist/{access-BO2g7QVe.mjs → access-BM6jSA5j.mjs} +0 -0
  208. /package/dist/{bun-sqlite-dialect-CeNvMaZz.mjs → bun-sqlite-dialect-CDGusFqF.mjs} +0 -0
  209. /package/dist/{captcha-BqaMxlKC.mjs → captcha-DGP9lCRe.mjs} +0 -0
  210. /package/dist/{client-CygJhqeB.mjs → client-BuveH3MN.mjs} +0 -0
  211. /package/dist/{client-WiN43tAS.mjs → client-cBiytuOL.mjs} +0 -0
  212. /package/dist/{date-Cx2yCiC4.mjs → date-CMr04reD.mjs} +0 -0
  213. /package/dist/{esm-C-QicDNz.mjs → esm-BbvI87Z2.mjs} +0 -0
  214. /package/dist/{get-tables-BQP7YW9c.mjs → get-tables-CKvSyDST.mjs} +0 -0
  215. /package/dist/{helper-tmxW3BHj.d.mts → helper-BgOIGHsu.d.mts} +0 -0
  216. /package/dist/{misc-beA1w3XV.mjs → misc-fKH4d5XQ.mjs} +0 -0
  217. /package/dist/{node-sqlite-dialect-BcKxBQnm.mjs → node-sqlite-dialect-CIOjcg31.mjs} +0 -0
  218. /package/dist/{parser-DcrIEuXE.mjs → parser-D7EkhAjZ.mjs} +0 -0
  219. /package/dist/{password-sVC4nePO.mjs → password-5CdcAVaH.mjs} +0 -0
  220. /package/dist/{permission-DLYUF3cW.mjs → permission-CDDWgA_O.mjs} +0 -0
  221. /package/dist/{plugin-helper-4L-lzsjZ.mjs → plugin-helper-RJJBTOba.mjs} +0 -0
  222. /package/dist/{promise-8rrEHRXd.mjs → promise-D7Oo6_cY.mjs} +0 -0
  223. /package/dist/{types-Bjn15Uy6.mjs → types-DCPpFKUB.mjs} +0 -0
  224. /package/dist/{types-B1OL7hyf.d.mts → types-DNQ9XQrB.d.mts} +0 -0
@@ -0,0 +1,1395 @@
1
+ import { t as getIp } from "./get-request-ip-DSjrEpGo.mjs";
2
+ import { t as safeJSONParse } from "./json-D0TBLy4q.mjs";
3
+ import { n as deleteSessionCookie } from "./cookies-BuvlgcA-.mjs";
4
+ import { n as mergeSchema } from "./date-CMr04reD.mjs";
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";
8
+ import { env } from "@gatwech/core/env";
9
+ import { PayvoAuthError } from "@gatwech/core/error";
10
+ import { defineErrorCodes } from "@gatwech/core/utils";
11
+ import { APIError } from "better-call";
12
+ import { createAuthMiddleware } from "@gatwech/core/api";
13
+ import { betterFetch } from "@better-fetch/fetch";
14
+
15
+ //#region src/plugins/sentinel/actions.ts
16
+ const CHALLENGE_TTL_MS = 6e4;
17
+ /**
18
+ * Applies a security action: records the event and decides whether the
19
+ * request continues, gets a proof-of-work challenge or is blocked
20
+ */
21
+ async function executeAction(params) {
22
+ const { auth, event, action, reason } = params;
23
+ const recorder = getAuditRecorder(auth);
24
+ const record = async (eventType, status) => {
25
+ if (!recorder) {
26
+ auth.logger.warn(`[sentinel] ${reason}: ${status}`, params.metadata);
27
+ return;
28
+ }
29
+ await recorder(event, {
30
+ eventType,
31
+ status,
32
+ userId: params.userId ?? null,
33
+ identifier: params.identifier ?? null,
34
+ ipAddress: params.ipAddress,
35
+ visitorId: params.visitorId,
36
+ eventData: {
37
+ reason,
38
+ action,
39
+ ...params.metadata
40
+ }
41
+ });
42
+ };
43
+ if (action === "log") {
44
+ await record(params.eventType, "success");
45
+ return { outcome: "allow" };
46
+ }
47
+ if (action === "challenge") {
48
+ if (params.challengePassed) {
49
+ await record(SECURITY_EVENT_TYPES.ALLOWED_AFTER_CHALLENGE, "success");
50
+ return { outcome: "allow" };
51
+ }
52
+ const nonce = createNonce();
53
+ try {
54
+ await params.store.saveChallenge({
55
+ nonce,
56
+ difficulty: params.difficulty,
57
+ reason,
58
+ ipAddress: params.ipAddress,
59
+ visitorId: params.visitorId,
60
+ expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS)
61
+ });
62
+ } catch (e) {
63
+ auth.logger.error("[sentinel] Failed to save challenge", e);
64
+ }
65
+ await record(params.eventType, "challenged");
66
+ return {
67
+ outcome: "challenge",
68
+ reason,
69
+ nonce,
70
+ difficulty: params.difficulty
71
+ };
72
+ }
73
+ await record(params.eventType, "blocked");
74
+ await record(SECURITY_EVENT_TYPES.BLOCKED, "blocked");
75
+ return {
76
+ outcome: "block",
77
+ reason
78
+ };
79
+ }
80
+ function challengeHeaders(decision) {
81
+ return {
82
+ [POW_CHALLENGE_HEADER]: `${decision.nonce}:${decision.difficulty}`,
83
+ [POW_REASON_HEADER]: decision.reason,
84
+ "access-control-expose-headers": `${POW_CHALLENGE_HEADER}, ${POW_REASON_HEADER}`
85
+ };
86
+ }
87
+ const SENTINEL_ERROR_CODES = defineErrorCodes({
88
+ POW_CHALLENGE_REQUIRED: "Challenge required",
89
+ SECURITY_BLOCKED: "Request blocked for security reasons"
90
+ });
91
+ /** The error for a decision, or null when the request is allowed */
92
+ function errorForDecision(decision) {
93
+ if (decision.outcome === "allow") return null;
94
+ if (decision.outcome === "challenge") return new APIError("LOCKED", {
95
+ message: SENTINEL_ERROR_CODES.POW_CHALLENGE_REQUIRED,
96
+ code: "POW_CHALLENGE_REQUIRED",
97
+ challenge: {
98
+ nonce: decision.nonce,
99
+ difficulty: decision.difficulty,
100
+ reason: decision.reason
101
+ }
102
+ }, challengeHeaders(decision));
103
+ return new APIError("FORBIDDEN", {
104
+ message: SENTINEL_ERROR_CODES.SECURITY_BLOCKED,
105
+ code: "SECURITY_BLOCKED",
106
+ reason: decision.reason
107
+ });
108
+ }
109
+ /** Throws the error for a decision, for use in endpoint hooks */
110
+ function throwForDecision(decision) {
111
+ const error = errorForDecision(decision);
112
+ if (error) throw error;
113
+ }
114
+ /** Builds the response for a decision, for use in `onRequest` */
115
+ function responseForDecision(decision) {
116
+ if (decision.outcome === "allow") return void 0;
117
+ if (decision.outcome === "challenge") return new Response(JSON.stringify({
118
+ message: SENTINEL_ERROR_CODES.POW_CHALLENGE_REQUIRED,
119
+ code: "POW_CHALLENGE_REQUIRED",
120
+ challenge: {
121
+ nonce: decision.nonce,
122
+ difficulty: decision.difficulty,
123
+ reason: decision.reason
124
+ }
125
+ }), {
126
+ status: 423,
127
+ headers: {
128
+ "content-type": "application/json",
129
+ ...challengeHeaders(decision)
130
+ }
131
+ });
132
+ return new Response(JSON.stringify({
133
+ message: SENTINEL_ERROR_CODES.SECURITY_BLOCKED,
134
+ code: "SECURITY_BLOCKED",
135
+ reason: decision.reason
136
+ }), {
137
+ status: 403,
138
+ headers: { "content-type": "application/json" }
139
+ });
140
+ }
141
+
142
+ //#endregion
143
+ //#region src/plugins/sentinel/email.ts
144
+ const GMAIL_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
145
+ /**
146
+ * Providers that deliver `user+tag@domain` to `user@domain`, so the tag can be
147
+ * dropped without changing the mailbox
148
+ */
149
+ const PLUS_ADDRESSING_DOMAINS = new Set([
150
+ "gmail.com",
151
+ "googlemail.com",
152
+ "outlook.com",
153
+ "hotmail.com",
154
+ "live.com",
155
+ "msn.com",
156
+ "icloud.com",
157
+ "me.com",
158
+ "mac.com",
159
+ "protonmail.com",
160
+ "proton.me",
161
+ "pm.me",
162
+ "fastmail.com",
163
+ "zoho.com"
164
+ ]);
165
+ /**
166
+ * Normalizes an email address so aliases of the same mailbox match:
167
+ * lowercases it, maps `googlemail.com` to `gmail.com`, removes dots in Gmail
168
+ * addresses and strips plus-address tags on providers that support them
169
+ */
170
+ function normalizeEmail(email) {
171
+ const trimmed = email.trim().toLowerCase();
172
+ const at = trimmed.lastIndexOf("@");
173
+ if (at < 1 || at === trimmed.length - 1) return trimmed;
174
+ let local = trimmed.slice(0, at);
175
+ let domain = trimmed.slice(at + 1);
176
+ if (domain === "googlemail.com") domain = "gmail.com";
177
+ if (PLUS_ADDRESSING_DOMAINS.has(domain)) local = local.split("+")[0];
178
+ if (GMAIL_DOMAINS.has(domain)) local = local.replace(/\./g, "");
179
+ return local ? `${local}@${domain}` : trimmed;
180
+ }
181
+ function getEmailDomain(email) {
182
+ const at = email.lastIndexOf("@");
183
+ return at < 0 ? "" : email.slice(at + 1).trim().toLowerCase();
184
+ }
185
+ let disposableDomains;
186
+ /**
187
+ * Whether a domain, or any parent domain, is a known disposable email
188
+ * provider. The list is loaded on first use.
189
+ */
190
+ async function isDisposableDomain(domain) {
191
+ disposableDomains ??= import("disposable-email-domains-js").then((module) => module.isDisposableEmailDomain);
192
+ const isDisposable = await disposableDomains;
193
+ const labels = domain.split(".");
194
+ for (let i = 0; i < labels.length - 1; i++) if (isDisposable(labels.slice(i).join("."))) return true;
195
+ return false;
196
+ }
197
+ /**
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.
200
+ */
201
+ async function hasMxRecords(domain) {
202
+ let dns;
203
+ try {
204
+ dns = await import("node:dns/promises");
205
+ } catch {
206
+ return null;
207
+ }
208
+ 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;
214
+ }
215
+ }
216
+ /**
217
+ * Heuristics for addresses that look generated rather than chosen by a
218
+ * person
219
+ */
220
+ function looksGenerated(email) {
221
+ const at = email.lastIndexOf("@");
222
+ const local = email.slice(0, at);
223
+ const domainName = email.slice(at + 1).split(".").slice(0, -1).join(".");
224
+ const letters = local.replace(/[^a-z]/gi, "");
225
+ return /\d{5,}/.test(local) || /\d{4,}/.test(domainName) || local.length >= 12 && letters.length / local.length < .4 || /(?:[a-z]\d){5,}/i.test(local) || /^[0-9a-f]{16,}$/i.test(local);
226
+ }
227
+
228
+ //#endregion
229
+ //#region src/plugins/sentinel/geo.ts
230
+ const EARTH_RADIUS_KM = 6371;
231
+ const toRadians = (degrees) => degrees * Math.PI / 180;
232
+ /** Great-circle distance between two coordinates, in kilometres */
233
+ function haversineDistanceKm(from, to) {
234
+ const dLat = toRadians(to.latitude - from.latitude);
235
+ const dLon = toRadians(to.longitude - from.longitude);
236
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(from.latitude)) * Math.cos(toRadians(to.latitude)) * Math.sin(dLon / 2) ** 2;
237
+ return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
238
+ }
239
+ /**
240
+ * Speed needed to travel between two sign-ins, in km/h. Distances under
241
+ * 50 km are treated as the same place, since IP geolocation isn't precise
242
+ * enough to tell them apart.
243
+ */
244
+ function travelSpeedKmh(from, to) {
245
+ const distance = haversineDistanceKm(from, to);
246
+ if (distance < 50) return 0;
247
+ return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
248
+ }
249
+
250
+ //#endregion
251
+ //#region src/plugins/sentinel/intel.ts
252
+ 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;
253
+ /**
254
+ * Local bot heuristics, used when neither a provider nor the hosted backend
255
+ * gives a verdict
256
+ */
257
+ function looksLikeBot(request) {
258
+ const userAgent = request.headers.get("user-agent")?.trim();
259
+ if (!userAgent) return true;
260
+ if (BOT_USER_AGENT.test(userAgent)) return true;
261
+ return !request.headers.get("accept-language") && !request.headers.get("x-visitor-id");
262
+ }
263
+ const UNKNOWN = {
264
+ suspiciousIp: null,
265
+ bot: null,
266
+ geo: null
267
+ };
268
+ /**
269
+ * 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.
271
+ * Returns null verdicts when the backend isn't configured or can't answer.
272
+ */
273
+ function createIntelClient(options) {
274
+ const apiKey = options.apiKey ?? env.PAYVO_INFRA_API_KEY;
275
+ const apiUrl = (options.apiUrl ?? env.PAYVO_INFRA_API_URL)?.replace(/\/+$/, "");
276
+ const kvUrl = (options.kvUrl ?? env.PAYVO_INFRA_KV_URL)?.replace(/\/+$/, "");
277
+ const configured = Boolean(apiKey && apiUrl);
278
+ const kv = {
279
+ timeout: options.kvOptions?.timeout ?? 1e3,
280
+ attempts: options.kvOptions?.retry?.attempts ?? 2,
281
+ baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
282
+ maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
283
+ };
284
+ return {
285
+ configured,
286
+ verifiesVisitors: Boolean(kvUrl && apiKey),
287
+ misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
288
+ async verifyVisitor(auth, request) {
289
+ if (!kvUrl || !apiKey) return request.visitorId;
290
+ const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
291
+ const cached = await auth.secondaryStorage?.get(cacheKey);
292
+ if (typeof cached === "string") return cached || null;
293
+ for (let attempt = 0; attempt <= kv.attempts; attempt++) {
294
+ if (attempt > 0) {
295
+ const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
296
+ await new Promise((resolve) => setTimeout(resolve, wait));
297
+ }
298
+ const { data, error } = await betterFetch(`${kvUrl}/v1/visitors/verify`, {
299
+ method: "POST",
300
+ headers: {
301
+ ...options.kvOptions?.headers,
302
+ authorization: `Bearer ${apiKey}`
303
+ },
304
+ body: request,
305
+ timeout: kv.timeout
306
+ }).catch((e) => ({
307
+ data: null,
308
+ error: e
309
+ }));
310
+ if (data && !error) {
311
+ const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
312
+ await auth.secondaryStorage?.set(cacheKey, visitorId ?? "", 300);
313
+ return visitorId;
314
+ }
315
+ }
316
+ auth.logger.error("[sentinel] Failed to verify visitor id");
317
+ },
318
+ async lookup(auth, request) {
319
+ const { ip, visitorId } = request;
320
+ if (!configured || !ip) return UNKNOWN;
321
+ const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
322
+ const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
323
+ if (cached) return cached;
324
+ const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
325
+ method: "POST",
326
+ headers: {
327
+ ...options.apiOptions?.headers,
328
+ authorization: `Bearer ${apiKey}`
329
+ },
330
+ body: {
331
+ ip,
332
+ visitorId,
333
+ userAgent: request.userAgent,
334
+ headers: { "accept-language": request.acceptLanguage }
335
+ },
336
+ timeout: options.apiOptions?.timeout ?? 3e3
337
+ }).catch((e) => ({
338
+ data: null,
339
+ error: e
340
+ }));
341
+ if (error || !data) {
342
+ auth.logger.error("[sentinel] Failed to look up request intel", error);
343
+ return UNKNOWN;
344
+ }
345
+ const intel = {
346
+ suspiciousIp: data.ip?.suspicious ?? null,
347
+ bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
348
+ geo: data.geo ?? null
349
+ };
350
+ if (data.ttlSeconds && auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
351
+ return intel;
352
+ }
353
+ };
354
+ }
355
+
356
+ //#endregion
357
+ //#region src/plugins/sentinel/schema.ts
358
+ /** Added to users when `emailNormalization` is enabled */
359
+ const normalizedEmailSchema = { user: { fields: { normalizedEmail: {
360
+ type: "string",
361
+ required: false,
362
+ input: false,
363
+ returned: false
364
+ } } } };
365
+ const schema = {
366
+ sentinelCounter: { fields: {
367
+ key: {
368
+ type: "string",
369
+ required: true,
370
+ unique: true
371
+ },
372
+ count: {
373
+ type: "number",
374
+ required: true
375
+ },
376
+ expiresAt: {
377
+ type: "date",
378
+ required: true
379
+ }
380
+ } },
381
+ sentinelSignIn: { fields: {
382
+ userId: {
383
+ type: "string",
384
+ required: true,
385
+ references: {
386
+ model: "user",
387
+ field: "id",
388
+ onDelete: "cascade"
389
+ }
390
+ },
391
+ ipAddress: {
392
+ type: "string",
393
+ required: false
394
+ },
395
+ visitorId: {
396
+ type: "string",
397
+ required: false
398
+ },
399
+ userAgent: {
400
+ type: "string",
401
+ required: false
402
+ },
403
+ country: {
404
+ type: "string",
405
+ required: false
406
+ },
407
+ city: {
408
+ type: "string",
409
+ required: false
410
+ },
411
+ latitude: {
412
+ type: "number",
413
+ required: false
414
+ },
415
+ longitude: {
416
+ type: "number",
417
+ required: false
418
+ },
419
+ createdAt: {
420
+ type: "date",
421
+ required: true
422
+ }
423
+ } },
424
+ powChallenge: { fields: {
425
+ nonce: {
426
+ type: "string",
427
+ required: true,
428
+ unique: true
429
+ },
430
+ difficulty: {
431
+ type: "number",
432
+ required: true
433
+ },
434
+ reason: {
435
+ type: "string",
436
+ required: true
437
+ },
438
+ ipAddress: {
439
+ type: "string",
440
+ required: false
441
+ },
442
+ visitorId: {
443
+ type: "string",
444
+ required: false
445
+ },
446
+ expiresAt: {
447
+ type: "date",
448
+ required: true
449
+ },
450
+ createdAt: {
451
+ type: "date",
452
+ required: true
453
+ }
454
+ } }
455
+ };
456
+
457
+ //#endregion
458
+ //#region src/plugins/sentinel/store.ts
459
+ const PREFIX = "sentinel:";
460
+ /**
461
+ * Counters and challenges, kept in secondary storage when it's configured
462
+ * and in the database otherwise
463
+ */
464
+ function createSentinelStore(auth) {
465
+ const storage = auth.secondaryStorage;
466
+ async function findCounter(key) {
467
+ return auth.adapter.findOne({
468
+ model: "sentinelCounter",
469
+ where: [{
470
+ field: "key",
471
+ value: key
472
+ }]
473
+ });
474
+ }
475
+ return {
476
+ async increment(name, ttlSeconds, options = {}) {
477
+ const key = PREFIX + name;
478
+ if (storage?.increment) {
479
+ const count = await storage.increment(key, ttlSeconds);
480
+ if (options.sliding && count > 1) await storage.set(key, String(count), ttlSeconds);
481
+ return count;
482
+ }
483
+ if (storage) {
484
+ const current = safeJSONParse(await storage.get(key));
485
+ const now = Date.now();
486
+ const expiresAt = current && current.expiresAt > now && !options.sliding ? current.expiresAt : now + ttlSeconds * 1e3;
487
+ const count = current && current.expiresAt > now ? current.count + 1 : 1;
488
+ await storage.set(key, JSON.stringify({
489
+ count,
490
+ expiresAt
491
+ }), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
492
+ return count;
493
+ }
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({
509
+ model: "sentinelCounter",
510
+ where: [{
511
+ field: "id",
512
+ value: row.id
513
+ }],
514
+ update: {
515
+ count: 1,
516
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
517
+ }
518
+ });
519
+ return 1;
520
+ }
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;
533
+ },
534
+ async get(name) {
535
+ const key = PREFIX + name;
536
+ if (storage) {
537
+ const value = await storage.get(key);
538
+ if (value === null || value === void 0) return 0;
539
+ if (storage.increment) return Number(value) || 0;
540
+ const current = safeJSONParse(value);
541
+ return current && current.expiresAt > Date.now() ? current.count : 0;
542
+ }
543
+ const row = await findCounter(key);
544
+ if (!row || new Date(row.expiresAt).getTime() <= Date.now()) return 0;
545
+ return row.count;
546
+ },
547
+ async reset(name) {
548
+ const key = PREFIX + name;
549
+ if (storage) {
550
+ await storage.delete(key);
551
+ return;
552
+ }
553
+ await auth.adapter.deleteMany({
554
+ model: "sentinelCounter",
555
+ where: [{
556
+ field: "key",
557
+ value: key
558
+ }]
559
+ });
560
+ },
561
+ async saveChallenge(challenge) {
562
+ const ttlSeconds = Math.max(Math.ceil((challenge.expiresAt.getTime() - Date.now()) / 1e3), 1);
563
+ if (storage) {
564
+ await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
565
+ return;
566
+ }
567
+ await auth.adapter.create({
568
+ model: "powChallenge",
569
+ data: {
570
+ ...challenge,
571
+ createdAt: /* @__PURE__ */ new Date()
572
+ }
573
+ });
574
+ },
575
+ async consumeChallenge(nonce) {
576
+ let challenge = null;
577
+ if (storage) {
578
+ const key = `${PREFIX}pow:${nonce}`;
579
+ let value;
580
+ if (storage.getAndDelete) value = await storage.getAndDelete(key);
581
+ else {
582
+ value = await storage.get(key);
583
+ if (value) await storage.delete(key);
584
+ }
585
+ challenge = safeJSONParse(value);
586
+ } else {
587
+ const row = await auth.adapter.findOne({
588
+ model: "powChallenge",
589
+ where: [{
590
+ field: "nonce",
591
+ value: nonce
592
+ }]
593
+ });
594
+ if (!row) return null;
595
+ if (!await auth.adapter.create({
596
+ model: "sentinelCounter",
597
+ data: {
598
+ key: `${PREFIX}pow-used:${nonce}`,
599
+ count: 1,
600
+ expiresAt: new Date(row.expiresAt)
601
+ }
602
+ }).then(() => true).catch(() => false)) return null;
603
+ await auth.adapter.delete({
604
+ model: "powChallenge",
605
+ where: [{
606
+ field: "nonce",
607
+ value: nonce
608
+ }]
609
+ });
610
+ challenge = row;
611
+ }
612
+ if (!challenge) return null;
613
+ if (new Date(challenge.expiresAt).getTime() <= Date.now()) return null;
614
+ return challenge;
615
+ }
616
+ };
617
+ }
618
+
619
+ //#endregion
620
+ //#region src/plugins/sentinel/index.ts
621
+ const SIGN_UP_PATHS = ["/sign-up/email"];
622
+ const PASSWORD_RESET_PATHS = ["/request-password-reset", "/forget-password/email-otp"];
623
+ /** The email field normalized on each endpoint */
624
+ const NORMALIZED_EMAIL_FIELDS = {
625
+ "/sign-up/email": "email",
626
+ "/sign-in/email": "email",
627
+ "/sign-in/email-otp": "email",
628
+ "/sign-in/magic-link": "email",
629
+ "/email-otp/send-verification-otp": "email",
630
+ "/email-otp/verify-email": "email",
631
+ "/email-otp/check-verification-otp": "email",
632
+ "/email-otp/reset-password": "email",
633
+ "/request-password-reset": "email",
634
+ "/forget-password/email-otp": "email",
635
+ "/change-email": "newEmail"
636
+ };
637
+ /** The new password field each endpoint accepts */
638
+ const NEW_PASSWORD_FIELDS = {
639
+ "/sign-up/email": "password",
640
+ "/change-password": "newPassword",
641
+ "/set-password": "newPassword",
642
+ "/reset-password": "newPassword",
643
+ "/email-otp/reset-password": "password"
644
+ };
645
+ /**
646
+ * Endpoints that check a password, one-time code or token, so failures count
647
+ * towards credential stuffing
648
+ */
649
+ const isAttemptPath = (path) => CREDENTIAL_SIGN_IN_PATHS.includes(path) || VERIFICATION_PATHS.includes(path);
650
+ /** Account creation that isn't done by the visitor signing up */
651
+ const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
652
+ /** Social sign-ups, whose email the provider has already verified */
653
+ const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
654
+ const DAY_SECONDS = 1440 * 60;
655
+ /**
656
+ * Sign-ins checked with a password, which can be repeated with a challenge
657
+ * solution
658
+ */
659
+ const REPEATABLE_SIGN_IN_PATHS = [
660
+ "/sign-in/email",
661
+ "/sign-in/username",
662
+ "/sign-in/phone-number"
663
+ ];
664
+ /** How long checks that passed on a first factor cover the second factor */
665
+ const SECOND_FACTOR_SECONDS = 600;
666
+ /** Response statuses that sentinel itself returns, not failed credentials */
667
+ const SENTINEL_STATUSES = new Set([
668
+ 403,
669
+ 423,
670
+ 429
671
+ ]);
672
+ const sentinel = (options) => {
673
+ const opts = {
674
+ ...options,
675
+ failOpen: options?.failOpen ?? true
676
+ };
677
+ const security = opts.security ?? {};
678
+ const difficulty = security.challengeDifficulty ?? 18;
679
+ const intel = createIntelClient(opts);
680
+ const credentialStuffing = security.credentialStuffing?.enabled ? {
681
+ action: security.credentialStuffing.action ?? "challenge",
682
+ thresholds: security.credentialStuffing.thresholds ?? {
683
+ challenge: 3,
684
+ block: 5
685
+ },
686
+ windowSeconds: security.credentialStuffing.windowSeconds ?? 3600,
687
+ cooldownSeconds: security.credentialStuffing.cooldownSeconds ?? 900
688
+ } : null;
689
+ const velocity = security.velocity?.enabled ? {
690
+ thresholds: security.velocity.thresholds ?? {
691
+ challenge: 10,
692
+ block: 20
693
+ },
694
+ maxSignupsPerVisitor: security.velocity.maxSignupsPerVisitor ?? 5,
695
+ maxPasswordResetsPerIp: security.velocity.maxPasswordResetsPerIp ?? 10,
696
+ maxSignInsPerIp: security.velocity.maxSignInsPerIp ?? 50,
697
+ windowSeconds: security.velocity.windowSeconds ?? 3600,
698
+ action: security.velocity.action ?? "challenge"
699
+ } : null;
700
+ const freeTrialAbuse = security.freeTrialAbuse?.enabled ? {
701
+ thresholds: security.freeTrialAbuse.thresholds,
702
+ maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
703
+ retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
704
+ action: security.freeTrialAbuse.action ?? "block"
705
+ } : null;
706
+ const botBlocking = resolveToggle(security.botBlocking, "challenge");
707
+ const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
708
+ if (security.geoBlocking?.allowList && security.geoBlocking.denyList) throw new PayvoAuthError("sentinel: use either geoBlocking.allowList or geoBlocking.denyList, not both");
709
+ const geoBlocking = security.geoBlocking?.allowList || security.geoBlocking?.denyList ? {
710
+ allowList: security.geoBlocking.allowList?.map((code) => code.toUpperCase()),
711
+ denyList: security.geoBlocking.denyList?.map((code) => code.toUpperCase()),
712
+ action: security.geoBlocking.action ?? "block"
713
+ } : null;
714
+ const impossibleTravel = security.impossibleTravel?.enabled ? {
715
+ maxSpeedKmh: security.impossibleTravel.maxSpeedKmh ?? 1e3,
716
+ action: security.impossibleTravel.action ?? "challenge"
717
+ } : null;
718
+ const compromisedPassword = security.compromisedPassword?.enabled ? {
719
+ action: security.compromisedPassword.action ?? "block",
720
+ minBreachCount: security.compromisedPassword.minBreachCount ?? 1
721
+ } : null;
722
+ const emailValidation = security.emailValidation?.enabled ? {
723
+ strictness: security.emailValidation.strictness ?? "medium",
724
+ action: security.emailValidation.action ?? "block",
725
+ domainAllowlist: new Set(security.emailValidation.domainAllowlist?.map((domain) => domain.toLowerCase()))
726
+ } : null;
727
+ const emailNormalization = security.emailNormalization?.enabled ?? false;
728
+ if (security.staleUsers?.notifyAdmin && !security.staleUsers.adminEmail) throw new PayvoAuthError("sentinel: staleUsers.adminEmail is required when notifyAdmin is true");
729
+ const staleUsers = security.staleUsers?.enabled ? {
730
+ staleDays: security.staleUsers.staleDays ?? 90,
731
+ action: security.staleUsers.action ?? "log",
732
+ notifyUser: security.staleUsers.notifyUser ?? false,
733
+ adminEmail: security.staleUsers.notifyAdmin ? security.staleUsers.adminEmail : void 0
734
+ } : null;
735
+ const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
736
+ const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
737
+ 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();
742
+ function clientIp(request, auth) {
743
+ if (!request) return null;
744
+ if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
745
+ return getIp(request, auth.options);
746
+ }
747
+ /**
748
+ * The visitor id sent by the client, checked with the identification
749
+ * service when one is configured
750
+ */
751
+ function resolveVisitor(auth, headers, ip) {
752
+ const sent = headers?.get("x-visitor-id") || null;
753
+ if (!headers || !sent) return Promise.resolve({
754
+ id: null,
755
+ verified: false
756
+ });
757
+ const existing = visitorResults.get(headers);
758
+ if (existing) return existing;
759
+ const unchecked = {
760
+ id: opts.failOpen ? sent : null,
761
+ verified: false
762
+ };
763
+ const result = intel.verifyVisitor(auth, {
764
+ visitorId: sent,
765
+ ip,
766
+ userAgent: headers.get("user-agent")
767
+ }).then((verified) => verified === void 0 ? unchecked : {
768
+ id: verified,
769
+ verified: intel.verifiesVisitors
770
+ }).catch(() => unchecked);
771
+ visitorResults.set(headers, result);
772
+ return result;
773
+ }
774
+ function relativePath(request, auth) {
775
+ const pathname = new URL(request.url).pathname;
776
+ const basePath = auth.options.basePath ?? "/api/auth";
777
+ return pathname.startsWith(basePath) ? pathname.slice(basePath.length) || "/" : pathname;
778
+ }
779
+ /**
780
+ * Whether the request carries a valid, unused solution to a challenge
781
+ * issued to the same IP address and visitor
782
+ */
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;
789
+ try {
790
+ const challenge = await store.consumeChallenge(parsed.nonce);
791
+ if (!challenge) return false;
792
+ if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
793
+ if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
794
+ return await verifySolution(parsed.nonce, parsed.value, challenge.difficulty);
795
+ } catch (e) {
796
+ auth.logger.error("[sentinel] Failed to verify challenge", e);
797
+ return false;
798
+ }
799
+ })();
800
+ challengeResults.set(key, result);
801
+ return result;
802
+ }
803
+ /** Runs a check, applying `failOpen` when it can't complete */
804
+ async function guard(auth, check) {
805
+ try {
806
+ return await check();
807
+ } catch (e) {
808
+ if (e instanceof APIError) throw e;
809
+ auth.logger.error("[sentinel] Security check failed", e);
810
+ return opts.failOpen ? { outcome: "allow" } : {
811
+ outcome: "block",
812
+ reason: "check_failed"
813
+ };
814
+ }
815
+ }
816
+ async function requestDetails(ctx, identifier) {
817
+ const headers = ctx.headers ?? ctx.request?.headers;
818
+ const ip = clientIp(ctx.request ?? headers, ctx.context);
819
+ const body = ctx.body ?? {};
820
+ const value = identifier ?? [
821
+ body.email,
822
+ body.username,
823
+ body.phoneNumber
824
+ ].find((field) => typeof field === "string");
825
+ const visitor = await resolveVisitor(ctx.context, headers, ip);
826
+ return {
827
+ headers,
828
+ ip,
829
+ visitorId: visitor.id,
830
+ visitorVerified: visitor.verified,
831
+ identifier: value && emailNormalization && value.includes("@") ? normalizeEmail(value) : value ?? null,
832
+ userAgent: headers?.get("user-agent") ?? null,
833
+ acceptLanguage: headers?.get("accept-language") ?? null
834
+ };
835
+ }
836
+ function act(ctx, store, details, action, eventType, reason, metadata, userId) {
837
+ return guard(ctx.context, async () => executeAction({
838
+ auth: ctx.context,
839
+ event: ctx,
840
+ store,
841
+ action,
842
+ eventType,
843
+ reason,
844
+ ipAddress: details.ip,
845
+ visitorId: details.visitorId,
846
+ identifier: details.identifier,
847
+ userId,
848
+ challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context) : false,
849
+ difficulty,
850
+ metadata
851
+ }));
852
+ }
853
+ /** The key a request's challenge result is cached under */
854
+ function challengeKey(ctx, details) {
855
+ return ctx.request ?? details.headers ?? ctx;
856
+ }
857
+ /**
858
+ * Counters a sign-in attempt is tracked under: one per visitor and IP
859
+ * address, and one per visitor and IP address for the account it signs in
860
+ * to
861
+ */
862
+ function attemptKeys(details) {
863
+ const account = details.identifier?.toLowerCase();
864
+ return [details.visitorId ? `cs:visitor:${details.visitorId}` : null, details.ip ? `cs:ip:${details.ip}` : null].filter((scope) => scope !== null).map((scope) => ({
865
+ scope,
866
+ account: account ? `${scope}:account:${account}` : null
867
+ }));
868
+ }
869
+ /**
870
+ * Counts a sign-in attempt and returns the failed attempts before it.
871
+ * Attempts are counted before the password is checked, so attempts sent at
872
+ * the same time can't all pass the check. Successful sign-ins are counted
873
+ * separately and subtracted.
874
+ */
875
+ async function countAttempt(store, keys, options$1) {
876
+ if (!credentialStuffing) return 0;
877
+ const { windowSeconds } = credentialStuffing;
878
+ const { readOnly } = options$1;
879
+ const count = (name) => readOnly ? store.get(name) : store.increment(name, windowSeconds);
880
+ const failures = await Promise.all(keys.map(async ({ scope, account }) => {
881
+ const total = await count(scope);
882
+ if (total === 1 && !readOnly) await store.reset(`${scope}:succeeded`);
883
+ const succeeded = await store.get(`${scope}:succeeded`);
884
+ const own = account ? await count(account) : 0;
885
+ return Math.max(total - succeeded, own) - 1;
886
+ }));
887
+ return Math.max(0, ...failures);
888
+ }
889
+ function counterNames(keys) {
890
+ return keys.flatMap(({ scope, account }) => account ? [scope, account] : [scope]);
891
+ }
892
+ /**
893
+ * Identities a rate limit counts under. A visitor id that wasn't verified
894
+ * can be changed on every request, so the IP address counts too.
895
+ */
896
+ function rateIds(details) {
897
+ if (details.visitorId && details.visitorVerified) return [`visitor:${details.visitorId}`];
898
+ return [details.visitorId ? `visitor:${details.visitorId}` : null, details.ip ? `ip:${details.ip}` : null].filter((id) => id !== null);
899
+ }
900
+ /** Increments a counter for each identity and returns the highest count */
901
+ async function incrementAll(store, names, ttlSeconds) {
902
+ const counts = await Promise.all(names.map((name) => store.increment(name, ttlSeconds)));
903
+ return Math.max(0, ...counts);
904
+ }
905
+ /** The location of a request, from the provider or the hosted backend */
906
+ async function locate(auth, details) {
907
+ if (!details.ip) return null;
908
+ if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
909
+ return (await intel.lookup(auth, details)).geo;
910
+ }
911
+ async function lastSignIn(auth, userId) {
912
+ const [row] = await auth.adapter.findMany({
913
+ model: "sentinelSignIn",
914
+ where: [{
915
+ field: "userId",
916
+ value: userId
917
+ }],
918
+ sortBy: {
919
+ field: "createdAt",
920
+ direction: "desc"
921
+ },
922
+ limit: 1
923
+ });
924
+ return row ?? null;
925
+ }
926
+ function daysSince(date) {
927
+ return Math.floor((Date.now() - new Date(date).getTime()) / (DAY_SECONDS * 1e3));
928
+ }
929
+ /** The travel speed from the previous sign-in, when both have coordinates */
930
+ function travel(previous, geo) {
931
+ if (previous?.latitude == null || previous.longitude == null || geo?.latitude == null || geo.longitude == null) return null;
932
+ const speedKmh = travelSpeedKmh({
933
+ latitude: previous.latitude,
934
+ longitude: previous.longitude,
935
+ at: new Date(previous.createdAt)
936
+ }, {
937
+ latitude: geo.latitude,
938
+ longitude: geo.longitude,
939
+ at: /* @__PURE__ */ new Date()
940
+ });
941
+ return {
942
+ speedKmh: Math.round(speedKmh),
943
+ from: {
944
+ country: previous.country,
945
+ city: previous.city
946
+ },
947
+ to: {
948
+ country: geo.country,
949
+ city: geo.city ?? null
950
+ }
951
+ };
952
+ }
953
+ async function notify(auth, notification) {
954
+ if (!opts.sendNotification) return;
955
+ try {
956
+ await opts.sendNotification(notification);
957
+ } catch (e) {
958
+ auth.logger.error("[sentinel] Failed to send notification", e);
959
+ }
960
+ }
961
+ /**
962
+ * The address of the account an email is an alias of, or null when the
963
+ * email is an account's address itself or no account matches
964
+ */
965
+ async function findAliasedAddress(auth, email) {
966
+ const exact = await auth.adapter.findOne({
967
+ model: "user",
968
+ where: [{
969
+ field: "email",
970
+ value: email.toLowerCase()
971
+ }]
972
+ });
973
+ const normalizedEmail = normalizeEmail(email);
974
+ if (exact) {
975
+ if (!exact.normalizedEmail) await auth.adapter.update({
976
+ model: "user",
977
+ where: [{
978
+ field: "id",
979
+ value: exact.id
980
+ }],
981
+ update: { normalizedEmail }
982
+ });
983
+ return null;
984
+ }
985
+ return (await auth.adapter.findOne({
986
+ model: "user",
987
+ where: [{
988
+ field: "normalizedEmail",
989
+ value: normalizedEmail
990
+ }]
991
+ }))?.email ?? null;
992
+ }
993
+ /** Disposable domains, MX records and generated-looking addresses */
994
+ async function validateEmail(ctx, store, details, email) {
995
+ if (!emailValidation) return;
996
+ const domain = getEmailDomain(email);
997
+ if (!domain || emailValidation.domainAllowlist.has(domain)) return;
998
+ throwForDecision(await guard(ctx.context, async () => {
999
+ const { strictness } = emailValidation;
1000
+ let check = null;
1001
+ if (await isDisposableDomain(domain)) check = "disposable";
1002
+ else if (strictness !== "low" && await hasMxRecords(domain) === false) check = "no_mx_records";
1003
+ else if (strictness === "high" && looksGenerated(email.toLowerCase())) check = "generated";
1004
+ if (!check) return { outcome: "allow" };
1005
+ return act(ctx, store, details, emailValidation.action, SECURITY_EVENT_TYPES.INVALID_EMAIL, "invalid_email", {
1006
+ domain,
1007
+ check,
1008
+ strictness
1009
+ });
1010
+ }));
1011
+ }
1012
+ /**
1013
+ * Checks run when an account is about to be created, whichever endpoint
1014
+ * creates it: email validation, sign-up velocity and free trial abuse
1015
+ */
1016
+ async function checkAccountCreation(ctx, email) {
1017
+ if (isManagedAccountCreation(ctx.path)) return;
1018
+ const event = ctx;
1019
+ const store = createSentinelStore(ctx.context);
1020
+ const details = await requestDetails(event, email);
1021
+ if (!OAUTH_CALLBACK_PATHS.includes(ctx.path)) await validateEmail(event, store, details, email);
1022
+ if (velocity) {
1023
+ const ids = rateIds(details);
1024
+ if (ids.length) throwForDecision(await guard(ctx.context, async () => {
1025
+ const count = await incrementAll(store, ids.map((id) => `velocity:sign-up:${id}`), velocity.windowSeconds);
1026
+ if (count <= velocity.maxSignupsPerVisitor) return { outcome: "allow" };
1027
+ return act(event, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1028
+ count,
1029
+ limit: velocity.maxSignupsPerVisitor,
1030
+ windowSeconds: velocity.windowSeconds
1031
+ });
1032
+ }));
1033
+ }
1034
+ if (freeTrialAbuse && details.visitorId) {
1035
+ const visitorId = details.visitorId;
1036
+ throwForDecision(await guard(ctx.context, async () => {
1037
+ const created = await store.get(`trial:${visitorId}`);
1038
+ const { thresholds } = freeTrialAbuse;
1039
+ let action = null;
1040
+ if (thresholds) {
1041
+ if (created >= thresholds.block) action = "block";
1042
+ else if (created >= thresholds.challenge) action = "challenge";
1043
+ } else if (created >= freeTrialAbuse.maxAccountsPerVisitor) action = freeTrialAbuse.action;
1044
+ if (!action) return { outcome: "allow" };
1045
+ return act(event, store, details, action, SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "free_trial_abuse", { accounts: created });
1046
+ }));
1047
+ }
1048
+ }
1049
+ return {
1050
+ id: "sentinel",
1051
+ init(ctx) {
1052
+ if ((unknownDeviceNotification || staleUsers?.notifyUser || staleUsers?.adminEmail) && !opts.sendNotification) ctx.logger.warn("[sentinel] Notifications are enabled but sendNotification is not set, so none will be sent");
1053
+ if (intel.misconfigured) ctx.logger.warn("[sentinel] Missing PAYVO_INFRA_API_KEY or PAYVO_INFRA_API_URL. Security checks that use the hosted backend fall back to allow mode.");
1054
+ if (!checksAccountCreation && !emailNormalization) return;
1055
+ return { options: { databaseHooks: { user: {
1056
+ create: {
1057
+ async before(user, context) {
1058
+ if (context && checksAccountCreation) await checkAccountCreation(context, user.email);
1059
+ if (emailNormalization) return { data: {
1060
+ ...user,
1061
+ normalizedEmail: normalizeEmail(user.email)
1062
+ } };
1063
+ },
1064
+ async after(user, context) {
1065
+ if (!freeTrialAbuse || !context) return;
1066
+ if (isManagedAccountCreation(context.path)) return;
1067
+ const event = context;
1068
+ const headers = event.headers ?? event.request?.headers;
1069
+ const ip = clientIp(event.request ?? headers, context.context);
1070
+ const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
1071
+ if (!visitorId) return;
1072
+ try {
1073
+ await createSentinelStore(context.context).increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
1074
+ } catch (e) {
1075
+ context.context.logger.error("[sentinel] Failed to track sign-up", e);
1076
+ }
1077
+ }
1078
+ },
1079
+ update: { async before(data) {
1080
+ if (!emailNormalization || typeof data.email !== "string") return;
1081
+ return { data: {
1082
+ ...data,
1083
+ normalizedEmail: normalizeEmail(data.email)
1084
+ } };
1085
+ } }
1086
+ } } } };
1087
+ },
1088
+ async onRequest(request, ctx) {
1089
+ if (!botBlocking && !suspiciousIpBlocking && !geoBlocking) return;
1090
+ const store = createSentinelStore(ctx);
1091
+ const event = {
1092
+ context: ctx,
1093
+ request,
1094
+ headers: request.headers,
1095
+ path: relativePath(request, ctx)
1096
+ };
1097
+ const response = responseForDecision(await guard(ctx, async () => {
1098
+ const details = await requestDetails(event);
1099
+ const { ip } = details;
1100
+ const remote = await intel.lookup(ctx, details);
1101
+ 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;
1105
+ }
1106
+ }
1107
+ if (geoBlocking && ip) {
1108
+ const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : remote.geo)?.country?.toUpperCase() ?? null;
1109
+ 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;
1112
+ }
1113
+ }
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;
1118
+ }
1119
+ }
1120
+ return { outcome: "allow" };
1121
+ }));
1122
+ return response ? { response } : void 0;
1123
+ },
1124
+ hooks: {
1125
+ before: [{
1126
+ matcher(context) {
1127
+ return emailNormalization && context.path in NORMALIZED_EMAIL_FIELDS;
1128
+ },
1129
+ handler: createAuthMiddleware(async (ctx) => {
1130
+ const field = NORMALIZED_EMAIL_FIELDS[ctx.path];
1131
+ const email = ctx.body?.[field];
1132
+ if (typeof email !== "string") return;
1133
+ const stored = await findAliasedAddress(ctx.context, email).catch((e) => {
1134
+ ctx.context.logger.error("[sentinel] Failed to resolve alias", e);
1135
+ return null;
1136
+ });
1137
+ if (!stored) return;
1138
+ return { context: { body: {
1139
+ ...ctx.body,
1140
+ [field]: stored
1141
+ } } };
1142
+ })
1143
+ }, {
1144
+ matcher(context) {
1145
+ const isCredentialSignIn = CREDENTIAL_SIGN_IN_PATHS.includes(context.path);
1146
+ return Boolean(credentialStuffing && isAttemptPath(context.path) || velocity && (isCredentialSignIn || SIGN_UP_PATHS.includes(context.path) || PASSWORD_RESET_PATHS.includes(context.path)) || emailValidation && context.path === "/change-email" || compromisedPassword && context.path in NEW_PASSWORD_FIELDS);
1147
+ },
1148
+ handler: createAuthMiddleware(async (ctx) => {
1149
+ const store = createSentinelStore(ctx.context);
1150
+ const details = await requestDetails(ctx);
1151
+ const body = ctx.body ?? {};
1152
+ if (emailValidation && ctx.path === "/change-email" && typeof body.newEmail === "string") await validateEmail(ctx, store, details, body.newEmail);
1153
+ if (compromisedPassword && ctx.path in NEW_PASSWORD_FIELDS) {
1154
+ const password = body[NEW_PASSWORD_FIELDS[ctx.path]];
1155
+ if (typeof password === "string" && password) throwForDecision(await guard(ctx.context, async () => {
1156
+ const breachCount = await getPasswordBreachCount(password);
1157
+ if (breachCount < compromisedPassword.minBreachCount) return { outcome: "allow" };
1158
+ return act(ctx, store, details, compromisedPassword.action, SECURITY_EVENT_TYPES.COMPROMISED_PASSWORD, "compromised_password", { breachCount });
1159
+ }));
1160
+ }
1161
+ if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
1162
+ const keys = attemptKeys(details);
1163
+ const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context);
1164
+ const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
1165
+ const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
1166
+ const { thresholds } = credentialStuffing;
1167
+ const metadata = {
1168
+ failedAttempts: failures,
1169
+ thresholds
1170
+ };
1171
+ if (blocked || failures >= thresholds.block) return act(ctx, store, details, credentialStuffing.action === "log" ? "log" : "block", SECURITY_EVENT_TYPES.CREDENTIAL_STUFFING, "credential_stuffing", metadata);
1172
+ if (failures >= thresholds.challenge) return act(ctx, store, details, credentialStuffing.action === "log" ? "log" : "challenge", SECURITY_EVENT_TYPES.CREDENTIAL_STUFFING, "credential_stuffing", metadata);
1173
+ return { outcome: "allow" };
1174
+ }));
1175
+ if (velocity) {
1176
+ const { windowSeconds } = velocity;
1177
+ let limit = null;
1178
+ if (CREDENTIAL_SIGN_IN_PATHS.includes(ctx.path) && details.ip) limit = {
1179
+ name: `velocity:sign-in:${details.ip}`,
1180
+ max: velocity.maxSignInsPerIp
1181
+ };
1182
+ else if (PASSWORD_RESET_PATHS.includes(ctx.path) && details.ip) limit = {
1183
+ name: `velocity:password-reset:${details.ip}`,
1184
+ max: velocity.maxPasswordResetsPerIp
1185
+ };
1186
+ const ids = rateIds(details);
1187
+ const isTracked = CREDENTIAL_SIGN_IN_PATHS.includes(ctx.path) || SIGN_UP_PATHS.includes(ctx.path) || PASSWORD_RESET_PATHS.includes(ctx.path);
1188
+ throwForDecision(await guard(ctx.context, async () => {
1189
+ if (limit) {
1190
+ const count = await store.increment(limit.name, windowSeconds);
1191
+ if (count > limit.max) {
1192
+ const result = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1193
+ count,
1194
+ limit: limit.max,
1195
+ windowSeconds
1196
+ });
1197
+ if (result.outcome !== "allow") return result;
1198
+ }
1199
+ }
1200
+ if (ids.length && isTracked) {
1201
+ const total = await incrementAll(store, ids.map((id) => `velocity:total:${id}`), windowSeconds);
1202
+ const { thresholds } = velocity;
1203
+ if (total > thresholds.challenge) return act(ctx, store, details, total > thresholds.block ? "block" : "challenge", SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1204
+ count: total,
1205
+ thresholds,
1206
+ windowSeconds
1207
+ });
1208
+ }
1209
+ return { outcome: "allow" };
1210
+ }));
1211
+ }
1212
+ })
1213
+ }],
1214
+ after: [{
1215
+ matcher(context) {
1216
+ return Boolean(credentialStuffing && isAttemptPath(context.path));
1217
+ },
1218
+ handler: createAuthMiddleware(async (ctx) => {
1219
+ if (!credentialStuffing) return;
1220
+ const returned = ctx.context.returned;
1221
+ const status = returned instanceof APIError ? returned.statusCode : 200;
1222
+ try {
1223
+ const store = createSentinelStore(ctx.context);
1224
+ const keys = attemptKeys(await requestDetails(ctx));
1225
+ const { windowSeconds, cooldownSeconds, thresholds } = credentialStuffing;
1226
+ if (status < 400) for (const { scope, account } of keys) {
1227
+ const [total, succeeded, own] = await Promise.all([
1228
+ store.get(scope),
1229
+ store.get(`${scope}:succeeded`),
1230
+ account ? store.get(account) : 0
1231
+ ]);
1232
+ if (account) await store.reset(account);
1233
+ if (total - succeeded <= own) {
1234
+ await store.reset(scope);
1235
+ await store.reset(`${scope}:succeeded`);
1236
+ } else await store.increment(`${scope}:succeeded`, windowSeconds);
1237
+ }
1238
+ else if (!SENTINEL_STATUSES.has(status) && status < 500) {
1239
+ const block = async (key, also = []) => {
1240
+ await store.increment(`${key}:blocked`, cooldownSeconds);
1241
+ await Promise.all([key, ...also].map((name) => store.reset(name)));
1242
+ };
1243
+ for (const { scope, account } of keys) {
1244
+ const [total, succeeded] = await Promise.all([store.get(scope), store.get(`${scope}:succeeded`)]);
1245
+ if (total - succeeded >= thresholds.block) await block(scope, [`${scope}:succeeded`]);
1246
+ if (account && await store.get(account) >= thresholds.block) await block(account);
1247
+ }
1248
+ }
1249
+ } catch (e) {
1250
+ ctx.context.logger.error("[sentinel] Failed to track attempt", e);
1251
+ }
1252
+ })
1253
+ }, {
1254
+ matcher(context) {
1255
+ return tracksSignIns && (SIGN_IN_PATHS.includes(context.path) || SIGN_UP_PATHS.includes(context.path));
1256
+ },
1257
+ handler: createAuthMiddleware(async (ctx) => {
1258
+ const returned = ctx.context.returned;
1259
+ const newSession = ctx.context.newSession;
1260
+ if (!newSession || returned instanceof APIError && returned.statusCode >= 400) return;
1261
+ const auth = ctx.context;
1262
+ const store = createSentinelStore(auth);
1263
+ const user = newSession.user;
1264
+ const isSignUp = SIGN_UP_PATHS.includes(ctx.path);
1265
+ const awaitsSecondFactor = Boolean(returned && !(returned instanceof APIError) && "twoFactorRedirect" in returned);
1266
+ const passedKey = `checked:${user.id}`;
1267
+ const checkedBefore = ctx.path.startsWith("/two-factor/") && await store.get(passedKey).catch(() => 0) > 0;
1268
+ if (checkedBefore) await store.reset(passedKey).catch(() => {});
1269
+ const repeatable = REPEATABLE_SIGN_IN_PATHS.includes(ctx.path);
1270
+ /**
1271
+ * Undoes a sign-in that a check rejects after the session was
1272
+ * created. Sign-ins that can't be repeated with a solution are
1273
+ * blocked instead of challenged.
1274
+ */
1275
+ const reject = async (decision) => {
1276
+ if (decision.outcome === "allow") return null;
1277
+ await auth.internalAdapter.deleteSession(newSession.session.token).catch((e) => auth.logger.error("[sentinel] Failed to revoke session", e));
1278
+ deleteSessionCookie(ctx);
1279
+ auth.setNewSession(null);
1280
+ return errorForDecision(decision);
1281
+ };
1282
+ const enforce = (action) => action === "challenge" && !repeatable ? "block" : action;
1283
+ let details;
1284
+ let previous;
1285
+ let geo;
1286
+ try {
1287
+ details = await requestDetails(ctx, user.email);
1288
+ previous = await lastSignIn(auth, user.id);
1289
+ geo = await locate(auth, details).catch(() => null);
1290
+ } catch (e) {
1291
+ auth.logger.error("[sentinel] Failed to track sign-in", e);
1292
+ return;
1293
+ }
1294
+ const signIn = {
1295
+ at: /* @__PURE__ */ new Date(),
1296
+ ipAddress: details.ip,
1297
+ userAgent: details.userAgent,
1298
+ location: [geo?.city, geo?.country].filter(Boolean).join(", ") || null
1299
+ };
1300
+ if (!isSignUp && !checkedBefore && impossibleTravel && previous) {
1301
+ const trip = travel(previous, geo);
1302
+ if (trip && trip.speedKmh > impossibleTravel.maxSpeedKmh) {
1303
+ const rejected = await reject(await act(ctx, store, details, enforce(impossibleTravel.action), SECURITY_EVENT_TYPES.IMPOSSIBLE_TRAVEL, "impossible_travel", {
1304
+ ...trip,
1305
+ maxSpeedKmh: impossibleTravel.maxSpeedKmh
1306
+ }, user.id));
1307
+ if (rejected) return rejected;
1308
+ }
1309
+ }
1310
+ const daysInactive = !isSignUp && staleUsers && previous ? daysSince(previous.createdAt) : null;
1311
+ const isStale = Boolean(staleUsers && daysInactive !== null && daysInactive > staleUsers.staleDays);
1312
+ if (staleUsers && isStale && !checkedBefore) {
1313
+ const rejected = await reject(await act(ctx, store, details, enforce(staleUsers.action), SECURITY_EVENT_TYPES.STALE_ACCOUNT, "stale_account", {
1314
+ daysInactive,
1315
+ staleDays: staleUsers.staleDays
1316
+ }, user.id));
1317
+ if (rejected) return rejected;
1318
+ }
1319
+ if (awaitsSecondFactor) {
1320
+ await store.increment(passedKey, SECOND_FACTOR_SECONDS).catch((e) => auth.logger.error("[sentinel] Failed to track sign-in", e));
1321
+ return;
1322
+ }
1323
+ try {
1324
+ if (staleUsers && isStale && daysInactive !== null) {
1325
+ if (staleUsers.notifyUser) await notify(auth, {
1326
+ type: "stale-account",
1327
+ recipient: "user",
1328
+ email: user.email,
1329
+ user,
1330
+ signIn,
1331
+ daysInactive
1332
+ });
1333
+ if (staleUsers.adminEmail) await notify(auth, {
1334
+ type: "stale-account",
1335
+ recipient: "admin",
1336
+ email: staleUsers.adminEmail,
1337
+ user,
1338
+ signIn,
1339
+ daysInactive
1340
+ });
1341
+ }
1342
+ if (!isSignUp && unknownDeviceNotification && details.visitorId && previous) {
1343
+ if (!await auth.adapter.findOne({
1344
+ model: "sentinelSignIn",
1345
+ where: [{
1346
+ field: "userId",
1347
+ value: user.id
1348
+ }, {
1349
+ field: "visitorId",
1350
+ value: details.visitorId
1351
+ }]
1352
+ })) await notify(auth, {
1353
+ type: "unknown-device",
1354
+ recipient: "user",
1355
+ email: user.email,
1356
+ user,
1357
+ signIn
1358
+ });
1359
+ }
1360
+ await auth.adapter.create({
1361
+ model: "sentinelSignIn",
1362
+ data: {
1363
+ userId: user.id,
1364
+ ipAddress: details.ip,
1365
+ visitorId: details.visitorId,
1366
+ userAgent: details.userAgent,
1367
+ country: geo?.country ?? null,
1368
+ city: geo?.city ?? null,
1369
+ latitude: geo?.latitude ?? null,
1370
+ longitude: geo?.longitude ?? null,
1371
+ createdAt: /* @__PURE__ */ new Date()
1372
+ }
1373
+ });
1374
+ } catch (e) {
1375
+ if (e instanceof APIError) throw e;
1376
+ auth.logger.error("[sentinel] Failed to track sign-in", e);
1377
+ }
1378
+ })
1379
+ }]
1380
+ },
1381
+ schema: mergeSchema(emailNormalization ? {
1382
+ ...schema,
1383
+ ...normalizedEmailSchema
1384
+ } : schema, opts.schema),
1385
+ $ERROR_CODES: SENTINEL_ERROR_CODES,
1386
+ options: opts
1387
+ };
1388
+ };
1389
+ function resolveToggle(value, defaultAction) {
1390
+ if (!value) return null;
1391
+ return { action: value === true ? defaultAction : value.action };
1392
+ }
1393
+
1394
+ //#endregion
1395
+ export { SENTINEL_ERROR_CODES as i, haversineDistanceKm as n, normalizeEmail as r, sentinel as t };