@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.
- package/dist/index.d.mts +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +110 -7
- package/package.json +4 -4
- package/templates/standalone/.env.example +49 -5
- package/templates/standalone/Dockerfile +59 -16
- package/templates/standalone/README.ja.md +92 -13
- package/templates/standalone/README.md +358 -18
- package/templates/standalone/config/application.conf +132 -4
- package/templates/standalone/config/clients.yaml.example +56 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +132 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +15 -20
- package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
- package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
- package/templates/standalone/src/app.mts +78 -10
- package/templates/standalone/src/buildModules.mts +43 -1
- package/templates/standalone/src/configPath.mts +2 -2
- package/templates/standalone/src/logger.mts +90 -2
- package/templates/standalone/src/metrics.mts +190 -0
- package/templates/standalone/src/modules.mts +319 -33
- package/templates/standalone/src/shutdown.mts +162 -0
- package/templates/standalone/src/terminalError.mts +60 -0
- package/templates/standalone/tests/index.test.js +69 -5
- package/templates/standalone/tsconfig.json +9 -1
- package/templates/standalone/vitest.config.mts +8 -0
- 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.
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
|
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 ({
|
|
279
|
-
|
|
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)
|
|
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)
|
|
377
|
+
sessionRPRegistryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
378
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
379
|
+
.sessionRPRegistryClient;
|
|
286
380
|
},
|
|
287
|
-
sessionFamilyIndexClient: async ({
|
|
288
|
-
|
|
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 ({
|
|
291
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|