@o3co/create-auth-provider 0.14.0 → 0.16.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/README.ja.md +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +34 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +565 -130
- package/templates/standalone/README.md +356 -92
- package/templates/standalone/config/application.conf +77 -2
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +3 -2
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
- package/templates/standalone/src/__tests__/listen.test.mts +128 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
- package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
- package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
- package/templates/standalone/src/app.mts +30 -51
- package/templates/standalone/src/buildModules.mts +94 -12
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +118 -12
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +90 -18
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -44,10 +44,13 @@ export function createAppLogger(config: AppConfig): Logger {
|
|
|
44
44
|
return pino({
|
|
45
45
|
name: "provider",
|
|
46
46
|
level: config.logging.level,
|
|
47
|
-
// `err` is pino's conventional key for an
|
|
48
|
-
// event in this stack uses it — `logger.error({ err },
|
|
49
|
-
//
|
|
50
|
-
//
|
|
47
|
+
// `err` is pino's conventional key for an error, and every structured
|
|
48
|
+
// event in this stack uses it — `logger.error({ err: loggableError(err) },
|
|
49
|
+
// "…_error")`. Core's projection is plain data with no `message`, so
|
|
50
|
+
// this serialiser hands it through as it is, every cause's fields
|
|
51
|
+
// included. It is here for an Error handed to it as it is, which would
|
|
52
|
+
// otherwise stringify to `{}`, the stack lost exactly where it is
|
|
53
|
+
// needed.
|
|
51
54
|
serializers: { err: stdSerializers.err },
|
|
52
55
|
});
|
|
53
56
|
}
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
defineModule,
|
|
31
31
|
type LifecycleRegistrar,
|
|
32
32
|
type Logger,
|
|
33
|
+
loggableError,
|
|
33
34
|
type Module,
|
|
34
35
|
type ReadinessRegistrar,
|
|
35
36
|
registerBuiltinAuditSinks,
|
|
@@ -39,7 +40,11 @@ import {
|
|
|
39
40
|
import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
|
|
40
41
|
import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
|
|
41
42
|
import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
|
|
42
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
makeIoredisClients,
|
|
45
|
+
makeIoredisFederationGrantIntentStoreClient,
|
|
46
|
+
makeIoredisFederationGrantStoreClient,
|
|
47
|
+
} from "@o3co/auth-provider-redis/ioredis";
|
|
43
48
|
import { extractFederationSection } from "@o3co/auth-provider-session";
|
|
44
49
|
// Named import is required here, not default. ioredis is CJS and its entry
|
|
45
50
|
// does `module.exports = Redis` with the class re-exported as both `default`
|
|
@@ -366,7 +371,11 @@ export const storesModule: Module = defineModule({
|
|
|
366
371
|
* Redis-backed adapters (refresh-token-family + 4 user-session stores +
|
|
367
372
|
* subject-level revocation index + watermark + rate limiter + code
|
|
368
373
|
* repository + access-token denylist + replay seen-set + federation token
|
|
369
|
-
* store + device-code store + the consent stores)
|
|
374
|
+
* store + device-code store + WebAuthn challenge store + the consent stores),
|
|
375
|
+
* and the two federation-grant stores' clients from their own factories.
|
|
376
|
+
* Every store module `@o3co/auth-provider-redis` ships finds its client
|
|
377
|
+
* slot here, including the ones this template does not select — pinned by
|
|
378
|
+
* `all-modules-composition.multi.test.mts`.
|
|
370
379
|
*
|
|
371
380
|
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
372
381
|
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
@@ -392,11 +401,12 @@ export const storesModule: Module = defineModule({
|
|
|
392
401
|
* the single connection cleanly.
|
|
393
402
|
*
|
|
394
403
|
* Conditional inclusion: `buildModules` only adds this module to the
|
|
395
|
-
* manifest when at least one
|
|
396
|
-
* (`
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
404
|
+
* manifest when at least one composed module needs a Redis client
|
|
405
|
+
* (`usingRedisAnywhere` there): an adapter switch set to `"redis"` — the two
|
|
406
|
+
* federation-grant store switches only while `federationGrants.enabled` —
|
|
407
|
+
* and the refresh-token family store, which is Redis-backed unless a test
|
|
408
|
+
* override replaces it. Adding the module unconditionally would open an
|
|
409
|
+
* ioredis socket for memory-only compositions.
|
|
400
410
|
*/
|
|
401
411
|
export const standaloneRedisClientsModule: Module = defineModule({
|
|
402
412
|
name: "standalone:redis-clients",
|
|
@@ -438,6 +448,28 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
438
448
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
439
449
|
.sessionFederationIndexClient;
|
|
440
450
|
},
|
|
451
|
+
// #593 slice 7: the grant store and the intent store, off the same
|
|
452
|
+
// socket. Consumed by the Redis grant modules when their switches say
|
|
453
|
+
// "redis"; provided whenever this module is installed, as every slot
|
|
454
|
+
// here is, because a slot is cheap and the socket is the cost.
|
|
455
|
+
federationGrantStoreClient: async ({
|
|
456
|
+
config,
|
|
457
|
+
lifecycleRegistrar,
|
|
458
|
+
readinessRegistrar,
|
|
459
|
+
logger,
|
|
460
|
+
}) => {
|
|
461
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
462
|
+
.federationGrantStoreClient;
|
|
463
|
+
},
|
|
464
|
+
federationGrantIntentStoreClient: async ({
|
|
465
|
+
config,
|
|
466
|
+
lifecycleRegistrar,
|
|
467
|
+
readinessRegistrar,
|
|
468
|
+
logger,
|
|
469
|
+
}) => {
|
|
470
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
471
|
+
.federationGrantIntentStoreClient;
|
|
472
|
+
},
|
|
441
473
|
// #484: the replay seen-set behind private_key_jwt client assertions.
|
|
442
474
|
replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
443
475
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
@@ -505,6 +537,15 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
505
537
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
506
538
|
.deviceCodeStoreClient;
|
|
507
539
|
},
|
|
540
|
+
// The WebAuthn challenge store's client, off the same shared socket, for
|
|
541
|
+
// the reason the device-code slot above is here: this template does not
|
|
542
|
+
// mount WebAuthn, and a deployment that adds `webauthnModule` with
|
|
543
|
+
// `redisChallengeStoreModule` would otherwise be refused at boot for a
|
|
544
|
+
// `challengeStoreClient` nothing provided.
|
|
545
|
+
challengeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
546
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
547
|
+
.challengeStoreClient;
|
|
548
|
+
},
|
|
508
549
|
// #561: the consent stores' clients, off the same shared socket.
|
|
509
550
|
// `redisConsentStoreModule` requires both — it provides the consent
|
|
510
551
|
// records and the parked requests together — and `buildModules` selects
|
|
@@ -630,13 +671,26 @@ const SHARED_REDIS_TIMEOUTS = {
|
|
|
630
671
|
// on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
|
|
631
672
|
// that don't seed it), each call creates a fresh client; tests are
|
|
632
673
|
// isolated and don't need cross-slot sharing.
|
|
633
|
-
|
|
674
|
+
/**
|
|
675
|
+
* Every per-purpose client off the one socket: the general bundle, plus the
|
|
676
|
+
* two the federation-grant stores need (#593 slice 7). Those two have their
|
|
677
|
+
* own factories in the Redis package, so a Cluster deployment can give the
|
|
678
|
+
* grants a connection of their own; this template shares the socket, as it
|
|
679
|
+
* does for every other store.
|
|
680
|
+
*/
|
|
681
|
+
type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
|
|
682
|
+
readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
|
|
683
|
+
readonly federationGrantIntentStoreClient: ReturnType<
|
|
684
|
+
typeof makeIoredisFederationGrantIntentStoreClient
|
|
685
|
+
>;
|
|
686
|
+
};
|
|
687
|
+
const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
|
|
634
688
|
function getOrCreateClients(
|
|
635
689
|
config: AppConfig,
|
|
636
690
|
lifecycleRegistrar: LifecycleRegistrar | undefined,
|
|
637
691
|
readinessRegistrar?: ReadinessRegistrar,
|
|
638
692
|
injectedLogger?: Logger,
|
|
639
|
-
):
|
|
693
|
+
): StandaloneRedisClients {
|
|
640
694
|
const logger = injectedLogger ?? consoleLogger;
|
|
641
695
|
const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
|
|
642
696
|
if (cached) return cached;
|
|
@@ -656,9 +710,11 @@ function getOrCreateClients(
|
|
|
656
710
|
|
|
657
711
|
// Attach an error handler so unhandled "error" events do not crash the
|
|
658
712
|
// process. Initial connection failures surface here; downstream adapter
|
|
659
|
-
// operations then fail visibly.
|
|
713
|
+
// operations then fail visibly. It logs the projection: ioredis puts the
|
|
714
|
+
// command a reply answered on the error, and for a refused handshake that
|
|
715
|
+
// is `AUTH` with the configured password.
|
|
660
716
|
io.on("error", (err: unknown) => {
|
|
661
|
-
logger.error({ err }, "standalone_redis_clients_error");
|
|
717
|
+
logger.error({ err: loggableError(err) }, "standalone_redis_clients_error");
|
|
662
718
|
});
|
|
663
719
|
|
|
664
720
|
lifecycleRegistrar?.register(async () => {
|
|
@@ -676,7 +732,11 @@ function getOrCreateClients(
|
|
|
676
732
|
// The wrapper opens its own connections for refresh rotation
|
|
677
733
|
// (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
|
|
678
734
|
// `io`; passing the logger lets those report through the same channel.
|
|
679
|
-
const clients =
|
|
735
|
+
const clients: StandaloneRedisClients = {
|
|
736
|
+
...makeIoredisClients(io, { logger }),
|
|
737
|
+
federationGrantStoreClient: makeIoredisFederationGrantStoreClient(io),
|
|
738
|
+
federationGrantIntentStoreClient: makeIoredisFederationGrantIntentStoreClient(io),
|
|
739
|
+
};
|
|
680
740
|
if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
|
|
681
741
|
return clients;
|
|
682
742
|
}
|
|
@@ -703,6 +763,47 @@ function optionalString(
|
|
|
703
763
|
return { [field]: value };
|
|
704
764
|
}
|
|
705
765
|
|
|
766
|
+
/**
|
|
767
|
+
* An optional boolean from the `federations.google` slice. HOCON gives a
|
|
768
|
+
* boolean; an environment override (`${?VAR}`) gives the string, in the
|
|
769
|
+
* spellings core's `coerceBooleanFromEnv` accepts: "true" / "false" / "1" /
|
|
770
|
+
* "0", trimmed and in any case. One deliberate difference: that coercion reads
|
|
771
|
+
* an empty value as false, and this refuses it. HOCON substitutes an
|
|
772
|
+
* exported-but-empty variable as "", and for a security switch that must not
|
|
773
|
+
* quietly turn the check off. Anything else is refused too — a typo that
|
|
774
|
+
* silently reads as either value is worse than a boot failure.
|
|
775
|
+
*/
|
|
776
|
+
function optionalBoolean(
|
|
777
|
+
slice: Record<string, unknown>,
|
|
778
|
+
field: string,
|
|
779
|
+
): Record<string, boolean> | Record<string, never> {
|
|
780
|
+
const value = slice[field];
|
|
781
|
+
if (value === undefined || value === null) return {};
|
|
782
|
+
if (typeof value === "boolean") return { [field]: value };
|
|
783
|
+
if (typeof value === "string") {
|
|
784
|
+
const normalized = value.trim().toLowerCase();
|
|
785
|
+
if (normalized === "true" || normalized === "1") return { [field]: true };
|
|
786
|
+
if (normalized === "false" || normalized === "0") return { [field]: false };
|
|
787
|
+
}
|
|
788
|
+
throw new Error(
|
|
789
|
+
`federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* `accessType` from the `federations.google` slice: `"offline"` or `"online"`,
|
|
795
|
+
* exactly, or absent. `federation-google` refuses any other value too; this
|
|
796
|
+
* refuses it first, naming the key, as the fields above do.
|
|
797
|
+
*/
|
|
798
|
+
function optionalAccessType(
|
|
799
|
+
slice: Record<string, unknown>,
|
|
800
|
+
): { accessType: "offline" | "online" } | Record<string, never> {
|
|
801
|
+
const value = slice.accessType;
|
|
802
|
+
if (value === undefined || value === null) return {};
|
|
803
|
+
if (value === "offline" || value === "online") return { accessType: value };
|
|
804
|
+
throw new Error('federations.google.accessType must be "offline" or "online" when present');
|
|
805
|
+
}
|
|
806
|
+
|
|
706
807
|
/**
|
|
707
808
|
* Google federation config bridge — supplies the typed `googleFederationConfig`
|
|
708
809
|
* ComponentMap slot from the `config.federations.google` slice.
|
|
@@ -769,6 +870,11 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
769
870
|
...optionalString(slice, "sessionDomain"),
|
|
770
871
|
...optionalString(slice, "authCallbackUrl"),
|
|
771
872
|
...optionalString(slice, "clientUrl"),
|
|
873
|
+
// #597: absent means the provider's default, which is to require it.
|
|
874
|
+
...optionalBoolean(slice, "requireAuthorizationResponseIss"),
|
|
875
|
+
// Absent means the provider's default, "offline": consent on every
|
|
876
|
+
// sign-in, and a refresh token for every session.
|
|
877
|
+
...optionalAccessType(slice),
|
|
772
878
|
};
|
|
773
879
|
},
|
|
774
880
|
},
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
* The host's routes, in the order `app.mts` mounts them: liveness, readiness
|
|
19
|
+
* and the metrics scrape ahead of the composed auth router — so they keep
|
|
20
|
+
* answering while the auth pipeline is degraded, which is when an operator
|
|
21
|
+
* needs them — then that router, then core's terminal error handler, last,
|
|
22
|
+
* because Express hands an error only to handlers mounted after the route
|
|
23
|
+
* that raised it. A function rather than inline in `app.mts`, so the order
|
|
24
|
+
* and the handler can be tested as the process mounts them.
|
|
25
|
+
*
|
|
26
|
+
* The handler is core's `terminalErrorHandler`, the one that already ends
|
|
27
|
+
* the composed router, so an error one of the host's routes lets through
|
|
28
|
+
* is answered as every other route's is: `500 server_error` /
|
|
29
|
+
* `unexpected_error` in the RFC 6749 envelope with `Cache-Control:
|
|
30
|
+
* no-store`, logged once as `unhandled_request_error` with the path through
|
|
31
|
+
* `auditErrorText` and the error's `loggableError` projection; a refusal an
|
|
32
|
+
* `http-errors` error marks as the client's keeps its 4xx; a response whose
|
|
33
|
+
* headers already went out is closed rather than handed to Express's final
|
|
34
|
+
* handler, which would print the stack.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
createHealthcheckRouter,
|
|
39
|
+
createReadinessRouter,
|
|
40
|
+
type Logger,
|
|
41
|
+
type ReadinessProbe,
|
|
42
|
+
terminalErrorHandler,
|
|
43
|
+
} from "@o3co/auth-provider-core";
|
|
44
|
+
import express, { type Express, type Router } from "express";
|
|
45
|
+
import type { Metrics } from "./metrics.mjs";
|
|
46
|
+
|
|
47
|
+
export interface MountRoutesOptions {
|
|
48
|
+
/** The router `createApp` composed (`handle.router`). */
|
|
49
|
+
readonly router: Router;
|
|
50
|
+
/** The readiness probes the builders registered (`handle.readinessProbes`). */
|
|
51
|
+
readonly probes: readonly ReadinessProbe[];
|
|
52
|
+
/** Per-probe deadline, in milliseconds: `config.http.readinessTimeoutMs`. */
|
|
53
|
+
readonly readinessTimeoutMs: number;
|
|
54
|
+
readonly metrics: Metrics;
|
|
55
|
+
readonly logger: Logger;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mount the host's routes, the composed router and the terminal error handler on `app`. */
|
|
59
|
+
export function mountRoutes(app: Express, options: MountRoutesOptions): void {
|
|
60
|
+
// Liveness: the process is up and its event loop is turning. Deliberately
|
|
61
|
+
// static — restarting the process would not bring Redis back, so a Redis
|
|
62
|
+
// outage must not read as "this container is broken, kill it".
|
|
63
|
+
app.use(createHealthcheckRouter(express));
|
|
64
|
+
|
|
65
|
+
// Readiness: can this replica serve right now? Redis backs sessions,
|
|
66
|
+
// authorization codes and refresh-token families in the deployable
|
|
67
|
+
// defaults, so a replica that has lost it answers 503 here and should be
|
|
68
|
+
// taken out of rotation. Probes are contributed by the builders that own
|
|
69
|
+
// each connection; a memory-only deployment registers none and is always
|
|
70
|
+
// ready.
|
|
71
|
+
app.use(
|
|
72
|
+
createReadinessRouter(express, {
|
|
73
|
+
probes: options.probes,
|
|
74
|
+
timeoutMs: options.readinessTimeoutMs,
|
|
75
|
+
logger: options.logger,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Prometheus scrape endpoint. Same reasoning as the probes above: it has to
|
|
80
|
+
// keep answering while the auth pipeline is degraded, because that is when
|
|
81
|
+
// the series matter.
|
|
82
|
+
app.use(
|
|
83
|
+
options.metrics.route(express, {
|
|
84
|
+
probes: options.probes,
|
|
85
|
+
probeTimeoutMs: options.readinessTimeoutMs,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// The composed auth router, which ends in core's terminal handler.
|
|
90
|
+
app.use(options.router);
|
|
91
|
+
// Core's terminal handler again, LAST (#293 item 8), for the host routes
|
|
92
|
+
// above: Express routes an error only to handlers registered after the
|
|
93
|
+
// route that threw it. See the file header.
|
|
94
|
+
app.use(terminalErrorHandler(options.logger));
|
|
95
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { Server } from "node:http";
|
|
18
|
-
import type
|
|
18
|
+
import { type Logger, loggableError } from "@o3co/auth-provider-core";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Graceful shutdown for the scaffolded server (#290).
|
|
@@ -54,17 +54,25 @@ import type { Logger } from "@o3co/auth-provider-core";
|
|
|
54
54
|
* and the process exits **non-zero** — an orchestrator that only ever sees
|
|
55
55
|
* `0` cannot tell a clean drain from one that ran out of time.
|
|
56
56
|
* 5. **`cleanup` runs after draining, before exit**, and its failure is logged
|
|
57
|
-
* through the app logger
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* through the app logger — as core's `loggableError` projection, never the
|
|
58
|
+
* error, which holds every cleanup's own error — and reflected in the exit
|
|
59
|
+
* code. It never wedges the process: a dispose that throws still exits, and
|
|
60
|
+
* one that never settles is cut off at `cleanupTimeoutMs`.
|
|
60
61
|
* 6. **A `close` that fails is not reported as a clean drain.** `server.close`
|
|
61
62
|
* reports through its callback, and treating that as success would tell an
|
|
62
63
|
* orchestrator the listener came down when it did not.
|
|
63
64
|
*
|
|
64
|
-
*
|
|
65
|
-
* (
|
|
66
|
-
*
|
|
67
|
-
*
|
|
65
|
+
* Every stage is one object-first line under a snake_case event name:
|
|
66
|
+
* `shutdown_draining` (info, `drainTimeoutMs`), `shutdown_drain_deadline_exceeded`
|
|
67
|
+
* (error), `shutdown_server_close_failed` (error, `err`), `shutdown_cleanup_timed_out`
|
|
68
|
+
* (error, `cleanupTimeoutMs`), `shutdown_cleanup_failed` (error, `err`) and
|
|
69
|
+
* `shutdown_complete` (info, `reason`, `drain`, `exitCode`).
|
|
70
|
+
*
|
|
71
|
+
* Size `drainTimeoutMs` plus `cleanupTimeoutMs` **below** the orchestrator's
|
|
72
|
+
* own kill grace period (Kubernetes `terminationGracePeriodSeconds` is 30s by
|
|
73
|
+
* default, compose `stop_grace_period` 10s; with federation grants on the sum
|
|
74
|
+
* is 55s, so the grace is 60s or more) — the point is to close on our terms
|
|
75
|
+
* before SIGKILL arrives on someone else's.
|
|
68
76
|
*/
|
|
69
77
|
export interface GracefulShutdownOptions {
|
|
70
78
|
readonly logger: Logger;
|
|
@@ -102,6 +110,71 @@ export function deferExit(code: number, exitProcess: (code: number) => void = pr
|
|
|
102
110
|
const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
|
|
103
111
|
const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
|
|
104
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The least cleanup allowance federation grants get (#593 slice 7): 45
|
|
115
|
+
* seconds. The package's background registry drains on dispose — it waits for
|
|
116
|
+
* a rotated upstream credential's write — and the longest tail one refresh
|
|
117
|
+
* can have is the upstream hard timeout, the persist budget and the wait for
|
|
118
|
+
* the grant's lock, back to back. With the shipped budgets (25 s + 3 s + 5 s)
|
|
119
|
+
* and {@link FEDERATION_GRANTS_CLEANUP_MARGIN_MS} that is exactly this floor;
|
|
120
|
+
* a deployment that raises a budget raises the allowance with it, below.
|
|
121
|
+
* The ten-second drain that cleanup would otherwise inherit is shorter than
|
|
122
|
+
* the tail, and a shutdown under it would abandon exactly the write the drain
|
|
123
|
+
* exists to wait for, after the IdP had moved on to the new refresh token.
|
|
124
|
+
*
|
|
125
|
+
* A host policy, not a grant setting: what it bounds is `handle.dispose()`,
|
|
126
|
+
* and the orchestrator's grace period has to cover the drain, this and an exit
|
|
127
|
+
* margin — the compose files ship 60 seconds for the shipped budgets, and an
|
|
128
|
+
* operator who raises a budget raises the grace to match.
|
|
129
|
+
*/
|
|
130
|
+
export const FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS = 45_000;
|
|
131
|
+
|
|
132
|
+
/** What the allowance adds to the longest refresh tail: an exit margin. */
|
|
133
|
+
export const FEDERATION_GRANTS_CLEANUP_MARGIN_MS = 12_000;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The most a timer can be asked for: Node's `setTimeout` takes a 32-bit signed
|
|
137
|
+
* delay, and a larger one fires after ~1 ms instead. A sum of budgets an
|
|
138
|
+
* operator set high enough to reach it would otherwise turn the allowance
|
|
139
|
+
* into no allowance at all (Copilot, #614).
|
|
140
|
+
*/
|
|
141
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The `cleanupTimeoutMs` to hand {@link installGracefulShutdown}, from the
|
|
145
|
+
* config: while the feature is on, the longer of the floor above and the
|
|
146
|
+
* configured refresh tail plus the margin; while it is off, nothing — the
|
|
147
|
+
* drain's own budget, as before. A fragment to spread, so an absent allowance
|
|
148
|
+
* is absent rather than `undefined`. A config that carries no budgets (one
|
|
149
|
+
* built by hand, without `reference.conf`) gets the floor.
|
|
150
|
+
*/
|
|
151
|
+
export function cleanupAllowanceFor(config: {
|
|
152
|
+
readonly federationGrants?:
|
|
153
|
+
| {
|
|
154
|
+
readonly enabled?: boolean | undefined;
|
|
155
|
+
readonly upstreamHardTimeoutMs?: number | undefined;
|
|
156
|
+
readonly persistRetryBudgetMs?: number | undefined;
|
|
157
|
+
readonly lockWaitMs?: number | undefined;
|
|
158
|
+
}
|
|
159
|
+
| undefined;
|
|
160
|
+
}): { readonly cleanupTimeoutMs: number } | Record<string, never> {
|
|
161
|
+
const grants = config.federationGrants;
|
|
162
|
+
if (grants?.enabled !== true) return {};
|
|
163
|
+
const budgets = [grants.upstreamHardTimeoutMs, grants.persistRetryBudgetMs, grants.lockWaitMs];
|
|
164
|
+
const tail = budgets.every((budget) => typeof budget === "number" && Number.isFinite(budget))
|
|
165
|
+
? (budgets as number[]).reduce(
|
|
166
|
+
(sum, budget) => sum + budget,
|
|
167
|
+
FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
|
|
168
|
+
)
|
|
169
|
+
: 0;
|
|
170
|
+
return {
|
|
171
|
+
cleanupTimeoutMs: Math.min(
|
|
172
|
+
Math.max(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS, tail),
|
|
173
|
+
MAX_TIMER_MS,
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
105
178
|
export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
|
|
106
179
|
const {
|
|
107
180
|
logger,
|
|
@@ -160,7 +233,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
|
|
|
160
233
|
let outcome = reason;
|
|
161
234
|
try {
|
|
162
235
|
if ((await runCleanup()) === CLEANUP_TIMED_OUT) {
|
|
163
|
-
logger.error({ cleanupTimeoutMs }, "
|
|
236
|
+
logger.error({ cleanupTimeoutMs }, "shutdown_cleanup_timed_out");
|
|
164
237
|
exitCode = 1;
|
|
165
238
|
outcome = "cleanup-timeout";
|
|
166
239
|
}
|
|
@@ -168,12 +241,14 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
|
|
|
168
241
|
// Through the app logger, not `console.error`: a shutdown that
|
|
169
242
|
// failed to release its Redis connections is exactly the line an
|
|
170
243
|
// operator needs to find later, and a bare write is the one their
|
|
171
|
-
// pipeline drops.
|
|
172
|
-
|
|
244
|
+
// pipeline drops. The projection, not the error: `dispose()`
|
|
245
|
+
// rejects with every cleanup's own error on `errors`, a store's
|
|
246
|
+
// write — and what it wrote — among them.
|
|
247
|
+
logger.error({ err: loggableError(err) }, "shutdown_cleanup_failed");
|
|
173
248
|
exitCode = 1;
|
|
174
249
|
outcome = "cleanup-failed";
|
|
175
250
|
}
|
|
176
|
-
logger.info({ reason: outcome, drain: reason, exitCode }, "
|
|
251
|
+
logger.info({ reason: outcome, drain: reason, exitCode }, "shutdown_complete");
|
|
177
252
|
exit(exitCode);
|
|
178
253
|
};
|
|
179
254
|
|
|
@@ -181,13 +256,10 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
|
|
|
181
256
|
if (shuttingDown) return;
|
|
182
257
|
shuttingDown = true;
|
|
183
258
|
for (const signal of SIGNALS) offSignal(signal, handler);
|
|
184
|
-
logger.info({ drainTimeoutMs }, "
|
|
259
|
+
logger.info({ drainTimeoutMs }, "shutdown_draining");
|
|
185
260
|
|
|
186
261
|
const deadline = setTimeout(() => {
|
|
187
|
-
logger.error(
|
|
188
|
-
{ drainTimeoutMs },
|
|
189
|
-
"graceful shutdown: drain deadline exceeded, closing remaining connections",
|
|
190
|
-
);
|
|
262
|
+
logger.error({ drainTimeoutMs }, "shutdown_drain_deadline_exceeded");
|
|
191
263
|
server.closeAllConnections();
|
|
192
264
|
void finish(1, "drain-timeout");
|
|
193
265
|
}, drainTimeoutMs);
|
|
@@ -203,7 +275,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
|
|
|
203
275
|
// here. Reporting "drained" and exiting 0 on it would tell an
|
|
204
276
|
// orchestrator the shutdown went cleanly when the listener did
|
|
205
277
|
// not actually come down.
|
|
206
|
-
logger.error({ err }, "
|
|
278
|
+
logger.error({ err: loggableError(err) }, "shutdown_server_close_failed");
|
|
207
279
|
void finish(1, "close-failed");
|
|
208
280
|
return;
|
|
209
281
|
}
|
|
@@ -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.16.0",
|
|
3
|
+
"@o3co/auth-provider-device-grant": "0.16.0",
|
|
4
|
+
"@o3co/auth-provider-dpop": "0.16.0",
|
|
5
|
+
"@o3co/auth-provider-mtls": "0.16.0",
|
|
6
|
+
"@o3co/auth-provider-federation-apple": "0.16.0",
|
|
7
|
+
"@o3co/auth-provider-federation-google": "0.16.0",
|
|
8
|
+
"@o3co/auth-provider-federation-github": "0.16.0",
|
|
9
|
+
"@o3co/auth-provider-federation-grants": "0.16.0",
|
|
10
|
+
"@o3co/auth-provider-federation-oidc": "0.16.0",
|
|
11
|
+
"@o3co/auth-provider-oauth": "0.16.0",
|
|
12
|
+
"@o3co/auth-provider-oauth-token-exchange": "0.16.0",
|
|
13
|
+
"@o3co/auth-provider-session": "0.16.0",
|
|
14
|
+
"@o3co/auth-provider-foundation": "0.16.0",
|
|
15
|
+
"@o3co/auth-provider-redis": "0.16.0",
|
|
16
|
+
"@o3co/auth-provider-webauthn": "0.16.0"
|
|
16
17
|
}
|