@aurabase/aurabase-js 0.3.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 (344) hide show
  1. package/.changeset/config.json +11 -0
  2. package/CHANGELOG.md +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +833 -0
  5. package/package.json +90 -0
  6. package/packages/ai/LICENSE +21 -0
  7. package/packages/ai/README.md +106 -0
  8. package/packages/ai/dist/index.cjs +229 -0
  9. package/packages/ai/dist/index.d.cts +139 -0
  10. package/packages/ai/dist/index.d.ts +139 -0
  11. package/packages/ai/dist/index.js +226 -0
  12. package/packages/ai/node_modules/.bin/esbuild +19 -0
  13. package/packages/ai/node_modules/.bin/tsc +22 -0
  14. package/packages/ai/node_modules/.bin/tsserver +22 -0
  15. package/packages/ai/node_modules/.bin/tsup +22 -0
  16. package/packages/ai/node_modules/.bin/tsup-node +22 -0
  17. package/packages/ai/node_modules/.bin/vite +22 -0
  18. package/packages/ai/node_modules/.bin/vitest +22 -0
  19. package/packages/ai/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  20. package/packages/ai/package.json +61 -0
  21. package/packages/ai/src/AiService.ts +226 -0
  22. package/packages/ai/src/index.ts +18 -0
  23. package/packages/ai/src/sse.ts +141 -0
  24. package/packages/ai/src/types.ts +30 -0
  25. package/packages/ai/tests/chat-stream.test.ts +151 -0
  26. package/packages/ai/tests/rag-namespaces.test.ts +54 -0
  27. package/packages/ai/tests/sse.test.ts +69 -0
  28. package/packages/ai/tsconfig.json +5 -0
  29. package/packages/ai/tsup.config.ts +11 -0
  30. package/packages/aurabase/LICENSE +21 -0
  31. package/packages/aurabase/README.md +64 -0
  32. package/packages/aurabase/dist/AuraClient-DAtDbfPB.d.cts +77 -0
  33. package/packages/aurabase/dist/AuraClient-DAtDbfPB.d.ts +77 -0
  34. package/packages/aurabase/dist/index.cjs +237 -0
  35. package/packages/aurabase/dist/index.d.cts +46 -0
  36. package/packages/aurabase/dist/index.d.ts +46 -0
  37. package/packages/aurabase/dist/index.js +177 -0
  38. package/packages/aurabase/dist/ssr.cjs +224 -0
  39. package/packages/aurabase/dist/ssr.d.cts +65 -0
  40. package/packages/aurabase/dist/ssr.d.ts +65 -0
  41. package/packages/aurabase/dist/ssr.js +214 -0
  42. package/packages/aurabase/node_modules/.bin/esbuild +19 -0
  43. package/packages/aurabase/node_modules/.bin/tsc +22 -0
  44. package/packages/aurabase/node_modules/.bin/tsserver +22 -0
  45. package/packages/aurabase/node_modules/.bin/tsup +22 -0
  46. package/packages/aurabase/node_modules/.bin/tsup-node +22 -0
  47. package/packages/aurabase/node_modules/.bin/vite +22 -0
  48. package/packages/aurabase/node_modules/.bin/vitest +22 -0
  49. package/packages/aurabase/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  50. package/packages/aurabase/package.json +80 -0
  51. package/packages/aurabase/src/AuraClient.ts +237 -0
  52. package/packages/aurabase/src/index.ts +152 -0
  53. package/packages/aurabase/src/ssr.ts +156 -0
  54. package/packages/aurabase/tests/ai-streaming-rag-e2e.test.ts +82 -0
  55. package/packages/aurabase/tests/ai.test.ts +111 -0
  56. package/packages/aurabase/tests/auth-admin.test.ts +515 -0
  57. package/packages/aurabase/tests/auth-e2e.test.ts +135 -0
  58. package/packages/aurabase/tests/auth-hardening.test.ts +206 -0
  59. package/packages/aurabase/tests/auth.test.ts +947 -0
  60. package/packages/aurabase/tests/database.test.ts +230 -0
  61. package/packages/aurabase/tests/db-types-e2e.test.ts +217 -0
  62. package/packages/aurabase/tests/dispose.test.ts +23 -0
  63. package/packages/aurabase/tests/foreign-token.e2e.test.ts +129 -0
  64. package/packages/aurabase/tests/functions.test.ts +107 -0
  65. package/packages/aurabase/tests/integration.test.ts +413 -0
  66. package/packages/aurabase/tests/live-production-validation.js +241 -0
  67. package/packages/aurabase/tests/live-sdk-production-validation.js +293 -0
  68. package/packages/aurabase/tests/mocks.ts +139 -0
  69. package/packages/aurabase/tests/notifications.test.ts +314 -0
  70. package/packages/aurabase/tests/observability-backup.test.ts +186 -0
  71. package/packages/aurabase/tests/observability-hook.test.ts +130 -0
  72. package/packages/aurabase/tests/production-robustness.test.ts +268 -0
  73. package/packages/aurabase/tests/public-api-surface.test.ts +53 -0
  74. package/packages/aurabase/tests/query-builder.test.ts +329 -0
  75. package/packages/aurabase/tests/readme-smoke-e2e.test.ts +231 -0
  76. package/packages/aurabase/tests/realtime-filter-e2e.test.ts +344 -0
  77. package/packages/aurabase/tests/refresh-locks.test.ts +210 -0
  78. package/packages/aurabase/tests/retry-option.test.ts +17 -0
  79. package/packages/aurabase/tests/rpc-e2e.test.ts +254 -0
  80. package/packages/aurabase/tests/rs256-local-e2e.test.ts +114 -0
  81. package/packages/aurabase/tests/rt-alias-public-e2e.test.ts +235 -0
  82. package/packages/aurabase/tests/rt-http-broadcast-e2e.test.ts +124 -0
  83. package/packages/aurabase/tests/service-role-guard.test.ts +99 -0
  84. package/packages/aurabase/tests/ssr.test.ts +91 -0
  85. package/packages/aurabase/tests/storage-backend-e2e.test.ts +481 -0
  86. package/packages/aurabase/tests/storage-sdk-parity-e2e.test.ts +104 -0
  87. package/packages/aurabase/tests/storage-transform-e2e.test.ts +684 -0
  88. package/packages/aurabase/tests/storage.test.ts +55 -0
  89. package/packages/aurabase/tests/types/client-generic.test-d.ts +79 -0
  90. package/packages/aurabase/tests/types/docs-snippets.test-d.ts +571 -0
  91. package/packages/aurabase/tests/types/error-code.test-d.ts +53 -0
  92. package/packages/aurabase/tsconfig.json +5 -0
  93. package/packages/aurabase/tsconfig.test-d.json +5 -0
  94. package/packages/aurabase/tsup.config.ts +11 -0
  95. package/packages/aurabase/vitest.config.ts +16 -0
  96. package/packages/aurabase/vitest.e2e.config.ts +9 -0
  97. package/packages/auth/LICENSE +21 -0
  98. package/packages/auth/README.md +215 -0
  99. package/packages/auth/dist/index.cjs +1175 -0
  100. package/packages/auth/dist/index.d.cts +397 -0
  101. package/packages/auth/dist/index.d.ts +397 -0
  102. package/packages/auth/dist/index.js +1170 -0
  103. package/packages/auth/node_modules/.bin/esbuild +19 -0
  104. package/packages/auth/node_modules/.bin/tsc +22 -0
  105. package/packages/auth/node_modules/.bin/tsserver +22 -0
  106. package/packages/auth/node_modules/.bin/tsup +22 -0
  107. package/packages/auth/node_modules/.bin/tsup-node +22 -0
  108. package/packages/auth/node_modules/.bin/vite +22 -0
  109. package/packages/auth/node_modules/.bin/vitest +22 -0
  110. package/packages/auth/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  111. package/packages/auth/package.json +61 -0
  112. package/packages/auth/src/AuthAdminService.ts +149 -0
  113. package/packages/auth/src/AuthService.ts +1166 -0
  114. package/packages/auth/src/SessionManager.ts +92 -0
  115. package/packages/auth/src/index.ts +17 -0
  116. package/packages/auth/src/jwks.ts +337 -0
  117. package/packages/auth/src/pkce.ts +27 -0
  118. package/packages/auth/src/types.ts +89 -0
  119. package/packages/auth/tests/auth-lifecycle.test.ts +149 -0
  120. package/packages/auth/tests/detect-session.test.ts +361 -0
  121. package/packages/auth/tests/getclaims.test.ts +780 -0
  122. package/packages/auth/tests/is-mfa-challenge.test.ts +27 -0
  123. package/packages/auth/tests/password-recovery.test.ts +124 -0
  124. package/packages/auth/tests/pkce.test.ts +192 -0
  125. package/packages/auth/tests/session-manager.test.ts +103 -0
  126. package/packages/auth/tests/set-session.test.ts +139 -0
  127. package/packages/auth/tests/verify-otp.test.ts +153 -0
  128. package/packages/auth/tsconfig.json +5 -0
  129. package/packages/auth/tsup.config.ts +11 -0
  130. package/packages/core/LICENSE +21 -0
  131. package/packages/core/README.md +132 -0
  132. package/packages/core/dist/index.cjs +695 -0
  133. package/packages/core/dist/index.d.cts +1137 -0
  134. package/packages/core/dist/index.d.ts +1137 -0
  135. package/packages/core/dist/index.js +673 -0
  136. package/packages/core/node_modules/.bin/esbuild +22 -0
  137. package/packages/core/node_modules/.bin/tsc +22 -0
  138. package/packages/core/node_modules/.bin/tsserver +22 -0
  139. package/packages/core/node_modules/.bin/tsup +22 -0
  140. package/packages/core/node_modules/.bin/tsup-node +22 -0
  141. package/packages/core/node_modules/.bin/vite +22 -0
  142. package/packages/core/node_modules/.bin/vitest +22 -0
  143. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  144. package/packages/core/package.json +57 -0
  145. package/packages/core/src/AuraError.ts +89 -0
  146. package/packages/core/src/BaseService.ts +44 -0
  147. package/packages/core/src/client.ts +34 -0
  148. package/packages/core/src/cookie-utils.ts +42 -0
  149. package/packages/core/src/fetch.ts +435 -0
  150. package/packages/core/src/index.ts +22 -0
  151. package/packages/core/src/retry.ts +63 -0
  152. package/packages/core/src/serialize.ts +6 -0
  153. package/packages/core/src/stores/BaseAuthStore.ts +164 -0
  154. package/packages/core/src/stores/LocalAuthStore.ts +199 -0
  155. package/packages/core/src/types.ts +1042 -0
  156. package/packages/core/tests/auth-store.test.ts +300 -0
  157. package/packages/core/tests/error-code.test.ts +61 -0
  158. package/packages/core/tests/error.test.ts +24 -0
  159. package/packages/core/tests/fetch.test.ts +286 -0
  160. package/packages/core/tests/mocks.ts +139 -0
  161. package/packages/core/tests/observability.test.ts +86 -0
  162. package/packages/core/tests/refresh-hardening.test.ts +164 -0
  163. package/packages/core/tests/refresh-observability.test.ts +111 -0
  164. package/packages/core/tests/retry.test.ts +106 -0
  165. package/packages/core/tests/serialize.test.ts +12 -0
  166. package/packages/core/tests/timeout.test.ts +79 -0
  167. package/packages/core/tsconfig.json +5 -0
  168. package/packages/core/tsup.config.ts +11 -0
  169. package/packages/db/LICENSE +21 -0
  170. package/packages/db/README.md +152 -0
  171. package/packages/db/VENDORED.md +93 -0
  172. package/packages/db/dist/index.cjs +544 -0
  173. package/packages/db/dist/index.d.cts +1111 -0
  174. package/packages/db/dist/index.d.ts +1111 -0
  175. package/packages/db/dist/index.js +541 -0
  176. package/packages/db/node_modules/.bin/esbuild +19 -0
  177. package/packages/db/node_modules/.bin/tsc +22 -0
  178. package/packages/db/node_modules/.bin/tsserver +22 -0
  179. package/packages/db/node_modules/.bin/tsup +22 -0
  180. package/packages/db/node_modules/.bin/tsup-node +22 -0
  181. package/packages/db/node_modules/.bin/vite +22 -0
  182. package/packages/db/node_modules/.bin/vitest +22 -0
  183. package/packages/db/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  184. package/packages/db/package.json +62 -0
  185. package/packages/db/src/DatabaseService.ts +170 -0
  186. package/packages/db/src/QueryBuilder.ts +830 -0
  187. package/packages/db/src/index.ts +2 -0
  188. package/packages/db/src/pgrst-types/common/common.ts +86 -0
  189. package/packages/db/src/pgrst-types/common/rpc.ts +151 -0
  190. package/packages/db/src/pgrst-types/feature-flags.ts +20 -0
  191. package/packages/db/src/pgrst-types/types.ts +134 -0
  192. package/packages/db/src/select-query-parser/parser.ts +479 -0
  193. package/packages/db/src/select-query-parser/result.ts +553 -0
  194. package/packages/db/src/select-query-parser/types.ts +132 -0
  195. package/packages/db/src/select-query-parser/utils.ts +707 -0
  196. package/packages/db/tests/filters.test.ts +81 -0
  197. package/packages/db/tests/mutations-filtered.e2e.test.ts +132 -0
  198. package/packages/db/tests/mutations-filtered.test.ts +188 -0
  199. package/packages/db/tests/order.test.ts +45 -0
  200. package/packages/db/tests/rpc.test.ts +41 -0
  201. package/packages/db/tests/scale/build-large-schema.ts +94 -0
  202. package/packages/db/tests/scale/scale-compile.test.ts +167 -0
  203. package/packages/db/tests/schema.test.ts +77 -0
  204. package/packages/db/tests/single-accept.e2e.test.ts +123 -0
  205. package/packages/db/tests/single.test.ts +211 -0
  206. package/packages/db/tests/types/filters.test-d.ts +52 -0
  207. package/packages/db/tests/types/fixtures.ts +40 -0
  208. package/packages/db/tests/types/from-views.test-d.ts +18 -0
  209. package/packages/db/tests/types/mutations.test-d.ts +23 -0
  210. package/packages/db/tests/types/rpc.test-d.ts +60 -0
  211. package/packages/db/tests/types/schema-shape.test-d.ts +8 -0
  212. package/packages/db/tests/types/select-inference.test-d.ts +38 -0
  213. package/packages/db/tests/vendored/fixtures.ts +83 -0
  214. package/packages/db/tests/vendored/get-result.test-d.ts +75 -0
  215. package/packages/db/tsconfig.json +5 -0
  216. package/packages/db/tsconfig.test-d.json +5 -0
  217. package/packages/db/tsup.config.ts +11 -0
  218. package/packages/db/vitest.config.ts +8 -0
  219. package/packages/functions/LICENSE +21 -0
  220. package/packages/functions/README.md +96 -0
  221. package/packages/functions/dist/index.cjs +173 -0
  222. package/packages/functions/dist/index.d.cts +81 -0
  223. package/packages/functions/dist/index.d.ts +81 -0
  224. package/packages/functions/dist/index.js +168 -0
  225. package/packages/functions/node_modules/.bin/esbuild +19 -0
  226. package/packages/functions/node_modules/.bin/tsc +22 -0
  227. package/packages/functions/node_modules/.bin/tsserver +22 -0
  228. package/packages/functions/node_modules/.bin/tsup +22 -0
  229. package/packages/functions/node_modules/.bin/tsup-node +22 -0
  230. package/packages/functions/node_modules/.bin/vite +22 -0
  231. package/packages/functions/node_modules/.bin/vitest +22 -0
  232. package/packages/functions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  233. package/packages/functions/package.json +60 -0
  234. package/packages/functions/src/FunctionsService.ts +188 -0
  235. package/packages/functions/src/errors.ts +70 -0
  236. package/packages/functions/src/index.ts +6 -0
  237. package/packages/functions/tests/invoke.test.ts +97 -0
  238. package/packages/functions/tests/response-type-errors.test.ts +135 -0
  239. package/packages/functions/tsconfig.json +5 -0
  240. package/packages/functions/tsup.config.ts +11 -0
  241. package/packages/gen/LICENSE +21 -0
  242. package/packages/gen/README.md +84 -0
  243. package/packages/gen/dist/cli.cjs +334 -0
  244. package/packages/gen/dist/cli.d.cts +1 -0
  245. package/packages/gen/dist/cli.d.ts +1 -0
  246. package/packages/gen/dist/cli.js +332 -0
  247. package/packages/gen/dist/index.cjs +257 -0
  248. package/packages/gen/dist/index.d.cts +12 -0
  249. package/packages/gen/dist/index.d.ts +12 -0
  250. package/packages/gen/dist/index.js +255 -0
  251. package/packages/gen/node_modules/.bin/esbuild +19 -0
  252. package/packages/gen/node_modules/.bin/tsc +22 -0
  253. package/packages/gen/node_modules/.bin/tsserver +22 -0
  254. package/packages/gen/node_modules/.bin/tsup +22 -0
  255. package/packages/gen/node_modules/.bin/tsup-node +22 -0
  256. package/packages/gen/node_modules/.bin/vite +22 -0
  257. package/packages/gen/node_modules/.bin/vitest +22 -0
  258. package/packages/gen/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  259. package/packages/gen/package.json +66 -0
  260. package/packages/gen/src/cli.ts +67 -0
  261. package/packages/gen/src/generate.ts +0 -0
  262. package/packages/gen/src/index.ts +2 -0
  263. package/packages/gen/tests/compile.test.ts +213 -0
  264. package/packages/gen/tests/e2e.test.ts +130 -0
  265. package/packages/gen/tests/generate.test.ts +396 -0
  266. package/packages/gen/tsconfig.json +5 -0
  267. package/packages/gen/tsup.config.ts +11 -0
  268. package/packages/notifications/LICENSE +21 -0
  269. package/packages/notifications/README.md +134 -0
  270. package/packages/notifications/dist/index.cjs +128 -0
  271. package/packages/notifications/dist/index.d.cts +81 -0
  272. package/packages/notifications/dist/index.d.ts +81 -0
  273. package/packages/notifications/dist/index.js +125 -0
  274. package/packages/notifications/node_modules/.bin/esbuild +19 -0
  275. package/packages/notifications/node_modules/.bin/tsc +22 -0
  276. package/packages/notifications/node_modules/.bin/tsserver +22 -0
  277. package/packages/notifications/node_modules/.bin/tsup +22 -0
  278. package/packages/notifications/node_modules/.bin/tsup-node +22 -0
  279. package/packages/notifications/node_modules/.bin/vite +22 -0
  280. package/packages/notifications/node_modules/.bin/vitest +22 -0
  281. package/packages/notifications/package.json +60 -0
  282. package/packages/notifications/src/NotificationsService.ts +230 -0
  283. package/packages/notifications/src/index.ts +19 -0
  284. package/packages/notifications/tsconfig.json +5 -0
  285. package/packages/notifications/tsup.config.ts +11 -0
  286. package/packages/realtime/LICENSE +21 -0
  287. package/packages/realtime/README.md +183 -0
  288. package/packages/realtime/dist/index.cjs +977 -0
  289. package/packages/realtime/dist/index.d.cts +259 -0
  290. package/packages/realtime/dist/index.d.ts +259 -0
  291. package/packages/realtime/dist/index.js +973 -0
  292. package/packages/realtime/node_modules/.bin/esbuild +19 -0
  293. package/packages/realtime/node_modules/.bin/tsc +22 -0
  294. package/packages/realtime/node_modules/.bin/tsserver +22 -0
  295. package/packages/realtime/node_modules/.bin/tsup +22 -0
  296. package/packages/realtime/node_modules/.bin/tsup-node +22 -0
  297. package/packages/realtime/node_modules/.bin/vite +22 -0
  298. package/packages/realtime/node_modules/.bin/vitest +22 -0
  299. package/packages/realtime/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  300. package/packages/realtime/package.json +60 -0
  301. package/packages/realtime/src/AuraChannel.ts +485 -0
  302. package/packages/realtime/src/HeartbeatManager.ts +73 -0
  303. package/packages/realtime/src/RealtimeService.ts +505 -0
  304. package/packages/realtime/src/columnFilter.ts +301 -0
  305. package/packages/realtime/src/index.ts +4 -0
  306. package/packages/realtime/tests/ack-routing.test.ts +81 -0
  307. package/packages/realtime/tests/column-filter.test.ts +144 -0
  308. package/packages/realtime/tests/dispatch.test.ts +191 -0
  309. package/packages/realtime/tests/heartbeat.test.ts +67 -0
  310. package/packages/realtime/tests/http-broadcast.test.ts +68 -0
  311. package/packages/realtime/tests/realtime-token.test.ts +43 -0
  312. package/packages/realtime/tests/reconnect-race.test.ts +149 -0
  313. package/packages/realtime/tests/ref-id.test.ts +34 -0
  314. package/packages/realtime/tests/set-auth.test.ts +70 -0
  315. package/packages/realtime/tsconfig.json +5 -0
  316. package/packages/realtime/tsup.config.ts +11 -0
  317. package/packages/storage/LICENSE +21 -0
  318. package/packages/storage/README.md +141 -0
  319. package/packages/storage/dist/index.cjs +333 -0
  320. package/packages/storage/dist/index.d.cts +185 -0
  321. package/packages/storage/dist/index.d.ts +185 -0
  322. package/packages/storage/dist/index.js +329 -0
  323. package/packages/storage/node_modules/.bin/esbuild +19 -0
  324. package/packages/storage/node_modules/.bin/tsc +22 -0
  325. package/packages/storage/node_modules/.bin/tsserver +22 -0
  326. package/packages/storage/node_modules/.bin/tsup +22 -0
  327. package/packages/storage/node_modules/.bin/tsup-node +22 -0
  328. package/packages/storage/node_modules/.bin/vite +22 -0
  329. package/packages/storage/node_modules/.bin/vitest +22 -0
  330. package/packages/storage/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  331. package/packages/storage/package.json +60 -0
  332. package/packages/storage/src/DownloadBuilder.ts +60 -0
  333. package/packages/storage/src/StorageService.ts +323 -0
  334. package/packages/storage/src/index.ts +2 -0
  335. package/packages/storage/src/path.ts +35 -0
  336. package/packages/storage/src/transform.ts +15 -0
  337. package/packages/storage/tests/download.test.ts +64 -0
  338. package/packages/storage/tests/parity.test.ts +154 -0
  339. package/packages/storage/tests/path.test.ts +87 -0
  340. package/packages/storage/tests/transform.test.ts +79 -0
  341. package/packages/storage/tsconfig.json +5 -0
  342. package/packages/storage/tsup.config.ts +11 -0
  343. package/pnpm-workspace.yaml +24 -0
  344. package/tsconfig.base.json +15 -0
@@ -0,0 +1,780 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { AuthService } from '../src/AuthService'
3
+ import { __resetJwksCache } from '../src/jwks'
4
+ import { BaseAuthStore } from '@aurabase/core'
5
+ import type { AuraClientLike, User, AuraObservabilityEvent } from '@aurabase/core'
6
+
7
+ // --- helpers -----------------------------------------------------------------
8
+
9
+ function b64url(obj: unknown): string {
10
+ return btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
11
+ }
12
+
13
+ function b64urlBytes(bytes: Uint8Array): string {
14
+ let bin = ''
15
+ for (const b of bytes) bin += String.fromCharCode(b)
16
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
17
+ }
18
+
19
+ /** Génère une paire RS256 et exporte la clé publique en JWK (avec kid). */
20
+ async function genRs256(kid?: string): Promise<{ pair: CryptoKeyPair; jwk: JsonWebKey }> {
21
+ const pair = await crypto.subtle.generateKey(
22
+ { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
23
+ true,
24
+ ['sign', 'verify'],
25
+ )
26
+ const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
27
+ if (kid) (jwk as { kid?: string }).kid = kid
28
+ return { pair, jwk }
29
+ }
30
+
31
+ /** Signe un JWT RS256 avec la clé privée fournie. */
32
+ async function signRs256(
33
+ payload: Record<string, unknown>,
34
+ priv: CryptoKey,
35
+ kid?: string,
36
+ ): Promise<string> {
37
+ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) }
38
+ const signingInput = `${b64url(header)}.${b64url(payload)}`
39
+ const sig = await crypto.subtle.sign(
40
+ { name: 'RSASSA-PKCS1-v1_5' },
41
+ priv,
42
+ new TextEncoder().encode(signingInput),
43
+ )
44
+ return `${signingInput}.${b64urlBytes(new Uint8Array(sig))}`
45
+ }
46
+
47
+ /** JWT HS256 factice (signature non vérifiable localement → fallback getUser). */
48
+ function hs256Jwt(sub = 'u1', ttlSec = 3600): string {
49
+ const exp = Math.floor(Date.now() / 1000) + ttlSec
50
+ return `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url({ sub, exp })}.sig`
51
+ }
52
+
53
+ /** JWT HS256 factice avec payload arbitraire (pour tester iss/aud/exp au fallback). */
54
+ function hs256WithClaims(payload: Record<string, unknown>): string {
55
+ return `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.sig`
56
+ }
57
+
58
+ function fakeUser(id = 'u1'): User {
59
+ return {
60
+ id,
61
+ email: 't@e.com',
62
+ role: 'authenticated',
63
+ email_verified: true,
64
+ is_banned: false,
65
+ is_anonymous: false,
66
+ mfa_enabled: false,
67
+ user_metadata: {},
68
+ app_metadata: {},
69
+ created_at: '2025-01-01T00:00:00Z',
70
+ }
71
+ }
72
+
73
+ class FakeBC {
74
+ static instances: FakeBC[] = []
75
+ onmessage: ((e: { data: unknown }) => void) | null = null
76
+ posted: unknown[] = []
77
+ closed = false
78
+ constructor(public name: string) {
79
+ FakeBC.instances.push(this)
80
+ }
81
+ postMessage(d: unknown) {
82
+ this.posted.push(d)
83
+ }
84
+ close() {
85
+ this.closed = true
86
+ }
87
+ }
88
+
89
+ beforeEach(() => {
90
+ FakeBC.instances = []
91
+ ;(globalThis as unknown as { BroadcastChannel: unknown }).BroadcastChannel = FakeBC
92
+ // Le cache JWKS est process-global (limite connue, documentée dans jwks.ts) :
93
+ // on l'isole entre tests plutôt que de multiplier les baseUrls distincts.
94
+ __resetJwksCache()
95
+ })
96
+ afterEach(() => {
97
+ vi.useRealTimers()
98
+ vi.restoreAllMocks()
99
+ delete (globalThis as unknown as { BroadcastChannel?: unknown }).BroadcastChannel
100
+ __resetJwksCache()
101
+ })
102
+
103
+ function makeAuth(
104
+ baseUrl: string,
105
+ fetchFn: typeof globalThis.fetch,
106
+ store = new BaseAuthStore(),
107
+ onEvent?: (e: AuraObservabilityEvent) => void,
108
+ ) {
109
+ const client: AuraClientLike = {
110
+ baseUrl,
111
+ projectId: 'p',
112
+ apiKey: 'k',
113
+ fetchFn,
114
+ authStore: store,
115
+ auth: null as unknown as AuraClientLike['auth'],
116
+ ...(onEvent ? { onEvent } : {}),
117
+ }
118
+ const svc = new AuthService(client, { autoRefresh: false, persistSession: false })
119
+ ;(client as { auth: unknown }).auth = svc
120
+ return { svc, client, store }
121
+ }
122
+
123
+ /** fetchFn servant un JWKS + l'endpoint /user, avec compteur de fetch JWKS. */
124
+ function makeFetch(
125
+ jwks: () => { keys: JsonWebKey[] },
126
+ user: User = fakeUser(),
127
+ opts: { jwksStatus?: number; cacheControl?: string; delayMs?: number } = {},
128
+ ) {
129
+ const counts = { jwks: 0, user: 0 }
130
+ const fetchFn = (async (url: string | URL | Request) => {
131
+ const u = String(url)
132
+ if (u.includes('/.well-known/jwks.json')) {
133
+ counts.jwks++
134
+ if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs))
135
+ const status = opts.jwksStatus ?? 200
136
+ if (status >= 400) return new Response('fail', { status })
137
+ return new Response(JSON.stringify(jwks()), {
138
+ status,
139
+ headers: {
140
+ 'Content-Type': 'application/json',
141
+ 'Cache-Control': opts.cacheControl ?? 'max-age=600',
142
+ },
143
+ })
144
+ }
145
+ if (u.endsWith('/user')) {
146
+ counts.user++
147
+ return new Response(JSON.stringify(user), {
148
+ status: 200,
149
+ headers: { 'Content-Type': 'application/json' },
150
+ })
151
+ }
152
+ return new Response('{}', { status: 200 })
153
+ }) as unknown as typeof globalThis.fetch
154
+ return { fetchFn, counts }
155
+ }
156
+
157
+ // --- tests -------------------------------------------------------------------
158
+
159
+ describe('AuthService.getClaims — vérif JWT locale via JWKS', () => {
160
+ it('vérifie un JWT RS256 valide localement → claims', async () => {
161
+ const { pair, jwk } = await genRs256('k1')
162
+ const token = await signRs256(
163
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600, role: 'authenticated' },
164
+ pair.privateKey,
165
+ 'k1',
166
+ )
167
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [jwk] }))
168
+ const { svc } = makeAuth('http://rs256-ok.local', fetchFn)
169
+
170
+ const { data, error } = await svc.getClaims(token)
171
+
172
+ expect(error).toBeNull()
173
+ expect(data?.claims.sub).toBe('u1')
174
+ expect(data?.header.alg).toBe('RS256')
175
+ expect(counts.jwks).toBe(1)
176
+ expect(counts.user).toBe(0) // pas de fallback réseau
177
+ })
178
+
179
+ it('signature falsifiée → error signature_invalid', async () => {
180
+ const { pair, jwk } = await genRs256('k1')
181
+ const token = await signRs256(
182
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
183
+ pair.privateKey,
184
+ 'k1',
185
+ )
186
+ // Altère le PREMIER caractère de la signature (le dernier caractère base64url
187
+ // ne porte que des bits de padding inutilisés → décoderait les mêmes octets).
188
+ const parts = token.split('.')
189
+ parts[2] = (parts[2][0] === 'A' ? 'B' : 'A') + parts[2].slice(1)
190
+ const tampered = parts.join('.')
191
+ const { fetchFn } = makeFetch(() => ({ keys: [jwk] }))
192
+ const { svc } = makeAuth('http://rs256-tampered.local', fetchFn)
193
+
194
+ const { data, error } = await svc.getClaims(tampered)
195
+
196
+ expect(data).toBeNull()
197
+ expect((error as { code?: string })?.code).toBe('signature_invalid')
198
+ })
199
+
200
+ it('token expiré → error token_expired', async () => {
201
+ const { pair, jwk } = await genRs256('k1')
202
+ const token = await signRs256(
203
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) - 60 },
204
+ pair.privateKey,
205
+ 'k1',
206
+ )
207
+ const { fetchFn } = makeFetch(() => ({ keys: [jwk] }))
208
+ const { svc } = makeAuth('http://rs256-expired.local', fetchFn)
209
+
210
+ const { data, error } = await svc.getClaims(token)
211
+
212
+ expect(data).toBeNull()
213
+ expect((error as { code?: string })?.code).toBe('token_expired')
214
+ })
215
+
216
+ it('alg HS256 (token de session) → fallback getUser (mocké)', async () => {
217
+ // Chemin nominal CLIENT : le token HS256 EST celui de la session (store).
218
+ // getClaims() no-arg → usingStoreToken → fallback getUser autorisé.
219
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
220
+ const store = new BaseAuthStore()
221
+ store.save(hs256Jwt('u1'), 'refresh', fakeUser('u1'))
222
+ const { svc } = makeAuth('http://hs256.local', fetchFn, store)
223
+
224
+ const { data, error } = await svc.getClaims()
225
+
226
+ expect(error).toBeNull()
227
+ expect(data?.claims.sub).toBe('u1')
228
+ // alg non supporté → aucune tentative de fetch JWKS, fallback direct getUser.
229
+ expect(counts.jwks).toBe(0)
230
+ expect(counts.user).toBe(1)
231
+ })
232
+
233
+ it('LOT D : token HS256 ÉTRANGER (≠ session) → getUser(foreignToken) valide le token ÉTRANGER (pas le store)', async () => {
234
+ // Le store porte u_store ; on demande les claims d'un token étranger u_foreign
235
+ // (même projet). Avant LOT D : le SDK ne pouvait PAS cibler le token étranger
236
+ // (Authorization toujours écrasé par store.token) → fail-closed unverifiable_token.
237
+ // Après LOT D : getUser(foreignToken) envoie le token ÉTRANGER en Authorization,
238
+ // le backend le valide (isolation projet native) et renvoie SON identité.
239
+ // Preuve anti-misattribution : /user renvoie le sub du token RÉELLEMENT présenté.
240
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
241
+ const u = String(url)
242
+ if (u.includes('/.well-known/jwks.json')) {
243
+ return new Response(JSON.stringify({ keys: [] }), {
244
+ status: 200,
245
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=600' },
246
+ })
247
+ }
248
+ if (u.endsWith('/user')) {
249
+ // Décode le sub du JWT présenté dans Authorization (le backend validerait
250
+ // réellement la signature ; ici on prouve QUEL token est présenté).
251
+ const auth = (init?.headers as Record<string, string> | undefined)?.['Authorization'] ?? ''
252
+ const jwt = auth.replace(/^Bearer /, '')
253
+ const payload = jwt.split('.')[1] ?? ''
254
+ const b64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=')
255
+ const sub = (JSON.parse(atob(b64)) as { sub?: string }).sub ?? 'unknown'
256
+ return new Response(JSON.stringify(fakeUser(sub)), {
257
+ status: 200,
258
+ headers: { 'Content-Type': 'application/json' },
259
+ })
260
+ }
261
+ return new Response('{}', { status: 200 })
262
+ }) as unknown as typeof globalThis.fetch
263
+ const store = new BaseAuthStore()
264
+ store.save(hs256Jwt('u_store'), 'refresh', fakeUser('u_store'))
265
+ const { svc } = makeAuth('http://foreign-token.local', fetchFn, store)
266
+
267
+ const { data, error } = await svc.getClaims(hs256Jwt('u_foreign'))
268
+
269
+ expect(error).toBeNull()
270
+ // Les claims proviennent du token ÉTRANGER validé serveur, PAS du store.
271
+ expect(data?.claims.sub).toBe('u_foreign')
272
+ })
273
+
274
+ it('LOT D : token étranger refusé par le backend (403 autre projet / invalide) → error, pas de fabrication', async () => {
275
+ // Isolation projet native : un token d'un AUTRE projet → 403 côté gateway.
276
+ // getClaims doit remonter l'AuraError, jamais fabriquer des claims.
277
+ const fetchFn = (async (url: string | URL | Request) => {
278
+ const u = String(url)
279
+ if (u.includes('/.well-known/jwks.json')) {
280
+ return new Response(JSON.stringify({ keys: [] }), {
281
+ status: 200,
282
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=600' },
283
+ })
284
+ }
285
+ if (u.endsWith('/user')) {
286
+ return new Response(JSON.stringify({ error: { code: 'forbidden', message: 'wrong project' } }), {
287
+ status: 403,
288
+ headers: { 'Content-Type': 'application/json' },
289
+ })
290
+ }
291
+ return new Response('{}', { status: 200 })
292
+ }) as unknown as typeof globalThis.fetch
293
+ const store = new BaseAuthStore()
294
+ store.save(hs256Jwt('u_store'), 'refresh', fakeUser('u_store'))
295
+ const { svc } = makeAuth('http://foreign-403.local', fetchFn, store)
296
+
297
+ const { data, error } = await svc.getClaims(hs256Jwt('u_foreign'))
298
+
299
+ expect(data).toBeNull()
300
+ expect((error as { status?: number })?.status).toBe(403)
301
+ })
302
+
303
+ it('LOT D : getUser(jwt) valide le token passé, pas celui du store', async () => {
304
+ const seen: string[] = []
305
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
306
+ const u = String(url)
307
+ if (u.endsWith('/user')) {
308
+ const auth = (init?.headers as Record<string, string> | undefined)?.['Authorization'] ?? ''
309
+ seen.push(auth)
310
+ return new Response(JSON.stringify(fakeUser('u_foreign')), {
311
+ status: 200,
312
+ headers: { 'Content-Type': 'application/json' },
313
+ })
314
+ }
315
+ return new Response('{}', { status: 200 })
316
+ }) as unknown as typeof globalThis.fetch
317
+ const store = new BaseAuthStore()
318
+ store.save(hs256Jwt('u_store'), 'refresh', fakeUser('u_store'))
319
+ const { svc } = makeAuth('http://getuser-jwt.local', fetchFn, store)
320
+
321
+ const { data, error } = await svc.getUser('the-foreign-jwt')
322
+
323
+ expect(error).toBeNull()
324
+ expect(data?.id).toBe('u_foreign')
325
+ expect(seen).toContain('Bearer the-foreign-jwt') // le token PASSÉ, pas le store
326
+ })
327
+
328
+ it('JWKS vide (token de session) → fallback getUser', async () => {
329
+ const { pair } = await genRs256() // token RS256 SANS kid
330
+ const token = await signRs256(
331
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
332
+ pair.privateKey,
333
+ )
334
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
335
+ const store = new BaseAuthStore()
336
+ store.save(token, 'r', fakeUser('u1')) // token de session → fallback autorisé
337
+ const { svc } = makeAuth('http://jwks-empty.local', fetchFn, store)
338
+
339
+ const { data, error } = await svc.getClaims() // no-arg
340
+
341
+ expect(error).toBeNull()
342
+ expect(data?.claims.sub).toBe('u1')
343
+ expect(counts.jwks).toBe(1) // pas de kid → pas de refetch
344
+ expect(counts.user).toBe(1)
345
+ })
346
+
347
+ it('JWK malformé (kty compatible) → error signature_invalid, sans rejet ni fallback', async () => {
348
+ // Token RS256 valide, mais le JWKS sert une clé RSA corrompue (n/e invalides).
349
+ // Le match par kty la sélectionne, puis crypto.subtle.importKey *jette*.
350
+ const { pair } = await genRs256('k1')
351
+ const token = await signRs256(
352
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
353
+ pair.privateKey,
354
+ 'k1',
355
+ )
356
+ const badJwk: JsonWebKey = { kty: 'RSA', kid: 'k1', n: '!!!not-base64url!!!', e: 'AQAB', alg: 'RS256' }
357
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [badJwk] }), fakeUser('u1'))
358
+ const { svc } = makeAuth('http://jwk-malformed.local', fetchFn)
359
+
360
+ // Ne doit jamais rejeter la promesse : le contrat est AuraResponse { data, error }.
361
+ const { data, error } = await svc.getClaims(token)
362
+
363
+ expect(data).toBeNull()
364
+ expect((error as { code?: string })?.code).toBe('signature_invalid')
365
+ expect(counts.user).toBe(0) // fail-closed : pas de fallback getUser sur JWKS corrompu
366
+ })
367
+
368
+ it('kid inconnu → refetch JWKS une fois, puis fallback si toujours absent', async () => {
369
+ const { pair } = await genRs256('unknown-kid')
370
+ const token = await signRs256(
371
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
372
+ pair.privateKey,
373
+ 'unknown-kid',
374
+ )
375
+ // JWKS toujours vide → aucune clé ne matchera, ni par kid ni par kty.
376
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
377
+ const store = new BaseAuthStore()
378
+ store.save(token, 'r', fakeUser('u1')) // token de session → fallback autorisé
379
+ const { svc } = makeAuth('http://kid-unknown.local', fetchFn, store)
380
+
381
+ const { data, error } = await svc.getClaims() // no-arg
382
+
383
+ expect(error).toBeNull()
384
+ expect(data?.claims.sub).toBe('u1')
385
+ expect(counts.jwks).toBe(2) // 1 initial + 1 refetch forcé (rotation)
386
+ expect(counts.user).toBe(1) // puis fallback getUser
387
+ })
388
+
389
+ it('rotation RS256 : kid absent mais autre clé RSA présente → fallback (jamais signature_invalid)', async () => {
390
+ // Token signé avec kid='k-old' ; le JWKS ne sert qu'une AUTRE clé RSA valide
391
+ // (kid='k-new'). selectKey borné au kid ne doit PAS sélectionner k-new (sinon
392
+ // verify échoue → signature_invalid SANS fallback). On veut no_matching_key →
393
+ // fallback getUser (le token est le token de session).
394
+ const { pair } = await genRs256('k-old') // clé de signature (k-old)
395
+ const { jwk: jwkNew } = await genRs256('k-new') // 2e paire, clé publique servie
396
+ const token = await signRs256(
397
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
398
+ pair.privateKey,
399
+ 'k-old',
400
+ )
401
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [jwkNew] }), fakeUser('u1'))
402
+ const store = new BaseAuthStore()
403
+ store.save(token, 'r', fakeUser('u1')) // token de session → fallback autorisé
404
+ const { svc } = makeAuth('http://rs256-rotation.local', fetchFn, store)
405
+
406
+ const { data, error } = await svc.getClaims() // no-arg
407
+
408
+ expect(error).toBeNull()
409
+ expect(data?.claims.sub).toBe('u1')
410
+ expect(counts.user).toBe(1) // fallback getUser, PAS signature_invalid
411
+ expect(counts.jwks).toBe(2) // initial + refetch forcé (kid présent introuvable)
412
+ })
413
+ })
414
+
415
+ describe('AuthService.getClaims — robustesse JWKS (LOT 1)', () => {
416
+ it('#1 dédup in-flight : 2 getClaims concurrents cache froid → 1 seul fetch JWKS', async () => {
417
+ const { pair, jwk } = await genRs256('k1')
418
+ const token = await signRs256(
419
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
420
+ pair.privateKey,
421
+ 'k1',
422
+ )
423
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [jwk] }), fakeUser(), { delayMs: 5 })
424
+ const { svc } = makeAuth('http://jwks-dedup.local', fetchFn)
425
+
426
+ const [a, b] = await Promise.all([svc.getClaims(token), svc.getClaims(token)])
427
+
428
+ expect(a.error).toBeNull()
429
+ expect(b.error).toBeNull()
430
+ expect(counts.jwks).toBe(1) // un SEUL appel réseau malgré 2 vérifs concurrentes
431
+ })
432
+
433
+ it('#2 cooldown : 2 tokens à kid inconnu rapprochés → 1 seul refetch forcé', async () => {
434
+ const { pair } = await genRs256('unknown-kid')
435
+ const token = await signRs256(
436
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
437
+ pair.privateKey,
438
+ 'unknown-kid',
439
+ )
440
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
441
+ const store = new BaseAuthStore()
442
+ store.save(token, 'r', fakeUser('u1'))
443
+ const { svc } = makeAuth('http://jwks-cooldown.local', fetchFn, store)
444
+
445
+ await svc.getClaims() // 1 initial + 1 refetch forcé = 2
446
+ await svc.getClaims() // initial servi du cache ; refetch forcé bloqué par cooldown
447
+
448
+ // Sans cooldown : 3 (le 2e appel referait un refetch forcé). Avec : borné à 2.
449
+ expect(counts.jwks).toBe(2)
450
+ })
451
+
452
+ it('#3 negative-cache : fetch KO puis re-appel < TTL → pas de 2e réseau', async () => {
453
+ const { pair } = await genRs256('k1')
454
+ const rsToken = await signRs256(
455
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
456
+ pair.privateKey,
457
+ 'k1',
458
+ )
459
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'), { jwksStatus: 500 })
460
+ const store = new BaseAuthStore()
461
+ store.save(rsToken, 'r', fakeUser('u1'))
462
+ const { svc } = makeAuth('http://jwks-negcache.local', fetchFn, store)
463
+
464
+ await svc.getClaims() // fetch réseau → 500 → jwks_fetch_failed → fallback getUser
465
+ await svc.getClaims() // < TTL → negative-cache → PAS de 2e réseau
466
+
467
+ expect(counts.jwks).toBe(1)
468
+ })
469
+
470
+ it('#4 cap max-age : max-age=999999999 plafonné (refetch après la borne haute)', async () => {
471
+ vi.useFakeTimers()
472
+ const { pair, jwk } = await genRs256('k1')
473
+ const token = await signRs256(
474
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 10_000_000 },
475
+ pair.privateKey,
476
+ 'k1',
477
+ )
478
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [jwk] }), fakeUser(), {
479
+ cacheControl: 'max-age=999999999',
480
+ })
481
+ const { svc } = makeAuth('http://jwks-cap.local', fetchFn)
482
+
483
+ const r1 = await svc.getClaims(token)
484
+ expect(r1.error).toBeNull()
485
+ expect(counts.jwks).toBe(1)
486
+
487
+ // Avance de 25h : au-delà du cap 24h mais très en deçà de 999999999s.
488
+ vi.advanceTimersByTime(25 * 60 * 60 * 1000)
489
+
490
+ const r2 = await svc.getClaims(token)
491
+ expect(r2.error).toBeNull()
492
+ // Sans cap : toujours en cache → 1. Avec cap 24h : cache périmé → refetch → 2.
493
+ expect(counts.jwks).toBe(2)
494
+ })
495
+
496
+ it('#6 sélection no-kid : 2 clés RSA sans kid, token signé par la 2e → succès', async () => {
497
+ const { jwk: jwk1 } = await genRs256() // clé leurre (ne signe pas)
498
+ const { pair: pair2, jwk: jwk2 } = await genRs256() // clé de signature
499
+ const token = await signRs256(
500
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
501
+ pair2.privateKey,
502
+ ) // pas de kid
503
+ const { fetchFn } = makeFetch(() => ({ keys: [jwk1, jwk2] }))
504
+ const { svc } = makeAuth('http://jwks-nokid.local', fetchFn)
505
+
506
+ const { data, error } = await svc.getClaims(token)
507
+
508
+ expect(error).toBeNull()
509
+ expect(data?.claims.sub).toBe('u1')
510
+ expect(data?.header.alg).toBe('RS256')
511
+ })
512
+
513
+ it('#7 fallback : alg RÉEL du token (RS256), pas HS256 en dur ; exp non fabriqué', async () => {
514
+ // Vrai token RS256 en session, mais fetch JWKS KO → fallback getUser.
515
+ const { pair } = await genRs256('k1')
516
+ const token = await signRs256(
517
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
518
+ pair.privateKey,
519
+ 'k1',
520
+ )
521
+ const { fetchFn } = makeFetch(() => ({ keys: [] }), fakeUser('u1'), { jwksStatus: 503 })
522
+ const store = new BaseAuthStore()
523
+ store.save(token, 'r', fakeUser('u1'))
524
+ const { svc } = makeAuth('http://jwks-fallback-alg.local', fetchFn, store)
525
+
526
+ const { data, error } = await svc.getClaims()
527
+
528
+ expect(error).toBeNull()
529
+ expect(data?.header.alg).toBe('RS256') // alg réel décodé, PAS 'HS256' en dur
530
+ // Honnêteté de type : le fallback ne fabrique pas d'exp/iat.
531
+ expect(data?.claims.exp).toBeUndefined()
532
+ expect(data?.claims.iat).toBeUndefined()
533
+ })
534
+
535
+ it('#7b local RS256 : exp présent (number) sur le chemin de vérif locale', async () => {
536
+ const { pair, jwk } = await genRs256('k1')
537
+ const exp = Math.floor(Date.now() / 1000) + 3600
538
+ const token = await signRs256({ sub: 'u1', exp }, pair.privateKey, 'k1')
539
+ const { fetchFn } = makeFetch(() => ({ keys: [jwk] }))
540
+ const { svc } = makeAuth('http://jwks-local-exp.local', fetchFn)
541
+
542
+ const { data } = await svc.getClaims(token)
543
+
544
+ expect(typeof data?.claims.exp).toBe('number')
545
+ expect(data?.claims.exp).toBe(exp)
546
+ })
547
+
548
+ it('#8 issuer/audience : validés sur le chemin local (RS256)', async () => {
549
+ const { pair, jwk } = await genRs256('k1')
550
+ const token = await signRs256(
551
+ {
552
+ sub: 'u1',
553
+ exp: Math.floor(Date.now() / 1000) + 3600,
554
+ iss: 'https://issuer.a',
555
+ aud: 'aud-1',
556
+ },
557
+ pair.privateKey,
558
+ 'k1',
559
+ )
560
+ const { fetchFn } = makeFetch(() => ({ keys: [jwk] }))
561
+ const { svc } = makeAuth('http://jwks-issaud.local', fetchFn)
562
+
563
+ const ok = await svc.getClaims(token, { issuer: 'https://issuer.a', audience: 'aud-1' })
564
+ expect(ok.error).toBeNull()
565
+ expect(ok.data?.claims.sub).toBe('u1')
566
+
567
+ const badIss = await svc.getClaims(token, { issuer: 'https://issuer.b' })
568
+ expect(badIss.data).toBeNull()
569
+ expect((badIss.error as { code?: string })?.code).toBe('invalid_issuer')
570
+
571
+ const badAud = await svc.getClaims(token, { audience: 'aud-2' })
572
+ expect(badAud.data).toBeNull()
573
+ expect((badAud.error as { code?: string })?.code).toBe('invalid_audience')
574
+ })
575
+ })
576
+
577
+ describe('AuthService.getClaims — observabilité jwks-fetch-failed (LOT 3, I8)', () => {
578
+ it('fetch JWKS en échec (500) → onEvent reçoit {kind:"jwks-fetch-failed", baseUrl}', async () => {
579
+ const { pair } = await genRs256('k1')
580
+ const token = await signRs256(
581
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
582
+ pair.privateKey,
583
+ 'k1',
584
+ )
585
+ const { fetchFn } = makeFetch(() => ({ keys: [] }), fakeUser('u1'), { jwksStatus: 500 })
586
+ const store = new BaseAuthStore()
587
+ store.save(token, 'r', fakeUser('u1'))
588
+ const events: AuraObservabilityEvent[] = []
589
+ const { svc } = makeAuth('http://jwks-obs-fail.local', fetchFn, store, (e) => events.push(e))
590
+
591
+ const { error } = await svc.getClaims()
592
+
593
+ expect(error).toBeNull() // fallback getUser réussit malgré l'échec JWKS
594
+ expect(events).toContainEqual({
595
+ kind: 'jwks-fetch-failed',
596
+ baseUrl: 'http://jwks-obs-fail.local',
597
+ })
598
+ })
599
+
600
+ it('kid inconnu → refetch forcé qui échoue → onEvent reçoit aussi jwks-fetch-failed', async () => {
601
+ const { pair } = await genRs256('unknown-kid')
602
+ const token = await signRs256(
603
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
604
+ pair.privateKey,
605
+ 'unknown-kid',
606
+ )
607
+ // 1er fetch JWKS OK (cache froid, vide) ; le refetch forcé (kid inconnu) échoue.
608
+ let call = 0
609
+ const fetchFn = (async (url: string | URL | Request) => {
610
+ const u = String(url)
611
+ if (u.includes('/.well-known/jwks.json')) {
612
+ call++
613
+ if (call === 1) {
614
+ return new Response(JSON.stringify({ keys: [] }), {
615
+ status: 200,
616
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=0' },
617
+ })
618
+ }
619
+ return new Response('fail', { status: 500 })
620
+ }
621
+ if (u.endsWith('/user')) {
622
+ return new Response(JSON.stringify(fakeUser('u1')), {
623
+ status: 200,
624
+ headers: { 'Content-Type': 'application/json' },
625
+ })
626
+ }
627
+ return new Response('{}', { status: 200 })
628
+ }) as unknown as typeof globalThis.fetch
629
+ const store = new BaseAuthStore()
630
+ store.save(token, 'r', fakeUser('u1'))
631
+ const events: AuraObservabilityEvent[] = []
632
+ const { svc } = makeAuth('http://jwks-obs-refetch-fail.local', fetchFn, store, (e) => events.push(e))
633
+
634
+ const { error } = await svc.getClaims()
635
+
636
+ expect(error).toBeNull()
637
+ expect(events).toContainEqual({
638
+ kind: 'jwks-fetch-failed',
639
+ baseUrl: 'http://jwks-obs-refetch-fail.local',
640
+ })
641
+ })
642
+
643
+ it('onEvent absent : aucun throw, comportement getClaims inchangé', async () => {
644
+ const { pair } = await genRs256('k1')
645
+ const token = await signRs256(
646
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
647
+ pair.privateKey,
648
+ 'k1',
649
+ )
650
+ const { fetchFn } = makeFetch(() => ({ keys: [] }), fakeUser('u1'), { jwksStatus: 500 })
651
+ const store = new BaseAuthStore()
652
+ store.save(token, 'r', fakeUser('u1'))
653
+ const { svc } = makeAuth('http://jwks-obs-noop.local', fetchFn, store) // pas d'onEvent
654
+
655
+ const { error, data } = await svc.getClaims()
656
+ expect(error).toBeNull()
657
+ expect(data?.claims.sub).toBe('u1')
658
+ })
659
+ })
660
+
661
+ describe('AuthService.getClaims — SEC-F1 : options iss/aud/allowExpired appliquées AU FALLBACK', () => {
662
+ it('audience ≠ aud sur token HS256 (fallback) → invalid_audience (pas un no-op), AVANT tout appel serveur', async () => {
663
+ const now = Math.floor(Date.now() / 1000)
664
+ const token = hs256WithClaims({ sub: 'u1', exp: now + 3600, aud: 'real-aud' })
665
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
666
+ const store = new BaseAuthStore()
667
+ store.save(token, 'r', fakeUser('u1')) // token de session → fallback autorisé
668
+ const { svc } = makeAuth('http://f1-aud.local', fetchFn, store)
669
+
670
+ const { data, error } = await svc.getClaims(token, { audience: 'x' })
671
+
672
+ expect(data).toBeNull()
673
+ expect((error as { code?: string })?.code).toBe('invalid_audience')
674
+ expect(counts.user).toBe(0) // rejeté localement, aucun fallback réseau
675
+ })
676
+
677
+ it('issuer ≠ iss sur token HS256 (fallback) → invalid_issuer', async () => {
678
+ const now = Math.floor(Date.now() / 1000)
679
+ const token = hs256WithClaims({ sub: 'u1', exp: now + 3600, iss: 'https://issuer.a' })
680
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
681
+ const store = new BaseAuthStore()
682
+ store.save(token, 'r', fakeUser('u1'))
683
+ const { svc } = makeAuth('http://f1-iss.local', fetchFn, store)
684
+
685
+ const { data, error } = await svc.getClaims(token, { issuer: 'https://issuer.b' })
686
+
687
+ expect(data).toBeNull()
688
+ expect((error as { code?: string })?.code).toBe('invalid_issuer')
689
+ expect(counts.user).toBe(0)
690
+ })
691
+
692
+ it('iss/aud conformes sur token HS256 (fallback) → fallback getUser OK', async () => {
693
+ const now = Math.floor(Date.now() / 1000)
694
+ const token = hs256WithClaims({ sub: 'u1', exp: now + 3600, iss: 'https://issuer.a', aud: 'aud-1' })
695
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
696
+ const store = new BaseAuthStore()
697
+ store.save(token, 'r', fakeUser('u1'))
698
+ const { svc } = makeAuth('http://f1-ok.local', fetchFn, store)
699
+
700
+ const { data, error } = await svc.getClaims(token, { issuer: 'https://issuer.a', audience: 'aud-1' })
701
+
702
+ expect(error).toBeNull()
703
+ expect(data?.claims.sub).toBe('u1')
704
+ expect(counts.user).toBe(1) // options conformes → le fallback s'exécute
705
+ })
706
+
707
+ it('allowExpired sur token HS256 EXPIRÉ (fallback) → code cohérent allow_expired_unsupported_on_fallback', async () => {
708
+ const now = Math.floor(Date.now() / 1000)
709
+ const token = hs256WithClaims({ sub: 'u1', exp: now - 60 }) // expiré
710
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [] }), fakeUser('u1'))
711
+ const store = new BaseAuthStore()
712
+ store.save(token, 'r', fakeUser('u1'))
713
+ const { svc } = makeAuth('http://f1-exp.local', fetchFn, store)
714
+
715
+ const { data, error } = await svc.getClaims(token, { allowExpired: true })
716
+
717
+ expect(data).toBeNull()
718
+ expect((error as { code?: string })?.code).toBe('allow_expired_unsupported_on_fallback')
719
+ expect(counts.user).toBe(0) // pas d'appel serveur voué à un 401 trompeur
720
+ })
721
+ })
722
+
723
+ describe('AuthService.getClaims — SEC-F2 : isolation projet sur le chemin LOCAL (RS256)', () => {
724
+ it('token RS256 valide d’un AUTRE projet → rejeté project_isolation_violation', async () => {
725
+ const { pair, jwk } = await genRs256('k1')
726
+ const token = await signRs256(
727
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600, project_id: 'other-project' },
728
+ pair.privateKey,
729
+ 'k1',
730
+ )
731
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [jwk] }))
732
+ // makeAuth : projectId = 'p'.
733
+ const { svc } = makeAuth('http://f2-iso.local', fetchFn)
734
+
735
+ const { data, error } = await svc.getClaims(token)
736
+
737
+ expect(data).toBeNull()
738
+ expect((error as { code?: string })?.code).toBe('project_isolation_violation')
739
+ expect(counts.user).toBe(0) // rejeté localement, pas de fabrication ni fallback
740
+ })
741
+
742
+ it('token RS256 du BON projet (project_id === client.projectId) → accepté', async () => {
743
+ const { pair, jwk } = await genRs256('k1')
744
+ const token = await signRs256(
745
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600, project_id: 'p' },
746
+ pair.privateKey,
747
+ 'k1',
748
+ )
749
+ const { fetchFn } = makeFetch(() => ({ keys: [jwk] }))
750
+ const { svc } = makeAuth('http://f2-ok.local', fetchFn)
751
+
752
+ const { data, error } = await svc.getClaims(token)
753
+
754
+ expect(error).toBeNull()
755
+ expect(data?.claims.project_id).toBe('p')
756
+ })
757
+ })
758
+
759
+ describe('AuthService.getClaims — SEC-F4 : cache JWKS CLIENT-SCOPED (WeakMap)', () => {
760
+ it('deux clients distincts (même baseUrl) → caches JWKS ISOLÉS (chacun fetch le sien)', async () => {
761
+ const { pair, jwk } = await genRs256('k1')
762
+ const token = await signRs256(
763
+ { sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 },
764
+ pair.privateKey,
765
+ 'k1',
766
+ )
767
+ // Un SEUL fetchFn/baseUrl partagé, mais DEUX clients (services) distincts.
768
+ const { fetchFn, counts } = makeFetch(() => ({ keys: [jwk] }))
769
+ const { svc: a } = makeAuth('http://f4-shared.local', fetchFn, new BaseAuthStore())
770
+ const { svc: b } = makeAuth('http://f4-shared.local', fetchFn, new BaseAuthStore())
771
+
772
+ const ra = await a.getClaims(token)
773
+ const rb = await b.getClaims(token)
774
+
775
+ expect(ra.error).toBeNull()
776
+ expect(rb.error).toBeNull()
777
+ // Cache global → 1 seul fetch. Client-scoped → chaque client fetch le sien = 2.
778
+ expect(counts.jwks).toBe(2)
779
+ })
780
+ })