@o3co/create-auth-provider 0.13.0 → 0.15.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.
Files changed (27) hide show
  1. package/package.json +2 -2
  2. package/templates/standalone/.env.example +27 -0
  3. package/templates/standalone/Dockerfile +1 -1
  4. package/templates/standalone/README.ja.md +80 -2
  5. package/templates/standalone/README.md +160 -4
  6. package/templates/standalone/config/application.conf +56 -1
  7. package/templates/standalone/docker-compose.production.yml +7 -0
  8. package/templates/standalone/docker-compose.yml +7 -0
  9. package/templates/standalone/package.json +4 -3
  10. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
  11. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +62 -3
  12. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
  13. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
  14. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
  15. package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
  16. package/templates/standalone/src/__tests__/replica-safety.test.mts +58 -0
  17. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +37 -0
  18. package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
  19. package/templates/standalone/src/__tests__/smoke.test.mts +1 -0
  20. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
  21. package/templates/standalone/src/app.mts +9 -1
  22. package/templates/standalone/src/buildModules.mts +110 -6
  23. package/templates/standalone/src/modules.mts +96 -6
  24. package/templates/standalone/src/shutdown.mts +70 -4
  25. package/templates/standalone/vitest.config.mts +6 -0
  26. package/templates/standalone/vitest.supertest-loopback.mts +250 -0
  27. package/templates/versions.json +15 -14
@@ -33,7 +33,13 @@
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 { deferExit, installGracefulShutdown } from "../shutdown.mjs";
36
+ import {
37
+ cleanupAllowanceFor,
38
+ deferExit,
39
+ FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
40
+ FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
41
+ installGracefulShutdown,
42
+ } from "../shutdown.mjs";
37
43
 
38
44
  /** A `Server` double whose `close` callback fires only when we say so. */
39
45
  function makeServer() {
@@ -80,7 +86,13 @@ const makeLogger = () => {
80
86
  };
81
87
 
82
88
  /** Drive one shutdown without touching the real `process` or exiting. */
83
- function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?: number } = {}) {
89
+ function install(
90
+ opts: {
91
+ cleanup?: () => void | Promise<void>;
92
+ drainTimeoutMs?: number;
93
+ cleanupTimeoutMs?: number;
94
+ } = {},
95
+ ) {
84
96
  const { server, spies, finishDraining, failClose } = makeServer();
85
97
  const logger = makeLogger();
86
98
  const exit = vi.fn();
@@ -90,6 +102,7 @@ function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?:
90
102
  logger,
91
103
  cleanup: opts.cleanup ?? (() => {}),
92
104
  ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
105
+ ...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
93
106
  exit,
94
107
  onSignal: (name, handler) => signals.set(name, handler),
95
108
  offSignal: (name) => signals.delete(name),
@@ -322,3 +335,84 @@ describe("installGracefulShutdown (#290)", () => {
322
335
  expect(signals.size).toBe(0);
323
336
  });
324
337
  });
338
+
339
+ describe("#593 slice 7: the cleanup allowance federation grants need", () => {
340
+ it("is at least the 45 seconds the package asks for, and only when the feature is on", () => {
341
+ // The package's drain waits for a rotated credential's write; the
342
+ // default cleanup budget is the ten-second drain, which is shorter than
343
+ // the upstream hard timeout and persist budget the feature ships with —
344
+ // a shutdown under it abandons exactly the write the drain exists for.
345
+ expect(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS).toBeGreaterThanOrEqual(45_000);
346
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: true } })).toEqual({
347
+ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
348
+ });
349
+ // Off, nothing: the cleanup budget stays the drain's, as it was.
350
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: false } })).toEqual({});
351
+ expect(cleanupAllowanceFor({})).toEqual({});
352
+ });
353
+
354
+ it("grows with the configured refresh tail, so a raised budget is not cut off by a fixed timer (Copilot, #614)", () => {
355
+ // The longest tail one refresh has: the upstream hard timeout, the
356
+ // persist budget and the wait for the lock, back to back, plus an exit
357
+ // margin. The shipped budgets (25 s + 3 s + 5 s) land exactly on the
358
+ // floor; a deployment that doubles its upstream timeout gets more.
359
+ const shipped = {
360
+ upstreamHardTimeoutMs: 25_000,
361
+ persistRetryBudgetMs: 3_000,
362
+ lockWaitMs: 5_000,
363
+ };
364
+ expect(
365
+ 25_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
366
+ "the margin is what makes the shipped budgets the floor",
367
+ ).toBe(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS);
368
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: true, ...shipped } })).toEqual({
369
+ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
370
+ });
371
+ expect(
372
+ cleanupAllowanceFor({
373
+ federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 60_000 },
374
+ }),
375
+ ).toEqual({ cleanupTimeoutMs: 60_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS });
376
+ // Lowered budgets never go below the documented minimum, and a config
377
+ // without budgets (built by hand) gets the floor rather than a guess.
378
+ expect(
379
+ cleanupAllowanceFor({
380
+ federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 1_000 },
381
+ }),
382
+ ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
383
+ expect(
384
+ cleanupAllowanceFor({ federationGrants: { enabled: true, upstreamHardTimeoutMs: 90_000 } }),
385
+ ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
386
+ });
387
+
388
+ it("never asks a timer for more than Node can count: an oversized sum is capped, not overflowed (Copilot, #614)", () => {
389
+ // setTimeout takes a 32-bit signed delay; past it the timer fires after
390
+ // about a millisecond, which would turn a generous allowance into none.
391
+ const oversized = cleanupAllowanceFor({
392
+ federationGrants: {
393
+ enabled: true,
394
+ upstreamHardTimeoutMs: 2_000_000_000,
395
+ persistRetryBudgetMs: 2_000_000_000,
396
+ lockWaitMs: 5_000,
397
+ },
398
+ });
399
+ expect(oversized).toEqual({ cleanupTimeoutMs: 2_147_483_647 });
400
+ });
401
+
402
+ it("is honoured by the shutdown: a cleanup that needs longer than the drain is given it", async () => {
403
+ vi.useFakeTimers();
404
+ try {
405
+ const { signals, finishDraining, exit } = install({
406
+ cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
407
+ drainTimeoutMs: 5_000,
408
+ ...cleanupAllowanceFor({ federationGrants: { enabled: true } }),
409
+ });
410
+ signals.get("SIGTERM")?.();
411
+ finishDraining();
412
+ await vi.advanceTimersByTimeAsync(31_000);
413
+ expect(exit).toHaveBeenCalledExactlyOnceWith(0);
414
+ } finally {
415
+ vi.useRealTimers();
416
+ }
417
+ });
418
+ });
@@ -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
@@ -0,0 +1,125 @@
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
+ * #556 — the server `request(app)` starts listens on the address supertest
19
+ * dials, in this project's own test run.
20
+ *
21
+ * Unpatched, supertest starts its server with `app.listen(0)` — the
22
+ * dual-stack wildcard `[::]:P` — and sends the request to `127.0.0.1:P`. On
23
+ * macOS the kernel can hand out a `P` another process already holds as
24
+ * `127.0.0.1:P`; the request then reaches that process, and if it never
25
+ * answers the test hangs until its timeout. `vitest.supertest-loopback.mts`
26
+ * (wired in through `setupFiles` in `vitest.config.mts`) binds the server to
27
+ * `127.0.0.1` instead. These tests fail if that wiring is lost.
28
+ */
29
+
30
+ import http from "node:http";
31
+ import net, { type AddressInfo } from "node:net";
32
+ import express from "express";
33
+ import request from "supertest";
34
+ import { describe, expect, it } from "vitest";
35
+
36
+ function helloApp() {
37
+ const app = express();
38
+ app.get("/hello", (_req, res) => {
39
+ res.status(200).send("hello");
40
+ });
41
+ return app;
42
+ }
43
+
44
+ /** Resolves with the address `server` binds, whoever calls `listen`. */
45
+ function boundAddress(server: net.Server): Promise<AddressInfo> {
46
+ return new Promise((resolve) => {
47
+ server.once("listening", () => resolve(server.address() as AddressInfo));
48
+ });
49
+ }
50
+
51
+ describe("#556 — supertest's own server listens on the loopback address it dials", () => {
52
+ it("binds 127.0.0.1, not the dual-stack wildcard", async () => {
53
+ const server = http.createServer(helloApp());
54
+ const bound = boundAddress(server);
55
+
56
+ const res = await request(server).get("/hello");
57
+
58
+ expect(res.status).toBe(200);
59
+ expect(await bound).toMatchObject({ address: "127.0.0.1", family: "IPv4" });
60
+ // supertest still closes the server it started.
61
+ expect(server.listening).toBe(false);
62
+ });
63
+
64
+ it("rejects promptly with the request's own error when the request cannot be built", async () => {
65
+ // Node refuses this header value (ERR_INVALID_CHAR) while supertest builds
66
+ // the request. With the loopback bind that happens after the bind settles,
67
+ // outside the promise the test awaits; the throw must still reject that
68
+ // promise, as it does unpatched, and not become an unhandled rejection
69
+ // that leaves the request hanging until the test timeout.
70
+ const server = http.createServer(helloApp());
71
+
72
+ await expect(request(server).get("/hello").set("x-test", "bad\nvalue")).rejects.toMatchObject({
73
+ code: "ERR_INVALID_CHAR",
74
+ });
75
+ // The request never went out, so nothing would close the server it started.
76
+ expect(server.listening).toBe(false);
77
+ }, 5_000);
78
+
79
+ it("hands the same error to an .end() callback", async () => {
80
+ const server = http.createServer(helloApp());
81
+
82
+ const err = await new Promise<unknown>((resolve) => {
83
+ request(server)
84
+ .get("/hello")
85
+ .set("x-test", "bad\nvalue")
86
+ .end((error) => resolve(error));
87
+ });
88
+
89
+ expect(err).toMatchObject({ code: "ERR_INVALID_CHAR" });
90
+ }, 5_000);
91
+
92
+ it("serves requests an agent sends together over its one server", async () => {
93
+ const agent = request.agent(helloApp());
94
+
95
+ const responses = await Promise.all([agent.get("/hello"), agent.get("/hello")]);
96
+
97
+ expect(responses.map((res) => res.status)).toEqual([200, 200]);
98
+ });
99
+
100
+ it("fails the request, instead of hanging on another socket, when 127.0.0.1:P is taken", async () => {
101
+ // The collision the kernel produces by chance, produced on purpose: a
102
+ // socket that accepts and never answers holds 127.0.0.1:P, and the
103
+ // server supertest starts is steered onto P.
104
+ const held: net.Socket[] = [];
105
+ const squatter = net.createServer((socket) => {
106
+ held.push(socket);
107
+ });
108
+ await new Promise<void>((resolve) => squatter.listen(0, "127.0.0.1", resolve));
109
+ const { port } = squatter.address() as AddressInfo;
110
+
111
+ const server = http.createServer(helloApp());
112
+ const listen = server.listen.bind(server) as (...args: unknown[]) => net.Server;
113
+ (server as { listen: (...args: unknown[]) => net.Server }).listen = (_port, ...rest) =>
114
+ listen(port, ...rest);
115
+
116
+ try {
117
+ await expect(request(server).get("/hello")).rejects.toMatchObject({
118
+ code: "EADDRINUSE",
119
+ });
120
+ } finally {
121
+ for (const socket of held) socket.destroy();
122
+ squatter.close();
123
+ }
124
+ }, 5_000);
125
+ });
@@ -29,7 +29,7 @@ import { buildModules } from "./buildModules.mjs";
29
29
  import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
30
30
  import { createAppLogger } from "./logger.mjs";
31
31
  import { createMetrics } from "./metrics.mjs";
32
- import { installGracefulShutdown } from "./shutdown.mjs";
32
+ import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.mjs";
33
33
  import { createTerminalErrorHandler } from "./terminalError.mjs";
34
34
 
35
35
  // Step 1: Load and validate application config (HOCON → Zod schema).
@@ -164,8 +164,16 @@ await (async (): Promise<void> => {
164
164
  // from the code you deploy. See `shutdown.mts` for the stated guarantees
165
165
  // and for the deadline the previous implementation did not have. Size
166
166
  // `drainTimeoutMs` below your orchestrator's kill grace period.
167
+ //
168
+ // #593 slice 7: with federation grants on, cleanup gets the longest refresh
169
+ // tail the configured budgets allow plus a margin — 45 s under the shipped
170
+ // ones — rather than inheriting the drain's ten: the dispose is what waits
171
+ // for a rotated credential's write. The compose files size
172
+ // `stop_grace_period` to cover the drain, this and an exit margin for the
173
+ // shipped budgets; a raised budget raises both.
167
174
  installGracefulShutdown(server, {
168
175
  logger,
169
176
  cleanup: () => handle.dispose(),
177
+ ...cleanupAllowanceFor(config),
170
178
  });
171
179
  })();
@@ -21,19 +21,26 @@ import {
21
21
  type Module,
22
22
  memoryAccessTokenDenylistModule,
23
23
  memoryConsentStoreModule,
24
+ memoryFederationGrantIntentStoreModule,
25
+ memoryFederationGrantStoreModule,
24
26
  memoryRateLimiterModule,
25
27
  memoryReplaySeenSetModule,
26
28
  } from "@o3co/auth-provider-core";
27
29
  import { googleFederationModule } from "@o3co/auth-provider-federation-google";
30
+ import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
28
31
  import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
29
32
  import {
30
33
  oauthAuthorizationModule,
31
34
  oauthModule,
32
35
  oauthSessionModule,
36
+ subjectRevocationServiceModule,
33
37
  } from "@o3co/auth-provider-oauth";
34
38
  import {
35
39
  redisAccessTokenDenylistModule,
36
40
  redisCodeRepositoryModule,
41
+ redisConsentStoreModule,
42
+ redisFederationGrantIntentStoreModule,
43
+ redisFederationGrantStoreModuleFor,
37
44
  redisFederationTokenStoreModuleFor,
38
45
  redisRateLimiterModule,
39
46
  redisRefreshTokenFamilyStoreModule,
@@ -102,6 +109,15 @@ export interface BuildModulesOverrides {
102
109
  readonly auditSinkModule?: Module;
103
110
  }
104
111
 
112
+ /**
113
+ * The access-token lifetime core's `reference.conf` ships on the deprecated
114
+ * `oauth.accessToken.expiresIn`. Not a default — nothing mints with it — only
115
+ * what an unmodified deployment carries there, so the deprecation line can tell
116
+ * an operator's override from the shipped value. Pinned against the real file
117
+ * by `access-token-lifetime-alias.test.mts`.
118
+ */
119
+ const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
120
+
105
121
  /**
106
122
  * Compose the standalone v0.5.0 module list from `config`. Splitting this
107
123
  * out of `app.mts` keeps the composition root testable: a smoke test can
@@ -156,6 +172,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
156
172
  // first-party. `"none"` (the default) wires nothing — such clients are
157
173
  // refused, as before — so a first-party-only deployment pays nothing and
158
174
  // a multi-replica one is not handed a memory store it cannot run.
175
+ // `"redis"` (#561) is the store such a deployment selects.
159
176
  const consentStoreAdapter = config.consentStore?.adapter ?? "none";
160
177
  // #456: adapter switch for the federation token store. `"memory"` by
161
178
  // default, the template's local-dev shape; `"redis"` mounts
@@ -163,6 +180,22 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
163
180
  // promised and what the previous factory-based module never delivered: it
164
181
  // built the Redis store without a client and failed at boot.
165
182
  const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
183
+ // #593 slice 7: federation grants — a user's standing consent that a client
184
+ // may obtain upstream tokens with no session behind the call. Off by
185
+ // default, and off installs nothing: no store, no socket, no boot
186
+ // requirement a deployment that never asked for grants would have to meet.
187
+ // On, the routes and the background registry the shutdown drains come as a
188
+ // pair, each store follows its own switch (grants in Redis with acquisition
189
+ // in memory is a supported single-replica shape), and the subject-revocation
190
+ // service is installed so a credential change can reach the grants — its
191
+ // `federationGrantStore` edge is what makes "revoke everything this subject
192
+ // holds" include them. Enabling the feature is also a statement that the
193
+ // deployment has a consent page, a callback per connection and a user
194
+ // repository that covers each connection's registration; the routes module
195
+ // refuses at boot what is missing, naming it.
196
+ const federationGrantsEnabled = config.federationGrants?.enabled === true;
197
+ const federationGrantStoreAdapter = config.federationGrantStore?.adapter ?? "memory";
198
+ const federationGrantIntentStoreAdapter = config.federationGrantIntentStore?.adapter ?? "memory";
166
199
 
167
200
  // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
168
201
  // authoritative switch; the legacy `repositories.code.type = "redis"`
@@ -184,6 +217,26 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
184
217
  codeRepositoryAdapter = "memory";
185
218
  }
186
219
 
220
+ // The access-token lifetime's deprecated alias: `oauth.accessToken.expiresIn`
221
+ // (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
222
+ // `defaultExpiresIn` is unset — `resolveAccessTokenLifetime` does that, and
223
+ // this only says so. Core's `reference.conf` keeps the shipped lifetime on
224
+ // the deprecated key, so "the alias supplied the default" is every
225
+ // deployment that set nothing; an override of it is the one case with
226
+ // something to move, the reading `legacyCodeType === "redis"` gives above.
227
+ // See CHANGELOG for the removal version.
228
+ const accessToken = config.oauth.accessToken;
229
+ if (
230
+ accessToken.defaultExpiresIn === undefined &&
231
+ accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
232
+ ) {
233
+ console.warn(
234
+ "[buildModules] `oauth.accessToken.expiresIn` (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is " +
235
+ "deprecated; use `oauth.accessToken.defaultExpiresIn` " +
236
+ "(OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN) instead — see CHANGELOG for the removal version.",
237
+ );
238
+ }
239
+
187
240
  const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
188
241
  redisRefreshTokenFamilyStoreModule,
189
242
  ];
@@ -202,7 +255,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
202
255
  codeRepositoryAdapter === "redis" ||
203
256
  accessTokenDenylistAdapter === "redis" ||
204
257
  replaySeenSetAdapter === "redis" ||
205
- federationTokenStoreAdapter === "redis";
258
+ consentStoreAdapter === "redis" ||
259
+ federationTokenStoreAdapter === "redis" ||
260
+ // Only while the feature is on: a switch left at "redis" for a feature
261
+ // that is off must not open a socket.
262
+ (federationGrantsEnabled &&
263
+ (federationGrantStoreAdapter === "redis" || federationGrantIntentStoreAdapter === "redis"));
206
264
 
207
265
  // The four user-session stores switch on `userSessionStores.adapter`; the
208
266
  // federation-token store is always wired and switches on its own key
@@ -237,10 +295,17 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
237
295
  ? [redisAccessTokenDenylistModule]
238
296
  : [memoryAccessTokenDenylistModule];
239
297
 
240
- // #527: opt-in, memory only for now. The module declares itself
241
- // replica-unsafe, so `deployment.mode = "multi"` refuses it by name.
298
+ // #527: opt-in. Each module provides both slots the consent step needs —
299
+ // the consent records and the requests parked while the page asks — so the
300
+ // two cannot be wired apart. The memory one declares itself replica-unsafe
301
+ // and `deployment.mode = "multi"` refuses it by name; the Redis one (#561)
302
+ // shares both over the ioredis socket.
242
303
  const consentStoreModules: Module[] =
243
- consentStoreAdapter === "memory" ? [memoryConsentStoreModule] : [];
304
+ consentStoreAdapter === "redis"
305
+ ? [redisConsentStoreModule]
306
+ : consentStoreAdapter === "memory"
307
+ ? [memoryConsentStoreModule]
308
+ : [];
244
309
 
245
310
  // #455 / #456: mutually-exclusive federation-token-store pair, the same
246
311
  // shape as the session stores. One module per adapter, so the memory one
@@ -260,6 +325,29 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
260
325
  ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
261
326
  : [inMemoryFederationTokenStoreModule];
262
327
 
328
+ // #593 slice 7: one module per store, nothing while the feature is off.
329
+ // The Redis grant store is built for this composition root so its
330
+ // plaintext guard knows which environment selected the config (#473), as
331
+ // the federation-token store's is. Both memory modules declare
332
+ // `replicaSafety`, so `deployment.mode = "multi"` refuses them by name; a
333
+ // Redis grant store beside memory user-session stores is refused by the
334
+ // routes module itself, because the grants would outlive the boundary that
335
+ // ends them (D13).
336
+ const federationGrantStoreModules: Module[] = !federationGrantsEnabled
337
+ ? []
338
+ : federationGrantStoreAdapter === "redis"
339
+ ? [
340
+ overrides.environment === undefined
341
+ ? redisFederationGrantStoreModuleFor()
342
+ : redisFederationGrantStoreModuleFor({ environment: overrides.environment }),
343
+ ]
344
+ : [memoryFederationGrantStoreModule];
345
+ const federationGrantIntentStoreModules: Module[] = !federationGrantsEnabled
346
+ ? []
347
+ : federationGrantIntentStoreAdapter === "redis"
348
+ ? [redisFederationGrantIntentStoreModule]
349
+ : [memoryFederationGrantIntentStoreModule];
350
+
263
351
  return [
264
352
  // D-5: sessionStoreModule wires the express-session middleware into the
265
353
  // boot-planner-managed lifecycle. **Mount order is enforced by this
@@ -271,6 +359,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
271
359
  // `session.storage.type = "memory"` declares itself replica-unsafe and
272
360
  // `deployment.mode = "multi"` refuses it by name like the other stores.
273
361
  sessionStoreModuleFor(config),
362
+ // #593 slice 7: before `oauthModule`, whose router mounts under the same
363
+ // `/oauth` prefix with body parsers of its own — mounted after it, a
364
+ // malformed body would be refused by the OAuth router's parser without
365
+ // this package's request id, `Cache-Control: no-store` or throttle (the
366
+ // package README's mounting-order rule). The browser half sits after the
367
+ // session middleware by its own `after`, wherever it is listed.
368
+ ...(federationGrantsEnabled ? federationGrantsModules : []),
274
369
  oauthModule({ config }),
275
370
  oauthSessionModule({ config }),
276
371
  oauthAuthorizationModule({ config }),
@@ -304,6 +399,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
304
399
  // bundled into `storesModule` pre-Wave-5d, and the redis session-stores
305
400
  // branch dropped the provider.
306
401
  ...federationTokenStoreModules,
402
+ // #593 slice 7: the grant store and the intent store, when the feature is on.
403
+ ...federationGrantStoreModules,
404
+ ...federationGrantIntentStoreModules,
307
405
  // User-session-store family: redis (multi-replica) or memory (dev).
308
406
  ...sessionStoresModules,
309
407
  // OAuth-endpoint rate limiter: redis (shared counters) or memory.
@@ -314,13 +412,19 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
314
412
  // (single-instance dev). Always wired — see the switch above.
315
413
  ...accessTokenDenylistModules,
316
414
  ...replaySeenSetModules,
317
- // Consent records for clients that are not first-party: memory, or
318
- // nothing (#527).
415
+ // Consent records for clients that are not first-party: redis
416
+ // (multi-replica, #561), memory (single-instance), or nothing (#527).
319
417
  ...consentStoreModules,
320
418
  // RT family store: redis by default (closes OR-1); override path
321
419
  // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
322
420
  ...refreshTokenFamilyModules,
323
421
  defaultRefreshTokenFamilyRotationModule,
324
422
  defaultRefreshTokenFamilyRevocationModule,
423
+ // #593 slice 7: the composed "end everything this subject holds" a
424
+ // credential change calls, reached as `handle.components.subjectRevocationService`.
425
+ // Installed with the feature, which is what makes it reach the grants;
426
+ // the template never installed it before, so a deployment without
427
+ // grants is as it was — a Store there calls core's revokeAllForSubject.
428
+ ...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
325
429
  ];
326
430
  }
@@ -39,7 +39,11 @@ import {
39
39
  import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
40
40
  import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
41
41
  import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
42
- import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
42
+ import {
43
+ makeIoredisClients,
44
+ makeIoredisFederationGrantIntentStoreClient,
45
+ makeIoredisFederationGrantStoreClient,
46
+ } from "@o3co/auth-provider-redis/ioredis";
43
47
  import { extractFederationSection } from "@o3co/auth-provider-session";
44
48
  // Named import is required here, not default. ioredis is CJS and its entry
45
49
  // does `module.exports = Redis` with the class re-exported as both `default`
@@ -361,11 +365,12 @@ export const storesModule: Module = defineModule({
361
365
 
362
366
  /**
363
367
  * Shared ioredis clients module — opens ONE long-lived ioredis connection
364
- * per replica, wraps it via `makeIoredisClients()` (returns 13 typed
368
+ * per replica, wraps it via `makeIoredisClients()` (returns 16 typed
365
369
  * per-purpose clients), and exposes the slots consumed by the standalone's
366
370
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
367
371
  * subject-level revocation index + watermark + rate limiter + code
368
- * repository + access-token denylist).
372
+ * repository + access-token denylist + replay seen-set + federation token
373
+ * store + device-code store + the consent stores).
369
374
  *
370
375
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
371
376
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -437,6 +442,28 @@ export const standaloneRedisClientsModule: Module = defineModule({
437
442
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
438
443
  .sessionFederationIndexClient;
439
444
  },
445
+ // #593 slice 7: the grant store and the intent store, off the same
446
+ // socket. Consumed by the Redis grant modules when their switches say
447
+ // "redis"; provided whenever this module is installed, as every slot
448
+ // here is, because a slot is cheap and the socket is the cost.
449
+ federationGrantStoreClient: async ({
450
+ config,
451
+ lifecycleRegistrar,
452
+ readinessRegistrar,
453
+ logger,
454
+ }) => {
455
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
456
+ .federationGrantStoreClient;
457
+ },
458
+ federationGrantIntentStoreClient: async ({
459
+ config,
460
+ lifecycleRegistrar,
461
+ readinessRegistrar,
462
+ logger,
463
+ }) => {
464
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
465
+ .federationGrantIntentStoreClient;
466
+ },
440
467
  // #484: the replay seen-set behind private_key_jwt client assertions.
441
468
  replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
442
469
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
@@ -504,6 +531,23 @@ export const standaloneRedisClientsModule: Module = defineModule({
504
531
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
505
532
  .deviceCodeStoreClient;
506
533
  },
534
+ // #561: the consent stores' clients, off the same shared socket.
535
+ // `redisConsentStoreModule` requires both — it provides the consent
536
+ // records and the parked requests together — and `buildModules` selects
537
+ // it under `consentStore.adapter = "redis"`.
538
+ consentStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
539
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
540
+ .consentStoreClient;
541
+ },
542
+ pendingConsentStoreClient: async ({
543
+ config,
544
+ lifecycleRegistrar,
545
+ readinessRegistrar,
546
+ logger,
547
+ }) => {
548
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
549
+ .pendingConsentStoreClient;
550
+ },
507
551
  // #456: the federation-token-store client, off the same shared socket.
508
552
  // `redisFederationTokenStoreModule` requires it, and nothing provided
509
553
  // it — which is why the Redis branch of the federation store could not
@@ -612,13 +656,26 @@ const SHARED_REDIS_TIMEOUTS = {
612
656
  // on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
613
657
  // that don't seed it), each call creates a fresh client; tests are
614
658
  // isolated and don't need cross-slot sharing.
615
- const clientsCache = new WeakMap<LifecycleRegistrar, ReturnType<typeof makeIoredisClients>>();
659
+ /**
660
+ * Every per-purpose client off the one socket: the general bundle, plus the
661
+ * two the federation-grant stores need (#593 slice 7). Those two have their
662
+ * own factories in the Redis package, so a Cluster deployment can give the
663
+ * grants a connection of their own; this template shares the socket, as it
664
+ * does for every other store.
665
+ */
666
+ type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
667
+ readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
668
+ readonly federationGrantIntentStoreClient: ReturnType<
669
+ typeof makeIoredisFederationGrantIntentStoreClient
670
+ >;
671
+ };
672
+ const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
616
673
  function getOrCreateClients(
617
674
  config: AppConfig,
618
675
  lifecycleRegistrar: LifecycleRegistrar | undefined,
619
676
  readinessRegistrar?: ReadinessRegistrar,
620
677
  injectedLogger?: Logger,
621
- ): ReturnType<typeof makeIoredisClients> {
678
+ ): StandaloneRedisClients {
622
679
  const logger = injectedLogger ?? consoleLogger;
623
680
  const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
624
681
  if (cached) return cached;
@@ -658,7 +715,11 @@ function getOrCreateClients(
658
715
  // The wrapper opens its own connections for refresh rotation
659
716
  // (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
660
717
  // `io`; passing the logger lets those report through the same channel.
661
- const clients = makeIoredisClients(io, { logger });
718
+ const clients: StandaloneRedisClients = {
719
+ ...makeIoredisClients(io, { logger }),
720
+ federationGrantStoreClient: makeIoredisFederationGrantStoreClient(io),
721
+ federationGrantIntentStoreClient: makeIoredisFederationGrantIntentStoreClient(io),
722
+ };
662
723
  if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
663
724
  return clients;
664
725
  }
@@ -685,6 +746,33 @@ function optionalString(
685
746
  return { [field]: value };
686
747
  }
687
748
 
749
+ /**
750
+ * An optional boolean from the `federations.google` slice. HOCON gives a
751
+ * boolean; an environment override (`${?VAR}`) gives the string, in the
752
+ * spellings core's `coerceBooleanFromEnv` accepts: "true" / "false" / "1" /
753
+ * "0", trimmed and in any case. One deliberate difference: that coercion reads
754
+ * an empty value as false, and this refuses it. HOCON substitutes an
755
+ * exported-but-empty variable as "", and for a security switch that must not
756
+ * quietly turn the check off. Anything else is refused too — a typo that
757
+ * silently reads as either value is worse than a boot failure.
758
+ */
759
+ function optionalBoolean(
760
+ slice: Record<string, unknown>,
761
+ field: string,
762
+ ): Record<string, boolean> | Record<string, never> {
763
+ const value = slice[field];
764
+ if (value === undefined || value === null) return {};
765
+ if (typeof value === "boolean") return { [field]: value };
766
+ if (typeof value === "string") {
767
+ const normalized = value.trim().toLowerCase();
768
+ if (normalized === "true" || normalized === "1") return { [field]: true };
769
+ if (normalized === "false" || normalized === "0") return { [field]: false };
770
+ }
771
+ throw new Error(
772
+ `federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
773
+ );
774
+ }
775
+
688
776
  /**
689
777
  * Google federation config bridge — supplies the typed `googleFederationConfig`
690
778
  * ComponentMap slot from the `config.federations.google` slice.
@@ -751,6 +839,8 @@ export const googleFederationConfigModule: Module = defineModule({
751
839
  ...optionalString(slice, "sessionDomain"),
752
840
  ...optionalString(slice, "authCallbackUrl"),
753
841
  ...optionalString(slice, "clientUrl"),
842
+ // #597: absent means the provider's default, which is to require it.
843
+ ...optionalBoolean(slice, "requireAuthorizationResponseIss"),
754
844
  };
755
845
  },
756
846
  },