@o3co/create-auth-provider 0.13.0 → 0.15.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.
Files changed (27) hide show
  1. package/package.json +2 -2
  2. package/templates/standalone/.env.example +27 -0
  3. package/templates/standalone/Dockerfile +1 -1
  4. package/templates/standalone/README.ja.md +80 -2
  5. package/templates/standalone/README.md +160 -4
  6. package/templates/standalone/config/application.conf +56 -1
  7. package/templates/standalone/docker-compose.production.yml +7 -0
  8. package/templates/standalone/docker-compose.yml +7 -0
  9. package/templates/standalone/package.json +4 -3
  10. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
  11. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +62 -3
  12. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
  13. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
  14. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
  15. package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
  16. package/templates/standalone/src/__tests__/replica-safety.test.mts +58 -0
  17. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +37 -0
  18. package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
  19. package/templates/standalone/src/__tests__/smoke.test.mts +1 -0
  20. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
  21. package/templates/standalone/src/app.mts +9 -1
  22. package/templates/standalone/src/buildModules.mts +110 -6
  23. package/templates/standalone/src/modules.mts +96 -6
  24. package/templates/standalone/src/shutdown.mts +70 -4
  25. package/templates/standalone/vitest.config.mts +6 -0
  26. package/templates/standalone/vitest.supertest-loopback.mts +250 -0
  27. package/templates/versions.json +15 -14
@@ -61,10 +61,11 @@ import type { Logger } from "@o3co/auth-provider-core";
61
61
  * reports through its callback, and treating that as success would tell an
62
62
  * orchestrator the listener came down when it did not.
63
63
  *
64
- * Size `drainTimeoutMs` **below** the orchestrator's own kill grace period
65
- * (Kubernetes `terminationGracePeriodSeconds`, compose `stop_grace_period`,
66
- * both 30s by default) — the point is to close on our terms before SIGKILL
67
- * arrives on someone else's.
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.
68
69
  */
69
70
  export interface GracefulShutdownOptions {
70
71
  readonly logger: Logger;
@@ -102,6 +103,71 @@ export function deferExit(code: number, exitProcess: (code: number) => void = pr
102
103
  const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
103
104
  const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
104
105
 
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
+ */
134
+ const MAX_TIMER_MS = 2_147_483_647;
135
+
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
+ };
169
+ }
170
+
105
171
  export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
106
172
  const {
107
173
  logger,
@@ -20,5 +20,11 @@ export default defineConfig({
20
20
  // monorepo the same packages are symlinks to source and are inlined
21
21
  // anyway; this makes the two layouts behave the same.
22
22
  server: { deps: { inline: [/@o3co\/auth-provider-/] } },
23
+ // #556: the server `request(app)` starts listens on 127.0.0.1, the
24
+ // address supertest dials. Unpatched, macOS can give it a port another
25
+ // process holds on 127.0.0.1, and the request hangs on that process until
26
+ // the test timeout. Rationale and mechanics in the file itself. The
27
+ // Dockerfile `test` stage copies it; scaffold-assets.test.mts checks that.
28
+ setupFiles: ["./vitest.supertest-loopback.mts"],
23
29
  },
24
30
  });
@@ -0,0 +1,250 @@
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
+ /**
18
+ * vitest setup file (#556): the server supertest starts listens on the
19
+ * address supertest dials.
20
+ *
21
+ * `request(app)` / `request.agent(app)` start a server with `app.listen(0)`
22
+ * and send the request to `127.0.0.1:<port>`. A hostless `listen` binds the
23
+ * dual-stack wildcard `[::]:P`, and on macOS (BSD `SO_REUSEADDR` semantics —
24
+ * libuv sets it on every TCP listener) the kernel will choose a `P` that
25
+ * another process already holds as `127.0.0.1:P`. The more specific socket
26
+ * wins, so the test's request is delivered to that process. When it accepts
27
+ * and never answers — an editor helper, in the case that was caught — the
28
+ * request hangs until the test timeout, in whichever supertest test drew the
29
+ * port, and passes on the next run. Linux refuses the conflicting bind
30
+ * (EADDRINUSE), so CI does not show it.
31
+ *
32
+ * Binding `127.0.0.1` explicitly removes both halves: the kernel will not
33
+ * give out a `127.0.0.1:P` someone else holds, and against a wildcard holder
34
+ * our specific socket is the one that receives. A specific-host `listen` is
35
+ * asynchronous (it goes through `dns.lookup`), and supertest reads the port
36
+ * synchronously in its constructor, so the URL is completed when the request
37
+ * is sent (`end`, which `then` and `expect(..., fn)` go through). A server the
38
+ * test started itself is never touched.
39
+ *
40
+ * Which version of supertest closes the server it started, and when, has
41
+ * changed under this file once (7.3.0): 7.2 closed `_server` before asserting;
42
+ * 7.3 tracks the servers it started itself in a private registry and closes
43
+ * only those, so a server this file bound is left listening. The patched
44
+ * `end` therefore closes the server this request started, before the test's
45
+ * callback, whenever supertest has not — the same order either way.
46
+ *
47
+ * supertest is loaded from the package that owns the running test file (the
48
+ * nearest `package.json` above it), so the patched class is the one that test
49
+ * imports, whatever directory vitest was started from. A package that does
50
+ * not declare supertest is left alone. Everything else this file cannot do —
51
+ * no test path, a declared supertest that does not load, a supertest whose
52
+ * internals no longer look like this — throws, so the guard is never silently
53
+ * off. That load-time check sees only the two methods' presence; a supertest
54
+ * that moved the `listen` out of `serverAddress` would pass it and leave the
55
+ * guard off. The behavioural tests catch that shape of change: this
56
+ * template's `supertest-loopback.test.mts` and its twin in the session
57
+ * package assert the address the server actually bound.
58
+ *
59
+ * This file is part of the project template and ships with every scaffold.
60
+ * The auth.provider workspace loads this same file for its packages (see
61
+ * `WORKSPACE_TEST_SETUP` in its `vitest.shared.mts`).
62
+ */
63
+
64
+ import { once } from "node:events";
65
+ import { existsSync, readFileSync } from "node:fs";
66
+ import { createRequire } from "node:module";
67
+ import type { Server } from "node:net";
68
+ import { dirname, join } from "node:path";
69
+ import { Server as TlsServer } from "node:tls";
70
+ import { expect } from "vitest";
71
+
72
+ const LOOPBACK = "127.0.0.1";
73
+ const PATCHED = Symbol.for("o3co.auth.provider.supertest-loopback");
74
+ const PENDING = Symbol("o3co.auth.provider.supertest-loopback.pending");
75
+
76
+ type Pending = {
77
+ readonly server: Server;
78
+ readonly protocol: string;
79
+ readonly path: string;
80
+ /** Settles on `listening`, rejects on a listen `error`. */
81
+ readonly ready: Promise<unknown>;
82
+ };
83
+
84
+ type TestInstance = {
85
+ url: string;
86
+ _server?: Server;
87
+ [PENDING]?: Pending;
88
+ };
89
+
90
+ type TestPrototype = {
91
+ serverAddress(this: TestInstance, app: Server, path: string): string;
92
+ end(this: TestInstance, fn?: (err: unknown, res?: unknown) => void): TestInstance;
93
+ [PATCHED]?: true;
94
+ };
95
+
96
+ const DEPENDENCY_FIELDS = [
97
+ "dependencies",
98
+ "devDependencies",
99
+ "peerDependencies",
100
+ "optionalDependencies",
101
+ ] as const;
102
+
103
+ /** The nearest `package.json` at or above `dir`. */
104
+ function owningManifest(dir: string): string | undefined {
105
+ for (let current = dir; ; current = dirname(current)) {
106
+ const candidate = join(current, "package.json");
107
+ if (existsSync(candidate)) return candidate;
108
+ if (dirname(current) === current) return undefined;
109
+ }
110
+ }
111
+
112
+ /** supertest's `Test.prototype` as the running test file sees it, or `undefined` when its package has no supertest. */
113
+ function supertestFor(testPath: string): TestPrototype | undefined {
114
+ const manifestPath = owningManifest(dirname(testPath));
115
+ if (!manifestPath) {
116
+ throw new Error(`vitest.supertest-loopback.mts: no package.json above ${testPath}.`);
117
+ }
118
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
119
+ const declared = DEPENDENCY_FIELDS.some(
120
+ (field) => (manifest[field] as Record<string, unknown> | undefined)?.supertest !== undefined,
121
+ );
122
+ if (!declared) return undefined;
123
+
124
+ let supertest: { Test?: { prototype: TestPrototype } };
125
+ try {
126
+ supertest = createRequire(manifestPath)("supertest");
127
+ } catch (cause) {
128
+ throw new Error(
129
+ `vitest.supertest-loopback.mts: ${manifestPath} declares supertest, but it cannot be loaded from there; the #556 loopback guard would be off.`,
130
+ { cause },
131
+ );
132
+ }
133
+ const proto = supertest.Test?.prototype;
134
+ if (typeof proto?.serverAddress !== "function" || typeof proto.end !== "function") {
135
+ throw new Error(
136
+ "vitest.supertest-loopback.mts: supertest no longer exposes Test.prototype.serverAddress/end; " +
137
+ "re-check the #556 loopback guard against the installed supertest before removing it.",
138
+ );
139
+ }
140
+ return proto;
141
+ }
142
+
143
+ /**
144
+ * The bind in progress per server. A second request on a server whose bind has
145
+ * not finished (an agent's requests sent together share one server) waits on
146
+ * the same bind instead of calling `listen` twice — and, like unpatched
147
+ * supertest when it finds a server already listening, does not own it.
148
+ */
149
+ const binding = new WeakMap<Server, Promise<unknown>>();
150
+
151
+ /**
152
+ * A throw from the test's own callback, raised where it cannot propagate to
153
+ * the test: reported as an uncaught exception, which fails the run by name,
154
+ * instead of an unhandled rejection of a promise nobody holds.
155
+ */
156
+ function rethrowOutsideThisChain(err: unknown): void {
157
+ process.nextTick(() => {
158
+ throw err;
159
+ });
160
+ }
161
+
162
+ function patch(proto: TestPrototype): void {
163
+ if (proto[PATCHED]) return;
164
+ const originalServerAddress = proto.serverAddress;
165
+ const originalEnd = proto.end;
166
+
167
+ proto.serverAddress = function serverAddress(app, path) {
168
+ // Already listening: the test owns the server and chose its address.
169
+ if (app.address()) return originalServerAddress.call(this, app, path);
170
+
171
+ const protocol = app instanceof TlsServer ? "https" : "http";
172
+ let ready = binding.get(app);
173
+ if (!ready) {
174
+ // supertest 7.2 closes `_server` after the response; 7.3 does not,
175
+ // and the callback in the patched `end` below closes it instead.
176
+ this._server = app.listen(0, LOOPBACK);
177
+ // Subscribed now, not in `end`: a listen error emitted before the test
178
+ // sends the request must still reach it, not crash the worker as an
179
+ // unhandled 'error' event.
180
+ const bound = once(app, "listening");
181
+ binding.set(app, bound);
182
+ bound.then(
183
+ () => binding.delete(app),
184
+ () => binding.delete(app),
185
+ );
186
+ ready = bound;
187
+ }
188
+ this[PENDING] = { server: app, protocol, path, ready };
189
+ // No port yet; `end` fills it in before anything is sent.
190
+ return `${protocol}://${LOOPBACK}${path}`;
191
+ };
192
+
193
+ proto.end = function end(fn) {
194
+ const pending = this[PENDING];
195
+ if (!pending) return originalEnd.call(this, fn);
196
+ this[PENDING] = undefined;
197
+
198
+ const { server, protocol, path, ready } = pending;
199
+ let called = false;
200
+ const callback = (err: unknown, res?: unknown) => {
201
+ called = true;
202
+ // supertest 7.2 has closed the server it started by now, before the
203
+ // callback; 7.3 closes only servers in its own private registry, which
204
+ // this bind is not in. Close what this request started, if it is
205
+ // still open, before the callback — the order 7.2 kept.
206
+ if (this._server === server && server.listening) {
207
+ server.close(() => fn?.(err, res));
208
+ return;
209
+ }
210
+ fn?.(err, res);
211
+ };
212
+ // Unpatched, whatever `end` throws while building the request (a header
213
+ // value Node refuses, a body that will not serialize) reaches the caller
214
+ // at once — and `then` runs `end` inside a promise executor, so the
215
+ // awaited request rejects with it. Deferred, there is no caller left: the
216
+ // throw, like a failed bind, is handed to the request's callback, which
217
+ // is what rejects that promise. Nothing here may reject: a rejection of
218
+ // this chain is one nobody awaits.
219
+ const fail = (err: unknown) => {
220
+ if (called) return rethrowOutsideThisChain(err);
221
+ // The request never went out, so supertest will not close the server
222
+ // this request started.
223
+ if (this._server?.listening) this._server.close();
224
+ try {
225
+ callback(err);
226
+ } catch (thrown) {
227
+ rethrowOutsideThisChain(thrown);
228
+ }
229
+ };
230
+ ready
231
+ .then(() => {
232
+ const { port } = server.address() as { port: number };
233
+ this.url = `${protocol}://${LOOPBACK}:${port}${path}`;
234
+ originalEnd.call(this, callback);
235
+ })
236
+ .catch(fail);
237
+ return this;
238
+ };
239
+
240
+ proto[PATCHED] = true;
241
+ }
242
+
243
+ const { testPath } = expect.getState();
244
+ if (!testPath) {
245
+ throw new Error(
246
+ "vitest.supertest-loopback.mts: vitest did not say which test file this setup runs for, so the #556 loopback guard cannot find supertest.",
247
+ );
248
+ }
249
+ const proto = supertestFor(testPath);
250
+ if (proto) patch(proto);
@@ -1,16 +1,17 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.13.0",
3
- "@o3co/auth-provider-device-grant": "0.13.0",
4
- "@o3co/auth-provider-dpop": "0.13.0",
5
- "@o3co/auth-provider-mtls": "0.13.0",
6
- "@o3co/auth-provider-federation-apple": "0.13.0",
7
- "@o3co/auth-provider-federation-google": "0.13.0",
8
- "@o3co/auth-provider-federation-github": "0.13.0",
9
- "@o3co/auth-provider-federation-oidc": "0.13.0",
10
- "@o3co/auth-provider-oauth": "0.13.0",
11
- "@o3co/auth-provider-oauth-token-exchange": "0.13.0",
12
- "@o3co/auth-provider-session": "0.13.0",
13
- "@o3co/auth-provider-foundation": "0.13.0",
14
- "@o3co/auth-provider-redis": "0.13.0",
15
- "@o3co/auth-provider-webauthn": "0.13.0"
2
+ "@o3co/auth-provider-core": "0.15.0",
3
+ "@o3co/auth-provider-device-grant": "0.15.0",
4
+ "@o3co/auth-provider-dpop": "0.15.0",
5
+ "@o3co/auth-provider-mtls": "0.15.0",
6
+ "@o3co/auth-provider-federation-apple": "0.15.0",
7
+ "@o3co/auth-provider-federation-google": "0.15.0",
8
+ "@o3co/auth-provider-federation-github": "0.15.0",
9
+ "@o3co/auth-provider-federation-grants": "0.15.0",
10
+ "@o3co/auth-provider-federation-oidc": "0.15.0",
11
+ "@o3co/auth-provider-oauth": "0.15.0",
12
+ "@o3co/auth-provider-oauth-token-exchange": "0.15.0",
13
+ "@o3co/auth-provider-session": "0.15.0",
14
+ "@o3co/auth-provider-foundation": "0.15.0",
15
+ "@o3co/auth-provider-redis": "0.15.0",
16
+ "@o3co/auth-provider-webauthn": "0.15.0"
16
17
  }