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