@o3co/create-auth-provider 0.14.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.
@@ -0,0 +1,431 @@
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
+ * #593 acceptance criterion 8, at the two HTTP logout endpoints (D14).
19
+ *
20
+ * A federation grant outlives the session it was agreed through. The proof of
21
+ * that used to be a direct call to `cascadeLogout()` — which is the cascade
22
+ * `/oauth/logout` runs, and not what `/session/logout` runs: that endpoint has
23
+ * its own record hygiene (`invalidateSessionRecords`) and reaches the grant
24
+ * store on no path, by construction. A proof of the helper is not a proof of
25
+ * the endpoints. So this drives both, on the standalone, as a deployment
26
+ * composes them: a grant seeded beside a live browser session, the session's
27
+ * records gone afterwards, the grant and its credential exactly as they were,
28
+ * and `/token` still disclosing.
29
+ *
30
+ * The seeded grant's access token is fresh, so the disclosure is the cached
31
+ * one and no upstream is asked — the OIDC federation the connection names is
32
+ * configured with `discovery = false` and hand-typed endpoints so that the
33
+ * deployment boots without a network.
34
+ */
35
+
36
+ import { createHmac } from "node:crypto";
37
+ import { fileURLToPath } from "node:url";
38
+ import {
39
+ type AppConfig,
40
+ AppConfigSchema,
41
+ createApp,
42
+ createKeyStoreFactory,
43
+ defineModule,
44
+ type FederationTokenStore,
45
+ federationGrantAuthorizationRevision,
46
+ federationGrantIdentityRevision,
47
+ InMemoryClientRepository,
48
+ InMemoryUserRepository,
49
+ type MemoryFederationGrantStore,
50
+ memoryRefreshTokenFamilyStoreModule,
51
+ registerBuiltinKeyStores,
52
+ type UserSessionStore,
53
+ } from "@o3co/auth-provider-core";
54
+ import { parseFile } from "@o3co/ts.hocon";
55
+ import { validate } from "@o3co/ts.hocon/zod";
56
+ import express from "express";
57
+ import request from "supertest";
58
+ import { afterEach, describe, expect, it } from "vitest";
59
+ import { buildModules } from "../buildModules.mjs";
60
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
61
+
62
+ const DAY = 86_400_000;
63
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
64
+
65
+ const ISSUER = "https://auth.test";
66
+ const JWT_SECRET = "federation-grants-survive-logout.at-least-32-bytes.ok";
67
+ const UPSTREAM = "https://issuer.example";
68
+ const UPSTREAM_CLIENT_ID = "provider-client";
69
+ const ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
70
+
71
+ const CLIENT_ID = "worker";
72
+ const CLIENT_SECRET = "worker-secret-value";
73
+ const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
74
+ const USERNAME = "alice";
75
+ const PASSWORD = "correct-horse-battery-staple";
76
+ const SUB = "u-alice";
77
+
78
+ const CONNECTION = "calendar";
79
+ const SCOPES: readonly string[] = ["openid", "offline_access", "calendar.read"];
80
+ const BOUNDARY = "production";
81
+ const GRANT_ID = "g-1";
82
+ const REFRESH_TOKEN = "SENTINEL-refresh-token";
83
+ const ACCESS_TOKEN = "upstream-access-token";
84
+
85
+ const ENV: Readonly<Record<string, string>> = {
86
+ OAUTH_JWT_ALGORITHM: "HS256",
87
+ OAUTH_JWT_SECRET: JWT_SECRET,
88
+ OAUTH_JWT_ISSUER: ISSUER,
89
+ SESSION_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
90
+ SESSION_SECURE: "false",
91
+ SESSION_NAME: "auth.session",
92
+ SESSION_STORAGE_TYPE: "memory",
93
+ CLIENT_USER_TYPE: "yaml",
94
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
95
+ USER_SESSION_STORES_ADAPTER: "memory",
96
+ RATE_LIMITER_ADAPTER: "memory",
97
+ OAUTH_CODE_ADAPTER: "memory",
98
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
99
+ REPLAY_SEEN_SET_ADAPTER: "memory",
100
+ FEDERATION_TOKEN_STORE_TYPE: "memory",
101
+ CONSENT_STORE_ADAPTER: "none",
102
+ FEDERATION_GRANT_STORE_ADAPTER: "memory",
103
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
104
+ FEDERATION_GRANTS_ENABLED: "true",
105
+ FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
106
+ // The connect flow is not driven here, and the bundled repository has no
107
+ // identity lookup; "unsupported" is the declaration a deployment without one
108
+ // makes (#613), and what lets a connection be configured at all.
109
+ FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
110
+ };
111
+
112
+ /**
113
+ * The shipped configuration under the environment above, with the two lists
114
+ * that have no environment form (comment 9 on #593) written over it: the
115
+ * upstream federation the connection names, and the connection.
116
+ */
117
+ function resolveConfig(): AppConfig {
118
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
119
+ const config = validate(
120
+ parseFile(envConfPath, { env: ENV })
121
+ .withFallback(parseFile(applicationConfPath, { env: ENV }))
122
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: ENV })),
123
+ AppConfigSchema,
124
+ );
125
+ return {
126
+ ...config,
127
+ federations: {
128
+ ...config.federations,
129
+ upstream: {
130
+ enabled: true,
131
+ type: "oidc",
132
+ issuer: UPSTREAM,
133
+ clientId: UPSTREAM_CLIENT_ID,
134
+ clientSecret: "provider-secret",
135
+ callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
136
+ scopes: [...SCOPES],
137
+ discovery: false,
138
+ endpoints: {
139
+ authorizationEndpoint: `${UPSTREAM}/authorize`,
140
+ tokenEndpoint: `${UPSTREAM}/token`,
141
+ jwksUri: `${UPSTREAM}/jwks`,
142
+ },
143
+ redirectAllowlist: [],
144
+ },
145
+ },
146
+ federationGrants: {
147
+ ...config.federationGrants,
148
+ encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
149
+ connections: {
150
+ [CONNECTION]: {
151
+ federation: "upstream",
152
+ scopes: [...SCOPES],
153
+ boundary: BOUNDARY,
154
+ maxAccessTokenLifetime: 3600,
155
+ callbackURL: `${ISSUER}/session/federation-grants/callback/${CONNECTION}`,
156
+ },
157
+ },
158
+ },
159
+ } as AppConfig;
160
+ }
161
+
162
+ const testRepositoriesModule = defineModule({
163
+ name: "test:repositories",
164
+ provides: {
165
+ clientRepository: () =>
166
+ new InMemoryClientRepository(
167
+ new Map([
168
+ [
169
+ CLIENT_ID,
170
+ {
171
+ tokenEndpointAuthMethod: "client_secret_basic",
172
+ clientSecret: CLIENT_SECRET,
173
+ allowedRedirectUris: [],
174
+ allowedScopes: ["openid", "profile"],
175
+ allowedAudiences: [],
176
+ allowedGrantTypes: ["session"],
177
+ backchannelLogoutSessionRequired: true,
178
+ frontchannelLogoutSessionRequired: true,
179
+ allowedAzpForFederationToken: false,
180
+ allowedFederationGrantConnections: [CONNECTION],
181
+ },
182
+ ],
183
+ ]),
184
+ ),
185
+ userRepository: () =>
186
+ new InMemoryUserRepository(
187
+ new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
188
+ ),
189
+ } as never,
190
+ });
191
+
192
+ const testKeyStoreModule = defineModule({
193
+ name: "test:key-store",
194
+ requires: ["config"] as const,
195
+ provides: {
196
+ keyStore: async ({ config: c }) => {
197
+ const factory = createKeyStoreFactory();
198
+ registerBuiltinKeyStores(factory);
199
+ return factory.create({
200
+ type: "local",
201
+ ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
202
+ });
203
+ },
204
+ },
205
+ });
206
+
207
+ /** The grant as the connect flow would have left it, a fresh upstream token in hand. */
208
+ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
209
+ const at = new Date();
210
+ // The revisions as `connections.mts` resolves them from the configuration
211
+ // above: the federation's issuer and client, the connection's scopes and
212
+ // boundary, no resource, no authorization parameters.
213
+ const resolved = {
214
+ upstreamIssuer: UPSTREAM,
215
+ upstreamClientId: UPSTREAM_CLIENT_ID,
216
+ scopes: SCOPES,
217
+ boundary: BOUNDARY,
218
+ authorizationParams: {},
219
+ };
220
+ await store.createPending({
221
+ id: GRANT_ID,
222
+ subject: SUB,
223
+ clientId: CLIENT_ID,
224
+ connection: CONNECTION,
225
+ intent: { handle: "h", expiresAt: new Date(at.getTime() + 600_000) },
226
+ now: at,
227
+ });
228
+ const written = await store.activate({
229
+ grantId: GRANT_ID,
230
+ intentHandle: "h",
231
+ authorization: {
232
+ identityRevision: federationGrantIdentityRevision(resolved),
233
+ authorizationRevision: federationGrantAuthorizationRevision(resolved),
234
+ upstream: { issuer: UPSTREAM, subject: "upstream-alice" },
235
+ scopes: [...SCOPES],
236
+ consent: { at, sid: "sid-consent", scopes: [...SCOPES] },
237
+ authorizedAt: at,
238
+ expiresAt: new Date(at.getTime() + 30 * DAY),
239
+ },
240
+ credentials: {
241
+ refreshToken: REFRESH_TOKEN,
242
+ accessToken: {
243
+ value: ACCESS_TOKEN,
244
+ tokenType: "Bearer",
245
+ obtainedAt: at,
246
+ issuedLifetime: 3600,
247
+ scopes: [...SCOPES],
248
+ },
249
+ },
250
+ now: at,
251
+ });
252
+ if (!written.ok) throw new Error("fixture: the grant was not activated");
253
+ }
254
+
255
+ const base64url = (value: unknown): string =>
256
+ Buffer.from(JSON.stringify(value)).toString("base64url");
257
+
258
+ /**
259
+ * An id_token for the session, signed with the deployment's own HS256 key, as
260
+ * the RP would present it: `/oauth/logout` reads `sid` from the hint and from
261
+ * nothing else.
262
+ */
263
+ function idTokenHint(sid: string): string {
264
+ const header = base64url({ alg: "HS256", kid: "v0", typ: "JWT" });
265
+ const now = Math.floor(Date.now() / 1000);
266
+ const payload = base64url({
267
+ iss: ISSUER,
268
+ sub: SUB,
269
+ aud: CLIENT_ID,
270
+ sid,
271
+ iat: now,
272
+ exp: now + 3600,
273
+ });
274
+ const signature = createHmac("sha256", JWT_SECRET)
275
+ .update(`${header}.${payload}`)
276
+ .digest("base64url");
277
+ return `${header}.${payload}.${signature}`;
278
+ }
279
+
280
+ const claimsOf = (jwt: string): Record<string, unknown> =>
281
+ JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
282
+ string,
283
+ unknown
284
+ >;
285
+
286
+ describe("#593 AC8: a grant outlives the browser session at both logout endpoints (D14)", () => {
287
+ let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
288
+
289
+ afterEach(async () => {
290
+ await handleRef?.dispose();
291
+ handleRef = undefined;
292
+ });
293
+
294
+ async function boot() {
295
+ const config = resolveConfig();
296
+ const handle = await createApp({
297
+ modules: buildModules(config, {
298
+ keyStoreModule: testKeyStoreModule,
299
+ repositoriesModule: testRepositoriesModule,
300
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
301
+ }),
302
+ bootstrapComponents: { config, pathResolver: (s) => s },
303
+ });
304
+ handleRef = handle;
305
+ const components = handle.components as {
306
+ federationGrantStore: MemoryFederationGrantStore;
307
+ userSessionStore: UserSessionStore;
308
+ federationTokenStore: FederationTokenStore;
309
+ };
310
+ await seedGrant(components.federationGrantStore);
311
+ const app = express();
312
+ app.use(handle.router);
313
+ return { app, config, ...components };
314
+ }
315
+
316
+ /** The real browser half: a CSRF pair, a login, and the cookies plus the token the login reissued. */
317
+ async function login(app: express.Express, config: AppConfig) {
318
+ const csrfRes = await request(app).get("/session/csrf");
319
+ expect(csrfRes.status).toBe(200);
320
+ const csrfToken = csrfRes.body.csrf_token as string;
321
+ const headerName = csrfRes.body.header_name as string;
322
+ const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
323
+
324
+ const loginRes = await request(app)
325
+ .post("/session/login")
326
+ .set("Cookie", csrfCookies)
327
+ .set(headerName, csrfToken)
328
+ .type("form")
329
+ .send({ username: USERNAME, password: PASSWORD });
330
+ expect(loginRes.status).toBe(200);
331
+
332
+ const cookies = loginRes.headers["set-cookie"] as unknown as string[];
333
+ const csrfCookiePrefix = `${config.session.name}.csrf=`;
334
+ const reissued = cookies
335
+ .find((c) => c.startsWith(csrfCookiePrefix))
336
+ ?.slice(csrfCookiePrefix.length)
337
+ .split(";")[0];
338
+ expect(reissued).toBeDefined();
339
+ return { cookies, csrfToken: decodeURIComponent(reissued as string), headerName };
340
+ }
341
+
342
+ /** The session's `sid`, read off the access token the session grant mints for it. */
343
+ async function sidOf(app: express.Express, cookies: string[]): Promise<string> {
344
+ const res = await request(app)
345
+ .post("/oauth/token")
346
+ .set("Authorization", BASIC)
347
+ .set("Cookie", cookies)
348
+ .type("form")
349
+ .send({ grant_type: "session" });
350
+ expect(res.status).toBe(200);
351
+ const sid = claimsOf(res.body.access_token as string).sid;
352
+ expect(typeof sid).toBe("string");
353
+ return sid as string;
354
+ }
355
+
356
+ const disclose = (app: express.Express) =>
357
+ request(app)
358
+ .post(`/oauth/federation-grants/${GRANT_ID}/token`)
359
+ .set("Authorization", BASIC)
360
+ .send({ sub: SUB });
361
+
362
+ /**
363
+ * What a logout has to leave: the record, its credential — the values, not
364
+ * only a readable state, since a rotated or dropped refresh token beside an
365
+ * untouched access token would still read `"ok"` — and a disclosure that
366
+ * still answers.
367
+ */
368
+ async function expectGrantUntouched(app: express.Express, store: MemoryFederationGrantStore) {
369
+ expect((await store.find(GRANT_ID, new Date()))?.status).toBe("active");
370
+ const opened = await store.open(GRANT_ID, new Date());
371
+ if (opened?.credentials.state !== "ok")
372
+ throw new Error("the grant's credential is not readable");
373
+ expect(opened.credentials.value).toMatchObject({
374
+ refreshToken: REFRESH_TOKEN,
375
+ accessToken: { value: ACCESS_TOKEN },
376
+ });
377
+ const response = await disclose(app);
378
+ expect(response.status).toBe(200);
379
+ expect(response.body.access_token).toBe(ACCESS_TOKEN);
380
+ }
381
+
382
+ it("POST /session/logout ends the session's own records and leaves the grant spendable", async () => {
383
+ const { app, config, federationGrantStore, userSessionStore, federationTokenStore } =
384
+ await boot();
385
+ const { cookies, csrfToken, headerName } = await login(app, config);
386
+ const sid = await sidOf(app, cookies);
387
+ // A session-bound upstream token beside the grant: the record #276's
388
+ // guarantee is about, keyed by this session.
389
+ await federationTokenStore.attach(sid, "upstream", {
390
+ accessToken: "session-bound-upstream-token",
391
+ expiresAt: null,
392
+ });
393
+ expect(await userSessionStore.get(sid)).not.toBeNull();
394
+ await expectGrantUntouched(app, federationGrantStore);
395
+
396
+ const logout = await request(app)
397
+ .post("/session/logout")
398
+ .set("Cookie", cookies)
399
+ .set(headerName, csrfToken);
400
+ expect([200, 204]).toContain(logout.status);
401
+
402
+ // The session-bound records are gone (#276 holds) ...
403
+ expect(await userSessionStore.get(sid)).toBeNull();
404
+ expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
405
+ // ... and the grant is not among them.
406
+ await expectGrantUntouched(app, federationGrantStore);
407
+ });
408
+
409
+ it("POST /oauth/logout runs the cascade and leaves the grant spendable", async () => {
410
+ const { app, config, federationGrantStore, userSessionStore, federationTokenStore } =
411
+ await boot();
412
+ const { cookies } = await login(app, config);
413
+ const sid = await sidOf(app, cookies);
414
+ await federationTokenStore.attach(sid, "upstream", {
415
+ accessToken: "session-bound-upstream-token",
416
+ expiresAt: null,
417
+ });
418
+ expect(await userSessionStore.get(sid)).not.toBeNull();
419
+ await expectGrantUntouched(app, federationGrantStore);
420
+
421
+ const logout = await request(app)
422
+ .post("/oauth/logout")
423
+ .type("form")
424
+ .send({ id_token_hint: idTokenHint(sid) });
425
+ expect(logout.status).toBe(200);
426
+
427
+ expect(await userSessionStore.get(sid)).toBeNull();
428
+ expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
429
+ await expectGrantUntouched(app, federationGrantStore);
430
+ });
431
+ });
@@ -112,3 +112,60 @@ describe("googleFederationConfigModule — redirect-policy plumbing (#278)", ()
112
112
  expect(out.redirectAllowlist).toEqual(["https://app.example.com/dashboard"]);
113
113
  });
114
114
  });
115
+
116
+ describe("googleFederationConfigModule — requireAuthorizationResponseIss (#597)", () => {
117
+ it("is absent by default, so the provider's own default (required) applies", () => {
118
+ expect("requireAuthorizationResponseIss" in buildConfig(credentials)).toBe(false);
119
+ });
120
+
121
+ it("forwards the operator's escape hatch", () => {
122
+ expect(
123
+ buildConfig({ ...credentials, requireAuthorizationResponseIss: false })
124
+ .requireAuthorizationResponseIss,
125
+ ).toBe(false);
126
+ expect(
127
+ buildConfig({ ...credentials, requireAuthorizationResponseIss: true })
128
+ .requireAuthorizationResponseIss,
129
+ ).toBe(true);
130
+ });
131
+
132
+ it("reads the strings an environment override produces", () => {
133
+ expect(
134
+ buildConfig({ ...credentials, requireAuthorizationResponseIss: "false" })
135
+ .requireAuthorizationResponseIss,
136
+ ).toBe(false);
137
+ expect(
138
+ buildConfig({ ...credentials, requireAuthorizationResponseIss: "true" })
139
+ .requireAuthorizationResponseIss,
140
+ ).toBe(true);
141
+ });
142
+
143
+ it("reads the spellings every other env-overridable boolean accepts", () => {
144
+ for (const [raw, expected] of [
145
+ ["False", false],
146
+ [" false ", false],
147
+ ["0", false],
148
+ ["TRUE", true],
149
+ ["1", true],
150
+ ] as const) {
151
+ expect(
152
+ buildConfig({ ...credentials, requireAuthorizationResponseIss: raw })
153
+ .requireAuthorizationResponseIss,
154
+ ).toBe(expected);
155
+ }
156
+ });
157
+
158
+ it("refuses an empty value, which elsewhere reads as false: exported-but-empty must not switch this check off", () => {
159
+ expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: "" })).toThrow(
160
+ /federations\.google\.requireAuthorizationResponseIss/,
161
+ );
162
+ });
163
+
164
+ it("refuses anything else: a typo must not silently switch the check off, or leave it on", () => {
165
+ for (const bad of ["no", "off", 0, 1, []]) {
166
+ expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: bad })).toThrow(
167
+ /federations\.google\.requireAuthorizationResponseIss/,
168
+ );
169
+ }
170
+ });
171
+ });
@@ -412,3 +412,17 @@ function dockerignoreExcludes(dockerignore: string, file: string): boolean {
412
412
  }
413
413
  return excluded;
414
414
  }
415
+
416
+ describe("#593 slice 7 — the compose files give the process time to finish a shutdown", () => {
417
+ it.each(["/docker-compose.yml", "/docker-compose.production.yml"])(
418
+ "%s declares a stop_grace_period covering the HTTP drain, the grants cleanup and an exit margin",
419
+ (rel) => {
420
+ // compose's own default is ten seconds — the drain budget alone. With
421
+ // federation grants on, the cleanup that drains a rotated credential's
422
+ // write gets 45 more, and SIGKILL must arrive after that, not during.
423
+ const match = /^\s+stop_grace_period:\s*(\d+)s\s*$/m.exec(read(rel));
424
+ expect(match, `${rel} has no stop_grace_period`).not.toBeNull();
425
+ expect(Number(match?.[1])).toBeGreaterThanOrEqual(60);
426
+ },
427
+ );
428
+ });
@@ -33,7 +33,13 @@
33
33
  import type { Server } from "node:http";
34
34
  import type { Logger } from "@o3co/auth-provider-core";
35
35
  import { describe, expect, it, vi } from "vitest";
36
- import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
36
+ import {
37
+ cleanupAllowanceFor,
38
+ deferExit,
39
+ FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
40
+ FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
41
+ installGracefulShutdown,
42
+ } from "../shutdown.mjs";
37
43
 
38
44
  /** A `Server` double whose `close` callback fires only when we say so. */
39
45
  function makeServer() {
@@ -80,7 +86,13 @@ const makeLogger = () => {
80
86
  };
81
87
 
82
88
  /** Drive one shutdown without touching the real `process` or exiting. */
83
- function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?: number } = {}) {
89
+ function install(
90
+ opts: {
91
+ cleanup?: () => void | Promise<void>;
92
+ drainTimeoutMs?: number;
93
+ cleanupTimeoutMs?: number;
94
+ } = {},
95
+ ) {
84
96
  const { server, spies, finishDraining, failClose } = makeServer();
85
97
  const logger = makeLogger();
86
98
  const exit = vi.fn();
@@ -90,6 +102,7 @@ function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?:
90
102
  logger,
91
103
  cleanup: opts.cleanup ?? (() => {}),
92
104
  ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
105
+ ...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
93
106
  exit,
94
107
  onSignal: (name, handler) => signals.set(name, handler),
95
108
  offSignal: (name) => signals.delete(name),
@@ -322,3 +335,84 @@ describe("installGracefulShutdown (#290)", () => {
322
335
  expect(signals.size).toBe(0);
323
336
  });
324
337
  });
338
+
339
+ describe("#593 slice 7: the cleanup allowance federation grants need", () => {
340
+ it("is at least the 45 seconds the package asks for, and only when the feature is on", () => {
341
+ // The package's drain waits for a rotated credential's write; the
342
+ // default cleanup budget is the ten-second drain, which is shorter than
343
+ // the upstream hard timeout and persist budget the feature ships with —
344
+ // a shutdown under it abandons exactly the write the drain exists for.
345
+ expect(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS).toBeGreaterThanOrEqual(45_000);
346
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: true } })).toEqual({
347
+ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
348
+ });
349
+ // Off, nothing: the cleanup budget stays the drain's, as it was.
350
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: false } })).toEqual({});
351
+ expect(cleanupAllowanceFor({})).toEqual({});
352
+ });
353
+
354
+ it("grows with the configured refresh tail, so a raised budget is not cut off by a fixed timer (Copilot, #614)", () => {
355
+ // The longest tail one refresh has: the upstream hard timeout, the
356
+ // persist budget and the wait for the lock, back to back, plus an exit
357
+ // margin. The shipped budgets (25 s + 3 s + 5 s) land exactly on the
358
+ // floor; a deployment that doubles its upstream timeout gets more.
359
+ const shipped = {
360
+ upstreamHardTimeoutMs: 25_000,
361
+ persistRetryBudgetMs: 3_000,
362
+ lockWaitMs: 5_000,
363
+ };
364
+ expect(
365
+ 25_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
366
+ "the margin is what makes the shipped budgets the floor",
367
+ ).toBe(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS);
368
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: true, ...shipped } })).toEqual({
369
+ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
370
+ });
371
+ expect(
372
+ cleanupAllowanceFor({
373
+ federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 60_000 },
374
+ }),
375
+ ).toEqual({ cleanupTimeoutMs: 60_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS });
376
+ // Lowered budgets never go below the documented minimum, and a config
377
+ // without budgets (built by hand) gets the floor rather than a guess.
378
+ expect(
379
+ cleanupAllowanceFor({
380
+ federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 1_000 },
381
+ }),
382
+ ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
383
+ expect(
384
+ cleanupAllowanceFor({ federationGrants: { enabled: true, upstreamHardTimeoutMs: 90_000 } }),
385
+ ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
386
+ });
387
+
388
+ it("never asks a timer for more than Node can count: an oversized sum is capped, not overflowed (Copilot, #614)", () => {
389
+ // setTimeout takes a 32-bit signed delay; past it the timer fires after
390
+ // about a millisecond, which would turn a generous allowance into none.
391
+ const oversized = cleanupAllowanceFor({
392
+ federationGrants: {
393
+ enabled: true,
394
+ upstreamHardTimeoutMs: 2_000_000_000,
395
+ persistRetryBudgetMs: 2_000_000_000,
396
+ lockWaitMs: 5_000,
397
+ },
398
+ });
399
+ expect(oversized).toEqual({ cleanupTimeoutMs: 2_147_483_647 });
400
+ });
401
+
402
+ it("is honoured by the shutdown: a cleanup that needs longer than the drain is given it", async () => {
403
+ vi.useFakeTimers();
404
+ try {
405
+ const { signals, finishDraining, exit } = install({
406
+ cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
407
+ drainTimeoutMs: 5_000,
408
+ ...cleanupAllowanceFor({ federationGrants: { enabled: true } }),
409
+ });
410
+ signals.get("SIGTERM")?.();
411
+ finishDraining();
412
+ await vi.advanceTimersByTimeAsync(31_000);
413
+ expect(exit).toHaveBeenCalledExactlyOnceWith(0);
414
+ } finally {
415
+ vi.useRealTimers();
416
+ }
417
+ });
418
+ });
@@ -29,7 +29,7 @@ import { buildModules } from "./buildModules.mjs";
29
29
  import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
30
30
  import { createAppLogger } from "./logger.mjs";
31
31
  import { createMetrics } from "./metrics.mjs";
32
- import { installGracefulShutdown } from "./shutdown.mjs";
32
+ import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.mjs";
33
33
  import { createTerminalErrorHandler } from "./terminalError.mjs";
34
34
 
35
35
  // Step 1: Load and validate application config (HOCON → Zod schema).
@@ -164,8 +164,16 @@ await (async (): Promise<void> => {
164
164
  // from the code you deploy. See `shutdown.mts` for the stated guarantees
165
165
  // and for the deadline the previous implementation did not have. Size
166
166
  // `drainTimeoutMs` below your orchestrator's kill grace period.
167
+ //
168
+ // #593 slice 7: with federation grants on, cleanup gets the longest refresh
169
+ // tail the configured budgets allow plus a margin — 45 s under the shipped
170
+ // ones — rather than inheriting the drain's ten: the dispose is what waits
171
+ // for a rotated credential's write. The compose files size
172
+ // `stop_grace_period` to cover the drain, this and an exit margin for the
173
+ // shipped budgets; a raised budget raises both.
167
174
  installGracefulShutdown(server, {
168
175
  logger,
169
176
  cleanup: () => handle.dispose(),
177
+ ...cleanupAllowanceFor(config),
170
178
  });
171
179
  })();