@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,835 @@
1
+ /**
2
+ * `IdentityService` — composable functions for an app that owns its login.
3
+ *
4
+ * Orchestrates the identity primitives ({@link PasswordIdentityService},
5
+ * {@link TokenFlowService}, delivery hooks, {@link RevocableSessionService})
6
+ * over an **app-owned** {@link IdentityUserStore} into the flows a single app
7
+ * needs: sign-up, sign-in, password reset, email verification, and passwordless
8
+ * sign-in (magic links + one-time codes). These are plain
9
+ * methods you call from your own routes/loaders/actions — there is no opinionated
10
+ * mounted framework here. A thin, optional Hono route factory lives in
11
+ * `@udibo/oauth2/hono/identity` for apps that want the endpoints mounted.
12
+ *
13
+ * Storage-agnostic by design: every flow addresses users by opaque id and the
14
+ * {@link IdentityUserStore} is yours — back it with whatever database, ORM, and
15
+ * schema your app already uses. The library composes the flows; it never owns
16
+ * your tables. It stays below the turnkey-identity-service line.
17
+ *
18
+ * @module
19
+ */
20
+ import { delay } from "../deps/jsr.io/@std/async/1.5.0/delay.js";
21
+ import { buildResetUrl, buildSignInUrl, buildUnlockUrl, buildVerificationUrl, } from "./delivery.js";
22
+ import { PasswordIdentityService, } from "./password.js";
23
+ import { TokenPurpose, } from "./token-flow.js";
24
+ import { assertPasswordPolicy, } from "./password-policy.js";
25
+ import { enforceRateLimit } from "./rate-limit.js";
26
+ import { EmailOtpService, otpRateKey } from "./otp.js";
27
+ import { dispatchIdentityEvent, } from "./events.js";
28
+ import { findLegacyVerifier, } from "./migration.js";
29
+ const DEFAULT_RESET_TTL = 60 * 60 * 1000;
30
+ const DEFAULT_VERIFY_TTL = 24 * 60 * 60 * 1000;
31
+ const DEFAULT_UNLOCK_TTL = 60 * 60 * 1000;
32
+ const DEFAULT_SIGNIN_LINK_TTL = 15 * 60 * 1000;
33
+ const DEFAULT_FAILED_SIGN_IN_FLOOR_MS = 250;
34
+ function foldEmail(email) {
35
+ return email.trim().toLowerCase();
36
+ }
37
+ /**
38
+ * The rate-limit key for each throttled flow, one entry per
39
+ * {@link IdentityRateLimiters} key. Every check and every reset derives its key
40
+ * here, so the window a flow fills is always the window its reset clears.
41
+ *
42
+ * Case-folding is a decision per key type. Email-addressed keys fold, so casing
43
+ * variants of one mailbox share a window instead of each earning a fresh send
44
+ * budget (`signInCode` folds inside {@link otpRateKey}, so the window it fills
45
+ * is the one a standalone {@link EmailOtpService} would). Opaque user ids pass
46
+ * through untouched — only the app knows whether they are case-sensitive.
47
+ * `signin:` is trimmed but not folded: an identifier
48
+ * may be a username or phone whose equality only the app's `findByIdentifier`
49
+ * defines, and folding would merge two distinct accounts (`Bob`, `bob`) into
50
+ * one window, letting failures against either deny sign-in to the other.
51
+ */
52
+ const RATE_KEYS = {
53
+ signIn: (identifier) => `signin:${identifier.trim()}`,
54
+ passwordReset: (email) => `pwreset:${foldEmail(email)}`,
55
+ emailVerification: (userId) => `verifyemail:${userId}`,
56
+ accountUnlock: (userId) => `unlock:${userId}`,
57
+ signInLink: (email) => `pwless:${foldEmail(email)}`,
58
+ signInCode: (email) => otpRateKey(TokenPurpose.SignIn, email),
59
+ };
60
+ /**
61
+ * Composable own-auth flows. Construct once with your dependencies and call the
62
+ * methods from your routes; the credential form, copy, and session creation stay
63
+ * in your app.
64
+ */
65
+ export class IdentityService {
66
+ #users;
67
+ #tokens;
68
+ #passwords;
69
+ #sessions;
70
+ #delivery;
71
+ #baseUrl;
72
+ #ttl;
73
+ #passwordPolicy;
74
+ #rateLimiters;
75
+ #otp;
76
+ #lockout;
77
+ #enforce;
78
+ #onEvent;
79
+ #legacyVerifiers;
80
+ #failedSignInFloorMs;
81
+ /** Wires the user store and optional collaborators; applies the TTL and hasher defaults. */
82
+ constructor(options) {
83
+ this.#users = options.users;
84
+ this.#tokens = options.tokens;
85
+ this.#passwords = options.passwords ?? new PasswordIdentityService();
86
+ this.#sessions = options.sessions;
87
+ this.#delivery = options.delivery;
88
+ this.#baseUrl = options.baseUrl;
89
+ this.#ttl = {
90
+ passwordReset: options.ttl?.passwordReset ?? DEFAULT_RESET_TTL,
91
+ emailVerification: options.ttl?.emailVerification ?? DEFAULT_VERIFY_TTL,
92
+ accountUnlock: options.ttl?.accountUnlock ?? DEFAULT_UNLOCK_TTL,
93
+ signInLink: options.ttl?.signInLink ?? DEFAULT_SIGNIN_LINK_TTL,
94
+ };
95
+ this.#passwordPolicy = options.passwordPolicy;
96
+ this.#rateLimiters = {
97
+ signIn: options.rateLimiters?.signIn ?? options.rateLimiter,
98
+ passwordReset: options.rateLimiters?.passwordReset ?? options.rateLimiter,
99
+ emailVerification: options.rateLimiters?.emailVerification ??
100
+ options.rateLimiter,
101
+ accountUnlock: options.rateLimiters?.accountUnlock ?? options.rateLimiter,
102
+ signInLink: options.rateLimiters?.signInLink ?? options.rateLimiter,
103
+ signInCode: options.rateLimiters?.signInCode ?? options.rateLimiter,
104
+ };
105
+ this.#otp = options.otp
106
+ ? new EmailOtpService({
107
+ store: options.otp.store,
108
+ digits: options.otp.digits,
109
+ ttlMs: options.otp.ttlMs,
110
+ maxAttempts: options.otp.maxAttempts,
111
+ })
112
+ : undefined;
113
+ this.#lockout = options.lockout;
114
+ this.#enforce = (options.protectionMode ?? "enforce") === "enforce";
115
+ this.#onEvent = options.onEvent;
116
+ this.#legacyVerifiers = options.legacyVerifiers?.length
117
+ ? options.legacyVerifiers
118
+ : undefined;
119
+ const floor = options.failedSignInFloorMs ??
120
+ DEFAULT_FAILED_SIGN_IN_FLOOR_MS;
121
+ this.#failedSignInFloorMs = Number.isFinite(floor) && floor >= 0
122
+ ? floor
123
+ : DEFAULT_FAILED_SIGN_IN_FLOOR_MS;
124
+ }
125
+ /**
126
+ * Create a user with a hashed password. `profile` is your app's own fields.
127
+ *
128
+ * @throws {IdentityError} `weak_password` when `password` fails the password
129
+ * policy — including the 8–256 character defaults that apply when
130
+ * {@link IdentityServiceOptions.passwordPolicy} is omitted, and a non-string
131
+ * password (the hash would `String`-coerce it).
132
+ */
133
+ async signUp(input) {
134
+ await assertPasswordPolicy(input.password, this.#passwordPolicy);
135
+ const credential = await this.#passwords.hash(input.password);
136
+ const user = await this.#users.create(input.profile, credential);
137
+ await this.#emit({ type: "sign_up", userId: user.id });
138
+ return user;
139
+ }
140
+ /**
141
+ * Verify credentials; returns the user, or `null` for an unknown identifier or
142
+ * wrong password (don't reveal which). Establishing a session is your app's
143
+ * job — do it on a non-null result.
144
+ *
145
+ * The throttle window is keyed on the identifier as submitted (trimmed, not
146
+ * case-folded), so pass the same normalized form you resolve accounts by —
147
+ * otherwise casing variants of one account each get their own window, and
148
+ * the identifiers you later hand {@link IdentityService.resetSignInThrottle}
149
+ * won't match the ones that filled it.
150
+ *
151
+ * Every rejection is held to
152
+ * {@link IdentityServiceOptions.failedSignInFloorMs} (250 ms by default),
153
+ * measured from entry, so an unknown identifier, a locked account, a wrong
154
+ * native password, and a wrong password against an imported hash all take the
155
+ * same time — the branch that runs a foreign KDF is not visible from
156
+ * outside. A success returns as soon as it is done.
157
+ *
158
+ * @throws {IdentityError} `rate_limited` (with `retryAfterMs`) when a
159
+ * `rateLimiter` is configured, the identifier's limit is hit, and
160
+ * `protectionMode` is `"enforce"`. Rate-limit rejections throw before the
161
+ * account lookup and are not held to the floor.
162
+ */
163
+ async signIn(input) {
164
+ const startedAt = performance.now();
165
+ const identifier = input.identifier.trim();
166
+ // Throttle before lookup so 429s don't reveal whether the account exists.
167
+ await this.#throttle("signIn", identifier, "too many sign-in attempts", {
168
+ type: "sign_in.rate_limited",
169
+ identifier,
170
+ });
171
+ const user = await this.#users.findByIdentifier(identifier);
172
+ if (!user) {
173
+ await this.#equalizeTiming(input.password);
174
+ await this.#emit({
175
+ type: "sign_in.failed",
176
+ identifier,
177
+ reason: "unknown_identifier",
178
+ });
179
+ return await this.#rejectSignIn(startedAt);
180
+ }
181
+ if (await this.#isLockedOut(user.id, identifier)) {
182
+ await this.#equalizeTiming(input.password);
183
+ return await this.#rejectSignIn(startedAt);
184
+ }
185
+ const outcome = await this.#authenticate(user.id, input.password);
186
+ if (outcome !== "authenticated") {
187
+ await this.#emit({
188
+ type: "sign_in.failed",
189
+ identifier,
190
+ reason: outcome,
191
+ userId: user.id,
192
+ });
193
+ if (outcome === "wrong_password") {
194
+ await this.#recordSignInFailure(user.id, identifier);
195
+ }
196
+ return await this.#rejectSignIn(startedAt);
197
+ }
198
+ await this.#resetThrottle("signIn", identifier);
199
+ if (this.#lockout)
200
+ await this.#lockout.reset(user.id);
201
+ await this.#emit({
202
+ type: "sign_in.succeeded",
203
+ userId: user.id,
204
+ identifier,
205
+ });
206
+ return user;
207
+ }
208
+ /**
209
+ * Clear the sign-in rate-limit window for one identifier. Call it after a
210
+ * flow that proves account ownership (account unlock, password reset) with
211
+ * each identifier the user signs in with — otherwise the failed attempts
212
+ * that caused the lockout still fill the window and the user's next sign-in
213
+ * is rejected as rate-limited despite being unlocked. No-op without a
214
+ * `rateLimiter`.
215
+ */
216
+ async resetSignInThrottle(identifier) {
217
+ await this.#resetThrottle("signIn", identifier);
218
+ }
219
+ /**
220
+ * Enumeration-safe: always resolves or throws identically whether or not the
221
+ * email maps to a user. If it does, mints a single-use reset token and calls
222
+ * `delivery.sendPasswordReset`. With a `rateLimiter`, requests throttle per
223
+ * target email, case-folded so casing variants share one window — the check
224
+ * runs before the lookup, so a 429 reveals nothing —
225
+ * and throw {@link IdentityError} `rate_limited` when exceeded; catch it and
226
+ * return your uniform success response. Give it a tighter threshold than
227
+ * sign-in via `rateLimiters.passwordReset`.
228
+ */
229
+ async requestPasswordReset(email) {
230
+ const tokens = this.#requireTokens("requestPasswordReset");
231
+ const normalized = email.trim();
232
+ await this.#throttle("passwordReset", normalized, "too many password reset requests", { type: "password_reset.rate_limited", email: normalized });
233
+ const user = await this.#users.findByEmail(normalized);
234
+ if (!user) {
235
+ await this.#emit({
236
+ type: "password_reset.requested",
237
+ email: normalized,
238
+ });
239
+ return;
240
+ }
241
+ await this.#issueAndDeliver(tokens, {
242
+ hook: "sendPasswordReset",
243
+ to: normalized,
244
+ subject: user.id,
245
+ data: { email: normalized },
246
+ ttlMs: this.#ttl.passwordReset,
247
+ requested: {
248
+ type: "password_reset.requested",
249
+ email: normalized,
250
+ userId: user.id,
251
+ },
252
+ });
253
+ }
254
+ /**
255
+ * Consume a reset token, set the new password, and (if a `sessions` service
256
+ * was configured) revoke the user's other sessions. Clears any failed-attempt
257
+ * lockout — the emailed token proves the same account ownership the unlock
258
+ * flow does. Returns `{ userId }`, or `null` for an invalid/expired/used
259
+ * token.
260
+ *
261
+ * A reset is the user's "lock everyone else out" action, so the outstanding
262
+ * passwordless credentials for the same account are voided too: sign-in links
263
+ * (needs a token store with `deleteBySubject` — without it they stay
264
+ * redeemable until they expire) and, when an `otp` service is configured, the
265
+ * pending sign-in code. Neither can fail the reset: a store that throws is
266
+ * logged and the reset still completes, since the password has already
267
+ * changed by then.
268
+ *
269
+ * Sessions are revoked **after** the password is changed. If that revocation
270
+ * throws, the reset is reported failed — a `password_reset.failed`
271
+ * (`session_revocation_failed`) event fires, no `password_reset.completed`
272
+ * fires, and the error rethrows — rather than silently returning success with
273
+ * the user's old sessions still live. Retry the reset to clear them.
274
+ *
275
+ * @throws {IdentityError} `weak_password` when `password` fails the password
276
+ * policy — including the 8–256 character defaults that apply when
277
+ * {@link IdentityServiceOptions.passwordPolicy} is omitted. The token is left
278
+ * unconsumed, so the user's link still works.
279
+ */
280
+ async resetPassword(input) {
281
+ const tokens = this.#requireTokens("resetPassword");
282
+ // Check the policy before consuming the single-use token so a weak password
283
+ // doesn't burn the user's reset link.
284
+ await assertPasswordPolicy(input.password, this.#passwordPolicy);
285
+ const consumed = await this.#consumeToken({
286
+ tokens,
287
+ purpose: TokenPurpose.PasswordReset,
288
+ token: input.token,
289
+ failed: () => ({
290
+ type: "password_reset.failed",
291
+ reason: "invalid_token",
292
+ }),
293
+ });
294
+ if (consumed.status !== "success")
295
+ return null;
296
+ const { resolved } = consumed;
297
+ await this.#users.setCredential(resolved.subject, await this.#passwords.hash(input.password));
298
+ try {
299
+ await this.#users.clearLegacyCredential?.(resolved.subject);
300
+ }
301
+ catch (error) {
302
+ // A throwing hook must not skip session revocation after the password
303
+ // already changed.
304
+ console.error("[@udibo/oauth2] resetPassword legacy-credential clear failed:", error instanceof Error ? error.message : error);
305
+ }
306
+ await this.#voidPasswordlessCredentials(tokens, resolved.subject, emailFromData(resolved.data));
307
+ if (this.#sessions) {
308
+ try {
309
+ await this.#sessions.revokeAllByUser(resolved.subject);
310
+ }
311
+ catch (error) {
312
+ await this.#emit({
313
+ type: "password_reset.failed",
314
+ reason: "session_revocation_failed",
315
+ });
316
+ throw error;
317
+ }
318
+ }
319
+ await this.#lockout?.reset(resolved.subject);
320
+ await this.#emit({
321
+ type: "password_reset.completed",
322
+ userId: resolved.subject,
323
+ });
324
+ return { userId: resolved.subject };
325
+ }
326
+ /**
327
+ * Mint + deliver an email-verification link for a user. Typically called
328
+ * right after sign-up, or from an authenticated "resend" action — not exposed
329
+ * as an unauthenticated route (you'd be emailing arbitrary user ids). With a
330
+ * `rateLimiter` (or a tighter `rateLimiters.emailVerification`), requests
331
+ * throttle per user and throw {@link IdentityError} `rate_limited` when
332
+ * exceeded — surface it as a friendly cooldown.
333
+ */
334
+ async requestEmailVerification(input) {
335
+ const tokens = this.#requireTokens("requestEmailVerification");
336
+ await this.#throttle("emailVerification", input.userId, "too many verification email requests", {
337
+ type: "email_verification.rate_limited",
338
+ userId: input.userId,
339
+ email: input.email,
340
+ });
341
+ await this.#issueAndDeliver(tokens, {
342
+ hook: "sendEmailVerification",
343
+ to: input.email,
344
+ subject: input.userId,
345
+ ttlMs: this.#ttl.emailVerification,
346
+ data: { email: input.email },
347
+ requested: {
348
+ type: "email_verification.requested",
349
+ userId: input.userId,
350
+ email: input.email,
351
+ },
352
+ });
353
+ }
354
+ /**
355
+ * Consume a verification token and mark the user's email verified.
356
+ *
357
+ * Returns a discriminated {@link VerifyEmailResult} so the UI can tell an
358
+ * **expired** link (offer to resend) from a generically **invalid** one — the
359
+ * `inspect()` distinction, surfaced through the flow. A `valid` token is
360
+ * consumed; a lost race (consumed between inspect and consume) reports
361
+ * `invalid`.
362
+ *
363
+ * The email is marked verified **before** the token is consumed, so a throw
364
+ * from `markEmailVerified` leaves the single-use link unspent and the user
365
+ * can retry with the same link (`markEmailVerified` must be idempotent).
366
+ *
367
+ * The address the token was issued for is passed to
368
+ * {@link IdentityUserStore.markEmailVerified} alongside the user id, so a
369
+ * store that predicates on both never verifies an address the link was not
370
+ * minted for.
371
+ */
372
+ async verifyEmail(token) {
373
+ const tokens = this.#requireTokens("verifyEmail");
374
+ const result = await this.#consumeToken({
375
+ tokens,
376
+ purpose: TokenPurpose.EmailVerification,
377
+ token,
378
+ failed: (reason) => ({ type: "email_verification.failed", reason }),
379
+ beforeConsume: (subject, data) => this.#users.markEmailVerified?.(subject, emailFromData(data)),
380
+ });
381
+ if (result.status !== "success")
382
+ return result;
383
+ const userId = result.resolved.subject;
384
+ const email = emailFromData(result.resolved.data);
385
+ await this.#emit({
386
+ type: "email_verification.completed",
387
+ userId,
388
+ email,
389
+ });
390
+ return { status: "success", userId, email };
391
+ }
392
+ /**
393
+ * Mint + deliver a single-use account-unlock link after a lockout. Call it
394
+ * with the locked account's email (typically from your `lockout` event
395
+ * handler — the service only knows opaque user ids). Requires `tokens` and
396
+ * a `delivery.sendAccountUnlock` hook to actually send. With a `rateLimiter`
397
+ * (or a tighter `rateLimiters.accountUnlock`), requests throttle per user and
398
+ * throw {@link IdentityError} `rate_limited` when exceeded.
399
+ */
400
+ async requestAccountUnlock(input) {
401
+ const tokens = this.#requireTokens("requestAccountUnlock");
402
+ await this.#throttle("accountUnlock", input.userId, "too many account unlock requests", {
403
+ type: "account_unlock.rate_limited",
404
+ userId: input.userId,
405
+ email: input.email,
406
+ });
407
+ await this.#issueAndDeliver(tokens, {
408
+ hook: "sendAccountUnlock",
409
+ to: input.email,
410
+ subject: input.userId,
411
+ ttlMs: this.#ttl.accountUnlock,
412
+ requested: {
413
+ type: "account_unlock.requested",
414
+ userId: input.userId,
415
+ email: input.email,
416
+ },
417
+ });
418
+ }
419
+ /**
420
+ * Consume an unlock token and clear the account's lockout state (failures
421
+ * and lock). The self-service unlock path: the emailed link lands here.
422
+ */
423
+ async unlockAccount(token) {
424
+ const tokens = this.#requireTokens("unlockAccount");
425
+ const result = await this.#consumeToken({
426
+ tokens,
427
+ purpose: TokenPurpose.AccountUnlock,
428
+ token,
429
+ failed: (reason) => ({ type: "account_unlock.failed", reason }),
430
+ });
431
+ if (result.status !== "success")
432
+ return result;
433
+ const userId = result.resolved.subject;
434
+ await this.#lockout?.reset(userId);
435
+ await this.#emit({ type: "account_unlock.completed", userId });
436
+ return { status: "success", userId };
437
+ }
438
+ async #getLegacyCredential(userId) {
439
+ if (!this.#legacyVerifiers)
440
+ return null;
441
+ const get = this.#users.getLegacyCredential;
442
+ if (!get)
443
+ return null;
444
+ return (await get.call(this.#users, userId)) ?? null;
445
+ }
446
+ async #tryLegacy(verifier, password, phc) {
447
+ try {
448
+ return await verifier.verify(password, phc);
449
+ }
450
+ catch {
451
+ // Never log the error object: a BYO verifier may echo the hash or the
452
+ // submitted password in its message. Fail closed.
453
+ console.error(`[@udibo/oauth2] legacy password verifier ${verifier.id} threw`);
454
+ return false;
455
+ }
456
+ }
457
+ /**
458
+ * Passwordless request: always resolves with the same `void` result. If the
459
+ * email maps to a user, mints a single-use sign-in token (invalidating any
460
+ * outstanding one) and calls `delivery.sendSignInLink`; an unknown email does
461
+ * neither, so the **return value** never reveals whether an account exists.
462
+ * The synchronous path is not fully timing-uniform, though — only the
463
+ * known-email branch awaits token minting and delivery — so send email from a
464
+ * background queue and apply IP-level throttling at the route to close the
465
+ * residual latency/side-channel oracle. Throttled per email (case-folded,
466
+ * key `pwless:<email>`) when a `rateLimiter` — or a tighter
467
+ * `rateLimiters.signInLink` — is configured; throws {@link IdentityError}
468
+ * `rate_limited` in enforce mode. Default link lifetime is 15 minutes.
469
+ */
470
+ async requestSignInLink(email, options) {
471
+ const tokens = this.#requireTokens("requestSignInLink");
472
+ const normalized = email.trim();
473
+ await this.#throttle("signInLink", normalized, "too many sign-in link requests", { type: "signin_link.rate_limited", email: normalized });
474
+ const user = await this.#users.findByEmail(normalized);
475
+ if (!user) {
476
+ await this.#emit({ type: "signin_link.requested", email: normalized });
477
+ return;
478
+ }
479
+ await this.#issueAndDeliver(tokens, {
480
+ hook: "sendSignInLink",
481
+ to: normalized,
482
+ subject: user.id,
483
+ ttlMs: options?.ttlMs ?? this.#ttl.signInLink,
484
+ requested: {
485
+ type: "signin_link.requested",
486
+ email: normalized,
487
+ userId: user.id,
488
+ },
489
+ });
490
+ }
491
+ /**
492
+ * Consume a sign-in link token (single-use). `success` carries the `userId`
493
+ * to sign in; establishing the session — and running your MFA gate first —
494
+ * stays your app's job. So does re-resolving the user: a user deleted or
495
+ * disabled since the link was minted must be treated as `invalid` by your
496
+ * user store, not signed in. `expired` and `invalid` are distinguished so
497
+ * the UI can offer a fresh link.
498
+ */
499
+ async consumeSignInLink(token) {
500
+ const tokens = this.#requireTokens("consumeSignInLink");
501
+ const result = await this.#consumeToken({
502
+ tokens,
503
+ purpose: TokenPurpose.SignIn,
504
+ token,
505
+ failed: (reason) => ({ type: "signin_link.failed", reason }),
506
+ });
507
+ if (result.status !== "success")
508
+ return result;
509
+ const userId = result.resolved.subject;
510
+ await this.#emit({ type: "signin_link.consumed", userId });
511
+ return { status: "success", userId };
512
+ }
513
+ /**
514
+ * Passwordless request: always resolves with the same `void` result. If the
515
+ * email maps to a user, mints a one-time numeric code (invalidating any
516
+ * outstanding one) and calls `delivery.sendSignInCode`; an unknown email does
517
+ * neither, so the **return value** never reveals whether an account exists.
518
+ * As with {@link IdentityService.requestSignInLink}, only the known-email
519
+ * branch awaits code minting and delivery, so send from a background queue
520
+ * and throttle by IP at the route to close the residual timing oracle.
521
+ * Requires the `otp` option. Throttled per email (case-folded, key
522
+ * `otp:signin:<email>`, shared with {@link IdentityService.verifySignInCode})
523
+ * when a `rateLimiter` — or a tighter `rateLimiters.signInCode` — is
524
+ * configured; throws {@link IdentityError} `rate_limited` in enforce mode.
525
+ * That is the flow's only throttle, and it runs before the lookup, so a
526
+ * known email can never be rate-limited on a request an unknown one survives.
527
+ * A throwing `sendSignInCode` is trapped like the link hooks (see
528
+ * {@link DeliveryHooks}): that exact code is invalidated — never a newer one a
529
+ * retry may have delivered meanwhile — a `delivery.failed` event is emitted,
530
+ * and the call still resolves `void`, so a mailer outage is not an enumeration
531
+ * oracle either.
532
+ */
533
+ async requestSignInCode(email) {
534
+ const otp = this.#requireOtp("requestSignInCode");
535
+ const normalized = email.trim();
536
+ await this.#throttle("signInCode", normalized, "too many sign-in code requests", { type: "signin_code.rate_limited", email: normalized });
537
+ const user = await this.#users.findByEmail(normalized);
538
+ if (!user) {
539
+ await this.#emit({ type: "signin_code.requested", email: normalized });
540
+ return;
541
+ }
542
+ await otp.request({
543
+ email: normalized,
544
+ purpose: TokenPurpose.SignIn,
545
+ onDeliver: (code, expiresAt, codeId) => this.#deliverCode(normalized, code, user.id, expiresAt, codeId),
546
+ });
547
+ await this.#emit({
548
+ type: "signin_code.requested",
549
+ email: normalized,
550
+ userId: user.id,
551
+ });
552
+ }
553
+ /**
554
+ * Verify a one-time sign-in code. An unknown email performs the same hash
555
+ * work as a known one and reports plain `invalid` — the result is not an
556
+ * enumeration oracle. Wrong tries spend the code's attempt budget (`locked`
557
+ * once it runs out); success consumes the code (single-use), resets the
558
+ * flow's throttle window, and carries the `userId` to sign in — session
559
+ * creation and your MFA gate stay your app's job, as does treating a
560
+ * since-deleted/disabled user as a failed sign-in.
561
+ */
562
+ async verifySignInCode(input) {
563
+ const otp = this.#requireOtp("verifySignInCode");
564
+ const email = input.email.trim();
565
+ await this.#throttle("signInCode", email, "too many sign-in code attempts", { type: "signin_code.rate_limited", email });
566
+ const user = await this.#users.findByEmail(email);
567
+ const result = await otp.verify({
568
+ email,
569
+ purpose: TokenPurpose.SignIn,
570
+ code: input.code,
571
+ });
572
+ if (!user) {
573
+ await this.#emit({
574
+ type: "signin_code.failed",
575
+ email,
576
+ reason: "unknown_email",
577
+ });
578
+ return { status: "invalid" };
579
+ }
580
+ if (result.status !== "success") {
581
+ await this.#emit({
582
+ type: "signin_code.failed",
583
+ email,
584
+ reason: result.status,
585
+ });
586
+ return { status: "invalid" };
587
+ }
588
+ await this.#resetThrottle("signInCode", email);
589
+ await this.#emit({ type: "signin_code.verified", userId: user.id });
590
+ return { status: "success", userId: user.id };
591
+ }
592
+ #throttle(purpose, value, message, event) {
593
+ return enforceRateLimit({
594
+ rateLimiter: this.#rateLimiters[purpose],
595
+ key: RATE_KEYS[purpose](value),
596
+ message,
597
+ enforce: this.#enforce,
598
+ event,
599
+ emit: (e) => this.#emit(e),
600
+ });
601
+ }
602
+ async #resetThrottle(purpose, value) {
603
+ await this.#rateLimiters[purpose]?.reset(RATE_KEYS[purpose](value));
604
+ }
605
+ async #equalizeTiming(password) {
606
+ await this.#passwords.hash(password);
607
+ }
608
+ async #rejectSignIn(startedAt) {
609
+ const remaining = this.#failedSignInFloorMs -
610
+ (performance.now() - startedAt);
611
+ if (remaining > 0)
612
+ await delay(remaining);
613
+ return null;
614
+ }
615
+ async #isLockedOut(userId, identifier) {
616
+ if (!this.#lockout)
617
+ return false;
618
+ const status = await this.#lockout.status(userId);
619
+ if (!status.locked)
620
+ return false;
621
+ await this.#emit({
622
+ type: "sign_in.locked",
623
+ userId,
624
+ identifier,
625
+ enforced: this.#enforce,
626
+ });
627
+ return this.#enforce;
628
+ }
629
+ async #authenticate(userId, password) {
630
+ const credential = await this.#users.getCredential(userId);
631
+ // Native credential is authoritative: only try an imported hash when there
632
+ // is none, so a wrong password can't resurrect a stale one (no downgrade).
633
+ const legacyHash = credential ? null : await this.#getLegacyCredential(userId);
634
+ if (!credential && !legacyHash) {
635
+ await this.#equalizeTiming(password);
636
+ return "no_password";
637
+ }
638
+ if (credential && await this.#passwords.verify(password, credential)) {
639
+ return await this.#rehashCredential(userId, password, credential)
640
+ ? "authenticated"
641
+ : "wrong_password";
642
+ }
643
+ if (!legacyHash)
644
+ return "wrong_password";
645
+ const verifier = findLegacyVerifier(this.#legacyVerifiers, legacyHash);
646
+ if (!verifier || !await this.#tryLegacy(verifier, password, legacyHash)) {
647
+ await this.#equalizeTiming(password);
648
+ return "wrong_password";
649
+ }
650
+ return await this.#upgradeCredential(userId, password, verifier.id)
651
+ ? "authenticated"
652
+ : "wrong_password";
653
+ }
654
+ async #rehashCredential(userId, password, credential) {
655
+ if (!this.#users.replaceCredential ||
656
+ !this.#passwords.needsRehash?.(credential))
657
+ return true;
658
+ let replaced;
659
+ try {
660
+ replaced = await this.#users.replaceCredential(userId, credential, await this.#passwords.hash(password));
661
+ }
662
+ catch (error) {
663
+ this.#logPersistFailure("password rehash", error);
664
+ return true;
665
+ }
666
+ return replaced || await this.#verifyCurrentCredential(userId, password);
667
+ }
668
+ async #upgradeCredential(userId, password, verifierId) {
669
+ if (!this.#users.replaceCredential)
670
+ return true;
671
+ let upgraded;
672
+ try {
673
+ upgraded = await this.#users.replaceCredential(userId, undefined, await this.#passwords.hash(password));
674
+ }
675
+ catch (error) {
676
+ this.#logPersistFailure("upgrade-on-login", error);
677
+ return true;
678
+ }
679
+ if (!upgraded)
680
+ return await this.#verifyCurrentCredential(userId, password);
681
+ try {
682
+ await this.#users.clearLegacyCredential?.(userId);
683
+ }
684
+ catch (error) {
685
+ this.#logPersistFailure("upgrade-on-login", error);
686
+ }
687
+ await this.#emit({ type: "password.upgraded", userId, verifierId });
688
+ return true;
689
+ }
690
+ async #verifyCurrentCredential(userId, password) {
691
+ const current = await this.#users.getCredential(userId);
692
+ return current !== undefined &&
693
+ await this.#passwords.verify(password, current);
694
+ }
695
+ #logPersistFailure(operation, error) {
696
+ console.error(`[@udibo/oauth2] ${operation} persist failed:`, error instanceof Error ? error.message : error);
697
+ }
698
+ async #recordSignInFailure(userId, identifier) {
699
+ if (!this.#lockout)
700
+ return;
701
+ const result = await this.#lockout.recordFailure(userId);
702
+ if (!result.justLocked)
703
+ return;
704
+ await this.#emit({
705
+ type: "lockout",
706
+ userId,
707
+ identifier,
708
+ failures: result.failures,
709
+ lockedUntil: result.lockedUntil,
710
+ enforced: this.#enforce,
711
+ });
712
+ }
713
+ async #voidPasswordlessCredentials(tokens, subject, email) {
714
+ try {
715
+ await tokens.invalidate(TokenPurpose.SignIn, subject);
716
+ if (email)
717
+ await this.#otp?.invalidate(email, TokenPurpose.SignIn);
718
+ }
719
+ catch (error) {
720
+ console.error("[@udibo/oauth2] resetPassword could not void the outstanding " +
721
+ "passwordless credentials; they stay usable until they expire:", error instanceof Error ? error.message : error);
722
+ }
723
+ }
724
+ async #issueAndDeliver(tokens, request) {
725
+ const { token, expiresAt } = await tokens.create({
726
+ purpose: DELIVERY_PURPOSES[request.hook],
727
+ subject: request.subject,
728
+ data: request.data,
729
+ ttlMs: request.ttlMs,
730
+ invalidateExisting: true,
731
+ });
732
+ await this.#deliver(request.hook, request.to, token, request.subject, expiresAt);
733
+ await this.#emit(request.requested);
734
+ }
735
+ async #consumeToken(input) {
736
+ const { tokens, purpose, token } = input;
737
+ const status = await tokens.inspect(purpose, token);
738
+ if (status.status === "expired") {
739
+ await this.#emit(input.failed("expired"));
740
+ return { status: "expired" };
741
+ }
742
+ if (status.status !== "valid") {
743
+ await this.#emit(input.failed("invalid"));
744
+ return { status: "invalid" };
745
+ }
746
+ await input.beforeConsume?.(status.subject, status.data);
747
+ const resolved = await tokens.consume(purpose, token);
748
+ if (!resolved) {
749
+ await this.#emit(input.failed("invalid"));
750
+ return { status: "invalid" };
751
+ }
752
+ return { status: "success", resolved };
753
+ }
754
+ #emit(event) {
755
+ return dispatchIdentityEvent(this.#onEvent, "identity", event);
756
+ }
757
+ #requireTokens(method) {
758
+ if (!this.#tokens) {
759
+ throw new Error(`IdentityService.${method}() requires a \`tokens\` option`);
760
+ }
761
+ return this.#tokens;
762
+ }
763
+ #requireOtp(method) {
764
+ if (!this.#otp) {
765
+ throw new Error(`IdentityService.${method}() requires an \`otp\` option`);
766
+ }
767
+ return this.#otp;
768
+ }
769
+ async #deliver(hook, to, token, subject, expiresAt) {
770
+ const delivery = this.#delivery;
771
+ const send = delivery?.[hook];
772
+ if (!send)
773
+ return;
774
+ const url = this.#baseUrl
775
+ ? URL_BUILDERS[hook](this.#baseUrl, token)
776
+ : undefined;
777
+ await this.#trapDeliveryFailure(hook, () => send.call(delivery, { to, url, token, subject, expiresAt }), async () => {
778
+ await this.#tokens?.consume(DELIVERY_PURPOSES[hook], token);
779
+ });
780
+ }
781
+ async #deliverCode(to, code, subject, expiresAt, codeId) {
782
+ const delivery = this.#delivery;
783
+ const send = delivery?.sendSignInCode;
784
+ if (!send)
785
+ return;
786
+ await this.#trapDeliveryFailure("sendSignInCode", () => send.call(delivery, { to, code, subject, expiresAt }), async () => {
787
+ await this.#otp?.invalidateCode(codeId);
788
+ });
789
+ }
790
+ async #trapDeliveryFailure(hook, send, invalidate) {
791
+ try {
792
+ await send();
793
+ return;
794
+ }
795
+ catch (error) {
796
+ const message = error instanceof Error ? error.message : String(error);
797
+ const invalidated = await this.#invalidateAfterFailedDelivery(hook, invalidate, message);
798
+ await this.#emit({
799
+ type: "delivery.failed",
800
+ hook,
801
+ invalidated,
802
+ error: message,
803
+ });
804
+ }
805
+ }
806
+ async #invalidateAfterFailedDelivery(hook, invalidate, message) {
807
+ try {
808
+ await invalidate();
809
+ console.error(`[@udibo/oauth2] delivery hook ${hook} failed; minted credential invalidated:`, message);
810
+ return true;
811
+ }
812
+ catch (error) {
813
+ console.error(`[@udibo/oauth2] delivery hook ${hook} failed AND the minted ` +
814
+ `credential could not be invalidated — it stays live until it ` +
815
+ `expires. Send failure:`, message, "Invalidation failure:", error instanceof Error ? error.message : error);
816
+ return false;
817
+ }
818
+ }
819
+ }
820
+ const URL_BUILDERS = {
821
+ sendPasswordReset: buildResetUrl,
822
+ sendAccountUnlock: buildUnlockUrl,
823
+ sendEmailVerification: buildVerificationUrl,
824
+ sendSignInLink: buildSignInUrl,
825
+ };
826
+ const DELIVERY_PURPOSES = {
827
+ sendPasswordReset: TokenPurpose.PasswordReset,
828
+ sendAccountUnlock: TokenPurpose.AccountUnlock,
829
+ sendEmailVerification: TokenPurpose.EmailVerification,
830
+ sendSignInLink: TokenPurpose.SignIn,
831
+ };
832
+ function emailFromData(data) {
833
+ const email = data?.email;
834
+ return typeof email === "string" ? email : undefined;
835
+ }