@udibo/oauth2 0.1.0

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 (375) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +174 -0
  3. package/SECURITY.md +77 -0
  4. package/esm/_dnt.polyfills.d.ts +17 -0
  5. package/esm/_dnt.polyfills.js +15 -0
  6. package/esm/adapters/hono/_common.d.ts +66 -0
  7. package/esm/adapters/hono/_common.js +108 -0
  8. package/esm/adapters/hono/authorization-server.d.ts +211 -0
  9. package/esm/adapters/hono/authorization-server.js +214 -0
  10. package/esm/adapters/hono/bff/auth-request-store.d.ts +164 -0
  11. package/esm/adapters/hono/bff/auth-request-store.js +154 -0
  12. package/esm/adapters/hono/bff/bff.d.ts +852 -0
  13. package/esm/adapters/hono/bff/bff.js +1194 -0
  14. package/esm/adapters/hono/bff/cookie-prefix.d.ts +72 -0
  15. package/esm/adapters/hono/bff/cookie-prefix.js +85 -0
  16. package/esm/adapters/hono/bff/mod.d.ts +19 -0
  17. package/esm/adapters/hono/bff/mod.js +19 -0
  18. package/esm/adapters/hono/bff/proxy.d.ts +108 -0
  19. package/esm/adapters/hono/bff/proxy.js +314 -0
  20. package/esm/adapters/hono/bff/session-store.d.ts +250 -0
  21. package/esm/adapters/hono/bff/session-store.js +216 -0
  22. package/esm/adapters/hono/bff/testing.d.ts +165 -0
  23. package/esm/adapters/hono/bff/testing.js +254 -0
  24. package/esm/adapters/hono/identity.d.ts +117 -0
  25. package/esm/adapters/hono/identity.js +215 -0
  26. package/esm/adapters/hono/log.d.ts +49 -0
  27. package/esm/adapters/hono/log.js +66 -0
  28. package/esm/adapters/hono/resource-server.d.ts +101 -0
  29. package/esm/adapters/hono/resource-server.js +108 -0
  30. package/esm/client/_http.d.ts +91 -0
  31. package/esm/client/_http.js +303 -0
  32. package/esm/client/base.d.ts +184 -0
  33. package/esm/client/base.js +93 -0
  34. package/esm/client/bff-client.d.ts +235 -0
  35. package/esm/client/bff-client.js +280 -0
  36. package/esm/client/browser-storage.d.ts +152 -0
  37. package/esm/client/browser-storage.js +246 -0
  38. package/esm/client/check.d.ts +114 -0
  39. package/esm/client/check.js +97 -0
  40. package/esm/client/direct-client.d.ts +707 -0
  41. package/esm/client/direct-client.js +1237 -0
  42. package/esm/client/discovery-cache.d.ts +138 -0
  43. package/esm/client/discovery-cache.js +124 -0
  44. package/esm/client/events.d.ts +54 -0
  45. package/esm/client/events.js +37 -0
  46. package/esm/client/mod.d.ts +35 -0
  47. package/esm/client/mod.js +33 -0
  48. package/esm/client/storage.d.ts +127 -0
  49. package/esm/client/storage.js +81 -0
  50. package/esm/deps/jsr.io/@std/assert/1.0.19/almost_equals.d.ts +29 -0
  51. package/esm/deps/jsr.io/@std/assert/1.0.19/almost_equals.js +47 -0
  52. package/esm/deps/jsr.io/@std/assert/1.0.19/array_includes.d.ts +28 -0
  53. package/esm/deps/jsr.io/@std/assert/1.0.19/array_includes.js +65 -0
  54. package/esm/deps/jsr.io/@std/assert/1.0.19/assert.d.ts +16 -0
  55. package/esm/deps/jsr.io/@std/assert/1.0.19/assert.js +23 -0
  56. package/esm/deps/jsr.io/@std/assert/1.0.19/assertion_error.d.ts +25 -0
  57. package/esm/deps/jsr.io/@std/assert/1.0.19/assertion_error.js +30 -0
  58. package/esm/deps/jsr.io/@std/assert/1.0.19/equal.d.ts +24 -0
  59. package/esm/deps/jsr.io/@std/assert/1.0.19/equal.js +193 -0
  60. package/esm/deps/jsr.io/@std/assert/1.0.19/equals.d.ts +36 -0
  61. package/esm/deps/jsr.io/@std/assert/1.0.19/equals.js +61 -0
  62. package/esm/deps/jsr.io/@std/assert/1.0.19/exists.d.ts +17 -0
  63. package/esm/deps/jsr.io/@std/assert/1.0.19/exists.js +27 -0
  64. package/esm/deps/jsr.io/@std/assert/1.0.19/fail.d.ts +14 -0
  65. package/esm/deps/jsr.io/@std/assert/1.0.19/fail.js +20 -0
  66. package/esm/deps/jsr.io/@std/assert/1.0.19/false.d.ts +18 -0
  67. package/esm/deps/jsr.io/@std/assert/1.0.19/false.js +23 -0
  68. package/esm/deps/jsr.io/@std/assert/1.0.19/greater.d.ts +19 -0
  69. package/esm/deps/jsr.io/@std/assert/1.0.19/greater.js +29 -0
  70. package/esm/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.d.ts +19 -0
  71. package/esm/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.js +29 -0
  72. package/esm/deps/jsr.io/@std/assert/1.0.19/instance_of.d.ts +22 -0
  73. package/esm/deps/jsr.io/@std/assert/1.0.19/instance_of.js +52 -0
  74. package/esm/deps/jsr.io/@std/assert/1.0.19/is_error.d.ts +24 -0
  75. package/esm/deps/jsr.io/@std/assert/1.0.19/is_error.js +53 -0
  76. package/esm/deps/jsr.io/@std/assert/1.0.19/less.d.ts +18 -0
  77. package/esm/deps/jsr.io/@std/assert/1.0.19/less.js +28 -0
  78. package/esm/deps/jsr.io/@std/assert/1.0.19/less_or_equal.d.ts +19 -0
  79. package/esm/deps/jsr.io/@std/assert/1.0.19/less_or_equal.js +29 -0
  80. package/esm/deps/jsr.io/@std/assert/1.0.19/match.d.ts +17 -0
  81. package/esm/deps/jsr.io/@std/assert/1.0.19/match.js +26 -0
  82. package/esm/deps/jsr.io/@std/assert/1.0.19/mod.d.ts +43 -0
  83. package/esm/deps/jsr.io/@std/assert/1.0.19/mod.js +45 -0
  84. package/esm/deps/jsr.io/@std/assert/1.0.19/not_equals.d.ts +26 -0
  85. package/esm/deps/jsr.io/@std/assert/1.0.19/not_equals.js +39 -0
  86. package/esm/deps/jsr.io/@std/assert/1.0.19/not_instance_of.d.ts +19 -0
  87. package/esm/deps/jsr.io/@std/assert/1.0.19/not_instance_of.js +29 -0
  88. package/esm/deps/jsr.io/@std/assert/1.0.19/not_match.d.ts +17 -0
  89. package/esm/deps/jsr.io/@std/assert/1.0.19/not_match.js +26 -0
  90. package/esm/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.d.ts +22 -0
  91. package/esm/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.js +32 -0
  92. package/esm/deps/jsr.io/@std/assert/1.0.19/object_match.d.ts +28 -0
  93. package/esm/deps/jsr.io/@std/assert/1.0.19/object_match.js +177 -0
  94. package/esm/deps/jsr.io/@std/assert/1.0.19/rejects.d.ts +41 -0
  95. package/esm/deps/jsr.io/@std/assert/1.0.19/rejects.js +53 -0
  96. package/esm/deps/jsr.io/@std/assert/1.0.19/strict_equals.d.ts +23 -0
  97. package/esm/deps/jsr.io/@std/assert/1.0.19/strict_equals.js +58 -0
  98. package/esm/deps/jsr.io/@std/assert/1.0.19/string_includes.d.ts +17 -0
  99. package/esm/deps/jsr.io/@std/assert/1.0.19/string_includes.js +26 -0
  100. package/esm/deps/jsr.io/@std/assert/1.0.19/throws.d.ts +44 -0
  101. package/esm/deps/jsr.io/@std/assert/1.0.19/throws.js +44 -0
  102. package/esm/deps/jsr.io/@std/assert/1.0.19/unimplemented.d.ts +14 -0
  103. package/esm/deps/jsr.io/@std/assert/1.0.19/unimplemented.js +20 -0
  104. package/esm/deps/jsr.io/@std/assert/1.0.19/unreachable.d.ts +14 -0
  105. package/esm/deps/jsr.io/@std/assert/1.0.19/unreachable.js +20 -0
  106. package/esm/deps/jsr.io/@std/async/1.5.0/delay.d.ts +44 -0
  107. package/esm/deps/jsr.io/@std/async/1.5.0/delay.js +84 -0
  108. package/esm/deps/jsr.io/@std/crypto/1.1.0/timing_safe_equal.d.ts +39 -0
  109. package/esm/deps/jsr.io/@std/crypto/1.1.0/timing_safe_equal.js +61 -0
  110. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common16.d.ts +22 -0
  111. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common16.js +51 -0
  112. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common32.d.ts +34 -0
  113. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common32.js +192 -0
  114. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common64.d.ts +34 -0
  115. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common64.js +113 -0
  116. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common_detach.d.ts +3 -0
  117. package/esm/deps/jsr.io/@std/encoding/1.0.11/_common_detach.js +13 -0
  118. package/esm/deps/jsr.io/@std/encoding/1.0.11/_types.d.ts +8 -0
  119. package/esm/deps/jsr.io/@std/encoding/1.0.11/_types.js +2 -0
  120. package/esm/deps/jsr.io/@std/encoding/1.0.11/_validate_binary_like.d.ts +1 -0
  121. package/esm/deps/jsr.io/@std/encoding/1.0.11/_validate_binary_like.js +26 -0
  122. package/esm/deps/jsr.io/@std/encoding/1.0.11/ascii85.d.ts +60 -0
  123. package/esm/deps/jsr.io/@std/encoding/1.0.11/ascii85.js +152 -0
  124. package/esm/deps/jsr.io/@std/encoding/1.0.11/base32.d.ts +39 -0
  125. package/esm/deps/jsr.io/@std/encoding/1.0.11/base32.js +87 -0
  126. package/esm/deps/jsr.io/@std/encoding/1.0.11/base58.d.ts +39 -0
  127. package/esm/deps/jsr.io/@std/encoding/1.0.11/base58.js +131 -0
  128. package/esm/deps/jsr.io/@std/encoding/1.0.11/base64.d.ts +39 -0
  129. package/esm/deps/jsr.io/@std/encoding/1.0.11/base64.js +82 -0
  130. package/esm/deps/jsr.io/@std/encoding/1.0.11/base64url.d.ts +39 -0
  131. package/esm/deps/jsr.io/@std/encoding/1.0.11/base64url.js +72 -0
  132. package/esm/deps/jsr.io/@std/encoding/1.0.11/hex.d.ts +38 -0
  133. package/esm/deps/jsr.io/@std/encoding/1.0.11/hex.js +87 -0
  134. package/esm/deps/jsr.io/@std/encoding/1.0.11/mod.d.ts +97 -0
  135. package/esm/deps/jsr.io/@std/encoding/1.0.11/mod.js +99 -0
  136. package/esm/deps/jsr.io/@std/encoding/1.0.11/varint.d.ts +119 -0
  137. package/esm/deps/jsr.io/@std/encoding/1.0.11/varint.js +209 -0
  138. package/esm/deps/jsr.io/@std/http/1.1.3/status.d.ts +343 -0
  139. package/esm/deps/jsr.io/@std/http/1.1.3/status.js +343 -0
  140. package/esm/deps/jsr.io/@std/internal/1.0.14/assertion_state.d.ts +159 -0
  141. package/esm/deps/jsr.io/@std/internal/1.0.14/assertion_state.js +223 -0
  142. package/esm/deps/jsr.io/@std/internal/1.0.14/build_message.d.ts +79 -0
  143. package/esm/deps/jsr.io/@std/internal/1.0.14/build_message.js +116 -0
  144. package/esm/deps/jsr.io/@std/internal/1.0.14/diff.d.ts +139 -0
  145. package/esm/deps/jsr.io/@std/internal/1.0.14/diff.js +277 -0
  146. package/esm/deps/jsr.io/@std/internal/1.0.14/diff_str.d.ts +98 -0
  147. package/esm/deps/jsr.io/@std/internal/1.0.14/diff_str.js +181 -0
  148. package/esm/deps/jsr.io/@std/internal/1.0.14/format.d.ts +29 -0
  149. package/esm/deps/jsr.io/@std/internal/1.0.14/format.js +68 -0
  150. package/esm/deps/jsr.io/@std/internal/1.0.14/styles.d.ts +158 -0
  151. package/esm/deps/jsr.io/@std/internal/1.0.14/styles.js +206 -0
  152. package/esm/deps/jsr.io/@std/internal/1.0.14/types.d.ts +24 -0
  153. package/esm/deps/jsr.io/@std/internal/1.0.14/types.js +3 -0
  154. package/esm/deps/jsr.io/@std/testing/1.0.20/_test_suite.d.ts +91 -0
  155. package/esm/deps/jsr.io/@std/testing/1.0.20/_test_suite.js +347 -0
  156. package/esm/deps/jsr.io/@std/testing/1.0.20/bdd.d.ts +382 -0
  157. package/esm/deps/jsr.io/@std/testing/1.0.20/bdd.js +1110 -0
  158. package/esm/deps/jsr.io/@udibo/http-error/0.11.1/mod.d.ts +363 -0
  159. package/esm/deps/jsr.io/@udibo/http-error/0.11.1/mod.js +616 -0
  160. package/esm/errors.d.ts +203 -0
  161. package/esm/errors.js +287 -0
  162. package/esm/identity/captcha.d.ts +147 -0
  163. package/esm/identity/captcha.js +64 -0
  164. package/esm/identity/delivery.d.ts +96 -0
  165. package/esm/identity/delivery.js +41 -0
  166. package/esm/identity/errors.d.ts +39 -0
  167. package/esm/identity/errors.js +46 -0
  168. package/esm/identity/events.d.ts +242 -0
  169. package/esm/identity/events.js +39 -0
  170. package/esm/identity/external/_shared.d.ts +86 -0
  171. package/esm/identity/external/_shared.js +107 -0
  172. package/esm/identity/external/_token-exchange.d.ts +61 -0
  173. package/esm/identity/external/_token-exchange.js +179 -0
  174. package/esm/identity/external/apple-client-secret.d.ts +82 -0
  175. package/esm/identity/external/apple-client-secret.js +133 -0
  176. package/esm/identity/external/apple.d.ts +64 -0
  177. package/esm/identity/external/apple.js +248 -0
  178. package/esm/identity/external/discord.d.ts +38 -0
  179. package/esm/identity/external/discord.js +62 -0
  180. package/esm/identity/external/errors.d.ts +48 -0
  181. package/esm/identity/external/errors.js +36 -0
  182. package/esm/identity/external/flow.d.ts +107 -0
  183. package/esm/identity/external/flow.js +158 -0
  184. package/esm/identity/external/github.d.ts +42 -0
  185. package/esm/identity/external/github.js +164 -0
  186. package/esm/identity/external/google.d.ts +52 -0
  187. package/esm/identity/external/google.js +38 -0
  188. package/esm/identity/external/mod.d.ts +63 -0
  189. package/esm/identity/external/mod.js +61 -0
  190. package/esm/identity/external/oauth2.d.ts +110 -0
  191. package/esm/identity/external/oauth2.js +158 -0
  192. package/esm/identity/external/oidc.d.ts +81 -0
  193. package/esm/identity/external/oidc.js +250 -0
  194. package/esm/identity/external/provider.d.ts +132 -0
  195. package/esm/identity/external/provider.js +12 -0
  196. package/esm/identity/hibp.d.ts +67 -0
  197. package/esm/identity/hibp.js +87 -0
  198. package/esm/identity/identifier.d.ts +44 -0
  199. package/esm/identity/identifier.js +47 -0
  200. package/esm/identity/lockout.d.ts +155 -0
  201. package/esm/identity/lockout.js +117 -0
  202. package/esm/identity/mfa/mod.d.ts +42 -0
  203. package/esm/identity/mfa/mod.js +42 -0
  204. package/esm/identity/mfa/recovery-codes.d.ts +96 -0
  205. package/esm/identity/mfa/recovery-codes.js +80 -0
  206. package/esm/identity/mfa/service.d.ts +338 -0
  207. package/esm/identity/mfa/service.js +351 -0
  208. package/esm/identity/mfa/totp.d.ts +186 -0
  209. package/esm/identity/mfa/totp.js +194 -0
  210. package/esm/identity/migration.d.ts +155 -0
  211. package/esm/identity/migration.js +243 -0
  212. package/esm/identity/mod.d.ts +65 -0
  213. package/esm/identity/mod.js +64 -0
  214. package/esm/identity/otp.d.ts +255 -0
  215. package/esm/identity/otp.js +245 -0
  216. package/esm/identity/password-policy.d.ts +45 -0
  217. package/esm/identity/password-policy.js +60 -0
  218. package/esm/identity/password.d.ts +171 -0
  219. package/esm/identity/password.js +160 -0
  220. package/esm/identity/rate-limit.d.ts +192 -0
  221. package/esm/identity/rate-limit.js +168 -0
  222. package/esm/identity/service.d.ts +579 -0
  223. package/esm/identity/service.js +835 -0
  224. package/esm/identity/session.d.ts +122 -0
  225. package/esm/identity/session.js +48 -0
  226. package/esm/identity/token-flow.d.ts +214 -0
  227. package/esm/identity/token-flow.js +173 -0
  228. package/esm/models/authentication.d.ts +24 -0
  229. package/esm/models/authentication.js +1 -0
  230. package/esm/models/authorization-code.d.ts +63 -0
  231. package/esm/models/authorization-code.js +1 -0
  232. package/esm/models/authorization.d.ts +165 -0
  233. package/esm/models/authorization.js +160 -0
  234. package/esm/models/client.d.ts +78 -0
  235. package/esm/models/client.js +1 -0
  236. package/esm/models/device-authorization.d.ts +30 -0
  237. package/esm/models/device-authorization.js +1 -0
  238. package/esm/models/responses.d.ts +93 -0
  239. package/esm/models/responses.js +1 -0
  240. package/esm/models/scope.d.ts +81 -0
  241. package/esm/models/scope.js +125 -0
  242. package/esm/models/token.d.ts +135 -0
  243. package/esm/models/token.js +1 -0
  244. package/esm/package.json +3 -0
  245. package/esm/react/callback.d.ts +52 -0
  246. package/esm/react/callback.js +71 -0
  247. package/esm/react/components/class-names.d.ts +91 -0
  248. package/esm/react/components/class-names.js +118 -0
  249. package/esm/react/components/internal.d.ts +62 -0
  250. package/esm/react/components/internal.js +102 -0
  251. package/esm/react/components/mfa-challenge-form.d.ts +66 -0
  252. package/esm/react/components/mfa-challenge-form.js +46 -0
  253. package/esm/react/components/mfa-enrollment-form.d.ts +83 -0
  254. package/esm/react/components/mfa-enrollment-form.js +41 -0
  255. package/esm/react/components/mod.d.ts +103 -0
  256. package/esm/react/components/mod.js +93 -0
  257. package/esm/react/components/request-password-reset-form.d.ts +61 -0
  258. package/esm/react/components/request-password-reset-form.js +41 -0
  259. package/esm/react/components/reset-password-form.d.ts +66 -0
  260. package/esm/react/components/reset-password-form.js +56 -0
  261. package/esm/react/components/sign-in-form.d.ts +74 -0
  262. package/esm/react/components/sign-in-form.js +43 -0
  263. package/esm/react/components/sign-up-form.d.ts +85 -0
  264. package/esm/react/components/sign-up-form.js +57 -0
  265. package/esm/react/components/types.d.ts +208 -0
  266. package/esm/react/components/types.js +14 -0
  267. package/esm/react/components/use-auth-form.d.ts +118 -0
  268. package/esm/react/components/use-auth-form.js +172 -0
  269. package/esm/react/components/user-menu.d.ts +79 -0
  270. package/esm/react/components/user-menu.js +73 -0
  271. package/esm/react/context.d.ts +51 -0
  272. package/esm/react/context.js +16 -0
  273. package/esm/react/mod.d.ts +26 -0
  274. package/esm/react/mod.js +26 -0
  275. package/esm/react/provider.d.ts +40 -0
  276. package/esm/react/provider.js +189 -0
  277. package/esm/react/require-auth.d.ts +31 -0
  278. package/esm/react/require-auth.js +37 -0
  279. package/esm/react/testing.d.ts +322 -0
  280. package/esm/react/testing.js +461 -0
  281. package/esm/react/use-authorization.d.ts +31 -0
  282. package/esm/react/use-authorization.js +36 -0
  283. package/esm/react/use-oauth2.d.ts +112 -0
  284. package/esm/react/use-oauth2.js +127 -0
  285. package/esm/server/authorization-server.d.ts +736 -0
  286. package/esm/server/authorization-server.js +1304 -0
  287. package/esm/server/client-authentication.d.ts +28 -0
  288. package/esm/server/client-authentication.js +51 -0
  289. package/esm/server/grants/authorization-code.d.ts +179 -0
  290. package/esm/server/grants/authorization-code.js +232 -0
  291. package/esm/server/grants/client-credentials.d.ts +52 -0
  292. package/esm/server/grants/client-credentials.js +53 -0
  293. package/esm/server/grants/device-authorization.d.ts +103 -0
  294. package/esm/server/grants/device-authorization.js +156 -0
  295. package/esm/server/grants/grant.d.ts +165 -0
  296. package/esm/server/grants/grant.js +173 -0
  297. package/esm/server/grants/password.d.ts +80 -0
  298. package/esm/server/grants/password.js +92 -0
  299. package/esm/server/grants/refresh-token.d.ts +80 -0
  300. package/esm/server/grants/refresh-token.js +136 -0
  301. package/esm/server/introspection-token-reader.d.ts +153 -0
  302. package/esm/server/introspection-token-reader.js +164 -0
  303. package/esm/server/jwks-token-reader.d.ts +263 -0
  304. package/esm/server/jwks-token-reader.js +508 -0
  305. package/esm/server/mod.d.ts +23 -0
  306. package/esm/server/mod.js +15 -0
  307. package/esm/server/public-suffix/mod.d.ts +62 -0
  308. package/esm/server/public-suffix/mod.js +103 -0
  309. package/esm/server/public-suffix/rules.d.ts +23 -0
  310. package/esm/server/public-suffix/rules.js +23 -0
  311. package/esm/server/redirect-uri.d.ts +187 -0
  312. package/esm/server/redirect-uri.js +331 -0
  313. package/esm/server/resource-server.d.ts +286 -0
  314. package/esm/server/resource-server.js +365 -0
  315. package/esm/server/services/authorization-code.d.ts +84 -0
  316. package/esm/server/services/authorization-code.js +35 -0
  317. package/esm/server/services/client.d.ts +35 -0
  318. package/esm/server/services/client.js +1 -0
  319. package/esm/server/services/device-authorization.d.ts +134 -0
  320. package/esm/server/services/device-authorization.js +7 -0
  321. package/esm/server/services/token.d.ts +242 -0
  322. package/esm/server/services/token.js +78 -0
  323. package/esm/server/services/user.d.ts +29 -0
  324. package/esm/server/services/user.js +7 -0
  325. package/esm/server/signing-keys.d.ts +201 -0
  326. package/esm/server/signing-keys.js +225 -0
  327. package/esm/server/utils/hash.d.ts +6 -0
  328. package/esm/server/utils/hash.js +11 -0
  329. package/esm/testing/contract/authorization-code.d.ts +37 -0
  330. package/esm/testing/contract/authorization-code.js +93 -0
  331. package/esm/testing/contract/client.d.ts +85 -0
  332. package/esm/testing/contract/client.js +126 -0
  333. package/esm/testing/contract/device-authorization.d.ts +37 -0
  334. package/esm/testing/contract/device-authorization.js +193 -0
  335. package/esm/testing/contract/lockout-store.d.ts +47 -0
  336. package/esm/testing/contract/lockout-store.js +122 -0
  337. package/esm/testing/contract/mfa-store.d.ts +50 -0
  338. package/esm/testing/contract/mfa-store.js +226 -0
  339. package/esm/testing/contract/mod.d.ts +39 -0
  340. package/esm/testing/contract/mod.js +39 -0
  341. package/esm/testing/contract/otp-store.d.ts +48 -0
  342. package/esm/testing/contract/otp-store.js +197 -0
  343. package/esm/testing/contract/rate-limit-store.d.ts +52 -0
  344. package/esm/testing/contract/rate-limit-store.js +105 -0
  345. package/esm/testing/contract/token-flow-store.d.ts +54 -0
  346. package/esm/testing/contract/token-flow-store.js +179 -0
  347. package/esm/testing/contract/token-reader.d.ts +77 -0
  348. package/esm/testing/contract/token-reader.js +78 -0
  349. package/esm/testing/contract/token.d.ts +61 -0
  350. package/esm/testing/contract/token.js +287 -0
  351. package/esm/testing/contract/user.d.ts +63 -0
  352. package/esm/testing/contract/user.js +75 -0
  353. package/esm/testing/mod.d.ts +34 -0
  354. package/esm/testing/mod.js +34 -0
  355. package/esm/testing/server.d.ts +197 -0
  356. package/esm/testing/server.js +193 -0
  357. package/esm/testing/services.d.ts +373 -0
  358. package/esm/testing/services.js +703 -0
  359. package/esm/utils/_buffer.d.ts +10 -0
  360. package/esm/utils/_buffer.js +12 -0
  361. package/esm/utils/_timing-safe.d.ts +13 -0
  362. package/esm/utils/_timing-safe.js +22 -0
  363. package/esm/utils/authentication-context.d.ts +3 -0
  364. package/esm/utils/authentication-context.js +14 -0
  365. package/esm/utils/basic-auth.d.ts +50 -0
  366. package/esm/utils/basic-auth.js +106 -0
  367. package/esm/utils/crypto.d.ts +97 -0
  368. package/esm/utils/crypto.js +162 -0
  369. package/esm/utils/pkce.d.ts +151 -0
  370. package/esm/utils/pkce.js +179 -0
  371. package/esm/utils/text.d.ts +24 -0
  372. package/esm/utils/text.js +28 -0
  373. package/esm/utils/url.d.ts +66 -0
  374. package/esm/utils/url.js +90 -0
  375. package/package.json +92 -0
@@ -0,0 +1,1194 @@
1
+ /**
2
+ * Backend-for-Frontend (BFF) adapter for Hono.
3
+ *
4
+ * Wraps a {@link DirectClient} configured with a client secret
5
+ * and exposes the session endpoints a browser SPA talks to:
6
+ *
7
+ * - `POST /auth/login` — redirects the browser to the authorize endpoint,
8
+ * persisting a per-request state/verifier record and binding its `state` to
9
+ * this browser with a short-lived cookie.
10
+ * - `GET /auth/callback` — requires the browser to present that binding
11
+ * cookie, exchanges the code, opens a session (or, in
12
+ * `sessionMode: "shared"`, attaches the tokens to the app's existing
13
+ * session), and redirects to the app's `return_to` path.
14
+ * - `GET|POST /auth/logout` — destroys the session and (best-effort) revokes
15
+ * the refresh token upstream. Registered for both methods because the
16
+ * shipped React client navigates the browser here (a GET).
17
+ * - `GET /auth/session` — returns `{ isAuthenticated, user }` from the
18
+ * session. **Never** exposes tokens to the browser.
19
+ * - `POST /auth/backchannel` — OIDC Back-Channel Logout receiver, mounted only
20
+ * when `backchannelLogout` is configured. A server-to-server call from the OP
21
+ * that ends the matching server-side session(s).
22
+ *
23
+ * A companion {@link HonoBff.attachToken} middleware reads the session
24
+ * cookie, looks up / refreshes the access token, and sets
25
+ * `Authorization: Bearer <token>` on the inbound request so a co-located
26
+ * resource-server adapter can validate it normally — all in-process, no
27
+ * outbound HTTP.
28
+ *
29
+ * The credentialed surface is **CSRF-protected by default**: a request that
30
+ * carries the session cookie must also carry a custom `x-csrf` header (the
31
+ * shipped `BffClient` and React adapter send it automatically), and the
32
+ * `GET /auth/logout` navigation — which cannot carry one, and is what the
33
+ * shipped client uses — is guarded by a same-origin check that fails closed.
34
+ * `POST /auth/logout` is the stronger path to adopt. See
35
+ * {@link HonoBffCsrfOptions}; pass `csrf: false` to disable.
36
+ *
37
+ * Sign-in itself is bound to the browser that started it, and that binding is
38
+ * **not** configurable: `/auth/login` records the `state` it minted in a
39
+ * short-lived `HttpOnly` `SameSite=Lax` cookie, and `/auth/callback` refuses
40
+ * any authorization response whose `state` that cookie does not vouch for. A
41
+ * pending authorization is therefore never completable from a browser other
42
+ * than the one that began it, whatever
43
+ * {@link HonoBffOptions.authRequestStorage} it is kept in — without it an
44
+ * attacker could start a sign-in and hand the victim the callback URL to log
45
+ * the victim into the attacker's account.
46
+ *
47
+ * @module
48
+ */
49
+ import { Hono } from "hono";
50
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
51
+ import { InvalidRequestError, InvalidTokenError, isOAuth2Error, TemporarilyUnavailableError, } from "../../../errors.js";
52
+ import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
53
+ import { sha256Hash } from "../../../server/utils/hash.js";
54
+ import { OAUTH2_CONTEXT_KEY } from "../_common.js";
55
+ import { loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
56
+ import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
57
+ import { DEFAULT_SESSION_MAX_AGE_MS, MemorySessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
58
+ import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
59
+ /**
60
+ * Whether the authorization server definitively rejected the grant, as opposed
61
+ * to failing to answer. Only these end the session.
62
+ */
63
+ function isRevokedGrant(error) {
64
+ if (!isOAuth2Error(error))
65
+ return false;
66
+ const code = error.extensions.error;
67
+ return code === "invalid_grant" || code === "invalid_token";
68
+ }
69
+ const BFF_PATH_KEYS = [
70
+ "login",
71
+ "callback",
72
+ "logout",
73
+ "session",
74
+ "backchannel",
75
+ ];
76
+ function resolvePaths(paths = {}, options) {
77
+ const basePath = (paths.basePath ?? "/auth").replace(/\/+$/, "");
78
+ const resolved = {
79
+ basePath,
80
+ login: paths.login ?? "/auth/login",
81
+ callback: paths.callback ?? "/auth/callback",
82
+ logout: paths.logout ?? "/auth/logout",
83
+ session: paths.session ?? "/auth/session",
84
+ backchannel: paths.backchannel ?? "/auth/backchannel",
85
+ };
86
+ if (basePath === "")
87
+ return resolved;
88
+ const stray = BFF_PATH_KEYS
89
+ .filter((key) => key !== "backchannel" || options.backchannelMounted)
90
+ .filter((key) => !resolved[key].startsWith(`${basePath}/`))
91
+ .map((key) => `${key} ("${resolved[key]}")`);
92
+ if (stray.length > 0) {
93
+ throw new Error(`HonoBff paths must sit under paths.basePath "${basePath}", but ` +
94
+ `${stray.join(", ")} ${stray.length === 1 ? "does" : "do"} not. ` +
95
+ `routes() strips the base to mount each handler while ` +
96
+ `loginContinuation() and the session probe's logoutUrl hand out the ` +
97
+ `unstripped paths, so a stray path is served at one URL and ` +
98
+ `advertised at another. Move it under "${basePath}/", or set ` +
99
+ `paths.basePath to "" to mount every path verbatim.`);
100
+ }
101
+ return resolved;
102
+ }
103
+ function sessionCookieName(opts) {
104
+ return resolveCookieName({
105
+ name: opts?.name,
106
+ base: "oauth2_session",
107
+ attributes: resolvePrefixConstrainedAttributes(opts),
108
+ consequence: "the session would silently never persist",
109
+ });
110
+ }
111
+ function resolveCsrf(opt) {
112
+ if (opt === false)
113
+ return null;
114
+ return {
115
+ headerName: (opt?.headerName ?? "x-csrf").toLowerCase(),
116
+ headerValue: opt?.headerValue,
117
+ };
118
+ }
119
+ const RESERVED_AUTHORIZE_PARAMS = [
120
+ "response_type",
121
+ "client_id",
122
+ "redirect_uri",
123
+ "state",
124
+ "code_challenge",
125
+ "code_challenge_method",
126
+ "response_mode",
127
+ "nonce",
128
+ "request",
129
+ "request_uri",
130
+ ];
131
+ const RESERVED_AUTHORIZE_PARAM_SET = new Set(RESERVED_AUTHORIZE_PARAMS);
132
+ function assertUsableAuthorizeParamName(name, option) {
133
+ if (name === "" || name !== name.trim()) {
134
+ throw new Error(`${option} may not use ${JSON.stringify(name)} as an ` +
135
+ "authorize-parameter name: a name must be non-empty and free of " +
136
+ "surrounding whitespace. A blank name addresses nothing at the " +
137
+ "authorization server, and a padded one is not the parameter it " +
138
+ `looks like — " state" is sent as "+state", so it would slip past ` +
139
+ "the reserved list below while reading as a name on that list. " +
140
+ "Splitting a list out of an environment variable without trimming " +
141
+ "is the usual source.");
142
+ }
143
+ if (!RESERVED_AUTHORIZE_PARAM_SET.has(name.toLowerCase()))
144
+ return;
145
+ throw new Error(`${option} may not name the authorize parameter "${name}". The login ` +
146
+ `flow reserves ${RESERVED_AUTHORIZE_PARAMS.join(", ")}, and must be ` +
147
+ "the only source of their values: one supplied from outside it would " +
148
+ "variously redirect the authorization code, break the PKCE binding " +
149
+ "the token exchange depends on, unpick the state the login-state " +
150
+ "cookie commits to, move the authorization response off the callback " +
151
+ "URL, pass off replay protection nothing verifies, or supersede the " +
152
+ "parameters beside it. None of them has a supported override.");
153
+ }
154
+ function resolveExtraAuthorizeParams(options) {
155
+ const fixed = Object.freeze({ ...options.extraParams });
156
+ const forwarded = Object.freeze([...(options.forwardedParams ?? [])]);
157
+ const pinnedByFoldedName = new Map();
158
+ for (const name of Object.keys(fixed)) {
159
+ assertUsableAuthorizeParamName(name, "extraParams");
160
+ if (name.toLowerCase() === "scope" && options.scope !== undefined) {
161
+ throw new Error(`extraParams may not set ${JSON.stringify(name)} while ` +
162
+ "HonoBffOptions.scope is set: both fix the scope every authorize " +
163
+ "request carries, so which one a sign-in should send would be " +
164
+ "ambiguous. The two are compared case-insensitively, since an " +
165
+ "authorization server that folds parameter-name case would read " +
166
+ "them as one parameter. Keep HonoBffOptions.scope, which is also " +
167
+ "the value the login flow records as the scope it requested.");
168
+ }
169
+ pinnedByFoldedName.set(name.toLowerCase(), name);
170
+ }
171
+ for (const name of forwarded) {
172
+ assertUsableAuthorizeParamName(name, "forwardedParams");
173
+ if (["acr_values", "max_age"].includes(name.toLowerCase())) {
174
+ throw new Error(`forwardedParams may not name the authentication control "${name}"; ` +
175
+ "pin it in extraParams so the server chooses the assurance and freshness requirements.");
176
+ }
177
+ const pinned = pinnedByFoldedName.get(name.toLowerCase());
178
+ if (pinned !== undefined) {
179
+ throw new Error(`forwardedParams may not list "${name}" while extraParams sets ` +
180
+ `"${pinned}": one pins the value every authorize request carries ` +
181
+ "and the other lets the browser choose it, so which value a " +
182
+ "sign-in should send would be ambiguous. The two are compared " +
183
+ "case-insensitively, since an authorization server that folds " +
184
+ "parameter-name case would read them as one parameter. Keep the " +
185
+ "fixed value in extraParams, or list the name in forwardedParams " +
186
+ "alone.");
187
+ }
188
+ }
189
+ return { fixed, forwarded };
190
+ }
191
+ function authorizeParamsForRequest(resolved, requestParams) {
192
+ const entries = Object.entries(resolved.fixed);
193
+ for (const name of resolved.forwarded) {
194
+ const value = requestParams.get(name);
195
+ if (value !== null)
196
+ entries.push([name, value]);
197
+ }
198
+ const resolvedParams = {};
199
+ const extraEntries = [];
200
+ for (const [name, value] of entries) {
201
+ const folded = name.toLowerCase();
202
+ if (folded === "scope")
203
+ resolvedParams.scope = value || undefined;
204
+ else if (folded === "prompt")
205
+ resolvedParams.prompt = value;
206
+ else
207
+ extraEntries.push([name, value]);
208
+ }
209
+ if (extraEntries.length > 0) {
210
+ resolvedParams.extraParams = Object.fromEntries(extraEntries);
211
+ }
212
+ return resolvedParams;
213
+ }
214
+ function cookieOpts(opts, maxAgeSeconds) {
215
+ const { secure, path, domain } = resolvePrefixConstrainedAttributes(opts);
216
+ return {
217
+ path,
218
+ secure,
219
+ httpOnly: opts?.httpOnly ?? true,
220
+ sameSite: (opts?.sameSite ?? "Strict"),
221
+ domain,
222
+ ...(maxAgeSeconds === undefined ? {} : { maxAge: maxAgeSeconds }),
223
+ };
224
+ }
225
+ const MAX_COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
226
+ const MAX_SESSION_MAX_AGE_MS = MAX_COOKIE_MAX_AGE_SECONDS * 1000;
227
+ function positiveSeconds(value, field) {
228
+ if (!Number.isFinite(value) || value <= 0) {
229
+ throw new Error(`${field} must be a positive number of seconds, got ${value}.`);
230
+ }
231
+ if (value > MAX_COOKIE_MAX_AGE_SECONDS) {
232
+ throw new Error(`${field} must not exceed 400 days (${MAX_COOKIE_MAX_AGE_SECONDS} ` +
233
+ `seconds), got ${value}. Browsers cap cookie lifetimes there, so a ` +
234
+ "larger value would fail when the cookie is written, not here.");
235
+ }
236
+ return value;
237
+ }
238
+ function positiveMs(value, field) {
239
+ if (!Number.isFinite(value) || value <= 0) {
240
+ throw new Error(`${field} must be a positive number of milliseconds, got ${value}.`);
241
+ }
242
+ if (value > MAX_SESSION_MAX_AGE_MS) {
243
+ throw new Error(`${field} must not exceed 400 days (${MAX_SESSION_MAX_AGE_MS} ` +
244
+ `milliseconds), got ${value}. Browsers cap cookie lifetimes there, so ` +
245
+ "a larger value would fail when the cookie is written, not here.");
246
+ }
247
+ return value;
248
+ }
249
+ const LOGIN_STATE_BASE_NAME = "oauth2_login_state";
250
+ const DEFAULT_LOGIN_STATE_TTL_MS = 10 * 60 * 1000;
251
+ const MAX_PENDING_LOGIN_STATES = 3;
252
+ const LOGIN_STATE_SEPARATOR = ".";
253
+ function loginStateCookieName(opts) {
254
+ return resolveCookieName({
255
+ name: undefined,
256
+ base: LOGIN_STATE_BASE_NAME,
257
+ attributes: resolvePrefixConstrainedAttributes(opts),
258
+ consequence: "every sign-in would fail at the callback",
259
+ });
260
+ }
261
+ function loginStateCookieOpts(opts, maxAgeSeconds) {
262
+ const { secure, path, domain } = resolvePrefixConstrainedAttributes(opts);
263
+ return {
264
+ path,
265
+ secure,
266
+ httpOnly: true,
267
+ sameSite: "Lax",
268
+ ...(domain ? { domain } : {}),
269
+ ...(maxAgeSeconds === undefined ? {} : { maxAge: maxAgeSeconds }),
270
+ };
271
+ }
272
+ /**
273
+ * Backend-for-Frontend adapter for Hono. Construct once per app, mount
274
+ * `routes()` under `/auth`, and use `attachToken()` as middleware on
275
+ * protected API routes. Apps that own their login surface (a custom sign-in
276
+ * form, create-account, forgot-password) call `loginContinuation()` to hand
277
+ * back to the OAuth2 flow after authenticating a user.
278
+ */
279
+ export class HonoBff {
280
+ #options;
281
+ #client;
282
+ #store;
283
+ #paths;
284
+ #cookieName;
285
+ #loginStateCookieName;
286
+ #loginStateTtlMs;
287
+ #sessionMaxAgeMs;
288
+ #cookieMaxAgeSeconds;
289
+ #warnedLogoutIsLocalOnly = false;
290
+ #sessionMode;
291
+ #csrf;
292
+ #extraAuthorizeParams;
293
+ #refreshes = new Map();
294
+ /**
295
+ * Defaults are applied for the session store ({@linkcode MemorySessionStore}),
296
+ * session mode (`"own"`), cookie name, and CSRF.
297
+ *
298
+ * @throws {Error} When {@linkcode HonoBffOptions.backchannelLogout} is
299
+ * configured against a store that cannot enumerate sessions (e.g.
300
+ * {@linkcode EncryptedCookieSessionStore}), when an explicit
301
+ * {@linkcode HonoBffCookieOptions.name} carries a `__Host-`/`__Secure-`
302
+ * prefix the other cookie options contradict, when
303
+ * {@linkcode HonoBffCookieOptions.sameSite} is `"None"` without
304
+ * {@linkcode HonoBffCookieOptions.secure}, when any
305
+ * {@linkcode HonoBffPaths} entry falls outside a non-empty
306
+ * {@linkcode HonoBffPaths.basePath}, when the session cookie would die
307
+ * before the session behind it (see
308
+ * {@linkcode HonoBffOptions.sessionMaxAgeMs}), or when
309
+ * {@linkcode HonoBffOptions.extraParams} or
310
+ * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
311
+ * parameter, gives one a name that is empty or not equal to its trimmed
312
+ * form (`" state"` and `"organization "` are refused rather than trimmed),
313
+ * names the same parameter in both, or pins `scope` in
314
+ * {@linkcode HonoBffOptions.extraParams} while
315
+ * {@linkcode HonoBffOptions.scope} is also set.
316
+ */
317
+ constructor(options) {
318
+ this.#options = options;
319
+ this.#client = options.client;
320
+ this.#store = options.sessionStore ?? new MemorySessionStore();
321
+ this.#paths = resolvePaths(options.paths, {
322
+ backchannelMounted: options.backchannelLogout !== undefined,
323
+ });
324
+ this.#cookieName = sessionCookieName(options.cookie);
325
+ this.#loginStateCookieName = loginStateCookieName(options.cookie);
326
+ this.#loginStateTtlMs = options.loginStateTtlMs ??
327
+ DEFAULT_LOGIN_STATE_TTL_MS;
328
+ this.#sessionMode = options.sessionMode ?? "own";
329
+ this.#csrf = resolveCsrf(options.csrf);
330
+ this.#extraAuthorizeParams = resolveExtraAuthorizeParams(options);
331
+ const sessionMaxAgeMs = positiveMs(options.sessionMaxAgeMs ?? DEFAULT_SESSION_MAX_AGE_MS, "sessionMaxAgeMs");
332
+ this.#sessionMaxAgeMs = sessionMaxAgeMs;
333
+ if (this.#sessionMode === "shared") {
334
+ const maxAge = options.cookie?.maxAge;
335
+ this.#cookieMaxAgeSeconds = maxAge === undefined || maxAge === "session"
336
+ ? undefined
337
+ : positiveSeconds(maxAge, "cookie.maxAge");
338
+ }
339
+ else {
340
+ const configuredMaxAge = options.cookie?.maxAge ??
341
+ Math.ceil(sessionMaxAgeMs / 1000);
342
+ this.#cookieMaxAgeSeconds = configuredMaxAge === "session"
343
+ ? undefined
344
+ : positiveSeconds(configuredMaxAge, "cookie.maxAge");
345
+ if (this.#cookieMaxAgeSeconds !== undefined) {
346
+ const storeMaxAgeMs = sessionStoreMaxAgeMs(this.#store);
347
+ const sessionSeconds = Math.ceil(Math.max(sessionMaxAgeMs, storeMaxAgeMs ?? 0) / 1000);
348
+ if (this.#cookieMaxAgeSeconds < sessionSeconds) {
349
+ const cookieSource = options.cookie?.maxAge !== undefined
350
+ ? `cookie.maxAge (${this.#cookieMaxAgeSeconds}s)`
351
+ : `the session cookie's Max-Age (${this.#cookieMaxAgeSeconds}s, ` +
352
+ "taken from sessionMaxAgeMs)";
353
+ const storeDrivesIt = (storeMaxAgeMs ?? 0) > sessionMaxAgeMs;
354
+ const remedy = storeDrivesIt
355
+ ? `Set sessionMaxAgeMs to ${sessionSeconds * 1000} so it matches ` +
356
+ "the session store's maxAgeMs, or lower that maxAgeMs to match " +
357
+ "the lifetime you want."
358
+ : "Raise cookie.maxAge, lower sessionMaxAgeMs (and the " +
359
+ 'store\'s maxAgeMs) to match, or pass cookie.maxAge: "session" ' +
360
+ "for a deliberately ephemeral cookie.";
361
+ throw new Error(`${cookieSource} is shorter than the session lifetime ` +
362
+ `(${sessionSeconds}s): the browser drops the cookie while the ` +
363
+ "session stays active and listed, so the user is signed out " +
364
+ `with no way to reach the session they still have. ${remedy}`);
365
+ }
366
+ }
367
+ }
368
+ if (options.backchannelLogout && !supportsBackchannelLogout(this.#store)) {
369
+ throw new Error("backchannelLogout requires a SessionStore that implements " +
370
+ "destroyByLogout() (a stateful store). The stateless " +
371
+ "EncryptedCookieSessionStore cannot enumerate sessions.");
372
+ }
373
+ }
374
+ /**
375
+ * Cookie name used for the session cookie. Read-only; configured via
376
+ * {@link HonoBffOptions.cookie}.
377
+ */
378
+ get cookieName() {
379
+ return this.#cookieName;
380
+ }
381
+ /**
382
+ * Name of the cookie `/auth/login` binds the pending sign-in's `state` to
383
+ * and `/auth/callback` requires. Read-only; derived from
384
+ * {@link HonoBffOptions.cookie}'s `secure` / `path` / `domain` (it carries
385
+ * the `__Host-` prefix whenever they allow it). A test that drives the
386
+ * login flow by hand must carry this cookie from the login response into
387
+ * the callback request, exactly as a browser would.
388
+ */
389
+ get loginStateCookieName() {
390
+ return this.#loginStateCookieName;
391
+ }
392
+ /**
393
+ * The CSRF header name required on credentialed requests, or `undefined`
394
+ * when CSRF is disabled. Useful in tests that seed a session and then call a
395
+ * protected route — they must send `{ [bff.csrfHeaderName!]: "1" }` alongside
396
+ * the cookie. The shipped `BffClient` / React adapter send it
397
+ * automatically in real apps.
398
+ */
399
+ get csrfHeaderName() {
400
+ return this.#csrf?.headerName;
401
+ }
402
+ /**
403
+ * The underlying session store. Exposed primarily for testing helpers
404
+ * (e.g. seeding a session directly without driving the login flow);
405
+ * production code should not reach in here.
406
+ */
407
+ get sessionStore() {
408
+ return this.#store;
409
+ }
410
+ #hasSessionCookie(c) {
411
+ return getCookie(c, this.#cookieName) !== undefined;
412
+ }
413
+ #cookieOptions() {
414
+ return cookieOpts(this.#options.cookie, this.#cookieMaxAgeSeconds);
415
+ }
416
+ #isBeyondSessionMaxAge(data) {
417
+ if (this.#sessionMode !== "own")
418
+ return false;
419
+ const { createdAt } = data;
420
+ if (!Number.isFinite(createdAt) || createdAt <= 0)
421
+ return false;
422
+ return Date.now() - createdAt > this.#sessionMaxAgeMs;
423
+ }
424
+ async #readLiveSession(c, cookieValue) {
425
+ const data = await this.#store.read(cookieValue);
426
+ if (!data)
427
+ return null;
428
+ if (!this.#isBeyondSessionMaxAge(data))
429
+ return data;
430
+ await this.#store.destroy(cookieValue);
431
+ deleteCookie(c, this.#cookieName, this.#cookieOptions());
432
+ return null;
433
+ }
434
+ #noStore(c) {
435
+ c.header("Cache-Control", "no-store");
436
+ c.header("Vary", "Cookie");
437
+ }
438
+ #loginStateHashes(c) {
439
+ const value = getCookie(c, this.#loginStateCookieName);
440
+ if (!value)
441
+ return [];
442
+ return value.split(LOGIN_STATE_SEPARATOR).filter((hash) => hash.length > 0);
443
+ }
444
+ #writeLoginStateHashes(c, hashes) {
445
+ if (hashes.length === 0) {
446
+ deleteCookie(c, this.#loginStateCookieName, loginStateCookieOpts(this.#options.cookie));
447
+ return;
448
+ }
449
+ setCookie(c, this.#loginStateCookieName, hashes.slice(-MAX_PENDING_LOGIN_STATES).join(LOGIN_STATE_SEPARATOR), loginStateCookieOpts(this.#options.cookie, Math.ceil(this.#loginStateTtlMs / 1000)));
450
+ }
451
+ #derivedRedirectUri(c, origin) {
452
+ if (!this.#options.deriveRedirectUri)
453
+ return undefined;
454
+ const base = origin ?? new URL(c.req.url).origin;
455
+ return new URL(this.#paths.callback, base).toString();
456
+ }
457
+ #csrfHeaderOk(c) {
458
+ if (!this.#csrf)
459
+ return true;
460
+ const value = c.req.header(this.#csrf.headerName);
461
+ if (this.#csrf.headerValue !== undefined) {
462
+ return value === this.#csrf.headerValue;
463
+ }
464
+ return typeof value === "string" && value.length > 0;
465
+ }
466
+ #sameOriginOk(c) {
467
+ const fetchSite = c.req.header("sec-fetch-site");
468
+ if (fetchSite)
469
+ return fetchSite === "same-origin" || fetchSite === "none";
470
+ const target = new URL(c.req.url).origin;
471
+ const check = (val) => {
472
+ if (!val)
473
+ return undefined;
474
+ try {
475
+ return new URL(val).origin === target;
476
+ }
477
+ catch {
478
+ return false;
479
+ }
480
+ };
481
+ const fromOrigin = check(c.req.header("origin"));
482
+ if (fromOrigin !== undefined)
483
+ return fromOrigin;
484
+ const fromReferer = check(c.req.header("referer"));
485
+ if (fromReferer !== undefined)
486
+ return fromReferer;
487
+ return false;
488
+ }
489
+ #csrfReject(c) {
490
+ return c.json({
491
+ error: "csrf_validation_failed",
492
+ error_description: "Missing or invalid CSRF header on a credentialed request.",
493
+ }, 403);
494
+ }
495
+ /**
496
+ * Returns a Hono app with all session endpoints mounted, each registered
497
+ * relative to {@link HonoBffPaths.basePath} (default `/auth`). Mount it under
498
+ * that same base — `app.route("/auth", bff.routes())` — so the served URLs
499
+ * are the configured {@link HonoBffPaths}. To mount at the app root instead,
500
+ * set `paths.basePath` to `""`.
501
+ */
502
+ routes() {
503
+ const app = new Hono();
504
+ const p = this.#pathsRelative();
505
+ app.get(p.login, this.loginHandler());
506
+ app.post(p.login, this.loginHandler());
507
+ app.get(p.callback, this.callbackHandler());
508
+ app.get(p.logout, this.logoutHandler());
509
+ app.post(p.logout, this.logoutHandler());
510
+ app.get(p.session, this.sessionHandler());
511
+ if (this.#options.backchannelLogout) {
512
+ app.post(p.backchannel, this.backchannelHandler());
513
+ }
514
+ return app;
515
+ }
516
+ /**
517
+ * Builds the URL that (re)starts sign-in toward `returnTo`, for an app that
518
+ * owns its login surface (a custom sign-in form, create-account,
519
+ * forgot-password) and needs to hand back to the OAuth2/BFF flow after it
520
+ * authenticates the user.
521
+ *
522
+ * - When `returnTo` is an **in-flight authorize URL** — its path matches the
523
+ * client's authorization endpoint, i.e. the user was bounced to your login
524
+ * surface mid-authorize with the PKCE `state` already minted — it is
525
+ * returned unchanged so the flow **resumes** without a redundant
526
+ * `/auth/login` hop.
527
+ * - Otherwise it returns `${login}?return_to=<returnTo>` so the BFF **starts**
528
+ * a fresh login (mints `state` at the login endpoint, attaches tokens to the
529
+ * session at the callback).
530
+ *
531
+ * `returnTo` is open-redirect-guarded (`safeReturnTo`), so the result is
532
+ * always a safe same-origin path. This is the mechanism of the own-auth + BFF
533
+ * topology; the credential form, validation, and copy stay in your app. It
534
+ * replaces hand-rolled helpers that hardcode the authorize + login paths —
535
+ * the BFF already knows both from its client config and `paths`.
536
+ *
537
+ * ```ts
538
+ * // after your sign-in / create-account action authenticates the user:
539
+ * return c.redirect(bff.loginContinuation(returnTo));
540
+ * ```
541
+ *
542
+ * Note: the in-flight-authorize check needs the client's authorization
543
+ * endpoint to be known synchronously (an explicit `endpoints.authorization`,
544
+ * or after `client.discover()` has run). For an issuer-only client that has
545
+ * not discovered yet, every `returnTo` is treated as "start a fresh login".
546
+ */
547
+ loginContinuation(returnTo) {
548
+ return computeLoginContinuation(returnTo, {
549
+ authorizeEndpoint: this.#client.authorizationEndpoint,
550
+ loginPath: this.#paths.login,
551
+ defaultReturnTo: this.#options.defaultReturnTo,
552
+ });
553
+ }
554
+ #pathsRelative() {
555
+ const { basePath } = this.#paths;
556
+ const strip = (p) => basePath && p.startsWith(`${basePath}/`) ? p.slice(basePath.length) : p;
557
+ return {
558
+ basePath,
559
+ login: strip(this.#paths.login),
560
+ callback: strip(this.#paths.callback),
561
+ logout: strip(this.#paths.logout),
562
+ session: strip(this.#paths.session),
563
+ backchannel: strip(this.#paths.backchannel),
564
+ };
565
+ }
566
+ /**
567
+ * Login handler — builds the authorize URL, binds the `state` it minted to
568
+ * this browser with the login-state cookie, and redirects the browser.
569
+ *
570
+ * The authorize request carries every {@link HonoBffOptions.extraParams}
571
+ * entry, plus each {@link HonoBffOptions.forwardedParams} name this
572
+ * request's query string supplies a value for. Nothing else off this
573
+ * request's query string reaches the authorize URL: `scope` falls back to
574
+ * {@link HonoBffOptions.scope} and `prompt` is sent only when one of those
575
+ * two options supplies it.
576
+ */
577
+ loginHandler() {
578
+ return async (c) => {
579
+ this.#noStore(c);
580
+ const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
581
+ const origin = this.#options.resolveOrigin?.(c);
582
+ const requested = authorizeParamsForRequest(this.#extraAuthorizeParams, new URL(c.req.url).searchParams);
583
+ const { url, state } = await this.#client.login({
584
+ returnTo,
585
+ scope: requested.scope ?? this.#options.scope,
586
+ origin,
587
+ prompt: requested.prompt,
588
+ extraParams: requested.extraParams,
589
+ redirectUri: this.#derivedRedirectUri(c, origin),
590
+ authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
591
+ });
592
+ this.#writeLoginStateHashes(c, [
593
+ ...this.#loginStateHashes(c),
594
+ await sha256Hash(state),
595
+ ]);
596
+ return c.redirect(url);
597
+ };
598
+ }
599
+ /**
600
+ * Callback handler — requires the login-state cookie to vouch for the
601
+ * returned `state`, then exchanges the code and opens a session.
602
+ */
603
+ callbackHandler() {
604
+ return async (c) => {
605
+ this.#noStore(c);
606
+ const params = new URL(c.req.url).searchParams;
607
+ const fail = (error) => this.#options.onCallbackError
608
+ ? this.#options.onCallbackError(c, error)
609
+ : c.json({ error: error.error, error_description: error.error_description }, 400);
610
+ if (params.get("error")) {
611
+ return await fail({
612
+ error: params.get("error"),
613
+ error_description: params.get("error_description") ?? "",
614
+ });
615
+ }
616
+ const code = params.get("code");
617
+ const state = params.get("state");
618
+ if (!code || !state) {
619
+ return await fail({
620
+ error: "invalid_request",
621
+ error_description: "missing code/state",
622
+ });
623
+ }
624
+ const pendingStates = this.#loginStateHashes(c);
625
+ const matched = timingSafeMatchIndex(pendingStates, await sha256Hash(state));
626
+ if (matched === undefined) {
627
+ return await fail({
628
+ error: "invalid_request",
629
+ error_description: "state was not started in this browser",
630
+ });
631
+ }
632
+ this.#writeLoginStateHashes(c, pendingStates.filter((_, index) => index !== matched));
633
+ try {
634
+ const origin = this.#options.resolveOrigin?.(c);
635
+ const { tokens, refreshToken, returnTo, raw } = await this.#client
636
+ .exchangeAuthorizationCode(code, state, {
637
+ origin,
638
+ redirectUri: this.#derivedRedirectUri(c, origin),
639
+ authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
640
+ });
641
+ let user = null;
642
+ let sid;
643
+ const idToken = raw.id_token;
644
+ if (idToken) {
645
+ try {
646
+ const claims = this.#client.decodeIdToken(idToken);
647
+ user = claims;
648
+ const claimSid = claims.sid;
649
+ if (typeof claimSid === "string")
650
+ sid = claimSid;
651
+ }
652
+ catch {
653
+ user = null;
654
+ }
655
+ }
656
+ if (this.#options.resolveUser) {
657
+ user = await this.#options.resolveUser(tokens, user);
658
+ }
659
+ const now = Date.now();
660
+ const data = {
661
+ tokens,
662
+ refreshToken,
663
+ user,
664
+ sid,
665
+ createdAt: now,
666
+ updatedAt: now,
667
+ };
668
+ const existing = this.#sessionMode === "shared"
669
+ ? getCookie(c, this.#cookieName)
670
+ : undefined;
671
+ const cookieValue = existing
672
+ ? await this.#store.update(existing, data)
673
+ : await this.#store.create(data);
674
+ if (cookieValue !== existing) {
675
+ setCookie(c, this.#cookieName, cookieValue, this.#cookieOptions());
676
+ }
677
+ return c.redirect(safeReturnTo(returnTo, this.#options.defaultReturnTo ?? "/"));
678
+ }
679
+ catch (cause) {
680
+ return await fail({
681
+ error: "invalid_grant",
682
+ error_description: "code exchange failed",
683
+ cause,
684
+ });
685
+ }
686
+ };
687
+ }
688
+ /** Logout handler — revokes the refresh token and clears the session cookie. */
689
+ logoutHandler() {
690
+ return async (c) => {
691
+ this.#noStore(c);
692
+ const cookieValue = getCookie(c, this.#cookieName);
693
+ if (cookieValue && this.#csrf) {
694
+ const ok = c.req.method === "GET"
695
+ ? this.#sameOriginOk(c)
696
+ : this.#csrfHeaderOk(c);
697
+ if (!ok)
698
+ return this.#csrfReject(c);
699
+ }
700
+ let idToken;
701
+ if (cookieValue) {
702
+ const data = await this.#store.read(cookieValue);
703
+ idToken = data?.tokens?.idToken;
704
+ if (data?.refreshToken) {
705
+ try {
706
+ await this.#client.revoke(data.refreshToken, {
707
+ tokenTypeHint: "refresh_token",
708
+ });
709
+ }
710
+ catch {
711
+ // best-effort
712
+ }
713
+ }
714
+ await this.#store.destroy(cookieValue);
715
+ deleteCookie(c, this.#cookieName, this.#cookieOptions());
716
+ }
717
+ const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
718
+ if (this.#options.rpInitiatedLogout) {
719
+ const endSession = this.#client.endSessionEndpoint;
720
+ if (endSession) {
721
+ const origin = this.#options.resolveOrigin?.(c) ??
722
+ new URL(c.req.url).origin;
723
+ const url = new URL(endSession, origin);
724
+ if (idToken)
725
+ url.searchParams.set("id_token_hint", idToken);
726
+ url.searchParams.set("client_id", this.#client.clientId);
727
+ url.searchParams.set("post_logout_redirect_uri", new URL(returnTo, origin).toString());
728
+ return c.redirect(url.toString());
729
+ }
730
+ this.#warnLogoutIsLocalOnly();
731
+ }
732
+ return c.redirect(returnTo);
733
+ };
734
+ }
735
+ #warnLogoutIsLocalOnly() {
736
+ if (this.#warnedLogoutIsLocalOnly)
737
+ return;
738
+ this.#warnedLogoutIsLocalOnly = true;
739
+ console.warn("[@udibo/oauth2] rpInitiatedLogout is enabled but no end_session " +
740
+ "endpoint is known, so logout only clears this app's session — the " +
741
+ "identity provider's stays alive and the next sign-in will be " +
742
+ "silent. Give the client an `issuer` so discovery can find the " +
743
+ "endpoint, or add `endpoints.endSession` explicitly.");
744
+ }
745
+ /** Session probe — returns `{ isAuthenticated, user }` without tokens. */
746
+ sessionHandler() {
747
+ return async (c) => {
748
+ this.#noStore(c);
749
+ const cookieValue = getCookie(c, this.#cookieName);
750
+ if (!cookieValue) {
751
+ return c.json({ isAuthenticated: false, user: null });
752
+ }
753
+ if (this.#csrf && !this.#csrfHeaderOk(c))
754
+ return this.#csrfReject(c);
755
+ const data = await this.#readLiveSession(c, cookieValue);
756
+ if (!data) {
757
+ return c.json({ isAuthenticated: false, user: null });
758
+ }
759
+ const expiresAt = data.tokens.accessTokenExpiresAt;
760
+ const sessionExpiresIn = expiresAt !== undefined
761
+ ? Math.max(0, Math.round((expiresAt - Date.now()) / 1000))
762
+ : null;
763
+ return c.json({
764
+ isAuthenticated: true,
765
+ user: data.user ?? null,
766
+ sessionExpiresIn,
767
+ logoutUrl: this.#paths.logout,
768
+ });
769
+ };
770
+ }
771
+ /**
772
+ * OIDC Back-Channel Logout receiver. A server-to-server `POST` from the OP
773
+ * carrying a signed `logout_token`; the configured verifier validates it (and
774
+ * returns the subject / OP session id), then the matching server-side
775
+ * session(s) are destroyed. Responds `200` on success and `400` on a
776
+ * missing/invalid token, with `Cache-Control: no-store`, per the spec.
777
+ */
778
+ backchannelHandler() {
779
+ return async (c) => {
780
+ c.header("Cache-Control", "no-store");
781
+ const opts = this.#options.backchannelLogout;
782
+ if (!opts)
783
+ return c.json({ error: "not_supported" }, 400);
784
+ let logoutToken;
785
+ try {
786
+ logoutToken = (await c.req.formData()).get("logout_token")?.toString();
787
+ }
788
+ catch {
789
+ logoutToken = undefined;
790
+ }
791
+ if (!logoutToken) {
792
+ return c.json({
793
+ error: "invalid_request",
794
+ error_description: "missing logout_token",
795
+ }, 400);
796
+ }
797
+ let subject;
798
+ try {
799
+ subject = await opts.verifyLogoutToken(logoutToken);
800
+ }
801
+ catch {
802
+ subject = null;
803
+ }
804
+ if (!subject || (subject.sub === undefined && subject.sid === undefined)) {
805
+ return c.json({
806
+ error: "invalid_request",
807
+ error_description: "invalid logout_token",
808
+ }, 400);
809
+ }
810
+ if (!supportsBackchannelLogout(this.#store)) {
811
+ return c.json({ error: "not_supported" }, 400);
812
+ }
813
+ await this.#store.destroyByLogout({ sub: subject.sub, sid: subject.sid });
814
+ return c.body(null, 200);
815
+ };
816
+ }
817
+ async #resolveSessionEntry(c) {
818
+ const cookieValue = getCookie(c, this.#cookieName);
819
+ if (!cookieValue)
820
+ return { status: "anonymous" };
821
+ const data = await this.#readLiveSession(c, cookieValue);
822
+ if (!data)
823
+ return { status: "anonymous" };
824
+ const skew = (this.#options.refreshSkewSeconds ?? 30) * 1000;
825
+ const needsRefresh = data.tokens.accessTokenExpiresAt !== undefined
826
+ ? data.tokens.accessTokenExpiresAt - skew < Date.now()
827
+ : false;
828
+ if (!needsRefresh || !data.refreshToken) {
829
+ return { status: "active", entry: { cookieValue, data } };
830
+ }
831
+ const outcome = await this.#refreshSessionEntry(c, { cookieValue, data });
832
+ if (outcome.status === "refreshed") {
833
+ return { status: "active", entry: outcome.entry };
834
+ }
835
+ return outcome.status === "unavailable"
836
+ ? { status: "unavailable" }
837
+ : { status: "anonymous" };
838
+ }
839
+ #temporarilyUnavailable(c) {
840
+ return this.#errorResponse(c, new TemporarilyUnavailableError(502, "token refresh temporarily unavailable"));
841
+ }
842
+ async #refreshSessionEntry(c, entry) {
843
+ const outcome = await this.#sharedRefresh(entry);
844
+ if (outcome.status === "refreshed") {
845
+ if (outcome.entry.cookieValue !== entry.cookieValue) {
846
+ setCookie(c, this.#cookieName, outcome.entry.cookieValue, this.#cookieOptions());
847
+ }
848
+ }
849
+ else if (outcome.status === "revoked") {
850
+ deleteCookie(c, this.#cookieName, this.#cookieOptions());
851
+ }
852
+ return outcome;
853
+ }
854
+ #sharedRefresh(entry) {
855
+ const inFlight = this.#refreshes.get(entry.cookieValue);
856
+ if (inFlight)
857
+ return inFlight;
858
+ const pending = this.#exchangeRefresh(entry).finally(() => {
859
+ this.#refreshes.delete(entry.cookieValue);
860
+ });
861
+ this.#refreshes.set(entry.cookieValue, pending);
862
+ return pending;
863
+ }
864
+ async #exchangeRefresh(entry) {
865
+ const { cookieValue, data } = entry;
866
+ if (!data.refreshToken)
867
+ return { status: "unavailable" };
868
+ try {
869
+ const result = await this.#client.exchangeRefreshToken(data.refreshToken);
870
+ const refreshed = {
871
+ tokens: result.tokens,
872
+ refreshToken: result.refreshToken ?? data.refreshToken,
873
+ user: data.user,
874
+ sid: data.sid,
875
+ createdAt: data.createdAt,
876
+ updatedAt: Date.now(),
877
+ };
878
+ const newCookieValue = await this.#store.update(cookieValue, refreshed);
879
+ return {
880
+ status: "refreshed",
881
+ entry: { cookieValue: newCookieValue, data: refreshed },
882
+ };
883
+ }
884
+ catch (error) {
885
+ if (!isRevokedGrant(error))
886
+ return { status: "unavailable" };
887
+ const current = await this.#store.read(cookieValue);
888
+ if (current && current.refreshToken !== data.refreshToken) {
889
+ return { status: "refreshed", entry: { cookieValue, data: current } };
890
+ }
891
+ await this.#store.destroy(cookieValue);
892
+ return { status: "revoked" };
893
+ }
894
+ }
895
+ /**
896
+ * Middleware that reads the session cookie, refreshes the access token
897
+ * if it's near expiry, and sets `Authorization: Bearer <token>` on the
898
+ * inbound request so downstream middleware can read it from the
899
+ * standard header.
900
+ *
901
+ * Prefer {@link protect} for the common case — it authenticates against
902
+ * the configured `resourceServer` directly without mutating the
903
+ * request, so middleware ordering doesn't matter. Reach for
904
+ * `attachToken` only when composing with downstream middleware that
905
+ * expects a bearer token in the `Authorization` header (e.g. a custom
906
+ * resource-server-like guard, or an in-process proxy).
907
+ *
908
+ * **Ordering caveat**: mount `attachToken` *before* any middleware
909
+ * that reads the request body (CSRF parsers, validators, etc.).
910
+ * `attachToken` replaces `c.req.raw` with a clone that carries the
911
+ * Authorization header, which transfers the body stream from the
912
+ * original Request to the clone. Downstream middleware that reads
913
+ * through `c.req` is fine; middleware that captured a pre-clone
914
+ * reference to `c.req.raw` will see a "used" body on it.
915
+ *
916
+ * Unauthenticated requests fall through without a header — the
917
+ * downstream guard returns the proper 401 with `WWW-Authenticate`.
918
+ */
919
+ attachToken() {
920
+ return async (c, next) => {
921
+ if (this.#csrf && this.#hasSessionCookie(c)) {
922
+ const inboundAuth = c.req.header("Authorization") ?? "";
923
+ const hasBearer = /^bearer\s+\S/i.test(inboundAuth);
924
+ if (!hasBearer && !this.#csrfHeaderOk(c))
925
+ return this.#csrfReject(c);
926
+ }
927
+ const resolution = await this.#resolveSessionEntry(c);
928
+ if (resolution.status === "unavailable") {
929
+ return this.#temporarilyUnavailable(c);
930
+ }
931
+ if (resolution.status !== "active") {
932
+ await next();
933
+ return;
934
+ }
935
+ const headers = new Headers(c.req.raw.headers);
936
+ headers.set("Authorization", `Bearer ${resolution.entry.data.tokens.accessToken}`);
937
+ c.req.raw = new Request(c.req.raw, { headers });
938
+ await next();
939
+ };
940
+ }
941
+ /**
942
+ * Returns a Hono middleware that asserts an **already-authenticated**
943
+ * request (guarded upstream by {@link protect}) carries `requiredScope`,
944
+ * **without** re-validating the token — the per-route companion to
945
+ * `protect` for mounts whose routes need different scopes (e.g. `read` vs
946
+ * `write`). Delegates to the configured resource server's `requireScope`;
947
+ * see {@link HonoResourceServer.requireScope}.
948
+ *
949
+ * @throws {Error} When the BFF was constructed without a `resourceServer` —
950
+ * thrown when the middleware is built, not per request.
951
+ *
952
+ * @example
953
+ * ```ts
954
+ * app.use("/api/*", bff.protect()); // session cookie OR bearer
955
+ * app.get("/api/items", bff.requireScope("read"), list);
956
+ * app.post("/api/items", bff.requireScope("write"), create);
957
+ * ```
958
+ */
959
+ requireScope(requiredScope) {
960
+ const resourceServer = this.#options.resourceServer;
961
+ if (!resourceServer) {
962
+ throw new Error("HonoBff.requireScope() requires a `resourceServer` option (the same " +
963
+ "one `protect` authenticates against).");
964
+ }
965
+ return resourceServer.requireScope(requiredScope);
966
+ }
967
+ /**
968
+ * Middleware that authenticates the request against the configured
969
+ * `resourceServer`, accepting **either** auth style so one guard protects an
970
+ * API used by both the BFF's own frontend and machine-to-machine clients:
971
+ *
972
+ * - **Browser frontend** — no `Authorization` header; the access token is
973
+ * resolved from the BFF session cookie (refreshed if near expiry) and
974
+ * validated. Available only when the BFF was constructed with a
975
+ * `resourceServer` option.
976
+ * - **Machine-to-machine** — an inbound `Authorization: Bearer <token>` is
977
+ * validated directly (e.g. a `client_credentials` token), so direct API
978
+ * clients work without a session cookie.
979
+ *
980
+ * Unlike `attachToken + resourceServer.protect()`, this does **not**
981
+ * mutate `c.req.raw` for the session path — the token is read from the
982
+ * session and handed to `resourceServer.authenticate()` directly, so
983
+ * middleware ordering doesn't matter and earlier middleware that already
984
+ * touched the request body is unaffected.
985
+ *
986
+ * ```ts
987
+ * app.use("/api/*", bff.protect("read")); // session cookie OR bearer token
988
+ * ```
989
+ *
990
+ * Without `resourceServer` configured, throws an error with a hint to
991
+ * either pass `resourceServer` or compose the middleware chain manually
992
+ * (`app.use(bff.attachToken(), resourceServer.protect(scope))`).
993
+ */
994
+ protect(requiredScope) {
995
+ const resourceServer = this.#options.resourceServer;
996
+ if (!resourceServer) {
997
+ throw new Error("HonoBff.protect() requires a `resourceServer` option. Either pass " +
998
+ "one to the constructor or compose middleware manually: " +
999
+ "`app.use(bff.attachToken(), resourceServer.protect(scope))`.");
1000
+ }
1001
+ return async (c, next) => {
1002
+ try {
1003
+ const inboundAuth = c.req.header("Authorization") ?? "";
1004
+ const hasBearer = /^bearer\s+\S/i.test(inboundAuth);
1005
+ if (!hasBearer && this.#csrf && this.#hasSessionCookie(c) &&
1006
+ !this.#csrfHeaderOk(c)) {
1007
+ return this.#csrfReject(c);
1008
+ }
1009
+ let authRequest;
1010
+ if (hasBearer) {
1011
+ authRequest = c.req.raw;
1012
+ }
1013
+ else {
1014
+ const resolution = await this.#resolveSessionEntry(c);
1015
+ if (resolution.status === "unavailable") {
1016
+ return this.#temporarilyUnavailable(c);
1017
+ }
1018
+ if (resolution.status === "active") {
1019
+ const headers = new Headers(c.req.raw.headers);
1020
+ headers.set("Authorization", `Bearer ${resolution.entry.data.tokens.accessToken}`);
1021
+ authRequest = new Request(c.req.raw.url, { headers });
1022
+ }
1023
+ else {
1024
+ authRequest = new Request(c.req.raw.url);
1025
+ }
1026
+ }
1027
+ const ctx = await resourceServer.authenticate(authRequest, requiredScope);
1028
+ c.set(OAUTH2_CONTEXT_KEY, ctx);
1029
+ await next();
1030
+ }
1031
+ catch (error) {
1032
+ return resourceServer.handleAuthError(error);
1033
+ }
1034
+ };
1035
+ }
1036
+ #errorResponse(c, error) {
1037
+ if (error.status === 401) {
1038
+ c.header("WWW-Authenticate", `Bearer error="${error.extensions.error ?? "invalid_token"}"`);
1039
+ }
1040
+ return c.json({
1041
+ error: error.extensions.error,
1042
+ error_description: error.message,
1043
+ }, error.status);
1044
+ }
1045
+ /**
1046
+ * Handler that forwards the request to a **separate** resource server with
1047
+ * the session's access token attached, and streams the response back — the
1048
+ * full-proxy BFF of the IETF "OAuth 2.0 for Browser-Based Apps" BCP §6.1.1.
1049
+ * Tokens stay server-side and the browser only ever talks to its own origin,
1050
+ * so there is no CORS to configure.
1051
+ *
1052
+ * Reach for this when the API lives in another service. When the resource
1053
+ * server is co-located in the same process, {@link protect} is faster and
1054
+ * simpler — it validates in-process with no outbound HTTP.
1055
+ *
1056
+ * Mount it as a catch-all and strip the mount prefix:
1057
+ *
1058
+ * @example
1059
+ * ```ts
1060
+ * app.all(
1061
+ * "/api/*",
1062
+ * bff.proxy("https://api.example.com/v1", { stripPrefix: "/api" }),
1063
+ * );
1064
+ * ```
1065
+ *
1066
+ * Semantics:
1067
+ *
1068
+ * - **Session only.** The access token comes from the session cookie
1069
+ * (refreshed when within {@link HonoBffOptions.refreshSkewSeconds} of
1070
+ * expiry, exactly as {@link protect} does). An inbound `Authorization`
1071
+ * header is *not* honored and never forwarded — point machine-to-machine
1072
+ * clients at the resource server directly. No session is `401`
1073
+ * `invalid_token`.
1074
+ * - **CSRF.** The same custom-header check the rest of the credentialed BFF
1075
+ * surface enforces, so mounting a proxy cannot reopen it. See
1076
+ * {@link HonoBffCsrfOptions}.
1077
+ * - **Retry.** An upstream `401` carrying `error="invalid_token"` triggers
1078
+ * one refresh-and-retry, when the session has a refresh token. A refresh
1079
+ * the authorization server rejects clears the session and answers `401`; a
1080
+ * refresh that merely fails to complete (network error, 5xx, 429) leaves
1081
+ * the session intact and answers `502` `temporarily_unavailable`. Bodied
1082
+ * requests are not retried (the body was streamed). Disable with
1083
+ * `retryOn401: false`.
1084
+ * - **Headers.** Only {@link DEFAULT_PROXY_FORWARD_HEADERS} (or your
1085
+ * `forwardHeaders`) travel upstream; the session cookie, inbound
1086
+ * `Authorization`, and hop-by-hop headers never do. Coming back, upstream
1087
+ * `Set-Cookie` and hop-by-hop headers are dropped and everything else —
1088
+ * including problem-details error bodies and their status — is passed
1089
+ * through verbatim.
1090
+ * - **Bodies stream** in both directions; nothing is buffered. Upstream
1091
+ * redirects are returned to the browser rather than followed, so the token
1092
+ * is never replayed to a `Location` the BFF did not vet.
1093
+ *
1094
+ * A `502` with `temporarily_unavailable` is returned when the upstream
1095
+ * cannot be reached, and a `400` `invalid_request` when a path segment has a
1096
+ * malformed percent-escape or hides a `..`.
1097
+ *
1098
+ * **Ordering caveat**: mount this before any middleware that reads the
1099
+ * request body — the proxy forwards `c.req.raw.body` as a stream, and a
1100
+ * middleware that already consumed it leaves nothing to forward.
1101
+ *
1102
+ * @param target Upstream base URL. Fixed here, never derived from the
1103
+ * request, so the proxy cannot be steered at another host. Query parameters
1104
+ * pinned on it are merged into every proxied request and win over an inbound
1105
+ * parameter of the same name.
1106
+ * @param options Prefix stripping, header allowlist, retry, and `fetch`
1107
+ * injection. See {@link HonoBffProxyOptions}.
1108
+ * @returns A Hono handler to mount on a catch-all route.
1109
+ */
1110
+ proxy(target, options = {}) {
1111
+ const base = new URL(target);
1112
+ const allowedHeaders = resolveForwardHeaders(options.forwardHeaders, this.#csrf?.headerName);
1113
+ const fetchImpl = options.fetch ??
1114
+ ((input, init) => fetch(input, init));
1115
+ const retryOn401 = options.retryOn401 ?? true;
1116
+ const mountPrefix = (options.stripPrefix ?? "").endsWith("/")
1117
+ ? options.stripPrefix.slice(0, -1)
1118
+ : options.stripPrefix ?? "";
1119
+ return async (c) => {
1120
+ if (this.#csrf && this.#hasSessionCookie(c) && !this.#csrfHeaderOk(c)) {
1121
+ return this.#csrfReject(c);
1122
+ }
1123
+ let url;
1124
+ try {
1125
+ url = resolveProxyUrl(base, c.req.url, options.stripPrefix);
1126
+ }
1127
+ catch {
1128
+ return this.#errorResponse(c, new InvalidRequestError("invalid proxy path"));
1129
+ }
1130
+ const resolution = await this.#resolveSessionEntry(c);
1131
+ if (resolution.status === "unavailable") {
1132
+ return this.#temporarilyUnavailable(c);
1133
+ }
1134
+ if (resolution.status !== "active") {
1135
+ return this.#errorResponse(c, new InvalidTokenError("no active session"));
1136
+ }
1137
+ const entry = resolution.entry;
1138
+ const body = c.req.raw.body;
1139
+ const send = (accessToken, requestBody) => {
1140
+ const init = {
1141
+ method: c.req.method,
1142
+ headers: buildUpstreamHeaders(c.req.raw.headers, allowedHeaders, accessToken),
1143
+ body: requestBody,
1144
+ redirect: "manual",
1145
+ };
1146
+ if (requestBody)
1147
+ init.duplex = "half";
1148
+ return fetchImpl(url, init);
1149
+ };
1150
+ let response;
1151
+ try {
1152
+ response = await send(entry.data.tokens.accessToken, body);
1153
+ }
1154
+ catch (cause) {
1155
+ return this.#errorResponse(c, new TemporarilyUnavailableError(502, "upstream request failed", {
1156
+ cause,
1157
+ }));
1158
+ }
1159
+ const canRetry = retryOn401 && body === null &&
1160
+ entry.data.refreshToken !== undefined;
1161
+ if (canRetry && isInvalidTokenChallenge(response)) {
1162
+ await response.body?.cancel();
1163
+ const outcome = await this.#refreshSessionEntry(c, entry);
1164
+ if (outcome.status === "revoked") {
1165
+ return this.#errorResponse(c, new InvalidTokenError("session refresh failed"));
1166
+ }
1167
+ if (outcome.status === "unavailable") {
1168
+ return this.#temporarilyUnavailable(c);
1169
+ }
1170
+ try {
1171
+ response = await send(outcome.entry.data.tokens.accessToken, null);
1172
+ }
1173
+ catch (cause) {
1174
+ return this.#errorResponse(c, new TemporarilyUnavailableError(502, "upstream request failed", {
1175
+ cause,
1176
+ }));
1177
+ }
1178
+ }
1179
+ const downstream = response.body;
1180
+ const init = {
1181
+ status: response.status,
1182
+ headers: buildDownstreamHeaders(response, {
1183
+ upstreamUrl: url,
1184
+ base,
1185
+ mountPrefix,
1186
+ }),
1187
+ };
1188
+ return downstream === null ? c.body(null, init) : c.body(downstream, {
1189
+ ...init,
1190
+ status: response.status,
1191
+ });
1192
+ };
1193
+ }
1194
+ }