nestjs-ddd-cli 3.2.1 → 3.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 (214) hide show
  1. package/README.md +118 -9
  2. package/ddd.schema.json +11 -1
  3. package/dist/commands/doctor-enhanced.d.ts +1 -0
  4. package/dist/commands/doctor-enhanced.js +94 -79
  5. package/dist/commands/doctor-enhanced.js.map +1 -1
  6. package/dist/commands/doctor.d.ts +9 -0
  7. package/dist/commands/doctor.js +68 -21
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/generate-all.js +238 -176
  10. package/dist/commands/generate-all.js.map +1 -1
  11. package/dist/commands/generate-controller.d.ts +1 -0
  12. package/dist/commands/generate-controller.js +71 -0
  13. package/dist/commands/generate-controller.js.map +1 -0
  14. package/dist/commands/generate-domain-service.d.ts +1 -1
  15. package/dist/commands/generate-domain-service.js +6 -4
  16. package/dist/commands/generate-domain-service.js.map +1 -1
  17. package/dist/commands/generate-dto.d.ts +4 -0
  18. package/dist/commands/generate-dto.js +162 -0
  19. package/dist/commands/generate-dto.js.map +1 -0
  20. package/dist/commands/generate-entity.js +44 -35
  21. package/dist/commands/generate-entity.js.map +1 -1
  22. package/dist/commands/generate-event.js +10 -4
  23. package/dist/commands/generate-event.js.map +1 -1
  24. package/dist/commands/generate-migration-deployment.d.ts +15 -0
  25. package/dist/commands/generate-migration-deployment.js +326 -0
  26. package/dist/commands/generate-migration-deployment.js.map +1 -0
  27. package/dist/commands/generate-migration-deployment.spec.d.ts +1 -0
  28. package/dist/commands/generate-migration-deployment.spec.js +337 -0
  29. package/dist/commands/generate-migration-deployment.spec.js.map +1 -0
  30. package/dist/commands/generate-module.js +41 -31
  31. package/dist/commands/generate-module.js.map +1 -1
  32. package/dist/commands/generate-query.js +23 -4
  33. package/dist/commands/generate-query.js.map +1 -1
  34. package/dist/commands/generate-repository.d.ts +1 -1
  35. package/dist/commands/generate-repository.js +17 -34
  36. package/dist/commands/generate-repository.js.map +1 -1
  37. package/dist/commands/generate-service.js +19 -4
  38. package/dist/commands/generate-service.js.map +1 -1
  39. package/dist/commands/generate-usecase.js +12 -31
  40. package/dist/commands/generate-usecase.js.map +1 -1
  41. package/dist/commands/generate-value-object.js +3 -14
  42. package/dist/commands/generate-value-object.js.map +1 -1
  43. package/dist/commands/init-project.d.ts +3 -0
  44. package/dist/commands/init-project.js +19 -9
  45. package/dist/commands/init-project.js.map +1 -1
  46. package/dist/commands/interactive-scaffold.js +10 -0
  47. package/dist/commands/interactive-scaffold.js.map +1 -1
  48. package/dist/commands/migration.d.ts +3 -0
  49. package/dist/commands/migration.js +83 -48
  50. package/dist/commands/migration.js.map +1 -1
  51. package/dist/commands/recipe.d.ts +2 -0
  52. package/dist/commands/recipe.js +282 -91
  53. package/dist/commands/recipe.js.map +1 -1
  54. package/dist/commands/recipes/banklink-connector-contract.recipe.d.ts +1 -0
  55. package/dist/commands/recipes/banklink-connector-contract.recipe.js +161 -0
  56. package/dist/commands/recipes/banklink-connector-contract.recipe.js.map +1 -0
  57. package/dist/commands/recipes/bounded-json-http.recipe.d.ts +2 -0
  58. package/dist/commands/recipes/bounded-json-http.recipe.js +53 -0
  59. package/dist/commands/recipes/bounded-json-http.recipe.js.map +1 -0
  60. package/dist/commands/recipes/business-reference-identifiers.recipe.d.ts +1 -0
  61. package/dist/commands/recipes/business-reference-identifiers.recipe.js +223 -0
  62. package/dist/commands/recipes/business-reference-identifiers.recipe.js.map +1 -0
  63. package/dist/commands/recipes/event-backbone.recipe.d.ts +1 -0
  64. package/dist/commands/recipes/event-backbone.recipe.js +1118 -0
  65. package/dist/commands/recipes/event-backbone.recipe.js.map +1 -0
  66. package/dist/commands/recipes/external-projection-worker.recipe.d.ts +6 -0
  67. package/dist/commands/recipes/external-projection-worker.recipe.js +211 -0
  68. package/dist/commands/recipes/external-projection-worker.recipe.js.map +1 -0
  69. package/dist/commands/recipes/index.d.ts +12 -0
  70. package/dist/commands/recipes/index.js +12 -0
  71. package/dist/commands/recipes/index.js.map +1 -1
  72. package/dist/commands/recipes/jest-cjs-compat.recipe.d.ts +3 -0
  73. package/dist/commands/recipes/jest-cjs-compat.recipe.js +130 -0
  74. package/dist/commands/recipes/jest-cjs-compat.recipe.js.map +1 -0
  75. package/dist/commands/recipes/joi-env.recipe.d.ts +1 -0
  76. package/dist/commands/recipes/joi-env.recipe.js +301 -0
  77. package/dist/commands/recipes/joi-env.recipe.js.map +1 -0
  78. package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
  79. package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
  80. package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
  81. package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
  82. package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
  83. package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
  84. package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
  85. package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
  86. package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
  87. package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
  88. package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
  89. package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
  90. package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
  91. package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
  92. package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
  93. package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
  94. package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
  95. package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
  96. package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
  97. package/dist/commands/recipes/platform-context.recipe.js +229 -0
  98. package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
  99. package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
  100. package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
  101. package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
  102. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
  103. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
  104. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
  105. package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
  106. package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
  107. package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
  108. package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
  109. package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
  110. package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
  111. package/dist/index.js +82 -11
  112. package/dist/index.js.map +1 -1
  113. package/dist/templates/ai-context/CLAUDE.md.hbs +54 -3
  114. package/dist/templates/ai-context/conventions.md.hbs +20 -8
  115. package/dist/templates/command/create-command.hbs +8 -3
  116. package/dist/templates/command/delete-command.hbs +1 -1
  117. package/dist/templates/command/update-command.hbs +1 -1
  118. package/dist/templates/controller/application-controller.hbs +7 -0
  119. package/dist/templates/controller/controller.hbs +6 -0
  120. package/dist/templates/dto/create-dto.hbs +17 -5
  121. package/dist/templates/dto/filter-dto.hbs +1 -1
  122. package/dist/templates/dto/response-dto.hbs +1 -1
  123. package/dist/templates/entity/entity.hbs +4 -0
  124. package/dist/templates/mapper/mapper.hbs +6 -2
  125. package/dist/templates/orm-entity/orm-entity.hbs +9 -1
  126. package/dist/templates/prisma/prisma-mapper.hbs +6 -0
  127. package/dist/templates/prisma/prisma-repository.hbs +57 -5
  128. package/dist/templates/prisma/prisma-schema.hbs +2 -0
  129. package/dist/templates/query/get-all.query.hbs +10 -2
  130. package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
  131. package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
  132. package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
  133. package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
  134. package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
  135. package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
  136. package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
  137. package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
  138. package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
  139. package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
  140. package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
  141. package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
  142. package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
  143. package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
  144. package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
  145. package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
  146. package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
  147. package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
  148. package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
  149. package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
  150. package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
  151. package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
  152. package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
  153. package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
  154. package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
  155. package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
  156. package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
  157. package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
  158. package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
  159. package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
  160. package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
  161. package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
  162. package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
  163. package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
  164. package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
  165. package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
  166. package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
  167. package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
  168. package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
  169. package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
  170. package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
  171. package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
  172. package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
  173. package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
  174. package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
  175. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
  176. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
  177. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
  178. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
  179. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
  180. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
  181. package/dist/templates/repository/repository.hbs +43 -5
  182. package/dist/templates/resolver/graphql-input.hbs +23 -6
  183. package/dist/templates/resolver/resolver.hbs +4 -0
  184. package/dist/templates/service/domain-service.hbs +2 -2
  185. package/dist/templates/test/controller.spec.hbs +5 -1
  186. package/dist/templates/test/prisma-repository.spec.hbs +192 -0
  187. package/dist/templates/test/repository.spec.hbs +41 -2
  188. package/dist/templates/test/usecase.spec.hbs +24 -5
  189. package/dist/templates/usecase/application-usecase.hbs +17 -0
  190. package/dist/templates/usecase/create-usecase.hbs +34 -3
  191. package/dist/templates/usecase/update-usecase.hbs +15 -1
  192. package/dist/utils/config.utils.d.ts +2 -0
  193. package/dist/utils/config.utils.js +14 -2
  194. package/dist/utils/config.utils.js.map +1 -1
  195. package/dist/utils/dependency.utils.d.ts +18 -2
  196. package/dist/utils/dependency.utils.js +144 -15
  197. package/dist/utils/dependency.utils.js.map +1 -1
  198. package/dist/utils/field.utils.d.ts +6 -0
  199. package/dist/utils/field.utils.js +78 -24
  200. package/dist/utils/field.utils.js.map +1 -1
  201. package/dist/utils/field.utils.spec.d.ts +1 -0
  202. package/dist/utils/field.utils.spec.js +38 -0
  203. package/dist/utils/field.utils.spec.js.map +1 -0
  204. package/dist/utils/file.utils.d.ts +39 -3
  205. package/dist/utils/file.utils.js +150 -13
  206. package/dist/utils/file.utils.js.map +1 -1
  207. package/dist/utils/naming.utils.js +5 -4
  208. package/dist/utils/naming.utils.js.map +1 -1
  209. package/dist/utils/static-recipe.utils.d.ts +4 -0
  210. package/dist/utils/static-recipe.utils.js +148 -0
  211. package/dist/utils/static-recipe.utils.js.map +1 -0
  212. package/dist/utils/template-engine.utils.js +83 -68
  213. package/dist/utils/template-engine.utils.js.map +1 -1
  214. package/package.json +20 -11
@@ -0,0 +1,350 @@
1
+ import {
2
+ InvalidOAuth2TokenError,
3
+ OAuth2IntrospectionUnavailableError,
4
+ OAuth2TokenIntrospectionClient,
5
+ OAuth2TokenIntrospectionOptions,
6
+ } from './oauth2-token-introspection.client';
7
+ import { OAuth2TokenIntrospectionModule } from './oauth2-token-introspection.module';
8
+ import { createServer } from 'node:http';
9
+ describe('OAuth2 token introspection', () => {
10
+ const original = 'ory_at_test_opaque';
11
+ const claims = {
12
+ active: true,
13
+ iss: 'https://accounts.example.test/',
14
+ sub: 'external-subject',
15
+ client_id: 'web-client',
16
+ aud: ['identity-api'],
17
+ scope: 'openid identity:read',
18
+ exp: 2000000000,
19
+ iat: 1900000000,
20
+ nbf: 1900000000,
21
+ token_type: 'Bearer',
22
+ token_use: 'access_token',
23
+ ext: { binding: { session: 'server-set-session' } },
24
+ };
25
+ const response = (body: unknown = claims) =>
26
+ new Response(JSON.stringify(body), { status: 200 });
27
+ const create = (options: Partial<OAuth2TokenIntrospectionOptions> = {}) =>
28
+ new OAuth2TokenIntrospectionClient({
29
+ introspectionUrl: 'https://provider.example.test/admin/oauth2/introspect',
30
+ transport: () => Promise.resolve(response()),
31
+ ...options,
32
+ });
33
+ it('uses the default transport against a real HTTP endpoint with form-only credentials', async () => {
34
+ let received = '';
35
+ let requestPath = '';
36
+ const server = createServer((request, outgoing) => {
37
+ requestPath = request.url ?? '';
38
+ expect(request.method).toBe('POST');
39
+ expect(request.headers['content-type']).toBe(
40
+ 'application/x-www-form-urlencoded',
41
+ );
42
+ request.on('data', (chunk: Buffer) => {
43
+ received += chunk.toString();
44
+ });
45
+ request.on('end', () => {
46
+ outgoing.setHeader('Content-Type', 'application/json');
47
+ outgoing.end(JSON.stringify(claims));
48
+ });
49
+ });
50
+ await new Promise<void>((resolve) =>
51
+ server.listen(0, '127.0.0.1', resolve),
52
+ );
53
+ const address = server.address();
54
+ if (!address || typeof address === 'string')
55
+ throw new Error('Missing test endpoint');
56
+ try {
57
+ const client = create({
58
+ introspectionUrl: `http://127.0.0.1:${address.port}/introspect`,
59
+ trustedInternalHttp: true,
60
+ transport: undefined,
61
+ });
62
+ await expect(client.introspect(original)).resolves.toMatchObject({
63
+ subject: claims.sub,
64
+ });
65
+ expect(requestPath).toBe('/introspect');
66
+ expect(new URLSearchParams(received).get('token')).toBe(original);
67
+ } finally {
68
+ server.closeAllConnections();
69
+ await new Promise<void>((resolve) => server.close(() => resolve()));
70
+ }
71
+ });
72
+ it('preserves provider evidence without inventing principal or grant claims', async () => {
73
+ const result = await create().introspect(original);
74
+ expect(result).toMatchObject({
75
+ issuer: claims.iss,
76
+ subject: claims.sub,
77
+ clientId: claims.client_id,
78
+ audiences: claims.aud,
79
+ scopes: ['openid', 'identity:read'],
80
+ expiresAt: claims.exp,
81
+ tokenUse: 'access_token',
82
+ extensions: claims.ext,
83
+ });
84
+ expect(Object.isFrozen(result)).toBe(true);
85
+ expect(Object.isFrozen(result.extensions['binding'])).toBe(true);
86
+ expect(result.claims['grant_type']).toBeUndefined();
87
+ });
88
+ it('accepts minimal RFC7662 evidence while leaving mandatory claims to consumer policy', async () => {
89
+ expect(
90
+ await create({
91
+ transport: () => Promise.resolve(response({ active: true })),
92
+ }).introspect(original),
93
+ ).toMatchObject({ audiences: [], scopes: [] });
94
+ await expect(
95
+ create().validate(original, {
96
+ validate: () => {
97
+ throw new InvalidOAuth2TokenError('policy_rejected');
98
+ },
99
+ }),
100
+ ).rejects.toMatchObject({ reason: 'policy_rejected' });
101
+ });
102
+ it('applies consumer policy before returning its principal', async () => {
103
+ const principal = await create().validate(original, {
104
+ validate: (evidence) => {
105
+ if (
106
+ evidence.issuer !== claims.iss ||
107
+ evidence.clientId !== 'web-client' ||
108
+ evidence.tokenUse !== 'access_token'
109
+ )
110
+ throw new InvalidOAuth2TokenError('policy_rejected');
111
+ return { kind: 'consumer-defined', subject: evidence.subject };
112
+ },
113
+ });
114
+ expect(principal).toEqual({
115
+ kind: 'consumer-defined',
116
+ subject: claims.sub,
117
+ });
118
+ });
119
+ it('does not use token_type_hint as proof of access-token kind', async () => {
120
+ const client = create({
121
+ transport: () =>
122
+ Promise.resolve(response({ ...claims, token_use: 'refresh_token' })),
123
+ });
124
+ await expect(
125
+ client.validate(original, {
126
+ validate: (evidence) => {
127
+ if (evidence.tokenUse !== 'access_token')
128
+ throw new InvalidOAuth2TokenError('policy_rejected');
129
+ return evidence;
130
+ },
131
+ }),
132
+ ).rejects.toMatchObject({ reason: 'policy_rejected' });
133
+ });
134
+ it('makes a new call every time so an active token can become revoked', async () => {
135
+ let calls = 0;
136
+ const client = create({
137
+ transport: () =>
138
+ Promise.resolve(response(++calls === 1 ? claims : { active: false })),
139
+ });
140
+ await client.introspect(original);
141
+ await expect(client.introspect(original)).rejects.toMatchObject({
142
+ reason: 'inactive_token',
143
+ });
144
+ expect(calls).toBe(2);
145
+ });
146
+ it('snapshots endpoint and secret headers, sends form only and disables redirects/cache', async () => {
147
+ const headers = { authorization: 'Basic PRIVATE_HEADER' };
148
+ const transport = jest.fn((_url: string, _init: RequestInit) => {
149
+ void _url;
150
+ void _init;
151
+ return Promise.resolve(response());
152
+ });
153
+ const options = {
154
+ introspectionUrl: 'https://provider.example.test/admin/oauth2/introspect',
155
+ requestHeaders: headers,
156
+ transport,
157
+ };
158
+ const client = create(options);
159
+ headers.authorization = 'changed';
160
+ options.introspectionUrl = 'https://untrusted.example.test/';
161
+ await client.introspect(original);
162
+ const requestCall = transport.mock.calls[0];
163
+ if (!requestCall) throw new Error('Missing request');
164
+ const [url, request] = requestCall;
165
+ expect(url).toBe('https://provider.example.test/admin/oauth2/introspect');
166
+ expect(request).toMatchObject({
167
+ method: 'POST',
168
+ redirect: 'error',
169
+ cache: 'no-store',
170
+ headers: { authorization: 'Basic PRIVATE_HEADER' },
171
+ });
172
+ expect(new URLSearchParams(request.body as string).get('token')).toBe(
173
+ original,
174
+ );
175
+ expect(
176
+ new URLSearchParams(request.body as string).get('token_type_hint'),
177
+ ).toBe('access_token');
178
+ });
179
+ it.each([
180
+ 'http://provider.example.test/',
181
+ 'https://user:SECRET@provider.example.test/',
182
+ 'https://provider.example.test/#SECRET',
183
+ 'https://provider.example.test/?SECRET',
184
+ 'file:///SECRET',
185
+ ])(
186
+ 'rejects unsafe endpoint %s with a sanitized configuration error',
187
+ (introspectionUrl) => {
188
+ expect(() => create({ introspectionUrl })).toThrow(
189
+ 'Invalid introspection configuration',
190
+ );
191
+ },
192
+ );
193
+ it('allows explicit trusted private HTTP without weakening HTTPS defaults', async () => {
194
+ await expect(
195
+ create({
196
+ introspectionUrl:
197
+ 'http://hydra-admin.ory-staging.svc.cluster.local:4445/admin/oauth2/introspect',
198
+ trustedInternalHttp: true,
199
+ }).introspect(original),
200
+ ).resolves.toMatchObject({ active: true });
201
+ });
202
+ it.each(['', 'Bearer token', 'token\n', 'é', 'x'.repeat(16385)])(
203
+ 'rejects malformed/bounded tokens without making a request',
204
+ async (token) => {
205
+ const transport = jest.fn(() => Promise.resolve(response()));
206
+ await expect(
207
+ create({ transport }).introspect(token),
208
+ ).rejects.toMatchObject({
209
+ reason: 'invalid_token',
210
+ });
211
+ expect(transport).not.toHaveBeenCalled();
212
+ },
213
+ );
214
+ it.each([
215
+ {},
216
+ [],
217
+ { active: 'true' },
218
+ { active: true, exp: '2000000000' },
219
+ { active: true, aud: [42] },
220
+ { active: true, ext: [] },
221
+ { active: true, sub: '' },
222
+ { active: true, scope: 'read\twrite' },
223
+ ])('fails closed on malformed provider evidence %j', async (body) => {
224
+ await expect(
225
+ create({ transport: () => Promise.resolve(response(body)) }).introspect(
226
+ original,
227
+ ),
228
+ ).rejects.toMatchObject({ reason: 'invalid_response' });
229
+ });
230
+ it('accepts string audiences and empty scopes', async () => {
231
+ expect(
232
+ await create({
233
+ transport: () =>
234
+ Promise.resolve(response({ active: true, aud: 'api', scope: '' })),
235
+ }).introspect(original),
236
+ ).toMatchObject({ audiences: ['api'], scopes: [] });
237
+ });
238
+ it('bounds claimed length and streamed bytes independently', async () => {
239
+ for (const transport of [
240
+ () =>
241
+ Promise.resolve(
242
+ new Response('{}', { headers: { 'content-length': '1000000' } }),
243
+ ),
244
+ () =>
245
+ Promise.resolve(
246
+ new Response(
247
+ JSON.stringify({ active: true, extra: 'x'.repeat(1000) }),
248
+ ),
249
+ ),
250
+ ]) {
251
+ await expect(
252
+ create({ maxResponseBytes: 128, transport }).introspect(original),
253
+ ).rejects.toMatchObject({ reason: 'invalid_response' });
254
+ }
255
+ });
256
+ it('bounds nested extension depth and rejects invalid JSON', async () => {
257
+ let nested: unknown = 'leaf';
258
+ for (let i = 0; i < 35; i++) nested = { child: nested };
259
+ await expect(
260
+ create({
261
+ transport: () =>
262
+ Promise.resolve(response({ active: true, ext: { nested } })),
263
+ }).introspect(original),
264
+ ).rejects.toMatchObject({ reason: 'invalid_response' });
265
+ await expect(
266
+ create({
267
+ transport: () => Promise.resolve(new Response('PRIVATE_PROVIDER_BODY')),
268
+ }).introspect(original),
269
+ ).rejects.toMatchObject({ reason: 'invalid_response' });
270
+ });
271
+ it('separates HTTP/provider failure from inactive credentials without leaking response bodies', async () => {
272
+ const error = await create({
273
+ transport: () =>
274
+ Promise.resolve(new Response('PRIVATE_PROVIDER_BODY', { status: 401 })),
275
+ })
276
+ .introspect(original)
277
+ .catch((error: unknown) => error);
278
+ expect(error).toBeInstanceOf(OAuth2IntrospectionUnavailableError);
279
+ expect(error).toMatchObject({
280
+ reason: 'http',
281
+ message: 'Token validation unavailable',
282
+ });
283
+ expect(String(error)).not.toContain('PRIVATE');
284
+ });
285
+ it('sanitizes transport and unexpected consumer policy errors', async () => {
286
+ for (const invoke of [
287
+ () =>
288
+ create({
289
+ transport: () => {
290
+ throw new Error('PRIVATE_HEADER');
291
+ },
292
+ }).introspect(original),
293
+ () =>
294
+ create().validate(original, {
295
+ validate: () => {
296
+ throw new Error('PRIVATE_IDENTITY');
297
+ },
298
+ }),
299
+ ]) {
300
+ const error = await invoke().catch((error: unknown) => error);
301
+ expect(error).toBeInstanceOf(OAuth2IntrospectionUnavailableError);
302
+ expect(String(error)).not.toContain('PRIVATE');
303
+ }
304
+ });
305
+ it('bounds a transport that ignores AbortSignal', async () => {
306
+ await expect(
307
+ create({
308
+ timeoutMs: 10,
309
+ transport: () =>
310
+ Promise.resolve(new Promise<Response>(() => undefined)),
311
+ }).introspect(original),
312
+ ).rejects.toMatchObject({ reason: 'timeout' });
313
+ });
314
+ it('bounds a hanging response stream and cancels it', async () => {
315
+ const cancel = jest.fn();
316
+ const stream = new ReadableStream<Uint8Array>({ cancel });
317
+ await expect(
318
+ create({
319
+ timeoutMs: 10,
320
+ transport: () => Promise.resolve(new Response(stream)),
321
+ }).introspect(original),
322
+ ).rejects.toMatchObject({ reason: 'timeout' });
323
+ expect(cancel).toHaveBeenCalled();
324
+ });
325
+ it('composes async registration through the owning module without provider redeclaration', () => {
326
+ const configToken = Symbol('config');
327
+ const useFactory = () =>
328
+ Promise.resolve({
329
+ introspectionUrl: 'https://provider.example.test/introspect',
330
+ });
331
+ const module = OAuth2TokenIntrospectionModule.registerAsync({
332
+ inject: [configToken],
333
+ useFactory,
334
+ });
335
+ expect(module.exports).toEqual([OAuth2TokenIntrospectionClient]);
336
+ expect(module.providers).toHaveLength(2);
337
+ expect(module.providers?.[0]).toMatchObject({
338
+ inject: [configToken],
339
+ useFactory,
340
+ });
341
+ expect(module.providers?.[1]).toMatchObject({
342
+ provide: OAuth2TokenIntrospectionClient,
343
+ });
344
+ expect(
345
+ OAuth2TokenIntrospectionModule.register({
346
+ introspectionUrl: 'https://provider.example.test/introspect',
347
+ }).exports,
348
+ ).toEqual(module.exports);
349
+ });
350
+ });
@@ -0,0 +1,241 @@
1
+ import {
2
+ BoundedJsonHttpClient,
3
+ BoundedJsonHttpUnavailableError,
4
+ } from './bounded-json-http.client';
5
+
6
+ export const OAUTH2_TOKEN_INTROSPECTION_OPTIONS = Symbol(
7
+ 'OAUTH2_TOKEN_INTROSPECTION_OPTIONS',
8
+ );
9
+
10
+ /** Supply only from validated deployment configuration, never request input. */
11
+ export interface OAuth2TokenIntrospectionOptions {
12
+ introspectionUrl: string;
13
+ requestHeaders?: Readonly<Record<string, string>>;
14
+ timeoutMs?: number;
15
+ maxResponseBytes?: number;
16
+ maxTokenBytes?: number;
17
+ /** Explicit opt-in for a verified private HTTP endpoint (for example K3s service DNS). */
18
+ trustedInternalHttp?: boolean;
19
+ /** Provider-neutral Fetch seam; must honor the supplied URL, signal and redirect policy. */
20
+ transport?: (url: string, init: RequestInit) => Promise<Response>;
21
+ }
22
+
23
+ /** Evidence, never an authorization grant. Optional RFC7662 claims remain optional. */
24
+ export interface OAuth2TokenEvidence {
25
+ readonly active: true;
26
+ readonly issuer?: string;
27
+ readonly subject?: string;
28
+ readonly clientId?: string;
29
+ readonly audiences: readonly string[];
30
+ readonly scopes: readonly string[];
31
+ readonly expiresAt?: number;
32
+ readonly issuedAt?: number;
33
+ readonly notBefore?: number;
34
+ readonly tokenType?: string;
35
+ readonly tokenUse?: string;
36
+ readonly extensions: Readonly<Record<string, unknown>>;
37
+ readonly claims: Readonly<Record<string, unknown>>;
38
+ }
39
+
40
+ /** The consumer owns provider profile, session, canonical identity and domain admission. */
41
+ export interface OAuth2TokenValidationPolicy<Principal> {
42
+ validate(evidence: OAuth2TokenEvidence): Principal | Promise<Principal>;
43
+ }
44
+
45
+ export class InvalidOAuth2TokenError extends Error {
46
+ constructor(
47
+ readonly reason: 'invalid_token' | 'inactive_token' | 'policy_rejected',
48
+ ) {
49
+ super('Access token rejected');
50
+ this.name = 'InvalidOAuth2TokenError';
51
+ }
52
+ }
53
+
54
+ export class OAuth2IntrospectionUnavailableError extends Error {
55
+ constructor(
56
+ readonly reason:
57
+ 'timeout' | 'network' | 'http' | 'invalid_response' | 'policy_failure',
58
+ ) {
59
+ super('Token validation unavailable');
60
+ this.name = 'OAuth2IntrospectionUnavailableError';
61
+ }
62
+ }
63
+
64
+ function bounded(
65
+ value: number | undefined,
66
+ fallback: number,
67
+ min: number,
68
+ max: number,
69
+ ): number {
70
+ const result = value ?? fallback;
71
+ if (!Number.isSafeInteger(result) || result < min || result > max) {
72
+ throw new Error('Invalid introspection configuration');
73
+ }
74
+ return result;
75
+ }
76
+
77
+ function record(value: unknown): value is Record<string, unknown> {
78
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
79
+ }
80
+
81
+ function hasControls(value: string): boolean {
82
+ return [...value].some((character) => {
83
+ const code = character.charCodeAt(0);
84
+ return code < 32 || code === 127;
85
+ });
86
+ }
87
+
88
+ function stringArray(value: unknown): value is string[] {
89
+ return (
90
+ Array.isArray(value) &&
91
+ value.every(
92
+ (item: unknown) =>
93
+ typeof item === 'string' && item.length > 0 && !hasControls(item),
94
+ )
95
+ );
96
+ }
97
+
98
+ function freeze(value: unknown, depth = 0): void {
99
+ if (depth > 32)
100
+ throw new OAuth2IntrospectionUnavailableError('invalid_response');
101
+ if (value !== null && typeof value === 'object') {
102
+ for (const child of Object.values(value)) freeze(child, depth + 1);
103
+ Object.freeze(value);
104
+ }
105
+ }
106
+
107
+ function optionalString(
108
+ claims: Record<string, unknown>,
109
+ key: string,
110
+ ): string | undefined {
111
+ const value = claims[key];
112
+ if (value === undefined) return undefined;
113
+ if (typeof value !== 'string' || value.length === 0 || hasControls(value)) {
114
+ throw new OAuth2IntrospectionUnavailableError('invalid_response');
115
+ }
116
+ return value;
117
+ }
118
+
119
+ function optionalTime(
120
+ claims: Record<string, unknown>,
121
+ key: string,
122
+ ): number | undefined {
123
+ const value = claims[key];
124
+ if (value === undefined) return undefined;
125
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
126
+ throw new OAuth2IntrospectionUnavailableError('invalid_response');
127
+ }
128
+ return value;
129
+ }
130
+
131
+ function evidence(claims: unknown): OAuth2TokenEvidence {
132
+ if (!record(claims) || typeof claims['active'] !== 'boolean') {
133
+ throw new OAuth2IntrospectionUnavailableError('invalid_response');
134
+ }
135
+ if (!claims['active']) throw new InvalidOAuth2TokenError('inactive_token');
136
+ const aud = claims['aud'];
137
+ const audiences =
138
+ typeof aud === 'string' ? [aud] : aud === undefined ? [] : aud;
139
+ if (!stringArray(audiences)) {
140
+ throw new OAuth2IntrospectionUnavailableError('invalid_response');
141
+ }
142
+ const scope = claims['scope'];
143
+ if (
144
+ scope !== undefined &&
145
+ (typeof scope !== 'string' || hasControls(scope))
146
+ ) {
147
+ throw new OAuth2IntrospectionUnavailableError('invalid_response');
148
+ }
149
+ const extensions = claims['ext'];
150
+ if (extensions !== undefined && !record(extensions)) {
151
+ throw new OAuth2IntrospectionUnavailableError('invalid_response');
152
+ }
153
+ const result: OAuth2TokenEvidence = {
154
+ active: true,
155
+ issuer: optionalString(claims, 'iss'),
156
+ subject: optionalString(claims, 'sub'),
157
+ clientId: optionalString(claims, 'client_id'),
158
+ audiences: Object.freeze([...audiences]),
159
+ scopes: Object.freeze(
160
+ scope === undefined ? [] : [...new Set(scope.split(' ').filter(Boolean))],
161
+ ),
162
+ expiresAt: optionalTime(claims, 'exp'),
163
+ issuedAt: optionalTime(claims, 'iat'),
164
+ notBefore: optionalTime(claims, 'nbf'),
165
+ tokenType: optionalString(claims, 'token_type'),
166
+ tokenUse: optionalString(claims, 'token_use'),
167
+ extensions: extensions ?? {},
168
+ claims,
169
+ };
170
+ freeze(result);
171
+ return result;
172
+ }
173
+
174
+ export class OAuth2TokenIntrospectionClient {
175
+ private readonly http: BoundedJsonHttpClient;
176
+ private readonly pathSegments: readonly string[];
177
+ private readonly maxTokenBytes: number;
178
+
179
+ constructor(options: OAuth2TokenIntrospectionOptions) {
180
+ try {
181
+ const url = new URL(options.introspectionUrl);
182
+ if (url.username || url.password || url.hash || url.search)
183
+ throw new Error();
184
+ this.pathSegments = Object.freeze(
185
+ url.pathname
186
+ .slice(1)
187
+ .split('/')
188
+ .map((segment) => decodeURIComponent(segment)),
189
+ );
190
+ this.maxTokenBytes = bounded(options.maxTokenBytes, 16384, 1, 65536);
191
+ this.http = new BoundedJsonHttpClient({ ...options, origin: url.origin });
192
+ } catch {
193
+ throw new Error('Invalid introspection configuration');
194
+ }
195
+ }
196
+
197
+ async introspect(token: string): Promise<OAuth2TokenEvidence> {
198
+ if (
199
+ typeof token !== 'string' ||
200
+ token.length === 0 ||
201
+ token.length > this.maxTokenBytes ||
202
+ /[^\x21-\x7e]/.test(token)
203
+ ) {
204
+ throw new InvalidOAuth2TokenError('invalid_token');
205
+ }
206
+ try {
207
+ return evidence(
208
+ await this.http.postForm(this.pathSegments, {
209
+ token,
210
+ token_type_hint: 'access_token',
211
+ }),
212
+ );
213
+ } catch (error) {
214
+ if (
215
+ error instanceof InvalidOAuth2TokenError ||
216
+ error instanceof OAuth2IntrospectionUnavailableError
217
+ )
218
+ throw error;
219
+ if (error instanceof BoundedJsonHttpUnavailableError)
220
+ throw new OAuth2IntrospectionUnavailableError(error.reason);
221
+ throw new OAuth2IntrospectionUnavailableError('network');
222
+ }
223
+ }
224
+
225
+ async validate<Principal>(
226
+ token: string,
227
+ policy: OAuth2TokenValidationPolicy<Principal>,
228
+ ): Promise<Principal> {
229
+ const verifiedEvidence = await this.introspect(token);
230
+ try {
231
+ return await policy.validate(verifiedEvidence);
232
+ } catch (error) {
233
+ if (
234
+ error instanceof InvalidOAuth2TokenError ||
235
+ error instanceof OAuth2IntrospectionUnavailableError
236
+ )
237
+ throw error;
238
+ throw new OAuth2IntrospectionUnavailableError('policy_failure');
239
+ }
240
+ }
241
+ }