@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -14,59 +14,16 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
+ /*
18
+ * Graceful shutdown for the scaffolded server, kept in the template so that
19
+ * "does SIGTERM wait for in-flight requests, and for how long?" is answerable
20
+ * from the code you deploy. See the template README, "Shutdown guarantees".
21
+ */
22
+
17
23
  import type { Server } from "node:http";
18
- import type { Logger } from "@o3co/auth-provider-core";
24
+ import { type Logger, loggableError } from "@o3co/auth-provider-core";
19
25
 
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, and one that never settles is
59
- * cut off at `cleanupTimeoutMs`.
60
- * 6. **A `close` that fails is not reported as a clean drain.** `server.close`
61
- * reports through its callback, and treating that as success would tell an
62
- * orchestrator the listener came down when it did not.
63
- *
64
- * Size `drainTimeoutMs` plus `cleanupTimeoutMs` **below** the orchestrator's
65
- * own kill grace period (Kubernetes `terminationGracePeriodSeconds` is 30s by
66
- * default, compose `stop_grace_period` 10s; with federation grants on the sum
67
- * is 55s, so the grace is 60s or more) — the point is to close on our terms
68
- * before SIGKILL arrives on someone else's.
69
- */
26
+ /** Options for {@link installGracefulShutdown}. */
70
27
  export interface GracefulShutdownOptions {
71
28
  readonly logger: Logger;
72
29
  /** Reverse-topological component cleanup — normally `handle.dispose()`. */
@@ -74,11 +31,19 @@ export interface GracefulShutdownOptions {
74
31
  /** How long in-flight requests get before connections are cut. Default 10s. */
75
32
  readonly drainTimeoutMs?: number;
76
33
  /**
77
- * How long `cleanup` gets before the shutdown gives up on it. Defaults to
78
- * `drainTimeoutMs`, so the worst-case shutdown is the two budgets in
79
- * sequence — size both against the orchestrator's grace period, not one.
34
+ * How long `cleanup` gets before the shutdown gives up on it, unless
35
+ * `cleanupAllowanceMs` is longer. Defaults to `drainTimeoutMs`, so the
36
+ * worst-case shutdown is the two budgets in sequence — size both against
37
+ * the orchestrator's grace period, not one.
80
38
  */
81
39
  readonly cleanupTimeoutMs?: number;
40
+ /**
41
+ * The least `cleanup` gets, read when the signal arrives: normally
42
+ * `() => handle.cleanupAllowanceMs`, the longest tail a module registered
43
+ * its cleanup with. Absent, unreadable, or not a whole number of
44
+ * milliseconds from 1 to 2147483647, the budget is `cleanupTimeoutMs` alone.
45
+ */
46
+ readonly cleanupAllowanceMs?: () => number | undefined;
82
47
  /** Injected in tests; defaults to {@link deferExit}. */
83
48
  readonly exit?: (code: number) => void;
84
49
  /** Injected in tests; defaults to `process.on`. */
@@ -103,71 +68,33 @@ export function deferExit(code: number, exitProcess: (code: number) => void = pr
103
68
  const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
104
69
  const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
105
70
 
106
- /**
107
- * The least cleanup allowance federation grants get (#593 slice 7): 45
108
- * seconds. The package's background registry drains on dispose — it waits for
109
- * a rotated upstream credential's write — and the longest tail one refresh
110
- * can have is the upstream hard timeout, the persist budget and the wait for
111
- * the grant's lock, back to back. With the shipped budgets (25 s + 3 s + 5 s)
112
- * and {@link FEDERATION_GRANTS_CLEANUP_MARGIN_MS} that is exactly this floor;
113
- * a deployment that raises a budget raises the allowance with it, below.
114
- * The ten-second drain that cleanup would otherwise inherit is shorter than
115
- * the tail, and a shutdown under it would abandon exactly the write the drain
116
- * exists to wait for, after the IdP had moved on to the new refresh token.
117
- *
118
- * A host policy, not a grant setting: what it bounds is `handle.dispose()`,
119
- * and the orchestrator's grace period has to cover the drain, this and an exit
120
- * margin — the compose files ship 60 seconds for the shipped budgets, and an
121
- * operator who raises a budget raises the grace to match.
122
- */
123
- export const FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS = 45_000;
124
-
125
- /** What the allowance adds to the longest refresh tail: an exit margin. */
126
- export const FEDERATION_GRANTS_CLEANUP_MARGIN_MS = 12_000;
127
-
128
- /**
129
- * The most a timer can be asked for: Node's `setTimeout` takes a 32-bit signed
130
- * delay, and a larger one fires after ~1 ms instead. A sum of budgets an
131
- * operator set high enough to reach it would otherwise turn the allowance
132
- * into no allowance at all (Copilot, #614).
133
- */
71
+ /** The longest delay a timer takes; a larger one, or NaN, fires after about a millisecond. */
134
72
  const MAX_TIMER_MS = 2_147_483_647;
135
73
 
136
- /**
137
- * The `cleanupTimeoutMs` to hand {@link installGracefulShutdown}, from the
138
- * config: while the feature is on, the longer of the floor above and the
139
- * configured refresh tail plus the margin; while it is off, nothing — the
140
- * drain's own budget, as before. A fragment to spread, so an absent allowance
141
- * is absent rather than `undefined`. A config that carries no budgets (one
142
- * built by hand, without `reference.conf`) gets the floor.
143
- */
144
- export function cleanupAllowanceFor(config: {
145
- readonly federationGrants?:
146
- | {
147
- readonly enabled?: boolean | undefined;
148
- readonly upstreamHardTimeoutMs?: number | undefined;
149
- readonly persistRetryBudgetMs?: number | undefined;
150
- readonly lockWaitMs?: number | undefined;
151
- }
152
- | undefined;
153
- }): { readonly cleanupTimeoutMs: number } | Record<string, never> {
154
- const grants = config.federationGrants;
155
- if (grants?.enabled !== true) return {};
156
- const budgets = [grants.upstreamHardTimeoutMs, grants.persistRetryBudgetMs, grants.lockWaitMs];
157
- const tail = budgets.every((budget) => typeof budget === "number" && Number.isFinite(budget))
158
- ? (budgets as number[]).reduce(
159
- (sum, budget) => sum + budget,
160
- FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
161
- )
162
- : 0;
163
- return {
164
- cleanupTimeoutMs: Math.min(
165
- Math.max(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS, tail),
166
- MAX_TIMER_MS,
167
- ),
168
- };
74
+ /** What `read` reports, when it is a delay a timer can wait; otherwise nothing. */
75
+ function allowanceFrom(read: (() => number | undefined) | undefined): number | undefined {
76
+ let allowance: unknown;
77
+ try {
78
+ allowance = read?.();
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ return typeof allowance === "number" &&
83
+ Number.isInteger(allowance) &&
84
+ allowance >= 1 &&
85
+ allowance <= MAX_TIMER_MS
86
+ ? allowance
87
+ : undefined;
169
88
  }
170
89
 
90
+ /**
91
+ * Install the graceful shutdown on `server`. The guarantees (a deadline on the
92
+ * drain, a non-zero exit past it, a bounded `cleanup` that never wedges the
93
+ * process), the log line of each stage and how to size `drainTimeoutMs` plus
94
+ * `cleanupTimeoutMs` below the orchestrator's kill grace period are in the
95
+ * template README, "Shutdown guarantees". Beyond them: a `close` that fails is
96
+ * not reported as a clean drain, and exits non-zero.
97
+ */
171
98
  export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
172
99
  const {
173
100
  logger,
@@ -182,7 +109,8 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
182
109
  },
183
110
  } = options;
184
111
 
185
- const cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs;
112
+ // Settled when the signal arrives, with the allowance as it stands then.
113
+ let cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs;
186
114
 
187
115
  let shuttingDown = false;
188
116
  let finished = false;
@@ -226,7 +154,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
226
154
  let outcome = reason;
227
155
  try {
228
156
  if ((await runCleanup()) === CLEANUP_TIMED_OUT) {
229
- logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out");
157
+ logger.error({ cleanupTimeoutMs }, "shutdown_cleanup_timed_out");
230
158
  exitCode = 1;
231
159
  outcome = "cleanup-timeout";
232
160
  }
@@ -234,12 +162,14 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
234
162
  // Through the app logger, not `console.error`: a shutdown that
235
163
  // failed to release its Redis connections is exactly the line an
236
164
  // operator needs to find later, and a bare write is the one their
237
- // pipeline drops.
238
- logger.error({ err }, "graceful shutdown: cleanup failed");
165
+ // pipeline drops. The projection, not the error: `dispose()`
166
+ // rejects with every cleanup's own error on `errors`, a store's
167
+ // write — and what it wrote — among them.
168
+ logger.error({ err: loggableError(err) }, "shutdown_cleanup_failed");
239
169
  exitCode = 1;
240
170
  outcome = "cleanup-failed";
241
171
  }
242
- logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete");
172
+ logger.info({ reason: outcome, drain: reason, exitCode }, "shutdown_complete");
243
173
  exit(exitCode);
244
174
  };
245
175
 
@@ -247,13 +177,11 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
247
177
  if (shuttingDown) return;
248
178
  shuttingDown = true;
249
179
  for (const signal of SIGNALS) offSignal(signal, handler);
250
- logger.info({ drainTimeoutMs }, "graceful shutdown: draining");
180
+ cleanupTimeoutMs = Math.max(cleanupTimeoutMs, allowanceFrom(options.cleanupAllowanceMs) ?? 0);
181
+ logger.info({ drainTimeoutMs, cleanupTimeoutMs }, "shutdown_draining");
251
182
 
252
183
  const deadline = setTimeout(() => {
253
- logger.error(
254
- { drainTimeoutMs },
255
- "graceful shutdown: drain deadline exceeded, closing remaining connections",
256
- );
184
+ logger.error({ drainTimeoutMs }, "shutdown_drain_deadline_exceeded");
257
185
  server.closeAllConnections();
258
186
  void finish(1, "drain-timeout");
259
187
  }, drainTimeoutMs);
@@ -269,7 +197,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
269
197
  // here. Reporting "drained" and exiting 0 on it would tell an
270
198
  // orchestrator the shutdown went cleanly when the listener did
271
199
  // not actually come down.
272
- logger.error({ err }, "graceful shutdown: server close failed");
200
+ logger.error({ err: loggableError(err) }, "shutdown_server_close_failed");
273
201
  void finish(1, "close-failed");
274
202
  return;
275
203
  }
@@ -32,7 +32,7 @@ const _extractCookies = (res) => (res.headers["set-cookie"] ?? []).join("; ");
32
32
  /**
33
33
  * The server's own origin, as a browser would report it.
34
34
  *
35
- * Since #272 `POST /session/login` and `POST /session/logout` reject a request
35
+ * `POST /session/login` and `POST /session/logout` reject a request
36
36
  * that carries neither a same-origin `Origin`/`Referer` nor a valid
37
37
  * double-submit CSRF token. A browser sets `Origin` on its own; a scripted
38
38
  * client like this one has to say it.
@@ -174,7 +174,7 @@ describe("POST /session/login", () => {
174
174
  });
175
175
  });
176
176
 
177
- describe("CSRF on the state-changing session routes (#272)", () => {
177
+ describe("CSRF on the state-changing session routes", () => {
178
178
  it("rejects a login carrying neither an origin signal nor a token", async () => {
179
179
  // The bypass this replaced: omitting `Origin` skipped the check entirely.
180
180
  const res = await client.post("/session/login", {