@decohere/http 0.0.1-dev.126 → 0.0.1-dev.5

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 (293) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -13
  3. package/src/app/app.ts +147 -0
  4. package/src/app/module.ts +19 -0
  5. package/src/auth/ability.ts +56 -0
  6. package/src/auth/auth.config.ts +34 -0
  7. package/src/auth/auth.extensions.ts +115 -0
  8. package/src/auth/auth.types.ts +124 -0
  9. package/src/auth/casl-authorization.service.ts +62 -0
  10. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  11. package/src/auth/safe-outbound-url.ts +117 -0
  12. package/src/auth/token-service.ts +192 -0
  13. package/src/builder/builder.ts +118 -0
  14. package/{es6/src/const.js → src/const.ts} +25 -17
  15. package/src/health/health.controller.ts +34 -0
  16. package/src/health/health.extensions.ts +53 -0
  17. package/src/health/readiness.service.ts +33 -0
  18. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  19. package/src/hosting/hosted-service-host.ts +102 -0
  20. package/src/hosting/hosted-service.ts +31 -0
  21. package/src/http/http.compression.ts +38 -0
  22. package/src/http/http.extensions.ts +244 -0
  23. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  24. package/src/http/http.options.ts +41 -0
  25. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  26. package/src/http/http.static.ts +359 -0
  27. package/{es6/src/index.js → src/index.ts} +1 -0
  28. package/src/openapi/index.ts +22 -0
  29. package/src/openapi/openapi.generator.ts +78 -0
  30. package/src/openapi/openapi.types.ts +60 -0
  31. package/src/openapi/request-schema.ts +155 -0
  32. package/src/openapi/request-validator.ts +95 -0
  33. package/src/providers/fastify.provider.ts +454 -0
  34. package/src/routing/anvil.types.ts +150 -0
  35. package/src/routing/decorators/argument.decorator.ts +33 -0
  36. package/src/routing/decorators/auth.decorator.ts +79 -0
  37. package/src/routing/decorators/controller.decorator.ts +9 -0
  38. package/src/routing/decorators/field.decorator.ts +15 -0
  39. package/src/routing/decorators/request.decorator.ts +34 -0
  40. package/src/routing/decorators/response.decorator.ts +18 -0
  41. package/src/routing/decorators/route.decorator.ts +52 -0
  42. package/src/routing/filters/exception-filter-host.ts +90 -0
  43. package/src/routing/filters/exception-filter.ts +29 -0
  44. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  45. package/src/routing/middleware/middleware.ts +26 -0
  46. package/src/routing/middleware/request-id.ts +18 -0
  47. package/src/routing/path.util.ts +38 -0
  48. package/src/routing/pipes/pipes.ts +132 -0
  49. package/src/routing/services/route-argument-resolver.ts +92 -0
  50. package/src/routing/services/route-registry.ts +229 -0
  51. package/src/routing/services/router.ts +166 -0
  52. package/src/sse/sse.decorator.ts +41 -0
  53. package/src/sse/sse.encoder.ts +86 -0
  54. package/src/sse/sse.types.ts +47 -0
  55. package/src/sse/sse.wire.ts +34 -0
  56. package/src/testing/boot-module.ts +63 -0
  57. package/src/testing/sse-reader.ts +120 -0
  58. package/tests/app.hosted-services.spec.ts +292 -0
  59. package/tests/app.shutdown.integration.spec.ts +160 -0
  60. package/tests/app.spec.ts +132 -0
  61. package/tests/auth.ability.spec.ts +48 -0
  62. package/tests/auth.decorators.spec.ts +20 -0
  63. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  64. package/tests/auth.integration.spec.ts +234 -0
  65. package/tests/auth.jwks-cache.spec.ts +52 -0
  66. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  67. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  68. package/tests/auth.oidc.integration.spec.ts +161 -0
  69. package/tests/auth.token-service.spec.ts +251 -0
  70. package/tests/builder.spec.ts +12 -0
  71. package/tests/health.integration.spec.ts +111 -0
  72. package/tests/helpers/listen-ephemeral.ts +13 -0
  73. package/tests/helpers/local-oidc-server.ts +100 -0
  74. package/tests/http.access-log.integration.spec.ts +111 -0
  75. package/tests/http.boolean-query.integration.spec.ts +59 -0
  76. package/tests/http.compression.integration.spec.ts +604 -0
  77. package/tests/http.date-query.integration.spec.ts +71 -0
  78. package/tests/http.global-prefix.integration.spec.ts +284 -0
  79. package/tests/http.number-query.integration.spec.ts +65 -0
  80. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  81. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  82. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  83. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  84. package/tests/http.security.integration.spec.ts +106 -0
  85. package/tests/http.static.integration.spec.ts +847 -0
  86. package/tests/http.static.spec.ts +111 -0
  87. package/tests/http.surface.integration.spec.ts +167 -0
  88. package/tests/openapi.integration.spec.ts +193 -0
  89. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  90. package/tests/routing.decorators.spec.ts +15 -0
  91. package/tests/routing.registry.spec.ts +44 -0
  92. package/tests/routing.resolver.spec.ts +92 -0
  93. package/tests/sse.integration.spec.ts +421 -0
  94. package/tests/sse.wire.spec.ts +33 -0
  95. package/LICENSE +0 -15
  96. package/commonjs/src/app/app.js +0 -166
  97. package/commonjs/src/app/index.js +0 -19
  98. package/commonjs/src/app/module.js +0 -17
  99. package/commonjs/src/auth/ability.js +0 -54
  100. package/commonjs/src/auth/auth.config.js +0 -23
  101. package/commonjs/src/auth/auth.extensions.js +0 -106
  102. package/commonjs/src/auth/auth.types.js +0 -65
  103. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  104. package/commonjs/src/auth/index.js +0 -23
  105. package/commonjs/src/auth/principal-provider.js +0 -19
  106. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  107. package/commonjs/src/auth/token-service.js +0 -151
  108. package/commonjs/src/builder/builder.js +0 -95
  109. package/commonjs/src/builder/index.js +0 -18
  110. package/commonjs/src/const.js +0 -87
  111. package/commonjs/src/health/health.controller.js +0 -75
  112. package/commonjs/src/health/health.extensions.js +0 -50
  113. package/commonjs/src/health/index.js +0 -21
  114. package/commonjs/src/health/readiness.service.js +0 -67
  115. package/commonjs/src/health/readiness.types.js +0 -7
  116. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  117. package/commonjs/src/hosting/hosted-service.js +0 -11
  118. package/commonjs/src/hosting/index.js +0 -19
  119. package/commonjs/src/http/http.compression.js +0 -27
  120. package/commonjs/src/http/http.extensions.js +0 -269
  121. package/commonjs/src/http/http.options.js +0 -26
  122. package/commonjs/src/http/http.options.store.js +0 -20
  123. package/commonjs/src/http/http.security.js +0 -27
  124. package/commonjs/src/http/http.static.js +0 -248
  125. package/commonjs/src/http/index.js +0 -22
  126. package/commonjs/src/index.js +0 -27
  127. package/commonjs/src/openapi/index.js +0 -45
  128. package/commonjs/src/openapi/openapi.generator.js +0 -103
  129. package/commonjs/src/openapi/openapi.types.js +0 -4
  130. package/commonjs/src/openapi/request-schema.js +0 -187
  131. package/commonjs/src/openapi/request-validator.js +0 -85
  132. package/commonjs/src/providers/fastify.provider.js +0 -391
  133. package/commonjs/src/providers/index.js +0 -18
  134. package/commonjs/src/routing/anvil.types.js +0 -99
  135. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  136. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  137. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  138. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  139. package/commonjs/src/routing/decorators/index.js +0 -24
  140. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  141. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  142. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  143. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  144. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  145. package/commonjs/src/routing/index.js +0 -25
  146. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  147. package/commonjs/src/routing/middleware/middleware.js +0 -20
  148. package/commonjs/src/routing/middleware/request-id.js +0 -30
  149. package/commonjs/src/routing/path.util.js +0 -51
  150. package/commonjs/src/routing/pipes/index.js +0 -18
  151. package/commonjs/src/routing/pipes/pipes.js +0 -145
  152. package/commonjs/src/routing/services/index.js +0 -18
  153. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  154. package/commonjs/src/routing/services/route-registry.js +0 -194
  155. package/commonjs/src/routing/services/router.js +0 -158
  156. package/commonjs/src/sse/index.js +0 -26
  157. package/commonjs/src/sse/sse.decorator.js +0 -42
  158. package/commonjs/src/sse/sse.encoder.js +0 -73
  159. package/commonjs/src/sse/sse.types.js +0 -36
  160. package/commonjs/src/sse/sse.wire.js +0 -40
  161. package/commonjs/src/testing/boot-module.js +0 -38
  162. package/commonjs/src/testing/index.js +0 -20
  163. package/commonjs/src/testing/sse-reader.js +0 -98
  164. package/es6/src/app/app.js +0 -148
  165. package/es6/src/app/index.js +0 -2
  166. package/es6/src/app/module.js +0 -7
  167. package/es6/src/auth/ability.js +0 -45
  168. package/es6/src/auth/auth.config.js +0 -19
  169. package/es6/src/auth/auth.extensions.js +0 -83
  170. package/es6/src/auth/auth.types.js +0 -35
  171. package/es6/src/auth/casl-authorization.service.js +0 -40
  172. package/es6/src/auth/index.js +0 -6
  173. package/es6/src/auth/principal-provider.js +0 -14
  174. package/es6/src/auth/safe-outbound-url.js +0 -99
  175. package/es6/src/auth/token-service.js +0 -141
  176. package/es6/src/builder/builder.js +0 -85
  177. package/es6/src/builder/index.js +0 -1
  178. package/es6/src/health/health.controller.js +0 -65
  179. package/es6/src/health/health.extensions.js +0 -40
  180. package/es6/src/health/index.js +0 -4
  181. package/es6/src/health/readiness.service.js +0 -57
  182. package/es6/src/health/readiness.types.js +0 -4
  183. package/es6/src/hosting/hosted-service-host.js +0 -81
  184. package/es6/src/hosting/hosted-service.js +0 -8
  185. package/es6/src/hosting/index.js +0 -2
  186. package/es6/src/http/http.compression.js +0 -9
  187. package/es6/src/http/http.extensions.js +0 -259
  188. package/es6/src/http/http.options.js +0 -16
  189. package/es6/src/http/http.options.store.js +0 -13
  190. package/es6/src/http/http.security.js +0 -9
  191. package/es6/src/http/http.static.js +0 -224
  192. package/es6/src/http/index.js +0 -5
  193. package/es6/src/openapi/index.js +0 -3
  194. package/es6/src/openapi/openapi.generator.js +0 -82
  195. package/es6/src/openapi/openapi.types.js +0 -1
  196. package/es6/src/openapi/request-schema.js +0 -160
  197. package/es6/src/openapi/request-validator.js +0 -71
  198. package/es6/src/providers/fastify.provider.js +0 -376
  199. package/es6/src/providers/index.js +0 -1
  200. package/es6/src/routing/anvil.types.js +0 -57
  201. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  202. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  203. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  204. package/es6/src/routing/decorators/field.decorator.js +0 -15
  205. package/es6/src/routing/decorators/index.js +0 -7
  206. package/es6/src/routing/decorators/request.decorator.js +0 -24
  207. package/es6/src/routing/decorators/response.decorator.js +0 -16
  208. package/es6/src/routing/decorators/route.decorator.js +0 -37
  209. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  210. package/es6/src/routing/filters/exception-filter.js +0 -10
  211. package/es6/src/routing/index.js +0 -8
  212. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  213. package/es6/src/routing/middleware/middleware.js +0 -10
  214. package/es6/src/routing/middleware/request-id.js +0 -14
  215. package/es6/src/routing/path.util.js +0 -33
  216. package/es6/src/routing/pipes/index.js +0 -1
  217. package/es6/src/routing/pipes/pipes.js +0 -118
  218. package/es6/src/routing/services/index.js +0 -1
  219. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  220. package/es6/src/routing/services/route-registry.js +0 -184
  221. package/es6/src/routing/services/router.js +0 -148
  222. package/es6/src/sse/index.js +0 -2
  223. package/es6/src/sse/sse.decorator.js +0 -31
  224. package/es6/src/sse/sse.encoder.js +0 -67
  225. package/es6/src/sse/sse.types.js +0 -22
  226. package/es6/src/sse/sse.wire.js +0 -30
  227. package/es6/src/testing/boot-module.js +0 -35
  228. package/es6/src/testing/index.js +0 -2
  229. package/es6/src/testing/sse-reader.js +0 -91
  230. package/types/src/app/app.d.ts +0 -41
  231. package/types/src/app/module.d.ts +0 -15
  232. package/types/src/auth/ability.d.ts +0 -19
  233. package/types/src/auth/auth.config.d.ts +0 -28
  234. package/types/src/auth/auth.extensions.d.ts +0 -53
  235. package/types/src/auth/auth.types.d.ts +0 -73
  236. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  237. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  238. package/types/src/auth/token-service.d.ts +0 -31
  239. package/types/src/builder/builder.d.ts +0 -36
  240. package/types/src/const.d.ts +0 -32
  241. package/types/src/health/health.controller.d.ts +0 -16
  242. package/types/src/health/health.extensions.d.ts +0 -31
  243. package/types/src/health/readiness.service.d.ts +0 -9
  244. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  245. package/types/src/hosting/hosted-service.d.ts +0 -31
  246. package/types/src/http/http.compression.d.ts +0 -32
  247. package/types/src/http/http.extensions.d.ts +0 -79
  248. package/types/src/http/http.options.d.ts +0 -39
  249. package/types/src/http/http.static.d.ts +0 -160
  250. package/types/src/index.d.ts +0 -10
  251. package/types/src/openapi/index.d.ts +0 -6
  252. package/types/src/openapi/openapi.generator.d.ts +0 -6
  253. package/types/src/openapi/openapi.types.d.ts +0 -58
  254. package/types/src/openapi/request-schema.d.ts +0 -21
  255. package/types/src/openapi/request-validator.d.ts +0 -9
  256. package/types/src/providers/fastify.provider.d.ts +0 -56
  257. package/types/src/routing/anvil.types.d.ts +0 -119
  258. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  259. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  260. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  261. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  262. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  263. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  264. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  265. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  266. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  267. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  268. package/types/src/routing/middleware/middleware.d.ts +0 -10
  269. package/types/src/routing/middleware/request-id.d.ts +0 -6
  270. package/types/src/routing/path.util.d.ts +0 -9
  271. package/types/src/routing/pipes/pipes.d.ts +0 -21
  272. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  273. package/types/src/routing/services/route-registry.d.ts +0 -20
  274. package/types/src/routing/services/router.d.ts +0 -22
  275. package/types/src/sse/sse.decorator.d.ts +0 -17
  276. package/types/src/sse/sse.encoder.d.ts +0 -16
  277. package/types/src/sse/sse.types.d.ts +0 -37
  278. package/types/src/sse/sse.wire.d.ts +0 -9
  279. package/types/src/testing/boot-module.d.ts +0 -34
  280. package/types/src/testing/sse-reader.d.ts +0 -29
  281. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  282. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  283. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  284. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  285. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  286. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  287. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  288. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  289. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  290. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  291. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  292. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  293. /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
@@ -0,0 +1,161 @@
1
+ import axios from "axios";
2
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { AuthConfig } from "../src/auth/auth.config.js";
5
+ import "../src/auth/auth.extensions.js";
6
+ import { Builder } from "../src/builder/builder.js";
7
+ import "../src/http/http.extensions.js";
8
+ import type { IConfigurationService } from "@decohere/core/configuration";
9
+ import { ConfigurationServiceToken } from "@decohere/core/configuration";
10
+ import type { Constructable } from "@decohere/core/reflection";
11
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
12
+ import { Authorize, Controller, Get } from "../src/routing/index.js";
13
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
14
+ import { LocalOidcServer } from "./helpers/local-oidc-server.js";
15
+
16
+ // Started once for the whole suite — key generation is expensive.
17
+ let oidc: LocalOidcServer;
18
+
19
+ beforeAll(async () => {
20
+ oidc = await LocalOidcServer.start();
21
+ });
22
+
23
+ afterAll(async () => {
24
+ await oidc.stop();
25
+ });
26
+
27
+ // Configures the app to use discoveryUrl pointing at the local OIDC server.
28
+ class OidcConfigService implements IConfigurationService {
29
+ public get dataSources() {
30
+ return [];
31
+ }
32
+ public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
33
+ if (type === AuthConfig) {
34
+ const config = new AuthConfig();
35
+ config.discoveryUrl = oidc.issuer;
36
+ config.audience = "https://api.test";
37
+ return config as unknown as T;
38
+ }
39
+ return {} as T;
40
+ }
41
+ }
42
+
43
+ @Authorize("read", "Article")
44
+ @Controller()
45
+ class OidcController {
46
+ @Get("/resource")
47
+ public resource() {
48
+ return { ok: true };
49
+ }
50
+ }
51
+
52
+ describe("Auth integration — discoveryUrl + real RS256 tokens", () => {
53
+ let app: App;
54
+
55
+ beforeEach(async () => {
56
+ app = await Builder.create()
57
+ .withHttpProvider(FastifyProvider)
58
+ .withServices((c) => {
59
+ c.registerSingleton(ConfigurationServiceToken, new OidcConfigService());
60
+ })
61
+ .withAuth()
62
+ .withControllers(OidcController)
63
+ .build();
64
+
65
+ axios.defaults.baseURL = await listenEphemeral(app);
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await app.dispose();
70
+ });
71
+
72
+ it("returns 401 when no token is provided", async () => {
73
+ await expect(axios.get("/oidc/resource")).rejects.toMatchObject({
74
+ response: { status: 401 },
75
+ });
76
+ });
77
+
78
+ it("accepts a valid RS256 token issued by the local IdP", async () => {
79
+ const token = await oidc.signToken({ sub: "user-1", scope: "read:Article" });
80
+ const res = await axios.get("/oidc/resource", {
81
+ headers: { Authorization: `Bearer ${token}` },
82
+ });
83
+ expect(res.status).toBe(200);
84
+ });
85
+
86
+ it("returns 401 for a token signed with the wrong key", async () => {
87
+ // Use the wrong OIDC server (different key pair)
88
+ const wrongOidc = await LocalOidcServer.start();
89
+ const token = await wrongOidc.signToken({ sub: "user-1", scope: "read:Article" });
90
+ await wrongOidc.stop();
91
+
92
+ await expect(
93
+ axios.get("/oidc/resource", { headers: { Authorization: `Bearer ${token}` } }),
94
+ ).rejects.toMatchObject({ response: { status: 401 } });
95
+ });
96
+
97
+ it("returns 401 for an expired token", async () => {
98
+ const token = await oidc.signToken({ sub: "user-1", scope: "read:Article" }, "-1s");
99
+ await expect(
100
+ axios.get("/oidc/resource", { headers: { Authorization: `Bearer ${token}` } }),
101
+ ).rejects.toMatchObject({ response: { status: 401 } });
102
+ });
103
+
104
+ it("returns 403 when the token lacks the required scope", async () => {
105
+ const token = await oidc.signToken({ sub: "user-1", scope: "write:Article" });
106
+ await expect(
107
+ axios.get("/oidc/resource", { headers: { Authorization: `Bearer ${token}` } }),
108
+ ).rejects.toMatchObject({ response: { status: 403 } });
109
+ });
110
+ });
111
+
112
+ describe("Auth integration — principalMapper", () => {
113
+ let app: App;
114
+
115
+ beforeEach(async () => {
116
+ // Map Keycloak-style realm_access.roles to scopes
117
+ app = await Builder.create()
118
+ .withHttpProvider(FastifyProvider)
119
+ .withServices((c) => {
120
+ c.registerSingleton(ConfigurationServiceToken, new OidcConfigService());
121
+ })
122
+ .withAuth(undefined, {
123
+ principalMapper: (payload) => ({
124
+ sub: payload.sub as string,
125
+ scopes: ((payload.realm_access as { roles?: string[] })?.roles ?? []).map(
126
+ (r) => `read:${r}`,
127
+ ),
128
+ }),
129
+ })
130
+ .withControllers(OidcController)
131
+ .build();
132
+
133
+ axios.defaults.baseURL = await listenEphemeral(app);
134
+ });
135
+
136
+ afterEach(async () => {
137
+ await app.dispose();
138
+ });
139
+
140
+ it("maps custom claims to scopes via principalMapper", async () => {
141
+ // Token uses realm_access.roles instead of scope claim
142
+ const token = await oidc.signToken({
143
+ sub: "user-1",
144
+ realm_access: { roles: ["Article"] },
145
+ });
146
+ const res = await axios.get("/oidc/resource", {
147
+ headers: { Authorization: `Bearer ${token}` },
148
+ });
149
+ expect(res.status).toBe(200);
150
+ });
151
+
152
+ it("returns 403 when custom-mapped scopes do not satisfy the requirement", async () => {
153
+ const token = await oidc.signToken({
154
+ sub: "user-1",
155
+ realm_access: { roles: ["OtherResource"] },
156
+ });
157
+ await expect(
158
+ axios.get("/oidc/resource", { headers: { Authorization: `Bearer ${token}` } }),
159
+ ).rejects.toMatchObject({ response: { status: 403 } });
160
+ });
161
+ });
@@ -0,0 +1,251 @@
1
+ import { exportJWK, exportSPKI, generateKeyPair, SignJWT } from "jose";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { TokenService } from "../src/auth/token-service.js";
4
+ import { UnauthorizedError } from "../src/routing/anvil.types.js";
5
+
6
+ async function mintToken(
7
+ privateKey: Parameters<typeof SignJWT.prototype.sign>[0],
8
+ payload: Record<string, unknown> = {},
9
+ options: { exp?: string; iss?: string; aud?: string; alg?: string } = {},
10
+ ) {
11
+ return new SignJWT(payload)
12
+ .setProtectedHeader({ alg: options.alg ?? "RS256" })
13
+ .setIssuedAt()
14
+ .setSubject((payload.sub as string) ?? "user-1")
15
+ .setIssuer(options.iss ?? "test-issuer")
16
+ .setAudience(options.aud ?? "test-audience")
17
+ .setExpirationTime(options.exp ?? "5m")
18
+ .sign(privateKey);
19
+ }
20
+
21
+ describe("TokenService", () => {
22
+ afterEach(() => {
23
+ vi.unstubAllEnvs();
24
+ });
25
+ describe("RS256 with public key (PEM)", () => {
26
+ it("verifies a valid token and returns the principal", async () => {
27
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
28
+ const pem = await exportSPKI(publicKey);
29
+ const service = new TokenService({
30
+ publicKey: pem,
31
+ algorithms: ["RS256"],
32
+ issuer: "test-issuer",
33
+ audience: "test-audience",
34
+ });
35
+
36
+ const token = await mintToken(privateKey, { roles: ["admin"] });
37
+ const principal = await service.verify(token);
38
+
39
+ expect(principal.sub).toBe("user-1");
40
+ expect(principal.roles).toContain("admin");
41
+ });
42
+
43
+ it("throws UnauthorizedError for an expired token", async () => {
44
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
45
+ const pem = await exportSPKI(publicKey);
46
+ const service = new TokenService({
47
+ publicKey: pem,
48
+ issuer: "test-issuer",
49
+ audience: "test-audience",
50
+ });
51
+
52
+ const token = await mintToken(privateKey, {}, { exp: "1s" });
53
+ // Wait for token to expire (jose v6 has 1-second resolution)
54
+ await new Promise((r) => setTimeout(r, 1100));
55
+
56
+ await expect(service.verify(token)).rejects.toThrow(UnauthorizedError);
57
+ });
58
+
59
+ it("throws UnauthorizedError for wrong issuer", async () => {
60
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
61
+ const pem = await exportSPKI(publicKey);
62
+ const service = new TokenService({
63
+ publicKey: pem,
64
+ issuer: "expected-issuer",
65
+ audience: "test-audience",
66
+ });
67
+
68
+ const token = await mintToken(privateKey, {}, { iss: "wrong-issuer" });
69
+ await expect(service.verify(token)).rejects.toThrow(UnauthorizedError);
70
+ });
71
+
72
+ it("throws UnauthorizedError for wrong audience", async () => {
73
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
74
+ const pem = await exportSPKI(publicKey);
75
+ const service = new TokenService({
76
+ publicKey: pem,
77
+ issuer: "test-issuer",
78
+ audience: "expected-audience",
79
+ });
80
+
81
+ const token = await mintToken(privateKey, {}, { aud: "wrong-audience" });
82
+ await expect(service.verify(token)).rejects.toThrow(UnauthorizedError);
83
+ });
84
+
85
+ it("does not accept a foreign-audience token when configured audience is empty", async () => {
86
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
87
+ const pem = await exportSPKI(publicKey);
88
+ // Empty audience must fail closed — jose skips `aud` checks when audience is ""/undefined.
89
+ expect(
90
+ () =>
91
+ new TokenService({
92
+ publicKey: pem,
93
+ issuer: "test-issuer",
94
+ audience: "",
95
+ }),
96
+ ).toThrow(/audience must be a non-empty string/);
97
+
98
+ const service = new TokenService({
99
+ publicKey: pem,
100
+ issuer: "test-issuer",
101
+ audience: "https://api.webapi",
102
+ });
103
+ const token = await mintToken(
104
+ privateKey,
105
+ { scope: "create:Hello" },
106
+ { aud: "https://api.other" },
107
+ );
108
+ await expect(service.verify(token)).rejects.toThrow(UnauthorizedError);
109
+ });
110
+
111
+ it("throws UnauthorizedError when signed with a different key", async () => {
112
+ const { publicKey } = await generateKeyPair("RS256");
113
+ const { privateKey: otherPrivate } = await generateKeyPair("RS256");
114
+ const pem = await exportSPKI(publicKey);
115
+ const service = new TokenService({
116
+ publicKey: pem,
117
+ issuer: "test-issuer",
118
+ audience: "test-audience",
119
+ });
120
+
121
+ const token = await mintToken(otherPrivate, {}, {});
122
+ await expect(service.verify(token)).rejects.toThrow(UnauthorizedError);
123
+ });
124
+
125
+ it("throws UnauthorizedError for a malformed token string", async () => {
126
+ const { publicKey } = await generateKeyPair("RS256");
127
+ const pem = await exportSPKI(publicKey);
128
+ const service = new TokenService({
129
+ publicKey: pem,
130
+ issuer: "test-issuer",
131
+ audience: "test-audience",
132
+ });
133
+
134
+ await expect(service.verify("not.a.jwt")).rejects.toThrow(UnauthorizedError);
135
+ });
136
+ });
137
+
138
+ describe("RS256 with public key (JWK JSON)", () => {
139
+ it("verifies a valid token using a JWK public key", async () => {
140
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
141
+ const jwkObj = await exportJWK(publicKey);
142
+ // JWK must include alg for importJWK without explicit algorithm arg
143
+ jwkObj.alg = "RS256";
144
+ const jwk = JSON.stringify(jwkObj);
145
+ const service = new TokenService({
146
+ publicKey: jwk,
147
+ algorithms: ["RS256"],
148
+ issuer: "test-issuer",
149
+ audience: "test-audience",
150
+ });
151
+
152
+ const token = await mintToken(privateKey);
153
+ const principal = await service.verify(token);
154
+ expect(principal.sub).toBe("user-1");
155
+ });
156
+ });
157
+
158
+ describe("HS256 with shared secret", () => {
159
+ const secret = "super-secret-key-that-is-long-enough-for-hs256";
160
+
161
+ it("verifies a HS256 token signed with the same secret", async () => {
162
+ const service = new TokenService({
163
+ secret,
164
+ issuer: "test-issuer",
165
+ audience: "test-audience",
166
+ });
167
+ const token = await service.sign({ sub: "alice", roles: ["reader"] });
168
+ const principal = await service.verify(token);
169
+
170
+ expect(principal.sub).toBe("alice");
171
+ expect(principal.roles).toContain("reader");
172
+ });
173
+
174
+ it("throws UnauthorizedError when secret does not match", async () => {
175
+ const signer = new TokenService({ secret, issuer: "x", audience: "y" });
176
+ const verifier = new TokenService({
177
+ secret: "different-secret-at-least-32-chars-long",
178
+ issuer: "x",
179
+ audience: "y",
180
+ });
181
+
182
+ const token = await signer.sign({ sub: "bob" });
183
+ await expect(verifier.verify(token)).rejects.toThrow(UnauthorizedError);
184
+ });
185
+
186
+ it("sign() throws when not using SecretConfig", async () => {
187
+ const { publicKey } = await generateKeyPair("RS256");
188
+ const pem = await exportSPKI(publicKey);
189
+ const service = new TokenService({
190
+ publicKey: pem,
191
+ issuer: "test-issuer",
192
+ audience: "test-audience",
193
+ });
194
+
195
+ await expect(service.sign({ sub: "x" })).rejects.toThrow("SecretConfig");
196
+ });
197
+ });
198
+
199
+ describe("devStatic mode", () => {
200
+ it("returns the static principal for any token in development", async () => {
201
+ vi.stubEnv("NODE_ENV", "development");
202
+ const service = new TokenService({
203
+ devStatic: { sub: "dev-user", roles: ["admin"] },
204
+ });
205
+
206
+ const principal = await service.verify("any-token-at-all");
207
+ expect(principal.sub).toBe("dev-user");
208
+ expect(principal.roles).toContain("admin");
209
+ });
210
+
211
+ it("rejects constructing TokenService with devStatic when NODE_ENV is production", () => {
212
+ vi.stubEnv("NODE_ENV", "production");
213
+ expect(
214
+ () =>
215
+ new TokenService({
216
+ devStatic: { sub: "dev-user", roles: ["admin"] },
217
+ }),
218
+ ).toThrow(/devStatic.*NODE_ENV/);
219
+ });
220
+ });
221
+
222
+ describe("scope claim parsing", () => {
223
+ it("splits a space-separated scope string into scopes array", async () => {
224
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
225
+ const pem = await exportSPKI(publicKey);
226
+ const service = new TokenService({
227
+ publicKey: pem,
228
+ issuer: "test-issuer",
229
+ audience: "test-audience",
230
+ });
231
+
232
+ const token = await mintToken(privateKey, { scope: "read write" });
233
+ const principal = await service.verify(token);
234
+ expect(principal.scopes).toEqual(["read", "write"]);
235
+ });
236
+
237
+ it("uses a scopes array claim directly", async () => {
238
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
239
+ const pem = await exportSPKI(publicKey);
240
+ const service = new TokenService({
241
+ publicKey: pem,
242
+ issuer: "test-issuer",
243
+ audience: "test-audience",
244
+ });
245
+
246
+ const token = await mintToken(privateKey, { scopes: ["read", "admin"] });
247
+ const principal = await service.verify(token);
248
+ expect(principal.scopes).toEqual(["read", "admin"]);
249
+ });
250
+ });
251
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { App } from "../src/app/app.js";
3
+ import { Builder } from "../src/builder/builder.js";
4
+
5
+ describe("Builder", () => {
6
+ it("builds a custom App subclass when provided", async () => {
7
+ class CustomApp extends App {}
8
+
9
+ const app = await Builder.create().build(CustomApp);
10
+ expect(app).toBeInstanceOf(CustomApp);
11
+ });
12
+ });
@@ -0,0 +1,111 @@
1
+ import axios from "axios";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { AuthConfig } from "../src/auth/auth.config.js";
5
+ import "../src/auth/auth.extensions.js";
6
+ import { Builder } from "../src/builder/builder.js";
7
+ import type { IReadinessCheck } from "../src/health/readiness.types.js";
8
+ import "../src/http/http.extensions.js";
9
+ import "../src/health/health.extensions.js";
10
+ import type { IConfigurationService } from "@decohere/core/configuration";
11
+ import { ConfigurationServiceToken } from "@decohere/core/configuration";
12
+ import type { Constructable } from "@decohere/core/reflection";
13
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
14
+ import { Authorize, Controller, Get } from "../src/routing/index.js";
15
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
16
+
17
+ class DevStaticConfigService implements IConfigurationService {
18
+ public get dataSources() {
19
+ return [];
20
+ }
21
+ public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
22
+ if (type === AuthConfig) {
23
+ const config = new AuthConfig();
24
+ config.devStatic = { sub: "dev-user", scopes: ["read:Article"] };
25
+ return config as unknown as T;
26
+ }
27
+ return {} as T;
28
+ }
29
+ }
30
+
31
+ @Controller()
32
+ class GuardController {
33
+ @Authorize("read", "Article")
34
+ @Get("/protected")
35
+ public secure() {
36
+ return { ok: true };
37
+ }
38
+ }
39
+
40
+ describe("health / ready probes", () => {
41
+ let app: App;
42
+
43
+ afterEach(async () => {
44
+ await app?.dispose();
45
+ vi.unstubAllEnvs();
46
+ });
47
+
48
+ it("GET /health returns 200 { status: ok } without auth", async () => {
49
+ app = await Builder.create().withHttpProvider(FastifyProvider).withHealth().build();
50
+ axios.defaults.baseURL = await listenEphemeral(app);
51
+
52
+ const res = await axios.get("/health");
53
+ expect(res.status).toBe(200);
54
+ expect(res.data).toEqual({ status: "ok" });
55
+ });
56
+
57
+ it("GET /ready returns 200 when no readiness checks are registered", async () => {
58
+ app = await Builder.create().withHttpProvider(FastifyProvider).withHealth().build();
59
+ axios.defaults.baseURL = await listenEphemeral(app);
60
+
61
+ const res = await axios.get("/ready");
62
+ expect(res.status).toBe(200);
63
+ expect(res.data).toEqual({ status: "ok" });
64
+ });
65
+
66
+ it("GET /ready returns 503 when a readiness check fails", async () => {
67
+ const failing: IReadinessCheck = {
68
+ name: "db",
69
+ async check() {
70
+ throw new Error("connection refused");
71
+ },
72
+ };
73
+
74
+ app = await Builder.create()
75
+ .withHttpProvider(FastifyProvider)
76
+ .withHealth({ checks: [failing] })
77
+ .build();
78
+ axios.defaults.baseURL = await listenEphemeral(app);
79
+
80
+ const res = await axios.get("/ready", { validateStatus: () => true });
81
+ expect(res.status).toBe(503);
82
+ expect(res.data).toEqual({ message: "Not ready" });
83
+ });
84
+
85
+ it("GET /health and /ready stay anonymous when withAuth is enabled", async () => {
86
+ vi.stubEnv("NODE_ENV", "development");
87
+
88
+ app = await Builder.create()
89
+ .withHttpProvider(FastifyProvider)
90
+ .withServices((c) => {
91
+ c.registerSingleton(ConfigurationServiceToken, new DevStaticConfigService());
92
+ })
93
+ .withAuth()
94
+ .withHealth()
95
+ .withControllers(GuardController)
96
+ .build();
97
+ axios.defaults.baseURL = await listenEphemeral(app);
98
+
99
+ const health = await axios.get("/health");
100
+ expect(health.status).toBe(200);
101
+ expect(health.data).toEqual({ status: "ok" });
102
+
103
+ const ready = await axios.get("/ready");
104
+ expect(ready.status).toBe(200);
105
+ expect(ready.data).toEqual({ status: "ok" });
106
+
107
+ await expect(axios.get("/guard/protected")).rejects.toMatchObject({
108
+ response: { status: 401 },
109
+ });
110
+ });
111
+ });
@@ -0,0 +1,13 @@
1
+ import type { App } from "../../src/app/app.js";
2
+
3
+ /**
4
+ * Bind an OS-assigned port (`listen(0)`) on loopback and return the advertised origin.
5
+ */
6
+ export async function listenEphemeral(app: App): Promise<string> {
7
+ await app.start({ port: 0, host: "127.0.0.1" });
8
+ const address = app.addresses[0];
9
+ if (address == null) {
10
+ throw new Error("app.start({ port: 0 }) advertised no IPv4 address");
11
+ }
12
+ return address;
13
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Minimal in-process OIDC server for integration testing.
3
+ *
4
+ * Uses the `jose` library (already an `@decohere/http` dependency) to generate a real
5
+ * RS256 key pair and serve standard OIDC discovery + JWKS endpoints via a
6
+ * Node.js HTTP server bound to a random port.
7
+ *
8
+ * No external dependencies, no Docker, works in CI.
9
+ *
10
+ * Usage:
11
+ * ```ts
12
+ * const oidc = await LocalOidcServer.start();
13
+ * // configure `@decohere/http` with: discoveryUrl: oidc.issuer
14
+ * const token = await oidc.signToken({ sub: "user-1", scope: "read:Article" });
15
+ * await oidc.stop();
16
+ * ```
17
+ */
18
+
19
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
20
+ import { createServer } from "node:http";
21
+ import type { AddressInfo } from "node:net";
22
+ import type { CryptoKey } from "jose";
23
+ import { exportJWK, generateKeyPair, SignJWT } from "jose";
24
+
25
+ export class LocalOidcServer {
26
+ private constructor(
27
+ private readonly server: Server,
28
+ private readonly privateKey: CryptoKey,
29
+ private readonly port: number,
30
+ ) {}
31
+
32
+ public static async start(): Promise<LocalOidcServer> {
33
+ const { privateKey, publicKey } = await generateKeyPair("RS256");
34
+ const publicJwk = {
35
+ ...(await exportJWK(publicKey)),
36
+ use: "sig",
37
+ alg: "RS256",
38
+ kid: "test-key-1",
39
+ };
40
+
41
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
42
+ const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
43
+
44
+ if (req.url === "/.well-known/openid-configuration") {
45
+ res.writeHead(200, { "Content-Type": "application/json" });
46
+ res.end(
47
+ JSON.stringify({
48
+ issuer,
49
+ jwks_uri: `${issuer}/jwks`,
50
+ authorization_endpoint: `${issuer}/auth`,
51
+ token_endpoint: `${issuer}/token`,
52
+ response_types_supported: ["code"],
53
+ subject_types_supported: ["public"],
54
+ id_token_signing_alg_values_supported: ["RS256"],
55
+ }),
56
+ );
57
+ return;
58
+ }
59
+
60
+ if (req.url === "/jwks") {
61
+ res.writeHead(200, { "Content-Type": "application/json" });
62
+ res.end(JSON.stringify({ keys: [publicJwk] }));
63
+ return;
64
+ }
65
+
66
+ res.writeHead(404);
67
+ res.end();
68
+ });
69
+
70
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
71
+ const port = (server.address() as AddressInfo).port;
72
+
73
+ return new LocalOidcServer(server, privateKey, port);
74
+ }
75
+
76
+ public get issuer(): string {
77
+ return `http://127.0.0.1:${this.port}`;
78
+ }
79
+
80
+ public async signToken(
81
+ claims: Record<string, unknown> & { aud?: string },
82
+ expiresIn = "1h",
83
+ ): Promise<string> {
84
+ const { aud, ...rest } = claims;
85
+ let builder = new SignJWT(rest)
86
+ .setProtectedHeader({ alg: "RS256", kid: "test-key-1" })
87
+ .setIssuedAt()
88
+ .setIssuer(this.issuer)
89
+ .setExpirationTime(expiresIn);
90
+ // Default audience so tests match AuthConfig.audience (required by TokenService).
91
+ builder = builder.setAudience(typeof aud === "string" ? aud : "https://api.test");
92
+ return builder.sign(this.privateKey);
93
+ }
94
+
95
+ public stop(): Promise<void> {
96
+ return new Promise((resolve, reject) =>
97
+ this.server.close((err) => (err ? reject(err) : resolve())),
98
+ );
99
+ }
100
+ }