@o3co/create-auth-provider 0.16.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 (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  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 +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  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 +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  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 +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  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 +4 -9
  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 +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  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 +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  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 +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  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 +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  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 +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  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
@@ -14,66 +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
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 — as core's `loggableError` projection, never the
58
- * error, which holds every cleanup's own error — and reflected in the exit
59
- * code. It never wedges the process: a dispose that throws still exits, and
60
- * one that never settles is cut off at `cleanupTimeoutMs`.
61
- * 6. **A `close` that fails is not reported as a clean drain.** `server.close`
62
- * reports through its callback, and treating that as success would tell an
63
- * orchestrator the listener came down when it did not.
64
- *
65
- * Every stage is one object-first line under a snake_case event name:
66
- * `shutdown_draining` (info, `drainTimeoutMs`), `shutdown_drain_deadline_exceeded`
67
- * (error), `shutdown_server_close_failed` (error, `err`), `shutdown_cleanup_timed_out`
68
- * (error, `cleanupTimeoutMs`), `shutdown_cleanup_failed` (error, `err`) and
69
- * `shutdown_complete` (info, `reason`, `drain`, `exitCode`).
70
- *
71
- * Size `drainTimeoutMs` plus `cleanupTimeoutMs` **below** the orchestrator's
72
- * own kill grace period (Kubernetes `terminationGracePeriodSeconds` is 30s by
73
- * default, compose `stop_grace_period` 10s; with federation grants on the sum
74
- * is 55s, so the grace is 60s or more) — the point is to close on our terms
75
- * before SIGKILL arrives on someone else's.
76
- */
26
+ /** Options for {@link installGracefulShutdown}. */
77
27
  export interface GracefulShutdownOptions {
78
28
  readonly logger: Logger;
79
29
  /** Reverse-topological component cleanup — normally `handle.dispose()`. */
@@ -81,11 +31,19 @@ export interface GracefulShutdownOptions {
81
31
  /** How long in-flight requests get before connections are cut. Default 10s. */
82
32
  readonly drainTimeoutMs?: number;
83
33
  /**
84
- * How long `cleanup` gets before the shutdown gives up on it. Defaults to
85
- * `drainTimeoutMs`, so the worst-case shutdown is the two budgets in
86
- * 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.
87
38
  */
88
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;
89
47
  /** Injected in tests; defaults to {@link deferExit}. */
90
48
  readonly exit?: (code: number) => void;
91
49
  /** Injected in tests; defaults to `process.on`. */
@@ -110,71 +68,33 @@ export function deferExit(code: number, exitProcess: (code: number) => void = pr
110
68
  const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
111
69
  const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
112
70
 
113
- /**
114
- * The least cleanup allowance federation grants get (#593 slice 7): 45
115
- * seconds. The package's background registry drains on dispose — it waits for
116
- * a rotated upstream credential's write — and the longest tail one refresh
117
- * can have is the upstream hard timeout, the persist budget and the wait for
118
- * the grant's lock, back to back. With the shipped budgets (25 s + 3 s + 5 s)
119
- * and {@link FEDERATION_GRANTS_CLEANUP_MARGIN_MS} that is exactly this floor;
120
- * a deployment that raises a budget raises the allowance with it, below.
121
- * The ten-second drain that cleanup would otherwise inherit is shorter than
122
- * the tail, and a shutdown under it would abandon exactly the write the drain
123
- * exists to wait for, after the IdP had moved on to the new refresh token.
124
- *
125
- * A host policy, not a grant setting: what it bounds is `handle.dispose()`,
126
- * and the orchestrator's grace period has to cover the drain, this and an exit
127
- * margin — the compose files ship 60 seconds for the shipped budgets, and an
128
- * operator who raises a budget raises the grace to match.
129
- */
130
- export const FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS = 45_000;
131
-
132
- /** What the allowance adds to the longest refresh tail: an exit margin. */
133
- export const FEDERATION_GRANTS_CLEANUP_MARGIN_MS = 12_000;
134
-
135
- /**
136
- * The most a timer can be asked for: Node's `setTimeout` takes a 32-bit signed
137
- * delay, and a larger one fires after ~1 ms instead. A sum of budgets an
138
- * operator set high enough to reach it would otherwise turn the allowance
139
- * into no allowance at all (Copilot, #614).
140
- */
71
+ /** The longest delay a timer takes; a larger one, or NaN, fires after about a millisecond. */
141
72
  const MAX_TIMER_MS = 2_147_483_647;
142
73
 
143
- /**
144
- * The `cleanupTimeoutMs` to hand {@link installGracefulShutdown}, from the
145
- * config: while the feature is on, the longer of the floor above and the
146
- * configured refresh tail plus the margin; while it is off, nothing — the
147
- * drain's own budget, as before. A fragment to spread, so an absent allowance
148
- * is absent rather than `undefined`. A config that carries no budgets (one
149
- * built by hand, without `reference.conf`) gets the floor.
150
- */
151
- export function cleanupAllowanceFor(config: {
152
- readonly federationGrants?:
153
- | {
154
- readonly enabled?: boolean | undefined;
155
- readonly upstreamHardTimeoutMs?: number | undefined;
156
- readonly persistRetryBudgetMs?: number | undefined;
157
- readonly lockWaitMs?: number | undefined;
158
- }
159
- | undefined;
160
- }): { readonly cleanupTimeoutMs: number } | Record<string, never> {
161
- const grants = config.federationGrants;
162
- if (grants?.enabled !== true) return {};
163
- const budgets = [grants.upstreamHardTimeoutMs, grants.persistRetryBudgetMs, grants.lockWaitMs];
164
- const tail = budgets.every((budget) => typeof budget === "number" && Number.isFinite(budget))
165
- ? (budgets as number[]).reduce(
166
- (sum, budget) => sum + budget,
167
- FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
168
- )
169
- : 0;
170
- return {
171
- cleanupTimeoutMs: Math.min(
172
- Math.max(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS, tail),
173
- MAX_TIMER_MS,
174
- ),
175
- };
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;
176
88
  }
177
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
+ */
178
98
  export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
179
99
  const {
180
100
  logger,
@@ -189,7 +109,8 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
189
109
  },
190
110
  } = options;
191
111
 
192
- const cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs;
112
+ // Settled when the signal arrives, with the allowance as it stands then.
113
+ let cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs;
193
114
 
194
115
  let shuttingDown = false;
195
116
  let finished = false;
@@ -256,7 +177,8 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
256
177
  if (shuttingDown) return;
257
178
  shuttingDown = true;
258
179
  for (const signal of SIGNALS) offSignal(signal, handler);
259
- logger.info({ drainTimeoutMs }, "shutdown_draining");
180
+ cleanupTimeoutMs = Math.max(cleanupTimeoutMs, allowanceFrom(options.cleanupAllowanceMs) ?? 0);
181
+ logger.info({ drainTimeoutMs, cleanupTimeoutMs }, "shutdown_draining");
260
182
 
261
183
  const deadline = setTimeout(() => {
262
184
  logger.error({ drainTimeoutMs }, "shutdown_drain_deadline_exceeded");
@@ -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", {