@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.
- package/package.json +1 -1
- package/templates/standalone/.env.example +22 -0
- package/templates/standalone/README.ja.md +74 -1
- package/templates/standalone/README.md +138 -1
- package/templates/standalone/config/application.conf +52 -0
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/package.json +3 -2
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +21 -0
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
- package/templates/standalone/src/app.mts +9 -1
- package/templates/standalone/src/buildModules.mts +66 -1
- package/templates/standalone/src/modules.mts +76 -4
- package/templates/standalone/src/shutdown.mts +70 -4
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
|
@@ -21,20 +21,26 @@ import {
|
|
|
21
21
|
type Module,
|
|
22
22
|
memoryAccessTokenDenylistModule,
|
|
23
23
|
memoryConsentStoreModule,
|
|
24
|
+
memoryFederationGrantIntentStoreModule,
|
|
25
|
+
memoryFederationGrantStoreModule,
|
|
24
26
|
memoryRateLimiterModule,
|
|
25
27
|
memoryReplaySeenSetModule,
|
|
26
28
|
} from "@o3co/auth-provider-core";
|
|
27
29
|
import { googleFederationModule } from "@o3co/auth-provider-federation-google";
|
|
30
|
+
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
28
31
|
import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
|
|
29
32
|
import {
|
|
30
33
|
oauthAuthorizationModule,
|
|
31
34
|
oauthModule,
|
|
32
35
|
oauthSessionModule,
|
|
36
|
+
subjectRevocationServiceModule,
|
|
33
37
|
} from "@o3co/auth-provider-oauth";
|
|
34
38
|
import {
|
|
35
39
|
redisAccessTokenDenylistModule,
|
|
36
40
|
redisCodeRepositoryModule,
|
|
37
41
|
redisConsentStoreModule,
|
|
42
|
+
redisFederationGrantIntentStoreModule,
|
|
43
|
+
redisFederationGrantStoreModuleFor,
|
|
38
44
|
redisFederationTokenStoreModuleFor,
|
|
39
45
|
redisRateLimiterModule,
|
|
40
46
|
redisRefreshTokenFamilyStoreModule,
|
|
@@ -174,6 +180,22 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
174
180
|
// promised and what the previous factory-based module never delivered: it
|
|
175
181
|
// built the Redis store without a client and failed at boot.
|
|
176
182
|
const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
|
|
183
|
+
// #593 slice 7: federation grants — a user's standing consent that a client
|
|
184
|
+
// may obtain upstream tokens with no session behind the call. Off by
|
|
185
|
+
// default, and off installs nothing: no store, no socket, no boot
|
|
186
|
+
// requirement a deployment that never asked for grants would have to meet.
|
|
187
|
+
// On, the routes and the background registry the shutdown drains come as a
|
|
188
|
+
// pair, each store follows its own switch (grants in Redis with acquisition
|
|
189
|
+
// in memory is a supported single-replica shape), and the subject-revocation
|
|
190
|
+
// service is installed so a credential change can reach the grants — its
|
|
191
|
+
// `federationGrantStore` edge is what makes "revoke everything this subject
|
|
192
|
+
// holds" include them. Enabling the feature is also a statement that the
|
|
193
|
+
// deployment has a consent page, a callback per connection and a user
|
|
194
|
+
// repository that covers each connection's registration; the routes module
|
|
195
|
+
// refuses at boot what is missing, naming it.
|
|
196
|
+
const federationGrantsEnabled = config.federationGrants?.enabled === true;
|
|
197
|
+
const federationGrantStoreAdapter = config.federationGrantStore?.adapter ?? "memory";
|
|
198
|
+
const federationGrantIntentStoreAdapter = config.federationGrantIntentStore?.adapter ?? "memory";
|
|
177
199
|
|
|
178
200
|
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
179
201
|
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
@@ -234,7 +256,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
234
256
|
accessTokenDenylistAdapter === "redis" ||
|
|
235
257
|
replaySeenSetAdapter === "redis" ||
|
|
236
258
|
consentStoreAdapter === "redis" ||
|
|
237
|
-
federationTokenStoreAdapter === "redis"
|
|
259
|
+
federationTokenStoreAdapter === "redis" ||
|
|
260
|
+
// Only while the feature is on: a switch left at "redis" for a feature
|
|
261
|
+
// that is off must not open a socket.
|
|
262
|
+
(federationGrantsEnabled &&
|
|
263
|
+
(federationGrantStoreAdapter === "redis" || federationGrantIntentStoreAdapter === "redis"));
|
|
238
264
|
|
|
239
265
|
// The four user-session stores switch on `userSessionStores.adapter`; the
|
|
240
266
|
// federation-token store is always wired and switches on its own key
|
|
@@ -299,6 +325,29 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
299
325
|
? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
|
|
300
326
|
: [inMemoryFederationTokenStoreModule];
|
|
301
327
|
|
|
328
|
+
// #593 slice 7: one module per store, nothing while the feature is off.
|
|
329
|
+
// The Redis grant store is built for this composition root so its
|
|
330
|
+
// plaintext guard knows which environment selected the config (#473), as
|
|
331
|
+
// the federation-token store's is. Both memory modules declare
|
|
332
|
+
// `replicaSafety`, so `deployment.mode = "multi"` refuses them by name; a
|
|
333
|
+
// Redis grant store beside memory user-session stores is refused by the
|
|
334
|
+
// routes module itself, because the grants would outlive the boundary that
|
|
335
|
+
// ends them (D13).
|
|
336
|
+
const federationGrantStoreModules: Module[] = !federationGrantsEnabled
|
|
337
|
+
? []
|
|
338
|
+
: federationGrantStoreAdapter === "redis"
|
|
339
|
+
? [
|
|
340
|
+
overrides.environment === undefined
|
|
341
|
+
? redisFederationGrantStoreModuleFor()
|
|
342
|
+
: redisFederationGrantStoreModuleFor({ environment: overrides.environment }),
|
|
343
|
+
]
|
|
344
|
+
: [memoryFederationGrantStoreModule];
|
|
345
|
+
const federationGrantIntentStoreModules: Module[] = !federationGrantsEnabled
|
|
346
|
+
? []
|
|
347
|
+
: federationGrantIntentStoreAdapter === "redis"
|
|
348
|
+
? [redisFederationGrantIntentStoreModule]
|
|
349
|
+
: [memoryFederationGrantIntentStoreModule];
|
|
350
|
+
|
|
302
351
|
return [
|
|
303
352
|
// D-5: sessionStoreModule wires the express-session middleware into the
|
|
304
353
|
// boot-planner-managed lifecycle. **Mount order is enforced by this
|
|
@@ -310,6 +359,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
310
359
|
// `session.storage.type = "memory"` declares itself replica-unsafe and
|
|
311
360
|
// `deployment.mode = "multi"` refuses it by name like the other stores.
|
|
312
361
|
sessionStoreModuleFor(config),
|
|
362
|
+
// #593 slice 7: before `oauthModule`, whose router mounts under the same
|
|
363
|
+
// `/oauth` prefix with body parsers of its own — mounted after it, a
|
|
364
|
+
// malformed body would be refused by the OAuth router's parser without
|
|
365
|
+
// this package's request id, `Cache-Control: no-store` or throttle (the
|
|
366
|
+
// package README's mounting-order rule). The browser half sits after the
|
|
367
|
+
// session middleware by its own `after`, wherever it is listed.
|
|
368
|
+
...(federationGrantsEnabled ? federationGrantsModules : []),
|
|
313
369
|
oauthModule({ config }),
|
|
314
370
|
oauthSessionModule({ config }),
|
|
315
371
|
oauthAuthorizationModule({ config }),
|
|
@@ -343,6 +399,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
343
399
|
// bundled into `storesModule` pre-Wave-5d, and the redis session-stores
|
|
344
400
|
// branch dropped the provider.
|
|
345
401
|
...federationTokenStoreModules,
|
|
402
|
+
// #593 slice 7: the grant store and the intent store, when the feature is on.
|
|
403
|
+
...federationGrantStoreModules,
|
|
404
|
+
...federationGrantIntentStoreModules,
|
|
346
405
|
// User-session-store family: redis (multi-replica) or memory (dev).
|
|
347
406
|
...sessionStoresModules,
|
|
348
407
|
// OAuth-endpoint rate limiter: redis (shared counters) or memory.
|
|
@@ -361,5 +420,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
361
420
|
...refreshTokenFamilyModules,
|
|
362
421
|
defaultRefreshTokenFamilyRotationModule,
|
|
363
422
|
defaultRefreshTokenFamilyRevocationModule,
|
|
423
|
+
// #593 slice 7: the composed "end everything this subject holds" a
|
|
424
|
+
// credential change calls, reached as `handle.components.subjectRevocationService`.
|
|
425
|
+
// Installed with the feature, which is what makes it reach the grants;
|
|
426
|
+
// the template never installed it before, so a deployment without
|
|
427
|
+
// grants is as it was — a Store there calls core's revokeAllForSubject.
|
|
428
|
+
...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
|
|
364
429
|
];
|
|
365
430
|
}
|
|
@@ -39,7 +39,11 @@ import {
|
|
|
39
39
|
import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
|
|
40
40
|
import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
|
|
41
41
|
import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
makeIoredisClients,
|
|
44
|
+
makeIoredisFederationGrantIntentStoreClient,
|
|
45
|
+
makeIoredisFederationGrantStoreClient,
|
|
46
|
+
} from "@o3co/auth-provider-redis/ioredis";
|
|
43
47
|
import { extractFederationSection } from "@o3co/auth-provider-session";
|
|
44
48
|
// Named import is required here, not default. ioredis is CJS and its entry
|
|
45
49
|
// does `module.exports = Redis` with the class re-exported as both `default`
|
|
@@ -438,6 +442,28 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
438
442
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
439
443
|
.sessionFederationIndexClient;
|
|
440
444
|
},
|
|
445
|
+
// #593 slice 7: the grant store and the intent store, off the same
|
|
446
|
+
// socket. Consumed by the Redis grant modules when their switches say
|
|
447
|
+
// "redis"; provided whenever this module is installed, as every slot
|
|
448
|
+
// here is, because a slot is cheap and the socket is the cost.
|
|
449
|
+
federationGrantStoreClient: async ({
|
|
450
|
+
config,
|
|
451
|
+
lifecycleRegistrar,
|
|
452
|
+
readinessRegistrar,
|
|
453
|
+
logger,
|
|
454
|
+
}) => {
|
|
455
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
456
|
+
.federationGrantStoreClient;
|
|
457
|
+
},
|
|
458
|
+
federationGrantIntentStoreClient: async ({
|
|
459
|
+
config,
|
|
460
|
+
lifecycleRegistrar,
|
|
461
|
+
readinessRegistrar,
|
|
462
|
+
logger,
|
|
463
|
+
}) => {
|
|
464
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
465
|
+
.federationGrantIntentStoreClient;
|
|
466
|
+
},
|
|
441
467
|
// #484: the replay seen-set behind private_key_jwt client assertions.
|
|
442
468
|
replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
443
469
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
@@ -630,13 +656,26 @@ const SHARED_REDIS_TIMEOUTS = {
|
|
|
630
656
|
// on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
|
|
631
657
|
// that don't seed it), each call creates a fresh client; tests are
|
|
632
658
|
// isolated and don't need cross-slot sharing.
|
|
633
|
-
|
|
659
|
+
/**
|
|
660
|
+
* Every per-purpose client off the one socket: the general bundle, plus the
|
|
661
|
+
* two the federation-grant stores need (#593 slice 7). Those two have their
|
|
662
|
+
* own factories in the Redis package, so a Cluster deployment can give the
|
|
663
|
+
* grants a connection of their own; this template shares the socket, as it
|
|
664
|
+
* does for every other store.
|
|
665
|
+
*/
|
|
666
|
+
type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
|
|
667
|
+
readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
|
|
668
|
+
readonly federationGrantIntentStoreClient: ReturnType<
|
|
669
|
+
typeof makeIoredisFederationGrantIntentStoreClient
|
|
670
|
+
>;
|
|
671
|
+
};
|
|
672
|
+
const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
|
|
634
673
|
function getOrCreateClients(
|
|
635
674
|
config: AppConfig,
|
|
636
675
|
lifecycleRegistrar: LifecycleRegistrar | undefined,
|
|
637
676
|
readinessRegistrar?: ReadinessRegistrar,
|
|
638
677
|
injectedLogger?: Logger,
|
|
639
|
-
):
|
|
678
|
+
): StandaloneRedisClients {
|
|
640
679
|
const logger = injectedLogger ?? consoleLogger;
|
|
641
680
|
const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
|
|
642
681
|
if (cached) return cached;
|
|
@@ -676,7 +715,11 @@ function getOrCreateClients(
|
|
|
676
715
|
// The wrapper opens its own connections for refresh rotation
|
|
677
716
|
// (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
|
|
678
717
|
// `io`; passing the logger lets those report through the same channel.
|
|
679
|
-
const clients =
|
|
718
|
+
const clients: StandaloneRedisClients = {
|
|
719
|
+
...makeIoredisClients(io, { logger }),
|
|
720
|
+
federationGrantStoreClient: makeIoredisFederationGrantStoreClient(io),
|
|
721
|
+
federationGrantIntentStoreClient: makeIoredisFederationGrantIntentStoreClient(io),
|
|
722
|
+
};
|
|
680
723
|
if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
|
|
681
724
|
return clients;
|
|
682
725
|
}
|
|
@@ -703,6 +746,33 @@ function optionalString(
|
|
|
703
746
|
return { [field]: value };
|
|
704
747
|
}
|
|
705
748
|
|
|
749
|
+
/**
|
|
750
|
+
* An optional boolean from the `federations.google` slice. HOCON gives a
|
|
751
|
+
* boolean; an environment override (`${?VAR}`) gives the string, in the
|
|
752
|
+
* spellings core's `coerceBooleanFromEnv` accepts: "true" / "false" / "1" /
|
|
753
|
+
* "0", trimmed and in any case. One deliberate difference: that coercion reads
|
|
754
|
+
* an empty value as false, and this refuses it. HOCON substitutes an
|
|
755
|
+
* exported-but-empty variable as "", and for a security switch that must not
|
|
756
|
+
* quietly turn the check off. Anything else is refused too — a typo that
|
|
757
|
+
* silently reads as either value is worse than a boot failure.
|
|
758
|
+
*/
|
|
759
|
+
function optionalBoolean(
|
|
760
|
+
slice: Record<string, unknown>,
|
|
761
|
+
field: string,
|
|
762
|
+
): Record<string, boolean> | Record<string, never> {
|
|
763
|
+
const value = slice[field];
|
|
764
|
+
if (value === undefined || value === null) return {};
|
|
765
|
+
if (typeof value === "boolean") return { [field]: value };
|
|
766
|
+
if (typeof value === "string") {
|
|
767
|
+
const normalized = value.trim().toLowerCase();
|
|
768
|
+
if (normalized === "true" || normalized === "1") return { [field]: true };
|
|
769
|
+
if (normalized === "false" || normalized === "0") return { [field]: false };
|
|
770
|
+
}
|
|
771
|
+
throw new Error(
|
|
772
|
+
`federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
706
776
|
/**
|
|
707
777
|
* Google federation config bridge — supplies the typed `googleFederationConfig`
|
|
708
778
|
* ComponentMap slot from the `config.federations.google` slice.
|
|
@@ -769,6 +839,8 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
769
839
|
...optionalString(slice, "sessionDomain"),
|
|
770
840
|
...optionalString(slice, "authCallbackUrl"),
|
|
771
841
|
...optionalString(slice, "clientUrl"),
|
|
842
|
+
// #597: absent means the provider's default, which is to require it.
|
|
843
|
+
...optionalBoolean(slice, "requireAuthorizationResponseIss"),
|
|
772
844
|
};
|
|
773
845
|
},
|
|
774
846
|
},
|
|
@@ -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
|
|
65
|
-
* (Kubernetes `terminationGracePeriodSeconds
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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,
|
|
@@ -37,13 +37,24 @@
|
|
|
37
37
|
* is sent (`end`, which `then` and `expect(..., fn)` go through). A server the
|
|
38
38
|
* test started itself is never touched.
|
|
39
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
|
+
*
|
|
40
47
|
* supertest is loaded from the package that owns the running test file (the
|
|
41
48
|
* nearest `package.json` above it), so the patched class is the one that test
|
|
42
49
|
* imports, whatever directory vitest was started from. A package that does
|
|
43
50
|
* not declare supertest is left alone. Everything else this file cannot do —
|
|
44
51
|
* no test path, a declared supertest that does not load, a supertest whose
|
|
45
52
|
* internals no longer look like this — throws, so the guard is never silently
|
|
46
|
-
* off.
|
|
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.
|
|
47
58
|
*
|
|
48
59
|
* This file is part of the project template and ships with every scaffold.
|
|
49
60
|
* The auth.provider workspace loads this same file for its packages (see
|
|
@@ -160,8 +171,8 @@ function patch(proto: TestPrototype): void {
|
|
|
160
171
|
const protocol = app instanceof TlsServer ? "https" : "http";
|
|
161
172
|
let ready = binding.get(app);
|
|
162
173
|
if (!ready) {
|
|
163
|
-
// supertest closes `_server` after the response
|
|
164
|
-
//
|
|
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.
|
|
165
176
|
this._server = app.listen(0, LOOPBACK);
|
|
166
177
|
// Subscribed now, not in `end`: a listen error emitted before the test
|
|
167
178
|
// sends the request must still reach it, not crash the worker as an
|
|
@@ -188,6 +199,14 @@ function patch(proto: TestPrototype): void {
|
|
|
188
199
|
let called = false;
|
|
189
200
|
const callback = (err: unknown, res?: unknown) => {
|
|
190
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
|
+
}
|
|
191
210
|
fn?.(err, res);
|
|
192
211
|
};
|
|
193
212
|
// Unpatched, whatever `end` throws while building the request (a header
|
package/templates/versions.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.
|
|
3
|
-
"@o3co/auth-provider-device-grant": "0.
|
|
4
|
-
"@o3co/auth-provider-dpop": "0.
|
|
5
|
-
"@o3co/auth-provider-mtls": "0.
|
|
6
|
-
"@o3co/auth-provider-federation-apple": "0.
|
|
7
|
-
"@o3co/auth-provider-federation-google": "0.
|
|
8
|
-
"@o3co/auth-provider-federation-github": "0.
|
|
9
|
-
"@o3co/auth-provider-federation-
|
|
10
|
-
"@o3co/auth-provider-
|
|
11
|
-
"@o3co/auth-provider-oauth
|
|
12
|
-
"@o3co/auth-provider-
|
|
13
|
-
"@o3co/auth-provider-
|
|
14
|
-
"@o3co/auth-provider-
|
|
15
|
-
"@o3co/auth-provider-
|
|
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
|
}
|