@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.
- package/README.ja.md +139 -48
- package/README.md +138 -47
- 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 +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- 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 +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- 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 +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- 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 +17 -7
- 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 +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- 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 +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- 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 +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- 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 +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- 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
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- 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
|
|
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
|
|
78
|
-
* `
|
|
79
|
-
*
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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 }, "
|
|
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
|
-
|
|
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 }, "
|
|
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
|
-
|
|
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 }, "
|
|
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
|
-
*
|
|
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", {
|