@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,1696 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/plugins/oauth/index.ts
21
+ var oauth_exports = {};
22
+ __export(oauth_exports, {
23
+ generateCodeChallenge: () => generateCodeChallenge,
24
+ generateCodeVerifier: () => generateCodeVerifier,
25
+ matchRedirectUri: () => matchRedirectUri,
26
+ oauth: () => oauth,
27
+ toOidcUserinfo: () => toOidcUserinfo,
28
+ verifyCodeChallenge: () => verifyCodeChallenge
29
+ });
30
+ module.exports = __toCommonJS(oauth_exports);
31
+
32
+ // src/core/auth/refresh-token.ts
33
+ async function generateRefreshToken() {
34
+ const bytes = new Uint8Array(32);
35
+ crypto.getRandomValues(bytes);
36
+ const raw = base64UrlEncode(bytes);
37
+ const hash = await hashToken(raw);
38
+ return { raw, hash };
39
+ }
40
+ function generateTokenFamily() {
41
+ const bytes = new Uint8Array(16);
42
+ crypto.getRandomValues(bytes);
43
+ return base64UrlEncode(bytes);
44
+ }
45
+ async function hashToken(raw) {
46
+ const data = new TextEncoder().encode(raw);
47
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
48
+ return hexEncode(new Uint8Array(hashBuffer));
49
+ }
50
+ function base64UrlEncode(bytes) {
51
+ const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join("");
52
+ return btoa(binString).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
53
+ }
54
+ function hexEncode(bytes) {
55
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
56
+ }
57
+
58
+ // src/core/auth/timing-safe.ts
59
+ function timingSafeEqualHex(a, b) {
60
+ if (a.length !== b.length)
61
+ return false;
62
+ let diff = 0;
63
+ for (let i = 0; i < a.length; i++)
64
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
65
+ return diff === 0;
66
+ }
67
+
68
+ // src/core/errors.ts
69
+ var FortressError = class extends Error {
70
+ code;
71
+ statusCode;
72
+ retryAfter;
73
+ details;
74
+ /** RFC 6749 §5.2 / §4.1.2.1 machine code. Set by `Errors.oauth()`. */
75
+ oauthError;
76
+ /** Human-readable description for the OAuth `error_description` field. */
77
+ oauthDescription;
78
+ /** Optional URI for the OAuth `error_uri` field (§5.2). */
79
+ oauthErrorUri;
80
+ constructor(code, message, statusCode, options) {
81
+ super(message, { cause: options?.cause });
82
+ this.code = code;
83
+ this.statusCode = statusCode;
84
+ this.retryAfter = options?.retryAfter;
85
+ this.details = options?.details;
86
+ this.oauthError = options?.oauthError;
87
+ this.oauthDescription = options?.oauthDescription;
88
+ this.oauthErrorUri = options?.oauthErrorUri;
89
+ }
90
+ toJSON() {
91
+ return {
92
+ code: this.code,
93
+ message: this.message,
94
+ statusCode: this.statusCode,
95
+ ...this.details !== void 0 && { details: this.details }
96
+ };
97
+ }
98
+ };
99
+ var Errors = {
100
+ unauthorized: (message = "Unauthorized") => new FortressError("UNAUTHORIZED", message, 401),
101
+ tokenReuse: () => new FortressError("TOKEN_REUSE", "Token reuse detected", 401),
102
+ sessionIdleTimeout: () => new FortressError("SESSION_IDLE_TIMEOUT", "Session idle timeout exceeded", 401),
103
+ sessionAbsoluteTimeout: () => new FortressError("SESSION_ABSOLUTE_TIMEOUT", "Session absolute timeout exceeded", 401),
104
+ forbidden: (message = "Forbidden") => new FortressError("FORBIDDEN", message, 403),
105
+ badRequest: (message = "Bad request") => new FortressError("BAD_REQUEST", message, 400),
106
+ notFound: (message = "Not found") => new FortressError("NOT_FOUND", message, 404),
107
+ conflict: (message = "Conflict", options) => new FortressError("CONFLICT", message, 409, options),
108
+ unprocessable: (message = "Unprocessable entity", options) => new FortressError("UNPROCESSABLE_ENTITY", message, 422, options),
109
+ rateLimited: (retryAfter) => new FortressError("RATE_LIMITED", "Too many requests", 429, { retryAfter }),
110
+ database: (message = "Database error", cause) => new FortressError("DATABASE_ERROR", message, 500, { cause }),
111
+ serviceUnavailable: (message = "Service unavailable", options) => new FortressError("SERVICE_UNAVAILABLE", message, 503, options),
112
+ validationError: (issues) => new FortressError("VALIDATION_ERROR", "Validation failed", 422, { details: issues }),
113
+ /**
114
+ * RFC 6749 §5.2 / §4.1.2.1 OAuth error.
115
+ *
116
+ * The HTTP error mapper detects `oauthError` and emits the OAuth-spec
117
+ * JSON body `{ error, error_description, error_uri? }` instead of the
118
+ * default fortress `{ code, message }` shape, so strict OAuth clients
119
+ * (Moodle, openid-client, Spring Security, etc.) can switch behaviour
120
+ * on the machine-readable `error` field.
121
+ *
122
+ * Status is inferred from the OAuth code per the spec table:
123
+ * - `invalid_client` → 401
124
+ * - everything else → 400
125
+ */
126
+ oauth: (error, description, options) => {
127
+ const status = options?.status ?? (error === "invalid_client" ? 401 : 400);
128
+ const code = status === 401 ? "UNAUTHORIZED" : "BAD_REQUEST";
129
+ return new FortressError(code, description ?? error, status, {
130
+ oauthError: error,
131
+ oauthDescription: description,
132
+ oauthErrorUri: options?.errorUri,
133
+ cause: options?.cause
134
+ });
135
+ },
136
+ /**
137
+ * Reconstruct a {@link FortressError} from a JSON error body emitted by
138
+ * `errorToResponse`. Used by the in-process `fortress.call.*` client to
139
+ * convert non-2xx responses into typed throws that mirror what a direct
140
+ * service-method call would produce.
141
+ *
142
+ * Maps by HTTP status when the body doesn't carry a recognizable
143
+ * `{ code, message }` payload, so network errors and opaque upstream
144
+ * failures still become structured `FortressError`s.
145
+ */
146
+ fromHttpResponse: (status, body) => {
147
+ const payload = body && typeof body === "object" ? body : {};
148
+ const message = typeof payload.message === "string" ? payload.message : `HTTP ${status}`;
149
+ const code = typeof payload.code === "string" && isFortressErrorCode(payload.code) ? payload.code : statusToErrorCode(status);
150
+ return new FortressError(code, message, status, { details: payload.details });
151
+ }
152
+ };
153
+ function isFortressErrorCode(code) {
154
+ return code === "UNAUTHORIZED" || code === "TOKEN_REUSE" || code === "SESSION_IDLE_TIMEOUT" || code === "SESSION_ABSOLUTE_TIMEOUT" || code === "FORBIDDEN" || code === "BAD_REQUEST" || code === "NOT_FOUND" || code === "CONFLICT" || code === "UNPROCESSABLE_ENTITY" || code === "RATE_LIMITED" || code === "DATABASE_ERROR" || code === "VALIDATION_ERROR" || code === "SERVICE_UNAVAILABLE";
155
+ }
156
+ function statusToErrorCode(status) {
157
+ if (status === 401)
158
+ return "UNAUTHORIZED";
159
+ if (status === 403)
160
+ return "FORBIDDEN";
161
+ if (status === 404)
162
+ return "NOT_FOUND";
163
+ if (status === 409)
164
+ return "CONFLICT";
165
+ if (status === 422)
166
+ return "UNPROCESSABLE_ENTITY";
167
+ if (status === 429)
168
+ return "RATE_LIMITED";
169
+ if (status === 503)
170
+ return "SERVICE_UNAVAILABLE";
171
+ if (status >= 500)
172
+ return "DATABASE_ERROR";
173
+ return "BAD_REQUEST";
174
+ }
175
+
176
+ // src/plugins/oauth/id-token.ts
177
+ var import_jose = require("jose");
178
+ async function issueIdToken(params) {
179
+ const ttl = params.ttlSeconds ?? 3600;
180
+ const now = Math.floor(Date.now() / 1e3);
181
+ const scopes = params.scope ? params.scope.split(" ").filter(Boolean) : [];
182
+ const exposeEmail = scopes.includes("email");
183
+ const exposeProfile = scopes.includes("profile");
184
+ const claims = {};
185
+ if (params.authTimeSeconds !== void 0)
186
+ claims.auth_time = params.authTimeSeconds;
187
+ if (params.nonce)
188
+ claims.nonce = params.nonce;
189
+ if (exposeEmail) {
190
+ claims.email = params.user.email;
191
+ if (typeof params.user.emailVerified === "boolean")
192
+ claims.email_verified = params.user.emailVerified;
193
+ }
194
+ if (exposeProfile) {
195
+ claims.name = params.user.name;
196
+ claims.preferred_username = params.user.email;
197
+ }
198
+ if (params.user.updatedAt) {
199
+ claims.updated_at = Math.floor(new Date(params.user.updatedAt).getTime() / 1e3);
200
+ }
201
+ return new import_jose.SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: params.signingKey.kid, typ: "JWT" }).setIssuer(params.issuerUrl).setSubject(String(params.user.id)).setAudience(params.clientId).setIssuedAt(now).setExpirationTime(now + ttl).sign(params.signingKey.privateKey);
202
+ }
203
+
204
+ // src/plugins/oauth/jwks.ts
205
+ var import_jose2 = require("jose");
206
+ async function getActiveSigningKey(db) {
207
+ const existing = await db.findOne({
208
+ model: "oauth_signing_key",
209
+ where: [{ field: "rotatedAt", operator: "isNull", value: null }]
210
+ });
211
+ if (existing)
212
+ return hydrate(existing);
213
+ const { publicKey, privateKey } = await (0, import_jose2.generateKeyPair)("RS256", { extractable: true });
214
+ const publicJwk = await (0, import_jose2.exportJWK)(publicKey);
215
+ const privateJwk = await (0, import_jose2.exportJWK)(privateKey);
216
+ const kid = await (0, import_jose2.calculateJwkThumbprint)(publicJwk, "sha256");
217
+ publicJwk.kid = kid;
218
+ publicJwk.alg = "RS256";
219
+ publicJwk.use = "sig";
220
+ privateJwk.kid = kid;
221
+ privateJwk.alg = "RS256";
222
+ await db.create({
223
+ model: "oauth_signing_key",
224
+ data: {
225
+ kid,
226
+ alg: "RS256",
227
+ publicJwk: JSON.stringify(publicJwk),
228
+ privateJwk: JSON.stringify(privateJwk),
229
+ rotatedAt: null
230
+ }
231
+ });
232
+ return {
233
+ kid,
234
+ alg: "RS256",
235
+ privateKey,
236
+ publicJwk
237
+ };
238
+ }
239
+ async function listJwks(db, graceSeconds = 3600) {
240
+ const all = await db.findMany({
241
+ model: "oauth_signing_key",
242
+ where: []
243
+ });
244
+ const cutoff = Date.now() - Math.max(0, graceSeconds) * 1e3;
245
+ return {
246
+ keys: all.filter((row) => row.rotatedAt === null || new Date(row.rotatedAt).getTime() > cutoff).sort((a, b) => {
247
+ if (a.rotatedAt === null && b.rotatedAt !== null)
248
+ return -1;
249
+ if (a.rotatedAt !== null && b.rotatedAt === null)
250
+ return 1;
251
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
252
+ }).map((row) => JSON.parse(row.publicJwk))
253
+ };
254
+ }
255
+ async function rotateSigningKey(db, graceSeconds = 3600) {
256
+ return db.transaction(async (tx) => {
257
+ if (tx.dialect === "pg" && tx.rawQuery)
258
+ await tx.rawQuery("SELECT pg_advisory_xact_lock(hashtext(?))", ["fortress-oauth-signing-key"]);
259
+ const now = /* @__PURE__ */ new Date();
260
+ const active = await tx.findOne({
261
+ model: "oauth_signing_key",
262
+ where: [{ field: "rotatedAt", operator: "isNull", value: null }]
263
+ });
264
+ if (active) {
265
+ await tx.update({
266
+ model: "oauth_signing_key",
267
+ where: [{ field: "id", operator: "=", value: active.id }],
268
+ data: { rotatedAt: now }
269
+ });
270
+ }
271
+ const cutoff = new Date(now.getTime() - Math.max(0, graceSeconds) * 1e3);
272
+ const expired = await tx.findMany({
273
+ model: "oauth_signing_key",
274
+ where: [{ field: "rotatedAt", operator: "lt", value: cutoff }]
275
+ });
276
+ for (const key of expired) {
277
+ await tx.delete({
278
+ model: "oauth_signing_key",
279
+ where: [{ field: "id", operator: "=", value: key.id }]
280
+ });
281
+ }
282
+ return getActiveSigningKey(tx);
283
+ });
284
+ }
285
+ async function hydrate(record) {
286
+ const publicJwk = JSON.parse(record.publicJwk);
287
+ const privateJwk = JSON.parse(record.privateJwk);
288
+ const privateKey = await (0, import_jose2.importJWK)(privateJwk, "RS256");
289
+ return {
290
+ kid: record.kid,
291
+ alg: "RS256",
292
+ privateKey,
293
+ publicJwk
294
+ };
295
+ }
296
+
297
+ // src/plugins/oauth/pkce.ts
298
+ var CODE_VERIFIER_PATTERN = /^[\w.~-]{43,128}$/;
299
+ function generateCodeVerifier() {
300
+ const bytes = new Uint8Array(32);
301
+ crypto.getRandomValues(bytes);
302
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
303
+ }
304
+ async function generateCodeChallenge(verifier) {
305
+ const data = new TextEncoder().encode(verifier);
306
+ const hash = await crypto.subtle.digest("SHA-256", data);
307
+ return btoa(String.fromCharCode(...new Uint8Array(hash))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
308
+ }
309
+ async function verifyCodeChallenge(verifier, challenge, method) {
310
+ if (method !== "S256")
311
+ return false;
312
+ if (!CODE_VERIFIER_PATTERN.test(verifier))
313
+ return false;
314
+ const computed = await generateCodeChallenge(verifier);
315
+ return timingSafeEqualHex(computed, challenge);
316
+ }
317
+
318
+ // src/plugins/oauth/index.ts
319
+ function oauth(config = {}) {
320
+ const authCodeExpiry = config.authCodeExpirySeconds ?? 600;
321
+ const pendingFlowExpiry = config.pendingFlowExpirySeconds ?? 600;
322
+ const accessTokenExpiry = config.accessTokenExpirySeconds ?? 3600;
323
+ const refreshTokenExpiry = config.refreshTokenExpirySeconds ?? 30 * 24 * 3600;
324
+ const refreshEnabled = refreshTokenExpiry > 0;
325
+ const idTokenExpiry = config.idTokenExpirySeconds ?? 3600;
326
+ const signingKeyGraceSeconds = config.signingKeyGraceSeconds ?? idTokenExpiry;
327
+ const scopePermissionMap = config.scopePermissionMap;
328
+ const issuerUrl = config.issuerUrl ?? "https://localhost";
329
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production" && !issuerUrl.startsWith("https://")) {
330
+ throw new Error(
331
+ `[fortress/oauth] issuerUrl must use https:// in production (got: ${issuerUrl})`
332
+ );
333
+ }
334
+ return {
335
+ name: "oauth",
336
+ models: [
337
+ {
338
+ name: "oauth_client",
339
+ fields: {
340
+ id: { type: "number", required: true },
341
+ clientId: { type: "string", required: true, unique: true },
342
+ // For public clients (`tokenEndpointAuthMethod === 'none'`) the
343
+ // hash is set to the empty string — the column is required by the
344
+ // adapter for backwards compat, but never compared against an
345
+ // inbound secret.
346
+ clientSecretHash: { type: "string", required: true },
347
+ name: { type: "string", required: true },
348
+ redirectUris: { type: "string", required: true },
349
+ grantTypes: { type: "string", required: true },
350
+ // RFC 6749 §3.3 / RFC 9700 §2.2.1 per-client scope allow-list.
351
+ // Optional for backwards compatibility with legacy v0 clients
352
+ // created before this column existed.
353
+ allowedScopes: { type: "string" },
354
+ // RFC 6749 §2.1 client type. Optional — absent on legacy clients,
355
+ // which are treated as confidential (`client_secret_basic`).
356
+ tokenEndpointAuthMethod: { type: "string" },
357
+ createdAt: { type: "date", required: true }
358
+ }
359
+ },
360
+ {
361
+ name: "oauth_authorization_code",
362
+ fields: {
363
+ id: { type: "number", required: true },
364
+ code: { type: "string", required: true, unique: true },
365
+ clientId: { type: "string", required: true },
366
+ userId: { type: "number", required: true },
367
+ redirectUri: { type: "string", required: true },
368
+ scope: { type: "string" },
369
+ codeChallenge: { type: "string" },
370
+ codeChallengeMethod: { type: "string" },
371
+ // OIDC Core §3.1.2.1 nonce — echoed into the id_token verbatim.
372
+ nonce: { type: "string" },
373
+ // Unix seconds; OIDC `auth_time` claim source.
374
+ authTime: { type: "number" },
375
+ expiresAt: { type: "date", required: true },
376
+ usedAt: { type: "date" },
377
+ createdAt: { type: "date", required: true }
378
+ }
379
+ },
380
+ {
381
+ name: "oauth_access_token",
382
+ fields: {
383
+ id: { type: "number", required: true },
384
+ token: { type: "string", required: true, unique: true },
385
+ clientId: { type: "string", required: true },
386
+ userId: { type: "number" },
387
+ scope: { type: "string" },
388
+ expiresAt: { type: "date", required: true },
389
+ createdAt: { type: "date", required: true }
390
+ }
391
+ },
392
+ {
393
+ name: "oauth_refresh_token",
394
+ fields: {
395
+ id: { type: "number", required: true },
396
+ token: { type: "string", required: true, unique: true },
397
+ familyId: { type: "string", required: true },
398
+ clientId: { type: "string", required: true },
399
+ userId: { type: "number", required: true },
400
+ scope: { type: "string" },
401
+ issuedAt: { type: "date", required: true },
402
+ expiresAt: { type: "date", required: true },
403
+ usedAt: { type: "date" },
404
+ parentId: { type: "number" },
405
+ createdAt: { type: "date", required: true }
406
+ }
407
+ },
408
+ {
409
+ name: "oauth_pending_flow",
410
+ fields: {
411
+ id: { type: "number", required: true },
412
+ flowId: { type: "string", required: true, unique: true },
413
+ clientId: { type: "string", required: true },
414
+ redirectUri: { type: "string", required: true },
415
+ scope: { type: "string" },
416
+ state: { type: "string", required: true },
417
+ codeChallenge: { type: "string" },
418
+ codeChallengeMethod: { type: "string" },
419
+ nonce: { type: "string" },
420
+ // H6 fix: subject the flow is bound to. Nullable for back-compat
421
+ // and to model the trust-on-first-use claim.
422
+ userId: { type: "number" },
423
+ // Single-use approval/denial claim. Older rows may have null.
424
+ usedAt: { type: "date" },
425
+ expiresAt: { type: "date", required: true },
426
+ createdAt: { type: "date", required: true }
427
+ }
428
+ },
429
+ {
430
+ // OIDC Core §2 / RFC 7517 signing key persistence. RS256 only for
431
+ // now; the row stores both public and private JWKs as JSON. Active
432
+ // key has rotatedAt == null.
433
+ name: "oauth_signing_key",
434
+ fields: {
435
+ id: { type: "number", required: true },
436
+ kid: { type: "string", required: true, unique: true },
437
+ alg: { type: "string", required: true },
438
+ publicJwk: { type: "string", required: true },
439
+ privateJwk: { type: "string", required: true },
440
+ createdAt: { type: "date", required: true },
441
+ rotatedAt: { type: "date" }
442
+ }
443
+ }
444
+ ],
445
+ methods: (ctx) => ({
446
+ /**
447
+ * Register a new OAuth client.
448
+ * Returns the clientId and raw clientSecret (shown once, stored as hash).
449
+ */
450
+ async createClient(data) {
451
+ const isPublic = data.tokenEndpointAuthMethod === "none";
452
+ const { raw: clientSecret, hash: clientSecretHash } = isPublic ? { raw: "", hash: "" } : await generateRefreshToken();
453
+ const { raw: clientIdRaw } = await generateRefreshToken();
454
+ const clientId = clientIdRaw.slice(0, 24);
455
+ await ctx.db.create({
456
+ model: "oauth_client",
457
+ data: {
458
+ clientId,
459
+ clientSecretHash,
460
+ name: data.name,
461
+ redirectUris: JSON.stringify(data.redirectUris),
462
+ grantTypes: JSON.stringify(data.grantTypes),
463
+ allowedScopes: data.allowedScopes ? JSON.stringify(data.allowedScopes) : null,
464
+ tokenEndpointAuthMethod: data.tokenEndpointAuthMethod ?? "client_secret_basic"
465
+ }
466
+ });
467
+ return {
468
+ clientId,
469
+ // Public clients have no secret — returning `null` makes the
470
+ // contract explicit at compile time.
471
+ clientSecret: isPublic ? null : clientSecret
472
+ };
473
+ },
474
+ /**
475
+ * Generate an authorization code for a user+client.
476
+ * Used after the user authenticates and authorizes the client.
477
+ */
478
+ async createAuthorizationCode(params) {
479
+ const client = await ctx.db.findOne({
480
+ model: "oauth_client",
481
+ where: [{ field: "clientId", operator: "=", value: params.clientId }]
482
+ });
483
+ if (!client)
484
+ throw Errors.oauth("invalid_request", "Invalid client_id");
485
+ const uris = JSON.parse(client.redirectUris);
486
+ if (!uris.some((r) => matchRedirectUri(r, params.redirectUri)))
487
+ throw Errors.oauth("invalid_request", "Invalid redirect_uri");
488
+ const isPublic = client.tokenEndpointAuthMethod === "none";
489
+ if (isPublic && (!params.codeChallenge || params.codeChallengeMethod !== "S256"))
490
+ throw Errors.oauth("invalid_request", "PKCE (S256) is required for public clients");
491
+ const { raw: code, hash: codeHash } = await generateRefreshToken();
492
+ const expiresAt = new Date(Date.now() + authCodeExpiry * 1e3);
493
+ await ctx.db.create({
494
+ model: "oauth_authorization_code",
495
+ data: {
496
+ code: codeHash,
497
+ clientId: params.clientId,
498
+ userId: params.userId,
499
+ redirectUri: params.redirectUri,
500
+ scope: params.scope ?? null,
501
+ codeChallenge: params.codeChallenge ?? null,
502
+ codeChallengeMethod: params.codeChallengeMethod ?? null,
503
+ nonce: params.nonce ?? null,
504
+ authTime: params.authTime ?? null,
505
+ expiresAt,
506
+ usedAt: null
507
+ }
508
+ });
509
+ return { code };
510
+ },
511
+ /**
512
+ * Exchange an authorization code for an access token.
513
+ */
514
+ async exchangeCode(params) {
515
+ const client = await ctx.db.findOne({
516
+ model: "oauth_client",
517
+ where: [{ field: "clientId", operator: "=", value: params.clientId }]
518
+ });
519
+ if (!client)
520
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
521
+ const isPublic = client.tokenEndpointAuthMethod === "none";
522
+ if (!isPublic) {
523
+ if (!params.clientSecret)
524
+ throw Errors.oauth("invalid_client", "Client secret required");
525
+ const secretValid = timingSafeEqualHex(
526
+ await hashToken(params.clientSecret),
527
+ client.clientSecretHash
528
+ );
529
+ if (!secretValid)
530
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
531
+ } else if (params.clientSecret) {
532
+ throw Errors.oauth("invalid_client", "Public clients must not present a client_secret");
533
+ }
534
+ const grantTypes = JSON.parse(client.grantTypes);
535
+ if (!grantTypes.includes("authorization_code"))
536
+ throw Errors.oauth("unauthorized_client", "Client does not support authorization_code grant");
537
+ const codeHash = await hashToken(params.code);
538
+ return ctx.db.transaction(async (tx) => {
539
+ const authCode = await tx.findOne({
540
+ model: "oauth_authorization_code",
541
+ where: [{ field: "code", operator: "=", value: codeHash }]
542
+ });
543
+ if (!authCode)
544
+ throw Errors.oauth("invalid_grant", "Invalid authorization code");
545
+ if (authCode.expiresAt < /* @__PURE__ */ new Date())
546
+ throw Errors.oauth("invalid_grant", "Authorization code expired");
547
+ if (authCode.clientId !== params.clientId)
548
+ throw Errors.oauth("invalid_grant", "Client mismatch");
549
+ if (authCode.redirectUri !== params.redirectUri)
550
+ throw Errors.oauth("invalid_grant", "Redirect URI mismatch");
551
+ if (isPublic && !authCode.codeChallenge)
552
+ throw Errors.oauth("invalid_grant", "PKCE required for public clients");
553
+ if (authCode.codeChallenge && authCode.codeChallengeMethod) {
554
+ if (!params.codeVerifier)
555
+ throw Errors.oauth("invalid_grant", "code_verifier required");
556
+ const valid = await verifyCodeChallenge(
557
+ params.codeVerifier,
558
+ authCode.codeChallenge,
559
+ authCode.codeChallengeMethod
560
+ );
561
+ if (!valid)
562
+ throw Errors.oauth("invalid_grant", "Invalid code_verifier");
563
+ }
564
+ const claimed = await tx.update({
565
+ model: "oauth_authorization_code",
566
+ where: [
567
+ { field: "id", operator: "=", value: authCode.id },
568
+ { field: "usedAt", operator: "isNull", value: null }
569
+ ],
570
+ data: { usedAt: /* @__PURE__ */ new Date() }
571
+ });
572
+ if (!claimed)
573
+ throw Errors.oauth("invalid_grant", "Authorization code already used");
574
+ const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
575
+ const expiresAt = new Date(Date.now() + accessTokenExpiry * 1e3);
576
+ await tx.create({
577
+ model: "oauth_access_token",
578
+ data: {
579
+ token: tokenHash,
580
+ clientId: params.clientId,
581
+ userId: authCode.userId,
582
+ scope: authCode.scope,
583
+ expiresAt
584
+ }
585
+ });
586
+ let idToken;
587
+ const scopes = (authCode.scope ?? "").split(" ").filter(Boolean);
588
+ if (scopes.includes("openid")) {
589
+ const user = await tx.findOne({
590
+ model: "user",
591
+ where: [{ field: "id", operator: "=", value: authCode.userId }]
592
+ });
593
+ if (user) {
594
+ const signingKey = await getActiveSigningKey(tx);
595
+ idToken = await issueIdToken({
596
+ user,
597
+ clientId: params.clientId,
598
+ issuerUrl,
599
+ ttlSeconds: idTokenExpiry,
600
+ nonce: authCode.nonce ?? void 0,
601
+ authTimeSeconds: authCode.authTime ?? Math.floor(Date.now() / 1e3),
602
+ scope: authCode.scope,
603
+ signingKey
604
+ });
605
+ }
606
+ }
607
+ let refreshTokenRaw;
608
+ if (refreshEnabled) {
609
+ const { raw, hash } = await generateRefreshToken();
610
+ refreshTokenRaw = raw;
611
+ await tx.create({
612
+ model: "oauth_refresh_token",
613
+ data: {
614
+ token: hash,
615
+ familyId: generateTokenFamily(),
616
+ clientId: params.clientId,
617
+ userId: authCode.userId,
618
+ scope: authCode.scope ?? null,
619
+ issuedAt: /* @__PURE__ */ new Date(),
620
+ expiresAt: new Date(Date.now() + refreshTokenExpiry * 1e3),
621
+ usedAt: null,
622
+ parentId: null
623
+ }
624
+ });
625
+ }
626
+ return {
627
+ accessToken: tokenRaw,
628
+ tokenType: "Bearer",
629
+ expiresIn: accessTokenExpiry,
630
+ scope: authCode.scope ?? void 0,
631
+ ...refreshTokenRaw ? { refreshToken: refreshTokenRaw } : {},
632
+ ...idToken ? { idToken } : {}
633
+ };
634
+ });
635
+ },
636
+ /**
637
+ * RFC 6749 §6 refresh-token grant with RFC 9700 §2.2.2 rotation.
638
+ *
639
+ * Flow:
640
+ * 1. Look up the inbound refresh token by hash.
641
+ * 2. If it's already been used (`usedAt != null`), this is a replay
642
+ * attack — delete every refresh token in the same family and
643
+ * throw `invalid_grant`. The legitimate client's most recent
644
+ * refresh dies with the family, so the user is forced to
645
+ * re-authenticate, but the attacker's stolen token is now
646
+ * useless.
647
+ * 3. Otherwise mark the old token used, mint a new access token +
648
+ * a new refresh token in the same family, link the new refresh
649
+ * via `parentId`, and return the pair.
650
+ */
651
+ async refreshTokenGrant(params) {
652
+ const client = await ctx.db.findOne({
653
+ model: "oauth_client",
654
+ where: [{ field: "clientId", operator: "=", value: params.clientId }]
655
+ });
656
+ if (!client)
657
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
658
+ const isPublic = client.tokenEndpointAuthMethod === "none";
659
+ if (!isPublic) {
660
+ if (!params.clientSecret)
661
+ throw Errors.oauth("invalid_client", "Client secret required");
662
+ const secretValid = timingSafeEqualHex(
663
+ await hashToken(params.clientSecret),
664
+ client.clientSecretHash
665
+ );
666
+ if (!secretValid)
667
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
668
+ } else if (params.clientSecret) {
669
+ throw Errors.oauth("invalid_client", "Public clients must not present a client_secret");
670
+ }
671
+ const refreshGrantTypes = JSON.parse(client.grantTypes);
672
+ if (!refreshGrantTypes.includes("refresh_token"))
673
+ throw Errors.oauth("unauthorized_client", "Client does not support refresh_token grant");
674
+ const tokenHash = await hashToken(params.refreshToken);
675
+ const grant = await ctx.db.transaction(async (tx) => {
676
+ const record = await tx.findOne({
677
+ model: "oauth_refresh_token",
678
+ where: [{ field: "token", operator: "=", value: tokenHash }]
679
+ });
680
+ if (!record)
681
+ throw Errors.oauth("invalid_grant", "Invalid refresh token");
682
+ if (record.clientId !== params.clientId)
683
+ throw Errors.oauth("invalid_grant", "Refresh token client mismatch");
684
+ if (record.expiresAt < /* @__PURE__ */ new Date())
685
+ throw Errors.oauth("invalid_grant", "Refresh token expired");
686
+ if (record.usedAt) {
687
+ await tx.delete({
688
+ model: "oauth_refresh_token",
689
+ where: [{ field: "familyId", operator: "=", value: record.familyId }]
690
+ });
691
+ return { replayDetected: true };
692
+ }
693
+ let scope = record.scope;
694
+ if (params.scope) {
695
+ const requested = params.scope.split(" ").filter(Boolean);
696
+ const original = (record.scope ?? "").split(" ").filter(Boolean);
697
+ const originalSet = new Set(original);
698
+ const widened = requested.find((s) => !originalSet.has(s));
699
+ if (widened) {
700
+ throw Errors.oauth("invalid_scope", `Refreshed scope cannot widen: ${widened}`);
701
+ }
702
+ scope = requested.join(" ");
703
+ }
704
+ const claimed = await tx.update({
705
+ model: "oauth_refresh_token",
706
+ where: [
707
+ { field: "id", operator: "=", value: record.id },
708
+ { field: "usedAt", operator: "isNull", value: null }
709
+ ],
710
+ data: { usedAt: /* @__PURE__ */ new Date() }
711
+ });
712
+ if (!claimed) {
713
+ await tx.delete({
714
+ model: "oauth_refresh_token",
715
+ where: [{ field: "familyId", operator: "=", value: record.familyId }]
716
+ });
717
+ return { replayDetected: true };
718
+ }
719
+ const { raw: accessRaw, hash: accessHash } = await generateRefreshToken();
720
+ await tx.create({
721
+ model: "oauth_access_token",
722
+ data: {
723
+ token: accessHash,
724
+ clientId: params.clientId,
725
+ userId: record.userId,
726
+ scope,
727
+ expiresAt: new Date(Date.now() + accessTokenExpiry * 1e3)
728
+ }
729
+ });
730
+ let idToken;
731
+ if ((scope ?? "").split(" ").filter(Boolean).includes("openid")) {
732
+ const user = await tx.findOne({
733
+ model: "user",
734
+ where: [{ field: "id", operator: "=", value: record.userId }]
735
+ });
736
+ if (user) {
737
+ const signingKey = await getActiveSigningKey(tx);
738
+ idToken = await issueIdToken({
739
+ user,
740
+ clientId: params.clientId,
741
+ issuerUrl,
742
+ ttlSeconds: idTokenExpiry,
743
+ // The original authentication time is not stored on the
744
+ // refresh family. Omit auth_time rather than fabricating a
745
+ // fresh authentication event at token rotation.
746
+ scope,
747
+ signingKey
748
+ });
749
+ }
750
+ }
751
+ const { raw: refreshRaw, hash: refreshHash } = await generateRefreshToken();
752
+ await tx.create({
753
+ model: "oauth_refresh_token",
754
+ data: {
755
+ token: refreshHash,
756
+ familyId: record.familyId,
757
+ clientId: params.clientId,
758
+ userId: record.userId,
759
+ scope,
760
+ issuedAt: /* @__PURE__ */ new Date(),
761
+ expiresAt: new Date(Date.now() + refreshTokenExpiry * 1e3),
762
+ usedAt: null,
763
+ parentId: record.id
764
+ }
765
+ });
766
+ return {
767
+ accessToken: accessRaw,
768
+ tokenType: "Bearer",
769
+ expiresIn: accessTokenExpiry,
770
+ refreshToken: refreshRaw,
771
+ ...scope ? { scope } : {},
772
+ ...idToken ? { idToken } : {}
773
+ };
774
+ });
775
+ if ("replayDetected" in grant)
776
+ throw Errors.oauth("invalid_grant", "Refresh token reuse detected; family revoked");
777
+ return grant;
778
+ },
779
+ /**
780
+ * Client credentials grant — issue token for a service client (no user).
781
+ */
782
+ async clientCredentialsGrant(params) {
783
+ const client = await ctx.db.findOne({
784
+ model: "oauth_client",
785
+ where: [{ field: "clientId", operator: "=", value: params.clientId }]
786
+ });
787
+ if (!client)
788
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
789
+ if (client.tokenEndpointAuthMethod === "none")
790
+ throw Errors.oauth("unauthorized_client", "Public clients cannot use client_credentials");
791
+ const grantTypes = JSON.parse(client.grantTypes);
792
+ if (!grantTypes.includes("client_credentials"))
793
+ throw Errors.oauth("unauthorized_client", "Client does not support client_credentials grant");
794
+ const secretValid = timingSafeEqualHex(
795
+ await hashToken(params.clientSecret),
796
+ client.clientSecretHash
797
+ );
798
+ if (!secretValid)
799
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
800
+ const effectiveScope = intersectScope(params.scope ?? null, client.allowedScopes);
801
+ if (params.scope && effectiveScope === "") {
802
+ throw Errors.oauth("invalid_scope", "No requested scope is allowed for this client");
803
+ }
804
+ const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
805
+ const expiresAt = new Date(Date.now() + accessTokenExpiry * 1e3);
806
+ await ctx.db.create({
807
+ model: "oauth_access_token",
808
+ data: {
809
+ token: tokenHash,
810
+ clientId: params.clientId,
811
+ userId: null,
812
+ scope: effectiveScope || null,
813
+ expiresAt
814
+ }
815
+ });
816
+ return {
817
+ accessToken: tokenRaw,
818
+ tokenType: "Bearer",
819
+ expiresIn: accessTokenExpiry
820
+ };
821
+ },
822
+ /**
823
+ * Revoke an access token OR refresh token (RFC 7009).
824
+ *
825
+ * Per §2.1: revoking a refresh token also revokes the related access
826
+ * tokens. When a refresh token is revoked here, the entire token
827
+ * family is dropped — every rotated descendant becomes unusable.
828
+ */
829
+ async revokeToken(token) {
830
+ const tokenHash = await hashToken(token);
831
+ await ctx.db.delete({
832
+ model: "oauth_access_token",
833
+ where: [{ field: "token", operator: "=", value: tokenHash }]
834
+ });
835
+ if (refreshEnabled) {
836
+ const refreshRecord = await ctx.db.findOne({
837
+ model: "oauth_refresh_token",
838
+ where: [{ field: "token", operator: "=", value: tokenHash }]
839
+ });
840
+ if (refreshRecord) {
841
+ await ctx.db.delete({
842
+ model: "oauth_refresh_token",
843
+ where: [{ field: "familyId", operator: "=", value: refreshRecord.familyId }]
844
+ });
845
+ }
846
+ }
847
+ },
848
+ /**
849
+ * Validate an access token and return associated user/client info.
850
+ */
851
+ async introspectToken(token) {
852
+ const tokenHash = await hashToken(token);
853
+ const record = await ctx.db.findOne({
854
+ model: "oauth_access_token",
855
+ where: [{ field: "token", operator: "=", value: tokenHash }]
856
+ });
857
+ if (!record || record.expiresAt < /* @__PURE__ */ new Date()) {
858
+ return { active: false };
859
+ }
860
+ return {
861
+ active: true,
862
+ clientId: record.clientId,
863
+ userId: record.userId ?? void 0,
864
+ scope: record.scope ?? void 0
865
+ };
866
+ },
867
+ /**
868
+ * Create a pending OAuth flow for unauthenticated users (identity broker pattern).
869
+ */
870
+ async createPendingFlow(params) {
871
+ const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1e3);
872
+ const { raw: flowId } = await generateRefreshToken();
873
+ const flow = await ctx.db.create({
874
+ model: "oauth_pending_flow",
875
+ data: {
876
+ flowId,
877
+ clientId: params.clientId,
878
+ redirectUri: params.redirectUri,
879
+ scope: params.scope ?? null,
880
+ state: params.state,
881
+ codeChallenge: params.codeChallenge ?? null,
882
+ codeChallengeMethod: params.codeChallengeMethod ?? null,
883
+ userId: params.userId ?? null,
884
+ usedAt: null,
885
+ expiresAt
886
+ }
887
+ });
888
+ return { flowId: flow.flowId };
889
+ },
890
+ /**
891
+ * Read a pending OAuth flow without consuming it.
892
+ *
893
+ * Used by the consent UI to fetch flow metadata (client, scopes, redirect URI)
894
+ * before the user approves or denies. Safe to call multiple times.
895
+ *
896
+ * @throws {FortressError} `not_found` if the flow doesn't exist.
897
+ * @throws {FortressError} `bad_request` if the flow has expired.
898
+ */
899
+ async getPendingFlow(flowId, context) {
900
+ const flow = await ctx.db.findOne({
901
+ model: "oauth_pending_flow",
902
+ where: pendingFlowLookupWhere(flowId)
903
+ });
904
+ if (!flow)
905
+ throw Errors.notFound("Pending flow not found");
906
+ if (flow.usedAt)
907
+ throw Errors.notFound("Pending flow not found");
908
+ if (flow.expiresAt < /* @__PURE__ */ new Date())
909
+ throw Errors.badRequest("Pending flow expired");
910
+ if (context?.userId !== void 0 && flow.userId !== null && flow.userId !== context.userId) {
911
+ throw Errors.notFound("Pending flow not found");
912
+ }
913
+ return flow;
914
+ },
915
+ /**
916
+ * Resume a pending OAuth flow after user authenticates (single-use consume).
917
+ *
918
+ * Returns the stored flow params and deletes the row so the same flow can't
919
+ * be replayed. Use {@link OAuthMethods.getPendingFlow} for non-destructive reads.
920
+ */
921
+ async resumePendingFlow(flowId, context) {
922
+ const flow = await ctx.db.findOne({
923
+ model: "oauth_pending_flow",
924
+ where: pendingFlowLookupWhere(flowId)
925
+ });
926
+ if (!flow)
927
+ throw Errors.notFound("Pending flow not found");
928
+ if (flow.usedAt)
929
+ throw Errors.notFound("Pending flow not found");
930
+ if (flow.expiresAt < /* @__PURE__ */ new Date())
931
+ throw Errors.badRequest("Pending flow expired");
932
+ if (context?.userId !== void 0 && flow.userId !== null && flow.userId !== context.userId) {
933
+ throw Errors.notFound("Pending flow not found");
934
+ }
935
+ await ctx.db.delete({
936
+ model: "oauth_pending_flow",
937
+ where: [{ field: "id", operator: "=", value: flow.id }]
938
+ });
939
+ return flow;
940
+ },
941
+ /**
942
+ * Get userinfo for an access token (OpenID Connect userinfo endpoint).
943
+ */
944
+ /**
945
+ * Handle the authorize endpoint. Validates the OAuth query, creates a
946
+ * pending flow row, and decides whether the browser should be sent to
947
+ * the host app's login page or consent page next.
948
+ *
949
+ * The dispatcher turns `redirectUrl` into a 302 response. Errors are
950
+ * thrown as {@link FortressError}s so they surface as a 4xx JSON body
951
+ * (the OAuth client is expected to handle that).
952
+ */
953
+ async handleAuthorizeRequest(query, context) {
954
+ const nonce = query.nonce;
955
+ if (!config.loginUrl || !config.consentUrl) {
956
+ throw Errors.badRequest(
957
+ "OAuth plugin is missing loginUrl/consentUrl configuration"
958
+ );
959
+ }
960
+ const clientId = query.client_id;
961
+ const redirectUri = query.redirect_uri;
962
+ const responseType = query.response_type;
963
+ const state = query.state;
964
+ const scope = query.scope;
965
+ const codeChallenge = query.code_challenge;
966
+ const codeChallengeMethod = query.code_challenge_method;
967
+ if (!clientId)
968
+ throw Errors.oauth("invalid_request", "client_id is required");
969
+ if (!redirectUri)
970
+ throw Errors.oauth("invalid_request", "redirect_uri is required");
971
+ if (responseType !== "code")
972
+ throw Errors.oauth("unsupported_response_type", 'response_type must be "code"');
973
+ if (!state)
974
+ throw Errors.oauth("invalid_request", "state is required");
975
+ const client = await ctx.db.findOne({
976
+ model: "oauth_client",
977
+ where: [{ field: "clientId", operator: "=", value: clientId }]
978
+ });
979
+ if (!client)
980
+ throw Errors.oauth("invalid_request", "Unknown client_id");
981
+ const allowedRedirects = JSON.parse(client.redirectUris);
982
+ if (!allowedRedirects.some((r) => matchRedirectUri(r, redirectUri)))
983
+ throw Errors.oauth("invalid_request", "Invalid redirect_uri");
984
+ if (!codeChallenge && !config.allowNonPkceConfidentialClients) {
985
+ throw Errors.oauth("invalid_request", "code_challenge is required (PKCE)");
986
+ }
987
+ if (codeChallenge && codeChallengeMethod && codeChallengeMethod !== "S256") {
988
+ throw Errors.oauth("invalid_request", "Only S256 code_challenge_method is supported");
989
+ }
990
+ if (codeChallenge && !codeChallengeMethod) {
991
+ throw Errors.oauth(
992
+ "invalid_request",
993
+ "code_challenge_method is required when code_challenge is set; only S256 is supported"
994
+ );
995
+ }
996
+ const effectiveScope = intersectScope(scope ?? null, client.allowedScopes);
997
+ if (scope && effectiveScope === "") {
998
+ throw Errors.oauth("invalid_scope", "No requested scope is allowed for this client");
999
+ }
1000
+ const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1e3);
1001
+ const { raw: flowId } = await generateRefreshToken();
1002
+ const flow = await ctx.db.create({
1003
+ model: "oauth_pending_flow",
1004
+ data: {
1005
+ flowId,
1006
+ clientId,
1007
+ redirectUri,
1008
+ scope: effectiveScope || null,
1009
+ state,
1010
+ codeChallenge: codeChallenge ?? null,
1011
+ codeChallengeMethod: codeChallengeMethod ?? null,
1012
+ nonce: nonce ?? null,
1013
+ // H6 fix — bind the flow to the authenticated user up front when
1014
+ // the authorize endpoint already knows them. The login redirect
1015
+ // path leaves userId null and lets the first authenticated
1016
+ // GetFlow / Approve claim it.
1017
+ userId: context.userId ?? null,
1018
+ usedAt: null,
1019
+ expiresAt
1020
+ }
1021
+ });
1022
+ const target = context.userId ? config.consentUrl : config.loginUrl;
1023
+ const url = new URL(target);
1024
+ url.searchParams.set("flow", flow.flowId);
1025
+ return { redirectUrl: url.toString(), flowId: flow.flowId };
1026
+ },
1027
+ /**
1028
+ * Read a pending flow as the consent UI's data source. Strips fields
1029
+ * that should never leave the server (PKCE challenge / method).
1030
+ */
1031
+ async handleGetFlow(flowId, context) {
1032
+ const flow = await this.getPendingFlow(flowId, context);
1033
+ if (flow.userId === null) {
1034
+ await ctx.db.update({
1035
+ model: "oauth_pending_flow",
1036
+ where: [
1037
+ { field: "id", operator: "=", value: flow.id },
1038
+ { field: "userId", operator: "isNull", value: null }
1039
+ ],
1040
+ data: { userId: context.userId }
1041
+ });
1042
+ }
1043
+ const client = await ctx.db.findOne({
1044
+ model: "oauth_client",
1045
+ where: [{ field: "clientId", operator: "=", value: flow.clientId }]
1046
+ });
1047
+ if (!client)
1048
+ throw Errors.notFound("OAuth client not found");
1049
+ return {
1050
+ flowId: flow.flowId,
1051
+ client: { clientId: client.clientId, name: client.name },
1052
+ redirectUri: flow.redirectUri,
1053
+ scopes: flow.scope ? flow.scope.split(" ").filter(Boolean) : [],
1054
+ state: flow.state
1055
+ };
1056
+ },
1057
+ /**
1058
+ * Approve a pending flow on behalf of the authenticated user. Creates
1059
+ * the authorization code, consumes the pending flow, and returns the
1060
+ * client's redirect URI with `?code=...&state=...` appended.
1061
+ */
1062
+ async handleApproveFlow(flowId, context) {
1063
+ const { flow, code } = await ctx.db.transaction(async (tx) => {
1064
+ const flow2 = await tx.findOne({
1065
+ model: "oauth_pending_flow",
1066
+ where: pendingFlowLookupWhere(flowId)
1067
+ });
1068
+ if (!flow2)
1069
+ throw Errors.notFound("Pending flow not found");
1070
+ if (flow2.usedAt)
1071
+ throw Errors.notFound("Pending flow not found");
1072
+ if (flow2.expiresAt < /* @__PURE__ */ new Date())
1073
+ throw Errors.badRequest("Pending flow expired");
1074
+ if (flow2.userId !== null && flow2.userId !== context.userId)
1075
+ throw Errors.notFound("Pending flow not found");
1076
+ const claimed = await tx.update({
1077
+ model: "oauth_pending_flow",
1078
+ where: [
1079
+ { field: "id", operator: "=", value: flow2.id },
1080
+ { field: "usedAt", operator: "isNull", value: null }
1081
+ ],
1082
+ data: {
1083
+ usedAt: /* @__PURE__ */ new Date(),
1084
+ userId: flow2.userId ?? context.userId
1085
+ }
1086
+ });
1087
+ if (!claimed)
1088
+ throw Errors.notFound("Pending flow not found");
1089
+ const client = await tx.findOne({
1090
+ model: "oauth_client",
1091
+ where: [{ field: "clientId", operator: "=", value: flow2.clientId }]
1092
+ });
1093
+ if (!client)
1094
+ throw Errors.oauth("invalid_request", "Invalid client_id");
1095
+ const uris = JSON.parse(client.redirectUris);
1096
+ if (!uris.some((r) => matchRedirectUri(r, flow2.redirectUri)))
1097
+ throw Errors.oauth("invalid_request", "Invalid redirect_uri");
1098
+ const isPublic = client.tokenEndpointAuthMethod === "none";
1099
+ if (isPublic && (!flow2.codeChallenge || flow2.codeChallengeMethod !== "S256"))
1100
+ throw Errors.oauth("invalid_request", "PKCE (S256) is required for public clients");
1101
+ const { raw: code2, hash: codeHash } = await generateRefreshToken();
1102
+ await tx.create({
1103
+ model: "oauth_authorization_code",
1104
+ data: {
1105
+ code: codeHash,
1106
+ clientId: flow2.clientId,
1107
+ userId: context.userId,
1108
+ redirectUri: flow2.redirectUri,
1109
+ scope: flow2.scope ?? null,
1110
+ codeChallenge: flow2.codeChallenge ?? null,
1111
+ codeChallengeMethod: flow2.codeChallengeMethod ?? null,
1112
+ nonce: flow2.nonce ?? null,
1113
+ authTime: context.authTimeSeconds ?? Math.floor(Date.now() / 1e3),
1114
+ expiresAt: new Date(Date.now() + authCodeExpiry * 1e3),
1115
+ usedAt: null
1116
+ }
1117
+ });
1118
+ await tx.delete({
1119
+ model: "oauth_pending_flow",
1120
+ where: [{ field: "id", operator: "=", value: flow2.id }]
1121
+ });
1122
+ return { flow: flow2, code: code2 };
1123
+ });
1124
+ const url = new URL(flow.redirectUri);
1125
+ url.searchParams.set("code", code);
1126
+ url.searchParams.set("state", flow.state);
1127
+ url.searchParams.set("iss", issuerUrl);
1128
+ return { redirectUrl: url.toString() };
1129
+ },
1130
+ /**
1131
+ * Deny a pending flow. Consumes the flow row and returns the OAuth
1132
+ * client's redirect URI with `?error=access_denied&state=...&iss=...`.
1133
+ *
1134
+ * Per RFC 9207 §2, the issuer parameter is included on error responses
1135
+ * too — the RP needs to validate the AS identity before trusting any
1136
+ * field, including `error`.
1137
+ */
1138
+ async handleDenyFlow(flowId, context) {
1139
+ const flow = await this.resumePendingFlow(flowId, context);
1140
+ const url = new URL(flow.redirectUri);
1141
+ url.searchParams.set("error", "access_denied");
1142
+ url.searchParams.set("state", flow.state);
1143
+ url.searchParams.set("iss", issuerUrl);
1144
+ return { redirectUrl: url.toString() };
1145
+ },
1146
+ async getUserInfo(token) {
1147
+ const result = await this._lookupBearer(token);
1148
+ return result?.user ?? null;
1149
+ },
1150
+ /**
1151
+ * Internal: resolve a bearer token to its access-token row + user.
1152
+ * Shared by `getUserInfo` (returns the raw user) and
1153
+ * `handleUserInfoRequest` (maps to OIDC claims).
1154
+ */
1155
+ async _lookupBearer(token) {
1156
+ const tokenHash = await hashToken(token);
1157
+ const record = await ctx.db.findOne({
1158
+ model: "oauth_access_token",
1159
+ where: [{ field: "token", operator: "=", value: tokenHash }]
1160
+ });
1161
+ if (!record || !record.userId || record.expiresAt < /* @__PURE__ */ new Date())
1162
+ return null;
1163
+ const user = await ctx.db.findOne({
1164
+ model: "user",
1165
+ where: [{ field: "id", operator: "=", value: record.userId }]
1166
+ });
1167
+ if (!user)
1168
+ return null;
1169
+ return { user, scope: record.scope };
1170
+ },
1171
+ // --- HTTP handler methods (RFC 6749 / 7662 / 7009) ---
1172
+ /**
1173
+ * Handle POST /oauth/token — dispatches to exchangeCode or clientCredentialsGrant.
1174
+ */
1175
+ async handleTokenRequest(body, clientAuth) {
1176
+ const clientId = clientAuth?.clientId ?? body.client_id;
1177
+ const clientSecret = clientAuth?.clientSecret ?? body.client_secret;
1178
+ if (!clientId)
1179
+ throw Errors.oauth("invalid_client", "Client authentication required");
1180
+ const clientRecord = await ctx.db.findOne({
1181
+ model: "oauth_client",
1182
+ where: [{ field: "clientId", operator: "=", value: clientId }]
1183
+ });
1184
+ if (!clientRecord)
1185
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
1186
+ const isPublic = clientRecord.tokenEndpointAuthMethod === "none";
1187
+ if (!isPublic && !clientSecret)
1188
+ throw Errors.oauth("invalid_client", "Client authentication required");
1189
+ if (body.grant_type === "authorization_code") {
1190
+ if (!body.code || !body.redirect_uri)
1191
+ throw Errors.oauth("invalid_request", "Missing required parameters: code, redirect_uri");
1192
+ if (isPublic && !body.code_verifier)
1193
+ throw Errors.oauth("invalid_grant", "code_verifier required for public clients");
1194
+ const result = await this.exchangeCode({
1195
+ code: body.code,
1196
+ clientId,
1197
+ clientSecret: isPublic ? void 0 : clientSecret,
1198
+ redirectUri: body.redirect_uri,
1199
+ codeVerifier: body.code_verifier
1200
+ });
1201
+ return {
1202
+ access_token: result.accessToken,
1203
+ token_type: result.tokenType,
1204
+ expires_in: result.expiresIn,
1205
+ ...result.refreshToken ? { refresh_token: result.refreshToken } : {},
1206
+ ...result.idToken ? { id_token: result.idToken } : {},
1207
+ ...result.scope ? { scope: result.scope } : {}
1208
+ };
1209
+ }
1210
+ if (body.grant_type === "refresh_token") {
1211
+ if (!body.refresh_token)
1212
+ throw Errors.oauth("invalid_request", "refresh_token is required");
1213
+ const result = await this.refreshTokenGrant({
1214
+ clientId,
1215
+ clientSecret: isPublic ? void 0 : clientSecret,
1216
+ refreshToken: body.refresh_token,
1217
+ scope: body.scope
1218
+ });
1219
+ return {
1220
+ access_token: result.accessToken,
1221
+ token_type: result.tokenType,
1222
+ expires_in: result.expiresIn,
1223
+ refresh_token: result.refreshToken,
1224
+ ...result.idToken ? { id_token: result.idToken } : {},
1225
+ ...result.scope ? { scope: result.scope } : {}
1226
+ };
1227
+ }
1228
+ if (body.grant_type === "client_credentials") {
1229
+ if (!clientSecret)
1230
+ throw Errors.oauth("invalid_client", "client_credentials requires a client secret");
1231
+ const result = await this.clientCredentialsGrant({
1232
+ clientId,
1233
+ clientSecret,
1234
+ scope: body.scope
1235
+ });
1236
+ return {
1237
+ access_token: result.accessToken,
1238
+ token_type: result.tokenType,
1239
+ expires_in: result.expiresIn
1240
+ };
1241
+ }
1242
+ throw Errors.oauth("unsupported_grant_type", `Unsupported grant_type: ${body.grant_type}`);
1243
+ },
1244
+ /**
1245
+ * Handle POST /oauth/introspect (RFC 7662).
1246
+ */
1247
+ async handleIntrospectRequest(body, clientAuth) {
1248
+ const client = await ctx.db.findOne({
1249
+ model: "oauth_client",
1250
+ where: [{ field: "clientId", operator: "=", value: clientAuth.clientId }]
1251
+ });
1252
+ if (!client)
1253
+ throw Errors.unauthorized("Invalid client credentials");
1254
+ const secretValid = timingSafeEqualHex(
1255
+ await hashToken(clientAuth.clientSecret),
1256
+ client.clientSecretHash
1257
+ );
1258
+ if (!secretValid)
1259
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
1260
+ const result = await this.introspectToken(body.token);
1261
+ if (!result.active || result.clientId !== clientAuth.clientId)
1262
+ return { active: false };
1263
+ return {
1264
+ active: true,
1265
+ client_id: result.clientId,
1266
+ ...result.userId != null ? { sub: String(result.userId) } : {},
1267
+ ...result.scope ? { scope: result.scope } : {},
1268
+ token_type: "Bearer"
1269
+ };
1270
+ },
1271
+ /**
1272
+ * Handle POST /oauth/revoke (RFC 7009). Always returns success.
1273
+ */
1274
+ async handleRevokeRequest(body, clientAuth) {
1275
+ const client = await ctx.db.findOne({
1276
+ model: "oauth_client",
1277
+ where: [{ field: "clientId", operator: "=", value: clientAuth.clientId }]
1278
+ });
1279
+ if (!client || !timingSafeEqualHex(await hashToken(clientAuth.clientSecret), client.clientSecretHash))
1280
+ throw Errors.oauth("invalid_client", "Invalid client credentials");
1281
+ const tokenHash = await hashToken(body.token);
1282
+ await ctx.db.delete({
1283
+ model: "oauth_access_token",
1284
+ where: [
1285
+ { field: "token", operator: "=", value: tokenHash },
1286
+ { field: "clientId", operator: "=", value: clientAuth.clientId }
1287
+ ]
1288
+ });
1289
+ if (refreshEnabled) {
1290
+ const refreshRecord = await ctx.db.findOne({
1291
+ model: "oauth_refresh_token",
1292
+ where: [
1293
+ { field: "token", operator: "=", value: tokenHash },
1294
+ { field: "clientId", operator: "=", value: clientAuth.clientId }
1295
+ ]
1296
+ });
1297
+ if (refreshRecord) {
1298
+ await ctx.db.delete({
1299
+ model: "oauth_refresh_token",
1300
+ where: [
1301
+ { field: "familyId", operator: "=", value: refreshRecord.familyId },
1302
+ { field: "clientId", operator: "=", value: clientAuth.clientId }
1303
+ ]
1304
+ });
1305
+ }
1306
+ }
1307
+ },
1308
+ /**
1309
+ * Handle GET /oauth/userinfo (OpenID Connect Core §5.3).
1310
+ *
1311
+ * Returns the OIDC-shaped claims object expected by strict RPs (Moodle
1312
+ * `core\oauth2\client::get_userinfo()`, openid-client, Keycloak
1313
+ * federation, Spring Security). The pre-1.0 fortress shape that
1314
+ * leaked DB-internal fields (`id`, `isActive`, `createdAt`) is gone
1315
+ * — callers must rely on the spec-conformant fields below.
1316
+ *
1317
+ * Standard claim coverage (OIDC Core §5.1) gated by access-token
1318
+ * scope (§5.4):
1319
+ * - `sub` always (stringified `user.id`)
1320
+ * - `email`, `email_verified` only when scope contains `email`
1321
+ * - `name`, `preferred_username` only when scope contains `profile`
1322
+ * - `updated_at` always (Unix seconds, per §5.1)
1323
+ *
1324
+ * Extra claims can be added per-deployment via
1325
+ * {@link OAuthConfig.userinfoClaims}.
1326
+ */
1327
+ async handleUserInfoRequest(bearerToken) {
1328
+ const result = await this._lookupBearer(bearerToken);
1329
+ if (!result)
1330
+ throw Errors.unauthorized("Invalid or expired access token");
1331
+ const claims = toOidcUserinfo(result.user, result.scope);
1332
+ if (config.userinfoClaims) {
1333
+ const extra = await config.userinfoClaims(result.user, result.scope);
1334
+ Object.assign(claims, extra);
1335
+ }
1336
+ return claims;
1337
+ },
1338
+ /**
1339
+ * Handle GET /.well-known/openid-configuration (RFC 8414 + OIDC
1340
+ * Discovery 1.0). Includes the RFC 9207 metadata flag and the OIDC
1341
+ * fields strict RPs (Moodle, openid-client, Spring Security) require
1342
+ * for autoconfig.
1343
+ */
1344
+ handleDiscovery() {
1345
+ const issuer = issuerUrl;
1346
+ const mappedScopes = scopePermissionMap ? Object.keys(scopePermissionMap) : [];
1347
+ const scopesSupported = Array.from(/* @__PURE__ */ new Set([
1348
+ "openid",
1349
+ "email",
1350
+ "profile",
1351
+ ...config.scopesSupported ?? [],
1352
+ ...mappedScopes
1353
+ ]));
1354
+ return {
1355
+ issuer,
1356
+ authorization_endpoint: `${issuer}/oauth/authorize`,
1357
+ token_endpoint: `${issuer}/oauth/token`,
1358
+ introspection_endpoint: `${issuer}/oauth/introspect`,
1359
+ revocation_endpoint: `${issuer}/oauth/revoke`,
1360
+ userinfo_endpoint: `${issuer}/oauth/userinfo`,
1361
+ jwks_uri: `${issuer}/oauth/.well-known/jwks.json`,
1362
+ response_types_supported: ["code"],
1363
+ response_modes_supported: ["query"],
1364
+ grant_types_supported: refreshEnabled ? ["authorization_code", "client_credentials", "refresh_token"] : ["authorization_code", "client_credentials"],
1365
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
1366
+ code_challenge_methods_supported: ["S256"],
1367
+ subject_types_supported: ["public"],
1368
+ // OIDC Discovery §3 REQUIRED when id_tokens are issued.
1369
+ id_token_signing_alg_values_supported: ["RS256"],
1370
+ scopes_supported: scopesSupported,
1371
+ claims_supported: [
1372
+ "sub",
1373
+ "email",
1374
+ "email_verified",
1375
+ "name",
1376
+ "preferred_username",
1377
+ "updated_at",
1378
+ "iss",
1379
+ "aud",
1380
+ "exp",
1381
+ "iat",
1382
+ "auth_time",
1383
+ "nonce"
1384
+ ],
1385
+ // RFC 9207 §3.
1386
+ authorization_response_iss_parameter_supported: true
1387
+ };
1388
+ },
1389
+ /**
1390
+ * Handle GET /oauth/.well-known/jwks.json (RFC 7517).
1391
+ *
1392
+ * Returns the AS's JSON Web Key Set: the active RS256 public key
1393
+ * plus any rotated keys still in the verification grace window. RPs
1394
+ * fetch this URL (advertised via discovery's `jwks_uri`) and use it
1395
+ * to verify id_token signatures by `kid`.
1396
+ */
1397
+ async rotateSigningKey() {
1398
+ const key = await rotateSigningKey(ctx.db, signingKeyGraceSeconds);
1399
+ return { kid: key.kid };
1400
+ },
1401
+ async handleJwksRequest() {
1402
+ const jwks = await listJwks(ctx.db, signingKeyGraceSeconds);
1403
+ if (jwks.keys.length === 0) {
1404
+ await getActiveSigningKey(ctx.db);
1405
+ return await listJwks(ctx.db, signingKeyGraceSeconds);
1406
+ }
1407
+ return jwks;
1408
+ },
1409
+ /**
1410
+ * Resolve an access token's scopes to IAM permissions using the configured scopePermissionMap.
1411
+ */
1412
+ async resolveTokenPermissions(token) {
1413
+ const info = await this.introspectToken(token);
1414
+ if (!info.active || !info.scope || !scopePermissionMap)
1415
+ return [];
1416
+ const scopes = info.scope.split(" ");
1417
+ const permissions = [];
1418
+ for (const scope of scopes) {
1419
+ const mapping = scopePermissionMap[scope];
1420
+ if (mapping) {
1421
+ permissions.push(mapping);
1422
+ }
1423
+ }
1424
+ return permissions;
1425
+ }
1426
+ }),
1427
+ routes: {
1428
+ // Front door for the auth-code flow. Opt-in: only mounted when the
1429
+ // host app has wired up `loginUrl` and `consentUrl`.
1430
+ ...config.enableAuthorizeEndpoint ? {
1431
+ handleAuthorizeRequest: {
1432
+ method: "GET",
1433
+ path: "/oauth/authorize",
1434
+ handler: "handleAuthorizeRequest",
1435
+ meta: {
1436
+ summary: "Start an OAuth authorization-code flow",
1437
+ tags: ["OAuth"],
1438
+ security: ["none"],
1439
+ bearerKind: "oauth"
1440
+ },
1441
+ input: {
1442
+ query: {
1443
+ type: "object",
1444
+ properties: {
1445
+ client_id: { type: "string", description: "Registered OAuth client ID" },
1446
+ redirect_uri: { type: "string", format: "uri", description: "Must match a redirect URI registered with the client" },
1447
+ response_type: { type: "string", enum: ["code"], description: 'Only "code" is supported' },
1448
+ scope: { type: "string", description: "Space-separated requested scopes" },
1449
+ state: { type: "string", description: "Opaque value returned to the client; required to prevent CSRF" },
1450
+ code_challenge: { type: "string", description: "PKCE code challenge (recommended for public clients)" },
1451
+ code_challenge_method: { type: "string", enum: ["S256"], description: "PKCE method \u2014 only S256 is supported" }
1452
+ },
1453
+ required: ["client_id", "redirect_uri", "response_type", "state"]
1454
+ }
1455
+ },
1456
+ responses: {
1457
+ 302: { description: "Redirect to login (unauthenticated) or consent (authenticated) page with ?flow=<id>" },
1458
+ 400: { description: "Invalid request" }
1459
+ }
1460
+ }
1461
+ } : {},
1462
+ // Consent API — SPA-friendly flow inspection + approve/deny.
1463
+ ...config.enableConsentApi ? {
1464
+ handleGetFlow: {
1465
+ method: "GET",
1466
+ path: "/oauth/flows/:flowId",
1467
+ handler: "handleGetFlow",
1468
+ meta: {
1469
+ summary: "Fetch pending OAuth flow metadata",
1470
+ tags: ["OAuth"],
1471
+ security: ["bearer"]
1472
+ },
1473
+ responses: {
1474
+ 200: {
1475
+ description: "Flow metadata",
1476
+ schema: {
1477
+ type: "object",
1478
+ properties: {
1479
+ flowId: { type: "string" },
1480
+ client: {
1481
+ type: "object",
1482
+ properties: {
1483
+ clientId: { type: "string" },
1484
+ name: { type: "string" }
1485
+ }
1486
+ },
1487
+ redirectUri: { type: "string" },
1488
+ scopes: { type: "array", items: { type: "string" } },
1489
+ state: { type: "string" }
1490
+ }
1491
+ }
1492
+ },
1493
+ 401: { description: "Authentication required" },
1494
+ 404: { description: "Flow not found" }
1495
+ }
1496
+ },
1497
+ handleApproveFlow: {
1498
+ method: "POST",
1499
+ path: "/oauth/flows/:flowId/approve",
1500
+ handler: "handleApproveFlow",
1501
+ meta: {
1502
+ summary: "Approve a pending OAuth flow",
1503
+ tags: ["OAuth"],
1504
+ security: ["bearer"]
1505
+ },
1506
+ responses: {
1507
+ 200: {
1508
+ description: "Authorization code issued",
1509
+ schema: {
1510
+ type: "object",
1511
+ properties: { redirectUrl: { type: "string" } }
1512
+ }
1513
+ },
1514
+ 401: { description: "Authentication required" },
1515
+ 404: { description: "Flow not found" }
1516
+ }
1517
+ },
1518
+ handleDenyFlow: {
1519
+ method: "POST",
1520
+ path: "/oauth/flows/:flowId/deny",
1521
+ handler: "handleDenyFlow",
1522
+ meta: {
1523
+ summary: "Deny a pending OAuth flow",
1524
+ tags: ["OAuth"],
1525
+ security: ["bearer"]
1526
+ },
1527
+ responses: {
1528
+ 200: {
1529
+ description: "Flow denied",
1530
+ schema: {
1531
+ type: "object",
1532
+ properties: { redirectUrl: { type: "string" } }
1533
+ }
1534
+ },
1535
+ 401: { description: "Authentication required" },
1536
+ 404: { description: "Flow not found" }
1537
+ }
1538
+ }
1539
+ } : {},
1540
+ handleTokenRequest: {
1541
+ method: "POST",
1542
+ path: "/oauth/token",
1543
+ handler: "handleTokenRequest",
1544
+ meta: { summary: "Exchange credentials for tokens", tags: ["OAuth"], security: ["basic"], bearerKind: "oauth" },
1545
+ input: {
1546
+ body: {
1547
+ type: "object",
1548
+ properties: {
1549
+ grant_type: { type: "string", enum: ["authorization_code", "client_credentials", "refresh_token"], description: "OAuth grant type" },
1550
+ code: { type: "string", description: "Authorization code" },
1551
+ redirect_uri: { type: "string", format: "uri", description: "Redirect URI used in authorize" },
1552
+ client_id: { type: "string", description: "Client ID (if not using Basic auth)" },
1553
+ client_secret: { type: "string", description: "Client secret (if not using Basic auth)" },
1554
+ code_verifier: { type: "string", description: "PKCE code verifier" },
1555
+ refresh_token: { type: "string", description: "Refresh token for grant_type=refresh_token" },
1556
+ scope: { type: "string", description: "Space-separated scopes" }
1557
+ },
1558
+ required: ["grant_type"]
1559
+ }
1560
+ },
1561
+ responses: {
1562
+ 200: { description: "Token issued", schema: { type: "object", properties: { access_token: { type: "string" }, token_type: { type: "string" }, expires_in: { type: "number" }, refresh_token: { type: "string" }, id_token: { type: "string" }, scope: { type: "string" } } } },
1563
+ 400: { description: "Invalid request" },
1564
+ 401: { description: "Invalid client credentials" }
1565
+ }
1566
+ },
1567
+ handleIntrospectRequest: {
1568
+ method: "POST",
1569
+ path: "/oauth/introspect",
1570
+ handler: "handleIntrospectRequest",
1571
+ meta: { summary: "Introspect a token (RFC 7662)", tags: ["OAuth"], security: ["basic"], bearerKind: "oauth" },
1572
+ input: { body: { type: "object", properties: { token: { type: "string" } }, required: ["token"] } },
1573
+ responses: {
1574
+ 200: { description: "Token info", schema: { type: "object", properties: { active: { type: "boolean" }, sub: { type: "string" }, scope: { type: "string" }, exp: { type: "number" } } } },
1575
+ 401: { description: "Client authentication required" }
1576
+ }
1577
+ },
1578
+ handleRevokeRequest: {
1579
+ method: "POST",
1580
+ path: "/oauth/revoke",
1581
+ handler: "handleRevokeRequest",
1582
+ meta: { summary: "Revoke a token (RFC 7009)", tags: ["OAuth"], security: ["basic"], bearerKind: "oauth" },
1583
+ input: { body: { type: "object", properties: { token: { type: "string" } }, required: ["token"] } },
1584
+ responses: { 200: { description: "Token revoked" } }
1585
+ },
1586
+ handleUserInfoRequest: {
1587
+ method: "GET",
1588
+ path: "/oauth/userinfo",
1589
+ handler: "handleUserInfoRequest",
1590
+ meta: { summary: "Get user info (OIDC Core \xA75.3)", tags: ["OAuth"], security: ["bearer"], bearerKind: "oauth" },
1591
+ responses: {
1592
+ 200: {
1593
+ description: "OIDC userinfo response",
1594
+ schema: {
1595
+ type: "object",
1596
+ properties: {
1597
+ sub: { type: "string", description: "Stringified user identifier" },
1598
+ email: { type: "string", format: "email" },
1599
+ email_verified: { type: "boolean" },
1600
+ name: { type: "string" },
1601
+ preferred_username: { type: "string" },
1602
+ updated_at: { type: "number", description: "Unix seconds since the user record was last updated" }
1603
+ },
1604
+ required: ["sub"],
1605
+ additionalProperties: true
1606
+ }
1607
+ },
1608
+ 401: { description: "Invalid or expired bearer token" }
1609
+ }
1610
+ },
1611
+ handleDiscovery: {
1612
+ method: "GET",
1613
+ path: "/oauth/.well-known/openid-configuration",
1614
+ handler: "handleDiscovery",
1615
+ meta: { summary: "OIDC discovery document", tags: ["OAuth"], security: ["none"], bearerKind: "oauth" },
1616
+ responses: { 200: { description: "OIDC configuration", schema: { type: "object", additionalProperties: true } } }
1617
+ },
1618
+ handleJwksRequest: {
1619
+ method: "GET",
1620
+ path: "/oauth/.well-known/jwks.json",
1621
+ handler: "handleJwksRequest",
1622
+ meta: { summary: "JSON Web Key Set (RFC 7517)", tags: ["OAuth"], security: ["none"], bearerKind: "oauth" },
1623
+ responses: {
1624
+ 200: {
1625
+ description: "JWKS for verifying id_token signatures",
1626
+ schema: {
1627
+ type: "object",
1628
+ properties: {
1629
+ keys: {
1630
+ type: "array",
1631
+ items: { type: "object", additionalProperties: true }
1632
+ }
1633
+ },
1634
+ required: ["keys"]
1635
+ }
1636
+ }
1637
+ }
1638
+ }
1639
+ }
1640
+ };
1641
+ }
1642
+ function matchRedirectUri(registered, inbound) {
1643
+ if (registered === inbound)
1644
+ return true;
1645
+ let r, i;
1646
+ try {
1647
+ r = new URL(registered);
1648
+ i = new URL(inbound);
1649
+ } catch {
1650
+ return false;
1651
+ }
1652
+ const isLoopback = r.protocol === "http:" && (r.hostname === "127.0.0.1" || r.hostname === "[::1]") && r.hostname === i.hostname;
1653
+ if (!isLoopback)
1654
+ return false;
1655
+ return r.protocol === i.protocol && r.pathname === i.pathname && r.search === i.search;
1656
+ }
1657
+ function pendingFlowLookupWhere(flowId) {
1658
+ return typeof flowId === "number" ? [{ field: "id", operator: "=", value: flowId }] : [{ field: "flowId", operator: "=", value: flowId }];
1659
+ }
1660
+ function intersectScope(requested, clientAllowedJson) {
1661
+ if (!requested)
1662
+ return "";
1663
+ if (clientAllowedJson == null)
1664
+ return requested;
1665
+ const allowed = JSON.parse(clientAllowedJson);
1666
+ const allowedSet = new Set(allowed);
1667
+ return requested.split(" ").filter(Boolean).filter((s) => allowedSet.has(s)).join(" ");
1668
+ }
1669
+ function toOidcUserinfo(user, scope) {
1670
+ const scopes = scope ? scope.split(" ").filter(Boolean) : [];
1671
+ const exposeEmail = scopes.includes("email");
1672
+ const exposeProfile = scopes.includes("profile");
1673
+ const claims = { sub: String(user.id) };
1674
+ if (exposeEmail) {
1675
+ claims.email = user.email;
1676
+ if (typeof user.emailVerified === "boolean")
1677
+ claims.email_verified = user.emailVerified;
1678
+ }
1679
+ if (exposeProfile) {
1680
+ claims.name = user.name;
1681
+ claims.preferred_username = user.email;
1682
+ }
1683
+ if (user.updatedAt) {
1684
+ claims.updated_at = Math.floor(new Date(user.updatedAt).getTime() / 1e3);
1685
+ }
1686
+ return claims;
1687
+ }
1688
+ // Annotate the CommonJS export names for ESM import in node:
1689
+ 0 && (module.exports = {
1690
+ generateCodeChallenge,
1691
+ generateCodeVerifier,
1692
+ matchRedirectUri,
1693
+ oauth,
1694
+ toOidcUserinfo,
1695
+ verifyCodeChallenge
1696
+ });