@o3co/create-auth-provider 0.11.0 → 0.12.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.
@@ -0,0 +1,288 @@
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
+ /**
18
+ * `POST /session/logout` must invalidate the token the `session` grant minted
19
+ * from the same browser session.
20
+ *
21
+ * This lives here rather than in `packages/session` or `packages/oauth`
22
+ * because it is the one assertion neither package can make alone: the logout
23
+ * endpoint is in `@o3co/auth-provider-session`, the introspection and userinfo
24
+ * endpoints are in `@o3co/auth-provider-oauth`, and the two are siblings that
25
+ * may not import each other. The standalone template is the composition root
26
+ * where both are mounted on one app, so it is where the contract between them
27
+ * is testable.
28
+ *
29
+ * What it pins: #506 stamped `sid` on the `session` grant's access token and
30
+ * gave `/oauth/introspect` the session-liveness check `/oauth/userinfo`
31
+ * already ran. Both resolve the `UserSession` record — which `/session/logout`
32
+ * used to leave alive, because it destroyed the cookie and nothing else. So in
33
+ * the BFF / `auth.proxy` injection topology, whose logout IS this endpoint, an
34
+ * access token minted from the session kept introspecting `active: true` and
35
+ * kept answering at `/userinfo` for its full lifetime after the user logged
36
+ * out. Only `/oauth/logout` with an `id_token_hint` was ever closed by #506.
37
+ */
38
+
39
+ import { generateKeyPairSync } from "node:crypto";
40
+ import {
41
+ type AppConfig,
42
+ createApp,
43
+ createKeyStoreFactory,
44
+ createReadinessRouter,
45
+ defineModule,
46
+ InMemoryClientRepository,
47
+ InMemoryUserRepository,
48
+ memoryRefreshTokenFamilyStoreModule,
49
+ registerBuiltinKeyStores,
50
+ } from "@o3co/auth-provider-core";
51
+ import express from "express";
52
+ import request from "supertest";
53
+ import { afterEach, describe, expect, it } from "vitest";
54
+ import { buildModules } from "../buildModules.mjs";
55
+
56
+ const keyPair = generateKeyPairSync("ed25519", {
57
+ publicKeyEncoding: { type: "spki", format: "pem" },
58
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
59
+ });
60
+
61
+ const CLIENT_ID = "bff-client";
62
+ const CLIENT_SECRET = "bff-secret";
63
+ const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
64
+ const USERNAME = "alice";
65
+ const PASSWORD = "correct-horse-battery-staple";
66
+
67
+ const config: AppConfig = {
68
+ http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
69
+ logging: { level: "silent" },
70
+ oauth: {
71
+ jwt: {
72
+ issuer: "https://auth.test",
73
+ signingKey: {
74
+ provider: "local",
75
+ local: {
76
+ algorithm: "EdDSA",
77
+ kid: "v0",
78
+ privateKey: keyPair.privateKey,
79
+ publicKey: keyPair.publicKey,
80
+ previousKeys: [],
81
+ },
82
+ },
83
+ },
84
+ accessToken: { expiresIn: 3600 },
85
+ refreshToken: {
86
+ expiresIn: 86400,
87
+ unknownFamilyPolicy: "reject" as const,
88
+ legacyRtPolicy: "reject" as const,
89
+ },
90
+ // The grant this whole test is about: it mints straight from an
91
+ // authenticated browser session, which is the BFF topology.
92
+ grants: { session: { enabled: true } },
93
+ oidcMode: "oidc-required",
94
+ code: { adapter: "memory" as const },
95
+ },
96
+ session: {
97
+ secret: "test-session-secret.at-least-32-bytes.ok",
98
+ name: "auth.sid",
99
+ maxAge: 3600000,
100
+ secure: false,
101
+ sameSite: "lax",
102
+ domain: null,
103
+ storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
104
+ },
105
+ rateLimit: {
106
+ login: { windowMs: 60000, limit: 100 },
107
+ failMode: "open",
108
+ },
109
+ federations: { google: { enabled: false } },
110
+ repositories: {
111
+ client: { type: "yaml", path: "./config/clients.yaml" },
112
+ user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
113
+ code: { type: "memory", defaultExpiresIn: 600 },
114
+ },
115
+ endpoints: { login: { url: "/login" } },
116
+ cors: { allowedOrigins: [] },
117
+ } as unknown as AppConfig;
118
+
119
+ const testRepositoriesModule = defineModule({
120
+ name: "test:repositories",
121
+ provides: {
122
+ clientRepository: () =>
123
+ new InMemoryClientRepository(
124
+ new Map([
125
+ [
126
+ CLIENT_ID,
127
+ {
128
+ tokenEndpointAuthMethod: "client_secret_basic",
129
+ clientSecret: CLIENT_SECRET,
130
+ allowedRedirectUris: [],
131
+ allowedScopes: ["openid", "profile"],
132
+ allowedAudiences: [],
133
+ allowedGrantTypes: ["session"],
134
+ backchannelLogoutSessionRequired: true,
135
+ frontchannelLogoutSessionRequired: true,
136
+ allowedAzpForFederationToken: false,
137
+ },
138
+ ],
139
+ ]),
140
+ ),
141
+ userRepository: () =>
142
+ new InMemoryUserRepository(
143
+ new Map([[USERNAME, { id: "u-alice", password: PASSWORD, email: "alice@example.com" }]]),
144
+ ),
145
+ } as never,
146
+ });
147
+
148
+ const testKeyStoreModule = defineModule({
149
+ name: "test:key-store",
150
+ requires: ["config"] as const,
151
+ provides: {
152
+ keyStore: async ({ config: c }) => {
153
+ const factory = createKeyStoreFactory();
154
+ registerBuiltinKeyStores(factory);
155
+ return factory.create({
156
+ type: "local",
157
+ ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
158
+ });
159
+ },
160
+ },
161
+ });
162
+
163
+ describe("POST /session/logout invalidates the session grant's access token", () => {
164
+ let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
165
+
166
+ afterEach(async () => {
167
+ await handleRef?.dispose();
168
+ handleRef = undefined;
169
+ });
170
+
171
+ async function buildApp() {
172
+ const handle = await createApp({
173
+ modules: buildModules(config, {
174
+ keyStoreModule: testKeyStoreModule,
175
+ repositoriesModule: testRepositoriesModule,
176
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
177
+ }),
178
+ bootstrapComponents: { config, pathResolver: (s) => s },
179
+ });
180
+ handleRef = handle;
181
+
182
+ const app = express();
183
+ app.use(
184
+ createReadinessRouter(express, {
185
+ probes: handle.readinessProbes,
186
+ timeoutMs: config.http.readinessTimeoutMs,
187
+ }),
188
+ );
189
+ app.use(handle.router);
190
+ return app;
191
+ }
192
+
193
+ /**
194
+ * Drives the real browser half: fetch a CSRF pair, log in, and return the
195
+ * cookies plus the token the login response reissued. `/session/login`
196
+ * hands back a fresh CSRF cookie precisely so the follow-up logout needs no
197
+ * second round trip.
198
+ */
199
+ async function login(app: express.Express) {
200
+ const csrfRes = await request(app).get("/session/csrf");
201
+ expect(csrfRes.status).toBe(200);
202
+ const csrfToken = csrfRes.body.csrf_token as string;
203
+ const headerName = csrfRes.body.header_name as string;
204
+ const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
205
+
206
+ const loginRes = await request(app)
207
+ .post("/session/login")
208
+ .set("Cookie", csrfCookies)
209
+ .set(headerName, csrfToken)
210
+ .type("form")
211
+ .send({ username: USERNAME, password: PASSWORD });
212
+ expect(loginRes.status).toBe(200);
213
+
214
+ const loginCookies = loginRes.headers["set-cookie"] as unknown as string[];
215
+ // The session cookie comes from the login response, and so does a FRESH
216
+ // CSRF cookie — the login handler reissues one on the regenerated
217
+ // session so the follow-up logout needs no second round trip. The
218
+ // mechanism is a signed double-submit, so the cookie's value IS the
219
+ // token the header has to echo; the pre-login token no longer matches.
220
+ const csrfCookiePrefix = `${config.session.name}.csrf=`;
221
+ const reissued = loginCookies
222
+ .find((c) => c.startsWith(csrfCookiePrefix))
223
+ ?.slice(csrfCookiePrefix.length)
224
+ .split(";")[0];
225
+ expect(reissued).toBeDefined();
226
+ return {
227
+ cookies: loginCookies,
228
+ csrfToken: decodeURIComponent(reissued as string),
229
+ headerName,
230
+ };
231
+ }
232
+
233
+ it("a token minted from the session stops introspecting active after the session logs out", async () => {
234
+ const app = await buildApp();
235
+ const { cookies, csrfToken, headerName } = await login(app);
236
+
237
+ // The BFF exchanges the browser session for an access token.
238
+ const tokenRes = await request(app)
239
+ .post("/oauth/token")
240
+ .set("Authorization", BASIC)
241
+ .set("Cookie", cookies)
242
+ .type("form")
243
+ .send({ grant_type: "session", scope: "openid profile" });
244
+ expect(tokenRes.status).toBe(200);
245
+ const accessToken = tokenRes.body.access_token as string;
246
+ expect(typeof accessToken).toBe("string");
247
+
248
+ // Before the logout the token is a live credential at both surfaces.
249
+ const beforeIntrospect = await request(app)
250
+ .post("/oauth/introspect")
251
+ .set("Authorization", BASIC)
252
+ .type("form")
253
+ .send({ token: accessToken });
254
+ expect(beforeIntrospect.status).toBe(200);
255
+ expect(beforeIntrospect.body.active).toBe(true);
256
+
257
+ const beforeUserinfo = await request(app)
258
+ .get("/oauth/userinfo")
259
+ .set("Authorization", `Bearer ${accessToken}`);
260
+ expect(beforeUserinfo.status).toBe(200);
261
+
262
+ // The logout the BFF topology actually performs — no id_token_hint, no
263
+ // `/oauth/logout`, just the session endpoint the browser calls.
264
+ const logoutRes = await request(app)
265
+ .post("/session/logout")
266
+ .set("Cookie", cookies)
267
+ .set(headerName, csrfToken);
268
+ expect(logoutRes.status).toBe(200);
269
+ expect(logoutRes.body).toMatchObject({ message: "Logged out successfully" });
270
+
271
+ // After it, the same token no longer authorizes anything. This is the
272
+ // assertion that failed before the fix: the `UserSession` record the
273
+ // liveness checks resolve outlived the logout.
274
+ const afterIntrospect = await request(app)
275
+ .post("/oauth/introspect")
276
+ .set("Authorization", BASIC)
277
+ .type("form")
278
+ .send({ token: accessToken });
279
+ expect(afterIntrospect.status).toBe(200);
280
+ expect(afterIntrospect.body.active).toBe(false);
281
+
282
+ const afterUserinfo = await request(app)
283
+ .get("/oauth/userinfo")
284
+ .set("Authorization", `Bearer ${accessToken}`);
285
+ expect(afterUserinfo.status).toBe(401);
286
+ expect(afterUserinfo.body.error_description).toBe("session_invalid");
287
+ });
288
+ });
@@ -33,7 +33,7 @@
33
33
  import type { Server } from "node:http";
34
34
  import type { Logger } from "@o3co/auth-provider-core";
35
35
  import { describe, expect, it, vi } from "vitest";
36
- import { installGracefulShutdown } from "../shutdown.mjs";
36
+ import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
37
37
 
38
38
  /** A `Server` double whose `close` callback fires only when we say so. */
39
39
  function makeServer() {
@@ -234,6 +234,86 @@ describe("installGracefulShutdown (#290)", () => {
234
234
  expect(cleanup).toHaveBeenCalledTimes(1);
235
235
  });
236
236
 
237
+ it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
238
+ // The guarantees above say cleanup "never wedges the process", but `finish`
239
+ // awaited it with no deadline — and the drain deadline is already cleared
240
+ // by then, so a dispose that never settles meant `exit` was never reached.
241
+ // The same defect was found in auth.proxy#81 and auth.policy-verifier#210,
242
+ // both of which took this file as their starting point.
243
+ vi.useFakeTimers();
244
+ try {
245
+ const { signals, finishDraining, exit, logger } = install({
246
+ cleanup: () => new Promise<void>(() => {}),
247
+ drainTimeoutMs: 5_000,
248
+ });
249
+ signals.get("SIGTERM")?.();
250
+ finishDraining();
251
+ await vi.advanceTimersByTimeAsync(5_000);
252
+ expect(logger.error).toHaveBeenCalledWith(
253
+ expect.objectContaining({ cleanupTimeoutMs: 5_000 }),
254
+ expect.stringContaining("cleanup timed out"),
255
+ );
256
+ expect(exit).toHaveBeenCalledWith(1);
257
+ } finally {
258
+ vi.useRealTimers();
259
+ }
260
+ });
261
+
262
+ it("does not penalise a cleanup that finishes inside its budget", async () => {
263
+ vi.useFakeTimers();
264
+ try {
265
+ const { signals, finishDraining, exit } = install({
266
+ cleanup: () => Promise.resolve(),
267
+ drainTimeoutMs: 5_000,
268
+ });
269
+ signals.get("SIGTERM")?.();
270
+ finishDraining();
271
+ await vi.advanceTimersByTimeAsync(10_000);
272
+ expect(exit).toHaveBeenCalledExactlyOnceWith(0);
273
+ } finally {
274
+ vi.useRealTimers();
275
+ }
276
+ });
277
+
278
+ it("defers the real exit a turn so a buffered log destination can flush", async () => {
279
+ // The scaffolded app logs NDJSON through pino, whose default destination is
280
+ // not synchronous: exiting in the same tick as the last `logger.error` can
281
+ // drop exactly the `cleanup failed` line an operator would look for.
282
+ const exitProcess = vi.fn();
283
+ deferExit(3, exitProcess);
284
+ expect(exitProcess).not.toHaveBeenCalled();
285
+ await new Promise((resolve) => setImmediate(resolve));
286
+ expect(exitProcess).toHaveBeenCalledWith(3);
287
+ });
288
+
289
+ it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
290
+ // `exitCode` became 1 while `reason` still said "drained", so the one line
291
+ // an operator alerts on contradicted itself. The drain outcome is still
292
+ // carried, under its own key, so neither fact is lost.
293
+ const { signals, finishDraining, logger, exit } = install({
294
+ cleanup: () => Promise.reject(new Error("teardown failed")),
295
+ });
296
+ signals.get("SIGTERM")?.();
297
+ finishDraining();
298
+ await new Promise((resolve) => setImmediate(resolve));
299
+ expect(logger.info).toHaveBeenCalledWith(
300
+ { reason: "cleanup-failed", drain: "drained", exitCode: 1 },
301
+ "graceful shutdown: complete",
302
+ );
303
+ expect(exit).toHaveBeenCalledWith(1);
304
+ });
305
+
306
+ it("keeps reason and drain identical when cleanup succeeds", async () => {
307
+ const { signals, finishDraining, logger } = install({ cleanup: () => Promise.resolve() });
308
+ signals.get("SIGTERM")?.();
309
+ finishDraining();
310
+ await new Promise((resolve) => setImmediate(resolve));
311
+ expect(logger.info).toHaveBeenCalledWith(
312
+ { reason: "drained", drain: "drained", exitCode: 0 },
313
+ "graceful shutdown: complete",
314
+ );
315
+ });
316
+
237
317
  it("removes its own signal listeners once shutting down", async () => {
238
318
  // Otherwise a repeated signal keeps re-entering a handler that has
239
319
  // already handed the process over to `close`.
@@ -193,18 +193,23 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
193
193
  // every in-memory shared store, so the denylist this change adds has to
194
194
  // come out of that environment as the Redis one — from the template's
195
195
  // own config, since the compose file names no denylist variable.
196
- // Asserted against the guard's own list rather than a module name, so a
197
- // rename cannot quietly invalidate it.
198
- const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
196
+ // Asked of each manifest, the way the guard does (#455), so a rename
197
+ // cannot quietly invalidate it and the template's own memory modules
198
+ // — which no name list in core covers — count too.
199
+ const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
199
200
  const config = buildResolvedConfig("production", {
200
201
  USER_SESSION_STORES_ADAPTER: "redis",
201
202
  RATE_LIMITER_ADAPTER: "redis",
202
203
  OAUTH_CODE_ADAPTER: "redis",
204
+ // #456: the federation token store defaults to memory, and the
205
+ // memory module declares itself replica-unsafe (#455).
206
+ FEDERATION_TOKEN_STORE_TYPE: "redis",
207
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
203
208
  });
204
- const names = buildModules(config).map((m) => m.name);
205
- expect(names).toContain("redis-access-token-denylist");
206
- for (const name of names) {
207
- expect(REPLICA_UNSAFE_MODULES).not.toContain(name);
209
+ const modules = buildModules(config);
210
+ expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
211
+ for (const module of modules) {
212
+ expect(replicaUnsafeReason(module), module.name).toBeUndefined();
208
213
  }
209
214
  });
210
215
 
@@ -99,8 +99,12 @@ await (async (): Promise<void> => {
99
99
  // `buildModules` is the single source of truth for the module list — it
100
100
  // gates federation modules on `config.federations.<name>.enabled`, which
101
101
  // the standalone scaffold defaults to false.
102
+ //
103
+ // `environment` is the name the config above was selected by (#473): the
104
+ // Redis federation-token store's plaintext guard reads it in addition to
105
+ // NODE_ENV, so `CONFIG_ENV=production` is production to the guard too.
102
106
  const handle = await createApp({
103
- modules: buildModules(config),
107
+ modules: buildModules(config, { environment: env }),
104
108
  bootstrapComponents: {
105
109
  config,
106
110
  pathResolver: import.meta.resolve,
@@ -31,16 +31,17 @@ import {
31
31
  import {
32
32
  redisAccessTokenDenylistModule,
33
33
  redisCodeRepositoryModule,
34
+ redisFederationTokenStoreModuleFor,
34
35
  redisRateLimiterModule,
35
36
  redisRefreshTokenFamilyStoreModule,
36
37
  redisSessionStoresModule,
37
38
  } from "@o3co/auth-provider-redis";
38
- import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
39
+ import { sessionModule, sessionStoreModuleFor } from "@o3co/auth-provider-session";
39
40
  import {
40
41
  auditSinkModule,
41
- federationTokenStoreModule,
42
42
  googleFederationConfigModule,
43
43
  inMemoryCodeRepositoryModule,
44
+ inMemoryFederationTokenStoreModule,
44
45
  inMemorySessionStoresModule,
45
46
  keyStoreModule,
46
47
  repositoriesModule,
@@ -48,11 +49,21 @@ import {
48
49
  } from "./modules.mjs";
49
50
 
50
51
  /**
51
- * Test-only overrides allowing the smoke test to substitute in-memory
52
- * implementations of the file-system-backed modules. Production callers
53
- * should not pass overrides; the defaults match the standalone scaffold.
52
+ * Overrides for the composition. All but `environment` are test-only: they let
53
+ * the smoke test substitute in-memory implementations of the file-system-backed
54
+ * modules, and production callers should not pass them — the defaults match the
55
+ * standalone scaffold.
54
56
  */
55
57
  export interface BuildModulesOverrides {
58
+ /**
59
+ * #473: the name this deployment selected its configuration by —
60
+ * `CONFIG_ENV || NODE_ENV`, computed once in `app.mts` and passed here so
61
+ * the Redis federation-token store's `allow-plaintext` guard reads the
62
+ * environment the config actually came from, not `NODE_ENV` alone. Omitted,
63
+ * the guard falls back to `NODE_ENV` (and `deployment.mode`, which it reads
64
+ * off the config either way).
65
+ */
66
+ readonly environment?: string;
56
67
  readonly keyStoreModule?: Module;
57
68
  readonly repositoriesModule?: Module;
58
69
  readonly storesModule?: Module;
@@ -117,6 +128,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
117
128
  // endpoint would answer 200 while the token kept working. The switch here is
118
129
  // only over WHICH denylist.
119
130
  const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
131
+ // #456: adapter switch for the federation token store. `"memory"` by
132
+ // default, the template's local-dev shape; `"redis"` mounts
133
+ // `redisFederationTokenStoreModule` off the shared socket — what the README
134
+ // promised and what the previous factory-based module never delivered: it
135
+ // built the Redis store without a client and failed at boot.
136
+ const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
120
137
 
121
138
  // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
122
139
  // authoritative switch; the legacy `repositories.code.type = "redis"`
@@ -154,12 +171,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
154
171
  rateLimiterAdapter === "redis" ||
155
172
  userSessionStoresAdapter === "redis" ||
156
173
  codeRepositoryAdapter === "redis" ||
157
- accessTokenDenylistAdapter === "redis";
174
+ accessTokenDenylistAdapter === "redis" ||
175
+ federationTokenStoreAdapter === "redis";
158
176
 
159
- // Federation-token store is always wired; only the 4 user-session
160
- // stores switch on the adapter. Pre-Wave-5d the redis branch dropped
161
- // the federation-token-store provider entirely (Copilot review on
162
- // PR #121); splitting `storesModule` fixes that boot failure.
177
+ // The four user-session stores switch on `userSessionStores.adapter`; the
178
+ // federation-token store is always wired and switches on its own key
179
+ // below (#456). Pre-Wave-5d the redis branch dropped the
180
+ // federation-token-store provider entirely (Copilot review on PR #121);
181
+ // splitting `storesModule` fixed that boot failure.
163
182
  const sessionStoresModules: Module[] =
164
183
  userSessionStoresAdapter === "redis"
165
184
  ? [redisSessionStoresModule]
@@ -188,13 +207,32 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
188
207
  ? [redisAccessTokenDenylistModule]
189
208
  : [memoryAccessTokenDenylistModule];
190
209
 
210
+ // #455 / #456: mutually-exclusive federation-token-store pair, the same
211
+ // shape as the session stores. One module per adapter, so the memory one
212
+ // can declare `replicaSafety` on its manifest and the Redis one can
213
+ // `require` its client slot. The previous single module chose the adapter
214
+ // at runtime under one name — the replica guard could not tell the two
215
+ // apart, and the Redis branch had no client to hand the builder.
216
+ //
217
+ // #473: the Redis module is built for this composition root so its
218
+ // plaintext guard knows which environment selected the config; it reads
219
+ // `deployment.mode` off the config itself.
220
+ const federationTokenStoreModules: Module[] =
221
+ federationTokenStoreAdapter === "redis"
222
+ ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
223
+ : [inMemoryFederationTokenStoreModule];
224
+
191
225
  return [
192
226
  // D-5: sessionStoreModule wires the express-session middleware into the
193
227
  // boot-planner-managed lifecycle. **Mount order is enforced by this
194
228
  // list position (declarationIndex tie-breaking)** — the module
195
229
  // intentionally has no `before`/`after` clause, so it MUST be listed
196
230
  // ahead of every session-consuming module here. Do not reorder.
197
- sessionStoreModule,
231
+ //
232
+ // #474: built from `config` rather than the static manifest, so that
233
+ // `session.storage.type = "memory"` declares itself replica-unsafe and
234
+ // `deployment.mode = "multi"` refuses it by name like the other stores.
235
+ sessionStoreModuleFor(config),
198
236
  oauthModule({ config }),
199
237
  oauthSessionModule({ config }),
200
238
  oauthAuthorizationModule({ config }),
@@ -220,11 +258,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
220
258
  // actually needs Redis. Memory-only deployments skip this so they
221
259
  // don't open an unused socket.
222
260
  ...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
223
- // Federation-token store — always wired; independent of the
224
- // `userSessionStores.adapter` switch. Was bundled into `storesModule`
225
- // pre-Wave-5d; split so the redis session-stores branch doesn't
226
- // drop the provider.
227
- federationTokenStoreModule,
261
+ // Federation-token store: redis (multi-replica) or memory (dev). Always
262
+ // wired, independent of the `userSessionStores.adapter` switch — it was
263
+ // bundled into `storesModule` pre-Wave-5d, and the redis session-stores
264
+ // branch dropped the provider.
265
+ ...federationTokenStoreModules,
228
266
  // User-session-store family: redis (multi-replica) or memory (dev).
229
267
  ...sessionStoresModules,
230
268
  // OAuth-endpoint rate limiter: redis (shared counters) or memory.