@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,2145 @@
1
+ /**
2
+ * OAuth 2.0 authorization server plugin for fortress.
3
+ *
4
+ * Implements the authorization-code grant (with PKCE) and client-credentials
5
+ * grant for confidential and public clients. Persists clients, authorization
6
+ * codes, access tokens, and pending flows via the fortress database adapter,
7
+ * and exposes the standard `/authorize` and `/token` endpoints when mounted.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { WhereClause } from '../../adapters/database/types';
13
+ import type { FortressPlugin } from '../../core/plugin';
14
+ import type { FortressUser } from '../../core/types';
15
+ import { generateRefreshToken, generateTokenFamily, hashToken } from '../../core/auth/refresh-token';
16
+ import { timingSafeEqualHex } from '../../core/auth/timing-safe';
17
+ import { Errors } from '../../core/errors';
18
+ import { issueIdToken } from './id-token';
19
+ import { getActiveSigningKey, listJwks, rotateSigningKey } from './jwks';
20
+ import { verifyCodeChallenge } from './pkce';
21
+
22
+ export interface OAuthConfig {
23
+ /** Authorization code expiry in seconds (default: 600 = 10 min) */
24
+ authCodeExpirySeconds?: number;
25
+ /** Pending flow expiry in seconds (default: 600 = 10 min) */
26
+ pendingFlowExpirySeconds?: number;
27
+ /** Access token expiry in seconds (default: 3600 = 1 hour) */
28
+ accessTokenExpirySeconds?: number;
29
+ /**
30
+ * Refresh token expiry in seconds (default: 30 days). Refresh tokens are
31
+ * sliding — each rotation issues a fresh token with a new expiry. Set to
32
+ * 0 to disable refresh-token issuance entirely.
33
+ *
34
+ * @see RFC 6749 §6 · RFC 9700 §2.2.2
35
+ */
36
+ refreshTokenExpirySeconds?: number;
37
+ /**
38
+ * id_token expiry in seconds (default: 3600 = 1 hour). Only relevant
39
+ * when the request includes `scope=openid`.
40
+ *
41
+ * @see OIDC Core 1.0 §2
42
+ */
43
+ idTokenExpirySeconds?: number;
44
+ /** Seconds rotated signing keys remain published in JWKS. Default: idTokenExpirySeconds. */
45
+ signingKeyGraceSeconds?: number;
46
+ /** Map OAuth scopes to IAM permissions. Example: `{ 'read:posts': { resource: 'post', action: 'read' } }` */
47
+ scopePermissionMap?: Record<string, { resource: string; action: string }>;
48
+ /** Base URL for the OAuth server (used in OIDC discovery document) */
49
+ issuerUrl?: string;
50
+ /**
51
+ * RFC 9700 §2.1.1 escape hatch: when `true`, `/oauth/authorize` will accept
52
+ * requests without a `code_challenge` from confidential clients. Defaults
53
+ * to `false`. Strongly discouraged — PKCE is mandatory in current OAuth
54
+ * BCP. Provided only so legacy server-side RPs can be migrated
55
+ * incrementally.
56
+ */
57
+ allowNonPkceConfidentialClients?: boolean;
58
+ /**
59
+ * Static list of OIDC/OAuth scope names the AS knows about, surfaced in
60
+ * discovery's `scopes_supported`. Merged with the keys of
61
+ * {@link OAuthConfig.scopePermissionMap} and the default OIDC scopes
62
+ * (`openid`, `email`, `profile`). Optional — informational metadata only;
63
+ * does not enforce per-client allow-listing on its own.
64
+ */
65
+ scopesSupported?: string[];
66
+ /**
67
+ * Per-deployment extension hook for `/oauth/userinfo`. Receives the
68
+ * {@link FortressUser} resolved from the access token plus the token's
69
+ * scope (or `null` if no scope was issued), and returns a claims record
70
+ * that is merged into the OIDC-shaped response on top of the standard
71
+ * claims fortress emits.
72
+ *
73
+ * Use this to attach app-specific claims (tenant, roles, custom
74
+ * profile fields). Returning `{}` is the no-op; returning a key already
75
+ * emitted by fortress overwrites it (you'd typically only do that for
76
+ * `preferred_username` or `name` when the host app stores its own).
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * userinfoClaims: (user, scope) => ({
81
+ * tenant_id: user.tenantId,
82
+ * ...(scope?.includes('profile') ? { picture: user.avatarUrl } : {}),
83
+ * })
84
+ * ```
85
+ */
86
+ userinfoClaims?: (
87
+ user: FortressUser,
88
+ scope: string | null,
89
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
90
+ /**
91
+ * Mount the front-door `GET /oauth/authorize` endpoint. Off by default —
92
+ * opt in once your host app has wired up `loginUrl` and `consentUrl`.
93
+ * Plugin methods (`createPendingFlow`, `handleAuthorizeRequest`) stay
94
+ * available regardless.
95
+ */
96
+ enableAuthorizeEndpoint?: boolean;
97
+ /**
98
+ * Mount the consent-API endpoints (`GET /oauth/flows/:flowId`,
99
+ * `POST /oauth/flows/:flowId/approve`, `POST /oauth/flows/:flowId/deny`).
100
+ * Off by default — opt in for the SPA-friendly consent flow (Pattern B).
101
+ * The corresponding methods stay available regardless.
102
+ */
103
+ enableConsentApi?: boolean;
104
+ /**
105
+ * Where the host app's sign-in page lives, e.g.
106
+ * `'https://app.example.com/signin'`. The authorize endpoint redirects
107
+ * unauthenticated users to `${loginUrl}?flow=<id>`.
108
+ */
109
+ loginUrl?: string;
110
+ /**
111
+ * Where the host app's consent page lives, e.g.
112
+ * `'https://app.example.com/oauth/consent'`. The authorize endpoint
113
+ * redirects authenticated users to `${consentUrl}?flow=<id>`.
114
+ */
115
+ consentUrl?: string;
116
+ }
117
+
118
+ interface OAuthClientRecord {
119
+ id: string;
120
+ clientId: string;
121
+ clientSecretHash: string;
122
+ name: string;
123
+ redirectUris: string; // JSON
124
+ grantTypes: string; // JSON
125
+ /**
126
+ * RFC 6749 §3.3 + RFC 9700 §2.2.1: per-client scope allow-list (JSON
127
+ * array). When set, requested scopes are intersected against this list at
128
+ * authorize / token time — unauthorised scopes are dropped silently per
129
+ * §3.3, and an empty intersection returns `error=invalid_scope`. When
130
+ * `null`, fortress passes through whatever scope the client requested
131
+ * (legacy v0 behaviour, deprecated — set `allowedScopes: []` to deny all
132
+ * or pass an explicit list).
133
+ */
134
+ allowedScopes: string | null;
135
+ /**
136
+ * RFC 6749 §2.1 client type. Persisted as the OIDC discovery alias:
137
+ * `'client_secret_basic'` (default), `'client_secret_post'`, or `'none'`
138
+ * for public clients (SPAs, native apps — RFC 8252). Public clients use
139
+ * PKCE in lieu of a secret; the token endpoint accepts them on `client_id`
140
+ * + verifier alone.
141
+ */
142
+ tokenEndpointAuthMethod: string | null;
143
+ createdAt: Date;
144
+ }
145
+
146
+ /** RFC 6749 §2.1 / OIDC Discovery `token_endpoint_auth_methods_supported` aliases. */
147
+ export type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
148
+
149
+ interface AuthCodeRecord {
150
+ id: string;
151
+ code: string;
152
+ clientId: string;
153
+ userId: string;
154
+ redirectUri: string;
155
+ scope: string | null;
156
+ codeChallenge: string | null;
157
+ codeChallengeMethod: string | null;
158
+ /** OIDC Core §3.1.2.1 — echoed into the id_token if the authorize request supplied one. */
159
+ nonce: string | null;
160
+ /** Unix seconds (OIDC `auth_time` claim) recorded when the user approved the flow. */
161
+ authTime: number | null;
162
+ expiresAt: Date;
163
+ usedAt: Date | null;
164
+ }
165
+
166
+ interface AccessTokenRecord {
167
+ id: string;
168
+ token: string;
169
+ clientId: string;
170
+ userId: string | null;
171
+ scope: string | null;
172
+ expiresAt: Date;
173
+ }
174
+
175
+ /**
176
+ * Persisted refresh token row. Stores only the SHA-256 hash; the raw token
177
+ * is returned to the client exactly once and never retained.
178
+ *
179
+ * Rotation tracking: every refresh-token request mints a fresh token with a
180
+ * new id, marks the old one's `usedAt`, and links via `parentId`. The
181
+ * `familyId` is shared across the entire chain — detecting reuse of an
182
+ * already-rotated token revokes every member of the family (RFC 9700
183
+ * §2.2.2).
184
+ */
185
+ interface RefreshTokenRecord {
186
+ id: string;
187
+ token: string;
188
+ familyId: string;
189
+ clientId: string;
190
+ userId: string;
191
+ scope: string | null;
192
+ issuedAt: Date;
193
+ expiresAt: Date;
194
+ usedAt: Date | null;
195
+ parentId: string | null;
196
+ }
197
+
198
+ /** Persisted state for an in-flight OAuth authorization-code flow. */
199
+ export interface PendingFlowRecord {
200
+ /** Internal DB primary key — never exposed to clients. */
201
+ id: string;
202
+ /** Public opaque handle used in URLs; replaces enumerable serial ids (H6). */
203
+ flowId: string;
204
+ clientId: string;
205
+ redirectUri: string;
206
+ scope: string | null;
207
+ state: string;
208
+ codeChallenge: string | null;
209
+ codeChallengeMethod: string | null;
210
+ /** OIDC Core §3.1.2.1 nonce — mirrored from the authorize query if present. */
211
+ nonce: string | null;
212
+ /**
213
+ * Subject the flow is bound to. Null until the first authenticated
214
+ * read/approve, then trust-on-first-use claims it for that user. Every
215
+ * subsequent read/approve/deny MUST match.
216
+ *
217
+ * Closes H6 (IDOR on `/oauth/flows/:flowId`): without this column, any
218
+ * authenticated user could fetch another user's RP state token or
219
+ * approve their flow.
220
+ */
221
+ userId: string | null;
222
+ /** Set during atomic approve/deny claim; prevents concurrent double-submit. */
223
+ usedAt: Date | null;
224
+ expiresAt: Date;
225
+ }
226
+
227
+ /** Client authentication extracted from HTTP request (Basic auth or body params) */
228
+ /** Resolved client credentials parsed from a token-endpoint request. */
229
+ export interface ClientAuth {
230
+ clientId: string;
231
+ clientSecret: string;
232
+ }
233
+
234
+ /** Token endpoint request body (application/x-www-form-urlencoded) */
235
+ /** Body shape accepted by the OAuth `/token` endpoint. */
236
+ export interface TokenRequestBody {
237
+ grant_type: string;
238
+ code?: string;
239
+ redirect_uri?: string;
240
+ client_id?: string;
241
+ client_secret?: string;
242
+ code_verifier?: string;
243
+ scope?: string;
244
+ /** RFC 6749 §6 refresh-token grant payload. */
245
+ refresh_token?: string;
246
+ }
247
+
248
+ /** Authorization endpoint query params */
249
+ /** Query parameters accepted by the OAuth `/authorize` endpoint. */
250
+ export interface AuthorizeRequestParams {
251
+ client_id: string;
252
+ redirect_uri: string;
253
+ response_type: string;
254
+ scope?: string;
255
+ state?: string;
256
+ code_challenge?: string;
257
+ code_challenge_method?: string;
258
+ }
259
+
260
+ export interface OAuthMethods {
261
+ // Programmatic API
262
+ createClient: (data: { name: string; redirectUris: string[]; grantTypes: string[]; allowedScopes?: string[]; tokenEndpointAuthMethod?: TokenEndpointAuthMethod }) => Promise<{ clientId: string; clientSecret: string | null }>;
263
+ createAuthorizationCode: (params: { clientId: string; userId: string; redirectUri: string; scope?: string; codeChallenge?: string; codeChallengeMethod?: string; nonce?: string; authTime?: number }) => Promise<{ code: string }>;
264
+ exchangeCode: (params: { code: string; clientId: string; clientSecret?: string; redirectUri: string; codeVerifier?: string }) => Promise<{ accessToken: string; tokenType: string; expiresIn: number; scope?: string; refreshToken?: string; idToken?: string }>;
265
+ refreshTokenGrant: (params: { clientId: string; clientSecret?: string; refreshToken: string; scope?: string }) => Promise<{ accessToken: string; tokenType: string; expiresIn: number; refreshToken: string; scope?: string; idToken?: string }>;
266
+ clientCredentialsGrant: (params: { clientId: string; clientSecret: string; scope?: string }) => Promise<{ accessToken: string; tokenType: string; expiresIn: number }>;
267
+ revokeToken: (token: string) => Promise<void>;
268
+ introspectToken: (token: string) => Promise<{ active: boolean; clientId?: string; userId?: string; scope?: string }>;
269
+ createPendingFlow: (params: { clientId: string; redirectUri: string; scope?: string; state: string; codeChallenge?: string; codeChallengeMethod?: string; userId?: string }) => Promise<{ flowId: string }>;
270
+ /**
271
+ * Read a pending flow without consuming it. Throws if not found, expired,
272
+ * or (when `userId` is supplied) owned by a different user — in the
273
+ * "different user" case the error is `not_found` rather than `forbidden`
274
+ * to avoid confirming the flow's existence.
275
+ */
276
+ getPendingFlow: (flowId: string | number, context?: { userId?: string }) => Promise<PendingFlowRecord>;
277
+ /** Read and delete a pending flow (single-use). Throws if not found or expired. */
278
+ resumePendingFlow: (flowId: string | number, context?: { userId?: string }) => Promise<PendingFlowRecord>;
279
+ getUserInfo: (token: string) => Promise<FortressUser | null>;
280
+ /** Rotate the RS256 id-token signing key and prune expired retired keys. */
281
+ rotateSigningKey: () => Promise<{ kid: string }>;
282
+ // HTTP handler methods (transport-agnostic, accept/return plain objects)
283
+ handleTokenRequest: (body: TokenRequestBody, clientAuth?: ClientAuth) => Promise<Record<string, unknown>>;
284
+ handleIntrospectRequest: (body: { token: string }, clientAuth: ClientAuth) => Promise<Record<string, unknown>>;
285
+ handleRevokeRequest: (body: { token: string }, clientAuth: ClientAuth) => Promise<void>;
286
+ /**
287
+ * OIDC Core 1.0 §5.3 userinfo endpoint. Returns the standard claims set
288
+ * (`sub`, `email`, `email_verified`, `name`, `preferred_username`,
289
+ * `updated_at`) gated by the access token's scope (§5.4), plus anything
290
+ * the {@link OAuthConfig.userinfoClaims} hook contributes. Throws 401
291
+ * for invalid / expired tokens (RFC 6750).
292
+ */
293
+ handleUserInfoRequest: (bearerToken: string) => Promise<Record<string, unknown>>;
294
+ /**
295
+ * RFC 7517 / OIDC Discovery JWKS endpoint. Returns the AS's public
296
+ * verification keys (`{ keys: [...] }`). The first call materialises the
297
+ * active signing key if none has been generated yet; subsequent calls
298
+ * are cheap reads.
299
+ */
300
+ handleJwksRequest: () => Promise<Record<string, unknown>>;
301
+ handleDiscovery: () => Record<string, unknown>;
302
+ /**
303
+ * Handle GET /oauth/authorize. Validates the query, creates a pending
304
+ * flow, and returns the URL to redirect the browser to (login if no
305
+ * user, consent if authenticated).
306
+ */
307
+ handleAuthorizeRequest: (
308
+ query: Record<string, string | undefined>,
309
+ context: { userId?: string },
310
+ ) => Promise<{ redirectUrl: string; flowId: string }>;
311
+ /**
312
+ * Handle GET /oauth/flows/:flowId. Returns the consent metadata the host
313
+ * app's consent page renders. Throws if the flow is unknown or expired.
314
+ *
315
+ * Requires the request principal to own the flow — the first authenticated
316
+ * read claims the flow for `context.userId` (trust-on-first-use); every
317
+ * subsequent read MUST come from the same user or the response is 404.
318
+ */
319
+ handleGetFlow: (
320
+ flowId: string | number,
321
+ context: { userId: string },
322
+ ) => Promise<{
323
+ flowId: string;
324
+ client: { clientId: string; name: string };
325
+ redirectUri: string;
326
+ scopes: string[];
327
+ state: string;
328
+ }>;
329
+ /**
330
+ * Handle POST /oauth/flows/:flowId/approve. Issues an authorization code
331
+ * for the authenticated user, deletes the pending flow, and returns the
332
+ * URL the browser should be sent back to (the OAuth client's redirect_uri
333
+ * with `?code=...&state=...`).
334
+ */
335
+ handleApproveFlow: (
336
+ flowId: string | number,
337
+ context: { userId: string; authTimeSeconds?: number },
338
+ ) => Promise<{ redirectUrl: string }>;
339
+ /**
340
+ * Handle POST /oauth/flows/:flowId/deny. Deletes the pending flow and
341
+ * returns the URL the browser should be sent back to (redirect_uri with
342
+ * `?error=access_denied&state=...`).
343
+ *
344
+ * Requires the request principal to own the flow.
345
+ */
346
+ handleDenyFlow: (
347
+ flowId: string | number,
348
+ context: { userId: string },
349
+ ) => Promise<{ redirectUrl: string }>;
350
+ resolveTokenPermissions: (token: string) => Promise<{ resource: string; action: string }[]>;
351
+ }
352
+ /**
353
+ * OAuth 2.0 server plugin factory. Returns a {@link FortressPlugin} that
354
+ * implements the authorization-code grant (with PKCE) and client-credentials
355
+ * grant, persists clients and tokens, and exposes `/authorize` and `/token`
356
+ * endpoints when mounted on a framework adapter.
357
+ */
358
+ export function oauth(config: OAuthConfig = {}): FortressPlugin & { readonly name: 'oauth' } {
359
+ const authCodeExpiry = config.authCodeExpirySeconds ?? 600;
360
+ const pendingFlowExpiry = config.pendingFlowExpirySeconds ?? 600;
361
+ const accessTokenExpiry = config.accessTokenExpirySeconds ?? 3600;
362
+ // 30 days. RFC 9700 §2.2.2 doesn't mandate a duration; this matches the
363
+ // default fortress core uses for user-session refresh tokens.
364
+ const refreshTokenExpiry = config.refreshTokenExpirySeconds ?? 30 * 24 * 3600;
365
+ const refreshEnabled = refreshTokenExpiry > 0;
366
+ const idTokenExpiry = config.idTokenExpirySeconds ?? 3600;
367
+ const signingKeyGraceSeconds = config.signingKeyGraceSeconds ?? idTokenExpiry;
368
+ const scopePermissionMap = config.scopePermissionMap;
369
+ const issuerUrl = config.issuerUrl ?? 'https://localhost';
370
+
371
+ // RFC 8414 §2 + RFC 9700 §4.16: production issuer URL MUST be HTTPS.
372
+ // Dev / test (`NODE_ENV !== 'production'`) keeps localhost-HTTP working
373
+ // for the example app and integration suites.
374
+ if (
375
+ typeof process !== 'undefined'
376
+ && process.env?.NODE_ENV === 'production'
377
+ && !issuerUrl.startsWith('https://')
378
+ ) {
379
+ throw new Error(
380
+ `[fortress/oauth] issuerUrl must use https:// in production (got: ${issuerUrl})`,
381
+ );
382
+ }
383
+
384
+ return {
385
+ name: 'oauth',
386
+
387
+ models: [
388
+ {
389
+ name: 'oauth_client',
390
+ fields: {
391
+ id: { type: 'number', required: true },
392
+ clientId: { type: 'string', required: true, unique: true },
393
+ // For public clients (`tokenEndpointAuthMethod === 'none'`) the
394
+ // hash is set to the empty string — the column is required by the
395
+ // adapter for backwards compat, but never compared against an
396
+ // inbound secret.
397
+ clientSecretHash: { type: 'string', required: true },
398
+ name: { type: 'string', required: true },
399
+ redirectUris: { type: 'string', required: true },
400
+ grantTypes: { type: 'string', required: true },
401
+ // RFC 6749 §3.3 / RFC 9700 §2.2.1 per-client scope allow-list.
402
+ // Optional for backwards compatibility with legacy v0 clients
403
+ // created before this column existed.
404
+ allowedScopes: { type: 'string' },
405
+ // RFC 6749 §2.1 client type. Optional — absent on legacy clients,
406
+ // which are treated as confidential (`client_secret_basic`).
407
+ tokenEndpointAuthMethod: { type: 'string' },
408
+ createdAt: { type: 'date', required: true },
409
+ },
410
+ },
411
+ {
412
+ name: 'oauth_authorization_code',
413
+ fields: {
414
+ id: { type: 'number', required: true },
415
+ code: { type: 'string', required: true, unique: true },
416
+ clientId: { type: 'string', required: true },
417
+ userId: { type: 'number', required: true },
418
+ redirectUri: { type: 'string', required: true },
419
+ scope: { type: 'string' },
420
+ codeChallenge: { type: 'string' },
421
+ codeChallengeMethod: { type: 'string' },
422
+ // OIDC Core §3.1.2.1 nonce — echoed into the id_token verbatim.
423
+ nonce: { type: 'string' },
424
+ // Unix seconds; OIDC `auth_time` claim source.
425
+ authTime: { type: 'number' },
426
+ expiresAt: { type: 'date', required: true },
427
+ usedAt: { type: 'date' },
428
+ createdAt: { type: 'date', required: true },
429
+ },
430
+ },
431
+ {
432
+ name: 'oauth_access_token',
433
+ fields: {
434
+ id: { type: 'number', required: true },
435
+ token: { type: 'string', required: true, unique: true },
436
+ clientId: { type: 'string', required: true },
437
+ userId: { type: 'number' },
438
+ scope: { type: 'string' },
439
+ expiresAt: { type: 'date', required: true },
440
+ createdAt: { type: 'date', required: true },
441
+ },
442
+ },
443
+ {
444
+ name: 'oauth_refresh_token',
445
+ fields: {
446
+ id: { type: 'number', required: true },
447
+ token: { type: 'string', required: true, unique: true },
448
+ familyId: { type: 'string', required: true },
449
+ clientId: { type: 'string', required: true },
450
+ userId: { type: 'number', required: true },
451
+ scope: { type: 'string' },
452
+ issuedAt: { type: 'date', required: true },
453
+ expiresAt: { type: 'date', required: true },
454
+ usedAt: { type: 'date' },
455
+ parentId: { type: 'number' },
456
+ createdAt: { type: 'date', required: true },
457
+ },
458
+ },
459
+ {
460
+ name: 'oauth_pending_flow',
461
+ fields: {
462
+ id: { type: 'number', required: true },
463
+ flowId: { type: 'string', required: true, unique: true },
464
+ clientId: { type: 'string', required: true },
465
+ redirectUri: { type: 'string', required: true },
466
+ scope: { type: 'string' },
467
+ state: { type: 'string', required: true },
468
+ codeChallenge: { type: 'string' },
469
+ codeChallengeMethod: { type: 'string' },
470
+ nonce: { type: 'string' },
471
+ // H6 fix: subject the flow is bound to. Nullable for back-compat
472
+ // and to model the trust-on-first-use claim.
473
+ userId: { type: 'number' },
474
+ // Single-use approval/denial claim. Older rows may have null.
475
+ usedAt: { type: 'date' },
476
+ expiresAt: { type: 'date', required: true },
477
+ createdAt: { type: 'date', required: true },
478
+ },
479
+ },
480
+ {
481
+ // OIDC Core §2 / RFC 7517 signing key persistence. RS256 only for
482
+ // now; the row stores both public and private JWKs as JSON. Active
483
+ // key has rotatedAt == null.
484
+ name: 'oauth_signing_key',
485
+ fields: {
486
+ id: { type: 'number', required: true },
487
+ kid: { type: 'string', required: true, unique: true },
488
+ alg: { type: 'string', required: true },
489
+ publicJwk: { type: 'string', required: true },
490
+ privateJwk: { type: 'string', required: true },
491
+ createdAt: { type: 'date', required: true },
492
+ rotatedAt: { type: 'date' },
493
+ },
494
+ },
495
+ ],
496
+
497
+ methods: ctx => ({
498
+ /**
499
+ * Register a new OAuth client.
500
+ * Returns the clientId and raw clientSecret (shown once, stored as hash).
501
+ */
502
+ async createClient(data: {
503
+ name: string;
504
+ redirectUris: string[];
505
+ grantTypes: string[];
506
+ /**
507
+ * RFC 6749 §3.3 / RFC 9700 §2.2.1 scope allow-list. Optional — omit
508
+ * to allow whatever scope the client requests (legacy v0
509
+ * behaviour); pass `[]` to deny all; pass `['openid', 'email']` to
510
+ * gate to a known set.
511
+ */
512
+ allowedScopes?: string[];
513
+ /**
514
+ * RFC 6749 §2.1 / OIDC Discovery client authentication method.
515
+ * Defaults to `'client_secret_basic'` (confidential client). Pass
516
+ * `'none'` for public clients (SPAs, native apps — RFC 8252) which
517
+ * authenticate via PKCE alone; no secret is generated.
518
+ */
519
+ tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
520
+ }): Promise<{ clientId: string; clientSecret: string | null }> {
521
+ const isPublic = data.tokenEndpointAuthMethod === 'none';
522
+ const { raw: clientSecret, hash: clientSecretHash } = isPublic
523
+ ? { raw: '', hash: '' }
524
+ : await generateRefreshToken();
525
+ const { raw: clientIdRaw } = await generateRefreshToken();
526
+ const clientId = clientIdRaw.slice(0, 24); // Shorter, readable client ID
527
+
528
+ await ctx.db.create({
529
+ model: 'oauth_client',
530
+ data: {
531
+ clientId,
532
+ clientSecretHash,
533
+ name: data.name,
534
+ redirectUris: JSON.stringify(data.redirectUris),
535
+ grantTypes: JSON.stringify(data.grantTypes),
536
+ allowedScopes: data.allowedScopes ? JSON.stringify(data.allowedScopes) : null,
537
+ tokenEndpointAuthMethod: data.tokenEndpointAuthMethod ?? 'client_secret_basic',
538
+ },
539
+ });
540
+
541
+ return {
542
+ clientId,
543
+ // Public clients have no secret — returning `null` makes the
544
+ // contract explicit at compile time.
545
+ clientSecret: isPublic ? null : clientSecret,
546
+ };
547
+ },
548
+
549
+ /**
550
+ * Generate an authorization code for a user+client.
551
+ * Used after the user authenticates and authorizes the client.
552
+ */
553
+ async createAuthorizationCode(params: {
554
+ clientId: string;
555
+ userId: string;
556
+ redirectUri: string;
557
+ scope?: string;
558
+ codeChallenge?: string;
559
+ codeChallengeMethod?: string;
560
+ /** OIDC Core §3.1.2.1 nonce; echoed verbatim into the id_token. */
561
+ nonce?: string;
562
+ /** Unix seconds when the user authenticated; OIDC `auth_time`. */
563
+ authTime?: number;
564
+ }): Promise<{ code: string }> {
565
+ // Validate client
566
+ const client = await ctx.db.findOne<OAuthClientRecord>({
567
+ model: 'oauth_client',
568
+ where: [{ field: 'clientId', operator: '=', value: params.clientId }],
569
+ });
570
+
571
+ if (!client)
572
+ throw Errors.oauth('invalid_request', 'Invalid client_id');
573
+
574
+ const uris = JSON.parse(client.redirectUris) as string[];
575
+ if (!uris.some(r => matchRedirectUri(r, params.redirectUri)))
576
+ throw Errors.oauth('invalid_request', 'Invalid redirect_uri');
577
+
578
+ // P1.3: enforce PKCE at code-issuance time for public clients. The
579
+ // exchange path also rejects a public-client code with no challenge,
580
+ // but failing here keeps a binding-less code from ever being stored
581
+ // and surfaces the error at the point of mint.
582
+ const isPublic = client.tokenEndpointAuthMethod === 'none';
583
+ if (isPublic && (!params.codeChallenge || params.codeChallengeMethod !== 'S256'))
584
+ throw Errors.oauth('invalid_request', 'PKCE (S256) is required for public clients');
585
+
586
+ const { raw: code, hash: codeHash } = await generateRefreshToken();
587
+ const expiresAt = new Date(Date.now() + authCodeExpiry * 1000);
588
+
589
+ await ctx.db.create({
590
+ model: 'oauth_authorization_code',
591
+ data: {
592
+ code: codeHash,
593
+ clientId: params.clientId,
594
+ userId: params.userId,
595
+ redirectUri: params.redirectUri,
596
+ scope: params.scope ?? null,
597
+ codeChallenge: params.codeChallenge ?? null,
598
+ codeChallengeMethod: params.codeChallengeMethod ?? null,
599
+ nonce: params.nonce ?? null,
600
+ authTime: params.authTime ?? null,
601
+ expiresAt,
602
+ usedAt: null,
603
+ },
604
+ });
605
+
606
+ return { code };
607
+ },
608
+
609
+ /**
610
+ * Exchange an authorization code for an access token.
611
+ */
612
+ async exchangeCode(params: {
613
+ code: string;
614
+ clientId: string;
615
+ /**
616
+ * Required for confidential clients; ignored for public clients
617
+ * (`tokenEndpointAuthMethod === 'none'`), which authenticate via
618
+ * PKCE alone (RFC 8252 §8.6).
619
+ */
620
+ clientSecret?: string;
621
+ redirectUri: string;
622
+ codeVerifier?: string;
623
+ }): Promise<{ accessToken: string; tokenType: string; expiresIn: number; scope?: string; refreshToken?: string; idToken?: string }> {
624
+ // Validate client credentials
625
+ const client = await ctx.db.findOne<OAuthClientRecord>({
626
+ model: 'oauth_client',
627
+ where: [{ field: 'clientId', operator: '=', value: params.clientId }],
628
+ });
629
+
630
+ if (!client)
631
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
632
+
633
+ const isPublic = client.tokenEndpointAuthMethod === 'none';
634
+ if (!isPublic) {
635
+ if (!params.clientSecret)
636
+ throw Errors.oauth('invalid_client', 'Client secret required');
637
+ const secretValid = timingSafeEqualHex(
638
+ await hashToken(params.clientSecret),
639
+ client.clientSecretHash,
640
+ );
641
+ if (!secretValid)
642
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
643
+ }
644
+ else if (params.clientSecret) {
645
+ // RFC 6749 §2.3.1: a public client MUST NOT present credentials.
646
+ throw Errors.oauth('invalid_client', 'Public clients must not present a client_secret');
647
+ }
648
+
649
+ // RFC 6749 §2.1 / §5.2: enforce per-client `grant_types` registration.
650
+ // Refresh + client-credentials grants check the same thing; we now
651
+ // also gate authorization_code here so a confidential client that
652
+ // was registered with `client_credentials` only can't silently
653
+ // exchange a smuggled code (M9).
654
+ const grantTypes = JSON.parse(client.grantTypes) as string[];
655
+ if (!grantTypes.includes('authorization_code'))
656
+ throw Errors.oauth('unauthorized_client', 'Client does not support authorization_code grant');
657
+
658
+ // P1.1: do lookup, CAS-claim, and token issuance inside a single
659
+ // transaction so two concurrent /token requests for the same code
660
+ // cannot both mint tokens. The conditional `update` (`usedAt is
661
+ // null`) is the compare-and-set: only one caller flips the row, the
662
+ // other gets `null` and is rejected with `invalid_grant`.
663
+ const codeHash = await hashToken(params.code);
664
+ return ctx.db.transaction(async (tx) => {
665
+ const authCode = await tx.findOne<AuthCodeRecord>({
666
+ model: 'oauth_authorization_code',
667
+ where: [{ field: 'code', operator: '=', value: codeHash }],
668
+ });
669
+
670
+ if (!authCode)
671
+ throw Errors.oauth('invalid_grant', 'Invalid authorization code');
672
+
673
+ if (authCode.expiresAt < new Date())
674
+ throw Errors.oauth('invalid_grant', 'Authorization code expired');
675
+
676
+ if (authCode.clientId !== params.clientId)
677
+ throw Errors.oauth('invalid_grant', 'Client mismatch');
678
+
679
+ if (authCode.redirectUri !== params.redirectUri)
680
+ throw Errors.oauth('invalid_grant', 'Redirect URI mismatch');
681
+
682
+ // RFC 7636 §4.4.1 + RFC 9700 §2.1.1 (P1.3): PKCE is mandatory for
683
+ // public clients. A public-client code MUST carry a challenge —
684
+ // missing state here is a server-side bug, not a leniency.
685
+ if (isPublic && !authCode.codeChallenge)
686
+ throw Errors.oauth('invalid_grant', 'PKCE required for public clients');
687
+
688
+ // Verify PKCE — RFC 7636 §4.6.
689
+ if (authCode.codeChallenge && authCode.codeChallengeMethod) {
690
+ if (!params.codeVerifier)
691
+ throw Errors.oauth('invalid_grant', 'code_verifier required');
692
+
693
+ const valid = await verifyCodeChallenge(
694
+ params.codeVerifier,
695
+ authCode.codeChallenge,
696
+ authCode.codeChallengeMethod,
697
+ );
698
+
699
+ if (!valid)
700
+ throw Errors.oauth('invalid_grant', 'Invalid code_verifier');
701
+ }
702
+
703
+ // CAS claim: mark the code used iff it hasn't been used yet. If
704
+ // a concurrent exchange already won the race the conditional
705
+ // update returns null and we reject this caller — the standard
706
+ // RFC 6749 §4.1.2 single-use enforcement, now actually atomic.
707
+ const claimed = await tx.update<AuthCodeRecord>({
708
+ model: 'oauth_authorization_code',
709
+ where: [
710
+ { field: 'id', operator: '=', value: authCode.id },
711
+ { field: 'usedAt', operator: 'isNull', value: null },
712
+ ],
713
+ data: { usedAt: new Date() },
714
+ });
715
+ if (!claimed)
716
+ throw Errors.oauth('invalid_grant', 'Authorization code already used');
717
+
718
+ // Issue access token
719
+ const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
720
+ const expiresAt = new Date(Date.now() + accessTokenExpiry * 1000);
721
+
722
+ await tx.create({
723
+ model: 'oauth_access_token',
724
+ data: {
725
+ token: tokenHash,
726
+ clientId: params.clientId,
727
+ userId: authCode.userId,
728
+ scope: authCode.scope,
729
+ expiresAt,
730
+ },
731
+ });
732
+
733
+ // OIDC Core §3.1.3.7: id_token alongside the access token when the
734
+ // request used scope=openid. Resolve user + active signing key.
735
+ let idToken: string | undefined;
736
+ const scopes = (authCode.scope ?? '').split(' ').filter(Boolean);
737
+ if (scopes.includes('openid')) {
738
+ const user = await tx.findOne<FortressUser>({
739
+ model: 'user',
740
+ where: [{ field: 'id', operator: '=', value: authCode.userId }],
741
+ });
742
+ if (user) {
743
+ const signingKey = await getActiveSigningKey(tx);
744
+ idToken = await issueIdToken({
745
+ user,
746
+ clientId: params.clientId,
747
+ issuerUrl,
748
+ ttlSeconds: idTokenExpiry,
749
+ nonce: authCode.nonce ?? undefined,
750
+ authTimeSeconds: authCode.authTime ?? Math.floor(Date.now() / 1000),
751
+ scope: authCode.scope,
752
+ signingKey,
753
+ });
754
+ }
755
+ }
756
+
757
+ // RFC 6749 §6 + RFC 9700 §2.2.2: issue a refresh token alongside
758
+ // the access token at the start of a new rotation family. Public
759
+ // clients (RFC 8252) get them too; rotation + replay detection is
760
+ // the mitigation, not client confidentiality.
761
+ let refreshTokenRaw: string | undefined;
762
+ if (refreshEnabled) {
763
+ const { raw, hash } = await generateRefreshToken();
764
+ refreshTokenRaw = raw;
765
+ await tx.create({
766
+ model: 'oauth_refresh_token',
767
+ data: {
768
+ token: hash,
769
+ familyId: generateTokenFamily(),
770
+ clientId: params.clientId,
771
+ userId: authCode.userId,
772
+ scope: authCode.scope ?? null,
773
+ issuedAt: new Date(),
774
+ expiresAt: new Date(Date.now() + refreshTokenExpiry * 1000),
775
+ usedAt: null,
776
+ parentId: null,
777
+ },
778
+ });
779
+ }
780
+
781
+ return {
782
+ accessToken: tokenRaw,
783
+ tokenType: 'Bearer',
784
+ expiresIn: accessTokenExpiry,
785
+ scope: authCode.scope ?? undefined,
786
+ ...(refreshTokenRaw ? { refreshToken: refreshTokenRaw } : {}),
787
+ ...(idToken ? { idToken } : {}),
788
+ };
789
+ });
790
+ },
791
+
792
+ /**
793
+ * RFC 6749 §6 refresh-token grant with RFC 9700 §2.2.2 rotation.
794
+ *
795
+ * Flow:
796
+ * 1. Look up the inbound refresh token by hash.
797
+ * 2. If it's already been used (`usedAt != null`), this is a replay
798
+ * attack — delete every refresh token in the same family and
799
+ * throw `invalid_grant`. The legitimate client's most recent
800
+ * refresh dies with the family, so the user is forced to
801
+ * re-authenticate, but the attacker's stolen token is now
802
+ * useless.
803
+ * 3. Otherwise mark the old token used, mint a new access token +
804
+ * a new refresh token in the same family, link the new refresh
805
+ * via `parentId`, and return the pair.
806
+ */
807
+ async refreshTokenGrant(params: {
808
+ clientId: string;
809
+ clientSecret?: string;
810
+ refreshToken: string;
811
+ scope?: string;
812
+ }): Promise<{ accessToken: string; tokenType: string; expiresIn: number; refreshToken: string; scope?: string; idToken?: string }> {
813
+ const client = await ctx.db.findOne<OAuthClientRecord>({
814
+ model: 'oauth_client',
815
+ where: [{ field: 'clientId', operator: '=', value: params.clientId }],
816
+ });
817
+ if (!client)
818
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
819
+
820
+ const isPublic = client.tokenEndpointAuthMethod === 'none';
821
+ if (!isPublic) {
822
+ if (!params.clientSecret)
823
+ throw Errors.oauth('invalid_client', 'Client secret required');
824
+ const secretValid = timingSafeEqualHex(
825
+ await hashToken(params.clientSecret),
826
+ client.clientSecretHash,
827
+ );
828
+ if (!secretValid)
829
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
830
+ }
831
+ else if (params.clientSecret) {
832
+ // Mirror exchangeCode: a public client MUST NOT present credentials
833
+ // even on /refresh (RFC 6749 §2.3.1 + M9 follow-up).
834
+ throw Errors.oauth('invalid_client', 'Public clients must not present a client_secret');
835
+ }
836
+
837
+ // Per-client grant_types enforcement (M9).
838
+ const refreshGrantTypes = JSON.parse(client.grantTypes) as string[];
839
+ if (!refreshGrantTypes.includes('refresh_token'))
840
+ throw Errors.oauth('unauthorized_client', 'Client does not support refresh_token grant');
841
+
842
+ const tokenHash = await hashToken(params.refreshToken);
843
+
844
+ const grant = await ctx.db.transaction(async (tx) => {
845
+ const record = await tx.findOne<RefreshTokenRecord>({
846
+ model: 'oauth_refresh_token',
847
+ where: [{ field: 'token', operator: '=', value: tokenHash }],
848
+ });
849
+ if (!record)
850
+ throw Errors.oauth('invalid_grant', 'Invalid refresh token');
851
+
852
+ if (record.clientId !== params.clientId)
853
+ throw Errors.oauth('invalid_grant', 'Refresh token client mismatch');
854
+
855
+ if (record.expiresAt < new Date())
856
+ throw Errors.oauth('invalid_grant', 'Refresh token expired');
857
+
858
+ // RFC 9700 §2.2.2 replay detection. Reuse of a rotated token means
859
+ // an attacker stole it, OR the legitimate client missed the
860
+ // response and is retrying. Either way, the family is compromised
861
+ // and must die.
862
+ if (record.usedAt) {
863
+ await tx.delete({
864
+ model: 'oauth_refresh_token',
865
+ where: [{ field: 'familyId', operator: '=', value: record.familyId }],
866
+ });
867
+ return { replayDetected: true as const };
868
+ }
869
+
870
+ // RFC 6749 §6: refreshed scope MUST NOT include any scope not
871
+ // originally granted. We allow narrowing only.
872
+ let scope = record.scope;
873
+ if (params.scope) {
874
+ const requested = params.scope.split(' ').filter(Boolean);
875
+ const original = (record.scope ?? '').split(' ').filter(Boolean);
876
+ const originalSet = new Set(original);
877
+ const widened = requested.find(s => !originalSet.has(s));
878
+ if (widened) {
879
+ throw Errors.oauth('invalid_scope', `Refreshed scope cannot widen: ${widened}`);
880
+ }
881
+ scope = requested.join(' ');
882
+ }
883
+
884
+ // Atomic compare-and-set claim: only one concurrent caller can mark
885
+ // a never-used refresh token as used.
886
+ const claimed = await tx.update<RefreshTokenRecord>({
887
+ model: 'oauth_refresh_token',
888
+ where: [
889
+ { field: 'id', operator: '=', value: record.id },
890
+ { field: 'usedAt', operator: 'isNull', value: null },
891
+ ],
892
+ data: { usedAt: new Date() },
893
+ });
894
+ if (!claimed) {
895
+ await tx.delete({
896
+ model: 'oauth_refresh_token',
897
+ where: [{ field: 'familyId', operator: '=', value: record.familyId }],
898
+ });
899
+ return { replayDetected: true as const };
900
+ }
901
+
902
+ // Mint the new access token.
903
+ const { raw: accessRaw, hash: accessHash } = await generateRefreshToken();
904
+ await tx.create({
905
+ model: 'oauth_access_token',
906
+ data: {
907
+ token: accessHash,
908
+ clientId: params.clientId,
909
+ userId: record.userId,
910
+ scope,
911
+ expiresAt: new Date(Date.now() + accessTokenExpiry * 1000),
912
+ },
913
+ });
914
+
915
+ // OIDC Core allows a refreshed response to carry a fresh id_token
916
+ // when the original grant included `openid`. Many strict OIDC RPs
917
+ // expect this so their session identity view stays current.
918
+ let idToken: string | undefined;
919
+ if ((scope ?? '').split(' ').filter(Boolean).includes('openid')) {
920
+ const user = await tx.findOne<FortressUser>({
921
+ model: 'user',
922
+ where: [{ field: 'id', operator: '=', value: record.userId }],
923
+ });
924
+ if (user) {
925
+ const signingKey = await getActiveSigningKey(tx);
926
+ idToken = await issueIdToken({
927
+ user,
928
+ clientId: params.clientId,
929
+ issuerUrl,
930
+ ttlSeconds: idTokenExpiry,
931
+ // The original authentication time is not stored on the
932
+ // refresh family. Omit auth_time rather than fabricating a
933
+ // fresh authentication event at token rotation.
934
+ scope,
935
+ signingKey,
936
+ });
937
+ }
938
+ }
939
+
940
+ // Mint the new refresh token in the same family.
941
+ const { raw: refreshRaw, hash: refreshHash } = await generateRefreshToken();
942
+ await tx.create({
943
+ model: 'oauth_refresh_token',
944
+ data: {
945
+ token: refreshHash,
946
+ familyId: record.familyId,
947
+ clientId: params.clientId,
948
+ userId: record.userId,
949
+ scope,
950
+ issuedAt: new Date(),
951
+ expiresAt: new Date(Date.now() + refreshTokenExpiry * 1000),
952
+ usedAt: null,
953
+ parentId: record.id,
954
+ },
955
+ });
956
+
957
+ return {
958
+ accessToken: accessRaw,
959
+ tokenType: 'Bearer',
960
+ expiresIn: accessTokenExpiry,
961
+ refreshToken: refreshRaw,
962
+ ...(scope ? { scope } : {}),
963
+ ...(idToken ? { idToken } : {}),
964
+ };
965
+ });
966
+
967
+ if ('replayDetected' in grant)
968
+ throw Errors.oauth('invalid_grant', 'Refresh token reuse detected; family revoked');
969
+ return grant;
970
+ },
971
+
972
+ /**
973
+ * Client credentials grant — issue token for a service client (no user).
974
+ */
975
+ async clientCredentialsGrant(params: {
976
+ clientId: string;
977
+ clientSecret: string;
978
+ scope?: string;
979
+ }): Promise<{ accessToken: string; tokenType: string; expiresIn: number }> {
980
+ const client = await ctx.db.findOne<OAuthClientRecord>({
981
+ model: 'oauth_client',
982
+ where: [{ field: 'clientId', operator: '=', value: params.clientId }],
983
+ });
984
+
985
+ if (!client)
986
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
987
+
988
+ // RFC 6749 §4.4 client_credentials inherently requires a secret.
989
+ // Public clients have no secret, so they cannot use this grant.
990
+ if (client.tokenEndpointAuthMethod === 'none')
991
+ throw Errors.oauth('unauthorized_client', 'Public clients cannot use client_credentials');
992
+
993
+ const grantTypes = JSON.parse(client.grantTypes) as string[];
994
+ if (!grantTypes.includes('client_credentials'))
995
+ throw Errors.oauth('unauthorized_client', 'Client does not support client_credentials grant');
996
+
997
+ const secretValid = timingSafeEqualHex(
998
+ await hashToken(params.clientSecret),
999
+ client.clientSecretHash,
1000
+ );
1001
+ if (!secretValid)
1002
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
1003
+
1004
+ // RFC 6749 §3.3 scope intersection — same as authorize code grant.
1005
+ const effectiveScope = intersectScope(params.scope ?? null, client.allowedScopes);
1006
+ if (params.scope && effectiveScope === '') {
1007
+ throw Errors.oauth('invalid_scope', 'No requested scope is allowed for this client');
1008
+ }
1009
+
1010
+ const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
1011
+ const expiresAt = new Date(Date.now() + accessTokenExpiry * 1000);
1012
+
1013
+ await ctx.db.create({
1014
+ model: 'oauth_access_token',
1015
+ data: {
1016
+ token: tokenHash,
1017
+ clientId: params.clientId,
1018
+ userId: null,
1019
+ scope: effectiveScope || null,
1020
+ expiresAt,
1021
+ },
1022
+ });
1023
+
1024
+ return {
1025
+ accessToken: tokenRaw,
1026
+ tokenType: 'Bearer',
1027
+ expiresIn: accessTokenExpiry,
1028
+ };
1029
+ },
1030
+
1031
+ /**
1032
+ * Revoke an access token OR refresh token (RFC 7009).
1033
+ *
1034
+ * Per §2.1: revoking a refresh token also revokes the related access
1035
+ * tokens. When a refresh token is revoked here, the entire token
1036
+ * family is dropped — every rotated descendant becomes unusable.
1037
+ */
1038
+ async revokeToken(token: string): Promise<void> {
1039
+ const tokenHash = await hashToken(token);
1040
+ // Try as access token first.
1041
+ await ctx.db.delete({
1042
+ model: 'oauth_access_token',
1043
+ where: [{ field: 'token', operator: '=', value: tokenHash }],
1044
+ });
1045
+ // Try as refresh token — if the hash matches, kill the whole family.
1046
+ if (refreshEnabled) {
1047
+ const refreshRecord = await ctx.db.findOne<RefreshTokenRecord>({
1048
+ model: 'oauth_refresh_token',
1049
+ where: [{ field: 'token', operator: '=', value: tokenHash }],
1050
+ });
1051
+ if (refreshRecord) {
1052
+ await ctx.db.delete({
1053
+ model: 'oauth_refresh_token',
1054
+ where: [{ field: 'familyId', operator: '=', value: refreshRecord.familyId }],
1055
+ });
1056
+ }
1057
+ }
1058
+ },
1059
+
1060
+ /**
1061
+ * Validate an access token and return associated user/client info.
1062
+ */
1063
+ async introspectToken(token: string): Promise<{
1064
+ active: boolean;
1065
+ clientId?: string;
1066
+ userId?: string;
1067
+ scope?: string;
1068
+ }> {
1069
+ const tokenHash = await hashToken(token);
1070
+ const record = await ctx.db.findOne<AccessTokenRecord>({
1071
+ model: 'oauth_access_token',
1072
+ where: [{ field: 'token', operator: '=', value: tokenHash }],
1073
+ });
1074
+
1075
+ if (!record || record.expiresAt < new Date()) {
1076
+ return { active: false };
1077
+ }
1078
+
1079
+ return {
1080
+ active: true,
1081
+ clientId: record.clientId,
1082
+ userId: record.userId ?? undefined,
1083
+ scope: record.scope ?? undefined,
1084
+ };
1085
+ },
1086
+
1087
+ /**
1088
+ * Create a pending OAuth flow for unauthenticated users (identity broker pattern).
1089
+ */
1090
+ async createPendingFlow(params: {
1091
+ clientId: string;
1092
+ redirectUri: string;
1093
+ scope?: string;
1094
+ state: string;
1095
+ codeChallenge?: string;
1096
+ codeChallengeMethod?: string;
1097
+ userId?: string;
1098
+ }): Promise<{ flowId: string }> {
1099
+ const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1000);
1100
+ const { raw: flowId } = await generateRefreshToken();
1101
+
1102
+ const flow = await ctx.db.create<PendingFlowRecord>({
1103
+ model: 'oauth_pending_flow',
1104
+ data: {
1105
+ flowId,
1106
+ clientId: params.clientId,
1107
+ redirectUri: params.redirectUri,
1108
+ scope: params.scope ?? null,
1109
+ state: params.state,
1110
+ codeChallenge: params.codeChallenge ?? null,
1111
+ codeChallengeMethod: params.codeChallengeMethod ?? null,
1112
+ userId: params.userId ?? null,
1113
+ usedAt: null,
1114
+ expiresAt,
1115
+ },
1116
+ });
1117
+
1118
+ return { flowId: flow.flowId };
1119
+ },
1120
+
1121
+ /**
1122
+ * Read a pending OAuth flow without consuming it.
1123
+ *
1124
+ * Used by the consent UI to fetch flow metadata (client, scopes, redirect URI)
1125
+ * before the user approves or denies. Safe to call multiple times.
1126
+ *
1127
+ * @throws {FortressError} `not_found` if the flow doesn't exist.
1128
+ * @throws {FortressError} `bad_request` if the flow has expired.
1129
+ */
1130
+ async getPendingFlow(
1131
+ flowId: string | number,
1132
+ context?: { userId?: string },
1133
+ ): Promise<PendingFlowRecord> {
1134
+ const flow = await ctx.db.findOne<PendingFlowRecord>({
1135
+ model: 'oauth_pending_flow',
1136
+ where: pendingFlowLookupWhere(flowId),
1137
+ });
1138
+
1139
+ if (!flow)
1140
+ throw Errors.notFound('Pending flow not found');
1141
+
1142
+ if (flow.usedAt)
1143
+ throw Errors.notFound('Pending flow not found');
1144
+
1145
+ if (flow.expiresAt < new Date())
1146
+ throw Errors.badRequest('Pending flow expired');
1147
+
1148
+ // H6 fix — owner check (only when the caller supplied a context).
1149
+ // We deliberately return 404, not 403, to avoid confirming that the
1150
+ // flow exists when a different user probes its id.
1151
+ if (
1152
+ context?.userId !== undefined
1153
+ && flow.userId !== null
1154
+ && flow.userId !== context.userId
1155
+ ) {
1156
+ throw Errors.notFound('Pending flow not found');
1157
+ }
1158
+
1159
+ return flow;
1160
+ },
1161
+
1162
+ /**
1163
+ * Resume a pending OAuth flow after user authenticates (single-use consume).
1164
+ *
1165
+ * Returns the stored flow params and deletes the row so the same flow can't
1166
+ * be replayed. Use {@link OAuthMethods.getPendingFlow} for non-destructive reads.
1167
+ */
1168
+ async resumePendingFlow(
1169
+ flowId: string | number,
1170
+ context?: { userId?: string },
1171
+ ): Promise<PendingFlowRecord> {
1172
+ const flow = await ctx.db.findOne<PendingFlowRecord>({
1173
+ model: 'oauth_pending_flow',
1174
+ where: pendingFlowLookupWhere(flowId),
1175
+ });
1176
+
1177
+ if (!flow)
1178
+ throw Errors.notFound('Pending flow not found');
1179
+
1180
+ if (flow.usedAt)
1181
+ throw Errors.notFound('Pending flow not found');
1182
+
1183
+ if (flow.expiresAt < new Date())
1184
+ throw Errors.badRequest('Pending flow expired');
1185
+
1186
+ // H6 fix — same owner check semantics as getPendingFlow.
1187
+ if (
1188
+ context?.userId !== undefined
1189
+ && flow.userId !== null
1190
+ && flow.userId !== context.userId
1191
+ ) {
1192
+ throw Errors.notFound('Pending flow not found');
1193
+ }
1194
+
1195
+ // Delete the flow (single-use)
1196
+ await ctx.db.delete({
1197
+ model: 'oauth_pending_flow',
1198
+ where: [{ field: 'id', operator: '=', value: flow.id }],
1199
+ });
1200
+
1201
+ return flow;
1202
+ },
1203
+
1204
+ /**
1205
+ * Get userinfo for an access token (OpenID Connect userinfo endpoint).
1206
+ */
1207
+ /**
1208
+ * Handle the authorize endpoint. Validates the OAuth query, creates a
1209
+ * pending flow row, and decides whether the browser should be sent to
1210
+ * the host app's login page or consent page next.
1211
+ *
1212
+ * The dispatcher turns `redirectUrl` into a 302 response. Errors are
1213
+ * thrown as {@link FortressError}s so they surface as a 4xx JSON body
1214
+ * (the OAuth client is expected to handle that).
1215
+ */
1216
+ async handleAuthorizeRequest(
1217
+ query: Record<string, string | undefined>,
1218
+ context: { userId?: string },
1219
+ ): Promise<{ redirectUrl: string; flowId: string }> {
1220
+ const nonce = query.nonce;
1221
+ if (!config.loginUrl || !config.consentUrl) {
1222
+ throw Errors.badRequest(
1223
+ 'OAuth plugin is missing loginUrl/consentUrl configuration',
1224
+ );
1225
+ }
1226
+
1227
+ const clientId = query.client_id;
1228
+ const redirectUri = query.redirect_uri;
1229
+ const responseType = query.response_type;
1230
+ const state = query.state;
1231
+ const scope = query.scope;
1232
+ const codeChallenge = query.code_challenge;
1233
+ const codeChallengeMethod = query.code_challenge_method;
1234
+
1235
+ if (!clientId)
1236
+ throw Errors.oauth('invalid_request', 'client_id is required');
1237
+ if (!redirectUri)
1238
+ throw Errors.oauth('invalid_request', 'redirect_uri is required');
1239
+ if (responseType !== 'code')
1240
+ throw Errors.oauth('unsupported_response_type', 'response_type must be "code"');
1241
+ if (!state)
1242
+ throw Errors.oauth('invalid_request', 'state is required');
1243
+
1244
+ // Validate the client and redirect URI up front so a bogus client
1245
+ // never reaches the user-facing pages. Per RFC 6749 §4.1.2.1, the AS
1246
+ // MUST NOT redirect when the client_id or redirect_uri can't be
1247
+ // trusted — returning the error directly here is the correct shape.
1248
+ const client = await ctx.db.findOne<OAuthClientRecord>({
1249
+ model: 'oauth_client',
1250
+ where: [{ field: 'clientId', operator: '=', value: clientId }],
1251
+ });
1252
+ if (!client)
1253
+ throw Errors.oauth('invalid_request', 'Unknown client_id');
1254
+
1255
+ const allowedRedirects = JSON.parse(client.redirectUris) as string[];
1256
+ if (!allowedRedirects.some(r => matchRedirectUri(r, redirectUri)))
1257
+ throw Errors.oauth('invalid_request', 'Invalid redirect_uri');
1258
+
1259
+ // RFC 9700 §2.1.1: PKCE is mandatory. The escape hatch
1260
+ // `allowNonPkceConfidentialClients` exists for legacy server-side RPs
1261
+ // and is documented as discouraged.
1262
+ if (!codeChallenge && !config.allowNonPkceConfidentialClients) {
1263
+ throw Errors.oauth('invalid_request', 'code_challenge is required (PKCE)');
1264
+ }
1265
+
1266
+ // PKCE method validation — we only support S256 (matches the
1267
+ // discovery document).
1268
+ if (codeChallenge && codeChallengeMethod && codeChallengeMethod !== 'S256') {
1269
+ throw Errors.oauth('invalid_request', 'Only S256 code_challenge_method is supported');
1270
+ }
1271
+ // RFC 7636 §4.3 default for `code_challenge_method` when only the
1272
+ // challenge is sent is `plain`, which we don't support — require the
1273
+ // method to be explicit.
1274
+ if (codeChallenge && !codeChallengeMethod) {
1275
+ throw Errors.oauth(
1276
+ 'invalid_request',
1277
+ 'code_challenge_method is required when code_challenge is set; only S256 is supported',
1278
+ );
1279
+ }
1280
+
1281
+ // RFC 6749 §3.3: intersect the requested scopes against the client's
1282
+ // allow-list. Empty intersection (after non-empty request) returns
1283
+ // `invalid_scope`; subset is silently narrowed.
1284
+ const effectiveScope = intersectScope(scope ?? null, client.allowedScopes);
1285
+ if (scope && effectiveScope === '') {
1286
+ throw Errors.oauth('invalid_scope', 'No requested scope is allowed for this client');
1287
+ }
1288
+
1289
+ const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1000);
1290
+ const { raw: flowId } = await generateRefreshToken();
1291
+ const flow = await ctx.db.create<PendingFlowRecord>({
1292
+ model: 'oauth_pending_flow',
1293
+ data: {
1294
+ flowId,
1295
+ clientId,
1296
+ redirectUri,
1297
+ scope: effectiveScope || null,
1298
+ state,
1299
+ codeChallenge: codeChallenge ?? null,
1300
+ codeChallengeMethod: codeChallengeMethod ?? null,
1301
+ nonce: nonce ?? null,
1302
+ // H6 fix — bind the flow to the authenticated user up front when
1303
+ // the authorize endpoint already knows them. The login redirect
1304
+ // path leaves userId null and lets the first authenticated
1305
+ // GetFlow / Approve claim it.
1306
+ userId: context.userId ?? null,
1307
+ usedAt: null,
1308
+ expiresAt,
1309
+ },
1310
+ });
1311
+
1312
+ const target = context.userId ? config.consentUrl : config.loginUrl;
1313
+ const url = new URL(target);
1314
+ url.searchParams.set('flow', flow.flowId);
1315
+ return { redirectUrl: url.toString(), flowId: flow.flowId };
1316
+ },
1317
+
1318
+ /**
1319
+ * Read a pending flow as the consent UI's data source. Strips fields
1320
+ * that should never leave the server (PKCE challenge / method).
1321
+ */
1322
+ async handleGetFlow(
1323
+ flowId: string | number,
1324
+ context: { userId: string },
1325
+ ): Promise<{
1326
+ flowId: string;
1327
+ client: { clientId: string; name: string };
1328
+ redirectUri: string;
1329
+ scopes: string[];
1330
+ state: string;
1331
+ }> {
1332
+ const flow = await this.getPendingFlow(flowId, context);
1333
+ // TOFU claim: if the flow was created on the login path (no user
1334
+ // yet) and this is the first authenticated touch, bind it to the
1335
+ // caller. Subsequent reads from a different user will 404.
1336
+ if (flow.userId === null) {
1337
+ await ctx.db.update({
1338
+ model: 'oauth_pending_flow',
1339
+ where: [
1340
+ { field: 'id', operator: '=', value: flow.id },
1341
+ { field: 'userId', operator: 'isNull', value: null },
1342
+ ],
1343
+ data: { userId: context.userId },
1344
+ });
1345
+ }
1346
+ const client = await ctx.db.findOne<OAuthClientRecord>({
1347
+ model: 'oauth_client',
1348
+ where: [{ field: 'clientId', operator: '=', value: flow.clientId }],
1349
+ });
1350
+ if (!client)
1351
+ throw Errors.notFound('OAuth client not found');
1352
+
1353
+ return {
1354
+ flowId: flow.flowId,
1355
+ client: { clientId: client.clientId, name: client.name },
1356
+ redirectUri: flow.redirectUri,
1357
+ scopes: flow.scope ? flow.scope.split(' ').filter(Boolean) : [],
1358
+ state: flow.state,
1359
+ };
1360
+ },
1361
+
1362
+ /**
1363
+ * Approve a pending flow on behalf of the authenticated user. Creates
1364
+ * the authorization code, consumes the pending flow, and returns the
1365
+ * client's redirect URI with `?code=...&state=...` appended.
1366
+ */
1367
+ async handleApproveFlow(
1368
+ flowId: string | number,
1369
+ context: { userId: string; authTimeSeconds?: number },
1370
+ ): Promise<{ redirectUrl: string }> {
1371
+ const { flow, code } = await ctx.db.transaction(async (tx) => {
1372
+ const flow = await tx.findOne<PendingFlowRecord>({
1373
+ model: 'oauth_pending_flow',
1374
+ where: pendingFlowLookupWhere(flowId),
1375
+ });
1376
+
1377
+ if (!flow)
1378
+ throw Errors.notFound('Pending flow not found');
1379
+ if (flow.usedAt)
1380
+ throw Errors.notFound('Pending flow not found');
1381
+ if (flow.expiresAt < new Date())
1382
+ throw Errors.badRequest('Pending flow expired');
1383
+ if (flow.userId !== null && flow.userId !== context.userId)
1384
+ throw Errors.notFound('Pending flow not found');
1385
+
1386
+ // Claim before minting a code. The `usedAt IS NULL` predicate is
1387
+ // the single-use compare-and-set that makes concurrent Approve
1388
+ // requests yield exactly one authorization code.
1389
+ const claimed = await tx.update<PendingFlowRecord>({
1390
+ model: 'oauth_pending_flow',
1391
+ where: [
1392
+ { field: 'id', operator: '=', value: flow.id },
1393
+ { field: 'usedAt', operator: 'isNull', value: null },
1394
+ ],
1395
+ data: {
1396
+ usedAt: new Date(),
1397
+ userId: flow.userId ?? context.userId,
1398
+ },
1399
+ });
1400
+ if (!claimed)
1401
+ throw Errors.notFound('Pending flow not found');
1402
+
1403
+ // Validate the client and redirect URI inside the same transaction
1404
+ // before creating the code. This mirrors createAuthorizationCode
1405
+ // while keeping the pending-flow claim + code mint atomic.
1406
+ const client = await tx.findOne<OAuthClientRecord>({
1407
+ model: 'oauth_client',
1408
+ where: [{ field: 'clientId', operator: '=', value: flow.clientId }],
1409
+ });
1410
+ if (!client)
1411
+ throw Errors.oauth('invalid_request', 'Invalid client_id');
1412
+
1413
+ const uris = JSON.parse(client.redirectUris) as string[];
1414
+ if (!uris.some(r => matchRedirectUri(r, flow.redirectUri)))
1415
+ throw Errors.oauth('invalid_request', 'Invalid redirect_uri');
1416
+
1417
+ const isPublic = client.tokenEndpointAuthMethod === 'none';
1418
+ if (isPublic && (!flow.codeChallenge || flow.codeChallengeMethod !== 'S256'))
1419
+ throw Errors.oauth('invalid_request', 'PKCE (S256) is required for public clients');
1420
+
1421
+ const { raw: code, hash: codeHash } = await generateRefreshToken();
1422
+ await tx.create({
1423
+ model: 'oauth_authorization_code',
1424
+ data: {
1425
+ code: codeHash,
1426
+ clientId: flow.clientId,
1427
+ userId: context.userId,
1428
+ redirectUri: flow.redirectUri,
1429
+ scope: flow.scope ?? null,
1430
+ codeChallenge: flow.codeChallenge ?? null,
1431
+ codeChallengeMethod: flow.codeChallengeMethod ?? null,
1432
+ nonce: flow.nonce ?? null,
1433
+ authTime: context.authTimeSeconds ?? Math.floor(Date.now() / 1000),
1434
+ expiresAt: new Date(Date.now() + authCodeExpiry * 1000),
1435
+ usedAt: null,
1436
+ },
1437
+ });
1438
+
1439
+ // Delete inside the transaction after minting. If code creation
1440
+ // fails, the transaction rolls the claim back; if it commits, the
1441
+ // flow is gone and cannot be reused.
1442
+ await tx.delete({
1443
+ model: 'oauth_pending_flow',
1444
+ where: [{ field: 'id', operator: '=', value: flow.id }],
1445
+ });
1446
+
1447
+ return { flow, code };
1448
+ });
1449
+
1450
+ const url = new URL(flow.redirectUri);
1451
+ url.searchParams.set('code', code);
1452
+ url.searchParams.set('state', flow.state);
1453
+ // RFC 9207 §2: the AS MUST identify itself in the authorization
1454
+ // response so the RP can detect mix-up attacks (RFC 9700 §4.4).
1455
+ url.searchParams.set('iss', issuerUrl);
1456
+ return { redirectUrl: url.toString() };
1457
+ },
1458
+
1459
+ /**
1460
+ * Deny a pending flow. Consumes the flow row and returns the OAuth
1461
+ * client's redirect URI with `?error=access_denied&state=...&iss=...`.
1462
+ *
1463
+ * Per RFC 9207 §2, the issuer parameter is included on error responses
1464
+ * too — the RP needs to validate the AS identity before trusting any
1465
+ * field, including `error`.
1466
+ */
1467
+ async handleDenyFlow(
1468
+ flowId: string | number,
1469
+ context: { userId: string },
1470
+ ): Promise<{ redirectUrl: string }> {
1471
+ // H6: owner check enforced via the context arg.
1472
+ const flow = await this.resumePendingFlow(flowId, context);
1473
+ const url = new URL(flow.redirectUri);
1474
+ url.searchParams.set('error', 'access_denied');
1475
+ url.searchParams.set('state', flow.state);
1476
+ url.searchParams.set('iss', issuerUrl);
1477
+ return { redirectUrl: url.toString() };
1478
+ },
1479
+
1480
+ async getUserInfo(token: string): Promise<FortressUser | null> {
1481
+ const result = await this._lookupBearer(token);
1482
+ return result?.user ?? null;
1483
+ },
1484
+
1485
+ /**
1486
+ * Internal: resolve a bearer token to its access-token row + user.
1487
+ * Shared by `getUserInfo` (returns the raw user) and
1488
+ * `handleUserInfoRequest` (maps to OIDC claims).
1489
+ */
1490
+ async _lookupBearer(token: string): Promise<{ user: FortressUser; scope: string | null } | null> {
1491
+ const tokenHash = await hashToken(token);
1492
+ const record = await ctx.db.findOne<AccessTokenRecord>({
1493
+ model: 'oauth_access_token',
1494
+ where: [{ field: 'token', operator: '=', value: tokenHash }],
1495
+ });
1496
+
1497
+ if (!record || !record.userId || record.expiresAt < new Date())
1498
+ return null;
1499
+
1500
+ const user = await ctx.db.findOne<FortressUser>({
1501
+ model: 'user',
1502
+ where: [{ field: 'id', operator: '=', value: record.userId }],
1503
+ });
1504
+ if (!user)
1505
+ return null;
1506
+ return { user, scope: record.scope };
1507
+ },
1508
+
1509
+ // --- HTTP handler methods (RFC 6749 / 7662 / 7009) ---
1510
+
1511
+ /**
1512
+ * Handle POST /oauth/token — dispatches to exchangeCode or clientCredentialsGrant.
1513
+ */
1514
+ async handleTokenRequest(
1515
+ body: TokenRequestBody,
1516
+ clientAuth?: ClientAuth,
1517
+ ): Promise<Record<string, unknown>> {
1518
+ const clientId = clientAuth?.clientId ?? body.client_id;
1519
+ const clientSecret = clientAuth?.clientSecret ?? body.client_secret;
1520
+
1521
+ if (!clientId)
1522
+ throw Errors.oauth('invalid_client', 'Client authentication required');
1523
+
1524
+ // Resolve client to know whether it's public; we only short-circuit
1525
+ // the secret check if the registered method is `'none'`.
1526
+ const clientRecord = await ctx.db.findOne<OAuthClientRecord>({
1527
+ model: 'oauth_client',
1528
+ where: [{ field: 'clientId', operator: '=', value: clientId }],
1529
+ });
1530
+ if (!clientRecord)
1531
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
1532
+ const isPublic = clientRecord.tokenEndpointAuthMethod === 'none';
1533
+ if (!isPublic && !clientSecret)
1534
+ throw Errors.oauth('invalid_client', 'Client authentication required');
1535
+
1536
+ if (body.grant_type === 'authorization_code') {
1537
+ if (!body.code || !body.redirect_uri)
1538
+ throw Errors.oauth('invalid_request', 'Missing required parameters: code, redirect_uri');
1539
+ // RFC 7636 §4.1: PKCE verifier is REQUIRED for public clients.
1540
+ if (isPublic && !body.code_verifier)
1541
+ throw Errors.oauth('invalid_grant', 'code_verifier required for public clients');
1542
+
1543
+ const result = await this.exchangeCode({
1544
+ code: body.code,
1545
+ clientId,
1546
+ clientSecret: isPublic ? undefined : clientSecret,
1547
+ redirectUri: body.redirect_uri,
1548
+ codeVerifier: body.code_verifier,
1549
+ }) as { accessToken: string; tokenType: string; expiresIn: number; scope?: string; refreshToken?: string; idToken?: string };
1550
+
1551
+ return {
1552
+ access_token: result.accessToken,
1553
+ token_type: result.tokenType,
1554
+ expires_in: result.expiresIn,
1555
+ ...(result.refreshToken ? { refresh_token: result.refreshToken } : {}),
1556
+ ...(result.idToken ? { id_token: result.idToken } : {}),
1557
+ ...(result.scope ? { scope: result.scope } : {}),
1558
+ };
1559
+ }
1560
+
1561
+ if (body.grant_type === 'refresh_token') {
1562
+ if (!body.refresh_token)
1563
+ throw Errors.oauth('invalid_request', 'refresh_token is required');
1564
+ const result = await this.refreshTokenGrant({
1565
+ clientId,
1566
+ clientSecret: isPublic ? undefined : clientSecret,
1567
+ refreshToken: body.refresh_token,
1568
+ scope: body.scope,
1569
+ });
1570
+ return {
1571
+ access_token: result.accessToken,
1572
+ token_type: result.tokenType,
1573
+ expires_in: result.expiresIn,
1574
+ refresh_token: result.refreshToken,
1575
+ ...(result.idToken ? { id_token: result.idToken } : {}),
1576
+ ...(result.scope ? { scope: result.scope } : {}),
1577
+ };
1578
+ }
1579
+
1580
+ if (body.grant_type === 'client_credentials') {
1581
+ if (!clientSecret)
1582
+ throw Errors.oauth('invalid_client', 'client_credentials requires a client secret');
1583
+ const result = await this.clientCredentialsGrant({
1584
+ clientId,
1585
+ clientSecret,
1586
+ scope: body.scope,
1587
+ });
1588
+
1589
+ return {
1590
+ access_token: result.accessToken,
1591
+ token_type: result.tokenType,
1592
+ expires_in: result.expiresIn,
1593
+ };
1594
+ }
1595
+
1596
+ throw Errors.oauth('unsupported_grant_type', `Unsupported grant_type: ${body.grant_type}`);
1597
+ },
1598
+
1599
+ /**
1600
+ * Handle POST /oauth/introspect (RFC 7662).
1601
+ */
1602
+ async handleIntrospectRequest(
1603
+ body: { token: string },
1604
+ clientAuth: ClientAuth,
1605
+ ): Promise<Record<string, unknown>> {
1606
+ // Validate client credentials
1607
+ const client = await ctx.db.findOne<OAuthClientRecord>({
1608
+ model: 'oauth_client',
1609
+ where: [{ field: 'clientId', operator: '=', value: clientAuth.clientId }],
1610
+ });
1611
+
1612
+ if (!client)
1613
+ throw Errors.unauthorized('Invalid client credentials');
1614
+
1615
+ const secretValid = timingSafeEqualHex(
1616
+ await hashToken(clientAuth.clientSecret),
1617
+ client.clientSecretHash,
1618
+ );
1619
+ if (!secretValid)
1620
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
1621
+
1622
+ const result = await this.introspectToken(body.token);
1623
+
1624
+ if (!result.active || result.clientId !== clientAuth.clientId)
1625
+ return { active: false };
1626
+
1627
+ return {
1628
+ active: true,
1629
+ client_id: result.clientId,
1630
+ ...(result.userId != null ? { sub: String(result.userId) } : {}),
1631
+ ...(result.scope ? { scope: result.scope } : {}),
1632
+ token_type: 'Bearer',
1633
+ };
1634
+ },
1635
+
1636
+ /**
1637
+ * Handle POST /oauth/revoke (RFC 7009). Always returns success.
1638
+ */
1639
+ async handleRevokeRequest(body: { token: string }, clientAuth: ClientAuth): Promise<void> {
1640
+ const client = await ctx.db.findOne<OAuthClientRecord>({
1641
+ model: 'oauth_client',
1642
+ where: [{ field: 'clientId', operator: '=', value: clientAuth.clientId }],
1643
+ });
1644
+ if (!client || !timingSafeEqualHex(await hashToken(clientAuth.clientSecret), client.clientSecretHash))
1645
+ throw Errors.oauth('invalid_client', 'Invalid client credentials');
1646
+
1647
+ const tokenHash = await hashToken(body.token);
1648
+ // RFC 7009 prevents one authenticated client from revoking another
1649
+ // client's credentials, while still returning success for unknown
1650
+ // tokens to avoid an oracle.
1651
+ await ctx.db.delete({
1652
+ model: 'oauth_access_token',
1653
+ where: [
1654
+ { field: 'token', operator: '=', value: tokenHash },
1655
+ { field: 'clientId', operator: '=', value: clientAuth.clientId },
1656
+ ],
1657
+ });
1658
+ if (refreshEnabled) {
1659
+ const refreshRecord = await ctx.db.findOne<RefreshTokenRecord>({
1660
+ model: 'oauth_refresh_token',
1661
+ where: [
1662
+ { field: 'token', operator: '=', value: tokenHash },
1663
+ { field: 'clientId', operator: '=', value: clientAuth.clientId },
1664
+ ],
1665
+ });
1666
+ if (refreshRecord) {
1667
+ await ctx.db.delete({
1668
+ model: 'oauth_refresh_token',
1669
+ where: [
1670
+ { field: 'familyId', operator: '=', value: refreshRecord.familyId },
1671
+ { field: 'clientId', operator: '=', value: clientAuth.clientId },
1672
+ ],
1673
+ });
1674
+ }
1675
+ }
1676
+ },
1677
+
1678
+ /**
1679
+ * Handle GET /oauth/userinfo (OpenID Connect Core §5.3).
1680
+ *
1681
+ * Returns the OIDC-shaped claims object expected by strict RPs (Moodle
1682
+ * `core\oauth2\client::get_userinfo()`, openid-client, Keycloak
1683
+ * federation, Spring Security). The pre-1.0 fortress shape that
1684
+ * leaked DB-internal fields (`id`, `isActive`, `createdAt`) is gone
1685
+ * — callers must rely on the spec-conformant fields below.
1686
+ *
1687
+ * Standard claim coverage (OIDC Core §5.1) gated by access-token
1688
+ * scope (§5.4):
1689
+ * - `sub` always (stringified `user.id`)
1690
+ * - `email`, `email_verified` only when scope contains `email`
1691
+ * - `name`, `preferred_username` only when scope contains `profile`
1692
+ * - `updated_at` always (Unix seconds, per §5.1)
1693
+ *
1694
+ * Extra claims can be added per-deployment via
1695
+ * {@link OAuthConfig.userinfoClaims}.
1696
+ */
1697
+ async handleUserInfoRequest(bearerToken: string): Promise<Record<string, unknown>> {
1698
+ const result = await this._lookupBearer(bearerToken);
1699
+ if (!result)
1700
+ throw Errors.unauthorized('Invalid or expired access token');
1701
+
1702
+ const claims = toOidcUserinfo(result.user, result.scope);
1703
+ if (config.userinfoClaims) {
1704
+ const extra = await config.userinfoClaims(result.user, result.scope);
1705
+ Object.assign(claims, extra);
1706
+ }
1707
+ return claims;
1708
+ },
1709
+
1710
+ /**
1711
+ * Handle GET /.well-known/openid-configuration (RFC 8414 + OIDC
1712
+ * Discovery 1.0). Includes the RFC 9207 metadata flag and the OIDC
1713
+ * fields strict RPs (Moodle, openid-client, Spring Security) require
1714
+ * for autoconfig.
1715
+ */
1716
+ handleDiscovery(): Record<string, unknown> {
1717
+ const issuer = issuerUrl;
1718
+ const mappedScopes = scopePermissionMap ? Object.keys(scopePermissionMap) : [];
1719
+ const scopesSupported = Array.from(new Set([
1720
+ 'openid',
1721
+ 'email',
1722
+ 'profile',
1723
+ ...(config.scopesSupported ?? []),
1724
+ ...mappedScopes,
1725
+ ]));
1726
+ return {
1727
+ issuer,
1728
+ authorization_endpoint: `${issuer}/oauth/authorize`,
1729
+ token_endpoint: `${issuer}/oauth/token`,
1730
+ introspection_endpoint: `${issuer}/oauth/introspect`,
1731
+ revocation_endpoint: `${issuer}/oauth/revoke`,
1732
+ userinfo_endpoint: `${issuer}/oauth/userinfo`,
1733
+ jwks_uri: `${issuer}/oauth/.well-known/jwks.json`,
1734
+ response_types_supported: ['code'],
1735
+ response_modes_supported: ['query'],
1736
+ grant_types_supported: refreshEnabled
1737
+ ? ['authorization_code', 'client_credentials', 'refresh_token']
1738
+ : ['authorization_code', 'client_credentials'],
1739
+ token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
1740
+ code_challenge_methods_supported: ['S256'],
1741
+ subject_types_supported: ['public'],
1742
+ // OIDC Discovery §3 REQUIRED when id_tokens are issued.
1743
+ id_token_signing_alg_values_supported: ['RS256'],
1744
+ scopes_supported: scopesSupported,
1745
+ claims_supported: [
1746
+ 'sub',
1747
+ 'email',
1748
+ 'email_verified',
1749
+ 'name',
1750
+ 'preferred_username',
1751
+ 'updated_at',
1752
+ 'iss',
1753
+ 'aud',
1754
+ 'exp',
1755
+ 'iat',
1756
+ 'auth_time',
1757
+ 'nonce',
1758
+ ],
1759
+ // RFC 9207 §3.
1760
+ authorization_response_iss_parameter_supported: true,
1761
+ };
1762
+ },
1763
+
1764
+ /**
1765
+ * Handle GET /oauth/.well-known/jwks.json (RFC 7517).
1766
+ *
1767
+ * Returns the AS's JSON Web Key Set: the active RS256 public key
1768
+ * plus any rotated keys still in the verification grace window. RPs
1769
+ * fetch this URL (advertised via discovery's `jwks_uri`) and use it
1770
+ * to verify id_token signatures by `kid`.
1771
+ */
1772
+ async rotateSigningKey(): Promise<{ kid: string }> {
1773
+ const key = await rotateSigningKey(ctx.db, signingKeyGraceSeconds);
1774
+ return { kid: key.kid };
1775
+ },
1776
+
1777
+ async handleJwksRequest(): Promise<Record<string, unknown>> {
1778
+ const jwks = await listJwks(ctx.db, signingKeyGraceSeconds);
1779
+ // First call materialises the active key if none exists yet —
1780
+ // ensures discovery + JWKS stay in sync without a deploy step.
1781
+ if (jwks.keys.length === 0) {
1782
+ await getActiveSigningKey(ctx.db);
1783
+ return await listJwks(ctx.db, signingKeyGraceSeconds);
1784
+ }
1785
+ return jwks;
1786
+ },
1787
+
1788
+ /**
1789
+ * Resolve an access token's scopes to IAM permissions using the configured scopePermissionMap.
1790
+ */
1791
+ async resolveTokenPermissions(token: string): Promise<{ resource: string; action: string }[]> {
1792
+ const info = await this.introspectToken(token);
1793
+ if (!info.active || !info.scope || !scopePermissionMap)
1794
+ return [];
1795
+
1796
+ const scopes = info.scope.split(' ');
1797
+ const permissions: { resource: string; action: string }[] = [];
1798
+
1799
+ for (const scope of scopes) {
1800
+ const mapping = scopePermissionMap[scope];
1801
+ if (mapping) {
1802
+ permissions.push(mapping);
1803
+ }
1804
+ }
1805
+
1806
+ return permissions;
1807
+ },
1808
+ }),
1809
+
1810
+ routes: {
1811
+ // Front door for the auth-code flow. Opt-in: only mounted when the
1812
+ // host app has wired up `loginUrl` and `consentUrl`.
1813
+ ...(config.enableAuthorizeEndpoint
1814
+ ? {
1815
+ handleAuthorizeRequest: {
1816
+ method: 'GET' as const,
1817
+ path: '/oauth/authorize',
1818
+ handler: 'handleAuthorizeRequest',
1819
+ meta: {
1820
+ summary: 'Start an OAuth authorization-code flow',
1821
+ tags: ['OAuth'],
1822
+ security: ['none'] as ('none' | 'basic' | 'bearer')[],
1823
+ bearerKind: 'oauth' as const,
1824
+ },
1825
+ input: {
1826
+ query: {
1827
+ type: 'object' as const,
1828
+ properties: {
1829
+ client_id: { type: 'string' as const, description: 'Registered OAuth client ID' },
1830
+ redirect_uri: { type: 'string' as const, format: 'uri', description: 'Must match a redirect URI registered with the client' },
1831
+ response_type: { type: 'string' as const, enum: ['code'], description: 'Only "code" is supported' },
1832
+ scope: { type: 'string' as const, description: 'Space-separated requested scopes' },
1833
+ state: { type: 'string' as const, description: 'Opaque value returned to the client; required to prevent CSRF' },
1834
+ code_challenge: { type: 'string' as const, description: 'PKCE code challenge (recommended for public clients)' },
1835
+ code_challenge_method: { type: 'string' as const, enum: ['S256'], description: 'PKCE method — only S256 is supported' },
1836
+ },
1837
+ required: ['client_id', 'redirect_uri', 'response_type', 'state'],
1838
+ },
1839
+ },
1840
+ responses: {
1841
+ 302: { description: 'Redirect to login (unauthenticated) or consent (authenticated) page with ?flow=<id>' },
1842
+ 400: { description: 'Invalid request' },
1843
+ },
1844
+ },
1845
+ }
1846
+ : {}),
1847
+ // Consent API — SPA-friendly flow inspection + approve/deny.
1848
+ ...(config.enableConsentApi
1849
+ ? {
1850
+ handleGetFlow: {
1851
+ method: 'GET' as const,
1852
+ path: '/oauth/flows/:flowId',
1853
+ handler: 'handleGetFlow',
1854
+ meta: {
1855
+ summary: 'Fetch pending OAuth flow metadata',
1856
+ tags: ['OAuth'],
1857
+ security: ['bearer'] as ('none' | 'basic' | 'bearer')[],
1858
+ },
1859
+ responses: {
1860
+ 200: {
1861
+ description: 'Flow metadata',
1862
+ schema: {
1863
+ type: 'object' as const,
1864
+ properties: {
1865
+ flowId: { type: 'string' as const },
1866
+ client: {
1867
+ type: 'object' as const,
1868
+ properties: {
1869
+ clientId: { type: 'string' as const },
1870
+ name: { type: 'string' as const },
1871
+ },
1872
+ },
1873
+ redirectUri: { type: 'string' as const },
1874
+ scopes: { type: 'array' as const, items: { type: 'string' as const } },
1875
+ state: { type: 'string' as const },
1876
+ },
1877
+ },
1878
+ },
1879
+ 401: { description: 'Authentication required' },
1880
+ 404: { description: 'Flow not found' },
1881
+ },
1882
+ },
1883
+ handleApproveFlow: {
1884
+ method: 'POST' as const,
1885
+ path: '/oauth/flows/:flowId/approve',
1886
+ handler: 'handleApproveFlow',
1887
+ meta: {
1888
+ summary: 'Approve a pending OAuth flow',
1889
+ tags: ['OAuth'],
1890
+ security: ['bearer'] as ('none' | 'basic' | 'bearer')[],
1891
+ },
1892
+ responses: {
1893
+ 200: {
1894
+ description: 'Authorization code issued',
1895
+ schema: {
1896
+ type: 'object' as const,
1897
+ properties: { redirectUrl: { type: 'string' as const } },
1898
+ },
1899
+ },
1900
+ 401: { description: 'Authentication required' },
1901
+ 404: { description: 'Flow not found' },
1902
+ },
1903
+ },
1904
+ handleDenyFlow: {
1905
+ method: 'POST' as const,
1906
+ path: '/oauth/flows/:flowId/deny',
1907
+ handler: 'handleDenyFlow',
1908
+ meta: {
1909
+ summary: 'Deny a pending OAuth flow',
1910
+ tags: ['OAuth'],
1911
+ security: ['bearer'] as ('none' | 'basic' | 'bearer')[],
1912
+ },
1913
+ responses: {
1914
+ 200: {
1915
+ description: 'Flow denied',
1916
+ schema: {
1917
+ type: 'object' as const,
1918
+ properties: { redirectUrl: { type: 'string' as const } },
1919
+ },
1920
+ },
1921
+ 401: { description: 'Authentication required' },
1922
+ 404: { description: 'Flow not found' },
1923
+ },
1924
+ },
1925
+ }
1926
+ : {}),
1927
+ handleTokenRequest: {
1928
+ method: 'POST',
1929
+ path: '/oauth/token',
1930
+ handler: 'handleTokenRequest',
1931
+ meta: { summary: 'Exchange credentials for tokens', tags: ['OAuth'], security: ['basic'], bearerKind: 'oauth' as const },
1932
+ input: {
1933
+ body: {
1934
+ type: 'object',
1935
+ properties: {
1936
+ grant_type: { type: 'string', enum: ['authorization_code', 'client_credentials', 'refresh_token'], description: 'OAuth grant type' },
1937
+ code: { type: 'string', description: 'Authorization code' },
1938
+ redirect_uri: { type: 'string', format: 'uri', description: 'Redirect URI used in authorize' },
1939
+ client_id: { type: 'string', description: 'Client ID (if not using Basic auth)' },
1940
+ client_secret: { type: 'string', description: 'Client secret (if not using Basic auth)' },
1941
+ code_verifier: { type: 'string', description: 'PKCE code verifier' },
1942
+ refresh_token: { type: 'string', description: 'Refresh token for grant_type=refresh_token' },
1943
+ scope: { type: 'string', description: 'Space-separated scopes' },
1944
+ },
1945
+ required: ['grant_type'],
1946
+ },
1947
+ },
1948
+ responses: {
1949
+ 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' } } } },
1950
+ 400: { description: 'Invalid request' },
1951
+ 401: { description: 'Invalid client credentials' },
1952
+ },
1953
+ },
1954
+ handleIntrospectRequest: {
1955
+ method: 'POST',
1956
+ path: '/oauth/introspect',
1957
+ handler: 'handleIntrospectRequest',
1958
+ meta: { summary: 'Introspect a token (RFC 7662)', tags: ['OAuth'], security: ['basic'], bearerKind: 'oauth' as const },
1959
+ input: { body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] } },
1960
+ responses: {
1961
+ 200: { description: 'Token info', schema: { type: 'object', properties: { active: { type: 'boolean' }, sub: { type: 'string' }, scope: { type: 'string' }, exp: { type: 'number' } } } },
1962
+ 401: { description: 'Client authentication required' },
1963
+ },
1964
+ },
1965
+ handleRevokeRequest: {
1966
+ method: 'POST',
1967
+ path: '/oauth/revoke',
1968
+ handler: 'handleRevokeRequest',
1969
+ meta: { summary: 'Revoke a token (RFC 7009)', tags: ['OAuth'], security: ['basic'], bearerKind: 'oauth' as const },
1970
+ input: { body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] } },
1971
+ responses: { 200: { description: 'Token revoked' } },
1972
+ },
1973
+ handleUserInfoRequest: {
1974
+ method: 'GET',
1975
+ path: '/oauth/userinfo',
1976
+ handler: 'handleUserInfoRequest',
1977
+ meta: { summary: 'Get user info (OIDC Core §5.3)', tags: ['OAuth'], security: ['bearer'], bearerKind: 'oauth' as const },
1978
+ responses: {
1979
+ 200: {
1980
+ description: 'OIDC userinfo response',
1981
+ schema: {
1982
+ type: 'object',
1983
+ properties: {
1984
+ sub: { type: 'string', description: 'Stringified user identifier' },
1985
+ email: { type: 'string', format: 'email' },
1986
+ email_verified: { type: 'boolean' },
1987
+ name: { type: 'string' },
1988
+ preferred_username: { type: 'string' },
1989
+ updated_at: { type: 'number', description: 'Unix seconds since the user record was last updated' },
1990
+ },
1991
+ required: ['sub'],
1992
+ additionalProperties: true,
1993
+ },
1994
+ },
1995
+ 401: { description: 'Invalid or expired bearer token' },
1996
+ },
1997
+ },
1998
+ handleDiscovery: {
1999
+ method: 'GET',
2000
+ path: '/oauth/.well-known/openid-configuration',
2001
+ handler: 'handleDiscovery',
2002
+ meta: { summary: 'OIDC discovery document', tags: ['OAuth'], security: ['none'], bearerKind: 'oauth' as const },
2003
+ responses: { 200: { description: 'OIDC configuration', schema: { type: 'object', additionalProperties: true } } },
2004
+ },
2005
+ handleJwksRequest: {
2006
+ method: 'GET',
2007
+ path: '/oauth/.well-known/jwks.json',
2008
+ handler: 'handleJwksRequest',
2009
+ meta: { summary: 'JSON Web Key Set (RFC 7517)', tags: ['OAuth'], security: ['none'], bearerKind: 'oauth' as const },
2010
+ responses: {
2011
+ 200: {
2012
+ description: 'JWKS for verifying id_token signatures',
2013
+ schema: {
2014
+ type: 'object',
2015
+ properties: {
2016
+ keys: {
2017
+ type: 'array',
2018
+ items: { type: 'object', additionalProperties: true },
2019
+ },
2020
+ },
2021
+ required: ['keys'],
2022
+ },
2023
+ },
2024
+ },
2025
+ },
2026
+ },
2027
+ };
2028
+ }
2029
+
2030
+ /**
2031
+ * RFC 8252 §8.4 redirect-URI matcher with the loopback exception.
2032
+ *
2033
+ * Confidential clients still get exact-match (the registered URI must equal
2034
+ * the inbound URI byte-for-byte). Native / public clients that registered an
2035
+ * `http://127.0.0.1/<path>` or `http://[::1]/<path>` redirect MUST be allowed
2036
+ * to vary the port at runtime, because the loopback HTTP server picks one
2037
+ * dynamically. Path, query, and fragment still have to match.
2038
+ *
2039
+ * This is intentionally narrow: only `127.0.0.1` and `[::1]` get the
2040
+ * any-port leniency. `localhost` (DNS-resolved) is NOT widened — RFC 8252
2041
+ * §8.3 actively recommends against it for security reasons (DNS rebinding).
2042
+ */
2043
+ export function matchRedirectUri(registered: string, inbound: string): boolean {
2044
+ if (registered === inbound)
2045
+ return true;
2046
+ let r: URL, i: URL;
2047
+ try {
2048
+ r = new URL(registered);
2049
+ i = new URL(inbound);
2050
+ }
2051
+ catch {
2052
+ return false;
2053
+ }
2054
+ const isLoopback = r.protocol === 'http:'
2055
+ && (r.hostname === '127.0.0.1' || r.hostname === '[::1]')
2056
+ && r.hostname === i.hostname;
2057
+ if (!isLoopback)
2058
+ return false;
2059
+ return (
2060
+ r.protocol === i.protocol
2061
+ && r.pathname === i.pathname
2062
+ && r.search === i.search
2063
+ );
2064
+ }
2065
+
2066
+ /**
2067
+ * RFC 6749 §3.3 scope-intersection helper.
2068
+ *
2069
+ * - When `clientAllowed` is `null` (legacy / unset), the request scope is
2070
+ * passed through unchanged.
2071
+ * - When `clientAllowed` is `[]`, no scope is allowed — returns `''`.
2072
+ * - Otherwise returns the intersection as a space-separated string,
2073
+ * preserving the original ordering of the requested scopes (so RPs see
2074
+ * the scopes back in the order they asked for, minus the dropped ones).
2075
+ */
2076
+ function pendingFlowLookupWhere(flowId: string | number): WhereClause[] {
2077
+ // Accept numeric ids for direct legacy/test calls, but every newly-created
2078
+ // flow returns and routes by the opaque `flowId` token. This closes the
2079
+ // enumerable consent-flow IDOR vector without breaking internal migration
2080
+ // tooling that may still have an integer id in hand.
2081
+ return typeof flowId === 'number'
2082
+ ? [{ field: 'id', operator: '=', value: flowId }]
2083
+ : [{ field: 'flowId', operator: '=', value: flowId }];
2084
+ }
2085
+
2086
+ function intersectScope(
2087
+ requested: string | null,
2088
+ clientAllowedJson: string | null | undefined,
2089
+ ): string {
2090
+ // No requested scope: empty result, regardless of client allow-list.
2091
+ if (!requested)
2092
+ return '';
2093
+ // Legacy v0 clients (created before `allowedScopes` existed) have a
2094
+ // missing column — the adapter returns `null` or `undefined`. Treat
2095
+ // both as "unset" and pass scope through.
2096
+ if (clientAllowedJson == null)
2097
+ return requested;
2098
+ const allowed = JSON.parse(clientAllowedJson) as string[];
2099
+ const allowedSet = new Set(allowed);
2100
+ return requested
2101
+ .split(' ')
2102
+ .filter(Boolean)
2103
+ .filter(s => allowedSet.has(s))
2104
+ .join(' ');
2105
+ }
2106
+
2107
+ export { generateCodeChallenge, generateCodeVerifier, verifyCodeChallenge } from './pkce';
2108
+
2109
+ /**
2110
+ * Map a fortress user record to an OIDC-Core-§5.1 claims object, gated by
2111
+ * the access token's scope per §5.4.
2112
+ *
2113
+ * Exported so host apps with a custom `handleUserInfoRequest` (e.g. one
2114
+ * that adds tenant claims) can compose on top of the same baseline rather
2115
+ * than re-implementing the OIDC shape from scratch.
2116
+ */
2117
+ export function toOidcUserinfo(
2118
+ user: FortressUser,
2119
+ scope: string | null | undefined,
2120
+ ): Record<string, unknown> {
2121
+ const scopes = scope ? scope.split(' ').filter(Boolean) : [];
2122
+ // Identity claims are always scope-gated, including when a generic OAuth
2123
+ // token calls this endpoint. Possessing an unrelated API scope must not
2124
+ // disclose profile PII.
2125
+ const exposeEmail = scopes.includes('email');
2126
+ const exposeProfile = scopes.includes('profile');
2127
+
2128
+ const claims: Record<string, unknown> = { sub: String(user.id) };
2129
+ if (exposeEmail) {
2130
+ claims.email = user.email;
2131
+ if (typeof user.emailVerified === 'boolean')
2132
+ claims.email_verified = user.emailVerified;
2133
+ }
2134
+ if (exposeProfile) {
2135
+ claims.name = user.name;
2136
+ // OIDC has no canonical "username" field on a fortress user record
2137
+ // (email serves that role); use email as the stable, human-readable
2138
+ // identifier RPs expect in `preferred_username`.
2139
+ claims.preferred_username = user.email;
2140
+ }
2141
+ if (user.updatedAt) {
2142
+ claims.updated_at = Math.floor(new Date(user.updatedAt).getTime() / 1000);
2143
+ }
2144
+ return claims;
2145
+ }