@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.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- 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
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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", {
|