@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,337 @@
1
+ # Social Login Plugin
2
+
3
+ ## Overview
4
+
5
+ The `social-login` plugin is an OAuth/OIDC consumer that lets users sign in with external identity providers. It handles the full OAuth 2.0 authorization code flow with PKCE, profile normalization, JIT (just-in-time) user provisioning, and account linking.
6
+
7
+ Built-in providers: **Google**, **GitHub**, **Microsoft**, **Apple**, **Discord**.
8
+ Any OIDC-compliant provider can be added via the `issuer` option.
9
+
10
+ ## Installation
11
+
12
+ ```typescript
13
+ import { createFortress } from '@bajustone/fortress';
14
+ import { socialLogin } from '@bajustone/fortress/plugins/social-login';
15
+
16
+ const fortress = createFortress({
17
+ database: adapter,
18
+ jwt: { key: process.env.JWT_SECRET!, issuer: 'my-app' },
19
+ plugins: [
20
+ socialLogin({
21
+ providers: [
22
+ {
23
+ name: 'google',
24
+ clientId: process.env.GOOGLE_CLIENT_ID!,
25
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
26
+ },
27
+ {
28
+ name: 'github',
29
+ clientId: process.env.GITHUB_CLIENT_ID!,
30
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
31
+ },
32
+ ],
33
+ }),
34
+ ],
35
+ });
36
+ ```
37
+
38
+ The plugin registers a `social_account` table that links provider identities to Fortress users. Your database adapter must support this model (the Drizzle adapter handles it automatically).
39
+
40
+ ## Configuration
41
+
42
+ `socialLogin(config)` accepts a `SocialLoginConfig` object:
43
+
44
+ | Option | Type | Default | Description |
45
+ |---|---|---|---|
46
+ | `providers` | `ProviderConfig[]` | (required) | List of configured providers. |
47
+ | `autoRegister` | `boolean` | `true` | Create a new Fortress user on first social login (JIT provisioning). |
48
+ | `linkAccounts` | `boolean` | `true` | When a provider-verified social login email matches an active existing user, link the social identity to that user instead of creating a duplicate. |
49
+ | `persistTokens` | `boolean` | `false` | Persist encrypted provider access/refresh tokens. **Off by default** — set to `true` only if you need server-side access to provider tokens (e.g. to call the provider's API after sign-in); requires `tokenEncryptionKey`. |
50
+ | `tokenEncryptionKey` | `string` | required when `persistTokens` | 32-byte AES-256-GCM key (base64/base64url/hex/raw UTF-8) for provider token encryption. Hard requirement — the plugin throws at construction if `persistTokens` is enabled without one. |
51
+
52
+ To call `getProviderTokens`, enable persistence explicitly:
53
+
54
+ ```typescript
55
+ socialLogin({
56
+ persistTokens: true,
57
+ tokenEncryptionKey: process.env.FORTRESS_SOCIAL_TOKEN_KEY!,
58
+ providers: [/* ... */],
59
+ })
60
+ ```
61
+ | `mapProfile` | `(provider, profile) => { email, name }` | `undefined` | Custom mapping from provider profile to Fortress user fields during JIT provisioning. |
62
+ | `onFirstLogin` | `(user, provider, profile) => Promise<void>` | `undefined` | Callback invoked once when a user is created via social login. Useful for assigning default roles, sending welcome emails, etc. |
63
+
64
+ ### ProviderConfig
65
+
66
+ Each entry in `providers` has these fields:
67
+
68
+ | Option | Type | Required | Description |
69
+ |---|---|---|---|
70
+ | `name` | `string` | Yes | Provider identifier. Use a built-in name (`google`, `github`, `microsoft`, `apple`, `discord`) or any string for custom OIDC. |
71
+ | `clientId` | `string` | Yes | OAuth client ID from the provider. |
72
+ | `clientSecret` | `string` | Yes | OAuth client secret from the provider. |
73
+ | `scopes` | `string[]` | No | Override the default scopes for this provider. |
74
+ | `tenant` | `string` | No | Microsoft only. Azure AD tenant: a tenant ID, `'common'`, or `'organizations'`. Defaults to `'common'`. |
75
+ | `allowedDomains` | `string[]` | No | Restrict sign-in to specific email domains (e.g., `['acme.com']`). |
76
+ | `issuer` | `string` | No | OIDC issuer URL for custom providers. Enables discovery of authorization, token, userinfo, and JWKS endpoints. |
77
+ | `authorizationUrl` / `tokenUrl` / `userInfoUrl` / `jwksUri` | `string` | No | Endpoint overrides for custom OIDC providers. |
78
+ | `teamId` / `keyId` / `privateKey` | `string` | Apple only | Apple ES256 client-secret JWT inputs. |
79
+
80
+ ## Usage
81
+
82
+ ### Setting Up Providers
83
+
84
+ **Built-in providers** require only `name`, `clientId`, and `clientSecret`:
85
+
86
+ ```typescript
87
+ socialLogin({
88
+ providers: [
89
+ { name: 'google', clientId: '...', clientSecret: '...' },
90
+ { name: 'github', clientId: '...', clientSecret: '...' },
91
+ { name: 'apple', clientId: '...', clientSecret: '...' },
92
+ { name: 'discord', clientId: '...', clientSecret: '...' },
93
+ ],
94
+ })
95
+ ```
96
+
97
+ **Custom OIDC providers** use the `issuer` field. Fortress constructs the standard OIDC endpoints from the issuer URL:
98
+
99
+ ```typescript
100
+ socialLogin({
101
+ providers: [
102
+ {
103
+ name: 'okta',
104
+ clientId: '...',
105
+ clientSecret: '...',
106
+ issuer: 'https://dev-123456.okta.com',
107
+ },
108
+ {
109
+ name: 'keycloak',
110
+ clientId: '...',
111
+ clientSecret: '...',
112
+ issuer: 'https://auth.example.com/realms/my-realm',
113
+ },
114
+ ],
115
+ })
116
+ ```
117
+
118
+ ### Authorization URL Generation
119
+
120
+ Redirect the user to the provider's authorization page. The plugin generates a PKCE challenge, OAuth CSRF state, and OIDC nonce automatically.
121
+
122
+ ```typescript
123
+ const { url, state, codeVerifier, nonce } = await fortress.plugins['social-login'].getAuthorizationUrl(
124
+ 'google',
125
+ 'https://myapp.com/auth/google/callback',
126
+ );
127
+
128
+ // Store these in the user's session (needed for callback verification)
129
+ session.set('oauth_state', state);
130
+ session.set('oauth_code_verifier', codeVerifier);
131
+ session.set('oidc_nonce', nonce);
132
+
133
+ // Redirect the user
134
+ return redirect(url);
135
+ ```
136
+
137
+ The returned values are:
138
+ - `state` -- random OAuth CSRF token
139
+ - `codeVerifier` -- PKCE code verifier (required for the callback)
140
+ - `nonce` -- separate OIDC nonce verified against the provider ID token
141
+
142
+ ### Handling the Callback
143
+
144
+ When the provider redirects back to your app, exchange the authorization code for tokens and resolve the user:
145
+
146
+ ```typescript
147
+ app.get('/auth/google/callback', async (c) => {
148
+ const code = c.req.query('code');
149
+ const returnedState = c.req.query('state');
150
+ const storedState = session.get('oauth_state');
151
+ const codeVerifier = session.get('oauth_code_verifier');
152
+ const nonce = session.get('oidc_nonce');
153
+
154
+ const { user, profile, isNewUser } = await fortress.plugins['social-login'].handleCallback(
155
+ 'google',
156
+ code,
157
+ 'https://myapp.com/auth/google/callback', // must match the redirect URI used above
158
+ codeVerifier,
159
+ returnedState,
160
+ storedState,
161
+ nonce,
162
+ );
163
+
164
+ // Issue a Fortress session (JWT) for the user
165
+ const tokens = await fortress.auth.login({ email: user.email });
166
+
167
+ if (isNewUser) {
168
+ // First-time social login -- maybe redirect to onboarding
169
+ }
170
+
171
+ return c.json({ accessToken: tokens.accessToken, user });
172
+ });
173
+ ```
174
+
175
+ `handleCallback` returns:
176
+ - `user` -- the Fortress user (existing or newly created)
177
+ - `profile` -- normalized provider profile (`id`, `email`, `emailVerified`, `name`, `avatar`, `raw`)
178
+ - `isNewUser` -- `true` if the user was created during this call (JIT provisioning)
179
+
180
+ > The OIDC discovery, token-exchange, and userinfo calls run through a shared `@bajustone/fetcher` client with a request timeout (10 s; HIBP breach checks use 6 s), so a hung provider cannot stall the callback. Token responses are rejected if `access_token` is missing; discovery/userinfo responses must be JSON objects. Discovery falls back to the static provider definition on timeout/error.
181
+
182
+ ### JIT User Provisioning
183
+
184
+ When `autoRegister` is `true` (the default) and no existing Fortress user matches the social identity, the plugin creates a new user automatically. The new user has `passwordHash: null`, marking them as a social-only account.
185
+
186
+ To customize the user record created during provisioning, use `mapProfile`:
187
+
188
+ ```typescript
189
+ socialLogin({
190
+ autoRegister: true,
191
+ mapProfile: (provider, profile) => ({
192
+ email: profile.email,
193
+ name: profile.displayName ?? profile.name ?? profile.email,
194
+ }),
195
+ providers: [/* ... */],
196
+ })
197
+ ```
198
+
199
+ To run logic after user creation (assign roles, send a welcome email), use `onFirstLogin`:
200
+
201
+ ```typescript
202
+ socialLogin({
203
+ onFirstLogin: async (user, provider, profile) => {
204
+ await fortress.iam.assignRole(user.id, 'member');
205
+ await sendWelcomeEmail(profile.email, profile.name);
206
+ },
207
+ providers: [/* ... */],
208
+ })
209
+ ```
210
+
211
+ Set `autoRegister: false` to require users to exist before they can sign in via social login. The plugin will throw an `unauthorized` error if no matching user is found.
212
+
213
+ ### Account Linking
214
+
215
+ When `linkAccounts` is `true` (the default) and a provider-verified social login email matches an active existing Fortress user, the social identity is linked to that user rather than creating a duplicate.
216
+
217
+ This means a user who first registered with email/password and later signs in with Google (using the same verified email) will have their Google identity attached to their existing account. Unverified provider emails never trigger by-email linking.
218
+
219
+ ### Domain Restrictions
220
+
221
+ Restrict sign-in to specific email domains per provider using `allowedDomains`. This is useful for corporate SSO where only company emails should be accepted:
222
+
223
+ ```typescript
224
+ socialLogin({
225
+ providers: [
226
+ {
227
+ name: 'google',
228
+ clientId: '...',
229
+ clientSecret: '...',
230
+ allowedDomains: ['acme.com', 'acme.io'],
231
+ },
232
+ ],
233
+ })
234
+ ```
235
+
236
+ If a user attempts to sign in with an email outside the allowed domains, `handleCallback` throws an `unauthorized` error with the message `"Email domain 'gmail.com' is not allowed for google"`.
237
+
238
+ ### Managing Linked Accounts
239
+
240
+ List all social identities linked to a user:
241
+
242
+ ```typescript
243
+ const accounts = await fortress.plugins['social-login'].getLinkedAccounts(userId);
244
+ // [
245
+ // { provider: 'google', providerAccountId: '1234567890', email: 'alice@acme.com' },
246
+ // { provider: 'github', providerAccountId: '987654', email: 'alice@acme.com' },
247
+ // ]
248
+ ```
249
+
250
+ Unlink a social identity from a user:
251
+
252
+ ```typescript
253
+ await fortress.plugins['social-login'].unlinkAccount(userId, 'github');
254
+ ```
255
+
256
+ List all configured providers:
257
+
258
+ ```typescript
259
+ const providers = fortress.plugins['social-login'].getProviders();
260
+ // ['google', 'github']
261
+ ```
262
+
263
+ ## Provider-Specific Notes
264
+
265
+ ### Google
266
+
267
+ - Uses OpenID Connect. Default scopes: `openid`, `profile`, `email`.
268
+ - Create credentials at [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
269
+ - Profile fields: `sub` (id), `email`, `name`, `picture`.
270
+
271
+ ### GitHub
272
+
273
+ - Uses custom OAuth 2.0 (not OIDC -- no discovery URL). Default scopes: `read:user`, `user:email`.
274
+ - Create an OAuth App at [GitHub Developer Settings](https://github.com/settings/developers).
275
+ - Profile fields: `id`, `email`, `name`/`login`, `avatar_url`.
276
+
277
+ ### Microsoft
278
+
279
+ - Uses OpenID Connect via Azure AD. Default scopes: `openid`, `profile`, `email`, `User.Read`.
280
+ - The `tenant` option controls which Azure AD tenant is used:
281
+ - `'common'` (default) -- any Microsoft account (personal + work/school).
282
+ - `'organizations'` -- work/school accounts only.
283
+ - A specific tenant ID -- restricts to a single Azure AD directory.
284
+
285
+ ```typescript
286
+ {
287
+ name: 'microsoft',
288
+ clientId: '...',
289
+ clientSecret: '...',
290
+ tenant: 'your-azure-tenant-id',
291
+ }
292
+ ```
293
+
294
+ - Register your app at [Azure Portal](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps).
295
+ - Profile is fetched from Microsoft Graph (`/v1.0/me`). Avatar requires a separate Graph API call and is not included.
296
+
297
+ ### Apple
298
+
299
+ - Uses OpenID Connect, but profile data comes from the ID token only (no userinfo endpoint).
300
+ - Apple sends the user's name only on the **first** authorization. Subsequent logins provide only `sub` and `email`.
301
+ - Default scopes: `name`, `email`.
302
+ - Create a Service ID at [Apple Developer](https://developer.apple.com/account/resources/identifiers/list/serviceId).
303
+
304
+ ### Discord
305
+
306
+ - Uses custom OAuth 2.0. Default scopes: `identify`, `email`.
307
+ - Create an application at [Discord Developer Portal](https://discord.com/developers/applications).
308
+ - Profile fields: `id`, `email`, `username`, `global_name`, `avatar`.
309
+
310
+ ## API Reference
311
+
312
+ All methods are accessed via `fortress.plugins['social-login']`.
313
+
314
+ | Method | Signature | Description |
315
+ |---|---|---|
316
+ | `getAuthorizationUrl` | `(providerName: string, redirectUri: string) => Promise<{ url: string; state: string; codeVerifier: string; nonce: string }>` | Generate the OAuth authorization URL. Store `state`, `codeVerifier`, and `nonce` in the user's session. |
317
+ | `handleCallback` | `(providerName: string, code: string, redirectUri: string, codeVerifier: string, returnedState: string, storedState: string, storedNonce: string) => Promise<{ user: FortressUser; profile: ProviderProfile; isNewUser: boolean }>` | Timing-safe-verify OAuth state, verify OIDC ID-token signature/issuer/audience/expiry/nonce, fetch the profile, and resolve or create the Fortress user. |
318
+ | `getLinkedAccounts` | `(userId: string) => Promise<{ provider: string; providerAccountId: string; email: string \| null }[]>` | List social identities linked to a user. |
319
+ | `getProviderTokens` | `(userId: string, provider: string) => Promise<{ accessToken: string \| null; refreshToken: string \| null; tokenExpiresAt: Date \| null }>` | Return decrypted provider tokens for a linked account. |
320
+ | `unlinkAccount` | `(userId: string, provider: string) => Promise<void>` | Remove a social identity from a user. |
321
+ | `getProviders` | `() => string[]` | List the names of all configured providers. |
322
+
323
+ ### ProviderProfile
324
+
325
+ The normalized profile returned by `handleCallback`:
326
+
327
+ ```typescript
328
+ interface ProviderProfile {
329
+ id: string; // Provider's unique user ID
330
+ email: string;
331
+ emailVerified: boolean;
332
+ name?: string;
333
+ displayName?: string;
334
+ avatar?: string;
335
+ raw: Record<string, unknown>; // Full raw response from the provider
336
+ }
337
+ ```
@@ -0,0 +1,122 @@
1
+ # Tenancy Plugin
2
+
3
+ ## Overview
4
+
5
+ The `tenancy` plugin adds schema-per-tenant isolation for PostgreSQL. Each tenant gets its own schema named from its numeric database id (by default `tenant_<id>`), and request-scoped database operations run with that tenant schema on PostgreSQL's `search_path`.
6
+
7
+ Tenant selection is derived from the verified JWT claim produced by `enrichTokenClaims`: `claims.customClaims.tenantId`. It is never read from client-supplied tenant context. A caller can therefore only get a tenant claim after being a member of that tenant via `tenant_user`.
8
+
9
+ Isolation is transaction-pinned and fail-closed: each wrapped operation starts a transaction, calls `set_config('search_path', ?, true)` with a bound parameter on the pinned connection, then runs the operation. If there is no verified tenant claim, or the adapter is not PostgreSQL, the adapter is returned unchanged. Business tables should live only in tenant schemas, so missing tenant context fails by not finding those tables rather than silently reading another tenant.
10
+
11
+ This plugin is PostgreSQL-specific. For database-agnostic row-level isolation, see [Data Isolation](./data-isolation.md).
12
+
13
+ ## Service accounts and tenancy
14
+
15
+ Service accounts have no `tenant_user` membership, so `enrichTokenClaims` does not add a tenant claim for them and the tenancy adapter wrapper does not switch schemas. That is intentional fail-closed behavior.
16
+
17
+ Tenant-scoped *permissions* for service accounts are unaffected. IAM still resolves grants through `role_binding.tenantId` when you pass an explicit `tenantId` to `fortress.iam.checkPermission(...)`; this is separate from schema switching and does not rely on any tenant header.
18
+
19
+ ## Installation
20
+
21
+ ```ts
22
+ import { createFortress } from '@bajustone/fortress';
23
+ import { tenancy } from '@bajustone/fortress/plugins/tenancy';
24
+
25
+ const fortress = createFortress({
26
+ jwt: { key: 'your-secret-at-least-32-bytes!!' },
27
+ database: adapter,
28
+ plugins: [
29
+ tenancy({
30
+ schemaPrefix: 'tenant_',
31
+ routes: false,
32
+ onSchemaCreated: async (schemaName, rawQuery) => {
33
+ await rawQuery(`CREATE TABLE IF NOT EXISTS ${schemaName}.widgets (id SERIAL PRIMARY KEY)`);
34
+ },
35
+ dropSchemaOnDelete: false,
36
+ }),
37
+ ],
38
+ });
39
+ ```
40
+
41
+ Programmatic methods are always available at `fortress.plugins.tenancy`. HTTP routes are mounted only when `routes: true`.
42
+
43
+ ## Configuration
44
+
45
+ | Option | Type | Default | Description |
46
+ |---|---|---|---|
47
+ | `schemaPrefix` | `string` | `'tenant_'` | Prefix for tenant schemas. Must match `^[a-z_][a-z0-9_]*$`. Full schema name is `${schemaPrefix}${tenant.id}`. |
48
+ | `routes` | `boolean` | `false` | Mount opt-in HTTP routes under `/tenancy/*`. |
49
+ | `onSchemaCreated` | `(schemaName, rawQuery) => Promise<void>` | `undefined` | Runs inside the `createTenant` transaction after `CREATE SCHEMA`. Use for per-tenant DDL/migrations. |
50
+ | `dropSchemaOnDelete` | `boolean` | `false` | When `true`, `deleteTenant` drops the tenant schema with `CASCADE`. |
51
+
52
+ ## HTTP Routes
53
+
54
+ Enable with `tenancy({ routes: true })`.
55
+
56
+ | Method | Path | Handler | Auth |
57
+ |---|---|---|---|
58
+ | `POST` | `/tenancy/tenants` | `createTenant` | Bearer + `fortress:manageTenants` |
59
+ | `DELETE` | `/tenancy/tenants/:id` | `deleteTenant` | Bearer + `fortress:manageTenants` |
60
+ | `GET` | `/tenancy/tenants/mine` | `getMyTenants` | Bearer; caller derived from token |
61
+ | `POST` | `/tenancy/switch` | `switchTenant` | Bearer; caller derived from token |
62
+
63
+ Self-service routes ignore any `userId` in the body when a route context is present.
64
+
65
+ ## API Reference
66
+
67
+ | Method | Signature | Returns |
68
+ |---|---|---|
69
+ | `createTenant` | `({ name, taxId, description? })` | `Promise<TenantRecord>` |
70
+ | `deleteTenant` | `({ id })` | `Promise<{ ok: true }>` |
71
+ | `addUserToTenant` | `(userId, tenantId)` | `Promise<void>` |
72
+ | `getUserTenants` | `(userId)` | `Promise<TenantRecord[]>` |
73
+ | `getMyTenants` | `({ userId? }, routeCtx?)` | `Promise<{ tenants: TenantRecord[] }>` |
74
+ | `switchTenant` | `({ taxId, userId? }, routeCtx?)` | `Promise<{ ok: true }>` |
75
+
76
+ ### createTenant
77
+
78
+ Creates the tenant row and, on PostgreSQL, schema `tenant_<id>` (or your configured prefix) in one transaction:
79
+
80
+ ```ts
81
+ const tenant = await fortress.plugins.tenancy.createTenant({
82
+ name: 'Acme Corp',
83
+ taxId: 'acme-corp', // unique external code; not used in schema names
84
+ });
85
+ ```
86
+
87
+ ### deleteTenant
88
+
89
+ Removes tenant memberships and the tenant row. The schema is only dropped when `dropSchemaOnDelete: true`.
90
+
91
+ ```ts
92
+ await fortress.plugins.tenancy.deleteTenant({ id: tenant.id });
93
+ ```
94
+
95
+ ### switchTenant
96
+
97
+ Sets the user's default tenant after verifying membership. The flip is serialized and performed as a two-phase update inside one transaction (clear, then set), while a partial unique index enforces at most one default membership per user. The new tenant takes effect on the next login/token refresh because tenant data is stored in JWT custom claims.
98
+
99
+ ```ts
100
+ await fortress.plugins.tenancy.switchTenant({ taxId: 'acme-corp', userId });
101
+ ```
102
+
103
+ ## Migration notes for pre-hardening schemas
104
+
105
+ Older experimental builds derived schema names from tenant codes/tax IDs (for example `tenant_acme-001`) and trusted `X-Tenant-Code` as request context. Hardened Fortress no longer reads that header and no longer uses tax IDs in SQL identifiers.
106
+
107
+ For existing deployments:
108
+
109
+ 1. For every row in `fortress_tenant`, compute the hardened schema name as `${schemaPrefix}${tenant.id}` (default: `tenant_<numeric id>`).
110
+ 2. Rename each old tenant schema to the hardened name, or recreate it and copy data:
111
+ ```sql
112
+ ALTER SCHEMA old_schema_name RENAME TO tenant_123;
113
+ ```
114
+ 3. Ensure all tenant business tables live only in tenant schemas, not `public`, so missing tenant context fails closed.
115
+ 4. Remove any app code that forwards or depends on `X-Tenant-Code`.
116
+ 5. Have users log in or refresh tokens after `switchTenant`; the active tenant comes from `claims.customClaims.tenantId`.
117
+
118
+ No Fortress-owned table shape changes are required beyond the normal migration catalog/version checks.
119
+
120
+ ## JWT staleness tradeoff
121
+
122
+ Tenant access is encoded in short-lived JWT access tokens. If a user is removed from a tenant, an already-issued token can retain its old `tenantId` claim until it expires or is refreshed. Keep access-token lifetimes short and force session/token revocation for immediate removal.
@@ -0,0 +1,191 @@
1
+ # Two-Factor Authentication Plugin
2
+
3
+ TOTP-based two-factor authentication with backup codes and trusted devices.
4
+
5
+ ## Installation
6
+
7
+ ```ts
8
+ import { createFortress } from '@bajustone/fortress';
9
+ import { twoFactor } from '@bajustone/fortress/plugins/two-factor';
10
+
11
+ const fortress = createFortress({
12
+ jwt: { key: 'your-secret-at-least-32-bytes-long' },
13
+ database: yourAdapter,
14
+ plugins: [
15
+ twoFactor({
16
+ secretEncryptionKey: process.env.FORTRESS_TOTP_ENCRYPTION_KEY!,
17
+ totp: { issuer: 'MyApp' },
18
+ }),
19
+ ],
20
+ } as const);
21
+ ```
22
+
23
+ The plugin registers three database models automatically: `two_factor_secret`, `backup_code`, and `trusted_device`.
24
+
25
+ ## Configuration
26
+
27
+ Pass a `TwoFactorConfig` object to `twoFactor()`. `secretEncryptionKey` is required; all other options are optional.
28
+
29
+ | Option | Type | Default | Description |
30
+ |---|---|---|---|
31
+ | `secretEncryptionKey` | `string` | required | Exactly 32 bytes (raw UTF-8, hex, base64, or base64url) used for AES-256-GCM encryption of TOTP seeds |
32
+ | `totp.issuer` | `string` | `'Fortress'` | Issuer name displayed in authenticator apps (Google Authenticator, Authy, etc.) |
33
+ | `totp.period` | `number` | `30` | TOTP time step in seconds |
34
+ | `totp.digits` | `number` | `6` | Number of digits in the TOTP code |
35
+ | `backupCodes.count` | `number` | `10` | Number of backup codes generated on enable |
36
+ | `trustedDeviceDays` | `number` | `30` | Days a device stays trusted after successful 2FA verification |
37
+
38
+ ```ts
39
+ twoFactor({
40
+ secretEncryptionKey: process.env.FORTRESS_TOTP_ENCRYPTION_KEY!,
41
+ totp: {
42
+ issuer: 'MyApp',
43
+ period: 30,
44
+ digits: 6,
45
+ },
46
+ backupCodes: { count: 10 },
47
+ trustedDeviceDays: 30,
48
+ })
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ Access the plugin methods via the type-safe `fortress.plugins['two-factor']` accessor.
54
+
55
+ ```ts
56
+ const tf = fortress.plugins['two-factor'];
57
+ ```
58
+
59
+ ### Enable 2FA
60
+
61
+ Call `enable` to generate a TOTP secret and backup codes. The user must verify a code before 2FA is activated. Only an AES-256-GCM ciphertext is stored; `isEnabled` stays `false` until the first successful `verify`.
62
+
63
+ Keep the encryption key in a secrets manager, separate from the database, and back it up. Losing or changing it makes existing seeds undecryptable and requires re-enrolment. Migration `0009_encrypt_totp_secrets` deletes legacy plaintext enrolments and associated recovery/trusted-device records so an upgrade cannot leave usable plaintext seeds behind.
64
+
65
+ ```ts
66
+ const setup = await fortress.plugins['two-factor'].enable(userId);
67
+
68
+ // setup.secret — base32-encoded TOTP secret (store nowhere; show once)
69
+ // setup.otpauthUrl — otpauth:// URI for QR code generation
70
+ // setup.backupCodes — array of one-time backup codes (show once, user saves them)
71
+ ```
72
+
73
+ Show the QR code to the user using any QR library:
74
+
75
+ ```ts
76
+ import QRCode from 'qrcode';
77
+
78
+ const qrDataUrl = await QRCode.toDataURL(setup.otpauthUrl);
79
+ // Render qrDataUrl as an <img> for the user to scan with their authenticator app
80
+ ```
81
+
82
+ Display the backup codes and instruct the user to store them securely. These are the only time the raw codes are available.
83
+
84
+ ### Confirm setup and complete challenges
85
+
86
+ After `enable`, activate the secret with `confirmSetup(userId, code)`. This setup-only method returns `{ verified: true, trustedDeviceToken? }` and does not issue a session. Device enrollment is explicit:
87
+
88
+ ```ts
89
+ const confirmation = await fortress.plugins['two-factor'].confirmSetup(
90
+ userId,
91
+ '123456',
92
+ { rememberDevice: true },
93
+ );
94
+ // If present, put confirmation.trustedDeviceToken in a host-managed
95
+ // Secure, HttpOnly, SameSite cookie. Fortress stores only its hash.
96
+ ```
97
+
98
+ During login, call `verify` with the pending continuation token and a TOTP or backup code. It returns the unified `AuthResult` and issues tokens only when every configured gate is complete.
99
+
100
+ ```ts
101
+ const result = await fortress.plugins['two-factor'].verify(
102
+ loginResult.pending.continuationToken,
103
+ code,
104
+ { rememberDevice: true },
105
+ );
106
+ ```
107
+
108
+ The equivalent HTTP endpoint is `POST /auth/2fa/verify` with `{ continuationToken, code, rememberDevice?: boolean }`. A successful opted-in response carries the raw token once in `pluginData.trustedDeviceToken`.
109
+
110
+ ### Disable 2FA
111
+
112
+ Removes the TOTP secret, all backup codes, and all trusted devices for the user.
113
+
114
+ ```ts
115
+ await fortress.plugins['two-factor'].disable(userId);
116
+ ```
117
+
118
+ After disabling, the user can call `enable` again to set up fresh 2FA.
119
+
120
+ ### Login flow with the post-auth gate
121
+
122
+ When a user with enabled 2FA logs in, Fortress checks trusted-device state before token issuance. An untrusted login returns an `AuthPending` with a required challenge and **no token fields**.
123
+
124
+ ```ts
125
+ const loginResult = await fortress.auth.login(email, password, {
126
+ trustedDeviceToken: readTrustedDeviceCookie(request),
127
+ });
128
+
129
+ if (loginResult.status === 'pending' && loginResult.pending.reason === 'two-factor') {
130
+ const completed = await fortress.plugins['two-factor'].verify(
131
+ loginResult.pending.continuationToken,
132
+ codeFromUser,
133
+ { rememberDevice: shouldRememberDevice },
134
+ );
135
+
136
+ if (completed.status === 'success') {
137
+ // completed.accessToken and completed.refreshToken are the issued session.
138
+ }
139
+ }
140
+ ```
141
+
142
+ ### Trusted Devices
143
+
144
+ Trusted-device enrollment occurs only when `rememberDevice: true` is supplied to `confirmSetup` or `verify`. Fortress generates a high-entropy opaque token, returns it once, and stores only its SHA-256 hash. User-Agent is metadata only and is never a trust credential.
145
+
146
+ The host must store the raw token in a `Secure`, `HttpOnly`, appropriately scoped `SameSite` cookie and pass it as `RequestMeta.trustedDeviceToken` on later programmatic logins. The HTTP login endpoint accepts the same opaque value as optional `trustedDeviceToken`; the host adapter is responsible for copying its cookie into that field.
147
+
148
+ - Trust expires after `trustedDeviceDays` (default 30).
149
+ - `lastUsedAt` is updated whenever a valid token is recognized.
150
+ - Calling `disable` removes all trusted devices for the user.
151
+ - Never expose the token to JavaScript-readable storage.
152
+
153
+ ## API Reference
154
+
155
+ All methods are accessed via `fortress.plugins['two-factor']`.
156
+
157
+ | Method | Signature | Description |
158
+ |---|---|---|
159
+ | `enable` | `(userId: string) => Promise<{ secret: string; otpauthUrl: string; backupCodes: string[] }>` | Generate an unconfirmed TOTP secret and backup codes. Throws if 2FA is already enabled. |
160
+ | `confirmSetup` | `(userId: string, code: string, meta?: RequestMeta) => Promise<{ verified: true; trustedDeviceToken?: string }>` | Verify the first TOTP code, activate setup, and optionally enroll a trusted device. |
161
+ | `verify` | `(continuationToken: string, code: string, meta?: RequestMeta) => Promise<AuthResult>` | Complete a pending login with TOTP or a single-use backup code, rerun remaining gates, and issue the session on success. |
162
+ | `disable` | `(userId: string) => Promise<void>` | Remove all 2FA data (secret, backup codes, trusted devices) for the user. |
163
+
164
+ The plugin also installs a `postAuthGate` hook. This is not called directly -- it runs automatically on every `fortress.auth.login()` call.
165
+
166
+ ## How It Works
167
+
168
+ ### TOTP (RFC 6238)
169
+
170
+ The implementation follows RFC 6238 (TOTP) built on RFC 4226 (HOTP):
171
+
172
+ 1. A 20-byte random secret is generated and base32-encoded.
173
+ 2. The current Unix timestamp is divided by the `period` (default 30s) to produce a counter.
174
+ 3. The counter is HMAC-SHA1 signed with the secret.
175
+ 4. Dynamic truncation extracts a 6-digit code from the HMAC result.
176
+ 5. Verification checks the current time window plus one window before and after (+-30s) to tolerate clock drift.
177
+
178
+ The `otpauthUrl` is a standard `otpauth://totp/` URI that authenticator apps (Google Authenticator, Authy, 1Password, etc.) can scan via QR code.
179
+
180
+ ### Backup Codes
181
+
182
+ - Generated as 8-character hex strings (4 random bytes each).
183
+ - Only SHA-256 hashes are stored in the database; raw codes are returned once from `enable`.
184
+ - Each backup code can be used exactly once. After use, the record is marked `isUsed: true`.
185
+ - All backup codes are deleted when 2FA is disabled.
186
+
187
+ ### Trusted Device Hashing
188
+
189
+ - The device fingerprint is `SHA-256(userId + ":" + userAgent)`.
190
+ - This is a convenience mechanism, not a strong device binding. User-Agent strings can be spoofed.
191
+ - Expired trusted device records are not automatically cleaned up. They are simply ignored when their `expiresAt` is in the past.