@o3co/create-auth-provider 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,9 +14,14 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { readFileSync } from "node:fs";
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
- import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
20
+ import {
21
+ type AppConfig,
22
+ AppConfigSchema,
23
+ resolveAccessTokenLifetime,
24
+ } from "@o3co/auth-provider-core";
20
25
  import { parseFile } from "@o3co/ts.hocon";
21
26
  import { validate } from "@o3co/ts.hocon/zod";
22
27
  import { describe, expect, it } from "vitest";
@@ -82,6 +87,10 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
82
87
  OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
83
88
 
84
89
  // --- oauth tokens / policy ----------------------------------------
90
+ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
91
+ OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
92
+ // Deprecated alias of the default. Exported alongside the new variable on
93
+ // purpose, with a different value: the new variable must win.
85
94
  OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
86
95
  OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
87
96
  OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
@@ -95,6 +104,18 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
95
104
  OAUTH_NONCE_MAX_LENGTH: "256",
96
105
  OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
97
106
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
107
+ OAUTH_CIMD_ENABLED: "true",
108
+ OAUTH_CIMD_ALLOWED_SCOPES: "read, write",
109
+ OAUTH_CIMD_ALLOWED_AUDIENCES: "https://mcp.example",
110
+ OAUTH_CIMD_ALLOWED_HOSTS: "client.example, .trusted.example",
111
+ OAUTH_CIMD_DENIED_HOSTS: "evil.example",
112
+ OAUTH_CIMD_MAX_BYTES: "8192",
113
+ OAUTH_CIMD_TIMEOUT_MS: "3000",
114
+ OAUTH_CIMD_CACHE_MAX_AGE_MS: "60000",
115
+ OAUTH_CIMD_MAX_CACHE_ENTRIES: "128",
116
+ OAUTH_CIMD_STALE_IF_ERROR_MS: "120000",
117
+ OAUTH_CIMD_NEGATIVE_CACHE_MS: "30000",
118
+ OAUTH_CIMD_MAX_CONCURRENT_FETCHES: "4",
98
119
  OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
99
120
  OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
100
121
 
@@ -136,6 +157,11 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
136
157
  DEPLOYMENT_MODE: "multi",
137
158
  USER_SESSION_STORES_ADAPTER: "redis",
138
159
  ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
160
+ // #484: the replay seen-set behind private_key_jwt client authentication.
161
+ REPLAY_SEEN_SET_ADAPTER: "redis",
162
+ // #561: the consent stores' shared backend and its namespace.
163
+ CONSENT_STORE_ADAPTER: "redis",
164
+ REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
139
165
  REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
140
166
  REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
141
167
  REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
@@ -154,6 +180,12 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
154
180
  FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
155
181
  FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
156
182
  FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
183
+ // #524: the generic OIDC federation the template ships disabled.
184
+ FEDERATIONS_OIDC_ENABLED: "true",
185
+ FEDERATIONS_OIDC_ISSUER: "https://idp.test",
186
+ FEDERATIONS_OIDC_CLIENT_ID: "oidc-client-id",
187
+ FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-client-secret",
188
+ FEDERATIONS_OIDC_CALLBACK_URL: "https://auth.test/session/oauth/federation/oidc/callback",
157
189
 
158
190
  // --- repositories -------------------------------------------------
159
191
  CLIENT_TYPE: "yaml",
@@ -162,6 +194,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
162
194
  CLIENT_USER_PATH: "./config/users.yaml",
163
195
  CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
164
196
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
197
+ CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
165
198
  CLIENT_USER_TIMEOUT: "5000",
166
199
  CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
167
200
  CLIENT_CODE_TYPE: "redis",
@@ -172,6 +205,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
172
205
 
173
206
  // --- endpoints ----------------------------------------------------
174
207
  ENDPOINTS_LOGIN_URL: "/login",
208
+ ENDPOINTS_CONSENT_URL: "/consent",
175
209
 
176
210
  // --- cors ---------------------------------------------------------
177
211
  // #500: a list, in the only shape an environment variable can carry one.
@@ -288,6 +322,7 @@ describe("#288: the shipped config boots with every documented override supplied
288
322
  expect(config.oauth.requireEmailVerified).toBe(true);
289
323
  expect(config.oauth.resourceIndicator?.enabled).toBe(true);
290
324
  expect(config.federations.google?.enabled).toBe(true);
325
+ expect(config.federations.oidc?.enabled).toBe(true);
291
326
  });
292
327
 
293
328
  it("turns every non-boolean override into its declared type", () => {
@@ -295,11 +330,34 @@ describe("#288: the shipped config boots with every documented override supplied
295
330
  expect(config.http.port).toBe(3000);
296
331
  expect(config.http.readinessTimeoutMs).toBe(1500);
297
332
  expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
298
- expect(config.oauth.accessToken.expiresIn).toBe(3600);
333
+ // The new default wins over the deprecated variable, and the parsed
334
+ // config mirrors it onto the old key for readers that predate the split.
335
+ expect(resolveAccessTokenLifetime(config)).toEqual({
336
+ defaultExpiresIn: 900,
337
+ maxExpiresIn: 7200,
338
+ });
339
+ expect(config.oauth.accessToken.expiresIn).toBe(900);
299
340
  expect(config.oauth.refreshToken.expiresIn).toBe(86400);
300
341
  expect(config.session.maxAge).toBe(3600000);
301
342
  expect(config.session.csrf?.ttlSeconds).toBe(7200);
302
343
  expect(config.oauth.nonce?.maxLength).toBe(256);
344
+ expect(config.consentStore?.adapter).toBe("redis");
345
+ expect(config.redisConsentStore?.keyPrefix).toBe("tenant-a:consent:");
346
+ // #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
347
+ expect(config.oauth.clientIdMetadataDocuments).toEqual({
348
+ enabled: true,
349
+ allowedScopes: ["read", "write"],
350
+ allowedAudiences: ["https://mcp.example"],
351
+ allowedHosts: ["client.example", ".trusted.example"],
352
+ deniedHosts: ["evil.example"],
353
+ maxBytes: 8192,
354
+ timeoutMs: 3000,
355
+ maxCacheEntries: 128,
356
+ staleIfErrorMs: 120000,
357
+ negativeCacheMs: 30000,
358
+ maxConcurrentFetches: 4,
359
+ cacheMaxAgeMs: 60000,
360
+ });
303
361
  // #500: a comma-separated string becomes a list of origins, trimmed.
304
362
  expect(config.cors?.allowedOrigins).toEqual([
305
363
  "https://app.example.com",
@@ -468,6 +526,26 @@ describe("#288: the shipped config boots with every documented override supplied
468
526
  ).toThrow(/allowUnmarkedClients/);
469
527
  });
470
528
 
529
+ it("refuses an access-token default above the max, naming both keys", () => {
530
+ expect(() =>
531
+ buildResolvedConfig({
532
+ ...DOCUMENTED_ENV,
533
+ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
534
+ OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
535
+ }),
536
+ ).toThrow(/defaultExpiresIn.*maxExpiresIn/s);
537
+ });
538
+
539
+ for (const name of [
540
+ "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
541
+ "OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
542
+ "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
543
+ ]) {
544
+ it(`refuses an empty ${name} rather than minting already-expired tokens`, () => {
545
+ expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, [name]: "" })).toThrow();
546
+ });
547
+ }
548
+
471
549
  it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
472
550
  // Pinned alongside the boolean cases because it is the same trap
473
551
  // read from the other side: for a *number*, empty means fail loudly.
@@ -477,10 +555,15 @@ describe("#288: the shipped config boots with every documented override supplied
477
555
  });
478
556
  });
479
557
 
480
- it("resolves against the shipped standalone template, not a fixture", () => {
558
+ it("resolves against the shipped standalone artifact, not a fixture", () => {
481
559
  // Guards the guard: if the paths above ever stop pointing at the real
482
- // artifact these tests would pass while testing nothing.
483
- expect(standaloneDir).toMatch(/templates[/\\]standalone[/\\]?$/);
560
+ // artifact these tests would pass while testing nothing. Checked by
561
+ // what the directory holds, not by what it is called: this file ships
562
+ // in every scaffolded project, and none of those lives at
563
+ // `templates/standalone` (#512).
564
+ for (const file of ["config/application.conf", "config/production.conf", "src/app.mts"]) {
565
+ expect(existsSync(join(standaloneDir, file)), file).toBe(true);
566
+ }
484
567
  expect(liveSubstitutions().size).toBeGreaterThan(40);
485
568
  });
486
569
  });
@@ -37,6 +37,18 @@ function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[
37
37
  return app;
38
38
  }
39
39
 
40
+ /**
41
+ * Starts `app` on 127.0.0.1, the address the raw-socket tests dial. A hostless
42
+ * `listen(0)` binds `[::]`, and on macOS the port it gets can be one another
43
+ * process holds on 127.0.0.1 — the request then reaches that process and can
44
+ * hang until the test times out (#556).
45
+ */
46
+ function listenOnLoopback(app: express.Express): Promise<ReturnType<express.Express["listen"]>> {
47
+ return new Promise((resolve) => {
48
+ const server = app.listen(0, "127.0.0.1", () => resolve(server));
49
+ });
50
+ }
51
+
40
52
  describe("GET /metrics", () => {
41
53
  it("serves the Prometheus text exposition format", async () => {
42
54
  const res = await request(buildApp()).get("/metrics");
@@ -95,7 +107,7 @@ describe("GET /metrics", () => {
95
107
  // route label, reachable without any access to /metrics. Sent over a real
96
108
  // socket because supertest cannot express a non-standard method.
97
109
  const app = buildApp();
98
- const server = app.listen(0);
110
+ const server = await listenOnLoopback(app);
99
111
  const port = (server.address() as { port: number }).port;
100
112
  try {
101
113
  for (const method of ["FOO", "M000001", "PURGE"]) {
@@ -135,7 +147,7 @@ describe("GET /metrics", () => {
135
147
  void res;
136
148
  });
137
149
 
138
- const server = app.listen(0);
150
+ const server = await listenOnLoopback(app);
139
151
  const port = (server.address() as { port: number }).port;
140
152
  try {
141
153
  const controller = new AbortController();
@@ -0,0 +1,138 @@
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 type { AppConfig } from "@o3co/auth-provider-core";
18
+ import { makeValidAppConfig } from "@o3co/auth-provider-core/testing";
19
+ import { describe, expect, it } from "vitest";
20
+ import { buildModules } from "../buildModules.mjs";
21
+ import { oidcFederationConfigModule } from "../modules.mjs";
22
+
23
+ /**
24
+ * #524: the scaffold turns every enabled `federations.<name>` of type
25
+ * `oidc` into one instance of the generic OIDC federation module, fed by a
26
+ * single config bridge. A deployment adds an IdP with configuration only.
27
+ */
28
+ type Factory = (deps: { config: unknown }) => Record<string, unknown>;
29
+
30
+ const bridge = (
31
+ oidcFederationConfigModule as unknown as {
32
+ provides: { oidcFederationConfigs: Factory };
33
+ }
34
+ ).provides.oidcFederationConfigs;
35
+
36
+ const okta = {
37
+ issuer: "https://dev-1.okta.test",
38
+ clientId: "okta-client",
39
+ clientSecret: "okta-secret",
40
+ callbackURL: "https://auth.test/session/oauth/federation/okta/callback",
41
+ };
42
+
43
+ /** Core's valid fixture, with the federations under test and every adapter on memory. */
44
+ const configWith = (federations: Record<string, unknown>): AppConfig => {
45
+ const base = makeValidAppConfig();
46
+ return {
47
+ ...base,
48
+ federations,
49
+ oauth: { ...base.oauth, code: { adapter: "memory" } },
50
+ } as unknown as AppConfig;
51
+ };
52
+
53
+ describe("oidcFederationConfigModule (#524)", () => {
54
+ it("has the scaffold's module name and reads config", () => {
55
+ expect(oidcFederationConfigModule.name).toBe("standalone:oidc-federation-config");
56
+ expect(oidcFederationConfigModule.requires).toEqual(["config"]);
57
+ });
58
+
59
+ it("reads every enabled oidc section into the slot, keyed by federation name", () => {
60
+ const out = bridge({
61
+ config: {
62
+ federations: {
63
+ okta: { enabled: true, type: "oidc", ...okta },
64
+ keycloak: {
65
+ enabled: true,
66
+ type: "oidc",
67
+ clientUrl: "https://app.test/",
68
+ oidc: {
69
+ issuer: "https://kc.test/realms/dev",
70
+ clientId: "kc-client",
71
+ clientSecret: "kc-secret",
72
+ callbackURL: "https://auth.test/session/oauth/federation/keycloak/callback",
73
+ },
74
+ },
75
+ google: {
76
+ enabled: true,
77
+ clientId: "g",
78
+ clientSecret: "s",
79
+ callbackURL: "https://auth.test/g",
80
+ },
81
+ paused: { enabled: false, type: "oidc", ...okta },
82
+ },
83
+ },
84
+ });
85
+ expect(Object.keys(out).sort()).toEqual(["keycloak", "okta"]);
86
+ expect(out.okta).toEqual(okta);
87
+ expect(out.keycloak).toMatchObject({
88
+ issuer: "https://kc.test/realms/dev",
89
+ clientUrl: "https://app.test/",
90
+ });
91
+ });
92
+
93
+ it("refuses a malformed section at boot, naming the field", () => {
94
+ const { issuer: _dropped, ...noIssuer } = okta;
95
+ expect(() =>
96
+ bridge({ config: { federations: { okta: { enabled: true, type: "oidc", ...noIssuer } } } }),
97
+ ).toThrow(/federations\.okta\.issuer/);
98
+ });
99
+
100
+ it("is an empty slot when no section is of type oidc", () => {
101
+ expect(bridge({ config: { federations: { google: { enabled: false } } } })).toEqual({});
102
+ });
103
+ });
104
+
105
+ describe("buildModules gating for OIDC federations (#524)", () => {
106
+ it("lists one federation:oidc:<name> per enabled section, plus the bridge once", () => {
107
+ const names = buildModules(
108
+ configWith({
109
+ okta: { enabled: true, type: "oidc", ...okta },
110
+ keycloak: { enabled: true, type: "oidc", ...okta },
111
+ google: { enabled: false },
112
+ }),
113
+ ).map((m) => m.name);
114
+ expect(names).toContain("federation:oidc:okta");
115
+ expect(names).toContain("federation:oidc:keycloak");
116
+ expect(names.filter((n) => n === "standalone:oidc-federation-config")).toHaveLength(1);
117
+ expect(names).not.toContain("federation:google");
118
+ });
119
+
120
+ it("a google section of type oidc is the generic provider, not the built-in Google pair", () => {
121
+ // The section's `type` names the implementation. Composing both would
122
+ // contribute the same federation and redirect-policy keys twice.
123
+ const names = buildModules(
124
+ configWith({ google: { enabled: true, type: "oidc", ...okta } }),
125
+ ).map((m) => m.name);
126
+ expect(names).toContain("federation:oidc:google");
127
+ expect(names).not.toContain("federation:google");
128
+ expect(names).not.toContain("standalone:google-federation-config");
129
+ });
130
+
131
+ it("lists nothing OIDC when no section is enabled (the shipped default)", () => {
132
+ const names = buildModules(
133
+ configWith({ google: { enabled: false }, oidc: { enabled: false, type: "oidc" } }),
134
+ ).map((m) => m.name);
135
+ expect(names.some((n) => n.startsWith("federation:oidc:"))).toBe(false);
136
+ expect(names).not.toContain("standalone:oidc-federation-config");
137
+ });
138
+ });
@@ -60,6 +60,7 @@ import {
60
60
  defineModule,
61
61
  InMemoryClientRepository,
62
62
  InMemoryUserRepository,
63
+ memoryRefreshTokenFamilyStoreModule,
63
64
  registerBuiltinKeyStores,
64
65
  replicaUnsafeReason,
65
66
  } from "@o3co/auth-provider-core";
@@ -159,8 +160,12 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
159
160
  RATE_LIMITER_ADAPTER: "redis",
160
161
  OAUTH_CODE_ADAPTER: "redis",
161
162
  ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
163
+ REPLAY_SEEN_SET_ADAPTER: "redis",
162
164
  FEDERATION_TOKEN_STORE_TYPE: "redis",
163
165
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
166
+ // #561: the consent step for clients that are not first-party, on the
167
+ // shared store — the one switch value `multi` accepts besides `none`.
168
+ CONSENT_STORE_ADAPTER: "redis",
164
169
  };
165
170
 
166
171
  function resolveConfig(env: Record<string, string>): AppConfig {
@@ -240,10 +245,14 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
240
245
  // #474: express-session's own store. Not a module of this template but
241
246
  // built here from its config, which is what lets the manifest declare.
242
247
  ["SESSION_STORAGE_TYPE", "sessionStoreModule"],
248
+ // #527: the consent store, wired only when the switch says so.
249
+ ["CONSENT_STORE_ADAPTER", "core-consent-store-memory"],
243
250
  // Core's, selected by the same kind of switch. The guard knew these by
244
251
  // name; pinned so the name table's departure (#455) did not lose them.
245
252
  ["RATE_LIMITER_ADAPTER", "core-rate-limiter-memory"],
246
253
  ["ACCESS_TOKEN_DENYLIST_ADAPTER", "core-access-token-denylist-memory"],
254
+ // #484: the jti single-use record behind private_key_jwt client auth.
255
+ ["REPLAY_SEEN_SET_ADAPTER", "core-replay-seen-set-memory"],
247
256
  ];
248
257
 
249
258
  for (const [variable, module] of cases) {
@@ -266,6 +275,8 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
266
275
  SESSION_STORAGE_TYPE: "memory",
267
276
  RATE_LIMITER_ADAPTER: "memory",
268
277
  ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
278
+ REPLAY_SEEN_SET_ADAPTER: "memory",
279
+ CONSENT_STORE_ADAPTER: "memory",
269
280
  });
270
281
  await expect(boot(config)).rejects.toMatchObject({
271
282
  reason: "replica-unsafe-adapter",
@@ -390,6 +401,60 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
390
401
  });
391
402
  });
392
403
 
404
+ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
405
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
406
+
407
+ afterEach(async () => {
408
+ await handleRef?.dispose();
409
+ handleRef = undefined;
410
+ });
411
+
412
+ it("selects the Redis consent module, providing both slots, and not the memory one", () => {
413
+ const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
414
+ const names = modules.map((m) => m.name);
415
+ expect(names).toContain("redis-consent-store");
416
+ expect(names).not.toContain("core-consent-store-memory");
417
+ // Exactly one provider for each slot: the consent step refuses a
418
+ // composition with one and not the other, and two would collide.
419
+ for (const slot of ["consentStore", "pendingConsentStore"]) {
420
+ const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes(slot));
421
+ expect(
422
+ providers.map((m) => m.name),
423
+ slot,
424
+ ).toEqual(["redis-consent-store"]);
425
+ }
426
+ });
427
+
428
+ it("pulls the shared clients module in for the consent stores alone", () => {
429
+ // Every other store on memory, single replica: the consent module still
430
+ // needs its two client slots, which only the shared clients module
431
+ // provides — the #439 shape otherwise.
432
+ const config = resolveConfig({
433
+ ...ALL_REDIS_ENV,
434
+ DEPLOYMENT_MODE: "single",
435
+ USER_SESSION_STORES_ADAPTER: "memory",
436
+ OAUTH_CODE_ADAPTER: "memory",
437
+ RATE_LIMITER_ADAPTER: "memory",
438
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
439
+ REPLAY_SEEN_SET_ADAPTER: "memory",
440
+ FEDERATION_TOKEN_STORE_TYPE: "memory",
441
+ });
442
+ const names = buildModules(config, {
443
+ keyStoreModule: testKeyStoreModule,
444
+ repositoriesModule: testRepositoriesModule,
445
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
446
+ }).map((m) => m.name);
447
+ expect(names).toContain("redis-consent-store");
448
+ expect(names).toContain("standalone:redis-clients");
449
+ });
450
+
451
+ it("boots under DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
452
+ handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
453
+ expect(handleRef.components.consentStore?.kind).toBe("redis");
454
+ expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
455
+ });
456
+ });
457
+
393
458
  describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
394
459
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
395
460
  let warnSpy: ReturnType<typeof vi.spyOn>;
@@ -244,3 +244,171 @@ describe("#407 — the two READMEs agree on security advice", () => {
244
244
  expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
245
245
  });
246
246
  });
247
+
248
+ /**
249
+ * Issue #512 — the suite that ships with a scaffolded project has to be green
250
+ * in that project, not only in this workspace.
251
+ *
252
+ * A scaffold installs `@o3co/auth-provider-*` from npm, where the packages sit
253
+ * under `node_modules` and vitest externalizes them: Node loads them natively
254
+ * and their own `import "ioredis"` / `import "redis"` never meet the
255
+ * `vi.mock` registry. In this workspace the same packages are symlinks to
256
+ * source outside `node_modules`, vitest inlines them, and the mocks apply —
257
+ * which is how replica-safety.test.mts stayed green here while dialling
258
+ * `redis.test` for real in every scaffold. The second half is `make test`:
259
+ * it runs this suite inside the `test` image, where a file the Dockerfile
260
+ * never copied simply does not exist.
261
+ */
262
+ describe("#512 — the shipped suite is green outside this repository", () => {
263
+ it("runs the published packages through vitest, so module mocks reach them", async () => {
264
+ // The config is loaded, not grepped: what matters is the value vitest
265
+ // resolves, and a `server.deps.inline` naming the wrong package would
266
+ // satisfy a substring check just as well.
267
+ const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
268
+ const { default: config } = (await import(configPath)) as {
269
+ default: {
270
+ test?: { server?: { deps?: { inline?: ReadonlyArray<string | RegExp> | true } } };
271
+ };
272
+ };
273
+ const inline = config.test?.server?.deps?.inline;
274
+ expect(inline).toBeDefined();
275
+ if (inline === true) return;
276
+ for (const name of [
277
+ "@o3co/auth-provider-core",
278
+ "@o3co/auth-provider-redis",
279
+ "@o3co/auth-provider-session",
280
+ ]) {
281
+ expect(
282
+ (inline ?? []).some((m) => (m instanceof RegExp ? m.test(name) : m === name)),
283
+ `${name} is not inlined`,
284
+ ).toBe(true);
285
+ }
286
+ });
287
+
288
+ /**
289
+ * Every file this suite reads from the project root. `config/` and `src/`
290
+ * reach the image through the builder stage; these do not, so the `test`
291
+ * stage has to bring them — and `.dockerignore` has to let them into the
292
+ * build context first.
293
+ */
294
+ const READ_FROM_THE_PROJECT_ROOT = [
295
+ "Dockerfile",
296
+ ".dockerignore",
297
+ ".env.example",
298
+ ".gitignore",
299
+ "README.md",
300
+ "README.ja.md",
301
+ "docker-compose.yml",
302
+ "docker-compose.production.yml",
303
+ ] as const;
304
+
305
+ it("reads only files the template actually ships", () => {
306
+ for (const file of READ_FROM_THE_PROJECT_ROOT) {
307
+ expect(existsSync(`${standaloneDir}${file}`), file).toBe(true);
308
+ }
309
+ });
310
+
311
+ it("copies each of them into the image the `test` target runs", () => {
312
+ const copied = copiedIntoStage(read("/Dockerfile"), "test");
313
+ for (const file of READ_FROM_THE_PROJECT_ROOT) {
314
+ expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
315
+ }
316
+ });
317
+
318
+ it("lets each of them through .dockerignore", () => {
319
+ const dockerignore = read("/.dockerignore");
320
+ for (const file of READ_FROM_THE_PROJECT_ROOT) {
321
+ expect(
322
+ dockerignoreExcludes(dockerignore, file),
323
+ `${file} is excluded from the build context`,
324
+ ).toBe(false);
325
+ }
326
+ });
327
+
328
+ it("ships every setup file vitest.config.mts loads, into the `test` image as well (#556)", async () => {
329
+ // vitest refuses to start when a `setupFiles` entry is missing, so a setup
330
+ // file the image never copied fails the whole of `make test`, not one test.
331
+ const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
332
+ const { default: config } = (await import(configPath)) as {
333
+ default: { test?: { setupFiles?: string | readonly string[] } };
334
+ };
335
+ const setupFiles = [config.test?.setupFiles ?? []].flat();
336
+ expect(setupFiles, "the #556 supertest loopback guard is not wired in").not.toHaveLength(0);
337
+
338
+ const copied = copiedIntoStage(read("/Dockerfile"), "test");
339
+ const dockerignore = read("/.dockerignore");
340
+ for (const entry of setupFiles) {
341
+ const file = entry.replace(/^\.\//, "");
342
+ expect(existsSync(`${standaloneDir}${file}`), `${file} does not exist`).toBe(true);
343
+ expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
344
+ expect(
345
+ dockerignoreExcludes(dockerignore, file),
346
+ `${file} is excluded from the build context`,
347
+ ).toBe(false);
348
+ }
349
+ });
350
+ });
351
+
352
+ /**
353
+ * The build-context sources every `COPY` in `stage` and its ancestors brings
354
+ * into the image. `COPY --from=` is skipped — it reads another stage, not
355
+ * the context — and `--chown` / other flags are dropped. Sources are
356
+ * normalised to the bare entry name (`./config/` → `config`).
357
+ */
358
+ function copiedIntoStage(dockerfile: string, stage: string): Set<string> {
359
+ const stages = new Map<string, { parent: string; sources: string[] }>();
360
+ let current: { parent: string; sources: string[] } | undefined;
361
+ // Continuation lines are joined first, so a multi-line instruction reads
362
+ // as one.
363
+ for (const line of dockerfile.replace(/\\\n/g, " ").split("\n")) {
364
+ const text = line.trim();
365
+ if (text === "" || text.startsWith("#")) continue;
366
+ const from = /^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i.exec(text);
367
+ if (from) {
368
+ const parent = from[1] as string;
369
+ current = { parent, sources: [] };
370
+ stages.set(from[2] ?? parent, current);
371
+ continue;
372
+ }
373
+ if (!current || !/^COPY\b/i.test(text)) continue;
374
+ const args = text.split(/\s+/).slice(1);
375
+ if (args.some((a) => a.startsWith("--from="))) continue;
376
+ const operands = args.filter((a) => !a.startsWith("--"));
377
+ for (const source of operands.slice(0, -1)) {
378
+ current.sources.push(source.replace(/^\.\//, "").replace(/\/$/, ""));
379
+ }
380
+ }
381
+ const copied = new Set<string>();
382
+ for (let name: string | undefined = stage; name !== undefined; ) {
383
+ const s = stages.get(name);
384
+ if (!s) break;
385
+ for (const source of s.sources) copied.add(source);
386
+ name = s.parent;
387
+ }
388
+ return copied;
389
+ }
390
+
391
+ /**
392
+ * Whether `.dockerignore` keeps a root-level entry out of the build context.
393
+ * The last matching pattern wins and a leading `!` re-includes; `*` and `?`
394
+ * stop at a `/` while `**` does not — the subset of Docker's matching that
395
+ * root-level file names exercise.
396
+ */
397
+ function dockerignoreExcludes(dockerignore: string, file: string): boolean {
398
+ let excluded = false;
399
+ for (const raw of dockerignore.split("\n")) {
400
+ const line = raw.trim();
401
+ if (line === "" || line.startsWith("#")) continue;
402
+ const negated = line.startsWith("!");
403
+ const pattern = negated ? line.slice(1) : line;
404
+ // `**` is split out first so the single-`*` rewrite cannot see it.
405
+ const segment = (s: string): string =>
406
+ s
407
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
408
+ .replace(/\*/g, "[^/]*")
409
+ .replace(/\?/g, "[^/]");
410
+ const regex = new RegExp(`^${pattern.split("**").map(segment).join(".*")}$`);
411
+ if (regex.test(file)) excluded = !negated;
412
+ }
413
+ return excluded;
414
+ }
@@ -251,7 +251,7 @@ describe("standalone smoke test", () => {
251
251
  it("Dockerfile pins its mutable build inputs (#289)", () => {
252
252
  // Base image by digest — the tag alone is a moving pointer, and
253
253
  // Dependabot's docker ecosystem bumps tag and digest together.
254
- expect(dockerfile).toMatch(/FROM node:24-alpine@sha256:[0-9a-f]{64} AS node-base/);
254
+ expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
255
255
  // Global corepack by version — `npm install -g corepack` with no pin
256
256
  // resolved whatever was latest at build time.
257
257
  expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
@@ -486,6 +486,7 @@ describe("standalone smoke test", () => {
486
486
  userSessionStores: { adapter: "redis" as const },
487
487
  rateLimiter: { adapter: "redis" as const },
488
488
  accessTokenDenylist: { adapter: "redis" as const },
489
+ consentStore: { adapter: "redis" as const },
489
490
  oauth: { ...config.oauth, code: { adapter: "redis" as const } },
490
491
  };
491
492
  // No `refreshTokenFamilyModules` override: the default is the Redis