@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,2220 @@
1
+ import type { DatabaseAdapter } from '../../adapters/database';
2
+ import type { OAuthMethods } from './index';
3
+ import { decodeJwt, importJWK, jwtVerify } from 'jose';
4
+ import { afterAll, beforeEach, describe, expect, it } from 'vitest';
5
+ import { generateRefreshToken } from '../../core/auth/refresh-token';
6
+ import { createTestAdapter } from '../../testing';
7
+ import { generateCodeChallenge, generateCodeVerifier, matchRedirectUri, oauth, toOidcUserinfo } from './index';
8
+
9
+ describe('oauth plugin', () => {
10
+ let db: DatabaseAdapter;
11
+ let methods: OAuthMethods;
12
+ let userId: string;
13
+
14
+ beforeEach(async () => {
15
+ db = createTestAdapter();
16
+
17
+ const plugin = oauth({
18
+ authCodeExpirySeconds: 600,
19
+ accessTokenExpirySeconds: 3600,
20
+ issuerUrl: 'https://auth.example.com',
21
+ scopePermissionMap: {
22
+ 'read:posts': { resource: 'post', action: 'read' },
23
+ 'write:posts': { resource: 'post', action: 'create' },
24
+ },
25
+ });
26
+ methods = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as OAuthMethods;
27
+
28
+ // Create a test user
29
+ const user = await db.create<{ id: string }>({
30
+ model: 'user',
31
+ data: { email: 'alice@example.com', name: 'Alice', passwordHash: 'hash', isActive: true },
32
+ });
33
+ userId = user.id;
34
+ });
35
+
36
+ describe('createClient', () => {
37
+ it('creates a client with id and secret', async () => {
38
+ const client = await methods.createClient({
39
+ name: 'Test App',
40
+ redirectUris: ['https://app.com/callback'],
41
+ grantTypes: ['authorization_code'],
42
+ });
43
+
44
+ expect(client.clientId).toBeTruthy();
45
+ expect(client.clientSecret!).toBeTruthy();
46
+ });
47
+ });
48
+
49
+ describe('authorization code flow', () => {
50
+ it('creates and exchanges an authorization code', async () => {
51
+ const client = await methods.createClient({
52
+ name: 'Test App',
53
+ redirectUris: ['https://app.com/callback'],
54
+ grantTypes: ['authorization_code'],
55
+ });
56
+
57
+ const { code } = await methods.createAuthorizationCode({
58
+ clientId: client.clientId,
59
+ userId,
60
+ redirectUri: 'https://app.com/callback',
61
+ });
62
+
63
+ const tokens = await methods.exchangeCode({
64
+ code,
65
+ clientId: client.clientId,
66
+ clientSecret: client.clientSecret!,
67
+ redirectUri: 'https://app.com/callback',
68
+ });
69
+
70
+ expect(tokens.accessToken).toBeTruthy();
71
+ expect(tokens.tokenType).toBe('Bearer');
72
+ expect(tokens.expiresIn).toBe(3600);
73
+ });
74
+
75
+ it('rejects reused authorization code', async () => {
76
+ const client = await methods.createClient({
77
+ name: 'Test App',
78
+ redirectUris: ['https://app.com/callback'],
79
+ grantTypes: ['authorization_code'],
80
+ });
81
+
82
+ const { code } = await methods.createAuthorizationCode({
83
+ clientId: client.clientId,
84
+ userId,
85
+ redirectUri: 'https://app.com/callback',
86
+ });
87
+
88
+ await methods.exchangeCode({
89
+ code,
90
+ clientId: client.clientId,
91
+ clientSecret: client.clientSecret!,
92
+ redirectUri: 'https://app.com/callback',
93
+ });
94
+
95
+ await expect(methods.exchangeCode({
96
+ code,
97
+ clientId: client.clientId,
98
+ clientSecret: client.clientSecret!,
99
+ redirectUri: 'https://app.com/callback',
100
+ })).rejects.toThrow('already used');
101
+ });
102
+
103
+ it('validates redirect_uri match', async () => {
104
+ const client = await methods.createClient({
105
+ name: 'Test App',
106
+ redirectUris: ['https://app.com/callback'],
107
+ grantTypes: ['authorization_code'],
108
+ });
109
+
110
+ await expect(methods.createAuthorizationCode({
111
+ clientId: client.clientId,
112
+ userId,
113
+ redirectUri: 'https://evil.com/callback',
114
+ })).rejects.toThrow('Invalid redirect_uri');
115
+ });
116
+ });
117
+
118
+ describe('pkce validation', () => {
119
+ it('validates code_verifier against code_challenge', async () => {
120
+ const client = await methods.createClient({
121
+ name: 'PKCE App',
122
+ redirectUris: ['https://app.com/callback'],
123
+ grantTypes: ['authorization_code'],
124
+ });
125
+
126
+ const codeVerifier = generateCodeVerifier();
127
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
128
+
129
+ const { code } = await methods.createAuthorizationCode({
130
+ clientId: client.clientId,
131
+ userId,
132
+ redirectUri: 'https://app.com/callback',
133
+ codeChallenge,
134
+ codeChallengeMethod: 'S256',
135
+ });
136
+
137
+ const tokens = await methods.exchangeCode({
138
+ code,
139
+ clientId: client.clientId,
140
+ clientSecret: client.clientSecret!,
141
+ redirectUri: 'https://app.com/callback',
142
+ codeVerifier,
143
+ });
144
+
145
+ expect(tokens.accessToken).toBeTruthy();
146
+ });
147
+
148
+ it('rejects wrong code_verifier', async () => {
149
+ const client = await methods.createClient({
150
+ name: 'PKCE App',
151
+ redirectUris: ['https://app.com/callback'],
152
+ grantTypes: ['authorization_code'],
153
+ });
154
+
155
+ const codeVerifier = generateCodeVerifier();
156
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
157
+
158
+ const { code } = await methods.createAuthorizationCode({
159
+ clientId: client.clientId,
160
+ userId,
161
+ redirectUri: 'https://app.com/callback',
162
+ codeChallenge,
163
+ codeChallengeMethod: 'S256',
164
+ });
165
+
166
+ await expect(methods.exchangeCode({
167
+ code,
168
+ clientId: client.clientId,
169
+ clientSecret: client.clientSecret!,
170
+ redirectUri: 'https://app.com/callback',
171
+ codeVerifier: 'wrong-verifier',
172
+ })).rejects.toThrow('Invalid code_verifier');
173
+ });
174
+ });
175
+
176
+ describe('client credentials grant', () => {
177
+ it('issues token for service client', async () => {
178
+ const client = await methods.createClient({
179
+ name: 'Service',
180
+ redirectUris: [],
181
+ grantTypes: ['client_credentials'],
182
+ });
183
+
184
+ const tokens = await methods.clientCredentialsGrant({
185
+ clientId: client.clientId,
186
+ clientSecret: client.clientSecret!,
187
+ });
188
+
189
+ expect(tokens.accessToken).toBeTruthy();
190
+ expect(tokens.tokenType).toBe('Bearer');
191
+ });
192
+
193
+ it('rejects client without client_credentials grant type', async () => {
194
+ const client = await methods.createClient({
195
+ name: 'Web App',
196
+ redirectUris: ['https://app.com/callback'],
197
+ grantTypes: ['authorization_code'],
198
+ });
199
+
200
+ await expect(methods.clientCredentialsGrant({
201
+ clientId: client.clientId,
202
+ clientSecret: client.clientSecret!,
203
+ })).rejects.toThrow('does not support');
204
+ });
205
+ });
206
+
207
+ describe('token introspection', () => {
208
+ it('introspects a valid token', async () => {
209
+ const client = await methods.createClient({
210
+ name: 'Service',
211
+ redirectUris: [],
212
+ grantTypes: ['client_credentials'],
213
+ });
214
+
215
+ const { accessToken } = await methods.clientCredentialsGrant({
216
+ clientId: client.clientId,
217
+ clientSecret: client.clientSecret!,
218
+ });
219
+
220
+ const info = await methods.introspectToken(accessToken);
221
+ expect(info.active).toBe(true);
222
+ expect(info.clientId).toBe(client.clientId);
223
+ });
224
+
225
+ it('returns inactive for unknown token', async () => {
226
+ const info = await methods.introspectToken('nonexistent');
227
+ expect(info.active).toBe(false);
228
+ });
229
+ });
230
+
231
+ describe('token revocation', () => {
232
+ it('revokes a token', async () => {
233
+ const client = await methods.createClient({
234
+ name: 'Service',
235
+ redirectUris: [],
236
+ grantTypes: ['client_credentials'],
237
+ });
238
+
239
+ const { accessToken } = await methods.clientCredentialsGrant({
240
+ clientId: client.clientId,
241
+ clientSecret: client.clientSecret!,
242
+ });
243
+
244
+ await methods.revokeToken(accessToken);
245
+
246
+ const info = await methods.introspectToken(accessToken);
247
+ expect(info.active).toBe(false);
248
+ });
249
+ });
250
+
251
+ describe('pending flow (identity broker)', () => {
252
+ it('creates and resumes a pending flow', async () => {
253
+ const client = await methods.createClient({
254
+ name: 'Moodle',
255
+ redirectUris: ['https://lms.example.com/callback'],
256
+ grantTypes: ['authorization_code'],
257
+ });
258
+
259
+ const { flowId } = await methods.createPendingFlow({
260
+ clientId: client.clientId,
261
+ redirectUri: 'https://lms.example.com/callback',
262
+ state: 'random-state-123',
263
+ });
264
+
265
+ const flow = await methods.resumePendingFlow(flowId);
266
+ expect(flow.clientId).toBe(client.clientId);
267
+ expect(flow.state).toBe('random-state-123');
268
+ });
269
+
270
+ it('pending flow is single-use', async () => {
271
+ const client = await methods.createClient({
272
+ name: 'App',
273
+ redirectUris: ['https://app.com/callback'],
274
+ grantTypes: ['authorization_code'],
275
+ });
276
+
277
+ const { flowId } = await methods.createPendingFlow({
278
+ clientId: client.clientId,
279
+ redirectUri: 'https://app.com/callback',
280
+ state: 'state-1',
281
+ });
282
+
283
+ await methods.resumePendingFlow(flowId);
284
+
285
+ await expect(methods.resumePendingFlow(flowId)).rejects.toThrow('not found');
286
+ });
287
+
288
+ it('getPendingFlow reads without consuming', async () => {
289
+ const client = await methods.createClient({
290
+ name: 'App',
291
+ redirectUris: ['https://app.com/callback'],
292
+ grantTypes: ['authorization_code'],
293
+ });
294
+
295
+ const { flowId } = await methods.createPendingFlow({
296
+ clientId: client.clientId,
297
+ redirectUri: 'https://app.com/callback',
298
+ scope: 'read:posts',
299
+ state: 'consent-state',
300
+ });
301
+
302
+ // First read returns flow
303
+ const flow1 = await methods.getPendingFlow(flowId);
304
+ expect(flow1.clientId).toBe(client.clientId);
305
+ expect(flow1.scope).toBe('read:posts');
306
+
307
+ // Second read still works (non-destructive)
308
+ const flow2 = await methods.getPendingFlow(flowId);
309
+ expect(flow2.state).toBe('consent-state');
310
+
311
+ // resumePendingFlow still consumes it
312
+ await methods.resumePendingFlow(flowId);
313
+ await expect(methods.getPendingFlow(flowId)).rejects.toThrow('not found');
314
+ });
315
+
316
+ it('handleAuthorizeRequest redirects unauthenticated users to loginUrl', async () => {
317
+ const client = await methods.createClient({
318
+ name: 'Moodle',
319
+ redirectUris: ['https://lms.example.com/callback'],
320
+ grantTypes: ['authorization_code'],
321
+ });
322
+
323
+ // Re-init plugin with login/consent URLs.
324
+ const localDb = createTestAdapter();
325
+ const localPlugin = oauth({
326
+ loginUrl: 'https://app.example.com/signin',
327
+ consentUrl: 'https://app.example.com/oauth/consent',
328
+ });
329
+ const localMethods = localPlugin.methods!({ db: localDb, config: { jwt: { key: 'x'.repeat(32) }, database: localDb } }) as unknown as OAuthMethods;
330
+ // Re-create the client in the local DB.
331
+ await localDb.create({
332
+ model: 'oauth_client',
333
+ data: {
334
+ clientId: client.clientId,
335
+ clientSecretHash: 'irrelevant',
336
+ name: 'Moodle',
337
+ redirectUris: JSON.stringify(['https://lms.example.com/callback']),
338
+ grantTypes: JSON.stringify(['authorization_code']),
339
+ },
340
+ });
341
+
342
+ const verifier = generateCodeVerifier();
343
+ const challenge = await generateCodeChallenge(verifier);
344
+
345
+ const result = await localMethods.handleAuthorizeRequest(
346
+ {
347
+ client_id: client.clientId,
348
+ redirect_uri: 'https://lms.example.com/callback',
349
+ response_type: 'code',
350
+ state: 'xyz',
351
+ scope: 'read:posts',
352
+ code_challenge: challenge,
353
+ code_challenge_method: 'S256',
354
+ },
355
+ { userId: undefined },
356
+ );
357
+
358
+ expect(result.redirectUrl.startsWith('https://app.example.com/signin?flow=')).toBe(true);
359
+ expect(typeof result.flowId).toBe('string');
360
+ expect(result.flowId.length).toBeGreaterThan(20);
361
+
362
+ const flow = await localMethods.getPendingFlow(result.flowId);
363
+ expect(flow.clientId).toBe(client.clientId);
364
+ expect(flow.state).toBe('xyz');
365
+ });
366
+
367
+ it('handleAuthorizeRequest redirects authenticated users to consentUrl', async () => {
368
+ const client = await methods.createClient({
369
+ name: 'Moodle',
370
+ redirectUris: ['https://lms.example.com/callback'],
371
+ grantTypes: ['authorization_code'],
372
+ });
373
+ const localDb = createTestAdapter();
374
+ const localPlugin = oauth({
375
+ loginUrl: 'https://app.example.com/signin',
376
+ consentUrl: 'https://app.example.com/oauth/consent',
377
+ });
378
+ const localMethods = localPlugin.methods!({ db: localDb, config: { jwt: { key: 'x'.repeat(32) }, database: localDb } }) as unknown as OAuthMethods;
379
+ await localDb.create({
380
+ model: 'oauth_client',
381
+ data: {
382
+ clientId: client.clientId,
383
+ clientSecretHash: 'irrelevant',
384
+ name: 'Moodle',
385
+ redirectUris: JSON.stringify(['https://lms.example.com/callback']),
386
+ grantTypes: JSON.stringify(['authorization_code']),
387
+ },
388
+ });
389
+
390
+ const verifier = generateCodeVerifier();
391
+ const challenge = await generateCodeChallenge(verifier);
392
+
393
+ const result = await localMethods.handleAuthorizeRequest(
394
+ {
395
+ client_id: client.clientId,
396
+ redirect_uri: 'https://lms.example.com/callback',
397
+ response_type: 'code',
398
+ state: 'logged-in-state',
399
+ code_challenge: challenge,
400
+ code_challenge_method: 'S256',
401
+ },
402
+ { userId: '42' },
403
+ );
404
+
405
+ expect(result.redirectUrl.startsWith('https://app.example.com/oauth/consent?flow=')).toBe(true);
406
+ });
407
+
408
+ it('handleAuthorizeRequest rejects unknown clients and bad redirect URIs', async () => {
409
+ const localDb = createTestAdapter();
410
+ const localPlugin = oauth({
411
+ loginUrl: 'https://app.example.com/signin',
412
+ consentUrl: 'https://app.example.com/oauth/consent',
413
+ });
414
+ const localMethods = localPlugin.methods!({ db: localDb, config: { jwt: { key: 'x'.repeat(32) }, database: localDb } }) as unknown as OAuthMethods;
415
+
416
+ await expect(
417
+ localMethods.handleAuthorizeRequest(
418
+ {
419
+ client_id: 'nonexistent',
420
+ redirect_uri: 'https://x.com/cb',
421
+ response_type: 'code',
422
+ state: 's',
423
+ },
424
+ { userId: undefined },
425
+ ),
426
+ ).rejects.toThrow('Unknown client_id');
427
+
428
+ const c = await localMethods.createClient({
429
+ name: 'X',
430
+ redirectUris: ['https://x.com/callback'],
431
+ grantTypes: ['authorization_code'],
432
+ });
433
+ await expect(
434
+ localMethods.handleAuthorizeRequest(
435
+ {
436
+ client_id: c.clientId,
437
+ redirect_uri: 'https://evil.com/cb',
438
+ response_type: 'code',
439
+ state: 's',
440
+ },
441
+ { userId: undefined },
442
+ ),
443
+ ).rejects.toThrow('Invalid redirect_uri');
444
+ });
445
+
446
+ it('handleAuthorizeRequest throws when loginUrl/consentUrl are not configured', async () => {
447
+ const localDb = createTestAdapter();
448
+ const localPlugin = oauth({}); // no URLs
449
+ const localMethods = localPlugin.methods!({ db: localDb, config: { jwt: { key: 'x'.repeat(32) }, database: localDb } }) as unknown as OAuthMethods;
450
+
451
+ await expect(
452
+ localMethods.handleAuthorizeRequest(
453
+ {
454
+ client_id: 'whatever',
455
+ redirect_uri: 'https://x.com/cb',
456
+ response_type: 'code',
457
+ state: 's',
458
+ },
459
+ { userId: undefined },
460
+ ),
461
+ ).rejects.toThrow('loginUrl/consentUrl');
462
+ });
463
+
464
+ it('handleGetFlow returns flow metadata without leaking PKCE fields', async () => {
465
+ const client = await methods.createClient({
466
+ name: 'Brand X',
467
+ redirectUris: ['https://x.com/callback'],
468
+ grantTypes: ['authorization_code'],
469
+ });
470
+ const verifier = generateCodeVerifier();
471
+ const challenge = await generateCodeChallenge(verifier);
472
+ const { flowId } = await methods.createPendingFlow({
473
+ clientId: client.clientId,
474
+ redirectUri: 'https://x.com/callback',
475
+ scope: 'read:posts write:posts',
476
+ state: 's-1',
477
+ codeChallenge: challenge,
478
+ codeChallengeMethod: 'S256',
479
+ });
480
+
481
+ const meta = await methods.handleGetFlow(flowId, { userId });
482
+ expect(meta.flowId).toBe(flowId);
483
+ expect(meta.client.clientId).toBe(client.clientId);
484
+ expect(meta.client.name).toBe('Brand X');
485
+ expect(meta.scopes).toEqual(['read:posts', 'write:posts']);
486
+ expect(meta.state).toBe('s-1');
487
+ // Crucially — no challenge / method exposed.
488
+ expect((meta as Record<string, unknown>).codeChallenge).toBeUndefined();
489
+ expect((meta as Record<string, unknown>).codeChallengeMethod).toBeUndefined();
490
+ });
491
+
492
+ it('handleApproveFlow issues a code and returns a redirect URL', async () => {
493
+ const client = await methods.createClient({
494
+ name: 'App',
495
+ redirectUris: ['https://app.com/callback'],
496
+ grantTypes: ['authorization_code'],
497
+ });
498
+ const { flowId } = await methods.createPendingFlow({
499
+ clientId: client.clientId,
500
+ redirectUri: 'https://app.com/callback',
501
+ scope: 'read:posts',
502
+ state: 'approve-state',
503
+ });
504
+
505
+ const result = await methods.handleApproveFlow(flowId, { userId });
506
+
507
+ const url = new URL(result.redirectUrl);
508
+ expect(url.origin + url.pathname).toBe('https://app.com/callback');
509
+ expect(url.searchParams.get('state')).toBe('approve-state');
510
+ const code = url.searchParams.get('code');
511
+ expect(code).toBeTruthy();
512
+
513
+ // Flow should now be consumed.
514
+ await expect(methods.getPendingFlow(flowId)).rejects.toThrow('not found');
515
+
516
+ // The issued code should be exchangeable.
517
+ const tokens = await methods.exchangeCode({
518
+ code: code as string,
519
+ clientId: client.clientId,
520
+ clientSecret: 'wrong-secret',
521
+ redirectUri: 'https://app.com/callback',
522
+ }).catch(e => e);
523
+ // We don't have the real secret in this test; just confirm the code
524
+ // was actually persisted by making sure the failure is auth-related,
525
+ // not "code not found".
526
+ expect(String(tokens)).not.toMatch(/code/i);
527
+ });
528
+
529
+ it('handleDenyFlow consumes the flow and returns access_denied URL', async () => {
530
+ const client = await methods.createClient({
531
+ name: 'App',
532
+ redirectUris: ['https://app.com/callback'],
533
+ grantTypes: ['authorization_code'],
534
+ });
535
+ const { flowId } = await methods.createPendingFlow({
536
+ clientId: client.clientId,
537
+ redirectUri: 'https://app.com/callback',
538
+ state: 'deny-state',
539
+ });
540
+
541
+ const result = await methods.handleDenyFlow(flowId, { userId });
542
+ const url = new URL(result.redirectUrl);
543
+ expect(url.searchParams.get('error')).toBe('access_denied');
544
+ expect(url.searchParams.get('state')).toBe('deny-state');
545
+
546
+ await expect(methods.getPendingFlow(flowId)).rejects.toThrow('not found');
547
+ });
548
+
549
+ it('getPendingFlow throws on expired flow', async () => {
550
+ const client = await methods.createClient({
551
+ name: 'App',
552
+ redirectUris: ['https://app.com/callback'],
553
+ grantTypes: ['authorization_code'],
554
+ });
555
+
556
+ const { flowId } = await methods.createPendingFlow({
557
+ clientId: client.clientId,
558
+ redirectUri: 'https://app.com/callback',
559
+ state: 'state-x',
560
+ });
561
+
562
+ // Force expiry by mutating the row directly through the adapter.
563
+ await db.update({
564
+ model: 'oauth_pending_flow',
565
+ where: [{ field: 'flowId', operator: '=', value: flowId }],
566
+ data: { expiresAt: new Date(Date.now() - 1000) },
567
+ });
568
+
569
+ await expect(methods.getPendingFlow(flowId)).rejects.toThrow('expired');
570
+ });
571
+ });
572
+
573
+ describe('getUserInfo', () => {
574
+ it('returns user info for valid token', async () => {
575
+ const client = await methods.createClient({
576
+ name: 'App',
577
+ redirectUris: ['https://app.com/callback'],
578
+ grantTypes: ['authorization_code'],
579
+ });
580
+
581
+ const { code } = await methods.createAuthorizationCode({
582
+ clientId: client.clientId,
583
+ userId,
584
+ redirectUri: 'https://app.com/callback',
585
+ });
586
+
587
+ const { accessToken } = await methods.exchangeCode({
588
+ code,
589
+ clientId: client.clientId,
590
+ clientSecret: client.clientSecret!,
591
+ redirectUri: 'https://app.com/callback',
592
+ });
593
+
594
+ const user = await methods.getUserInfo(accessToken);
595
+ expect(user).not.toBeNull();
596
+ expect(user!.email).toBe('alice@example.com');
597
+ });
598
+
599
+ it('returns null for invalid token', async () => {
600
+ const user = await methods.getUserInfo('invalid-token');
601
+ expect(user).toBeNull();
602
+ });
603
+ });
604
+
605
+ describe('handleTokenRequest', () => {
606
+ it('handles authorization_code grant type', async () => {
607
+ const client = await methods.createClient({
608
+ name: 'App',
609
+ redirectUris: ['https://app.com/callback'],
610
+ grantTypes: ['authorization_code'],
611
+ });
612
+
613
+ const { code } = await methods.createAuthorizationCode({
614
+ clientId: client.clientId,
615
+ userId,
616
+ redirectUri: 'https://app.com/callback',
617
+ });
618
+
619
+ const result = await methods.handleTokenRequest(
620
+ { grant_type: 'authorization_code', code, redirect_uri: 'https://app.com/callback' },
621
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
622
+ );
623
+
624
+ expect(result.access_token).toBeTruthy();
625
+ expect(result.token_type).toBe('Bearer');
626
+ expect(result.expires_in).toBe(3600);
627
+ });
628
+
629
+ it('handles client_credentials grant type', async () => {
630
+ const client = await methods.createClient({
631
+ name: 'Service',
632
+ redirectUris: [],
633
+ grantTypes: ['client_credentials'],
634
+ });
635
+
636
+ const result = await methods.handleTokenRequest(
637
+ { grant_type: 'client_credentials' },
638
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
639
+ );
640
+
641
+ expect(result.access_token).toBeTruthy();
642
+ expect(result.token_type).toBe('Bearer');
643
+ });
644
+
645
+ it('reads client credentials from body when no auth header', async () => {
646
+ const client = await methods.createClient({
647
+ name: 'Service',
648
+ redirectUris: [],
649
+ grantTypes: ['client_credentials'],
650
+ });
651
+
652
+ const result = await methods.handleTokenRequest({
653
+ grant_type: 'client_credentials',
654
+ client_id: client.clientId,
655
+ client_secret: client.clientSecret!,
656
+ });
657
+
658
+ expect(result.access_token).toBeTruthy();
659
+ });
660
+
661
+ it('rejects unsupported grant_type', async () => {
662
+ const client = await methods.createClient({
663
+ name: 'Service',
664
+ redirectUris: [],
665
+ grantTypes: ['client_credentials'],
666
+ });
667
+ await expect(
668
+ methods.handleTokenRequest(
669
+ { grant_type: 'implicit' },
670
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
671
+ ),
672
+ ).rejects.toThrow('Unsupported grant_type');
673
+ });
674
+
675
+ it('rejects missing client authentication', async () => {
676
+ await expect(
677
+ methods.handleTokenRequest({ grant_type: 'client_credentials' }),
678
+ ).rejects.toThrow('Client authentication required');
679
+ });
680
+ });
681
+
682
+ describe('handleIntrospectRequest', () => {
683
+ it('returns active token info in RFC 7662 format', async () => {
684
+ const client = await methods.createClient({
685
+ name: 'Service',
686
+ redirectUris: [],
687
+ grantTypes: ['client_credentials'],
688
+ });
689
+
690
+ const { accessToken } = await methods.clientCredentialsGrant({
691
+ clientId: client.clientId,
692
+ clientSecret: client.clientSecret!,
693
+ scope: 'read:posts',
694
+ });
695
+
696
+ const result = await methods.handleIntrospectRequest(
697
+ { token: accessToken },
698
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
699
+ );
700
+
701
+ expect(result.active).toBe(true);
702
+ expect(result.client_id).toBe(client.clientId);
703
+ expect(result.token_type).toBe('Bearer');
704
+ expect(result.scope).toBe('read:posts');
705
+ });
706
+
707
+ it('returns inactive for revoked token', async () => {
708
+ const client = await methods.createClient({
709
+ name: 'Service',
710
+ redirectUris: [],
711
+ grantTypes: ['client_credentials'],
712
+ });
713
+
714
+ const { accessToken } = await methods.clientCredentialsGrant({
715
+ clientId: client.clientId,
716
+ clientSecret: client.clientSecret!,
717
+ });
718
+
719
+ await methods.revokeToken(accessToken);
720
+
721
+ const result = await methods.handleIntrospectRequest(
722
+ { token: accessToken },
723
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
724
+ );
725
+
726
+ expect(result.active).toBe(false);
727
+ });
728
+ });
729
+
730
+ describe('handleRevokeRequest', () => {
731
+ it('authenticates the client and always succeeds for an unknown token', async () => {
732
+ const client = await methods.createClient({
733
+ name: 'Revoker',
734
+ redirectUris: [],
735
+ grantTypes: ['client_credentials'],
736
+ });
737
+ // RFC 7009: authenticated revocation of an unknown token returns 200.
738
+ await expect(methods.handleRevokeRequest(
739
+ { token: 'nonexistent' },
740
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
741
+ )).resolves.toBeUndefined();
742
+ });
743
+ });
744
+
745
+ describe('handleDiscovery', () => {
746
+ it('returns OIDC discovery document', () => {
747
+ const doc = methods.handleDiscovery();
748
+
749
+ expect(doc.issuer).toBe('https://auth.example.com');
750
+ expect(doc.token_endpoint).toBe('https://auth.example.com/oauth/token');
751
+ expect(doc.introspection_endpoint).toBe('https://auth.example.com/oauth/introspect');
752
+ expect(doc.revocation_endpoint).toBe('https://auth.example.com/oauth/revoke');
753
+ expect(doc.userinfo_endpoint).toBe('https://auth.example.com/oauth/userinfo');
754
+ expect(doc.grant_types_supported).toContain('authorization_code');
755
+ expect(doc.grant_types_supported).toContain('client_credentials');
756
+ expect(doc.code_challenge_methods_supported).toContain('S256');
757
+ });
758
+ });
759
+
760
+ describe('resolveTokenPermissions', () => {
761
+ it('maps scopes to IAM permissions', async () => {
762
+ const client = await methods.createClient({
763
+ name: 'Service',
764
+ redirectUris: [],
765
+ grantTypes: ['client_credentials'],
766
+ });
767
+
768
+ const { accessToken } = await methods.clientCredentialsGrant({
769
+ clientId: client.clientId,
770
+ clientSecret: client.clientSecret!,
771
+ scope: 'read:posts write:posts',
772
+ });
773
+
774
+ const permissions = await methods.resolveTokenPermissions(accessToken);
775
+
776
+ expect(permissions).toEqual([
777
+ { resource: 'post', action: 'read' },
778
+ { resource: 'post', action: 'create' },
779
+ ]);
780
+ });
781
+
782
+ it('returns empty for unknown scopes', async () => {
783
+ const client = await methods.createClient({
784
+ name: 'Service',
785
+ redirectUris: [],
786
+ grantTypes: ['client_credentials'],
787
+ });
788
+
789
+ const { accessToken } = await methods.clientCredentialsGrant({
790
+ clientId: client.clientId,
791
+ clientSecret: client.clientSecret!,
792
+ scope: 'unknown:scope',
793
+ });
794
+
795
+ const permissions = await methods.resolveTokenPermissions(accessToken);
796
+ expect(permissions).toEqual([]);
797
+ });
798
+
799
+ it('returns empty for token with no scope', async () => {
800
+ const client = await methods.createClient({
801
+ name: 'Service',
802
+ redirectUris: [],
803
+ grantTypes: ['client_credentials'],
804
+ });
805
+
806
+ const { accessToken } = await methods.clientCredentialsGrant({
807
+ clientId: client.clientId,
808
+ clientSecret: client.clientSecret!,
809
+ });
810
+
811
+ const permissions = await methods.resolveTokenPermissions(accessToken);
812
+ expect(permissions).toEqual([]);
813
+ });
814
+ });
815
+
816
+ // ===================================================================
817
+ // RFC 9700 §2.1.1 — mandatory PKCE on /oauth/authorize
818
+ // ===================================================================
819
+ describe('mandatory PKCE (RFC 9700 §2.1.1)', () => {
820
+ it('rejects authorize requests without code_challenge', async () => {
821
+ const client = await methods.createClient({
822
+ name: 'App',
823
+ redirectUris: ['https://app.com/callback'],
824
+ grantTypes: ['authorization_code'],
825
+ });
826
+ const localDb = createTestAdapter();
827
+ const localPlugin = oauth({
828
+ loginUrl: 'https://app.example.com/signin',
829
+ consentUrl: 'https://app.example.com/oauth/consent',
830
+ });
831
+ const localMethods = localPlugin.methods!({
832
+ db: localDb,
833
+ config: { jwt: { key: 'x'.repeat(32) }, database: localDb },
834
+ }) as unknown as OAuthMethods;
835
+ await localDb.create({
836
+ model: 'oauth_client',
837
+ data: {
838
+ clientId: client.clientId,
839
+ clientSecretHash: 'irrelevant',
840
+ name: 'App',
841
+ redirectUris: JSON.stringify(['https://app.com/callback']),
842
+ grantTypes: JSON.stringify(['authorization_code']),
843
+ },
844
+ });
845
+
846
+ await expect(
847
+ localMethods.handleAuthorizeRequest(
848
+ {
849
+ client_id: client.clientId,
850
+ redirect_uri: 'https://app.com/callback',
851
+ response_type: 'code',
852
+ state: 's',
853
+ },
854
+ { userId: undefined },
855
+ ),
856
+ ).rejects.toThrow('code_challenge is required');
857
+ });
858
+
859
+ it('rejects code_challenge with no method (defaults to plain, which we don\'t support)', async () => {
860
+ const client = await methods.createClient({
861
+ name: 'App',
862
+ redirectUris: ['https://app.com/callback'],
863
+ grantTypes: ['authorization_code'],
864
+ });
865
+ const localDb = createTestAdapter();
866
+ const localPlugin = oauth({
867
+ loginUrl: 'https://app.example.com/signin',
868
+ consentUrl: 'https://app.example.com/oauth/consent',
869
+ });
870
+ const localMethods = localPlugin.methods!({
871
+ db: localDb,
872
+ config: { jwt: { key: 'x'.repeat(32) }, database: localDb },
873
+ }) as unknown as OAuthMethods;
874
+ await localDb.create({
875
+ model: 'oauth_client',
876
+ data: {
877
+ clientId: client.clientId,
878
+ clientSecretHash: 'irrelevant',
879
+ name: 'App',
880
+ redirectUris: JSON.stringify(['https://app.com/callback']),
881
+ grantTypes: JSON.stringify(['authorization_code']),
882
+ },
883
+ });
884
+
885
+ await expect(
886
+ localMethods.handleAuthorizeRequest(
887
+ {
888
+ client_id: client.clientId,
889
+ redirect_uri: 'https://app.com/callback',
890
+ response_type: 'code',
891
+ state: 's',
892
+ code_challenge: 'abc',
893
+ },
894
+ { userId: undefined },
895
+ ),
896
+ ).rejects.toThrow('code_challenge_method is required');
897
+ });
898
+
899
+ it('escape hatch: allowNonPkceConfidentialClients lets legacy clients through', async () => {
900
+ const client = await methods.createClient({
901
+ name: 'Legacy',
902
+ redirectUris: ['https://legacy.com/cb'],
903
+ grantTypes: ['authorization_code'],
904
+ });
905
+ const localDb = createTestAdapter();
906
+ const localPlugin = oauth({
907
+ loginUrl: 'https://app.example.com/signin',
908
+ consentUrl: 'https://app.example.com/oauth/consent',
909
+ allowNonPkceConfidentialClients: true,
910
+ });
911
+ const localMethods = localPlugin.methods!({
912
+ db: localDb,
913
+ config: { jwt: { key: 'x'.repeat(32) }, database: localDb },
914
+ }) as unknown as OAuthMethods;
915
+ await localDb.create({
916
+ model: 'oauth_client',
917
+ data: {
918
+ clientId: client.clientId,
919
+ clientSecretHash: 'irrelevant',
920
+ name: 'Legacy',
921
+ redirectUris: JSON.stringify(['https://legacy.com/cb']),
922
+ grantTypes: JSON.stringify(['authorization_code']),
923
+ },
924
+ });
925
+
926
+ const result = await localMethods.handleAuthorizeRequest(
927
+ {
928
+ client_id: client.clientId,
929
+ redirect_uri: 'https://legacy.com/cb',
930
+ response_type: 'code',
931
+ state: 's',
932
+ },
933
+ { userId: undefined },
934
+ );
935
+ expect(typeof result.flowId).toBe('string');
936
+ expect(result.flowId.length).toBeGreaterThan(20);
937
+ });
938
+ });
939
+
940
+ // ===================================================================
941
+ // RFC 9207 — issuer parameter on the authorization response
942
+ // ===================================================================
943
+ describe('rFC 9207 issuer identification', () => {
944
+ it('handleApproveFlow appends iss= to the success redirect', async () => {
945
+ const client = await methods.createClient({
946
+ name: 'App',
947
+ redirectUris: ['https://app.com/callback'],
948
+ grantTypes: ['authorization_code'],
949
+ });
950
+ const { flowId } = await methods.createPendingFlow({
951
+ clientId: client.clientId,
952
+ redirectUri: 'https://app.com/callback',
953
+ state: 'iss-state',
954
+ });
955
+ const result = await methods.handleApproveFlow(flowId, { userId });
956
+ const url = new URL(result.redirectUrl);
957
+ expect(url.searchParams.get('iss')).toBe('https://auth.example.com');
958
+ });
959
+
960
+ it('handleDenyFlow appends iss= to the error redirect', async () => {
961
+ const client = await methods.createClient({
962
+ name: 'App',
963
+ redirectUris: ['https://app.com/callback'],
964
+ grantTypes: ['authorization_code'],
965
+ });
966
+ const { flowId } = await methods.createPendingFlow({
967
+ clientId: client.clientId,
968
+ redirectUri: 'https://app.com/callback',
969
+ state: 'iss-deny',
970
+ });
971
+ const result = await methods.handleDenyFlow(flowId, { userId });
972
+ const url = new URL(result.redirectUrl);
973
+ expect(url.searchParams.get('iss')).toBe('https://auth.example.com');
974
+ expect(url.searchParams.get('error')).toBe('access_denied');
975
+ });
976
+
977
+ it('discovery declares authorization_response_iss_parameter_supported', () => {
978
+ const doc = methods.handleDiscovery();
979
+ expect(doc.authorization_response_iss_parameter_supported).toBe(true);
980
+ });
981
+ });
982
+
983
+ // ===================================================================
984
+ // RFC 8414 / OIDC Discovery — metadata completeness
985
+ // ===================================================================
986
+ describe('discovery completeness', () => {
987
+ it('exposes scopes_supported merged from scopePermissionMap and OIDC defaults', () => {
988
+ const doc = methods.handleDiscovery();
989
+ const scopes = doc.scopes_supported as string[];
990
+ expect(scopes).toContain('openid');
991
+ expect(scopes).toContain('email');
992
+ expect(scopes).toContain('profile');
993
+ expect(scopes).toContain('read:posts');
994
+ expect(scopes).toContain('write:posts');
995
+ });
996
+
997
+ it('exposes claims_supported with OIDC core claims', () => {
998
+ const doc = methods.handleDiscovery();
999
+ const claims = doc.claims_supported as string[];
1000
+ expect(claims).toContain('sub');
1001
+ expect(claims).toContain('email');
1002
+ expect(claims).toContain('preferred_username');
1003
+ expect(claims).toContain('updated_at');
1004
+ });
1005
+
1006
+ it('exposes response_modes_supported = ["query"]', () => {
1007
+ const doc = methods.handleDiscovery();
1008
+ expect(doc.response_modes_supported).toEqual(['query']);
1009
+ });
1010
+ });
1011
+
1012
+ // ===================================================================
1013
+ // RFC 6749 §5.2 — token-endpoint error wire shape
1014
+ // ===================================================================
1015
+ describe('oAuth error wire shape (RFC 6749 §5.2)', () => {
1016
+ it('throws FortressErrors that carry the OAuth machine code', async () => {
1017
+ const client = await methods.createClient({
1018
+ name: 'Service',
1019
+ redirectUris: [],
1020
+ grantTypes: ['client_credentials'],
1021
+ });
1022
+ try {
1023
+ await methods.handleTokenRequest(
1024
+ { grant_type: 'implicit' as unknown as string },
1025
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
1026
+ );
1027
+ throw new Error('should have thrown');
1028
+ }
1029
+ catch (err: unknown) {
1030
+ expect(err).toBeInstanceOf(Error);
1031
+ const fortressErr = err as { oauthError?: string; oauthDescription?: string; statusCode?: number };
1032
+ expect(fortressErr.oauthError).toBe('unsupported_grant_type');
1033
+ expect(fortressErr.statusCode).toBe(400);
1034
+ }
1035
+ });
1036
+
1037
+ it('invalid_client errors are 401 with OAuth machine code', async () => {
1038
+ const client = await methods.createClient({
1039
+ name: 'Service',
1040
+ redirectUris: [],
1041
+ grantTypes: ['client_credentials'],
1042
+ });
1043
+
1044
+ try {
1045
+ await methods.handleTokenRequest(
1046
+ { grant_type: 'client_credentials' },
1047
+ { clientId: client.clientId, clientSecret: 'wrong-secret' },
1048
+ );
1049
+ throw new Error('should have thrown');
1050
+ }
1051
+ catch (err: unknown) {
1052
+ const fortressErr = err as { oauthError?: string; statusCode?: number };
1053
+ expect(fortressErr.oauthError).toBe('invalid_client');
1054
+ expect(fortressErr.statusCode).toBe(401);
1055
+ }
1056
+ });
1057
+ });
1058
+
1059
+ // ===================================================================
1060
+ // RFC 6749 §3.3 / RFC 9700 §2.2.1 — per-client scope allow-list
1061
+ // ===================================================================
1062
+ describe('per-client scope allow-list (RFC 6749 §3.3)', () => {
1063
+ it('intersects requested scope against client allow-list (auth code)', async () => {
1064
+ const client = await methods.createClient({
1065
+ name: 'Limited',
1066
+ redirectUris: ['https://app.com/cb'],
1067
+ grantTypes: ['authorization_code'],
1068
+ allowedScopes: ['openid', 'email'],
1069
+ });
1070
+ const verifier = generateCodeVerifier();
1071
+ const challenge = await generateCodeChallenge(verifier);
1072
+ const localDb = createTestAdapter();
1073
+ const localPlugin = oauth({
1074
+ loginUrl: 'https://app.example.com/signin',
1075
+ consentUrl: 'https://app.example.com/oauth/consent',
1076
+ issuerUrl: 'https://auth.example.com',
1077
+ });
1078
+ const localMethods = localPlugin.methods!({
1079
+ db: localDb,
1080
+ config: { jwt: { key: 'x'.repeat(32) }, database: localDb },
1081
+ }) as unknown as OAuthMethods;
1082
+ // Re-create the same client in the local DB with allow-list intact.
1083
+ await localDb.create({
1084
+ model: 'oauth_client',
1085
+ data: {
1086
+ clientId: client.clientId,
1087
+ clientSecretHash: 'irrelevant',
1088
+ name: 'Limited',
1089
+ redirectUris: JSON.stringify(['https://app.com/cb']),
1090
+ grantTypes: JSON.stringify(['authorization_code']),
1091
+ allowedScopes: JSON.stringify(['openid', 'email']),
1092
+ },
1093
+ });
1094
+
1095
+ const result = await localMethods.handleAuthorizeRequest(
1096
+ {
1097
+ client_id: client.clientId,
1098
+ redirect_uri: 'https://app.com/cb',
1099
+ response_type: 'code',
1100
+ state: 's',
1101
+ // Request a SUPERSET; admin:write should be silently dropped.
1102
+ scope: 'openid email admin:write',
1103
+ code_challenge: challenge,
1104
+ code_challenge_method: 'S256',
1105
+ },
1106
+ { userId: '1' },
1107
+ );
1108
+ const flow = await localMethods.getPendingFlow(result.flowId);
1109
+ expect(flow.scope).toBe('openid email');
1110
+ });
1111
+
1112
+ it('returns invalid_scope when intersection is empty (auth code)', async () => {
1113
+ const client = await methods.createClient({
1114
+ name: 'Strict',
1115
+ redirectUris: ['https://app.com/cb'],
1116
+ grantTypes: ['authorization_code'],
1117
+ allowedScopes: ['openid'],
1118
+ });
1119
+ const verifier = generateCodeVerifier();
1120
+ const challenge = await generateCodeChallenge(verifier);
1121
+ const localDb = createTestAdapter();
1122
+ const localPlugin = oauth({
1123
+ loginUrl: 'https://app.example.com/signin',
1124
+ consentUrl: 'https://app.example.com/oauth/consent',
1125
+ });
1126
+ const localMethods = localPlugin.methods!({
1127
+ db: localDb,
1128
+ config: { jwt: { key: 'x'.repeat(32) }, database: localDb },
1129
+ }) as unknown as OAuthMethods;
1130
+ await localDb.create({
1131
+ model: 'oauth_client',
1132
+ data: {
1133
+ clientId: client.clientId,
1134
+ clientSecretHash: 'irrelevant',
1135
+ name: 'Strict',
1136
+ redirectUris: JSON.stringify(['https://app.com/cb']),
1137
+ grantTypes: JSON.stringify(['authorization_code']),
1138
+ allowedScopes: JSON.stringify(['openid']),
1139
+ },
1140
+ });
1141
+
1142
+ try {
1143
+ await localMethods.handleAuthorizeRequest(
1144
+ {
1145
+ client_id: client.clientId,
1146
+ redirect_uri: 'https://app.com/cb',
1147
+ response_type: 'code',
1148
+ state: 's',
1149
+ scope: 'admin:write',
1150
+ code_challenge: challenge,
1151
+ code_challenge_method: 'S256',
1152
+ },
1153
+ { userId: '1' },
1154
+ );
1155
+ throw new Error('should have thrown');
1156
+ }
1157
+ catch (err: unknown) {
1158
+ const fortressErr = err as { oauthError?: string };
1159
+ expect(fortressErr.oauthError).toBe('invalid_scope');
1160
+ }
1161
+ });
1162
+
1163
+ it('intersects scope on client_credentials grant', async () => {
1164
+ const client = await methods.createClient({
1165
+ name: 'Service',
1166
+ redirectUris: [],
1167
+ grantTypes: ['client_credentials'],
1168
+ allowedScopes: ['read:posts'],
1169
+ });
1170
+ const tokens = await methods.clientCredentialsGrant({
1171
+ clientId: client.clientId,
1172
+ clientSecret: client.clientSecret!,
1173
+ scope: 'read:posts write:posts',
1174
+ });
1175
+ const info = await methods.introspectToken(tokens.accessToken);
1176
+ expect(info.scope).toBe('read:posts');
1177
+ });
1178
+
1179
+ it('legacy clients (allowedScopes unset) keep working: scope passes through', async () => {
1180
+ const client = await methods.createClient({
1181
+ name: 'Legacy',
1182
+ redirectUris: [],
1183
+ grantTypes: ['client_credentials'],
1184
+ // allowedScopes intentionally omitted
1185
+ });
1186
+ const tokens = await methods.clientCredentialsGrant({
1187
+ clientId: client.clientId,
1188
+ clientSecret: client.clientSecret!,
1189
+ scope: 'admin:everything',
1190
+ });
1191
+ const info = await methods.introspectToken(tokens.accessToken);
1192
+ expect(info.scope).toBe('admin:everything');
1193
+ });
1194
+ });
1195
+
1196
+ // ===================================================================
1197
+ // OIDC Core 1.0 §5.3 — userinfo response shape
1198
+ // ===================================================================
1199
+ describe('oIDC userinfo (OIDC Core §5.3)', () => {
1200
+ async function issueTokenWithScope(scope?: string): Promise<{ accessToken: string; clientId: string; clientSecret: string }> {
1201
+ const client = await methods.createClient({
1202
+ name: 'OIDC App',
1203
+ redirectUris: ['https://app.com/callback'],
1204
+ grantTypes: ['authorization_code'],
1205
+ });
1206
+ const { code } = await methods.createAuthorizationCode({
1207
+ clientId: client.clientId,
1208
+ userId,
1209
+ redirectUri: 'https://app.com/callback',
1210
+ scope,
1211
+ });
1212
+ const tokens = await methods.exchangeCode({
1213
+ code,
1214
+ clientId: client.clientId,
1215
+ clientSecret: client.clientSecret!,
1216
+ redirectUri: 'https://app.com/callback',
1217
+ });
1218
+ return { accessToken: tokens.accessToken, clientId: client.clientId, clientSecret: client.clientSecret! };
1219
+ }
1220
+
1221
+ it('returns sub as a string and standard OIDC claims', async () => {
1222
+ const { accessToken } = await issueTokenWithScope('openid email profile');
1223
+ const claims = await methods.handleUserInfoRequest(accessToken);
1224
+
1225
+ expect(typeof claims.sub).toBe('string');
1226
+ expect(claims.sub).toBe(String(userId));
1227
+ expect(claims.email).toBe('alice@example.com');
1228
+ expect(claims.name).toBe('Alice');
1229
+ expect(claims.preferred_username).toBe('alice@example.com');
1230
+ expect(typeof claims.updated_at).toBe('number');
1231
+ });
1232
+
1233
+ it('omits email when scope=openid does NOT include email', async () => {
1234
+ const { accessToken } = await issueTokenWithScope('openid');
1235
+ const claims = await methods.handleUserInfoRequest(accessToken);
1236
+
1237
+ expect(claims.sub).toBe(String(userId));
1238
+ expect(claims.email).toBeUndefined();
1239
+ expect(claims.name).toBeUndefined();
1240
+ });
1241
+
1242
+ it('omits name/preferred_username when scope=openid lacks profile', async () => {
1243
+ const { accessToken } = await issueTokenWithScope('openid email');
1244
+ const claims = await methods.handleUserInfoRequest(accessToken);
1245
+
1246
+ expect(claims.email).toBe('alice@example.com');
1247
+ expect(claims.name).toBeUndefined();
1248
+ expect(claims.preferred_username).toBeUndefined();
1249
+ });
1250
+
1251
+ it('does not expose identity claims to non-OIDC tokens without identity scopes', async () => {
1252
+ const { accessToken } = await issueTokenWithScope();
1253
+ const claims = await methods.handleUserInfoRequest(accessToken);
1254
+
1255
+ expect(claims.sub).toBe(String(userId));
1256
+ expect(claims.email).toBeUndefined();
1257
+ expect(claims.name).toBeUndefined();
1258
+ });
1259
+
1260
+ it('does NOT leak DB-internal fields', async () => {
1261
+ const { accessToken } = await issueTokenWithScope('openid email profile');
1262
+ const claims = await methods.handleUserInfoRequest(accessToken);
1263
+ expect(claims.id).toBeUndefined();
1264
+ expect(claims.isActive).toBeUndefined();
1265
+ expect(claims.createdAt).toBeUndefined();
1266
+ // The raw FortressUser had a numeric `id`; OIDC requires `sub` to be a string.
1267
+ expect(typeof claims.sub).toBe('string');
1268
+ });
1269
+
1270
+ it('throws 401 for invalid bearer token', async () => {
1271
+ await expect(methods.handleUserInfoRequest('not-a-real-token')).rejects.toMatchObject({
1272
+ statusCode: 401,
1273
+ });
1274
+ });
1275
+
1276
+ it('userinfoClaims hook merges into the response', async () => {
1277
+ const localDb = createTestAdapter();
1278
+ const localPlugin = oauth({
1279
+ issuerUrl: 'https://auth.example.com',
1280
+ userinfoClaims: (user, scope) => ({
1281
+ tenant: 'acme',
1282
+ token_scope: scope ?? '',
1283
+ // Hooks may overwrite fortress-emitted claims — here, replace `name`.
1284
+ name: `[acme] ${user.name}`,
1285
+ }),
1286
+ });
1287
+ const localMethods = localPlugin.methods!({
1288
+ db: localDb,
1289
+ config: { jwt: { key: 'x'.repeat(32) }, database: localDb },
1290
+ }) as unknown as OAuthMethods;
1291
+
1292
+ const localUser = await localDb.create<{ id: string }>({
1293
+ model: 'user',
1294
+ data: { email: 'bob@acme.com', name: 'Bob', passwordHash: 'h', isActive: true },
1295
+ });
1296
+ const localClient = await localMethods.createClient({
1297
+ name: 'Tenant App',
1298
+ redirectUris: ['https://acme.com/cb'],
1299
+ grantTypes: ['authorization_code'],
1300
+ });
1301
+ const { code } = await localMethods.createAuthorizationCode({
1302
+ clientId: localClient.clientId,
1303
+ userId: localUser.id,
1304
+ redirectUri: 'https://acme.com/cb',
1305
+ scope: 'openid email profile',
1306
+ });
1307
+ const tokens = await localMethods.exchangeCode({
1308
+ code,
1309
+ clientId: localClient.clientId,
1310
+ clientSecret: localClient.clientSecret!,
1311
+ redirectUri: 'https://acme.com/cb',
1312
+ });
1313
+ const claims = await localMethods.handleUserInfoRequest(tokens.accessToken);
1314
+
1315
+ expect(claims.tenant).toBe('acme');
1316
+ expect(claims.token_scope).toBe('openid email profile');
1317
+ expect(claims.name).toBe('[acme] Bob');
1318
+ // Standard claims still present where the hook didn't override.
1319
+ expect(claims.email).toBe('bob@acme.com');
1320
+ });
1321
+
1322
+ it('toOidcUserinfo: pure mapping function for host-app composition', () => {
1323
+ const claims = toOidcUserinfo(
1324
+ {
1325
+ id: '7',
1326
+ email: 'c@example.com',
1327
+ name: 'Carol',
1328
+ isActive: true,
1329
+ emailVerified: true,
1330
+ createdAt: new Date('2024-01-01T00:00:00Z'),
1331
+ updatedAt: new Date('2024-06-01T00:00:00Z'),
1332
+ },
1333
+ 'openid email profile',
1334
+ );
1335
+ expect(claims).toEqual({
1336
+ sub: '7',
1337
+ email: 'c@example.com',
1338
+ email_verified: true,
1339
+ name: 'Carol',
1340
+ preferred_username: 'c@example.com',
1341
+ updated_at: Math.floor(new Date('2024-06-01T00:00:00Z').getTime() / 1000),
1342
+ });
1343
+ });
1344
+ });
1345
+
1346
+ // ===================================================================
1347
+ // OIDC Core 1.0 §3.1.3.7 — id_token issuance, JWKS, nonce echo
1348
+ // ===================================================================
1349
+ describe('id_token issuance (OIDC Core §3.1.3.7)', () => {
1350
+ async function issueIdTokenViaCode(opts: { scope?: string; nonce?: string } = {}): Promise<{
1351
+ idToken: string;
1352
+ accessToken: string;
1353
+ clientId: string;
1354
+ }> {
1355
+ const client = await methods.createClient({
1356
+ name: 'OIDC RP',
1357
+ redirectUris: ['https://rp.example.com/cb'],
1358
+ grantTypes: ['authorization_code'],
1359
+ });
1360
+ const verifier = generateCodeVerifier();
1361
+ const challenge = await generateCodeChallenge(verifier);
1362
+ const { code } = await methods.createAuthorizationCode({
1363
+ clientId: client.clientId,
1364
+ userId,
1365
+ redirectUri: 'https://rp.example.com/cb',
1366
+ scope: opts.scope ?? 'openid email profile',
1367
+ codeChallenge: challenge,
1368
+ codeChallengeMethod: 'S256',
1369
+ nonce: opts.nonce,
1370
+ });
1371
+ const tokens = await methods.exchangeCode({
1372
+ code,
1373
+ clientId: client.clientId,
1374
+ clientSecret: client.clientSecret!,
1375
+ redirectUri: 'https://rp.example.com/cb',
1376
+ codeVerifier: verifier,
1377
+ }) as { accessToken: string; idToken?: string };
1378
+ return {
1379
+ idToken: tokens.idToken!,
1380
+ accessToken: tokens.accessToken,
1381
+ clientId: client.clientId,
1382
+ };
1383
+ }
1384
+
1385
+ it('issues id_token when scope=openid', async () => {
1386
+ const { idToken } = await issueIdTokenViaCode();
1387
+ expect(idToken).toBeTruthy();
1388
+ expect(idToken.split('.')).toHaveLength(3); // compact JWS = 3 parts
1389
+ });
1390
+
1391
+ it('does NOT issue id_token when scope omits openid', async () => {
1392
+ const client = await methods.createClient({
1393
+ name: 'Plain OAuth',
1394
+ redirectUris: ['https://rp.example.com/cb'],
1395
+ grantTypes: ['authorization_code'],
1396
+ });
1397
+ const { code } = await methods.createAuthorizationCode({
1398
+ clientId: client.clientId,
1399
+ userId,
1400
+ redirectUri: 'https://rp.example.com/cb',
1401
+ scope: 'email',
1402
+ });
1403
+ const tokens = await methods.exchangeCode({
1404
+ code,
1405
+ clientId: client.clientId,
1406
+ clientSecret: client.clientSecret!,
1407
+ redirectUri: 'https://rp.example.com/cb',
1408
+ }) as { idToken?: string };
1409
+ expect(tokens.idToken).toBeUndefined();
1410
+ });
1411
+
1412
+ it('id_token claims: iss, sub (string), aud, iat, exp, auth_time', async () => {
1413
+ const { idToken, clientId } = await issueIdTokenViaCode();
1414
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1415
+ const key = await importJWK(jwks.keys[0], 'RS256');
1416
+ const { payload } = await jwtVerify(idToken, key);
1417
+
1418
+ expect(payload.iss).toBe('https://auth.example.com');
1419
+ expect(payload.sub).toBe(String(userId));
1420
+ expect(typeof payload.sub).toBe('string');
1421
+ expect(payload.aud).toBe(clientId);
1422
+ expect(typeof payload.iat).toBe('number');
1423
+ expect(typeof payload.exp).toBe('number');
1424
+ expect(typeof payload.auth_time).toBe('number');
1425
+ });
1426
+
1427
+ it('echoes nonce verbatim into the id_token', async () => {
1428
+ const nonce = 'rp-supplied-nonce-12345';
1429
+ const { idToken } = await issueIdTokenViaCode({ nonce });
1430
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1431
+ const key = await importJWK(jwks.keys[0], 'RS256');
1432
+ const { payload } = await jwtVerify(idToken, key);
1433
+ expect(payload.nonce).toBe(nonce);
1434
+ });
1435
+
1436
+ it('omits nonce claim when authorize request had no nonce', async () => {
1437
+ const { idToken } = await issueIdTokenViaCode();
1438
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1439
+ const key = await importJWK(jwks.keys[0], 'RS256');
1440
+ const { payload } = await jwtVerify(idToken, key);
1441
+ expect(payload.nonce).toBeUndefined();
1442
+ });
1443
+
1444
+ it('id_token includes scope-gated email/name claims', async () => {
1445
+ const { idToken } = await issueIdTokenViaCode({ scope: 'openid email profile' });
1446
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1447
+ const key = await importJWK(jwks.keys[0], 'RS256');
1448
+ const { payload } = await jwtVerify(idToken, key);
1449
+ expect(payload.email).toBe('alice@example.com');
1450
+ expect(payload.name).toBe('Alice');
1451
+ expect(payload.preferred_username).toBe('alice@example.com');
1452
+ });
1453
+
1454
+ it('id_token omits email and profile claims for openid-only scope', async () => {
1455
+ const { idToken } = await issueIdTokenViaCode({ scope: 'openid' });
1456
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1457
+ const key = await importJWK(jwks.keys[0], 'RS256');
1458
+ const { payload } = await jwtVerify(idToken, key);
1459
+ expect(payload.email).toBeUndefined();
1460
+ expect(payload.email_verified).toBeUndefined();
1461
+ expect(payload.name).toBeUndefined();
1462
+ expect(payload.preferred_username).toBeUndefined();
1463
+ });
1464
+
1465
+ it('id_token omits profile claims when only openid+email scope', async () => {
1466
+ const { idToken } = await issueIdTokenViaCode({ scope: 'openid email' });
1467
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1468
+ const key = await importJWK(jwks.keys[0], 'RS256');
1469
+ const { payload } = await jwtVerify(idToken, key);
1470
+ expect(payload.email).toBe('alice@example.com');
1471
+ expect(payload.name).toBeUndefined();
1472
+ expect(payload.preferred_username).toBeUndefined();
1473
+ });
1474
+
1475
+ it('handleTokenRequest dispatches id_token via the /token endpoint', async () => {
1476
+ const client = await methods.createClient({
1477
+ name: 'OIDC',
1478
+ redirectUris: ['https://rp.example.com/cb'],
1479
+ grantTypes: ['authorization_code'],
1480
+ });
1481
+ const verifier = generateCodeVerifier();
1482
+ const challenge = await generateCodeChallenge(verifier);
1483
+ const { code } = await methods.createAuthorizationCode({
1484
+ clientId: client.clientId,
1485
+ userId,
1486
+ redirectUri: 'https://rp.example.com/cb',
1487
+ scope: 'openid',
1488
+ codeChallenge: challenge,
1489
+ codeChallengeMethod: 'S256',
1490
+ });
1491
+ const result = await methods.handleTokenRequest(
1492
+ {
1493
+ grant_type: 'authorization_code',
1494
+ code,
1495
+ redirect_uri: 'https://rp.example.com/cb',
1496
+ code_verifier: verifier,
1497
+ },
1498
+ { clientId: client.clientId, clientSecret: client.clientSecret! },
1499
+ );
1500
+ expect(result.id_token).toBeTruthy();
1501
+ });
1502
+ });
1503
+
1504
+ // ===================================================================
1505
+ // RFC 7517 / OIDC Discovery §3 — JWKS endpoint
1506
+ // ===================================================================
1507
+ describe('jWKS endpoint (RFC 7517)', () => {
1508
+ it('handleJwksRequest returns a non-empty keys array', async () => {
1509
+ const jwks = await methods.handleJwksRequest() as { keys: unknown[] };
1510
+ expect(Array.isArray(jwks.keys)).toBe(true);
1511
+ expect(jwks.keys.length).toBeGreaterThan(0);
1512
+ });
1513
+
1514
+ it('jWKS keys carry kid + alg + use=sig', async () => {
1515
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1516
+ const key = jwks.keys[0];
1517
+ expect(key.kid).toBeTruthy();
1518
+ expect(key.alg).toBe('RS256');
1519
+ expect(key.use).toBe('sig');
1520
+ // The d (private key) parameter MUST NOT be exposed.
1521
+ expect((key as Record<string, unknown>).d).toBeUndefined();
1522
+ });
1523
+
1524
+ it('id_token signature verifies against JWKS by kid', async () => {
1525
+ const client = await methods.createClient({
1526
+ name: 'RP',
1527
+ redirectUris: ['https://rp.example.com/cb'],
1528
+ grantTypes: ['authorization_code'],
1529
+ });
1530
+ const { code } = await methods.createAuthorizationCode({
1531
+ clientId: client.clientId,
1532
+ userId,
1533
+ redirectUri: 'https://rp.example.com/cb',
1534
+ scope: 'openid',
1535
+ });
1536
+ const tokens = await methods.exchangeCode({
1537
+ code,
1538
+ clientId: client.clientId,
1539
+ clientSecret: client.clientSecret!,
1540
+ redirectUri: 'https://rp.example.com/cb',
1541
+ }) as { idToken: string };
1542
+
1543
+ // RFC 7517-style verification: simulate openid-client's behaviour by
1544
+ // building a JWKS resolver from our endpoint output.
1545
+ const jwks = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1546
+ const key = await importJWK(jwks.keys[0], 'RS256');
1547
+ const { payload } = await jwtVerify(tokens.idToken, key, {
1548
+ issuer: 'https://auth.example.com',
1549
+ audience: client.clientId,
1550
+ });
1551
+ expect(payload.sub).toBe(String(userId));
1552
+ });
1553
+
1554
+ it('discovery declares jwks_uri + id_token_signing_alg_values_supported', () => {
1555
+ const doc = methods.handleDiscovery();
1556
+ expect(doc.jwks_uri).toBe('https://auth.example.com/oauth/.well-known/jwks.json');
1557
+ expect(doc.id_token_signing_alg_values_supported).toEqual(['RS256']);
1558
+ });
1559
+
1560
+ it('jWKS persists across calls (same kid)', async () => {
1561
+ const a = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1562
+ const b = await methods.handleJwksRequest() as { keys: import('jose').JWK[] };
1563
+ expect(a.keys[0].kid).toBe(b.keys[0].kid);
1564
+ });
1565
+ });
1566
+
1567
+ // ===================================================================
1568
+ // RFC 6749 §6 + RFC 9700 §2.2.2 — refresh token grant with rotation
1569
+ // ===================================================================
1570
+ describe('refresh token grant (RFC 6749 §6 / RFC 9700 §2.2.2)', () => {
1571
+ async function freshToken(scope?: string): Promise<{
1572
+ accessToken: string;
1573
+ refreshToken: string;
1574
+ clientId: string;
1575
+ clientSecret: string;
1576
+ }> {
1577
+ const client = await methods.createClient({
1578
+ name: 'Refresh App',
1579
+ redirectUris: ['https://app.com/cb'],
1580
+ grantTypes: ['authorization_code', 'refresh_token'],
1581
+ });
1582
+ const verifier = generateCodeVerifier();
1583
+ const challenge = await generateCodeChallenge(verifier);
1584
+ const { code } = await methods.createAuthorizationCode({
1585
+ clientId: client.clientId,
1586
+ userId,
1587
+ redirectUri: 'https://app.com/cb',
1588
+ scope,
1589
+ codeChallenge: challenge,
1590
+ codeChallengeMethod: 'S256',
1591
+ });
1592
+ const tokens = await methods.exchangeCode({
1593
+ code,
1594
+ clientId: client.clientId,
1595
+ clientSecret: client.clientSecret!,
1596
+ redirectUri: 'https://app.com/cb',
1597
+ codeVerifier: verifier,
1598
+ }) as { accessToken: string; refreshToken?: string };
1599
+ return {
1600
+ accessToken: tokens.accessToken,
1601
+ refreshToken: tokens.refreshToken!,
1602
+ clientId: client.clientId,
1603
+ clientSecret: client.clientSecret!,
1604
+ };
1605
+ }
1606
+
1607
+ it('exchangeCode returns a refresh_token alongside the access_token', async () => {
1608
+ const { refreshToken } = await freshToken();
1609
+ expect(refreshToken).toBeTruthy();
1610
+ expect(typeof refreshToken).toBe('string');
1611
+ });
1612
+
1613
+ it('refresh token rotation: each use issues a new pair, old one invalidated', async () => {
1614
+ const { refreshToken: r1, clientId, clientSecret } = await freshToken('openid email');
1615
+ const result = await methods.refreshTokenGrant({
1616
+ clientId,
1617
+ clientSecret,
1618
+ refreshToken: r1,
1619
+ });
1620
+ expect(result.accessToken).toBeTruthy();
1621
+ expect(result.refreshToken).toBeTruthy();
1622
+ expect(result.refreshToken).not.toBe(r1);
1623
+ expect(result.scope).toBe('openid email');
1624
+
1625
+ // The new access token verifies.
1626
+ const info = await methods.introspectToken(result.accessToken);
1627
+ expect(info.active).toBe(true);
1628
+ });
1629
+
1630
+ it('strict refresh concurrency: one winner, loser is replay and revokes the family', async () => {
1631
+ const { refreshToken: r1, clientId, clientSecret } = await freshToken();
1632
+
1633
+ const results = await Promise.allSettled([
1634
+ methods.refreshTokenGrant({ clientId, clientSecret, refreshToken: r1 }),
1635
+ methods.refreshTokenGrant({ clientId, clientSecret, refreshToken: r1 }),
1636
+ ]);
1637
+
1638
+ const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ refreshToken: string }>[];
1639
+ const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
1640
+ expect(fulfilled).toHaveLength(1);
1641
+ expect(rejected).toHaveLength(1);
1642
+ expect((rejected[0].reason as { oauthError?: string }).oauthError).toBe('invalid_grant');
1643
+ expect(String(rejected[0].reason)).toMatch(/reuse/i);
1644
+
1645
+ // Strict replay semantics: the duplicate loser kills the token family,
1646
+ // including the winner's newly issued refresh token.
1647
+ await expect(methods.refreshTokenGrant({
1648
+ clientId,
1649
+ clientSecret,
1650
+ refreshToken: fulfilled[0].value.refreshToken,
1651
+ })).rejects.toThrow('Invalid refresh token');
1652
+ });
1653
+
1654
+ it('replay detection: reusing R1 after R2 issued kills the family', async () => {
1655
+ const { refreshToken: r1, clientId, clientSecret } = await freshToken();
1656
+ const { refreshToken: r2 } = await methods.refreshTokenGrant({
1657
+ clientId,
1658
+ clientSecret,
1659
+ refreshToken: r1,
1660
+ });
1661
+
1662
+ // Replay R1 — attacker / network glitch.
1663
+ try {
1664
+ await methods.refreshTokenGrant({ clientId, clientSecret, refreshToken: r1 });
1665
+ throw new Error('should have thrown');
1666
+ }
1667
+ catch (err: unknown) {
1668
+ const fortressErr = err as { oauthError?: string; message?: string };
1669
+ expect(fortressErr.oauthError).toBe('invalid_grant');
1670
+ expect(fortressErr.message).toMatch(/reuse/i);
1671
+ }
1672
+
1673
+ // R2 (the legitimate next token) is now also dead.
1674
+ await expect(methods.refreshTokenGrant({
1675
+ clientId,
1676
+ clientSecret,
1677
+ refreshToken: r2,
1678
+ })).rejects.toThrow('Invalid refresh token');
1679
+ });
1680
+
1681
+ it('rejects refresh token when client mismatches', async () => {
1682
+ const { refreshToken } = await freshToken();
1683
+ const otherClient = await methods.createClient({
1684
+ name: 'Other',
1685
+ redirectUris: ['https://other.com/cb'],
1686
+ grantTypes: ['authorization_code', 'refresh_token'],
1687
+ });
1688
+ await expect(methods.refreshTokenGrant({
1689
+ clientId: otherClient.clientId,
1690
+ clientSecret: otherClient.clientSecret!,
1691
+ refreshToken,
1692
+ })).rejects.toThrow('client mismatch');
1693
+ });
1694
+
1695
+ it('rejects scope widening (RFC 6749 §6)', async () => {
1696
+ const { refreshToken, clientId, clientSecret } = await freshToken('openid');
1697
+ try {
1698
+ await methods.refreshTokenGrant({
1699
+ clientId,
1700
+ clientSecret,
1701
+ refreshToken,
1702
+ scope: 'openid admin:write',
1703
+ });
1704
+ throw new Error('should have thrown');
1705
+ }
1706
+ catch (err: unknown) {
1707
+ const fortressErr = err as { oauthError?: string };
1708
+ expect(fortressErr.oauthError).toBe('invalid_scope');
1709
+ }
1710
+ });
1711
+
1712
+ it('allows scope narrowing on refresh', async () => {
1713
+ const { refreshToken, clientId, clientSecret } = await freshToken('openid email profile');
1714
+ const result = await methods.refreshTokenGrant({
1715
+ clientId,
1716
+ clientSecret,
1717
+ refreshToken,
1718
+ scope: 'openid',
1719
+ });
1720
+ expect(result.scope).toBe('openid');
1721
+ });
1722
+
1723
+ it('refresh returns a fresh id_token when the original grant included openid', async () => {
1724
+ const { refreshToken, clientId, clientSecret } = await freshToken('openid email');
1725
+ const result = await methods.refreshTokenGrant({
1726
+ clientId,
1727
+ clientSecret,
1728
+ refreshToken,
1729
+ }) as { idToken?: string };
1730
+ expect(result.idToken).toBeTruthy();
1731
+ expect(decodeJwt(result.idToken!).auth_time).toBeUndefined();
1732
+ });
1733
+
1734
+ it('handleTokenRequest dispatches grant_type=refresh_token', async () => {
1735
+ const { refreshToken, clientId, clientSecret } = await freshToken();
1736
+ const result = await methods.handleTokenRequest(
1737
+ { grant_type: 'refresh_token', refresh_token: refreshToken },
1738
+ { clientId, clientSecret },
1739
+ );
1740
+ expect(result.access_token).toBeTruthy();
1741
+ expect(result.refresh_token).toBeTruthy();
1742
+ expect(result.token_type).toBe('Bearer');
1743
+ });
1744
+
1745
+ it('handleTokenRequest includes id_token on openid refresh', async () => {
1746
+ const { refreshToken, clientId, clientSecret } = await freshToken('openid');
1747
+ const result = await methods.handleTokenRequest(
1748
+ { grant_type: 'refresh_token', refresh_token: refreshToken },
1749
+ { clientId, clientSecret },
1750
+ );
1751
+ expect(result.id_token).toBeTruthy();
1752
+ });
1753
+
1754
+ it('discovery declares refresh_token in grant_types_supported', () => {
1755
+ const doc = methods.handleDiscovery();
1756
+ expect(doc.grant_types_supported).toContain('refresh_token');
1757
+ });
1758
+
1759
+ it('refreshTokenExpirySeconds=0 disables refresh-token issuance', async () => {
1760
+ const localDb = createTestAdapter();
1761
+ const localPlugin = oauth({
1762
+ issuerUrl: 'https://auth.example.com',
1763
+ refreshTokenExpirySeconds: 0,
1764
+ });
1765
+ const localMethods = localPlugin.methods!({
1766
+ db: localDb,
1767
+ config: { jwt: { key: 'x'.repeat(32) }, database: localDb },
1768
+ }) as unknown as OAuthMethods;
1769
+ const localUser = await localDb.create<{ id: string }>({
1770
+ model: 'user',
1771
+ data: { email: 'd@e.com', name: 'D', passwordHash: 'h', isActive: true },
1772
+ });
1773
+ const client = await localMethods.createClient({
1774
+ name: 'No-refresh',
1775
+ redirectUris: ['https://app.com/cb'],
1776
+ grantTypes: ['authorization_code'],
1777
+ });
1778
+ const verifier = generateCodeVerifier();
1779
+ const challenge = await generateCodeChallenge(verifier);
1780
+ const { code } = await localMethods.createAuthorizationCode({
1781
+ clientId: client.clientId,
1782
+ userId: localUser.id,
1783
+ redirectUri: 'https://app.com/cb',
1784
+ codeChallenge: challenge,
1785
+ codeChallengeMethod: 'S256',
1786
+ });
1787
+ const tokens = await localMethods.exchangeCode({
1788
+ code,
1789
+ clientId: client.clientId,
1790
+ clientSecret: client.clientSecret!,
1791
+ redirectUri: 'https://app.com/cb',
1792
+ codeVerifier: verifier,
1793
+ }) as { accessToken: string; refreshToken?: string };
1794
+ expect(tokens.accessToken).toBeTruthy();
1795
+ expect(tokens.refreshToken).toBeUndefined();
1796
+ expect(localMethods.handleDiscovery().grant_types_supported).not.toContain('refresh_token');
1797
+ });
1798
+
1799
+ it('revokeToken on a refresh token kills the entire family', async () => {
1800
+ const { refreshToken, clientId, clientSecret } = await freshToken();
1801
+ // Rotate once so we have R1 (revoked target) and R2 (descendant).
1802
+ const { refreshToken: r2 } = await methods.refreshTokenGrant({
1803
+ clientId,
1804
+ clientSecret,
1805
+ refreshToken,
1806
+ });
1807
+ // Revoke R2 — the family root (R1) is already used (rotated), so the
1808
+ // family revocation should sweep both.
1809
+ await methods.revokeToken(r2);
1810
+ await expect(methods.refreshTokenGrant({
1811
+ clientId,
1812
+ clientSecret,
1813
+ refreshToken: r2,
1814
+ })).rejects.toThrow('Invalid refresh token');
1815
+ });
1816
+ });
1817
+
1818
+ // ===================================================================
1819
+ // RFC 8252 — OAuth 2.0 for Native Apps (public-client mode + loopback)
1820
+ // ===================================================================
1821
+ describe('public clients (RFC 8252 / RFC 6749 §2.1)', () => {
1822
+ it('createClient with tokenEndpointAuthMethod="none" returns null secret', async () => {
1823
+ const client = await methods.createClient({
1824
+ name: 'SPA',
1825
+ redirectUris: ['https://spa.example.com/cb'],
1826
+ grantTypes: ['authorization_code'],
1827
+ tokenEndpointAuthMethod: 'none',
1828
+ });
1829
+ expect(client.clientId).toBeTruthy();
1830
+ expect(client.clientSecret).toBeNull();
1831
+ });
1832
+
1833
+ it('public client completes auth-code+PKCE flow with no secret', async () => {
1834
+ const client = await methods.createClient({
1835
+ name: 'SPA',
1836
+ redirectUris: ['https://spa.example.com/cb'],
1837
+ grantTypes: ['authorization_code'],
1838
+ tokenEndpointAuthMethod: 'none',
1839
+ });
1840
+ const verifier = generateCodeVerifier();
1841
+ const challenge = await generateCodeChallenge(verifier);
1842
+ const { code } = await methods.createAuthorizationCode({
1843
+ clientId: client.clientId,
1844
+ userId,
1845
+ redirectUri: 'https://spa.example.com/cb',
1846
+ codeChallenge: challenge,
1847
+ codeChallengeMethod: 'S256',
1848
+ });
1849
+ const tokens = await methods.exchangeCode({
1850
+ code,
1851
+ clientId: client.clientId,
1852
+ // No clientSecret — public client.
1853
+ redirectUri: 'https://spa.example.com/cb',
1854
+ codeVerifier: verifier,
1855
+ });
1856
+ expect(tokens.accessToken).toBeTruthy();
1857
+ });
1858
+
1859
+ it('public client rejected if it presents a client_secret (RFC 6749 §2.3.1)', async () => {
1860
+ const client = await methods.createClient({
1861
+ name: 'SPA',
1862
+ redirectUris: ['https://spa.example.com/cb'],
1863
+ grantTypes: ['authorization_code'],
1864
+ tokenEndpointAuthMethod: 'none',
1865
+ });
1866
+ const verifier = generateCodeVerifier();
1867
+ const challenge = await generateCodeChallenge(verifier);
1868
+ const { code } = await methods.createAuthorizationCode({
1869
+ clientId: client.clientId,
1870
+ userId,
1871
+ redirectUri: 'https://spa.example.com/cb',
1872
+ codeChallenge: challenge,
1873
+ codeChallengeMethod: 'S256',
1874
+ });
1875
+ await expect(methods.exchangeCode({
1876
+ code,
1877
+ clientId: client.clientId,
1878
+ clientSecret: 'i-should-not-be-here',
1879
+ redirectUri: 'https://spa.example.com/cb',
1880
+ codeVerifier: verifier,
1881
+ })).rejects.toThrow('Public clients must not present a client_secret');
1882
+ });
1883
+
1884
+ it('public client cannot use client_credentials grant', async () => {
1885
+ const client = await methods.createClient({
1886
+ name: 'SPA',
1887
+ redirectUris: [],
1888
+ grantTypes: ['client_credentials'],
1889
+ tokenEndpointAuthMethod: 'none',
1890
+ });
1891
+ try {
1892
+ await methods.handleTokenRequest(
1893
+ { grant_type: 'client_credentials', client_id: client.clientId },
1894
+ );
1895
+ throw new Error('should have thrown');
1896
+ }
1897
+ catch (err: unknown) {
1898
+ const fortressErr = err as { oauthError?: string };
1899
+ expect(fortressErr.oauthError).toBe('invalid_client');
1900
+ }
1901
+ });
1902
+
1903
+ it('confidential client still requires client_secret', async () => {
1904
+ const client = await methods.createClient({
1905
+ name: 'Web App',
1906
+ redirectUris: ['https://app.com/cb'],
1907
+ grantTypes: ['authorization_code'],
1908
+ // tokenEndpointAuthMethod defaults to 'client_secret_basic'
1909
+ });
1910
+ const verifier = generateCodeVerifier();
1911
+ const challenge = await generateCodeChallenge(verifier);
1912
+ const { code } = await methods.createAuthorizationCode({
1913
+ clientId: client.clientId,
1914
+ userId,
1915
+ redirectUri: 'https://app.com/cb',
1916
+ codeChallenge: challenge,
1917
+ codeChallengeMethod: 'S256',
1918
+ });
1919
+ await expect(methods.exchangeCode({
1920
+ code,
1921
+ clientId: client.clientId,
1922
+ // No secret
1923
+ redirectUri: 'https://app.com/cb',
1924
+ codeVerifier: verifier,
1925
+ })).rejects.toThrow('Client secret required');
1926
+ });
1927
+
1928
+ it('discovery exposes "none" in token_endpoint_auth_methods_supported', () => {
1929
+ const doc = methods.handleDiscovery();
1930
+ expect(doc.token_endpoint_auth_methods_supported).toContain('none');
1931
+ });
1932
+ });
1933
+
1934
+ // ===================================================================
1935
+ // RFC 8252 §8.4 — loopback redirect URI matcher
1936
+ // ===================================================================
1937
+ describe('loopback redirect URI (RFC 8252 §8.4)', () => {
1938
+ it('matchRedirectUri: exact match always passes', () => {
1939
+ expect(matchRedirectUri('https://app.com/cb', 'https://app.com/cb')).toBe(true);
1940
+ });
1941
+
1942
+ it('matchRedirectUri: 127.0.0.1 with any port matches', () => {
1943
+ expect(matchRedirectUri('http://127.0.0.1/cb', 'http://127.0.0.1:53127/cb')).toBe(true);
1944
+ expect(matchRedirectUri('http://127.0.0.1:8080/cb', 'http://127.0.0.1:9999/cb')).toBe(true);
1945
+ });
1946
+
1947
+ it('matchRedirectUri: [::1] with any port matches', () => {
1948
+ expect(matchRedirectUri('http://[::1]/cb', 'http://[::1]:53127/cb')).toBe(true);
1949
+ });
1950
+
1951
+ it('matchRedirectUri: path/scheme mismatch still rejected on loopback', () => {
1952
+ expect(matchRedirectUri('http://127.0.0.1/cb', 'http://127.0.0.1:1/different')).toBe(false);
1953
+ expect(matchRedirectUri('http://127.0.0.1/cb', 'https://127.0.0.1:1/cb')).toBe(false);
1954
+ });
1955
+
1956
+ it('matchRedirectUri: localhost (DNS name) is NOT given the loopback exception', () => {
1957
+ // RFC 8252 §8.3: prefer literal IPs; DNS rebinding risk.
1958
+ expect(matchRedirectUri('http://localhost/cb', 'http://localhost:8080/cb')).toBe(false);
1959
+ });
1960
+
1961
+ it('matchRedirectUri: non-loopback hosts get strict exact match', () => {
1962
+ expect(matchRedirectUri('https://app.com/cb', 'https://app.com:8080/cb')).toBe(false);
1963
+ });
1964
+
1965
+ it('end-to-end: registered http://127.0.0.1/cb accepts dynamic-port runtime URI', async () => {
1966
+ const client = await methods.createClient({
1967
+ name: 'Native App',
1968
+ redirectUris: ['http://127.0.0.1/cb'],
1969
+ grantTypes: ['authorization_code'],
1970
+ tokenEndpointAuthMethod: 'none',
1971
+ });
1972
+ // The native app's loopback server picked port 53127 at runtime.
1973
+ // Public clients now require PKCE at mint time (P1.3), so bind a challenge.
1974
+ const challenge = await generateCodeChallenge(generateCodeVerifier());
1975
+ const { code } = await methods.createAuthorizationCode({
1976
+ clientId: client.clientId,
1977
+ userId,
1978
+ redirectUri: 'http://127.0.0.1:53127/cb',
1979
+ codeChallenge: challenge,
1980
+ codeChallengeMethod: 'S256',
1981
+ });
1982
+ expect(code).toBeTruthy();
1983
+ });
1984
+ });
1985
+
1986
+ // ===================================================================
1987
+ // RFC 8414 §2 / RFC 9700 §4.16 — HTTPS issuer assertion in production
1988
+ // ===================================================================
1989
+ describe('hTTPS issuer assertion', () => {
1990
+ const previousEnv = process.env.NODE_ENV;
1991
+ afterAll(() => {
1992
+ process.env.NODE_ENV = previousEnv;
1993
+ });
1994
+
1995
+ it('rejects HTTP issuer URLs in production', () => {
1996
+ process.env.NODE_ENV = 'production';
1997
+ expect(() => oauth({ issuerUrl: 'http://insecure.example.com' })).toThrow(
1998
+ /must use https/,
1999
+ );
2000
+ });
2001
+
2002
+ it('accepts HTTPS issuer URLs in production', () => {
2003
+ process.env.NODE_ENV = 'production';
2004
+ expect(() => oauth({ issuerUrl: 'https://secure.example.com' })).not.toThrow();
2005
+ });
2006
+
2007
+ it('accepts HTTP issuer URLs in development', () => {
2008
+ process.env.NODE_ENV = 'development';
2009
+ expect(() => oauth({ issuerUrl: 'http://localhost:8080' })).not.toThrow();
2010
+ });
2011
+ });
2012
+
2013
+ // ===================================================================
2014
+ // Remediation regressions (P1.1, P1.2, P1.3, P3.4)
2015
+ // ===================================================================
2016
+ describe('remediation: code exchange single-use under concurrency (P1.1)', () => {
2017
+ it('exactly one of two concurrent exchanges of the same code succeeds', async () => {
2018
+ const client = await methods.createClient({
2019
+ name: 'Race App',
2020
+ redirectUris: ['https://app.com/cb'],
2021
+ grantTypes: ['authorization_code'],
2022
+ });
2023
+ const verifier = generateCodeVerifier();
2024
+ const challenge = await generateCodeChallenge(verifier);
2025
+ const { code } = await methods.createAuthorizationCode({
2026
+ clientId: client.clientId,
2027
+ userId,
2028
+ redirectUri: 'https://app.com/cb',
2029
+ codeChallenge: challenge,
2030
+ codeChallengeMethod: 'S256',
2031
+ });
2032
+
2033
+ const results = await Promise.allSettled([
2034
+ methods.exchangeCode({
2035
+ code,
2036
+ clientId: client.clientId,
2037
+ clientSecret: client.clientSecret!,
2038
+ redirectUri: 'https://app.com/cb',
2039
+ codeVerifier: verifier,
2040
+ }),
2041
+ methods.exchangeCode({
2042
+ code,
2043
+ clientId: client.clientId,
2044
+ clientSecret: client.clientSecret!,
2045
+ redirectUri: 'https://app.com/cb',
2046
+ codeVerifier: verifier,
2047
+ }),
2048
+ ]);
2049
+
2050
+ const fulfilled = results.filter(r => r.status === 'fulfilled').length;
2051
+ const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
2052
+ expect(fulfilled).toBe(1);
2053
+ expect(rejected.length).toBe(1);
2054
+ expect((rejected[0].reason as { oauthError?: string }).oauthError).toBe('invalid_grant');
2055
+ });
2056
+ });
2057
+
2058
+ describe('remediation: consent-flow approval single-use + owner check (P1.2 / H6)', () => {
2059
+ it('exactly one of two concurrent approvals of the same flow mints a code', async () => {
2060
+ const client = await methods.createClient({
2061
+ name: 'Approval Race App',
2062
+ redirectUris: ['https://app.com/cb'],
2063
+ grantTypes: ['authorization_code'],
2064
+ });
2065
+ const { flowId } = await methods.createPendingFlow({
2066
+ clientId: client.clientId,
2067
+ redirectUri: 'https://app.com/cb',
2068
+ state: 'approve-race',
2069
+ userId,
2070
+ });
2071
+
2072
+ const results = await Promise.allSettled([
2073
+ methods.handleApproveFlow(flowId, { userId }),
2074
+ methods.handleApproveFlow(flowId, { userId }),
2075
+ ]);
2076
+
2077
+ const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ redirectUrl: string }>[];
2078
+ const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
2079
+ expect(fulfilled).toHaveLength(1);
2080
+ expect(rejected).toHaveLength(1);
2081
+ expect(String(rejected[0].reason)).toMatch(/not found/i);
2082
+
2083
+ const url = new URL(fulfilled[0].value.redirectUrl);
2084
+ expect(url.searchParams.get('code')).toBeTruthy();
2085
+ const codeCount = await db.count({
2086
+ model: 'oauth_authorization_code',
2087
+ where: [{ field: 'clientId', operator: '=', value: client.clientId }],
2088
+ });
2089
+ expect(codeCount).toBe(1);
2090
+ });
2091
+
2092
+ it('a different user gets 404 when reading a pending flow', async () => {
2093
+ const client = await methods.createClient({
2094
+ name: 'IDOR App',
2095
+ redirectUris: ['https://app.com/cb'],
2096
+ grantTypes: ['authorization_code'],
2097
+ });
2098
+ const { flowId } = await methods.createPendingFlow({
2099
+ clientId: client.clientId,
2100
+ redirectUri: 'https://app.com/cb',
2101
+ state: 's',
2102
+ userId, // bound to user A up-front
2103
+ });
2104
+ // Create user B.
2105
+ const userB = await db.create<{ id: string }>({
2106
+ model: 'user',
2107
+ data: { email: 'bob@example.com', name: 'Bob', passwordHash: 'h', isActive: true },
2108
+ });
2109
+
2110
+ await expect(
2111
+ methods.handleGetFlow(flowId, { userId: userB.id }),
2112
+ ).rejects.toThrow(/not found/);
2113
+ await expect(
2114
+ methods.handleApproveFlow(flowId, { userId: userB.id }),
2115
+ ).rejects.toThrow(/not found/);
2116
+ await expect(
2117
+ methods.handleDenyFlow(flowId, { userId: userB.id }),
2118
+ ).rejects.toThrow(/not found/);
2119
+ });
2120
+
2121
+ it('tofu: an unbound flow is claimed by the first authenticated getFlow', async () => {
2122
+ const client = await methods.createClient({
2123
+ name: 'TOFU App',
2124
+ redirectUris: ['https://app.com/cb'],
2125
+ grantTypes: ['authorization_code'],
2126
+ });
2127
+ const { flowId } = await methods.createPendingFlow({
2128
+ clientId: client.clientId,
2129
+ redirectUri: 'https://app.com/cb',
2130
+ state: 's',
2131
+ });
2132
+ // User A reads first — should succeed and bind the flow to them.
2133
+ await methods.handleGetFlow(flowId, { userId });
2134
+ // User B reads next — should 404.
2135
+ const userB = await db.create<{ id: string }>({
2136
+ model: 'user',
2137
+ data: { email: 'eve@example.com', name: 'Eve', passwordHash: 'h', isActive: true },
2138
+ });
2139
+ await expect(
2140
+ methods.handleGetFlow(flowId, { userId: userB.id }),
2141
+ ).rejects.toThrow(/not found/);
2142
+ });
2143
+ });
2144
+
2145
+ describe('remediation: PKCE mandatory for public clients (P1.3 / H7)', () => {
2146
+ it('public client cannot mint a code without an S256 challenge', async () => {
2147
+ const client = await methods.createClient({
2148
+ name: 'Public SPA',
2149
+ redirectUris: ['https://spa.com/cb'],
2150
+ grantTypes: ['authorization_code'],
2151
+ tokenEndpointAuthMethod: 'none',
2152
+ });
2153
+ // P1.3: the mint path itself now refuses to issue a binding-less code
2154
+ // to a public client, so a PKCE-less code never reaches storage.
2155
+ await expect(methods.createAuthorizationCode({
2156
+ clientId: client.clientId,
2157
+ userId,
2158
+ redirectUri: 'https://spa.com/cb',
2159
+ })).rejects.toThrow(/PKCE \(S256\) is required for public clients/);
2160
+ });
2161
+
2162
+ it('a pre-existing public-client code with no challenge still cannot be exchanged', async () => {
2163
+ const client = await methods.createClient({
2164
+ name: 'Public SPA legacy',
2165
+ redirectUris: ['https://spa2.com/cb'],
2166
+ grantTypes: ['authorization_code'],
2167
+ tokenEndpointAuthMethod: 'none',
2168
+ });
2169
+ // Defense-in-depth: simulate a binding-less code that predates the
2170
+ // mint-time guard by inserting it directly (the mint path now refuses
2171
+ // to create one). The exchange path must still reject it.
2172
+ const { raw, hash } = await generateRefreshToken();
2173
+ await db.create({
2174
+ model: 'oauth_authorization_code',
2175
+ data: {
2176
+ code: hash,
2177
+ clientId: client.clientId,
2178
+ userId,
2179
+ redirectUri: 'https://spa2.com/cb',
2180
+ scope: null,
2181
+ codeChallenge: null,
2182
+ codeChallengeMethod: null,
2183
+ nonce: null,
2184
+ authTime: null,
2185
+ expiresAt: new Date(Date.now() + 60_000),
2186
+ usedAt: null,
2187
+ },
2188
+ });
2189
+ await expect(methods.exchangeCode({
2190
+ code: raw,
2191
+ clientId: client.clientId,
2192
+ redirectUri: 'https://spa2.com/cb',
2193
+ })).rejects.toThrow(/PKCE required for public clients/);
2194
+ });
2195
+ });
2196
+
2197
+ describe('remediation: per-client grant_types enforcement (P3.4 / M9)', () => {
2198
+ it('rejects authorization_code on a client not registered for it', async () => {
2199
+ const client = await methods.createClient({
2200
+ name: 'CC only',
2201
+ redirectUris: ['https://app.com/cb'],
2202
+ grantTypes: ['client_credentials'],
2203
+ });
2204
+ // Even though the redirect_uri is registered, the grant_types list
2205
+ // does not include `authorization_code`, so issuance must fail at
2206
+ // /token even if a code somehow exists.
2207
+ const { code } = await methods.createAuthorizationCode({
2208
+ clientId: client.clientId,
2209
+ userId,
2210
+ redirectUri: 'https://app.com/cb',
2211
+ });
2212
+ await expect(methods.exchangeCode({
2213
+ code,
2214
+ clientId: client.clientId,
2215
+ clientSecret: client.clientSecret!,
2216
+ redirectUri: 'https://app.com/cb',
2217
+ })).rejects.toThrow(/authorization_code grant/);
2218
+ });
2219
+ });
2220
+ });