@o3co/create-auth-provider 0.17.0-rc.4 → 0.18.0-rc.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 (110) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -913
  19. package/templates/standalone/README.md +0 -1537
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -118
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -424
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -55
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1192
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -346
  39. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  40. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  41. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  42. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  43. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  44. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  45. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
  46. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
  47. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
  48. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  49. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  50. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
  51. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  52. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  53. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  54. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
  55. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  56. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  57. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  58. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  59. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  60. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  61. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  62. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  63. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  64. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
  65. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  66. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
  67. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  68. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  69. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  70. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
  71. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  72. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  73. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  74. package/templates/standalone/src/__tests__/sections.test.mts +0 -504
  75. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
  76. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
  77. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  78. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  79. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  80. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  81. package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
  82. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
  83. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
  84. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  85. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  86. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  87. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
  88. package/templates/standalone/src/adapters.mts +0 -162
  89. package/templates/standalone/src/app.mts +0 -136
  90. package/templates/standalone/src/bootRefusal.mts +0 -117
  91. package/templates/standalone/src/buildModules.mts +0 -358
  92. package/templates/standalone/src/configPath.mts +0 -487
  93. package/templates/standalone/src/listen.mts +0 -60
  94. package/templates/standalone/src/logger.mts +0 -80
  95. package/templates/standalone/src/metrics.mts +0 -167
  96. package/templates/standalone/src/mfaSwitch.mts +0 -284
  97. package/templates/standalone/src/modules.mts +0 -705
  98. package/templates/standalone/src/rootRenames.mts +0 -95
  99. package/templates/standalone/src/routes.mts +0 -89
  100. package/templates/standalone/src/sections.mts +0 -377
  101. package/templates/standalone/src/shutdown.mts +0 -213
  102. package/templates/standalone/tests/Makefile +0 -5
  103. package/templates/standalone/tests/index.test.js +0 -283
  104. package/templates/standalone/tests/package-lock.json +0 -1617
  105. package/templates/standalone/tests/package.json +0 -16
  106. package/templates/standalone/tests/vitest.config.mjs +0 -26
  107. package/templates/standalone/tsconfig.json +0 -31
  108. package/templates/standalone/vitest.config.mts +0 -31
  109. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  110. package/templates/versions.json +0 -20
@@ -1,634 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- import { generateKeyPairSync } from "node:crypto";
18
- import { readFileSync } from "node:fs";
19
- import {
20
- type AppConfig,
21
- createApp,
22
- createHealthcheckRouter,
23
- createKeyStoreFactory,
24
- createReadinessRouter,
25
- defineModule,
26
- generateToken,
27
- InMemoryClientRepository,
28
- InMemoryUserRepository,
29
- memoryRefreshTokenFamilyStoreModule,
30
- registerBuiltinKeyStores,
31
- } from "@o3co/auth-provider-core";
32
- import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
33
- import type { OAuthSection } from "@o3co/auth-provider-oauth";
34
- import express from "express";
35
- import request from "supertest";
36
- import { afterEach, describe, expect, it } from "vitest";
37
- import { buildModules } from "../buildModules.mjs";
38
- import type { Switches } from "../configPath.mjs";
39
- import type { Adapters } from "../sections.mjs";
40
- import {
41
- capturedRenames,
42
- inProcessAdapters,
43
- shippedAdapters,
44
- } from "./library-references.fixture.mjs";
45
-
46
- const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
47
-
48
- // The template's shipped default is EdDSA with a published JWKS, so the
49
- // smoke test signs the way a real deployment of this scaffold does. Generated
50
- // per run — no key material is committed.
51
- const smokeKeyPair = generateKeyPairSync("ed25519", {
52
- publicKeyEncoding: { type: "spki", format: "pem" },
53
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
54
- });
55
-
56
- /** The `http` module's section, as the hand-built configuration below carries it. */
57
- const HTTP = {
58
- port: 0,
59
- trustProxy: false,
60
- readinessTimeoutMs: 1000,
61
- cors: { allowedOrigins: [] as string[] },
62
- };
63
-
64
- /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
65
- const config: AppConfig & Record<string, unknown> = {
66
- // What a resolution under an environment that sets none captures of
67
- // core's renamed variables.
68
- ...{
69
- "renamed-variables": capturedRenames({}),
70
- },
71
- http: HTTP,
72
- logging: { level: "silent" },
73
- "key-store": {
74
- provider: "local",
75
- local: {
76
- algorithm: "EdDSA",
77
- kid: "v0",
78
- privateKey: smokeKeyPair.privateKey,
79
- publicKey: smokeKeyPair.publicKey,
80
- previousKeys: [],
81
- },
82
- },
83
- // A composition with a consumer of session admission states what it
84
- // expects (ADR 2026-09-28-session-admission): the shipped
85
- // `application.conf` expects none, and so does this hand-built config.
86
- ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
87
- oauth: {
88
- jwt: {
89
- issuer: "https://auth.test",
90
- },
91
- accessToken: { defaultExpiresIn: 3600 },
92
- refreshToken: { expiresIn: 86400 },
93
- grants: {},
94
- oidcMode: "oidc-required",
95
- // No `authorize` section: the first-party invariant is unconditional.
96
- },
97
- "session-store": {
98
- // `session-store.secret` carries a 256-bit entropy floor.
99
- secret: "test-session-secret.at-least-32-bytes.ok",
100
- name: "auth.sid",
101
- maxAge: 3600000,
102
- secure: false,
103
- sameSite: "lax",
104
- domain: null,
105
- storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
106
- },
107
- session: {
108
- loginPage: { url: "/login" },
109
- rateLimit: { login: { windowMs: 60000, limit: 10 } },
110
- },
111
- rateLimit: { failMode: "open" },
112
- "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
113
- };
114
-
115
- /** What phase one hands `buildModules`: the configuration, with `changes` over every store in process. */
116
- const switchesWith = (changes: Partial<Adapters> = {}): Switches =>
117
- ({
118
- ...config,
119
- adapters: { ...inProcessAdapters(), ...changes },
120
- mfaMode: "off",
121
- }) as unknown as Switches;
122
-
123
- const switches = switchesWith();
124
-
125
- /**
126
- * The one confidential client, so the `clientAuthMw` middleware in front of
127
- * `/oauth/token` has a record to authenticate against. Smoke tests that hit
128
- * `/oauth/token` send `Authorization: ${SMOKE_BASIC_AUTH}`.
129
- */
130
- const SMOKE_CLIENT_ID = "smoke-client";
131
- const SMOKE_CLIENT_SECRET = "smoke-secret";
132
- const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
133
-
134
- /**
135
- * Test-only repository module in place of the file-system-backed repositories
136
- * `repositoriesModule` provides in production: the smoke tests verify the
137
- * boot pipeline shape, not the data layer. `codeRepository` is not provided
138
- * here; the `adapters.codeRepository` selection in `buildModules` wires it.
139
- */
140
- const testRepositoriesModule = defineModule({
141
- name: "test:repositories",
142
- provides: {
143
- // The map takes `ClientEntry`, the schema's OUTPUT type, where fields
144
- // with `.default(...)` are required; every field is supplied so the
145
- // literal needs no cast.
146
- clientRepository: () =>
147
- new InMemoryClientRepository(
148
- new Map([
149
- [
150
- SMOKE_CLIENT_ID,
151
- {
152
- tokenEndpointAuthMethod: "client_secret_basic",
153
- clientSecret: SMOKE_CLIENT_SECRET,
154
- allowedRedirectUris: [],
155
- allowedScopes: [],
156
- allowedAudiences: [],
157
- backchannelLogoutSessionRequired: true,
158
- frontchannelLogoutSessionRequired: true,
159
- allowedAzpForFederationToken: false,
160
- },
161
- ],
162
- ]),
163
- ),
164
- userRepository: () => new InMemoryUserRepository(new Map()),
165
- },
166
- });
167
-
168
- const testKeyStoreModule = defineModule({
169
- name: "test:key-store",
170
- requires: ["config"] as const,
171
- provides: {
172
- keyStore: async ({ config: c }) => {
173
- const factory = createKeyStoreFactory();
174
- registerBuiltinKeyStores(factory);
175
- return factory.create({
176
- type: "local",
177
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
178
- });
179
- },
180
- },
181
- });
182
-
183
- describe("standalone smoke test", () => {
184
- let handleRef: Awaited<ReturnType<typeof buildApp>>["handle"] | undefined;
185
-
186
- async function buildApp() {
187
- const handle = await createApp({
188
- modules: buildModules(switches, {
189
- keyStoreModule: testKeyStoreModule,
190
- repositoriesModule: testRepositoriesModule,
191
- // A memory RT family store in place of the default Redis store and
192
- // its client, so no real ioredis connection is opened.
193
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
194
- }),
195
- bootstrapComponents: { config, pathResolver: (s) => s },
196
- });
197
-
198
- const app = express();
199
- app.use(createHealthcheckRouter(express));
200
- app.use(
201
- createReadinessRouter(express, {
202
- probes: handle.readinessProbes,
203
- timeoutMs: HTTP.readinessTimeoutMs,
204
- }),
205
- );
206
- app.use(handle.router);
207
- return { app, handle };
208
- }
209
-
210
- afterEach(async () => {
211
- await handleRef?.dispose();
212
- handleRef = undefined;
213
- });
214
-
215
- it("Dockerfile declares runtime metadata and runs install/build steps as node", () => {
216
- // HEALTHCHECK and EXPOSE must read the port from ${HTTP_PORT} so an
217
- // operator overriding it keeps the app, EXPOSE and the healthcheck in
218
- // sync; they are asserted by that token, not the literal port. The token
219
- // is built by concatenation to sidestep biome's
220
- // `noTemplateCurlyInString`: it is a Dockerfile substitution, not JS.
221
- const HTTP_PORT_VAR = `$${"{"}HTTP_PORT}`;
222
- expect(dockerfile).toContain("ENV HTTP_PORT=3000");
223
- expect(dockerfile).toContain(`EXPOSE ${HTTP_PORT_VAR}`);
224
- expect(dockerfile).toMatch(
225
- /HEALTHCHECK\s+--interval=30s\s+--timeout=3s\s+--start-period=10s\s+--retries=3/,
226
- );
227
- // Match the literal `${HTTP_PORT}` token inside the HEALTHCHECK CMD.
228
- // `[$]` (character class) sidesteps biome's `noTemplateCurlyInString`
229
- // false-positive without changing what we actually accept.
230
- expect(dockerfile).toMatch(
231
- /CMD\s+wget\s+-q\s+-O\s+\/dev\/null\s+"http:\/\/localhost:[$]\{HTTP_PORT\}\/_healthcheck"\s+\|\|\s+exit\s+1/,
232
- );
233
- expect(dockerfile).toMatch(
234
- /FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
235
- );
236
- expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
237
- // Dependencies are resolved exactly once, in `deps`. The runtime
238
- // stage must take its node_modules from a prune of that same tree — a
239
- // second `pnpm install` would be a second resolution, so its absence
240
- // after the runtime FROM is part of the contract.
241
- expect(dockerfile).toMatch(
242
- /FROM deps AS prod-deps[\s\S]*USER node[\s\S]*RUN pnpm prune --prod/,
243
- );
244
- expect(dockerfile).toMatch(
245
- /FROM node-base AS runtime[\s\S]*COPY --from=prod-deps[\s\S]*USER node/,
246
- );
247
- expect(dockerfile.slice(dockerfile.indexOf("FROM node-base AS runtime"))).not.toContain(
248
- "pnpm install",
249
- );
250
- });
251
-
252
- it("Dockerfile pins its mutable build inputs", () => {
253
- // Base image by digest — the tag alone is a moving pointer, and
254
- // Dependabot's docker ecosystem bumps tag and digest together.
255
- expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
256
- // Global corepack by version — `npm install -g corepack` with no pin
257
- // resolves whatever is latest at build time.
258
- expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
259
- // The lockfile is a required build input for --frozen-lockfile.
260
- expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
261
- });
262
-
263
- it("GET /_healthcheck returns 200", async () => {
264
- const { app, handle } = await buildApp();
265
- handleRef = handle;
266
- const res = await request(app).get("/_healthcheck");
267
- expect(res.status).toBe(200);
268
- });
269
-
270
- it("GET /readyz returns 200 for a memory-only composition with no probes", async () => {
271
- // These smoke modules are all in-memory, so nothing registers a probe.
272
- // Readiness must not invent a dependency that is not wired.
273
- const { app, handle } = await buildApp();
274
- handleRef = handle;
275
- const res = await request(app).get("/readyz");
276
- expect(res.status).toBe(200);
277
- expect(res.body).toEqual({ status: "ready", checks: [] });
278
- });
279
-
280
- it("GET /readyz returns 503 naming the dependency when a probe fails", async () => {
281
- const { handle } = await buildApp();
282
- handleRef = handle;
283
- const app = express();
284
- app.use(
285
- createReadinessRouter(express, {
286
- probes: [
287
- {
288
- name: "redis",
289
- check: async () => {
290
- throw new Error("ECONNREFUSED");
291
- },
292
- },
293
- ],
294
- timeoutMs: HTTP.readinessTimeoutMs,
295
- }),
296
- );
297
- const res = await request(app).get("/readyz");
298
- expect(res.status).toBe(503);
299
- expect(res.body.status).toBe("unready");
300
- expect(res.body.checks[0].name).toBe("redis");
301
- });
302
-
303
- it("issuer-configured buildModules serves the advertised jwks_uri (discovery <-> JWKS presence contract)", async () => {
304
- // Against the REAL composition root: core's `jwksModule` contributes
305
- // `jwks_uri` to discovery AND mounts the JWKS route. Without it the
306
- // issuer-configured composition fails the discovery presence contract
307
- // at boot. The oauth package's integration test composes both modules by
308
- // hand, so only a scaffold-level test catches `buildModules` dropping it.
309
- const issuerConfig: AppConfig = {
310
- ...config,
311
- oauth: {
312
- ...(config.oauth as OAuthSection),
313
- jwt: { ...(config.oauth as OAuthSection).jwt, issuer: "https://auth.example.com" },
314
- },
315
- };
316
- const handle = await createApp({
317
- modules: buildModules(switches, {
318
- keyStoreModule: testKeyStoreModule,
319
- repositoriesModule: testRepositoriesModule,
320
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
321
- }),
322
- bootstrapComponents: { config: issuerConfig, pathResolver: (s) => s },
323
- });
324
- handleRef = handle;
325
- const app = express();
326
- app.use(handle.router);
327
- const disco = await request(app).get("/.well-known/openid-configuration");
328
- expect(disco.status).toBe(200);
329
- const jwksPath = new URL(disco.body.jwks_uri as string).pathname;
330
- const res = await request(app).get(jwksPath);
331
- expect(res.status).toBe(200);
332
- expect(Array.isArray(res.body.keys)).toBe(true);
333
- // Presence is not enough — the advertised jwks_uri must
334
- // publish an actual verification key, and never the private half.
335
- expect(res.body.keys).toHaveLength(1);
336
- expect(res.body.keys[0].alg).toBe("EdDSA");
337
- expect(res.body.keys[0].kid).toBe("v0");
338
- expect(res.body.keys[0].d).toBeUndefined();
339
- });
340
-
341
- it("POST /oauth/token with unsupported grant_type returns 400", async () => {
342
- const { app, handle } = await buildApp();
343
- handleRef = handle;
344
- const res = await request(app)
345
- .post("/oauth/token")
346
- .set("Authorization", SMOKE_BASIC_AUTH)
347
- .type("form")
348
- .send({ grant_type: "unsupported" });
349
- expect(res.status).toBe(400);
350
- });
351
-
352
- it("POST /oauth/token 400 responses do NOT have Cache-Control: no-store", async () => {
353
- const { app, handle } = await buildApp();
354
- handleRef = handle;
355
- const res = await request(app)
356
- .post("/oauth/token")
357
- .set("Authorization", SMOKE_BASIC_AUTH)
358
- .type("form")
359
- .send({ grant_type: "unsupported" });
360
- expect(res.status).toBe(400);
361
- expect(res.headers["cache-control"]).not.toBe("no-store");
362
- });
363
-
364
- // A freshly scaffolded app must boot under the default
365
- // `core.federations.google.enabled = false`. The federation types the
366
- // template bundles are loaded whatever the configuration says, and with no
367
- // entry enabled they handle nothing: no federation is registered.
368
- it("boots when google federation is disabled (default scaffold config)", async () => {
369
- const modules = buildModules(switches, {
370
- keyStoreModule: testKeyStoreModule,
371
- repositoriesModule: testRepositoriesModule,
372
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
373
- });
374
- const moduleNames = modules.map((m) => m.name);
375
- expect(moduleNames).toContain("federation-google-type");
376
- expect(moduleNames).toContain("federation-oidc");
377
-
378
- const handle = await createApp({
379
- modules,
380
- bootstrapComponents: { config, pathResolver: (s) => s },
381
- });
382
- handleRef = handle;
383
- expect(handle.components.federationProviders?.size ?? 0).toBe(0);
384
- });
385
-
386
- // The composition root must default to the Redis-backed RT family store
387
- // and the ioredis client module, NOT the in-memory store: multi-replica
388
- // deployments lose RT family persistence when each replica holds families
389
- // in-process. The smoke tests above exercise the memory override.
390
- describe("redis-clients + adapter wiring", () => {
391
- it("buildModules includes the shared redis-clients module + redis store by default", () => {
392
- const modules = buildModules(switches, {
393
- keyStoreModule: testKeyStoreModule,
394
- repositoriesModule: testRepositoriesModule,
395
- });
396
- const names = modules.map((m) => m.name);
397
- expect(names).toContain("redis-clients");
398
- expect(names).toContain("redis-refresh-token-family-store");
399
- expect(names).not.toContain("core-refresh-token-family-store-memory");
400
- });
401
-
402
- it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
403
- const modules = buildModules(switches, {
404
- keyStoreModule: testKeyStoreModule,
405
- repositoriesModule: testRepositoriesModule,
406
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
407
- });
408
- const names = modules.map((m) => m.name);
409
- expect(names).toContain("core-refresh-token-family-store-memory");
410
- expect(names).not.toContain("redis-clients");
411
- expect(names).not.toContain("redis-refresh-token-family-store");
412
- });
413
-
414
- it("buildModules wires memoryRateLimiterModule by default", () => {
415
- const modules = buildModules(switches, {
416
- keyStoreModule: testKeyStoreModule,
417
- repositoriesModule: testRepositoriesModule,
418
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
419
- });
420
- const names = modules.map((m) => m.name);
421
- expect(names).toContain("core-rate-limiter-memory");
422
- expect(names).not.toContain("redis-rate-limiter");
423
- });
424
-
425
- it("buildModules switches to redisRateLimiterModule when adapters.rateLimiter = 'redis'", () => {
426
- const modules = buildModules(switchesWith({ rateLimiter: "redis" }), {
427
- keyStoreModule: testKeyStoreModule,
428
- repositoriesModule: testRepositoriesModule,
429
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
430
- });
431
- const names = modules.map((m) => m.name);
432
- expect(names).toContain("redis-rate-limiter");
433
- expect(names).not.toContain("core-rate-limiter-memory");
434
- // Adapter = redis pulls in the shared redis-clients module too.
435
- expect(names).toContain("redis-clients");
436
- });
437
-
438
- it("buildModules switches to redisSessionStoresModule when adapters.userSessionStores = 'redis'", () => {
439
- const modules = buildModules(switchesWith({ userSessionStores: "redis" }), {
440
- keyStoreModule: testKeyStoreModule,
441
- repositoriesModule: testRepositoriesModule,
442
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
443
- });
444
- const names = modules.map((m) => m.name);
445
- expect(names).toContain("redis-session-stores");
446
- expect(names).not.toContain("standalone-in-memory-session-stores");
447
- expect(names).toContain("redis-clients");
448
- });
449
-
450
- // The tests above check which modules are selected, not whether the
451
- // selection is satisfiable: a `*Client` slot with no provider (such as
452
- // `redisSessionStoresModule`'s `subjectSessionIndexClient`) fails
453
- // stage-1 boot with `missing-required-component`. The invariant the
454
- // shared clients module exists for: every `*Client` slot any selected
455
- // module requires is one it provides.
456
- it("redis-clients provides every *Client slot the Redis branches require", () => {
457
- const allRedisConfig = switchesWith({
458
- userSessionStores: "redis",
459
- rateLimiter: "redis",
460
- accessTokenDenylist: "redis",
461
- consentStore: "redis",
462
- codeRepository: "redis",
463
- });
464
- // No `refreshTokenFamilyModules` override: the default is the Redis
465
- // RT-family store, so `refreshTokenFamilyClient` is part of what the
466
- // invariant covers. Still hermetic — buildModules opens no socket.
467
- const modules = buildModules(allRedisConfig, {
468
- keyStoreModule: testKeyStoreModule,
469
- repositoriesModule: testRepositoriesModule,
470
- });
471
- const clients = modules.find((m) => m.name === "redis-clients");
472
- expect(clients).toBeDefined();
473
- const provided = new Set(Object.keys(clients?.provides ?? {}));
474
- const requiredClientSlots = new Set(
475
- modules.flatMap((m) => [...(m.requires ?? [])]).filter((key) => key.endsWith("Client")),
476
- );
477
- expect(requiredClientSlots.size).toBeGreaterThan(0);
478
- const missing = [...requiredClientSlots].filter((key) => !provided.has(key));
479
- expect(missing).toEqual([]);
480
- });
481
- });
482
-
483
- // Without an access-token denylist, `/oauth/revoke` would answer 200 for
484
- // an access token and leave the JWT working until expiry. The template
485
- // always wires one, and its own application.conf selects the Redis-backed
486
- // adapter — the memory one forks per replica and `core.deployment.mode =
487
- // "multi"` refuses it.
488
- describe("access-token denylist wiring", () => {
489
- it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
490
- const modules = buildModules(switches, {
491
- keyStoreModule: testKeyStoreModule,
492
- repositoriesModule: testRepositoriesModule,
493
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
494
- });
495
- const providesDenylist = modules.filter((m) =>
496
- Object.keys(m.provides ?? {}).includes("accessTokenDenylist"),
497
- );
498
- expect(providesDenylist).toHaveLength(1);
499
- });
500
-
501
- it("defaults to the memory denylist when no adapter is configured", () => {
502
- const modules = buildModules(switches, {
503
- keyStoreModule: testKeyStoreModule,
504
- repositoriesModule: testRepositoriesModule,
505
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
506
- });
507
- const names = modules.map((m) => m.name);
508
- expect(names).toContain("core-access-token-denylist-memory");
509
- expect(names).not.toContain("redis-access-token-denylist");
510
- });
511
-
512
- it("switches to the Redis denylist when adapters.accessTokenDenylist = 'redis'", () => {
513
- const modules = buildModules(switchesWith({ accessTokenDenylist: "redis" }), {
514
- keyStoreModule: testKeyStoreModule,
515
- repositoriesModule: testRepositoriesModule,
516
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
517
- });
518
- const names = modules.map((m) => m.name);
519
- expect(names).toContain("redis-access-token-denylist");
520
- expect(names).not.toContain("core-access-token-denylist-memory");
521
- // The Redis branch must also pull in the shared ioredis socket, or the
522
- // `accessTokenDenylistClient` slot has no provider and boot fails on a
523
- // missing component instead of on the thing the operator changed.
524
- expect(names).toContain("redis-clients");
525
- });
526
-
527
- it("the template's reference.conf selects the replica-safe adapter", () => {
528
- // The template's own config is the artifact operators deploy. It runs
529
- // with `core.deployment.mode = multi` in the umbrella E2E, which refuses
530
- // every in-memory shared store — so shipping the memory denylist here
531
- // would be a boot failure in the very stack that proves the scaffold
532
- // works.
533
- expect(shippedAdapters().accessTokenDenylist).toBe("redis");
534
- });
535
-
536
- it("keeps the composition boot-valid end to end", async () => {
537
- // The core boot guard refuses a composition that reads
538
- // the denylist slot without one. A scaffold that trips its own library's
539
- // guard is not a scaffold.
540
- const handle = await createApp({
541
- modules: buildModules(switches, {
542
- keyStoreModule: testKeyStoreModule,
543
- repositoriesModule: testRepositoriesModule,
544
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
545
- }),
546
- bootstrapComponents: { config, pathResolver: (s) => s },
547
- });
548
- handleRef = handle;
549
- expect(handle).toBeDefined();
550
- });
551
- });
552
-
553
- // The OAuth code repository's selection: the memory branch wires
554
- // `inMemoryCodeRepositoryModule`; the redis branch wires
555
- // `redisCodeRepositoryModule` against the shared ioredis socket.
556
- describe("code-repository adapter wiring", () => {
557
- it("buildModules wires inMemoryCodeRepositoryModule when adapters.codeRepository = 'memory'", () => {
558
- const modules = buildModules(switches, {
559
- keyStoreModule: testKeyStoreModule,
560
- repositoriesModule: testRepositoriesModule,
561
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
562
- });
563
- const names = modules.map((m) => m.name);
564
- expect(names).toContain("standalone-in-memory-code-repository");
565
- expect(names).not.toContain("redis-code-repository");
566
- });
567
-
568
- it("buildModules switches to redisCodeRepositoryModule when adapters.codeRepository = 'redis'", () => {
569
- const modules = buildModules(switchesWith({ codeRepository: "redis" }), {
570
- keyStoreModule: testKeyStoreModule,
571
- repositoriesModule: testRepositoriesModule,
572
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
573
- });
574
- const names = modules.map((m) => m.name);
575
- expect(names).toContain("redis-code-repository");
576
- expect(names).not.toContain("standalone-in-memory-code-repository");
577
- // adapter = "redis" pulls in the shared ioredis socket so the
578
- // `codeRepositoryClient` slot is satisfied.
579
- expect(names).toContain("redis-clients");
580
- });
581
-
582
- it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
583
- // The `repositories` module must NOT provide `codeRepository` — the
584
- // slot is owned by `inMemoryCodeRepositoryModule` or
585
- // `redisCodeRepositoryModule` exclusively, selected via
586
- // `adapters.codeRepository`.
587
- const modules = buildModules(switches, {
588
- keyStoreModule: testKeyStoreModule,
589
- // The production `repositories` module (not the test override), so
590
- // its own provides are exercised.
591
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
592
- });
593
- const codeRepoProviders = modules.filter((m) =>
594
- Object.keys(m.provides ?? {}).includes("codeRepository"),
595
- );
596
- expect(codeRepoProviders).toHaveLength(1);
597
- expect(codeRepoProviders[0]?.name).toBe("standalone-in-memory-code-repository");
598
- });
599
- });
600
-
601
- it("POST /oauth/introspect returns iat in active token response", async () => {
602
- const { app, handle } = await buildApp();
603
- handleRef = handle;
604
-
605
- const ksf = createKeyStoreFactory();
606
- registerBuiltinKeyStores(ksf);
607
- const keyStore = await ksf.create({
608
- type: "local",
609
- ...(config["key-store"] as { local: object }).local,
610
- });
611
- // The token must carry the deployment's configured `iss`: introspection
612
- // pins it (RFC 9068 §4), and every deployment has one.
613
- const { token } = await generateToken(
614
- {},
615
- {
616
- keyStore,
617
- subject: "u1",
618
- expiresIn: 3600,
619
- tokenType: "at+jwt",
620
- issuer: (config.oauth as OAuthSection).jwt.issuer,
621
- },
622
- );
623
-
624
- const res = await request(app)
625
- .post("/oauth/introspect")
626
- .set("Authorization", `Bearer ${token}`)
627
- .type("form")
628
- .send({ token });
629
-
630
- expect(res.status).toBe(200);
631
- expect(res.body.active).toBe(true);
632
- expect(typeof res.body.iat).toBe("number");
633
- });
634
- });