@bajustone/fortress 1.0.0-rc.1

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 (465) hide show
  1. package/CHANGELOG.md +1011 -0
  2. package/LICENSE +21 -0
  3. package/README.md +760 -0
  4. package/SECURITY.md +197 -0
  5. package/bin/fortress.ts +667 -0
  6. package/dist/auth-service-BcyQ2PuZ.d.cts +307 -0
  7. package/dist/auth-service-BkzZkWfH.d.ts +307 -0
  8. package/dist/config-B2366s_G.d.ts +665 -0
  9. package/dist/config-GtlVt6ZD.d.cts +665 -0
  10. package/dist/convert-routes-BLCQT57f.d.ts +72 -0
  11. package/dist/convert-routes-BdMvP2_X.d.cts +72 -0
  12. package/dist/crypto.cjs +61 -0
  13. package/dist/crypto.d.cts +36 -0
  14. package/dist/crypto.d.ts +36 -0
  15. package/dist/crypto.js +35 -0
  16. package/dist/drift-BT1wtMDJ.d.cts +22 -0
  17. package/dist/drift-k9LiYpRP.d.ts +22 -0
  18. package/dist/drizzle/pg.cjs +481 -0
  19. package/dist/drizzle/pg.d.cts +15 -0
  20. package/dist/drizzle/pg.d.ts +15 -0
  21. package/dist/drizzle/pg.js +454 -0
  22. package/dist/drizzle.cjs +1442 -0
  23. package/dist/drizzle.d.cts +85 -0
  24. package/dist/drizzle.d.ts +85 -0
  25. package/dist/drizzle.js +1411 -0
  26. package/dist/express.cjs +1357 -0
  27. package/dist/express.d.cts +333 -0
  28. package/dist/express.d.ts +333 -0
  29. package/dist/express.js +1314 -0
  30. package/dist/fetcher.cjs +77 -0
  31. package/dist/fetcher.d.cts +7 -0
  32. package/dist/fetcher.d.ts +7 -0
  33. package/dist/fetcher.js +41 -0
  34. package/dist/fortress-BmSvFfqK.d.cts +1089 -0
  35. package/dist/fortress-uA-_cheR.d.ts +1089 -0
  36. package/dist/hono.cjs +1530 -0
  37. package/dist/hono.d.cts +514 -0
  38. package/dist/hono.d.ts +514 -0
  39. package/dist/hono.js +1483 -0
  40. package/dist/index-CxEkfCA0.d.cts +77 -0
  41. package/dist/index-CxEkfCA0.d.ts +77 -0
  42. package/dist/index-D054pcb-.d.cts +146 -0
  43. package/dist/index-jAMbbUAY.d.ts +146 -0
  44. package/dist/index.cjs +9046 -0
  45. package/dist/index.d.cts +717 -0
  46. package/dist/index.d.ts +717 -0
  47. package/dist/index.js +8935 -0
  48. package/dist/jwt.cjs +255 -0
  49. package/dist/jwt.d.cts +90 -0
  50. package/dist/jwt.d.ts +90 -0
  51. package/dist/jwt.js +227 -0
  52. package/dist/otel.cjs +108 -0
  53. package/dist/otel.d.cts +62 -0
  54. package/dist/otel.d.ts +62 -0
  55. package/dist/otel.js +73 -0
  56. package/dist/plugins/account-lockout.cjs +322 -0
  57. package/dist/plugins/account-lockout.d.cts +42 -0
  58. package/dist/plugins/account-lockout.d.ts +42 -0
  59. package/dist/plugins/account-lockout.js +295 -0
  60. package/dist/plugins/admin.cjs +2378 -0
  61. package/dist/plugins/admin.d.cts +72 -0
  62. package/dist/plugins/admin.d.ts +72 -0
  63. package/dist/plugins/admin.js +2351 -0
  64. package/dist/plugins/api-key.cjs +792 -0
  65. package/dist/plugins/api-key.d.cts +121 -0
  66. package/dist/plugins/api-key.d.ts +121 -0
  67. package/dist/plugins/api-key.js +765 -0
  68. package/dist/plugins/audit-log.cjs +493 -0
  69. package/dist/plugins/audit-log.d.cts +86 -0
  70. package/dist/plugins/audit-log.d.ts +86 -0
  71. package/dist/plugins/audit-log.js +468 -0
  72. package/dist/plugins/data-isolation.cjs +211 -0
  73. package/dist/plugins/data-isolation.d.cts +49 -0
  74. package/dist/plugins/data-isolation.d.ts +49 -0
  75. package/dist/plugins/data-isolation.js +186 -0
  76. package/dist/plugins/email-verification.cjs +273 -0
  77. package/dist/plugins/email-verification.d.cts +44 -0
  78. package/dist/plugins/email-verification.d.ts +44 -0
  79. package/dist/plugins/email-verification.js +246 -0
  80. package/dist/plugins/magic-link.cjs +231 -0
  81. package/dist/plugins/magic-link.d.cts +39 -0
  82. package/dist/plugins/magic-link.d.ts +39 -0
  83. package/dist/plugins/magic-link.js +204 -0
  84. package/dist/plugins/oauth.cjs +1696 -0
  85. package/dist/plugins/oauth.d.cts +406 -0
  86. package/dist/plugins/oauth.d.ts +406 -0
  87. package/dist/plugins/oauth.js +1669 -0
  88. package/dist/plugins/openapi.cjs +1185 -0
  89. package/dist/plugins/openapi.d.cts +6 -0
  90. package/dist/plugins/openapi.d.ts +6 -0
  91. package/dist/plugins/openapi.js +1158 -0
  92. package/dist/plugins/rate-limit/express.cjs +55 -0
  93. package/dist/plugins/rate-limit/express.d.cts +64 -0
  94. package/dist/plugins/rate-limit/express.d.ts +64 -0
  95. package/dist/plugins/rate-limit/express.js +30 -0
  96. package/dist/plugins/rate-limit/hono.cjs +51 -0
  97. package/dist/plugins/rate-limit/hono.d.cts +59 -0
  98. package/dist/plugins/rate-limit/hono.d.ts +59 -0
  99. package/dist/plugins/rate-limit/hono.js +26 -0
  100. package/dist/plugins/rate-limit/sveltekit.cjs +49 -0
  101. package/dist/plugins/rate-limit/sveltekit.d.cts +59 -0
  102. package/dist/plugins/rate-limit/sveltekit.d.ts +59 -0
  103. package/dist/plugins/rate-limit/sveltekit.js +24 -0
  104. package/dist/plugins/rate-limit.cjs +354 -0
  105. package/dist/plugins/rate-limit.d.cts +140 -0
  106. package/dist/plugins/rate-limit.d.ts +140 -0
  107. package/dist/plugins/rate-limit.js +325 -0
  108. package/dist/plugins/social-login.cjs +905 -0
  109. package/dist/plugins/social-login.d.cts +114 -0
  110. package/dist/plugins/social-login.d.ts +114 -0
  111. package/dist/plugins/social-login.js +880 -0
  112. package/dist/plugins/tenancy.cjs +780 -0
  113. package/dist/plugins/tenancy.d.cts +108 -0
  114. package/dist/plugins/tenancy.d.ts +108 -0
  115. package/dist/plugins/tenancy.js +753 -0
  116. package/dist/plugins/two-factor.cjs +555 -0
  117. package/dist/plugins/two-factor.d.cts +67 -0
  118. package/dist/plugins/two-factor.d.ts +67 -0
  119. package/dist/plugins/two-factor.js +526 -0
  120. package/dist/plugins/webauthn.cjs +786 -0
  121. package/dist/plugins/webauthn.d.cts +72 -0
  122. package/dist/plugins/webauthn.d.ts +72 -0
  123. package/dist/plugins/webauthn.js +766 -0
  124. package/dist/plugins/webhook.cjs +819 -0
  125. package/dist/plugins/webhook.d.cts +254 -0
  126. package/dist/plugins/webhook.d.ts +254 -0
  127. package/dist/plugins/webhook.js +789 -0
  128. package/dist/protect-D1v_0upK.d.cts +123 -0
  129. package/dist/protect-DsxV_-dJ.d.ts +123 -0
  130. package/dist/sveltekit.cjs +1258 -0
  131. package/dist/sveltekit.d.cts +420 -0
  132. package/dist/sveltekit.d.ts +420 -0
  133. package/dist/sveltekit.js +1207 -0
  134. package/dist/testing.cjs +4294 -0
  135. package/dist/testing.d.cts +197 -0
  136. package/dist/testing.d.ts +197 -0
  137. package/dist/testing.js +4264 -0
  138. package/dist/types-et0R8tsC.d.cts +217 -0
  139. package/dist/types-et0R8tsC.d.ts +217 -0
  140. package/docs/adapter-typed-helpers.md +192 -0
  141. package/docs/admin-recipes.md +227 -0
  142. package/docs/architecture.md +434 -0
  143. package/docs/ci/github-actions.yml +59 -0
  144. package/docs/ci.md +109 -0
  145. package/docs/compatibility.md +115 -0
  146. package/docs/deployment.md +305 -0
  147. package/docs/hardening.md +118 -0
  148. package/docs/host-owned-routes.md +154 -0
  149. package/docs/migrations/0001-schema-version.md +54 -0
  150. package/docs/migrations/0002-initial-schema.md +84 -0
  151. package/docs/migrations/upgrade-guide.md +160 -0
  152. package/docs/observability.md +394 -0
  153. package/docs/plugins/account-lockout.md +131 -0
  154. package/docs/plugins/admin.md +402 -0
  155. package/docs/plugins/api-key.md +327 -0
  156. package/docs/plugins/audit-log.md +261 -0
  157. package/docs/plugins/data-isolation.md +186 -0
  158. package/docs/plugins/email-verification.md +121 -0
  159. package/docs/plugins/magic-link.md +123 -0
  160. package/docs/plugins/oauth.md +544 -0
  161. package/docs/plugins/openapi.md +250 -0
  162. package/docs/plugins/rate-limit.md +223 -0
  163. package/docs/plugins/social-login.md +337 -0
  164. package/docs/plugins/tenancy.md +122 -0
  165. package/docs/plugins/two-factor.md +191 -0
  166. package/docs/plugins/webauthn.md +188 -0
  167. package/docs/plugins/webhook.md +242 -0
  168. package/docs/policy-as-code.md +156 -0
  169. package/docs/route-manifest.md +46 -0
  170. package/docs/security.md +261 -0
  171. package/docs/threat-model.md +161 -0
  172. package/examples/README.md +119 -0
  173. package/examples/express-app/index.ts +747 -0
  174. package/examples/hono-app/docker-compose.yml +14 -0
  175. package/examples/hono-app/index.ts +1009 -0
  176. package/examples/policy/fortress.policy.json +47 -0
  177. package/examples/sveltekit-app/README.md +125 -0
  178. package/examples/sveltekit-app/src/app.d.ts +16 -0
  179. package/examples/sveltekit-app/src/hooks.server.ts +23 -0
  180. package/examples/sveltekit-app/src/lib/server/fortress.ts +81 -0
  181. package/examples/sveltekit-app/src/routes/api/echo/[id]/+server.ts +32 -0
  182. package/examples/sveltekit-app/src/routes/api/fortress/[...path]/+server.ts +15 -0
  183. package/examples/sveltekit-app/src/routes/dashboard/+page.server.ts +20 -0
  184. package/examples/sveltekit-app/src/routes/login/+page.server.ts +48 -0
  185. package/examples/sveltekit-app/src/routes/oauth/consent/+page.server.ts +69 -0
  186. package/examples/sveltekit-app/src/routes/oauth/consent/+page.svelte +83 -0
  187. package/migrations/pg/0001_schema_version.down.sql +2 -0
  188. package/migrations/pg/0001_schema_version.sql +8 -0
  189. package/migrations/pg/0002_initial_schema.down.sql +36 -0
  190. package/migrations/pg/0002_initial_schema.sql +376 -0
  191. package/migrations/pg/0003_auth_continuation.down.sql +6 -0
  192. package/migrations/pg/0003_auth_continuation.sql +30 -0
  193. package/migrations/pg/0004_tenant_default_unique.down.sql +1 -0
  194. package/migrations/pg/0004_tenant_default_unique.sql +14 -0
  195. package/migrations/pg/0005_hot_indexes_timestamptz.down.sql +71 -0
  196. package/migrations/pg/0005_hot_indexes_timestamptz.sql +71 -0
  197. package/migrations/pg/0006_canonical_email.down.sql +3 -0
  198. package/migrations/pg/0006_canonical_email.sql +8 -0
  199. package/migrations/pg/0007_audit_chain_anchor.down.sql +1 -0
  200. package/migrations/pg/0007_audit_chain_anchor.sql +8 -0
  201. package/migrations/pg/0008_two_factor_hardening.down.sql +8 -0
  202. package/migrations/pg/0008_two_factor_hardening.sql +8 -0
  203. package/migrations/pg/0009_encrypt_totp_secrets.down.sql +2 -0
  204. package/migrations/pg/0009_encrypt_totp_secrets.sql +5 -0
  205. package/migrations/pg/0010_bigint_append_only_ids.down.sql +2 -0
  206. package/migrations/pg/0010_bigint_append_only_ids.sql +23 -0
  207. package/migrations/sqlite/0001_schema_version.down.sql +2 -0
  208. package/migrations/sqlite/0001_schema_version.sql +8 -0
  209. package/migrations/sqlite/0002_initial_schema.down.sql +36 -0
  210. package/migrations/sqlite/0002_initial_schema.sql +376 -0
  211. package/migrations/sqlite/0003_auth_continuation.down.sql +27 -0
  212. package/migrations/sqlite/0003_auth_continuation.sql +45 -0
  213. package/migrations/sqlite/0004_tenant_default_unique.down.sql +1 -0
  214. package/migrations/sqlite/0004_tenant_default_unique.sql +13 -0
  215. package/migrations/sqlite/0005_hot_indexes_timestamptz.down.sql +10 -0
  216. package/migrations/sqlite/0005_hot_indexes_timestamptz.sql +10 -0
  217. package/migrations/sqlite/0006_canonical_email.down.sql +3 -0
  218. package/migrations/sqlite/0006_canonical_email.sql +8 -0
  219. package/migrations/sqlite/0007_audit_chain_anchor.down.sql +1 -0
  220. package/migrations/sqlite/0007_audit_chain_anchor.sql +8 -0
  221. package/migrations/sqlite/0008_two_factor_hardening.down.sql +14 -0
  222. package/migrations/sqlite/0008_two_factor_hardening.sql +7 -0
  223. package/migrations/sqlite/0009_encrypt_totp_secrets.down.sql +2 -0
  224. package/migrations/sqlite/0009_encrypt_totp_secrets.sql +5 -0
  225. package/migrations/sqlite/0010_bigint_append_only_ids.down.sql +1 -0
  226. package/migrations/sqlite/0010_bigint_append_only_ids.sql +2 -0
  227. package/package.json +398 -0
  228. package/src/adapters/database/index.ts +63 -0
  229. package/src/adapters/database/types.ts +22 -0
  230. package/src/changelog-script.test.ts +21 -0
  231. package/src/cli.test.ts +79 -0
  232. package/src/core/auth/auth-admin.test.ts +247 -0
  233. package/src/core/auth/auth-endpoints.ts +558 -0
  234. package/src/core/auth/auth-observer.test.ts +191 -0
  235. package/src/core/auth/auth-service.ts +1464 -0
  236. package/src/core/auth/email.test.ts +17 -0
  237. package/src/core/auth/email.ts +11 -0
  238. package/src/core/auth/hooks.test.ts +251 -0
  239. package/src/core/auth/impersonation.test.ts +179 -0
  240. package/src/core/auth/jwt.test.ts +184 -0
  241. package/src/core/auth/jwt.ts +239 -0
  242. package/src/core/auth/login-timing.test.ts +77 -0
  243. package/src/core/auth/password-policy.test.ts +253 -0
  244. package/src/core/auth/password-policy.ts +220 -0
  245. package/src/core/auth/password.test.ts +42 -0
  246. package/src/core/auth/password.ts +62 -0
  247. package/src/core/auth/post-auth-gate.test.ts +258 -0
  248. package/src/core/auth/post-auth-gate.ts +228 -0
  249. package/src/core/auth/refresh-token.test.ts +86 -0
  250. package/src/core/auth/refresh-token.ts +105 -0
  251. package/src/core/auth/timing-safe.ts +23 -0
  252. package/src/core/config.ts +212 -0
  253. package/src/core/endpoint.ts +149 -0
  254. package/src/core/errors.ts +199 -0
  255. package/src/core/fetcher-interop.test.ts +106 -0
  256. package/src/core/fortress.test.ts +354 -0
  257. package/src/core/fortress.ts +550 -0
  258. package/src/core/http/call.test.ts +148 -0
  259. package/src/core/http/call.ts +149 -0
  260. package/src/core/http/cookie-serialize.test.ts +198 -0
  261. package/src/core/http/cookie-serialize.ts +107 -0
  262. package/src/core/http/csrf.test.ts +140 -0
  263. package/src/core/http/csrf.ts +173 -0
  264. package/src/core/http/dispatch.ts +652 -0
  265. package/src/core/http/error-response.test.ts +69 -0
  266. package/src/core/http/error-response.ts +93 -0
  267. package/src/core/http/fortress-rbac.test.ts +43 -0
  268. package/src/core/http/fortress-rbac.ts +127 -0
  269. package/src/core/http/handle-request.test.ts +441 -0
  270. package/src/core/http/handle-request.ts +354 -0
  271. package/src/core/http/match.test.ts +122 -0
  272. package/src/core/http/match.ts +126 -0
  273. package/src/core/http/outbound.test.ts +34 -0
  274. package/src/core/http/outbound.ts +105 -0
  275. package/src/core/http/plugin-middleware.ts +67 -0
  276. package/src/core/http/principal.test.ts +345 -0
  277. package/src/core/http/principal.ts +86 -0
  278. package/src/core/http/protect.test.ts +220 -0
  279. package/src/core/http/protect.ts +394 -0
  280. package/src/core/http/token-extraction.test.ts +59 -0
  281. package/src/core/http/token-extraction.ts +53 -0
  282. package/src/core/iam/explain.test.ts +177 -0
  283. package/src/core/iam/explain.ts +302 -0
  284. package/src/core/iam/iam-endpoints.ts +779 -0
  285. package/src/core/iam/iam-service.test.ts +829 -0
  286. package/src/core/iam/iam-service.ts +1137 -0
  287. package/src/core/iam/permission-cache.test.ts +115 -0
  288. package/src/core/iam/permission-cache.ts +71 -0
  289. package/src/core/iam/permission-check-observer.test.ts +90 -0
  290. package/src/core/iam/permission-evaluator.test.ts +291 -0
  291. package/src/core/iam/permission-evaluator.ts +198 -0
  292. package/src/core/iam/permission-sync.test.ts +166 -0
  293. package/src/core/iam/permission-sync.ts +192 -0
  294. package/src/core/iam/resource-sync.test.ts +70 -0
  295. package/src/core/iam/resource-sync.ts +182 -0
  296. package/src/core/iam/service-account-http.test.ts +529 -0
  297. package/src/core/iam/service-account.test.ts +282 -0
  298. package/src/core/internal-adapter.test.ts +389 -0
  299. package/src/core/internal-adapter.ts +435 -0
  300. package/src/core/json-schema.ts +50 -0
  301. package/src/core/manifest/__snapshots__/route-manifest.test.ts.snap +192 -0
  302. package/src/core/manifest/drift.ts +103 -0
  303. package/src/core/manifest/route-manifest.test.ts +142 -0
  304. package/src/core/manifest/route-manifest.ts +154 -0
  305. package/src/core/migrate.test.ts +72 -0
  306. package/src/core/migrations/engine.test.ts +308 -0
  307. package/src/core/migrations/engine.ts +548 -0
  308. package/src/core/migrations/migrations.ts +1771 -0
  309. package/src/core/migrations/pg-migration.integration-test.ts +353 -0
  310. package/src/core/migrations/upgrade-fixture.test.ts +378 -0
  311. package/src/core/observability/db-instrumentation.ts +205 -0
  312. package/src/core/observability/listener-list.test.ts +124 -0
  313. package/src/core/observability/listener-list.ts +73 -0
  314. package/src/core/observability/logger.test.ts +43 -0
  315. package/src/core/observability/logger.ts +49 -0
  316. package/src/core/observability/spans.test.ts +193 -0
  317. package/src/core/observability/types.ts +80 -0
  318. package/src/core/openapi-drift.test.ts +41 -0
  319. package/src/core/openapi.ts +47 -0
  320. package/src/core/plugin-methods-map.ts +75 -0
  321. package/src/core/plugin-runner.test.ts +233 -0
  322. package/src/core/plugin-runner.ts +276 -0
  323. package/src/core/plugin.ts +210 -0
  324. package/src/core/policy/apply.ts +272 -0
  325. package/src/core/policy/diff.ts +288 -0
  326. package/src/core/policy/loader.test.ts +63 -0
  327. package/src/core/policy/loader.ts +214 -0
  328. package/src/core/policy/policy.test.ts +232 -0
  329. package/src/core/policy/types.ts +105 -0
  330. package/src/core/schema-builder.test.ts +660 -0
  331. package/src/core/schema-builder.ts +881 -0
  332. package/src/core/standard-schema.ts +56 -0
  333. package/src/core/types.ts +258 -0
  334. package/src/core/validation.test.ts +195 -0
  335. package/src/core/validation.ts +192 -0
  336. package/src/drizzle/adapter.test.ts +425 -0
  337. package/src/drizzle/adapter.ts +474 -0
  338. package/src/drizzle/index.ts +31 -0
  339. package/src/drizzle/pg/adapter.integration-test.ts +456 -0
  340. package/src/drizzle/pg/index.ts +13 -0
  341. package/src/drizzle/pg/pg.integration-test.ts +1539 -0
  342. package/src/drizzle/pg/schema.ts +539 -0
  343. package/src/drizzle/pg-error-map.test.ts +218 -0
  344. package/src/drizzle/pg-error-map.ts +151 -0
  345. package/src/drizzle/schema.ts +544 -0
  346. package/src/drizzle/sqlite-serialization.test.ts +106 -0
  347. package/src/express/express-api-key-user-routes.test.ts +241 -0
  348. package/src/express/express.test.ts +442 -0
  349. package/src/express/handle.ts +191 -0
  350. package/src/express/index.ts +62 -0
  351. package/src/express/middleware.ts +551 -0
  352. package/src/express/protect.ts +154 -0
  353. package/src/express/validated.test.ts +86 -0
  354. package/src/express/validated.ts +99 -0
  355. package/src/fetcher/index.ts +81 -0
  356. package/src/hono/convert-routes.test.ts +220 -0
  357. package/src/hono/convert-routes.ts +196 -0
  358. package/src/hono/converters.test.ts +50 -0
  359. package/src/hono/converters.ts +43 -0
  360. package/src/hono/handle.ts +79 -0
  361. package/src/hono/helpers.ts +2 -0
  362. package/src/hono/hono-api-key-user-routes.test.ts +225 -0
  363. package/src/hono/hono-handlers.test.ts +861 -0
  364. package/src/hono/hono.test.ts +454 -0
  365. package/src/hono/index.ts +101 -0
  366. package/src/hono/middleware/auth.ts +236 -0
  367. package/src/hono/middleware/auth.types.test.ts +94 -0
  368. package/src/hono/middleware/csrf.test.ts +127 -0
  369. package/src/hono/middleware/csrf.ts +68 -0
  370. package/src/hono/middleware/error-handler.ts +12 -0
  371. package/src/hono/middleware/plugin-middleware.ts +35 -0
  372. package/src/hono/middleware/rbac.ts +131 -0
  373. package/src/hono/middleware/security-headers.test.ts +88 -0
  374. package/src/hono/middleware/security-headers.ts +68 -0
  375. package/src/hono/openapi.ts +237 -0
  376. package/src/hono/protect.ts +87 -0
  377. package/src/hono/validated.test.ts +123 -0
  378. package/src/hono/validated.ts +62 -0
  379. package/src/index.ts +309 -0
  380. package/src/integration.test.ts +718 -0
  381. package/src/internal/changelog.ts +56 -0
  382. package/src/otel/index.ts +158 -0
  383. package/src/otel/otel-types.test.ts +35 -0
  384. package/src/otel/otel.test.ts +235 -0
  385. package/src/plugins/account-lockout/account-lockout.test.ts +367 -0
  386. package/src/plugins/account-lockout/index.ts +267 -0
  387. package/src/plugins/admin/admin-api-key.test.ts +438 -0
  388. package/src/plugins/admin/index.ts +1612 -0
  389. package/src/plugins/api-key/api-key.test.ts +684 -0
  390. package/src/plugins/api-key/core.ts +336 -0
  391. package/src/plugins/api-key/index.ts +315 -0
  392. package/src/plugins/audit-log/audit-log.test.ts +749 -0
  393. package/src/plugins/audit-log/index.ts +680 -0
  394. package/src/plugins/data-isolation/data-isolation.test.ts +197 -0
  395. package/src/plugins/data-isolation/index.ts +157 -0
  396. package/src/plugins/email-verification/email-verification.test.ts +199 -0
  397. package/src/plugins/email-verification/index.ts +190 -0
  398. package/src/plugins/magic-link/index.ts +129 -0
  399. package/src/plugins/magic-link/magic-link.test.ts +156 -0
  400. package/src/plugins/oauth/id-token.ts +86 -0
  401. package/src/plugins/oauth/index.ts +2145 -0
  402. package/src/plugins/oauth/jwks.test.ts +32 -0
  403. package/src/plugins/oauth/jwks.ts +182 -0
  404. package/src/plugins/oauth/oauth.test.ts +2220 -0
  405. package/src/plugins/oauth/pkce.ts +58 -0
  406. package/src/plugins/openapi/index.ts +298 -0
  407. package/src/plugins/openapi/openapi.test.ts +425 -0
  408. package/src/plugins/openapi/spec-builder.ts +287 -0
  409. package/src/plugins/rate-limit/express.test.ts +89 -0
  410. package/src/plugins/rate-limit/express.ts +86 -0
  411. package/src/plugins/rate-limit/hono.test.ts +60 -0
  412. package/src/plugins/rate-limit/hono.ts +74 -0
  413. package/src/plugins/rate-limit/index.ts +383 -0
  414. package/src/plugins/rate-limit/memory-store.ts +61 -0
  415. package/src/plugins/rate-limit/rate-limit.test.ts +756 -0
  416. package/src/plugins/rate-limit/sveltekit.test.ts +58 -0
  417. package/src/plugins/rate-limit/sveltekit.ts +66 -0
  418. package/src/plugins/social-login/index.ts +715 -0
  419. package/src/plugins/social-login/providers/apple.ts +34 -0
  420. package/src/plugins/social-login/providers/discord.ts +26 -0
  421. package/src/plugins/social-login/providers/github.ts +54 -0
  422. package/src/plugins/social-login/providers/google.ts +23 -0
  423. package/src/plugins/social-login/providers/index.ts +22 -0
  424. package/src/plugins/social-login/providers/microsoft.ts +35 -0
  425. package/src/plugins/social-login/providers/oidc.ts +37 -0
  426. package/src/plugins/social-login/providers/providers.test.ts +211 -0
  427. package/src/plugins/social-login/social-login.test.ts +675 -0
  428. package/src/plugins/social-login/types.ts +80 -0
  429. package/src/plugins/tenancy/index.ts +544 -0
  430. package/src/plugins/tenancy/tenancy.test.ts +323 -0
  431. package/src/plugins/two-factor/index.ts +569 -0
  432. package/src/plugins/two-factor/two-factor.test.ts +235 -0
  433. package/src/plugins/webauthn/index.ts +554 -0
  434. package/src/plugins/webauthn/webauthn.test.ts +472 -0
  435. package/src/plugins/webhook/builtin-events.ts +29 -0
  436. package/src/plugins/webhook/delivery.test.ts +51 -0
  437. package/src/plugins/webhook/delivery.ts +154 -0
  438. package/src/plugins/webhook/hooks.ts +89 -0
  439. package/src/plugins/webhook/index.ts +445 -0
  440. package/src/plugins/webhook/queue/database.ts +67 -0
  441. package/src/plugins/webhook/queue/in-memory.test.ts +48 -0
  442. package/src/plugins/webhook/queue/in-memory.ts +71 -0
  443. package/src/plugins/webhook/queue/types.ts +51 -0
  444. package/src/plugins/webhook/registry.test.ts +48 -0
  445. package/src/plugins/webhook/registry.ts +64 -0
  446. package/src/plugins/webhook/signing.ts +45 -0
  447. package/src/plugins/webhook/ssrf.ts +198 -0
  448. package/src/plugins/webhook/types.ts +138 -0
  449. package/src/plugins/webhook/webhook.test.ts +324 -0
  450. package/src/sveltekit/actions.ts +233 -0
  451. package/src/sveltekit/catch-all.ts +43 -0
  452. package/src/sveltekit/cookies.ts +136 -0
  453. package/src/sveltekit/handle.ts +356 -0
  454. package/src/sveltekit/helpers.ts +69 -0
  455. package/src/sveltekit/index.ts +74 -0
  456. package/src/sveltekit/protect.ts +80 -0
  457. package/src/sveltekit/sveltekit-types.test.ts +31 -0
  458. package/src/sveltekit/sveltekit.test.ts +799 -0
  459. package/src/sveltekit/types.ts +134 -0
  460. package/src/sveltekit/validated.test.ts +117 -0
  461. package/src/sveltekit/validated.ts +78 -0
  462. package/src/testing/adapter-conformance.test.ts +408 -0
  463. package/src/testing/checks.test.ts +124 -0
  464. package/src/testing/checks.ts +304 -0
  465. package/src/testing/index.ts +107 -0
@@ -0,0 +1,1464 @@
1
+ import type { DatabaseAdapter } from '../../adapters/database';
2
+ import type { FortressConfig, PasswordHasher } from '../config';
3
+ import type { StoredContinuation, StoredRefreshToken } from '../internal-adapter';
4
+ import type { Unsubscribe } from '../observability/listener-list';
5
+ import type { FortressLogger } from '../observability/logger';
6
+ import type { Histogram, TelemetryProvider } from '../observability/types';
7
+ import type {
8
+ AfterHookContext,
9
+ FortressPlugin,
10
+ HookContext,
11
+ HookResult,
12
+ } from '../plugin';
13
+ import type {
14
+ AuthMethod,
15
+ AuthResult,
16
+ AuthSuccess,
17
+ AuthTokenPair,
18
+ CreateUserInput,
19
+ FortressUser,
20
+ LoginIdentifier,
21
+ LoginIdentifierType,
22
+ PendingReason,
23
+ RequestMeta,
24
+ SessionInfo,
25
+ TokenClaims,
26
+ } from '../types';
27
+ import type { JwtKeyMaterial } from './jwt';
28
+ import type { PasswordPolicyObserver } from './password-policy';
29
+ import { Errors, FortressError } from '../errors';
30
+ import { evaluatePermissions } from '../iam/permission-evaluator';
31
+ import { createInternalAdapter } from '../internal-adapter';
32
+ import { createListenerList } from '../observability/listener-list';
33
+ import { SILENT_LOGGER } from '../observability/logger';
34
+ import { normalizeEmail } from './email';
35
+ import { signAccessToken, verifyAccessToken } from './jwt';
36
+ import { createDefaultHasher, normalizePasswordInput } from './password';
37
+ import { validatePassword } from './password-policy';
38
+ import { consumeAuthContinuation, runPostAuthGates, verifyAuthContinuation } from './post-auth-gate';
39
+ import { deriveRefreshTokenSuccessor, generateRefreshToken, generateTokenFamily, hashRefreshFingerprint, hashToken } from './refresh-token';
40
+
41
+ const NUMERIC_SUBJECT_ID_RE = /^\d+$/;
42
+
43
+ /**
44
+ * Lifecycle event emitted by the auth service. Mirrors the IAM observer
45
+ * pattern (`addIamObserver`) so consumers — audit log, SIEM webhooks,
46
+ * telemetry plugins, Datadog adapters — can subscribe to auth events
47
+ * without reimplementing every hook individually.
48
+ */
49
+ export interface AuthEvent {
50
+ eventType:
51
+ | 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGIN_PENDING'
52
+ | 'LOGOUT' | 'REGISTER'
53
+ | 'TOKEN_REFRESH' | 'TOKEN_REUSE_DETECTED' | 'TOKEN_REUSE_GRACED' | 'TOKEN_FINGERPRINT_MISMATCH'
54
+ | 'MFA_VERIFY_SUCCESS' | 'MFA_VERIFY_FAILURE'
55
+ | 'SESSION_EXPIRED_IDLE' | 'SESSION_EXPIRED_ABSOLUTE'
56
+ | 'PASSWORD_BREACH_CHECK_DEGRADED'
57
+ | 'IMPERSONATE';
58
+ actorId?: string;
59
+ identifier?: string;
60
+ method?: 'password' | 'oauth' | 'magic_link' | 'webauthn' | '2fa' | 'api_key';
61
+ ipAddress?: string;
62
+ userAgent?: string;
63
+ outcome?: 'success' | 'failure' | 'pending';
64
+ /** Set on `LOGIN_PENDING` — which additional step the sign-in is waiting on. */
65
+ pendingReason?: PendingReason;
66
+ /** Free-form sub-action label (e.g. the specific fingerprint/session action that fired the event). */
67
+ action?: string;
68
+ error?: { message: string; code?: string };
69
+ metadata?: Record<string, unknown>;
70
+ }
71
+
72
+ /**
73
+ * Async auth event listener. May return a Promise — if you `return` it, any
74
+ * rejection is routed to `config.logger.error`. Firing work via
75
+ * `void asyncWork()` inside a sync body is an explicit opt-out of that
76
+ * safety net; rejections will escape to the runtime's unhandled-rejection
77
+ * handler instead.
78
+ */
79
+ export type AuthEventListener = (event: AuthEvent) => void | Promise<void>;
80
+
81
+ interface ResolvedConfig {
82
+ key: JwtKeyMaterial;
83
+ issuer: string;
84
+ audience?: string | string[];
85
+ accessTokenExpiry: number;
86
+ refreshTokenExpiry: number;
87
+ }
88
+
89
+ function resolveConfig(config: FortressConfig): ResolvedConfig {
90
+ return {
91
+ key: config.jwt.key,
92
+ issuer: config.jwt.issuer ?? 'fortress',
93
+ audience: config.jwt.audience,
94
+ accessTokenExpiry: config.jwt.accessTokenExpirySeconds ?? 900,
95
+ refreshTokenExpiry: config.jwt.refreshTokenExpirySeconds ?? 604800,
96
+ };
97
+ }
98
+
99
+ export interface AuthService {
100
+ login: (identifier: string, password: string, meta?: RequestMeta) => Promise<AuthResult>;
101
+ /** Consume a verified factor's continuation, rerun remaining gates, then issue tokens. */
102
+ completePendingAuth: (continuationToken: string, completion: unknown, meta?: RequestMeta) => Promise<AuthResult>;
103
+ /** Finish a trusted plugin-owned primary credential (for example a magic link). */
104
+ completePluginAuth: (
105
+ userId: string,
106
+ method: Exclude<AuthMethod, 'refresh' | 'impersonation'>,
107
+ meta?: RequestMeta,
108
+ ) => Promise<AuthResult>;
109
+ refresh: (refreshToken: string, meta?: RequestMeta) => Promise<AuthTokenPair>;
110
+ logout: (refreshToken: string) => Promise<void>;
111
+ me: (userId: string) => Promise<FortressUser>;
112
+ createUser: (data: CreateUserInput) => Promise<FortressUser>;
113
+ verifyToken: (token: string) => Promise<TokenClaims>;
114
+ signToken: (claims: Omit<TokenClaims, 'iat' | 'exp' | 'aud'>) => Promise<string>;
115
+ listSessions: (userId: string) => Promise<SessionInfo[]>;
116
+ revokeSession: (userId: string, tokenId: string) => Promise<void>;
117
+ revokeAllOtherSessions: (userId: string, currentTokenId: string) => Promise<void>;
118
+ addLoginIdentifier: (userId: string, type: LoginIdentifierType, value: string) => Promise<void>;
119
+ removeLoginIdentifier: (userId: string, type: LoginIdentifierType, value: string) => Promise<void>;
120
+ getLoginIdentifiers: (userId: string) => Promise<LoginIdentifier[]>;
121
+ /**
122
+ * Issue a short-lived, non-renewable access token that lets an admin act as another user.
123
+ * The token carries an RFC 8693 `act` claim identifying the real admin.
124
+ *
125
+ * Requires the admin user to hold `fortress:impersonate`. The built-in HTTP route also enforces this via endpoint metadata.
126
+ */
127
+ impersonate: (adminUserId: string, targetUserId: string, options?: { reason?: string; expirySeconds?: number }) => Promise<AuthResult>;
128
+
129
+ // ── Admin user management ──────────────────────────────────────────
130
+ listUsers: (options: { limit?: number; offset?: number; search?: string; sortBy?: string; sortDirection?: 'asc' | 'desc' }) => Promise<{ users: FortressUser[]; total: number }>;
131
+ getUserById: (userId: string) => Promise<FortressUser>;
132
+ updateUser: (userId: string, data: { name?: string; email?: string; isActive?: boolean; password?: string }) => Promise<FortressUser>;
133
+ deleteUser: (userId: string) => Promise<void>;
134
+
135
+ /**
136
+ * Register a listener for auth lifecycle events. Multiple listeners are
137
+ * supported — each is invoked in registration order. Listener failures
138
+ * never break auth operations; they are routed to the configured logger
139
+ * at `error` level. Returns an unsubscribe function.
140
+ */
141
+ addAuthObserver: (listener: AuthEventListener) => Unsubscribe;
142
+ }
143
+
144
+ export interface AuthServiceDeps {
145
+ logger: FortressLogger;
146
+ telemetry: TelemetryProvider;
147
+ /**
148
+ * Optional histogram instrument for token-verify latency. Provided by
149
+ * `createFortress` from the resolved telemetry provider — pulled in as a
150
+ * dep rather than built inside `auth-service` so the metric catalog
151
+ * stays centralized in one file.
152
+ */
153
+ tokenVerifyDuration?: Histogram;
154
+ }
155
+
156
+ export function createAuthService(
157
+ db: DatabaseAdapter,
158
+ config: FortressConfig,
159
+ plugins: readonly FortressPlugin[] = [],
160
+ deps?: AuthServiceDeps,
161
+ ): AuthService {
162
+ const resolved = resolveConfig(config);
163
+ const evaluationMode = config.rbac?.evaluationMode ?? 'allow-only';
164
+ const hasher: PasswordHasher = config.passwordHasher ?? createDefaultHasher();
165
+ // Real, well-formed Argon2 reference hash used as the timing-oracle dummy in
166
+ // the login "user not found / no password" branch. Computing a real hash here
167
+ // (rather than a hard-coded malformed PHC string) guarantees that the
168
+ // verify() call in that branch runs the *full* KDF and matches the cost of
169
+ // verifying a real user's password, so attackers can't distinguish
170
+ // "user exists" from "user not found" via response timing. Lazily computed
171
+ // once on first miss to avoid paying ~100ms at service construction.
172
+ let dummyHashPromise: Promise<string> | null = null;
173
+ const getDummyHash = (): Promise<string> => {
174
+ if (!dummyHashPromise) {
175
+ // Random input -> the hash is never going to verify against any real
176
+ // password the attacker controls, and is never persisted.
177
+ dummyHashPromise = hasher.hash(`fortress-timing-dummy-${crypto.randomUUID()}`);
178
+ }
179
+ return dummyHashPromise;
180
+ };
181
+ const adapter = createInternalAdapter(db);
182
+ const logger = deps?.logger;
183
+ const tokenVerifyDuration = deps?.tokenVerifyDuration;
184
+
185
+ const authEventListeners = createListenerList<AuthEvent>({
186
+ kind: 'async',
187
+ eventLabel: 'auth',
188
+ logger: () => logger ?? SILENT_LOGGER,
189
+ });
190
+
191
+ const passwordPolicyObserver: PasswordPolicyObserver = (event) => {
192
+ logger?.warn(
193
+ { failureMode: event.failureMode, status: event.status, error: event.error },
194
+ 'password breach check degraded',
195
+ );
196
+ authEventListeners.emit({
197
+ eventType: 'PASSWORD_BREACH_CHECK_DEGRADED',
198
+ outcome: 'failure',
199
+ metadata: {
200
+ failureMode: event.failureMode,
201
+ ...(event.status !== undefined && { status: event.status }),
202
+ ...(event.error instanceof Error && { error: event.error.message }),
203
+ },
204
+ });
205
+ };
206
+
207
+ async function runBeforeHooks<T extends Record<string, unknown>>(
208
+ hookName: 'beforeLogin' | 'beforeRegister' | 'beforeTokenRefresh' | 'beforeLogout',
209
+ ctx: HookContext & T,
210
+ ): Promise<HookResult | void> {
211
+ for (const plugin of plugins) {
212
+ const hook = plugin.hooks?.[hookName] as ((ctx: HookContext & T) => Promise<HookResult | void>) | undefined;
213
+ if (hook) {
214
+ const result = await hook(ctx);
215
+ if (result?.stop)
216
+ return result;
217
+ }
218
+ }
219
+ }
220
+
221
+ // After-* hooks run once the authentication decision is committed and the
222
+ // session tokens have already been issued/persisted. A failing side-effect
223
+ // hook (audit, webhook, analytics) must not roll back a completed login —
224
+ // doing so would return an error to the caller while leaving a live session
225
+ // behind, and would let any plugin brick authentication by throwing. So a
226
+ // throwing after-hook is logged and skipped (keeping the last good result),
227
+ // mirroring runOnLoginFailureHooks. Plugins that need to *veto* a login must
228
+ // use a pre-issuance hook (beforeLogin / postAuthGate), not afterLogin.
229
+ async function runAfterLoginHooks(
230
+ ctx: AfterHookContext,
231
+ result: AuthSuccess,
232
+ ): Promise<AuthSuccess> {
233
+ let current = result;
234
+ for (const plugin of plugins) {
235
+ if (plugin.hooks?.afterLogin) {
236
+ try {
237
+ current = await plugin.hooks.afterLogin(ctx, current);
238
+ }
239
+ catch (hookError) {
240
+ logger?.error({ plugin: plugin.name, error: hookError }, 'afterLogin hook failed');
241
+ }
242
+ }
243
+ }
244
+ return current;
245
+ }
246
+
247
+ async function runAfterRefreshHooks(
248
+ ctx: AfterHookContext,
249
+ result: AuthTokenPair,
250
+ ): Promise<AuthTokenPair> {
251
+ let current = result;
252
+ for (const plugin of plugins) {
253
+ if (plugin.hooks?.afterTokenRefresh) {
254
+ try {
255
+ current = await plugin.hooks.afterTokenRefresh(ctx, current);
256
+ }
257
+ catch (hookError) {
258
+ logger?.error({ plugin: plugin.name, error: hookError }, 'afterTokenRefresh hook failed');
259
+ }
260
+ }
261
+ }
262
+ return current;
263
+ }
264
+
265
+ async function runOnLoginFailureHooks(identifier: string, error: Error): Promise<void> {
266
+ for (const plugin of plugins) {
267
+ if (!plugin.hooks?.onLoginFailure)
268
+ continue;
269
+ try {
270
+ await plugin.hooks.onLoginFailure({ db, config, identifier, error });
271
+ }
272
+ catch (hookError) {
273
+ logger?.error(
274
+ { plugin: plugin.name, error: hookError },
275
+ 'onLoginFailure hook failed',
276
+ );
277
+ }
278
+ }
279
+ }
280
+
281
+ async function enrichClaims(userId: string): Promise<Record<string, unknown>> {
282
+ const customClaims: Record<string, unknown> = {};
283
+ for (const plugin of plugins) {
284
+ if (plugin.enrichTokenClaims) {
285
+ const claims = await plugin.enrichTokenClaims(userId, { db, config });
286
+ if (process.env.NODE_ENV !== 'production') {
287
+ for (const key of Object.keys(claims)) {
288
+ if (key in customClaims) {
289
+ logger?.warn(
290
+ { plugin: plugin.name, claim: key },
291
+ `plugin overwrites token claim '${key}'`,
292
+ );
293
+ }
294
+ }
295
+ }
296
+ Object.assign(customClaims, claims);
297
+ }
298
+ }
299
+ return customClaims;
300
+ }
301
+
302
+ const getUserGroups = adapter.getUserGroups;
303
+
304
+ const jwtSecrets = Array.isArray(resolved.key) ? resolved.key : [resolved.key];
305
+ async function computeFingerprintHash(meta: RequestMeta, secret = jwtSecrets[0]!): Promise<string | null> {
306
+ return meta.userAgent
307
+ ? hashRefreshFingerprint(meta.userAgent, meta.ipAddress, secret)
308
+ : null;
309
+ }
310
+ async function fingerprintMatches(stored: string, meta: RequestMeta): Promise<boolean> {
311
+ if (!meta.userAgent)
312
+ return false;
313
+ for (const secret of jwtSecrets) {
314
+ if (await hashRefreshFingerprint(meta.userAgent, meta.ipAddress, secret) === stored)
315
+ return true;
316
+ }
317
+ return false;
318
+ }
319
+
320
+ async function issueAccessToken(
321
+ user: FortressUser,
322
+ resolveGroups: (userId: string) => Promise<string[]>,
323
+ ): Promise<string> {
324
+ const groups = await resolveGroups(user.id);
325
+ const customClaims = await enrichClaims(user.id);
326
+ return signAccessToken(
327
+ {
328
+ sub: user.id,
329
+ subjectType: 'USER',
330
+ name: user.name,
331
+ groups,
332
+ iss: resolved.issuer,
333
+ customClaims: Object.keys(customClaims).length > 0 ? customClaims : undefined,
334
+ },
335
+ resolved.key,
336
+ resolved.accessTokenExpiry,
337
+ { audience: resolved.audience },
338
+ );
339
+ }
340
+
341
+ async function issueTokens(
342
+ user: FortressUser,
343
+ meta?: RequestMeta,
344
+ ): Promise<{ accessToken: string; refreshToken: string }> {
345
+ const accessToken = await issueAccessToken(user, getUserGroups);
346
+ const { raw, hash } = await generateRefreshToken();
347
+ const family = generateTokenFamily();
348
+ const issuedAt = new Date();
349
+
350
+ const fingerprintHash = meta ? await computeFingerprintHash(meta) : null;
351
+
352
+ const maxSessions = config.jwt.session?.maxSessionsPerUser;
353
+ const persistSession = async (target: DatabaseAdapter): Promise<void> => {
354
+ if (maxSessions != null && maxSessions > 0) {
355
+ // PostgreSQL needs a per-user lock; transaction isolation alone lets
356
+ // concurrent logins both observe spare capacity. A no-op update takes
357
+ // that row lock without bypassing DatabaseAdapter model/table mapping.
358
+ // SQLite's BEGIN IMMEDIATE transaction already serializes writers.
359
+ if (target.dialect === 'pg') {
360
+ const locked = await target.update({
361
+ model: 'user',
362
+ where: [{ field: 'id', operator: '=', value: user.id }],
363
+ // Reassign the immutable primary key to itself: this acquires the
364
+ // row lock without risking stale mutable-field writeback.
365
+ data: { id: user.id },
366
+ });
367
+ if (!locked)
368
+ throw Errors.notFound('User not found');
369
+ }
370
+
371
+ const active = (await target.findMany<StoredRefreshToken>({
372
+ model: 'refresh_token',
373
+ where: [
374
+ { field: 'userId', operator: '=', value: user.id },
375
+ { field: 'isRevoked', operator: '=', value: false },
376
+ ],
377
+ sortBy: { field: 'familyCreatedAt', direction: 'asc' },
378
+ })).filter(token => token.expiresAt > issuedAt);
379
+ const overflow = active.length - maxSessions + 1;
380
+ for (const token of active.slice(0, Math.max(0, overflow))) {
381
+ await target.update({
382
+ model: 'refresh_token',
383
+ where: [{ field: 'tokenFamily', operator: '=', value: token.tokenFamily }],
384
+ data: { isRevoked: true },
385
+ });
386
+ }
387
+ }
388
+
389
+ await target.create({
390
+ model: 'refresh_token',
391
+ data: {
392
+ userId: user.id,
393
+ tokenHash: hash,
394
+ tokenFamily: family,
395
+ familyCreatedAt: issuedAt,
396
+ successorTokenHash: null,
397
+ rotatedAt: null,
398
+ isRevoked: false,
399
+ expiresAt: new Date(issuedAt.getTime() + resolved.refreshTokenExpiry * 1000),
400
+ ipAddress: meta?.ipAddress ?? null,
401
+ userAgent: meta?.userAgent ?? null,
402
+ deviceName: meta?.deviceName ?? null,
403
+ lastActiveAt: issuedAt,
404
+ fingerprintHash,
405
+ },
406
+ });
407
+ };
408
+
409
+ if (maxSessions != null)
410
+ await db.transaction(persistSession);
411
+ else
412
+ await persistSession(db);
413
+
414
+ return { accessToken, refreshToken: raw };
415
+ }
416
+
417
+ type CompletedAuthMethod = Exclude<AuthMethod, 'refresh' | 'impersonation'>;
418
+
419
+ function eventMethodFor(method: CompletedAuthMethod): AuthEvent['method'] {
420
+ switch (method) {
421
+ case 'two-factor':
422
+ return '2fa';
423
+ case 'magic-link':
424
+ return 'magic_link';
425
+ default:
426
+ return method;
427
+ }
428
+ }
429
+
430
+ async function finishAuthentication(
431
+ user: FortressUser,
432
+ method: CompletedAuthMethod,
433
+ meta?: RequestMeta,
434
+ identifier?: string,
435
+ completedReasons: readonly PendingReason[] = [],
436
+ pluginData?: Record<string, unknown>,
437
+ ): Promise<AuthResult> {
438
+ const hold = await runPostAuthGates(plugins, db, config, user, meta, completedReasons);
439
+ if (hold) {
440
+ const pending: AuthResult = {
441
+ status: 'pending',
442
+ user,
443
+ pending: hold.challenge,
444
+ pluginData: { ...hold.pluginData, ...pluginData },
445
+ };
446
+ if (authEventListeners.size() > 0) {
447
+ authEventListeners.emit({
448
+ eventType: 'LOGIN_PENDING',
449
+ actorId: user.id,
450
+ identifier,
451
+ method: eventMethodFor(method),
452
+ ipAddress: meta?.ipAddress,
453
+ userAgent: meta?.userAgent,
454
+ outcome: 'pending',
455
+ pendingReason: hold.challenge.reason,
456
+ });
457
+ }
458
+ return pending;
459
+ }
460
+
461
+ const tokens = await issueTokens(user, meta);
462
+ let response: AuthSuccess = {
463
+ status: 'success',
464
+ user,
465
+ method,
466
+ accessToken: tokens.accessToken,
467
+ refreshToken: tokens.refreshToken,
468
+ ...(pluginData && Object.keys(pluginData).length > 0 ? { pluginData } : {}),
469
+ };
470
+
471
+ const afterCtx: AfterHookContext = { db, config, meta, responseHeaders: new Headers(), identifier };
472
+ response = await runAfterLoginHooks(afterCtx, response);
473
+
474
+ if (authEventListeners.size() > 0) {
475
+ authEventListeners.emit({
476
+ eventType: 'LOGIN_SUCCESS',
477
+ actorId: user.id,
478
+ identifier,
479
+ method: eventMethodFor(method),
480
+ ipAddress: meta?.ipAddress,
481
+ userAgent: meta?.userAgent,
482
+ outcome: 'success',
483
+ });
484
+ }
485
+
486
+ return response;
487
+ }
488
+
489
+ return {
490
+ async login(identifier: string, password: string, meta?: RequestMeta): Promise<AuthResult> {
491
+ const normalizedPassword = normalizePasswordInput(password);
492
+ const hookCtx: HookContext & { email: string } = { db, config, meta, email: identifier };
493
+ const beforeResult = await runBeforeHooks('beforeLogin', hookCtx);
494
+ if (beforeResult?.stop) {
495
+ return beforeResult.response as unknown as AuthResult;
496
+ }
497
+
498
+ // Resolve user via login_identifier first, fall back to email on user table
499
+ let user: (FortressUser & { passwordHash: string | null }) | null;
500
+ try {
501
+ user = await adapter.findUserByIdentifier(identifier);
502
+
503
+ if (!user || !user.passwordHash) {
504
+ // Run a real Argon2 verify against a well-formed reference hash so
505
+ // this branch takes the same wall-clock time as verifying a real
506
+ // user's password. The previous hard-coded PHC string was malformed
507
+ // (`$...$dummy`), so hash-wasm's parser threw before running the
508
+ // KDF and the branch completed in ~0.3ms instead of ~100ms — a
509
+ // trivial timing oracle for user enumeration.
510
+ await hasher.verify(await getDummyHash(), normalizedPassword).catch(() => {});
511
+ throw Errors.unauthorized('Invalid credentials');
512
+ }
513
+
514
+ // Constant-time + constant-message handling for disabled accounts:
515
+ // still run the Argon2 verify so an attacker can't distinguish
516
+ // "account exists but disabled" from "wrong password" via timing
517
+ // *or* via error message. Both paths return the generic
518
+ // `Invalid credentials` (M3).
519
+ const valid = await hasher.verify(user.passwordHash, normalizedPassword);
520
+ if (!user.isActive || !valid) {
521
+ throw Errors.unauthorized('Invalid credentials');
522
+ }
523
+ }
524
+ catch (error) {
525
+ await runOnLoginFailureHooks(identifier, error as Error);
526
+ if (authEventListeners.size() > 0) {
527
+ const err = error instanceof Error ? error : new Error(String(error));
528
+ authEventListeners.emit({
529
+ eventType: 'LOGIN_FAILURE',
530
+ identifier,
531
+ method: 'password',
532
+ ipAddress: meta?.ipAddress,
533
+ userAgent: meta?.userAgent,
534
+ outcome: 'failure',
535
+ error: {
536
+ message: err.message,
537
+ code: err instanceof FortressError ? err.code : undefined,
538
+ },
539
+ });
540
+ }
541
+ throw error;
542
+ }
543
+
544
+ const { passwordHash: _, ...safeUser } = user;
545
+ return finishAuthentication(safeUser, 'password', meta, identifier);
546
+ },
547
+
548
+ async completePluginAuth(
549
+ userId: string,
550
+ method: CompletedAuthMethod,
551
+ meta?: RequestMeta,
552
+ ): Promise<AuthResult> {
553
+ const storedUser = await db.findOne<FortressUser & { passwordHash?: string | null }>({
554
+ model: 'user',
555
+ where: [{ field: 'id', operator: '=', value: userId }],
556
+ });
557
+ if (!storedUser || !storedUser.isActive)
558
+ throw Errors.unauthorized('User not found or disabled');
559
+ const { passwordHash: _, ...user } = storedUser;
560
+ return finishAuthentication(user, method, meta);
561
+ },
562
+
563
+ async completePendingAuth(continuationToken: string, completion: unknown, meta?: RequestMeta): Promise<AuthResult> {
564
+ let attempted: { reason: PendingReason; userId: string } | undefined;
565
+ let verificationData: Record<string, unknown> | undefined;
566
+ let continuation: StoredContinuation;
567
+ try {
568
+ continuation = await consumeAuthContinuation(db, continuationToken, async (tx, pending) => {
569
+ attempted = { reason: pending.reason, userId: pending.userId };
570
+ const storedUser = await tx.findOne<FortressUser & { passwordHash?: string | null }>({
571
+ model: 'user',
572
+ where: [{ field: 'id', operator: '=', value: pending.userId }],
573
+ });
574
+ if (!storedUser || !storedUser.isActive)
575
+ throw Errors.unauthorized('User not found or disabled');
576
+ const { passwordHash: _, ...user } = storedUser;
577
+ verificationData = (await verifyAuthContinuation(plugins, tx, config, user, pending, completion, meta)) ?? undefined;
578
+ });
579
+ }
580
+ catch (error) {
581
+ if (attempted && (attempted.reason === 'two-factor' || attempted.reason === 'webauthn')) {
582
+ authEventListeners.emit({
583
+ eventType: 'MFA_VERIFY_FAILURE',
584
+ actorId: attempted.userId,
585
+ method: attempted.reason === 'two-factor' ? '2fa' : 'webauthn',
586
+ ipAddress: meta?.ipAddress,
587
+ userAgent: meta?.userAgent,
588
+ outcome: 'failure',
589
+ error: { message: error instanceof Error ? error.message : String(error) },
590
+ });
591
+ // A wrong second factor is a failed authentication attempt: feed it
592
+ // into the onLoginFailure hooks (account-lockout, audit, webhook)
593
+ // keyed by the user's identifier so a brute-forced 2FA/WebAuthn step
594
+ // is throttled and locked out like a password failure, not just
595
+ // capped per continuation. Guarded so a lookup/hook error can never
596
+ // mask the original verification error.
597
+ try {
598
+ const failedUser = await db.findOne<FortressUser>({
599
+ model: 'user',
600
+ where: [{ field: 'id', operator: '=', value: attempted.userId }],
601
+ });
602
+ if (failedUser?.email)
603
+ await runOnLoginFailureHooks(failedUser.email, error as Error);
604
+ }
605
+ catch (lockoutError) {
606
+ logger?.error({ error: lockoutError }, 'failed to record MFA failure for account lockout');
607
+ }
608
+ }
609
+ throw error;
610
+ }
611
+
612
+ if (continuation.reason === 'two-factor' || continuation.reason === 'webauthn') {
613
+ authEventListeners.emit({
614
+ eventType: 'MFA_VERIFY_SUCCESS',
615
+ actorId: continuation.userId,
616
+ method: continuation.reason === 'two-factor' ? '2fa' : 'webauthn',
617
+ ipAddress: meta?.ipAddress,
618
+ userAgent: meta?.userAgent,
619
+ outcome: 'success',
620
+ });
621
+ }
622
+
623
+ const storedUser = await db.findOne<FortressUser & { passwordHash?: string | null }>({
624
+ model: 'user',
625
+ where: [{ field: 'id', operator: '=', value: continuation.userId }],
626
+ });
627
+ if (!storedUser || !storedUser.isActive)
628
+ throw Errors.unauthorized('User not found or disabled');
629
+ const { passwordHash: _, ...refreshedUser } = storedUser;
630
+
631
+ const method: CompletedAuthMethod = continuation.reason === 'two-factor'
632
+ ? 'two-factor'
633
+ : continuation.reason === 'magic-link'
634
+ ? 'magic-link'
635
+ : continuation.reason === 'webauthn'
636
+ ? 'webauthn'
637
+ : 'password';
638
+
639
+ return finishAuthentication(refreshedUser, method, meta, undefined, [continuation.reason], verificationData);
640
+ },
641
+
642
+ async refresh(refreshToken: string, meta?: RequestMeta): Promise<AuthTokenPair> {
643
+ const hookCtx: HookContext & { token: string } = { db, config, meta, token: refreshToken };
644
+ const beforeResult = await runBeforeHooks('beforeTokenRefresh', hookCtx);
645
+ if (beforeResult?.stop) {
646
+ return beforeResult.response as unknown as AuthTokenPair;
647
+ }
648
+
649
+ const tokenHash = await hashToken(refreshToken);
650
+
651
+ const txResult = await db.transaction(async (tx) => {
652
+ const txAdapter = createInternalAdapter(tx);
653
+
654
+ // Atomic compare-and-set claim: exactly one concurrent refresh can
655
+ // flip isRevoked=false → true for this token hash. Losers see null
656
+ // and take the replay path below.
657
+ const stored = await tx.update<StoredRefreshToken>({
658
+ model: 'refresh_token',
659
+ where: [
660
+ { field: 'tokenHash', operator: '=', value: tokenHash },
661
+ { field: 'isRevoked', operator: '=', value: false },
662
+ ],
663
+ data: { isRevoked: true },
664
+ });
665
+
666
+ if (!stored) {
667
+ const reused = await txAdapter.findRefreshTokenByHash(tokenHash);
668
+ if (!reused)
669
+ throw Errors.unauthorized('Invalid refresh token');
670
+
671
+ const graceSeconds = config.jwt.session?.refreshGraceSeconds;
672
+ const now = new Date();
673
+ const withinGrace = graceSeconds != null
674
+ && graceSeconds > 0
675
+ && reused.rotatedAt != null
676
+ && now.getTime() - reused.rotatedAt.getTime() <= graceSeconds * 1000;
677
+ if (withinGrace && reused.successorTokenHash) {
678
+ const fingerprintMismatch = reused.fingerprintHash != null
679
+ && (!meta || !(await fingerprintMatches(reused.fingerprintHash, meta)));
680
+ const fingerprintAllowsGrace = !fingerprintMismatch
681
+ || config.jwt.validateRefreshFingerprint !== true;
682
+ if (fingerprintMismatch && config.jwt.validateRefreshFingerprint === 'warn') {
683
+ logger?.warn(
684
+ { tokenFamily: reused.tokenFamily },
685
+ 'refresh token fingerprint mismatch during grace recovery',
686
+ );
687
+ authEventListeners.emit({
688
+ eventType: 'TOKEN_FINGERPRINT_MISMATCH',
689
+ actorId: reused.userId,
690
+ ipAddress: meta?.ipAddress,
691
+ userAgent: meta?.userAgent,
692
+ metadata: { tokenFamily: reused.tokenFamily, action: 'grace-recovery' },
693
+ });
694
+ }
695
+ if (fingerprintAllowsGrace) {
696
+ const secrets = Array.isArray(resolved.key) ? resolved.key : [resolved.key];
697
+ let successorToken: Awaited<ReturnType<typeof deriveRefreshTokenSuccessor>> | null = null;
698
+ for (const secret of secrets) {
699
+ const candidate = await deriveRefreshTokenSuccessor(refreshToken, secret);
700
+ if (candidate.hash === reused.successorTokenHash) {
701
+ successorToken = candidate;
702
+ break;
703
+ }
704
+ }
705
+ const successor = successorToken
706
+ ? await tx.findOne<StoredRefreshToken>({
707
+ model: 'refresh_token',
708
+ where: [
709
+ { field: 'tokenHash', operator: '=', value: successorToken.hash },
710
+ { field: 'isRevoked', operator: '=', value: false },
711
+ { field: 'expiresAt', operator: 'gt', value: now },
712
+ ],
713
+ })
714
+ : null;
715
+ if (successor) {
716
+ const absoluteTimeout = config.jwt.session?.absoluteTimeoutSeconds;
717
+ if (absoluteTimeout != null && absoluteTimeout > 0 && now.getTime() - successor.familyCreatedAt.getTime() > absoluteTimeout * 1000) {
718
+ await tx.update({
719
+ model: 'refresh_token',
720
+ where: [{ field: 'tokenFamily', operator: '=', value: successor.tokenFamily }],
721
+ data: { isRevoked: true },
722
+ });
723
+ return {
724
+ sessionExpired: 'absolute' as const,
725
+ userId: successor.userId,
726
+ tokenFamily: successor.tokenFamily,
727
+ };
728
+ }
729
+
730
+ const idleTimeout = config.jwt.session?.idleTimeoutSeconds;
731
+ const lastActivity = successor.lastActiveAt ?? successor.familyCreatedAt;
732
+ if (idleTimeout != null && idleTimeout > 0 && now.getTime() - lastActivity.getTime() > idleTimeout * 1000) {
733
+ await tx.update({
734
+ model: 'refresh_token',
735
+ where: [{ field: 'tokenFamily', operator: '=', value: successor.tokenFamily }],
736
+ data: { isRevoked: true },
737
+ });
738
+ return {
739
+ sessionExpired: 'idle' as const,
740
+ userId: successor.userId,
741
+ tokenFamily: successor.tokenFamily,
742
+ };
743
+ }
744
+
745
+ const user = await tx.findOne<FortressUser>({
746
+ model: 'user',
747
+ where: [{ field: 'id', operator: '=', value: successor.userId }],
748
+ });
749
+ if (user?.isActive) {
750
+ return {
751
+ graced: true as const,
752
+ userId: user.id,
753
+ tokenFamily: reused.tokenFamily,
754
+ tokens: {
755
+ accessToken: await issueAccessToken(user, txAdapter.getUserGroups),
756
+ refreshToken: successorToken!.raw,
757
+ } satisfies AuthTokenPair,
758
+ };
759
+ }
760
+ }
761
+ }
762
+ }
763
+
764
+ await tx.update({
765
+ model: 'refresh_token',
766
+ where: [{ field: 'tokenFamily', operator: '=', value: reused.tokenFamily }],
767
+ data: { isRevoked: true },
768
+ });
769
+ // Do not throw inside the transaction: throwing would roll back
770
+ // the family revocation. Return a sentinel, commit, then throw.
771
+ return {
772
+ replayDetected: true as const,
773
+ userId: reused.userId,
774
+ tokenFamily: reused.tokenFamily,
775
+ };
776
+ }
777
+
778
+ const now = new Date();
779
+ if (stored.expiresAt < now)
780
+ throw Errors.unauthorized('Refresh token expired');
781
+
782
+ const absoluteTimeout = config.jwt.session?.absoluteTimeoutSeconds;
783
+ if (absoluteTimeout != null && absoluteTimeout > 0 && now.getTime() - stored.familyCreatedAt.getTime() > absoluteTimeout * 1000) {
784
+ await tx.update({
785
+ model: 'refresh_token',
786
+ where: [{ field: 'tokenFamily', operator: '=', value: stored.tokenFamily }],
787
+ data: { isRevoked: true },
788
+ });
789
+ return {
790
+ sessionExpired: 'absolute' as const,
791
+ userId: stored.userId,
792
+ tokenFamily: stored.tokenFamily,
793
+ };
794
+ }
795
+
796
+ const idleTimeout = config.jwt.session?.idleTimeoutSeconds;
797
+ const lastActivity = stored.lastActiveAt ?? stored.familyCreatedAt;
798
+ if (idleTimeout != null && idleTimeout > 0 && now.getTime() - lastActivity.getTime() > idleTimeout * 1000) {
799
+ await tx.update({
800
+ model: 'refresh_token',
801
+ where: [{ field: 'tokenFamily', operator: '=', value: stored.tokenFamily }],
802
+ data: { isRevoked: true },
803
+ });
804
+ return {
805
+ sessionExpired: 'idle' as const,
806
+ userId: stored.userId,
807
+ tokenFamily: stored.tokenFamily,
808
+ };
809
+ }
810
+
811
+ // Token fingerprint validation
812
+ if (config.jwt.validateRefreshFingerprint && stored.fingerprintHash) {
813
+ const currentFingerprintMatches = meta
814
+ ? await fingerprintMatches(stored.fingerprintHash, meta)
815
+ : false;
816
+
817
+ if (!currentFingerprintMatches) {
818
+ if (config.jwt.validateRefreshFingerprint === true) {
819
+ // Do not throw inside the transaction: the family revocation
820
+ // must commit before the caller receives the rejection.
821
+ await tx.update({
822
+ model: 'refresh_token',
823
+ where: [{ field: 'tokenFamily', operator: '=', value: stored.tokenFamily }],
824
+ data: { isRevoked: true },
825
+ });
826
+ return {
827
+ fingerprintMismatch: true as const,
828
+ userId: stored.userId,
829
+ tokenFamily: stored.tokenFamily,
830
+ };
831
+ }
832
+ else {
833
+ // Warn mode: log but allow
834
+ logger?.warn(
835
+ { tokenFamily: stored.tokenFamily },
836
+ 'refresh token fingerprint mismatch',
837
+ );
838
+ if (authEventListeners.size() > 0) {
839
+ authEventListeners.emit({
840
+ eventType: 'TOKEN_FINGERPRINT_MISMATCH',
841
+ actorId: stored.userId,
842
+ ipAddress: meta?.ipAddress,
843
+ userAgent: meta?.userAgent,
844
+ metadata: { tokenFamily: stored.tokenFamily },
845
+ });
846
+ }
847
+ }
848
+ }
849
+ }
850
+
851
+ // Get user
852
+ const user = await tx.findOne<FortressUser>({
853
+ model: 'user',
854
+ where: [{ field: 'id', operator: '=', value: stored.userId }],
855
+ });
856
+
857
+ if (!user || !user.isActive) {
858
+ throw Errors.unauthorized('User not found or disabled');
859
+ }
860
+
861
+ // Issue new tokens with same family. Grace-enabled rotations derive
862
+ // the successor so a benign retry can recompute the raw token while
863
+ // the database stores only its hash.
864
+ const accessToken = await issueAccessToken(user, txAdapter.getUserGroups);
865
+ const graceSeconds = config.jwt.session?.refreshGraceSeconds;
866
+ const newToken = graceSeconds != null && graceSeconds > 0
867
+ ? await deriveRefreshTokenSuccessor(
868
+ refreshToken,
869
+ Array.isArray(resolved.key) ? resolved.key[0] : resolved.key,
870
+ )
871
+ : await generateRefreshToken();
872
+
873
+ const newFingerprintHash = meta
874
+ ? (await computeFingerprintHash(meta) ?? stored.fingerprintHash)
875
+ : stored.fingerprintHash;
876
+
877
+ const rotatedAt = new Date();
878
+ await tx.update({
879
+ model: 'refresh_token',
880
+ where: [{ field: 'id', operator: '=', value: stored.id }],
881
+ data: { successorTokenHash: newToken.hash, rotatedAt },
882
+ });
883
+
884
+ await tx.create({
885
+ model: 'refresh_token',
886
+ data: {
887
+ userId: stored.userId,
888
+ tokenHash: newToken.hash,
889
+ tokenFamily: stored.tokenFamily, // same family for rotation tracking
890
+ familyCreatedAt: stored.familyCreatedAt,
891
+ successorTokenHash: null,
892
+ rotatedAt: null,
893
+ isRevoked: false,
894
+ expiresAt: new Date(rotatedAt.getTime() + resolved.refreshTokenExpiry * 1000),
895
+ ipAddress: meta?.ipAddress ?? stored.ipAddress,
896
+ userAgent: meta?.userAgent ?? stored.userAgent,
897
+ deviceName: meta?.deviceName ?? stored.deviceName,
898
+ lastActiveAt: rotatedAt,
899
+ fingerprintHash: newFingerprintHash,
900
+ },
901
+ });
902
+
903
+ return {
904
+ userId: user.id,
905
+ tokens: {
906
+ accessToken,
907
+ refreshToken: newToken.raw,
908
+ } satisfies AuthTokenPair,
909
+ };
910
+ });
911
+
912
+ if ('fingerprintMismatch' in txResult) {
913
+ authEventListeners.emit({
914
+ eventType: 'TOKEN_FINGERPRINT_MISMATCH',
915
+ actorId: txResult.userId,
916
+ ipAddress: meta?.ipAddress,
917
+ userAgent: meta?.userAgent,
918
+ outcome: 'failure',
919
+ metadata: { tokenFamily: txResult.tokenFamily, action: 'family-revoked' },
920
+ });
921
+ throw Errors.unauthorized('Refresh token fingerprint mismatch');
922
+ }
923
+
924
+ if ('sessionExpired' in txResult) {
925
+ const eventType = txResult.sessionExpired === 'idle'
926
+ ? 'SESSION_EXPIRED_IDLE' as const
927
+ : 'SESSION_EXPIRED_ABSOLUTE' as const;
928
+ authEventListeners.emit({
929
+ eventType,
930
+ actorId: txResult.userId,
931
+ ipAddress: meta?.ipAddress,
932
+ userAgent: meta?.userAgent,
933
+ outcome: 'failure',
934
+ metadata: { tokenFamily: txResult.tokenFamily },
935
+ });
936
+ throw txResult.sessionExpired === 'idle'
937
+ ? Errors.sessionIdleTimeout()
938
+ : Errors.sessionAbsoluteTimeout();
939
+ }
940
+
941
+ if ('replayDetected' in txResult) {
942
+ if (authEventListeners.size() > 0) {
943
+ authEventListeners.emit({
944
+ eventType: 'TOKEN_REUSE_DETECTED',
945
+ actorId: txResult.userId,
946
+ ipAddress: meta?.ipAddress,
947
+ userAgent: meta?.userAgent,
948
+ metadata: { tokenFamily: txResult.tokenFamily },
949
+ });
950
+ }
951
+ throw Errors.tokenReuse();
952
+ }
953
+
954
+ if ('graced' in txResult) {
955
+ authEventListeners.emit({
956
+ eventType: 'TOKEN_REUSE_GRACED',
957
+ actorId: txResult.userId,
958
+ ipAddress: meta?.ipAddress,
959
+ userAgent: meta?.userAgent,
960
+ outcome: 'success',
961
+ metadata: { tokenFamily: txResult.tokenFamily },
962
+ });
963
+ }
964
+
965
+ let result: AuthTokenPair = txResult.tokens;
966
+
967
+ const afterCtx: AfterHookContext = { db, config, meta, responseHeaders: new Headers() };
968
+ result = await runAfterRefreshHooks(afterCtx, result);
969
+
970
+ if (authEventListeners.size() > 0) {
971
+ authEventListeners.emit({
972
+ eventType: 'TOKEN_REFRESH',
973
+ actorId: txResult.userId,
974
+ ipAddress: meta?.ipAddress,
975
+ userAgent: meta?.userAgent,
976
+ outcome: 'success',
977
+ });
978
+ }
979
+
980
+ return result;
981
+ },
982
+
983
+ async logout(refreshToken: string): Promise<void> {
984
+ const hookCtx: HookContext & { token: string } = { db, config, token: refreshToken };
985
+ await runBeforeHooks('beforeLogout', hookCtx);
986
+
987
+ const tokenHash = await hashToken(refreshToken);
988
+
989
+ // Look up the user the token belongs to so the LOGOUT event can
990
+ // carry an actorId. If lookup fails (token unknown), emit with
991
+ // actorId undefined — observers still fire.
992
+ let actorId: string | undefined;
993
+ if (authEventListeners.size() > 0) {
994
+ const stored = await adapter.findRefreshTokenByHash(tokenHash);
995
+ if (stored) {
996
+ actorId = stored.userId;
997
+ }
998
+ }
999
+
1000
+ await db.update({
1001
+ model: 'refresh_token',
1002
+ where: [{ field: 'tokenHash', operator: '=', value: tokenHash }],
1003
+ data: { isRevoked: true },
1004
+ });
1005
+
1006
+ if (authEventListeners.size() > 0) {
1007
+ authEventListeners.emit({
1008
+ eventType: 'LOGOUT',
1009
+ actorId,
1010
+ });
1011
+ }
1012
+ },
1013
+
1014
+ async me(userId: string): Promise<FortressUser> {
1015
+ const user = await db.findOne<FortressUser>({
1016
+ model: 'user',
1017
+ where: [{ field: 'id', operator: '=', value: userId }],
1018
+ });
1019
+
1020
+ if (!user) {
1021
+ throw Errors.notFound('User not found');
1022
+ }
1023
+
1024
+ const { passwordHash: _, ...safeUser } = user as FortressUser & { passwordHash?: string };
1025
+ return safeUser;
1026
+ },
1027
+
1028
+ async createUser(data: CreateUserInput): Promise<FortressUser> {
1029
+ const normalizedData: CreateUserInput = { ...data, email: normalizeEmail(data.email) };
1030
+ const hookCtx: HookContext & { data: CreateUserInput } = { db, config, data: normalizedData };
1031
+ const beforeResult = await runBeforeHooks('beforeRegister', hookCtx);
1032
+ if (beforeResult?.stop) {
1033
+ return beforeResult.response as unknown as FortressUser;
1034
+ }
1035
+ // Hooks may mutate their data object; restore the canonical identity at
1036
+ // the final persistence boundary rather than trusting hook discipline.
1037
+ normalizedData.email = normalizeEmail(normalizedData.email);
1038
+
1039
+ // Preserve the established friendly duplicate error before spending on
1040
+ // password validation/hash; the transaction repeats this check and the
1041
+ // database constraints remain authoritative for races and aliases.
1042
+ const existing = await db.findOne<{ id: string }>({
1043
+ model: 'user',
1044
+ where: [{ field: 'email', operator: '=', value: normalizedData.email }],
1045
+ });
1046
+ if (existing)
1047
+ throw Errors.conflict('A user with this email already exists');
1048
+
1049
+ const normalizedPassword = normalizedData.password && normalizedData.password.length > 0
1050
+ ? normalizePasswordInput(normalizedData.password)
1051
+ : undefined;
1052
+ if (normalizedPassword !== undefined) {
1053
+ await validatePassword(normalizedPassword, config.passwordPolicy, passwordPolicyObserver);
1054
+ }
1055
+
1056
+ const passwordHash = normalizedPassword !== undefined ? await hasher.hash(normalizedPassword) : null;
1057
+
1058
+ const user = await db.transaction(async (tx) => {
1059
+ // The user and its canonical login identifier are one identity write:
1060
+ // either both persist or neither does.
1061
+ const existing = await tx.findOne<{ id: string }>({
1062
+ model: 'user',
1063
+ where: [{ field: 'email', operator: '=', value: normalizedData.email }],
1064
+ });
1065
+ if (existing)
1066
+ throw Errors.conflict('A user with this email already exists');
1067
+
1068
+ const created = await tx.create<FortressUser>({
1069
+ model: 'user',
1070
+ data: {
1071
+ email: normalizedData.email,
1072
+ name: normalizedData.name,
1073
+ passwordHash,
1074
+ isActive: normalizedData.isActive ?? true,
1075
+ emailVerified: false,
1076
+ },
1077
+ });
1078
+ await tx.create({
1079
+ model: 'login_identifier',
1080
+ data: { userId: created.id, type: 'email', value: normalizedData.email },
1081
+ });
1082
+ return created;
1083
+ });
1084
+
1085
+ const afterCtx: AfterHookContext = { db, config, responseHeaders: new Headers() };
1086
+ for (const plugin of plugins) {
1087
+ if (plugin.hooks?.afterRegister) {
1088
+ // Fail-open: a throwing afterRegister side-effect must not undo a
1089
+ // committed user creation (see runAfterLoginHooks).
1090
+ try {
1091
+ await plugin.hooks.afterRegister(afterCtx, user);
1092
+ }
1093
+ catch (hookError) {
1094
+ logger?.error({ plugin: plugin.name, error: hookError }, 'afterRegister hook failed');
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ if (authEventListeners.size() > 0) {
1100
+ authEventListeners.emit({
1101
+ eventType: 'REGISTER',
1102
+ actorId: user.id,
1103
+ identifier: normalizedData.email,
1104
+ method: 'password',
1105
+ outcome: 'success',
1106
+ });
1107
+ }
1108
+
1109
+ return user;
1110
+ },
1111
+
1112
+ async verifyToken(token: string): Promise<TokenClaims> {
1113
+ const start = performance.now();
1114
+ try {
1115
+ const claims = await verifyAccessToken(token, resolved.key, {
1116
+ issuer: resolved.issuer,
1117
+ ...(resolved.audience !== undefined ? { audience: resolved.audience } : {}),
1118
+ });
1119
+ tokenVerifyDuration?.record(
1120
+ (performance.now() - start) / 1000,
1121
+ { result: 'ok' },
1122
+ );
1123
+ return claims;
1124
+ }
1125
+ catch (err) {
1126
+ tokenVerifyDuration?.record(
1127
+ (performance.now() - start) / 1000,
1128
+ { result: 'invalid' },
1129
+ );
1130
+ throw err;
1131
+ }
1132
+ },
1133
+
1134
+ async signToken(claims: Omit<TokenClaims, 'iat' | 'exp' | 'aud'>): Promise<string> {
1135
+ return signAccessToken(claims, resolved.key, resolved.accessTokenExpiry, {
1136
+ audience: resolved.audience,
1137
+ });
1138
+ },
1139
+
1140
+ async listSessions(userId: string): Promise<SessionInfo[]> {
1141
+ const tokens = await db.findMany<{
1142
+ id: string;
1143
+ ipAddress: string | null;
1144
+ userAgent: string | null;
1145
+ deviceName: string | null;
1146
+ createdAt: Date;
1147
+ lastActiveAt: Date | null;
1148
+ }>({
1149
+ model: 'refresh_token',
1150
+ where: [
1151
+ { field: 'userId', operator: '=', value: userId },
1152
+ { field: 'isRevoked', operator: '=', value: false },
1153
+ { field: 'expiresAt', operator: 'gt', value: new Date() },
1154
+ ],
1155
+ });
1156
+
1157
+ return tokens.map(t => ({
1158
+ id: t.id,
1159
+ ipAddress: t.ipAddress,
1160
+ userAgent: t.userAgent,
1161
+ deviceName: t.deviceName,
1162
+ createdAt: t.createdAt,
1163
+ lastActiveAt: t.lastActiveAt,
1164
+ }));
1165
+ },
1166
+
1167
+ async revokeSession(userId: string, tokenId: string): Promise<void> {
1168
+ await db.update({
1169
+ model: 'refresh_token',
1170
+ where: [
1171
+ { field: 'id', operator: '=', value: tokenId },
1172
+ { field: 'userId', operator: '=', value: userId },
1173
+ ],
1174
+ data: { isRevoked: true },
1175
+ });
1176
+ },
1177
+
1178
+ async revokeAllOtherSessions(userId: string, currentTokenId: string): Promise<void> {
1179
+ // L-tier: replace the read-then-loop with a single conditional UPDATE.
1180
+ // The original N+1 path opened a window where new refresh tokens issued
1181
+ // during the loop would slip through unrevoked, and added unnecessary
1182
+ // adapter round-trips. The adapter performs `AND` of where clauses, so
1183
+ // `id != currentTokenId` filters out the keep-token in one statement.
1184
+ await db.update({
1185
+ model: 'refresh_token',
1186
+ where: [
1187
+ { field: 'userId', operator: '=', value: userId },
1188
+ { field: 'isRevoked', operator: '=', value: false },
1189
+ { field: 'id', operator: '!=', value: currentTokenId },
1190
+ ],
1191
+ data: { isRevoked: true },
1192
+ });
1193
+ },
1194
+
1195
+ async addLoginIdentifier(userId: string, type: LoginIdentifierType, value: string): Promise<void> {
1196
+ await db.create({
1197
+ model: 'login_identifier',
1198
+ data: { userId, type, value: type === 'email' ? normalizeEmail(value) : value },
1199
+ });
1200
+ },
1201
+
1202
+ async removeLoginIdentifier(userId: string, type: LoginIdentifierType, value: string): Promise<void> {
1203
+ const normalizedValue = type === 'email' ? normalizeEmail(value) : value;
1204
+ await db.delete({
1205
+ model: 'login_identifier',
1206
+ where: [
1207
+ { field: 'userId', operator: '=', value: userId },
1208
+ { field: 'type', operator: '=', value: type },
1209
+ { field: 'value', operator: '=', value: normalizedValue },
1210
+ ],
1211
+ });
1212
+ },
1213
+
1214
+ async getLoginIdentifiers(userId: string): Promise<LoginIdentifier[]> {
1215
+ return db.findMany<LoginIdentifier>({
1216
+ model: 'login_identifier',
1217
+ where: [{ field: 'userId', operator: '=', value: userId }],
1218
+ });
1219
+ },
1220
+
1221
+ async impersonate(
1222
+ adminUserId: string,
1223
+ targetUserId: string,
1224
+ options?: { reason?: string; expirySeconds?: number },
1225
+ ): Promise<AuthResult> {
1226
+ // Defense in depth for programmatic callers. The HTTP route also
1227
+ // enforces this via endpoint metadata before dispatch, but direct
1228
+ // service calls must fail closed too.
1229
+ const adminPermissions = await adapter.getSubjectPermissions({ type: 'USER', id: adminUserId });
1230
+ if (!evaluatePermissions(adminPermissions, 'fortress', 'impersonate', evaluationMode)) {
1231
+ throw Errors.forbidden('Insufficient permissions');
1232
+ }
1233
+
1234
+ const targetUser = await db.findOne<FortressUser>({
1235
+ model: 'user',
1236
+ where: [{ field: 'id', operator: '=', value: targetUserId }],
1237
+ });
1238
+
1239
+ if (!targetUser || !targetUser.isActive) {
1240
+ throw Errors.notFound('Target user not found');
1241
+ }
1242
+
1243
+ // RFC 8693-style act tokens must be short-lived; cap caller-supplied
1244
+ // expiry at MAX_IMPERSONATION_TTL_SECONDS (default 3600s, configurable
1245
+ // via `config.impersonation.maxTtlSeconds`). An admin with elevated
1246
+ // privileges can still cause damage, but not for the next 10 years.
1247
+ const requestedExpiry = options?.expirySeconds ?? 3600;
1248
+ const maxExpiry = config.impersonation?.maxTtlSeconds ?? 3600;
1249
+ const expirySeconds = Math.max(1, Math.min(requestedExpiry, maxExpiry));
1250
+ const groups = await getUserGroups(targetUser.id);
1251
+ const customClaims = await enrichClaims(targetUser.id);
1252
+
1253
+ const accessToken = await signAccessToken(
1254
+ {
1255
+ sub: targetUser.id,
1256
+ subjectType: 'USER',
1257
+ name: targetUser.name,
1258
+ groups,
1259
+ iss: resolved.issuer,
1260
+ act: { sub: adminUserId, subjectType: 'USER' },
1261
+ customClaims: Object.keys(customClaims).length > 0 ? customClaims : undefined,
1262
+ },
1263
+ resolved.key,
1264
+ expirySeconds,
1265
+ { audience: resolved.audience },
1266
+ );
1267
+
1268
+ // Do NOT issue a refresh token for impersonation — non-renewable
1269
+ const { passwordHash: _, ...safeUser } = targetUser as FortressUser & { passwordHash?: string };
1270
+
1271
+ // Audit trail for impersonation (P2.4 / L-tier follow-up). Emits
1272
+ // through the standard auth observer pipeline so audit-log plugin,
1273
+ // SIEM hooks, and metrics counters all see it.
1274
+ if (authEventListeners.size() > 0) {
1275
+ authEventListeners.emit({
1276
+ eventType: 'IMPERSONATE' as AuthEvent['eventType'],
1277
+ actorId: adminUserId,
1278
+ identifier: String(targetUserId),
1279
+ outcome: 'success',
1280
+ metadata: {
1281
+ targetUserId,
1282
+ reason: options?.reason ?? null,
1283
+ expirySeconds,
1284
+ clamped: expirySeconds !== requestedExpiry,
1285
+ },
1286
+ });
1287
+ }
1288
+
1289
+ return {
1290
+ status: 'impersonation' as const,
1291
+ user: safeUser,
1292
+ accessToken,
1293
+ refreshToken: null,
1294
+ pluginData: {
1295
+ impersonation: {
1296
+ adminUserId,
1297
+ reason: options?.reason ?? null,
1298
+ expiresInSeconds: expirySeconds,
1299
+ },
1300
+ },
1301
+ };
1302
+ },
1303
+
1304
+ // ── Admin user management ──────────────────────────────────────
1305
+
1306
+ async listUsers(options: {
1307
+ limit?: number;
1308
+ offset?: number;
1309
+ search?: string;
1310
+ sortBy?: string;
1311
+ sortDirection?: 'asc' | 'desc';
1312
+ }): Promise<{ users: FortressUser[]; total: number }> {
1313
+ const where: { field: string; operator: string; value: unknown }[] = [];
1314
+
1315
+ if (options.search) {
1316
+ where.push({ field: 'email', operator: 'like', value: `%${options.search}%` });
1317
+ }
1318
+
1319
+ const [users, total] = await Promise.all([
1320
+ db.findMany<FortressUser & { passwordHash?: string }>({
1321
+ model: 'user',
1322
+ where: where.length > 0 ? where : undefined,
1323
+ limit: options.limit ?? 50,
1324
+ offset: options.offset ?? 0,
1325
+ sortBy: options.sortBy
1326
+ ? { field: options.sortBy, direction: options.sortDirection ?? 'asc' }
1327
+ : { field: 'id', direction: 'asc' },
1328
+ }),
1329
+ db.count({
1330
+ model: 'user',
1331
+ where: where.length > 0 ? where : undefined,
1332
+ }),
1333
+ ]);
1334
+
1335
+ return {
1336
+ users: users.map(({ passwordHash: _, ...u }) => u),
1337
+ total,
1338
+ };
1339
+ },
1340
+
1341
+ async getUserById(userId: string): Promise<FortressUser> {
1342
+ const user = await db.findOne<FortressUser & { passwordHash?: string }>({
1343
+ model: 'user',
1344
+ where: [{ field: 'id', operator: '=', value: userId }],
1345
+ });
1346
+
1347
+ if (!user) {
1348
+ throw Errors.notFound('User not found');
1349
+ }
1350
+
1351
+ const { passwordHash: _, ...safeUser } = user;
1352
+ return safeUser;
1353
+ },
1354
+
1355
+ async updateUser(
1356
+ userId: string,
1357
+ data: { name?: string; email?: string; isActive?: boolean; password?: string },
1358
+ ): Promise<FortressUser> {
1359
+ const normalizedEmail = data.email === undefined ? undefined : normalizeEmail(data.email);
1360
+
1361
+ const updateData: Record<string, unknown> = {};
1362
+ if (data.name !== undefined)
1363
+ updateData.name = data.name;
1364
+ if (normalizedEmail !== undefined)
1365
+ updateData.email = normalizedEmail;
1366
+ if (data.isActive !== undefined)
1367
+ updateData.isActive = data.isActive;
1368
+ let credentialChanged = false;
1369
+ if (data.password !== undefined) {
1370
+ // Match createUser semantics: validate unconditionally and await.
1371
+ // The original code missed the await on a Promise — a breached or
1372
+ // weak password would surface as an unhandled rejection while the
1373
+ // hash was happily persisted (M1).
1374
+ const normalizedPassword = normalizePasswordInput(data.password);
1375
+ await validatePassword(normalizedPassword, config.passwordPolicy, passwordPolicyObserver);
1376
+ updateData.passwordHash = await hasher.hash(normalizedPassword);
1377
+ credentialChanged = true;
1378
+ }
1379
+
1380
+ const updated = await db.transaction(async (tx) => {
1381
+ // Serialize the read/modify/write across PostgreSQL processes. SQLite's
1382
+ // adapter transaction chain already serializes writers on its single
1383
+ // connection. Re-reading only after the lock prevents a stale old
1384
+ // email from missing the identifier row during concurrent updates.
1385
+ if (tx.dialect === 'pg' && tx.rawQuery && NUMERIC_SUBJECT_ID_RE.test(userId)) {
1386
+ await tx.rawQuery(
1387
+ 'SELECT pg_advisory_xact_lock(117993, CAST(? AS integer))',
1388
+ [userId],
1389
+ );
1390
+ }
1391
+ const existing = await tx.findOne<FortressUser>({
1392
+ model: 'user',
1393
+ where: [{ field: 'id', operator: '=', value: userId }],
1394
+ });
1395
+ if (!existing)
1396
+ throw Errors.notFound('User not found');
1397
+
1398
+ if (normalizedEmail !== undefined && normalizedEmail !== existing.email) {
1399
+ const duplicate = await tx.findOne<{ id: string }>({
1400
+ model: 'user',
1401
+ where: [{ field: 'email', operator: '=', value: normalizedEmail }],
1402
+ });
1403
+ if (duplicate)
1404
+ throw Errors.conflict('A user with this email already exists');
1405
+ }
1406
+
1407
+ const persisted = await tx.update<FortressUser & { passwordHash?: string }>({
1408
+ model: 'user',
1409
+ where: [{ field: 'id', operator: '=', value: userId }],
1410
+ data: updateData,
1411
+ });
1412
+ if (!persisted)
1413
+ throw Errors.notFound('User not found');
1414
+
1415
+ // Update the primary email identifier atomically with the user row.
1416
+ if (normalizedEmail !== undefined && normalizedEmail !== existing.email) {
1417
+ await tx.update({
1418
+ model: 'login_identifier',
1419
+ where: [
1420
+ { field: 'userId', operator: '=', value: userId },
1421
+ { field: 'type', operator: '=', value: 'email' },
1422
+ { field: 'value', operator: '=', value: existing.email },
1423
+ ],
1424
+ data: { value: normalizedEmail },
1425
+ });
1426
+ }
1427
+
1428
+ if (credentialChanged) {
1429
+ await tx.update({
1430
+ model: 'refresh_token',
1431
+ where: [
1432
+ { field: 'userId', operator: '=', value: userId },
1433
+ { field: 'isRevoked', operator: '=', value: false },
1434
+ ],
1435
+ data: { isRevoked: true },
1436
+ });
1437
+ }
1438
+ return persisted;
1439
+ });
1440
+
1441
+ const { passwordHash: _, ...safeUser } = updated;
1442
+ return safeUser;
1443
+ },
1444
+
1445
+ async deleteUser(userId: string): Promise<void> {
1446
+ const existing = await db.findOne<{ id: string }>({
1447
+ model: 'user',
1448
+ where: [{ field: 'id', operator: '=', value: userId }],
1449
+ });
1450
+ if (!existing) {
1451
+ throw Errors.notFound('User not found');
1452
+ }
1453
+
1454
+ await db.delete({
1455
+ model: 'user',
1456
+ where: [{ field: 'id', operator: '=', value: userId }],
1457
+ });
1458
+ },
1459
+
1460
+ addAuthObserver(listener: AuthEventListener): Unsubscribe {
1461
+ return authEventListeners.add(listener);
1462
+ },
1463
+ };
1464
+ }