@o3co/create-auth-provider 0.9.0 → 0.11.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 (37) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +110 -7
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +49 -5
  6. package/templates/standalone/Dockerfile +59 -16
  7. package/templates/standalone/README.ja.md +92 -13
  8. package/templates/standalone/README.md +358 -18
  9. package/templates/standalone/config/application.conf +132 -4
  10. package/templates/standalone/config/clients.yaml.example +56 -2
  11. package/templates/standalone/config/production.conf +9 -3
  12. package/templates/standalone/docker-compose.production.yml +132 -0
  13. package/templates/standalone/docker-compose.yml +7 -0
  14. package/templates/standalone/gitignore +21 -0
  15. package/templates/standalone/package.json +15 -20
  16. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
  18. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  19. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  20. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  21. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
  22. package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
  23. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  24. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  25. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  26. package/templates/standalone/src/app.mts +78 -10
  27. package/templates/standalone/src/buildModules.mts +43 -1
  28. package/templates/standalone/src/configPath.mts +2 -2
  29. package/templates/standalone/src/logger.mts +90 -2
  30. package/templates/standalone/src/metrics.mts +190 -0
  31. package/templates/standalone/src/modules.mts +319 -33
  32. package/templates/standalone/src/shutdown.mts +162 -0
  33. package/templates/standalone/src/terminalError.mts +60 -0
  34. package/templates/standalone/tests/index.test.js +69 -5
  35. package/templates/standalone/tsconfig.json +9 -1
  36. package/templates/standalone/vitest.config.mts +8 -0
  37. package/templates/versions.json +12 -11
@@ -16,16 +16,23 @@
16
16
  import path from "node:path";
17
17
  import {
18
18
  type AppConfig,
19
+ consoleLogger,
20
+ createAuditSinkFactory,
19
21
  createFederationTokenStoreFactory,
20
22
  createInMemorySessionFamilyIndex,
21
23
  createInMemorySessionFederationIndex,
22
24
  createInMemorySessionRPRegistry,
25
+ createInMemorySubjectRevocation,
26
+ createInMemorySubjectSessionIndex,
23
27
  createInMemoryUserSessionStore,
24
28
  createKeyStoreFactory,
25
29
  createRepositoryFactories,
26
30
  defineModule,
27
31
  type LifecycleRegistrar,
32
+ type Logger,
28
33
  type Module,
34
+ type ReadinessRegistrar,
35
+ registerBuiltinAuditSinks,
29
36
  registerBuiltinFederationTokenStores,
30
37
  registerBuiltinKeyStores,
31
38
  } from "@o3co/auth-provider-core";
@@ -34,16 +41,20 @@ import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
34
41
  import { redisFederationTokenStoreBuilder } from "@o3co/auth-provider-redis";
35
42
  import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
36
43
  import { extractFederationSection } from "@o3co/auth-provider-session";
37
- // Named import is required here, not default. Under `module: "nodenext"`
38
- // with esModuleInterop, the default import resolves to the entire ioredis
39
- // CJS module-exports namespace object (because ioredis 5.x ships
40
- // `export = Redis`), so `new Redis(...)` raises TS2351 "not constructable".
44
+ // Named import is required here, not default. ioredis is CJS and its entry
45
+ // does `module.exports = Redis` with the class re-exported as both `default`
46
+ // and `Redis`; under `module: "nodenext"` with esModuleInterop the default
47
+ // import therefore resolves to the module-exports namespace object rather
48
+ // than the class, and `new Redis(...)` raises TS2351 "not constructable".
49
+ // Still true on ioredis 6 — verified against 6.0.0, not inherited from the
50
+ // ioredis 5 era this note was first written in.
41
51
  // The 11 default-import sites elsewhere in the repo all live under
42
52
  // `packages/redis/__tests__/` which `packages/redis/tsconfig.json`
43
53
  // explicitly excludes from strict tsc build (vitest's vue-tsc is more
44
54
  // permissive about CJS interop). Standalone production source is built
45
55
  // with strict nodenext, so the named import is the right shape here.
46
56
  import { Redis } from "ioredis";
57
+ import { createAuditLogger, createLoggerAuditSink } from "./logger.mjs";
47
58
 
48
59
  /**
49
60
  * Helper: turn a v0.4.x { type, [type]: {...} } adapter-config slice into the
@@ -189,6 +200,20 @@ export const inMemorySessionStoresModule: Module = defineModule({
189
200
  sessionRPRegistry: () => createInMemorySessionRPRegistry(),
190
201
  sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
191
202
  sessionFederationIndex: () => createInMemorySessionFederationIndex(),
203
+ // #406: the two subject-level revocation slots. They were missing here
204
+ // on both branches, so a scaffolded deployment got
205
+ // `subjectRevocation: undefined` — `verifyJwt` skipped the watermark,
206
+ // the #376 refresh gate was inert, and `revokeAllForSubject` reported
207
+ // `unavailable`. Nothing failed and nothing warned, which is what #406
208
+ // is about; the boot guard now refuses that state, and the scaffold's
209
+ // answer is to wire them rather than to declare the capability absent.
210
+ //
211
+ // Single-process only, like every other store on this branch. The
212
+ // redis branch swaps in `redisSessionStoresModule`, which has provided
213
+ // both since #321 — before that there was no distributed adapter to
214
+ // point at, which is why this gap outlived #296.
215
+ subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
216
+ subjectRevocation: () => createInMemorySubjectRevocation(),
192
217
  },
193
218
  });
194
219
 
@@ -218,6 +243,66 @@ export const federationTokenStoreModule: Module = defineModule({
218
243
  },
219
244
  });
220
245
 
246
+ /**
247
+ * Audit-sink module — fills the `auditSink` slot every route that emits a
248
+ * security event reads (#287).
249
+ *
250
+ * The slot is `optional` on `oauthModule`, `sessionModule` and `webauthnModule`,
251
+ * and `emitAuditEvent` is a no-op when it is empty. That combination is why the
252
+ * gap was invisible: the scaffold wired no sink, so `token.issued.failure`,
253
+ * `authorize.rejected` / `authorize.granted`, `logout.cascade_failed` and the
254
+ * shared rate-limit guard's `rate_limit.unavailable` were all discarded by the
255
+ * deployable artifact, with nothing failing and nothing warning. This module is
256
+ * always in the manifest for that reason — there is no "no audit sink" branch,
257
+ * the way there is a memory/redis branch for the state stores.
258
+ *
259
+ * Two sink kinds are registered:
260
+ *
261
+ * - `"logger"` (the template's default) — one NDJSON line per event on stdout,
262
+ * in the same pino envelope as every other line this template emits, on a
263
+ * stream named `audit` whose level is fixed. See `createAuditLogger`.
264
+ * - `"console"` — core's built-in, from `registerBuiltinAuditSinks`: the bare
265
+ * event as one JSON object per line on stdout, with no log envelope. For a
266
+ * pipeline that wants the event and nothing else.
267
+ *
268
+ * A deployment with a real sink (SIEM, log pipeline, message bus) registers its
269
+ * builder here and names it in `audit.sink.type`; its options ride along in the
270
+ * same config block (`flattenAdapterConfig` unwraps `sink { type = "x", x { … } }`).
271
+ *
272
+ * One thing to know before writing such a builder: `createAuditSinkFactory()`
273
+ * takes no `BuilderContext`, so a sink builder receives `{}` and the
274
+ * `ctx.lifecycle?.register(…)` / `ctx.readiness?.register(…)` calls that
275
+ * CONTRIBUTING.md requires of a builder opening a connection are silent no-ops
276
+ * here. A sink holding a socket must own its cleanup another way until that
277
+ * factory takes a context.
278
+ *
279
+ * There is deliberately no `"none"`. An unknown type — including the `"none"`
280
+ * an operator might reasonably guess at — fails boot naming the sinks that
281
+ * exist, rather than producing a deployment with no audit trail (#304).
282
+ */
283
+ export const auditSinkModule: Module = defineModule({
284
+ name: "standalone:audit-sink",
285
+ requires: ["config"] as const,
286
+ provides: {
287
+ auditSink: async ({ config }) => {
288
+ const factory = createAuditSinkFactory();
289
+ registerBuiltinAuditSinks(factory);
290
+ factory.register("logger", () => createLoggerAuditSink(createAuditLogger()));
291
+ const slice = (config as AppConfig).audit?.sink;
292
+ // Absence lands on the default sink rather than on `undefined`: a
293
+ // config that says nothing about auditing is not a config asking for
294
+ // the events to be dropped. The literal default lives in HOCON
295
+ // (`reference.conf` / `application.conf`); this is the floor under a
296
+ // hand-built config that never met either.
297
+ return factory.create(
298
+ slice
299
+ ? flattenAdapterConfig(slice as { type: string } & Record<string, unknown>)
300
+ : { type: "logger" },
301
+ );
302
+ },
303
+ },
304
+ });
305
+
221
306
  /**
222
307
  * @deprecated since Wave 5d (F4 PR2): split into `inMemorySessionStoresModule`
223
308
  * + `federationTokenStoreModule`. Re-exported for backward compatibility
@@ -235,10 +320,11 @@ export const storesModule: Module = defineModule({
235
320
 
236
321
  /**
237
322
  * Shared ioredis clients module — opens ONE long-lived ioredis connection
238
- * per replica, wraps it via `makeIoredisClients()` (returns 9 typed
323
+ * per replica, wraps it via `makeIoredisClients()` (returns 13 typed
239
324
  * per-purpose clients), and exposes the slots consumed by the standalone's
240
325
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
241
- * rate limiter).
326
+ * subject-level revocation index + watermark + rate limiter + code
327
+ * repository + access-token denylist).
242
328
  *
243
329
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
244
330
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -273,26 +359,46 @@ export const storesModule: Module = defineModule({
273
359
  export const standaloneRedisClientsModule: Module = defineModule({
274
360
  name: "standalone:redis-clients",
275
361
  requires: ["config"] as const,
276
- optional: ["lifecycleRegistrar"] as const,
362
+ optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
277
363
  provides: {
278
- refreshTokenFamilyClient: async ({ config, lifecycleRegistrar }) => {
279
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).refreshTokenFamilyClient;
364
+ refreshTokenFamilyClient: async ({
365
+ config,
366
+ lifecycleRegistrar,
367
+ readinessRegistrar,
368
+ logger,
369
+ }) => {
370
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
371
+ .refreshTokenFamilyClient;
280
372
  },
281
- userSessionStoreClient: async ({ config, lifecycleRegistrar }) => {
282
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).userSessionStoreClient;
373
+ userSessionStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
374
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
375
+ .userSessionStoreClient;
283
376
  },
284
- sessionRPRegistryClient: async ({ config, lifecycleRegistrar }) => {
285
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).sessionRPRegistryClient;
377
+ sessionRPRegistryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
378
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
379
+ .sessionRPRegistryClient;
286
380
  },
287
- sessionFamilyIndexClient: async ({ config, lifecycleRegistrar }) => {
288
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).sessionFamilyIndexClient;
381
+ sessionFamilyIndexClient: async ({
382
+ config,
383
+ lifecycleRegistrar,
384
+ readinessRegistrar,
385
+ logger,
386
+ }) => {
387
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
388
+ .sessionFamilyIndexClient;
289
389
  },
290
- sessionFederationIndexClient: async ({ config, lifecycleRegistrar }) => {
291
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar)
390
+ sessionFederationIndexClient: async ({
391
+ config,
392
+ lifecycleRegistrar,
393
+ readinessRegistrar,
394
+ logger,
395
+ }) => {
396
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
292
397
  .sessionFederationIndexClient;
293
398
  },
294
- rateLimiterClient: async ({ config, lifecycleRegistrar }) => {
295
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).rateLimiterClient;
399
+ rateLimiterClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
400
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
401
+ .rateLimiterClient;
296
402
  },
297
403
  // OR-9: code-repository client wired off the same shared ioredis
298
404
  // socket. `redisCodeRepositoryModule` consumes this slot when
@@ -300,12 +406,126 @@ export const standaloneRedisClientsModule: Module = defineModule({
300
406
  // high-volume — sharing the connection avoids opening a separate
301
407
  // socket while the per-purpose typed wrapper keeps the Redis
302
408
  // command surface consumed by RedisCodeRepository explicit.
303
- codeRepositoryClient: async ({ config, lifecycleRegistrar }) => {
304
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar).codeRepositoryClient;
409
+ codeRepositoryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
410
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
411
+ .codeRepositoryClient;
412
+ },
413
+ // #277: access-token denylist client, off the same shared socket. A
414
+ // denylist is only worth having if every replica reads the same one —
415
+ // that is the entire reason `redisAccessTokenDenylistModule` exists — so
416
+ // it belongs on the connection the rest of the shared state already uses
417
+ // rather than opening a second one.
418
+ accessTokenDenylistClient: async ({
419
+ config,
420
+ lifecycleRegistrar,
421
+ readinessRegistrar,
422
+ logger,
423
+ }) => {
424
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
425
+ .accessTokenDenylistClient;
426
+ },
427
+ // #321: the two subject-level revocation client slots, off the same
428
+ // shared socket. `redisSessionStoresModule` requires both — the index
429
+ // enumerates what a credential change cascades over and the watermark
430
+ // refuses what the cascade missed — so without them the scaffold's
431
+ // Redis branch had no provider for the slots and stage-1 boot refused
432
+ // the manifest with `missing-required-component`. Nothing in the
433
+ // standalone consumes these slots directly; they exist so the module
434
+ // that does can be selected.
435
+ subjectSessionIndexClient: async ({
436
+ config,
437
+ lifecycleRegistrar,
438
+ readinessRegistrar,
439
+ logger,
440
+ }) => {
441
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
442
+ .subjectSessionIndexClient;
443
+ },
444
+ subjectRevocationClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
445
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
446
+ .subjectRevocationClient;
305
447
  },
306
448
  },
307
449
  });
308
450
 
451
+ /**
452
+ * Failure-timing options for the one shared ioredis socket (#286).
453
+ *
454
+ * On the driver's defaults this connection had no `commandTimeout` at all, so
455
+ * a partition did not produce errors — it produced *waiting*. Every `/token`
456
+ * request that touched Redis parked in the offline queue while the socket
457
+ * reconnected, and because nothing threw, the `rateLimit.failMode = "closed"`
458
+ * policy (`createRateLimitGuard`, OR-5) never engaged: it only fires when
459
+ * `limiter.check()` rejects. The load the policy exists to shed kept arriving
460
+ * and kept accumulating, and memory and sockets grew until ioredis's 20th
461
+ * reconnect attempt finally flushed the queue — tens of seconds later.
462
+ *
463
+ * The values below are chosen against the actual command surface of this
464
+ * socket, which is entirely O(1) primitives and small paged scans
465
+ * (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/`SSCAN`/`ZADD`/…).
466
+ * There is no blocking command here, so no legitimate command has any reason
467
+ * to take a meaningful fraction of a second.
468
+ *
469
+ * - `commandTimeout: 1000` — the hard ceiling on any single command. ioredis
470
+ * arms this timer in `sendCommand`, *before* the writability check, so it
471
+ * bounds a command that went into the offline queue exactly as it bounds one
472
+ * already on the wire. That is what makes the queued case fail rather than
473
+ * hang, and it is also the only guard that survives the nastiest partition
474
+ * shape: a zombie TCP connection where no `close` event ever fires, so the
475
+ * reconnect path described next is never entered at all.
476
+ * - `maxRetriesPerRequest: 3` — on the fourth reconnect attempt (roughly
477
+ * 0.4–1 s in on ioredis 6's jittered exponential backoff) the whole queue is
478
+ * failed in one go with `MaxRetriesPerRequestError`, rather than on the
479
+ * twentieth as the default has it. `commandTimeout` alone bounds each command's
480
+ * *latency*, but the queue would still grow to (request rate × 1 s) entries
481
+ * for as long as the outage lasted; this bounds its *depth*. Three rather
482
+ * than one so an ordinary sub-second reconnect blip is ridden out silently.
483
+ * - `connectTimeout: 5000` — halves the driver's 10 s default. A dropped SYN
484
+ * (a partition that black-holes packets rather than refusing them) is the
485
+ * only case that reaches this ceiling; a real connect on any deployment
486
+ * topology this template targets completes in well under a second.
487
+ * - `enableOfflineQueue: true` — the driver default, declared explicitly
488
+ * because it is a decision, not an inherited value. See below.
489
+ * - `lazyConnect: false` — also the driver default (still, on ioredis 6),
490
+ * declared so the boot-time-connect contract survives a future flip.
491
+ *
492
+ * **Why the offline queue stays on.** Turning it off is the sharper answer for
493
+ * the rate limiter specifically: a command issued while the socket is down
494
+ * rejects immediately instead of after `commandTimeout`, so the fail-closed
495
+ * policy sheds load at the first request rather than one second per request
496
+ * later. But `enableOfflineQueue` is a per-**connection** option, and this
497
+ * template runs every purpose off one socket — refresh-token families, the
498
+ * four user-session stores, the authorization-code repository, the
499
+ * access-token denylist and the rate limiter all come out of a single
500
+ * `makeIoredisClients(io)` call (pinned by "one connection in, one connection
501
+ * used" in `packages/redis/__tests__/ioredis.test.mts`). There is no way to
502
+ * express "off here, on there" without opening a second socket, which is the
503
+ * connection-pool pressure the shared-socket design was built to avoid.
504
+ *
505
+ * So the choice is one setting for all eleven purposes, and `false` is the
506
+ * wrong one to pick for all eleven: it converts every reconnect — including
507
+ * the sub-second blip of a routine managed-Redis failover, during which
508
+ * ioredis would have recovered transparently — into hard errors on session
509
+ * lookup, code redemption and refresh rotation. A failed refresh rotation is
510
+ * not a retryable blip to the end user; it is a re-login.
511
+ *
512
+ * `true` costs the rate limiter up to `commandTimeout` of delay before it
513
+ * starts shedding, and that is the honest trade. It is affordable precisely
514
+ * because `commandTimeout` exists: the pile-up is now bounded by
515
+ * (request rate × 1 s) and self-draining, rather than unbounded and growing.
516
+ * A deployment that wants the sharper behavior gives the rate limiter its own
517
+ * connection in its own composition root — the per-purpose client interfaces
518
+ * exist for exactly that, and it is a deliberate second socket rather than a
519
+ * silent global.
520
+ */
521
+ const SHARED_REDIS_TIMEOUTS = {
522
+ commandTimeout: 1_000,
523
+ connectTimeout: 5_000,
524
+ maxRetriesPerRequest: 3,
525
+ enableOfflineQueue: true,
526
+ lazyConnect: false,
527
+ } as const;
528
+
309
529
  // Module-scoped cache so each `provides.*` factory invocation reuses the
310
530
  // same `Redis` instance (and its `makeIoredisClients()` derivation) within
311
531
  // a single createApp() invocation. The boot planner calls each `provides`
@@ -324,7 +544,10 @@ const clientsCache = new WeakMap<LifecycleRegistrar, ReturnType<typeof makeIored
324
544
  function getOrCreateClients(
325
545
  config: AppConfig,
326
546
  lifecycleRegistrar: LifecycleRegistrar | undefined,
547
+ readinessRegistrar?: ReadinessRegistrar,
548
+ injectedLogger?: Logger,
327
549
  ): ReturnType<typeof makeIoredisClients> {
550
+ const logger = injectedLogger ?? consoleLogger;
328
551
  const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
329
552
  if (cached) return cached;
330
553
 
@@ -339,29 +562,57 @@ function getOrCreateClients(
339
562
  }
340
563
  const password = typeof cfg.password === "string" ? cfg.password : undefined;
341
564
 
342
- // `lazyConnect: false` is the ioredis 5.x default; the explicit option
343
- // documents the boot-time-connect contract so a future ioredis version
344
- // flip does not silently change the failure mode.
345
- const io = new Redis(cfg.url, { password, lazyConnect: false });
565
+ const io = new Redis(cfg.url, { password, ...SHARED_REDIS_TIMEOUTS });
346
566
 
347
- // Attach error handler so unhandled "error" events from ioredis 5.x do
348
- // not crash the Node.js process. Initial connection failures surface
349
- // here; downstream adapter operations then fail visibly. TODO(D-4):
350
- // swap `console.error` for the Logger interface once a v0.5.2 polish
351
- // PR lands.
567
+ // Attach an error handler so unhandled "error" events do not crash the
568
+ // process. Initial connection failures surface here; downstream adapter
569
+ // operations then fail visibly.
352
570
  io.on("error", (err: unknown) => {
353
- console.error("[standalone:redis-clients] ioredis error", err);
571
+ logger.error({ err }, "standalone_redis_clients_error");
354
572
  });
355
573
 
356
574
  lifecycleRegistrar?.register(async () => {
357
575
  await io.quit();
358
576
  });
359
577
 
360
- const clients = makeIoredisClients(io);
578
+ // One probe for the one socket. Registering per consumed slot would report
579
+ // the same connection six times; the cache above means this runs once per
580
+ // boot, on the call that actually constructs the client.
581
+ readinessRegistrar?.register({
582
+ name: "redis",
583
+ check: () => io.ping(),
584
+ });
585
+
586
+ // The wrapper opens its own connections for refresh rotation
587
+ // (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
588
+ // `io`; passing the logger lets those report through the same channel.
589
+ const clients = makeIoredisClients(io, { logger });
361
590
  if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
362
591
  return clients;
363
592
  }
364
593
 
594
+ /**
595
+ * Reads an optional string field off a federation slice.
596
+ *
597
+ * A field the operator did not set stays absent rather than becoming
598
+ * `undefined`, so `"sessionDomain" in config` still distinguishes the two. A
599
+ * field that is present but not a string throws: HOCON hands through whatever
600
+ * shape the file holds, and silently ignoring `sessionDomain = 42` would leave
601
+ * the redirect policy running with one fewer constraint than the operator
602
+ * wrote down.
603
+ */
604
+ function optionalString(
605
+ slice: Record<string, unknown>,
606
+ field: string,
607
+ ): Record<string, string> | Record<string, never> {
608
+ const value = slice[field];
609
+ if (value === undefined || value === null) return {};
610
+ if (typeof value !== "string") {
611
+ throw new Error(`federations.google.${field} must be a string when present`);
612
+ }
613
+ return { [field]: value };
614
+ }
615
+
365
616
  /**
366
617
  * Google federation config bridge — supplies the typed `googleFederationConfig`
367
618
  * ComponentMap slot from the `config.federations.google` slice.
@@ -369,6 +620,15 @@ function getOrCreateClients(
369
620
  * Per `@o3co/auth-provider-federation-google` README. Per-federation modules
370
621
  * (Phase 7 A5) consume this slot; the bridge is the standalone composition
371
622
  * root's responsibility because the slot's content is consumer-specific.
623
+ *
624
+ * #278: this used to return `{ clientId, clientSecret, callbackURL }` and stop
625
+ * there, dropping `redirectAllowlist` / `sessionDomain` / `authCallbackUrl` /
626
+ * `clientUrl` on the floor. Nothing complained — `googleFederationModule` hands
627
+ * this same object to `createFederationRedirectPolicy`, so the redirect policy
628
+ * was built from a config with none of the fields it reads, and a deployment
629
+ * that had configured them ran as though it had not. The generated app is the
630
+ * shape most operators start from, so the safe path has to be the one that
631
+ * works by filling in the config file.
372
632
  */
373
633
  export const googleFederationConfigModule: Module = defineModule({
374
634
  name: "standalone:google-federation-config",
@@ -393,7 +653,33 @@ export const googleFederationConfigModule: Module = defineModule({
393
653
  "federations.google requires clientId, clientSecret, callbackURL when enabled",
394
654
  );
395
655
  }
396
- return { clientId, clientSecret, callbackURL };
656
+
657
+ // The allowlist is checked for shape here and for content by
658
+ // `createFederationRedirectPolicy`, which owns the URL rules. This
659
+ // only has to establish that HOCON produced a list of strings —
660
+ // `redirectAllowlist = "https://…"` (a bare string, the natural typo)
661
+ // would otherwise reach the policy as a config it cannot read.
662
+ const rawAllowlist = slice.redirectAllowlist;
663
+ let redirectAllowlist: Record<string, readonly string[]> | Record<string, never> = {};
664
+ if (rawAllowlist !== undefined && rawAllowlist !== null) {
665
+ if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
666
+ throw new Error(
667
+ "federations.google.redirectAllowlist must be a list of URL strings, " +
668
+ 'e.g. ["https://app.example.com/welcome"]',
669
+ );
670
+ }
671
+ redirectAllowlist = { redirectAllowlist: rawAllowlist as readonly string[] };
672
+ }
673
+
674
+ return {
675
+ clientId,
676
+ clientSecret,
677
+ callbackURL,
678
+ ...redirectAllowlist,
679
+ ...optionalString(slice, "sessionDomain"),
680
+ ...optionalString(slice, "authCallbackUrl"),
681
+ ...optionalString(slice, "clientUrl"),
682
+ };
397
683
  },
398
684
  },
399
685
  });
@@ -0,0 +1,162 @@
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 { Server } from "node:http";
18
+ import type { Logger } from "@o3co/auth-provider-core";
19
+
20
+ /**
21
+ * Graceful shutdown for the scaffolded server (#290).
22
+ *
23
+ * ## Why this is in the template rather than a dependency
24
+ *
25
+ * It used to be `gracefulShutdown` from `@o3co/auth.utils@0.0.4`. The ops
26
+ * review's objection was not that the package is bad — it is that for the
27
+ * component which terminates every user session, "does SIGTERM wait for
28
+ * in-flight requests, and for how long?" has to be answerable from the code an
29
+ * operator deploys, and it was answerable only by reading a pre-1.0 package
30
+ * with no contract pinned here.
31
+ *
32
+ * Reading it answered the question, and the answer was the reason to move it:
33
+ * **there was no deadline**. `server.close()` waits for in-flight requests
34
+ * indefinitely, so a single stuck request meant the process never exited on
35
+ * its own and the orchestrator's SIGKILL took it down mid-flight — the
36
+ * opposite of a graceful shutdown, arriving only under the load that produces
37
+ * a stuck request. The cleanup-failure path also wrote to `console.error`, one
38
+ * bare line in a service whose every other line is NDJSON.
39
+ *
40
+ * Neither is fixed by documenting or pinning the dependency, which is why this
41
+ * is ~40 lines here with tests, rather than a version range and a README
42
+ * paragraph.
43
+ *
44
+ * ## The guarantees, stated
45
+ *
46
+ * 1. **SIGTERM and SIGINT** both start it; the second signal is ignored rather
47
+ * than starting a second dispose over the first one's stores.
48
+ * 2. **New connections stop immediately** (`server.close`), and idle keep-alive
49
+ * sockets are released (`closeIdleConnections`) — they hold the server open
50
+ * with no request behind them, so a quiet server would otherwise wait out
51
+ * the whole deadline for nothing.
52
+ * 3. **In-flight requests are given `drainTimeoutMs`** (default 10s) to finish.
53
+ * 4. **Past the deadline, remaining connections are cut** (`closeAllConnections`)
54
+ * and the process exits **non-zero** — an orchestrator that only ever sees
55
+ * `0` cannot tell a clean drain from one that ran out of time.
56
+ * 5. **`cleanup` runs after draining, before exit**, and its failure is logged
57
+ * through the app logger and reflected in the exit code. It never wedges the
58
+ * process: a dispose that throws still exits.
59
+ * 6. **A `close` that fails is not reported as a clean drain.** `server.close`
60
+ * reports through its callback, and treating that as success would tell an
61
+ * orchestrator the listener came down when it did not.
62
+ *
63
+ * Size `drainTimeoutMs` **below** the orchestrator's own kill grace period
64
+ * (Kubernetes `terminationGracePeriodSeconds`, compose `stop_grace_period`,
65
+ * both 30s by default) — the point is to close on our terms before SIGKILL
66
+ * arrives on someone else's.
67
+ */
68
+ export interface GracefulShutdownOptions {
69
+ readonly logger: Logger;
70
+ /** Reverse-topological component cleanup — normally `handle.dispose()`. */
71
+ readonly cleanup?: () => void | Promise<void>;
72
+ /** How long in-flight requests get before connections are cut. Default 10s. */
73
+ readonly drainTimeoutMs?: number;
74
+ /** Injected in tests; defaults to `process.exit`. */
75
+ readonly exit?: (code: number) => void;
76
+ /** Injected in tests; defaults to `process.on`. */
77
+ readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
78
+ /** Injected in tests; defaults to `process.removeListener`. */
79
+ readonly offSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
80
+ }
81
+
82
+ const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
83
+ const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
84
+
85
+ export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
86
+ const {
87
+ logger,
88
+ cleanup,
89
+ drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
90
+ exit = (code: number): void => {
91
+ process.exit(code);
92
+ },
93
+ onSignal = (signal, handler): void => {
94
+ process.on(signal, handler);
95
+ },
96
+ offSignal = (signal, handler): void => {
97
+ process.removeListener(signal, handler);
98
+ },
99
+ } = options;
100
+
101
+ let shuttingDown = false;
102
+ let finished = false;
103
+
104
+ /** Run `cleanup` and exit. Called by whichever of drain / deadline wins. */
105
+ const finish = async (code: number, reason: string): Promise<void> => {
106
+ if (finished) return;
107
+ finished = true;
108
+ let exitCode = code;
109
+ try {
110
+ await cleanup?.();
111
+ } catch (err) {
112
+ // Through the app logger, not `console.error`: a shutdown that
113
+ // failed to release its Redis connections is exactly the line an
114
+ // operator needs to find later, and a bare write is the one their
115
+ // pipeline drops.
116
+ logger.error({ err }, "graceful shutdown: cleanup failed");
117
+ exitCode = 1;
118
+ }
119
+ logger.info({ reason, exitCode }, "graceful shutdown: complete");
120
+ exit(exitCode);
121
+ };
122
+
123
+ const handler = (): void => {
124
+ if (shuttingDown) return;
125
+ shuttingDown = true;
126
+ for (const signal of SIGNALS) offSignal(signal, handler);
127
+ logger.info({ drainTimeoutMs }, "graceful shutdown: draining");
128
+
129
+ const deadline = setTimeout(() => {
130
+ logger.error(
131
+ { drainTimeoutMs },
132
+ "graceful shutdown: drain deadline exceeded, closing remaining connections",
133
+ );
134
+ server.closeAllConnections();
135
+ void finish(1, "drain-timeout");
136
+ }, drainTimeoutMs);
137
+ // The deadline must not be what keeps the process alive once the drain
138
+ // has already finished.
139
+ deadline.unref?.();
140
+
141
+ server.close((err) => {
142
+ clearTimeout(deadline);
143
+ if (err) {
144
+ // `close` reports through its callback — "Server is not running"
145
+ // is the common one, but any listener teardown failure lands
146
+ // here. Reporting "drained" and exiting 0 on it would tell an
147
+ // orchestrator the shutdown went cleanly when the listener did
148
+ // not actually come down.
149
+ logger.error({ err }, "graceful shutdown: server close failed");
150
+ void finish(1, "close-failed");
151
+ return;
152
+ }
153
+ void finish(0, "drained");
154
+ });
155
+ // Idle keep-alive sockets have no request behind them, so nothing is
156
+ // lost by releasing them — and without this a quiet server waits out
157
+ // the whole deadline for connections that will never send anything.
158
+ server.closeIdleConnections();
159
+ };
160
+
161
+ for (const signal of SIGNALS) onSignal(signal, handler);
162
+ }