nestjs-ddd-cli 3.2.2 → 3.3.1

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 (210) 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 +54 -28
  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 +7 -2
  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 +4 -2
  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 +229 -85
  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 +11 -0
  70. package/dist/commands/recipes/index.js +11 -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.js +1 -1
  76. package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
  77. package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
  78. package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
  79. package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
  80. package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
  81. package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
  82. package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
  83. package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
  84. package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
  85. package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
  86. package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
  87. package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
  88. package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
  89. package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
  90. package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
  91. package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
  92. package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
  93. package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
  94. package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
  95. package/dist/commands/recipes/platform-context.recipe.js +229 -0
  96. package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
  97. package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
  98. package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
  99. package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
  100. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
  101. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
  102. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
  103. package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
  104. package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
  105. package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
  106. package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
  107. package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
  108. package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
  109. package/dist/index.js +77 -9
  110. package/dist/index.js.map +1 -1
  111. package/dist/templates/ai-context/CLAUDE.md.hbs +39 -3
  112. package/dist/templates/ai-context/conventions.md.hbs +13 -8
  113. package/dist/templates/command/create-command.hbs +8 -3
  114. package/dist/templates/command/delete-command.hbs +1 -1
  115. package/dist/templates/command/update-command.hbs +1 -1
  116. package/dist/templates/controller/application-controller.hbs +7 -0
  117. package/dist/templates/controller/controller.hbs +6 -0
  118. package/dist/templates/dto/create-dto.hbs +17 -5
  119. package/dist/templates/dto/filter-dto.hbs +1 -1
  120. package/dist/templates/dto/response-dto.hbs +1 -1
  121. package/dist/templates/entity/entity.hbs +4 -0
  122. package/dist/templates/mapper/mapper.hbs +5 -1
  123. package/dist/templates/orm-entity/orm-entity.hbs +9 -1
  124. package/dist/templates/prisma/prisma-mapper.hbs +6 -0
  125. package/dist/templates/prisma/prisma-repository.hbs +57 -5
  126. package/dist/templates/prisma/prisma-schema.hbs +2 -0
  127. package/dist/templates/query/get-all.query.hbs +10 -2
  128. package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
  129. package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
  130. package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
  131. package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
  132. package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
  133. package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
  134. package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
  135. package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
  136. package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
  137. package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
  138. package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
  139. package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
  140. package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
  141. package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
  142. package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
  143. package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
  144. package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
  145. package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
  146. package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
  147. package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
  148. package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
  149. package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
  150. package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
  151. package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
  152. package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
  153. package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
  154. package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
  155. package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
  156. package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
  157. package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
  158. package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
  159. package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
  160. package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
  161. package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
  162. package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
  163. package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
  164. package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
  165. package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
  166. package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
  167. package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
  168. package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
  169. package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
  170. package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
  171. package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
  172. package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
  173. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
  174. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
  175. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
  176. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
  177. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
  178. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
  179. package/dist/templates/repository/repository.hbs +42 -4
  180. package/dist/templates/resolver/graphql-input.hbs +23 -6
  181. package/dist/templates/resolver/resolver.hbs +4 -0
  182. package/dist/templates/service/domain-service.hbs +2 -2
  183. package/dist/templates/test/controller.spec.hbs +5 -1
  184. package/dist/templates/test/prisma-repository.spec.hbs +192 -0
  185. package/dist/templates/test/repository.spec.hbs +40 -1
  186. package/dist/templates/test/usecase.spec.hbs +23 -4
  187. package/dist/templates/usecase/application-usecase.hbs +17 -0
  188. package/dist/templates/usecase/create-usecase.hbs +34 -3
  189. package/dist/templates/usecase/update-usecase.hbs +15 -1
  190. package/dist/utils/config.utils.d.ts +2 -0
  191. package/dist/utils/config.utils.js +14 -2
  192. package/dist/utils/config.utils.js.map +1 -1
  193. package/dist/utils/dependency.utils.d.ts +16 -2
  194. package/dist/utils/dependency.utils.js +100 -12
  195. package/dist/utils/dependency.utils.js.map +1 -1
  196. package/dist/utils/field.utils.d.ts +6 -0
  197. package/dist/utils/field.utils.js +50 -8
  198. package/dist/utils/field.utils.js.map +1 -1
  199. package/dist/utils/field.utils.spec.d.ts +1 -0
  200. package/dist/utils/field.utils.spec.js +38 -0
  201. package/dist/utils/field.utils.spec.js.map +1 -0
  202. package/dist/utils/file.utils.d.ts +23 -1
  203. package/dist/utils/file.utils.js +37 -1
  204. package/dist/utils/file.utils.js.map +1 -1
  205. package/dist/utils/naming.utils.js +5 -4
  206. package/dist/utils/naming.utils.js.map +1 -1
  207. package/dist/utils/static-recipe.utils.d.ts +4 -0
  208. package/dist/utils/static-recipe.utils.js +148 -0
  209. package/dist/utils/static-recipe.utils.js.map +1 -0
  210. package/package.json +12 -5
@@ -0,0 +1,148 @@
1
+ import { createClient } from '@redis/client';
2
+ import type { SessionCommands } from './opaque-session-store';
3
+
4
+ export class SessionStorageUnavailableError extends Error {
5
+ constructor() {
6
+ super('BFF session storage is unavailable');
7
+ this.name = 'SessionStorageUnavailableError';
8
+ }
9
+ }
10
+ export type ValkeySessionOptions = Readonly<{
11
+ url: string;
12
+ commandTimeoutMs?: number;
13
+ trustedInternalPlaintext?: boolean;
14
+ }>;
15
+ export interface SessionConnection {
16
+ readonly isOpen: boolean;
17
+ readonly isReady: boolean;
18
+ on(event: 'error', listener: () => void): unknown;
19
+ connect(): Promise<unknown>;
20
+ destroy(): void;
21
+ withAbortSignal(signal: AbortSignal): SessionCommands;
22
+ }
23
+ export type ValkeySessionDependencies = Readonly<{
24
+ createConnection?: (options: Readonly<{ url: string; timeoutMs: number }>) => SessionConnection;
25
+ }>;
26
+
27
+ /** One reusable command owner, no environment lookup, implicit retries or local fallback. */
28
+ export function createValkeySessionCommands(
29
+ input: ValkeySessionOptions,
30
+ dependencies: ValkeySessionDependencies = {},
31
+ ): SessionCommands & { close(): void } {
32
+ const options = Object.freeze({ ...input });
33
+ const timeoutMs = options.commandTimeoutMs ?? 2000;
34
+ let url: URL;
35
+ try {
36
+ url = new URL(options.url);
37
+ } catch {
38
+ throw new TypeError('Invalid BFF session storage configuration');
39
+ }
40
+ if (
41
+ (url.protocol !== 'rediss:' &&
42
+ !(url.protocol === 'redis:' && options.trustedInternalPlaintext === true)) ||
43
+ !url.hostname ||
44
+ url.search ||
45
+ url.hash ||
46
+ !/^(?:\/(?:\d{1,5})?)?$/.test(url.pathname) ||
47
+ options.url.length > 8192 ||
48
+ !Number.isSafeInteger(timeoutMs) ||
49
+ timeoutMs < 10 ||
50
+ timeoutMs > 5000
51
+ ) {
52
+ throw new TypeError('Invalid BFF session storage configuration');
53
+ }
54
+ const createConnection =
55
+ dependencies.createConnection ??
56
+ ((config) =>
57
+ createClient({
58
+ url: config.url,
59
+ disableOfflineQueue: true,
60
+ commandsQueueMaxLength: 128,
61
+ socket: { connectTimeout: config.timeoutMs, reconnectStrategy: false },
62
+ }));
63
+ let connecting: Promise<SessionConnection> | undefined;
64
+ let current: SessionConnection | undefined;
65
+ let closed = false;
66
+
67
+ async function client(): Promise<SessionConnection> {
68
+ if (closed) throw new SessionStorageUnavailableError();
69
+ if (!connecting) {
70
+ const connection = createConnection({ url: options.url, timeoutMs });
71
+ current = connection;
72
+ connection.on('error', () => {});
73
+ connecting = connection
74
+ .connect()
75
+ .then(() => connection)
76
+ .catch(() => {
77
+ if (connection.isOpen) connection.destroy();
78
+ if (current === connection) {
79
+ current = undefined;
80
+ connecting = undefined;
81
+ }
82
+ throw new SessionStorageUnavailableError();
83
+ });
84
+ }
85
+ const connection = await connecting;
86
+ if (closed || !connection.isReady) {
87
+ if (connection.isOpen) connection.destroy();
88
+ if (current === connection) {
89
+ current = undefined;
90
+ connecting = undefined;
91
+ }
92
+ throw new SessionStorageUnavailableError();
93
+ }
94
+ return connection;
95
+ }
96
+ return Object.freeze({
97
+ close() {
98
+ closed = true;
99
+ if (current?.isOpen) current.destroy();
100
+ current = undefined;
101
+ connecting = undefined;
102
+ },
103
+ async eval(script: string, command: { keys: string[]; arguments: string[] }): Promise<unknown> {
104
+ if (
105
+ typeof script !== 'string' ||
106
+ script.length > 16384 ||
107
+ !Array.isArray(command.keys) ||
108
+ !Array.isArray(command.arguments) ||
109
+ command.keys.length > 16 ||
110
+ command.arguments.length > 32 ||
111
+ command.keys.some((key: unknown) => typeof key !== 'string' || key.length > 1024) ||
112
+ command.arguments.some(
113
+ (argument: unknown) => typeof argument !== 'string' || argument.length > 40000,
114
+ )
115
+ ) {
116
+ throw new SessionStorageUnavailableError();
117
+ }
118
+ const keys = [...command.keys],
119
+ arguments_ = [...command.arguments];
120
+ const controller = new AbortController();
121
+ let timer: ReturnType<typeof setTimeout> | undefined;
122
+ const deadline = new Promise<never>((_, reject) => {
123
+ timer = setTimeout(() => {
124
+ controller.abort();
125
+ reject(new SessionStorageUnavailableError());
126
+ }, timeoutMs);
127
+ });
128
+ try {
129
+ return await Promise.race([
130
+ (async () => {
131
+ const connection = await client();
132
+ if (controller.signal.aborted) throw new SessionStorageUnavailableError();
133
+ return await connection.withAbortSignal(controller.signal).eval(script, {
134
+ keys,
135
+ arguments: arguments_,
136
+ });
137
+ })(),
138
+ deadline,
139
+ ]);
140
+ } catch {
141
+ throw new SessionStorageUnavailableError();
142
+ } finally {
143
+ clearTimeout(timer);
144
+ controller.abort();
145
+ }
146
+ },
147
+ });
148
+ }
@@ -0,0 +1,74 @@
1
+ # OIDC resource-server JWT verification
2
+
3
+ Generate with:
4
+
5
+ ```bash
6
+ ddd recipe oidc-resource-server --path <service> --dry-run
7
+ ddd recipe oidc-resource-server --path <service>
8
+ ```
9
+
10
+ Dry-run writes nothing. Conflicting files abort before writes; identical reruns
11
+ leave files alone. No module is auto-wired and no dependency is added: the
12
+ verifier uses NestJS plus the Node/Bun `node:crypto` and Fetch APIs already
13
+ available to the service.
14
+
15
+ ## Runtime registration
16
+
17
+ Prefer `registerAsync` so deployment configuration passes through the owning
18
+ service's validated configuration boundary:
19
+
20
+ ```ts
21
+ OidcResourceServerModule.registerAsync({
22
+ imports: [ConfigModule],
23
+ inject: [ConfigService],
24
+ useFactory: (config: ConfigService): OidcResourceServerOptions => ({
25
+ issuer: config.getOrThrow<string>('OIDC_ISSUER'),
26
+ jwksUri: config.getOrThrow<string>('OIDC_JWKS_URI'),
27
+ audiences: [config.getOrThrow<string>('OIDC_RESOURCE_AUDIENCE')],
28
+ authorizedClients: [config.getOrThrow<string>('OIDC_AUTHORIZED_CLIENT_ID')],
29
+ clockToleranceSeconds: 5,
30
+ }),
31
+ })
32
+ ```
33
+
34
+ Static `register(options)` remains available for already-validated in-memory
35
+ configuration and focused tests. The barrel exports:
36
+
37
+ - `OidcResourceServerModule`
38
+ - `OidcResourceServerModuleAsyncOptions`
39
+ - `OIDC_RESOURCE_SERVER_OPTIONS`
40
+ - `OidcResourceServerOptions`
41
+ - `OidcAccessTokenVerifier`
42
+ - `VerifiedOidcAccessToken`
43
+
44
+ Inject `OidcAccessTokenVerifier` and call `verify(originalAccessToken)`.
45
+
46
+ All URLs must be explicitly trusted HTTPS URLs from deployment configuration,
47
+ never request data. Audiences are resource/API audiences, not automatically
48
+ browser client IDs. Client allowlisting reads the issuer's `azp` claim.
49
+
50
+ The verifier deliberately supports RS256 only. It validates the signature,
51
+ exact issuer, resource audience, authorized client, nonempty subject, finite
52
+ required `exp`/`iat`, and optional `nbf`. Clock skew is bounded to 0..60 seconds.
53
+ Tokens, individual segments, JWKS bodies, JWKS key counts, RSA modulus sizes,
54
+ timeouts, cache lifetime, and unknown-key refresh frequency are bounded. Key
55
+ selection is by `kid`; rotation refreshes the JWKS after a controlled cooldown.
56
+ Fetch, parse, timeout, redirect, key-import, key-selection, and signature errors
57
+ all fail closed. Authentication errors never include bearer tokens, claims, keys,
58
+ or upstream response bodies.
59
+
60
+ The generated `oidc-access-token-verifier.spec.ts` uses real RSA keys and should
61
+ remain in the consuming repository. It proves valid verification plus rejection
62
+ of wrong signatures, algorithms, issuers, audiences, clients, subjects, times,
63
+ missing required claims, a typical ID-token substitute, and rotated keys.
64
+
65
+ This verifies a JWT, not universal proof that it is an access token. Issuers can
66
+ issue ID and access tokens with overlapping signed claims. The consumer MUST
67
+ enforce its selected provider access-token profile and current actor validation
68
+ before granting access. A valid ID token is never an API credential. Do not map
69
+ returned roles/groups to product permissions implicitly. HUMAN versus machine,
70
+ current organization, canonical identity binding, staff membership, revocation,
71
+ and transactional authorization belong to the consuming service.
72
+
73
+ Runtime baseline: Node 18+ or Bun with standards-compatible `fetch`, Web Streams,
74
+ and `node:crypto` JWK import. No package-manager or lockfile migration is required.
@@ -0,0 +1,2 @@
1
+ export * from './oidc-access-token-verifier';
2
+ export * from './oidc-resource-server.module';
@@ -0,0 +1,51 @@
1
+ import {
2
+ DynamicModule,
3
+ FactoryProvider,
4
+ Module,
5
+ ModuleMetadata,
6
+ } from '@nestjs/common';
7
+ import {
8
+ OIDC_RESOURCE_SERVER_OPTIONS,
9
+ OidcAccessTokenVerifier,
10
+ OidcResourceServerOptions,
11
+ } from './oidc-access-token-verifier';
12
+
13
+ export interface OidcResourceServerModuleAsyncOptions
14
+ extends Pick<ModuleMetadata, 'imports'> {
15
+ inject?: FactoryProvider<OidcResourceServerOptions>['inject'];
16
+ useFactory: FactoryProvider<OidcResourceServerOptions>['useFactory'];
17
+ }
18
+
19
+ const verifierProvider: FactoryProvider<OidcAccessTokenVerifier> = {
20
+ provide: OidcAccessTokenVerifier,
21
+ inject: [OIDC_RESOURCE_SERVER_OPTIONS],
22
+ useFactory: (options: OidcResourceServerOptions) => new OidcAccessTokenVerifier(options),
23
+ };
24
+
25
+ @Module({})
26
+ export class OidcResourceServerModule {
27
+ static register(options: OidcResourceServerOptions): DynamicModule {
28
+ return {
29
+ module: OidcResourceServerModule,
30
+ providers: [
31
+ { provide: OIDC_RESOURCE_SERVER_OPTIONS, useValue: options },
32
+ verifierProvider,
33
+ ],
34
+ exports: [OidcAccessTokenVerifier],
35
+ };
36
+ }
37
+
38
+ static registerAsync(options: OidcResourceServerModuleAsyncOptions): DynamicModule {
39
+ const optionsProvider: FactoryProvider<OidcResourceServerOptions> = {
40
+ provide: OIDC_RESOURCE_SERVER_OPTIONS,
41
+ inject: options.inject ?? [],
42
+ useFactory: options.useFactory,
43
+ };
44
+ return {
45
+ module: OidcResourceServerModule,
46
+ imports: options.imports ?? [],
47
+ providers: [optionsProvider, verifierProvider],
48
+ exports: [OidcAccessTokenVerifier],
49
+ };
50
+ }
51
+ }
@@ -0,0 +1,186 @@
1
+ import {
2
+ constants,
3
+ generateKeyPairSync,
4
+ KeyObject,
5
+ sign as signPayload,
6
+ } from 'node:crypto';
7
+ import {
8
+ OidcAccessTokenVerifier,
9
+ OidcResourceServerOptions,
10
+ } from './oidc-access-token-verifier';
11
+
12
+ function encode(value: unknown): string {
13
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
14
+ }
15
+
16
+ function signedToken(
17
+ claims: Record<string, unknown>,
18
+ privateKey: KeyObject,
19
+ header: Record<string, unknown> = { alg: 'RS256', kid: 'trusted' },
20
+ ): string {
21
+ const encodedHeader = encode(header);
22
+ const encodedPayload = encode(claims);
23
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
24
+ const signature = signPayload('RSA-SHA256', Buffer.from(signingInput, 'ascii'), {
25
+ key: privateKey,
26
+ padding: constants.RSA_PKCS1_PADDING,
27
+ });
28
+ return `${signingInput}.${signature.toString('base64url')}`;
29
+ }
30
+
31
+ describe('OidcAccessTokenVerifier', () => {
32
+ const trusted = generateKeyPairSync('rsa', { modulusLength: 2048 });
33
+ const wrong = generateKeyPairSync('rsa', { modulusLength: 2048 });
34
+ const rotated = generateKeyPairSync('rsa', { modulusLength: 2048 });
35
+ const trustedJwk = {
36
+ ...trusted.publicKey.export({ format: 'jwk' }),
37
+ kid: 'trusted',
38
+ alg: 'RS256',
39
+ use: 'sig',
40
+ };
41
+ const rotatedJwk = {
42
+ ...rotated.publicKey.export({ format: 'jwk' }),
43
+ kid: 'rotated',
44
+ alg: 'RS256',
45
+ use: 'sig',
46
+ };
47
+ const options: OidcResourceServerOptions = {
48
+ issuer: 'https://issuer.example',
49
+ jwksUri: 'https://issuer.example/keys',
50
+ audiences: ['resource-project'],
51
+ authorizedClients: ['ops-client'],
52
+ clockToleranceSeconds: 0,
53
+ jwksRefreshCooldownSeconds: 1,
54
+ };
55
+ const originalFetch = globalThis.fetch;
56
+ let publishedKeys: unknown[];
57
+ let requests: number;
58
+
59
+ beforeEach(() => {
60
+ publishedKeys = [trustedJwk];
61
+ requests = 0;
62
+ globalThis.fetch = (input) => {
63
+ expect(String(input)).toBe(options.jwksUri);
64
+ requests += 1;
65
+ return Promise.resolve(
66
+ new Response(JSON.stringify({ keys: publishedKeys }), {
67
+ status: 200,
68
+ headers: { 'content-type': 'application/json' },
69
+ }),
70
+ );
71
+ };
72
+ });
73
+
74
+ afterAll(() => {
75
+ globalThis.fetch = originalFetch;
76
+ });
77
+
78
+ function claims(patch: Record<string, unknown> = {}): Record<string, unknown> {
79
+ const now = Math.floor(Date.now() / 1000);
80
+ return {
81
+ sub: 'synthetic-human',
82
+ iss: options.issuer,
83
+ aud: ['resource-project'],
84
+ azp: 'ops-client',
85
+ iat: now,
86
+ exp: now + 300,
87
+ nbf: now - 1,
88
+ ...patch,
89
+ };
90
+ }
91
+
92
+ it('verifies RS256 and reuses the cached kid', async () => {
93
+ const verifier = new OidcAccessTokenVerifier(options);
94
+ await expect(verifier.verify(signedToken(claims(), trusted.privateKey))).resolves.toMatchObject({
95
+ sub: 'synthetic-human',
96
+ });
97
+ await expect(verifier.verify(signedToken(claims(), trusted.privateKey))).resolves.toBeDefined();
98
+ expect(requests).toBe(1);
99
+ });
100
+
101
+ it.each([
102
+ ['algorithm', claims(), { alg: 'HS256', kid: 'trusted' }],
103
+ ['issuer', claims({ iss: 'https://wrong.example' }), undefined],
104
+ ['audience', claims({ aud: 'browser-client' }), undefined],
105
+ ['authorized client', claims({ azp: 'sibling-client' }), undefined],
106
+ ['subject', claims({ sub: ' ' }), undefined],
107
+ ['expired timestamp', claims({ exp: 1 }), undefined],
108
+ ['future issued-at timestamp', claims({ iat: Date.now() / 1000 + 90 }), undefined],
109
+ ['future not-before timestamp', claims({ nbf: Date.now() / 1000 + 90 }), undefined],
110
+ // A typical ID token is audience-bound to the browser client, not this API resource.
111
+ ['ID-token substitute', claims({ aud: 'ops-client', nonce: 'browser-session' }), undefined],
112
+ ])('rejects the wrong %s', async (_case, tokenClaims, header) => {
113
+ const verifier = new OidcAccessTokenVerifier(options);
114
+ await expect(
115
+ verifier.verify(signedToken(tokenClaims, trusted.privateKey, header)),
116
+ ).rejects.toThrow('Invalid resource access token');
117
+ });
118
+
119
+ it('rejects a wrong signature without leaking token material', async () => {
120
+ const token = signedToken(claims(), wrong.privateKey);
121
+ const verifier = new OidcAccessTokenVerifier(options);
122
+ let message = '';
123
+ try {
124
+ await verifier.verify(token);
125
+ } catch (error) {
126
+ message = (error as Error).message;
127
+ }
128
+ expect(message).toBe('Invalid resource access token');
129
+ expect(message).not.toContain(token);
130
+ expect(message).not.toContain('synthetic-human');
131
+ });
132
+
133
+ it.each(['sub', 'exp', 'iat', 'azp', 'iss', 'aud'])('rejects a missing %s claim', async (claim) => {
134
+ const payload = claims();
135
+ delete payload[claim];
136
+ const verifier = new OidcAccessTokenVerifier(options);
137
+ await expect(verifier.verify(signedToken(payload, trusted.privateKey))).rejects.toThrow(
138
+ 'Invalid resource access token',
139
+ );
140
+ });
141
+
142
+ it('refreshes by kid when the issuer rotates keys', async () => {
143
+ const verifier = new OidcAccessTokenVerifier(options);
144
+ await verifier.verify(signedToken(claims(), trusted.privateKey));
145
+ publishedKeys = [rotatedJwk];
146
+ const currentTime = Date.now();
147
+ const now = jest.spyOn(Date, 'now').mockReturnValue(currentTime + 1100);
148
+ try {
149
+ await expect(
150
+ verifier.verify(
151
+ signedToken(claims(), rotated.privateKey, { alg: 'RS256', kid: 'rotated' }),
152
+ ),
153
+ ).resolves.toBeDefined();
154
+ } finally {
155
+ now.mockRestore();
156
+ }
157
+ expect(requests).toBe(2);
158
+ });
159
+
160
+ it('rejects unsafe configuration before any network request', () => {
161
+ expect(() =>
162
+ new OidcAccessTokenVerifier({ ...options, issuer: 'http://issuer.example' }),
163
+ ).toThrow();
164
+ expect(() => new OidcAccessTokenVerifier({ ...options, audiences: [] })).toThrow();
165
+ expect(() => new OidcAccessTokenVerifier({ ...options, authorizedClients: [] })).toThrow();
166
+ expect(() =>
167
+ new OidcAccessTokenVerifier({ ...options, clockToleranceSeconds: 61 }),
168
+ ).toThrow();
169
+ expect(() =>
170
+ new OidcAccessTokenVerifier({ ...options, jwksRefreshCooldownSeconds: 0 }),
171
+ ).toThrow();
172
+ expect(requests).toBe(0);
173
+ });
174
+
175
+ it('fails closed and rate-limits repeated JWKS outage retries', async () => {
176
+ globalThis.fetch = () => {
177
+ requests += 1;
178
+ return Promise.reject(new Error('offline with upstream details'));
179
+ };
180
+ const verifier = new OidcAccessTokenVerifier(options);
181
+ const token = signedToken(claims(), trusted.privateKey);
182
+ await expect(verifier.verify(token)).rejects.toThrow('Invalid resource access token');
183
+ await expect(verifier.verify(token)).rejects.toThrow('Invalid resource access token');
184
+ expect(requests).toBe(1);
185
+ });
186
+ });