@o3co/create-auth-provider 0.17.0 → 0.18.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- package/templates/versions.json +0 -20
|
@@ -1,385 +0,0 @@
|
|
|
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
|
-
* What an upstream IdP asserted about its own login counts only for a
|
|
19
|
-
* federation configured with `core.federations.<name>.trustUpstreamAmr = true`
|
|
20
|
-
* (ADR 2026-09-25-multi-factor-authentication), end to end: a federated login
|
|
21
|
-
* through the session routes, `/oauth/authorize` with `acr_values`, and
|
|
22
|
-
* `/oauth/token`, on the standalone as a deployment composes it
|
|
23
|
-
* (`buildModules`), with a federation whose IdP asserts `mfa` and `hwk`.
|
|
24
|
-
* By default they are kept in `authentication.upstreamAmr` and an `acr` entry
|
|
25
|
-
* only they could meet is withheld; trusted, they are recorded beside `fed`.
|
|
26
|
-
* The session routes and `oauth` read the switch separately (neither package
|
|
27
|
-
* depends on the other); this is where the two meet.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
31
|
-
import { fileURLToPath } from "node:url";
|
|
32
|
-
import {
|
|
33
|
-
createApp,
|
|
34
|
-
createKeyStoreFactory,
|
|
35
|
-
defineModule,
|
|
36
|
-
type FederationProvider,
|
|
37
|
-
federationsOf,
|
|
38
|
-
InMemoryClientRepository,
|
|
39
|
-
InMemoryUserRepository,
|
|
40
|
-
memoryRefreshTokenFamilyStoreModule,
|
|
41
|
-
registerBuiltinKeyStores,
|
|
42
|
-
type UserSessionStore,
|
|
43
|
-
} from "@o3co/auth-provider-core";
|
|
44
|
-
import { federationTypeForTests } from "@o3co/auth-provider-core/testing";
|
|
45
|
-
import type { OAuthSection } from "@o3co/auth-provider-oauth";
|
|
46
|
-
import { parseFile } from "@o3co/ts.hocon";
|
|
47
|
-
import express from "express";
|
|
48
|
-
import request from "supertest";
|
|
49
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
50
|
-
import { buildModules } from "#/buildModules.mjs";
|
|
51
|
-
import { resolveConfigPaths } from "#/configPath.mjs";
|
|
52
|
-
import { templateReference } from "../modules.mjs";
|
|
53
|
-
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
54
|
-
import {
|
|
55
|
-
type BothPhases,
|
|
56
|
-
bothPhasesOf,
|
|
57
|
-
capturedRenames,
|
|
58
|
-
libraryLayers,
|
|
59
|
-
} from "./library-references.fixture.mjs";
|
|
60
|
-
|
|
61
|
-
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
62
|
-
|
|
63
|
-
const ISSUER = "https://auth.test";
|
|
64
|
-
const CLIENT_ID = "rp";
|
|
65
|
-
const CLIENT_SECRET = "rp-secret-value";
|
|
66
|
-
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
67
|
-
const REDIRECT_URI = "https://rp.example/callback";
|
|
68
|
-
const FEDERATION = "partner";
|
|
69
|
-
const CALLBACK_URL = `${ISSUER}/session/oauth/federation/${FEDERATION}/callback`;
|
|
70
|
-
/** What the partner IdP asserts about its own login. */
|
|
71
|
-
const UPSTREAM_AMR = ["mfa", "hwk"];
|
|
72
|
-
const MFA_ACR = "urn:example:mfa";
|
|
73
|
-
const FED_ACR = "urn:example:fed";
|
|
74
|
-
|
|
75
|
-
const ENV: Readonly<Record<string, string>> = {
|
|
76
|
-
// This provider verifies no second factor of its own here: only upstream's.
|
|
77
|
-
MFA_MODE: "off",
|
|
78
|
-
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
79
|
-
KEY_STORE_LOCAL_SECRET: "upstream-amr.acceptance.at-least-32-bytes",
|
|
80
|
-
OAUTH_JWT_ISSUER: ISSUER,
|
|
81
|
-
SESSION_STORE_SECRET: "upstream-amr.acceptance-session.at-least-32-bytes",
|
|
82
|
-
SESSION_STORE_SECURE: "false",
|
|
83
|
-
SESSION_STORE_NAME: "auth.session",
|
|
84
|
-
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
85
|
-
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
86
|
-
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
87
|
-
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
88
|
-
ADAPTERS_RATE_LIMITER: "memory",
|
|
89
|
-
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
90
|
-
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
91
|
-
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
92
|
-
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
93
|
-
ADAPTERS_CONSENT_STORE: "none",
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
/** The shipped configuration, with the partner federation and an acr table beside it. */
|
|
97
|
-
function resolveConfig(trustUpstreamAmr: boolean | undefined): BothPhases {
|
|
98
|
-
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
99
|
-
const layers = parseFile(envConfPath, { env: ENV })
|
|
100
|
-
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
101
|
-
.withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
|
|
102
|
-
.withFallback(libraryLayers(ENV));
|
|
103
|
-
const shipped = bothPhasesOf(layers, ENV);
|
|
104
|
-
return {
|
|
105
|
-
...shipped,
|
|
106
|
-
core: {
|
|
107
|
-
...shipped.core,
|
|
108
|
-
federations: {
|
|
109
|
-
...federationsOf(shipped),
|
|
110
|
-
[FEDERATION]: {
|
|
111
|
-
enabled: true,
|
|
112
|
-
type: FEDERATION,
|
|
113
|
-
callbackURL: CALLBACK_URL,
|
|
114
|
-
...(trustUpstreamAmr === undefined ? {} : { trustUpstreamAmr }),
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
oauth: {
|
|
119
|
-
...(shipped.oauth as OAuthSection),
|
|
120
|
-
authorize: {
|
|
121
|
-
...(shipped.oauth as OAuthSection).authorize,
|
|
122
|
-
acrValues: { [MFA_ACR]: ["mfa"], [FED_ACR]: ["fed"] },
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
// What the resolution captured of the renamed variables.
|
|
126
|
-
"renamed-variables": capturedRenames(ENV),
|
|
127
|
-
} as BothPhases;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** An IdP that authenticates `ext-1` and says it did so with `mfa` and `hwk`. */
|
|
131
|
-
const partner: FederationProvider = {
|
|
132
|
-
name: FEDERATION,
|
|
133
|
-
scope: ["openid"],
|
|
134
|
-
buildAuthorizationUrl: ({ state }) => {
|
|
135
|
-
const url = new URL("https://idp.partner.example/authorize");
|
|
136
|
-
url.searchParams.set("state", state);
|
|
137
|
-
return url;
|
|
138
|
-
},
|
|
139
|
-
exchangeCode: async () => ({
|
|
140
|
-
issuer: "https://idp.partner.example",
|
|
141
|
-
sub: "ext-1",
|
|
142
|
-
accessToken: "upstream-at",
|
|
143
|
-
expiresAt: null,
|
|
144
|
-
amr: UPSTREAM_AMR,
|
|
145
|
-
}),
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* The partner federation's type: the `partner` entry is the IdP above, with a
|
|
150
|
-
* redirect policy that accepts every redirect and resolves the callback to `/`.
|
|
151
|
-
*/
|
|
152
|
-
const partnerFederationModule = federationTypeForTests(FEDERATION, { provider: () => partner });
|
|
153
|
-
|
|
154
|
-
const testRepositoriesModule = defineModule({
|
|
155
|
-
name: "test:repositories",
|
|
156
|
-
provides: {
|
|
157
|
-
clientRepository: () =>
|
|
158
|
-
new InMemoryClientRepository(
|
|
159
|
-
new Map([
|
|
160
|
-
[
|
|
161
|
-
CLIENT_ID,
|
|
162
|
-
{
|
|
163
|
-
tokenEndpointAuthMethod: "client_secret_basic",
|
|
164
|
-
clientSecret: CLIENT_SECRET,
|
|
165
|
-
allowedRedirectUris: [REDIRECT_URI],
|
|
166
|
-
allowedScopes: ["openid"],
|
|
167
|
-
allowedAudiences: [],
|
|
168
|
-
allowedGrantTypes: ["authorization_code"],
|
|
169
|
-
firstParty: true,
|
|
170
|
-
backchannelLogoutSessionRequired: false,
|
|
171
|
-
frontchannelLogoutSessionRequired: false,
|
|
172
|
-
allowedAzpForFederationToken: false,
|
|
173
|
-
},
|
|
174
|
-
],
|
|
175
|
-
]),
|
|
176
|
-
),
|
|
177
|
-
// The partner's `ext-1` is alice's linked identity.
|
|
178
|
-
userRepository: () =>
|
|
179
|
-
new InMemoryUserRepository(
|
|
180
|
-
new Map([["alice", { id: "u-alice", password: "unused", token: `${FEDERATION}:ext-1` }]]),
|
|
181
|
-
),
|
|
182
|
-
} as never,
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// The template's `key-store` section, read by its own schema, without the
|
|
186
|
-
// section's relocation bridge.
|
|
187
|
-
const testKeyStoreModule = defineModule({
|
|
188
|
-
name: "key-store",
|
|
189
|
-
section: { schema: keyStoreSectionSchema },
|
|
190
|
-
provides: {
|
|
191
|
-
keyStore: async ({ section }) => {
|
|
192
|
-
const factory = createKeyStoreFactory();
|
|
193
|
-
registerBuiltinKeyStores(factory);
|
|
194
|
-
return factory.create({
|
|
195
|
-
type: "local",
|
|
196
|
-
...(section.local ?? {}),
|
|
197
|
-
});
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
/** A JWT's claims, unverified: the deployment signed it, and the test reads what it said. */
|
|
203
|
-
const decodeJwt = (jwt: string): Record<string, unknown> =>
|
|
204
|
-
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
205
|
-
string,
|
|
206
|
-
unknown
|
|
207
|
-
>;
|
|
208
|
-
|
|
209
|
-
const cookiesOf = (res: request.Response): string[] =>
|
|
210
|
-
(res.headers["set-cookie"] as unknown as string[] | undefined) ?? [];
|
|
211
|
-
|
|
212
|
-
interface Deployment {
|
|
213
|
-
readonly app: express.Express;
|
|
214
|
-
readonly userSessionStore: UserSessionStore;
|
|
215
|
-
readonly dispose: () => Promise<void>;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async function boot(trustUpstreamAmr: boolean | undefined): Promise<Deployment> {
|
|
219
|
-
const config = resolveConfig(trustUpstreamAmr);
|
|
220
|
-
const handle = await createApp({
|
|
221
|
-
modules: [
|
|
222
|
-
...buildModules(config, {
|
|
223
|
-
keyStoreModule: testKeyStoreModule,
|
|
224
|
-
repositoriesModule: testRepositoriesModule,
|
|
225
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
226
|
-
}),
|
|
227
|
-
partnerFederationModule,
|
|
228
|
-
],
|
|
229
|
-
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
230
|
-
});
|
|
231
|
-
const app = express();
|
|
232
|
-
app.use(handle.router);
|
|
233
|
-
return {
|
|
234
|
-
app,
|
|
235
|
-
userSessionStore: (handle.components as { userSessionStore: UserSessionStore })
|
|
236
|
-
.userSessionStore,
|
|
237
|
-
dispose: () => handle.dispose(),
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Signs alice in through the partner federation; the browser's cookies after the callback. */
|
|
242
|
-
async function signInThroughPartner(app: express.Express): Promise<string[]> {
|
|
243
|
-
const start = await request(app).get(`/session/oauth/federation/${FEDERATION}`);
|
|
244
|
-
expect(start.status).toBe(302);
|
|
245
|
-
const state = new URL(start.headers.location as string).searchParams.get("state");
|
|
246
|
-
expect(state).toBeTruthy();
|
|
247
|
-
const callback = await request(app)
|
|
248
|
-
.get(`/session/oauth/federation/${FEDERATION}/callback`)
|
|
249
|
-
.query({ state, code: "upstream-code" })
|
|
250
|
-
.set("Cookie", cookiesOf(start));
|
|
251
|
-
expect(callback.status).toBe(302);
|
|
252
|
-
return cookiesOf(callback);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** `/oauth/authorize` for the signed-in browser: the redirect back to the client, as parameters. */
|
|
256
|
-
async function authorize(
|
|
257
|
-
app: express.Express,
|
|
258
|
-
cookies: readonly string[],
|
|
259
|
-
acrValues: string,
|
|
260
|
-
): Promise<{ readonly params: URLSearchParams; readonly verifier: string }> {
|
|
261
|
-
const verifier = randomBytes(32).toString("base64url");
|
|
262
|
-
const res = await request(app)
|
|
263
|
-
.get("/oauth/authorize")
|
|
264
|
-
.query({
|
|
265
|
-
response_type: "code",
|
|
266
|
-
client_id: CLIENT_ID,
|
|
267
|
-
redirect_uri: REDIRECT_URI,
|
|
268
|
-
scope: "openid",
|
|
269
|
-
state: "rp-state",
|
|
270
|
-
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
271
|
-
code_challenge_method: "S256",
|
|
272
|
-
acr_values: acrValues,
|
|
273
|
-
})
|
|
274
|
-
.set("Cookie", [...cookies]);
|
|
275
|
-
expect(res.status).toBe(302);
|
|
276
|
-
const location = new URL(res.headers.location as string);
|
|
277
|
-
expect(`${location.origin}${location.pathname}`).toBe(REDIRECT_URI);
|
|
278
|
-
return { params: location.searchParams, verifier };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** The tokens `/oauth/token` mints for `code`, decoded. */
|
|
282
|
-
async function tokensFor(
|
|
283
|
-
app: express.Express,
|
|
284
|
-
code: string,
|
|
285
|
-
verifier: string,
|
|
286
|
-
): Promise<{
|
|
287
|
-
readonly idToken: Record<string, unknown>;
|
|
288
|
-
readonly accessToken: Record<string, unknown>;
|
|
289
|
-
}> {
|
|
290
|
-
const res = await request(app)
|
|
291
|
-
.post("/oauth/token")
|
|
292
|
-
.set("Authorization", BASIC)
|
|
293
|
-
.type("form")
|
|
294
|
-
.send({
|
|
295
|
-
grant_type: "authorization_code",
|
|
296
|
-
code,
|
|
297
|
-
redirect_uri: REDIRECT_URI,
|
|
298
|
-
code_verifier: verifier,
|
|
299
|
-
});
|
|
300
|
-
expect(res.status).toBe(200);
|
|
301
|
-
return {
|
|
302
|
-
idToken: decodeJwt(res.body.id_token as string),
|
|
303
|
-
accessToken: decodeJwt(res.body.access_token as string),
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
describe("an upstream IdP's amr counts only for a federation that trusts it", () => {
|
|
308
|
-
let deployment: Deployment | undefined;
|
|
309
|
-
|
|
310
|
-
afterEach(async () => {
|
|
311
|
-
await deployment?.dispose();
|
|
312
|
-
deployment = undefined;
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it("by default: kept apart in the session, withheld from discovery, unmet at /authorize, on no token", async () => {
|
|
316
|
-
deployment = await boot(undefined);
|
|
317
|
-
const { app, userSessionStore } = deployment;
|
|
318
|
-
|
|
319
|
-
const discovery = await request(app).get("/.well-known/openid-configuration");
|
|
320
|
-
// The entry only the IdP's `mfa` could meet is dropped, since that `mfa`
|
|
321
|
-
// is not recorded: no deployment advertises what it cannot meet.
|
|
322
|
-
expect(discovery.body.acr_values_supported).toEqual([FED_ACR]);
|
|
323
|
-
|
|
324
|
-
const cookies = await signInThroughPartner(app);
|
|
325
|
-
|
|
326
|
-
const unmet = await authorize(app, cookies, MFA_ACR);
|
|
327
|
-
expect(unmet.params.get("error")).toBe("unmet_authentication_requirements");
|
|
328
|
-
expect(unmet.params.get("code")).toBeNull();
|
|
329
|
-
|
|
330
|
-
const { params, verifier } = await authorize(app, cookies, FED_ACR);
|
|
331
|
-
const code = params.get("code");
|
|
332
|
-
expect(code).toBeTruthy();
|
|
333
|
-
const { idToken, accessToken } = await tokensFor(app, code as string, verifier);
|
|
334
|
-
expect(idToken.acr).toBe(FED_ACR);
|
|
335
|
-
expect(idToken.amr).toEqual(["fed"]);
|
|
336
|
-
expect(accessToken.amr).toEqual(["fed"]);
|
|
337
|
-
|
|
338
|
-
// The session keeps what the IdP said, for the record.
|
|
339
|
-
const session = await userSessionStore.get(idToken.sid as string);
|
|
340
|
-
expect(session?.amr).toEqual(["fed"]);
|
|
341
|
-
expect(session?.authentication).toStrictEqual({
|
|
342
|
-
primary: "fed",
|
|
343
|
-
federation: FEDERATION,
|
|
344
|
-
upstreamAmr: UPSTREAM_AMR,
|
|
345
|
-
mfaAt: undefined,
|
|
346
|
-
// The partner reports no `auth_time`, and the federation does not let
|
|
347
|
-
// its callback stand for one (`callbackMeetsFreshness`, false by default).
|
|
348
|
-
upstreamAuthTime: null,
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it("trusted: recorded beside fed, advertised, met at /authorize and stamped", async () => {
|
|
353
|
-
deployment = await boot(true);
|
|
354
|
-
const { app, userSessionStore } = deployment;
|
|
355
|
-
|
|
356
|
-
const discovery = await request(app).get("/.well-known/openid-configuration");
|
|
357
|
-
expect(discovery.body.acr_values_supported).toEqual([MFA_ACR, FED_ACR]);
|
|
358
|
-
|
|
359
|
-
const cookies = await signInThroughPartner(app);
|
|
360
|
-
const { params, verifier } = await authorize(app, cookies, MFA_ACR);
|
|
361
|
-
const code = params.get("code");
|
|
362
|
-
expect(code).toBeTruthy();
|
|
363
|
-
const { idToken, accessToken } = await tokensFor(app, code as string, verifier);
|
|
364
|
-
expect(idToken.acr).toBe(MFA_ACR);
|
|
365
|
-
expect(idToken.amr).toEqual([...UPSTREAM_AMR, "fed"]);
|
|
366
|
-
expect(accessToken.amr).toEqual([...UPSTREAM_AMR, "fed"]);
|
|
367
|
-
|
|
368
|
-
const session = await userSessionStore.get(idToken.sid as string);
|
|
369
|
-
expect(session?.authentication).toStrictEqual({
|
|
370
|
-
primary: "fed",
|
|
371
|
-
federation: FEDERATION,
|
|
372
|
-
upstreamAmr: undefined,
|
|
373
|
-
mfaAt: undefined,
|
|
374
|
-
// The partner reports no `auth_time`, and the federation does not let
|
|
375
|
-
// its callback stand for one (`callbackMeetsFreshness`, false by default).
|
|
376
|
-
upstreamAuthTime: null,
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
it("says false explicitly as it says nothing: kept apart", async () => {
|
|
381
|
-
deployment = await boot(false);
|
|
382
|
-
const discovery = await request(deployment.app).get("/.well-known/openid-configuration");
|
|
383
|
-
expect(discovery.body.acr_values_supported).toEqual([FED_ACR]);
|
|
384
|
-
});
|
|
385
|
-
});
|
|
@@ -1,162 +0,0 @@
|
|
|
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 composition root's own section, `adapters`: which adapter fills each
|
|
19
|
-
* slot. Phase one reads it alone, before the modules are chosen, with the
|
|
20
|
-
* template's own schema; boot is never handed it. The paths the selections
|
|
21
|
-
* moved from, and the variables renamed with them, are refused here, before
|
|
22
|
-
* any module is chosen, in the words and under the reasons boot refuses a
|
|
23
|
-
* module's with (`config-path-relocated`, `environment-variable-renamed`): a
|
|
24
|
-
* renamed variable set, alone or beside its new name at any value, is
|
|
25
|
-
* refused, naming the variables and never a value.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { configRefused, issuesAt, pathsRelocated } from "./bootRefusal.mjs";
|
|
29
|
-
import { refuseRenamedVariables } from "./rootRenames.mjs";
|
|
30
|
-
import { type Adapters, adaptersSchema } from "./sections.mjs";
|
|
31
|
-
|
|
32
|
-
/** The composition root's own section. No module may be named after it. */
|
|
33
|
-
export const ADAPTERS_SECTION = "adapters";
|
|
34
|
-
|
|
35
|
-
/** Each selection's key under `adapters`, and the variable its path is bound to. */
|
|
36
|
-
const VARIABLES: Readonly<Record<keyof Adapters, string>> = {
|
|
37
|
-
rateLimiter: "ADAPTERS_RATE_LIMITER",
|
|
38
|
-
attemptCounter: "ADAPTERS_ATTEMPT_COUNTER",
|
|
39
|
-
userSessionStores: "ADAPTERS_USER_SESSION_STORES",
|
|
40
|
-
accessTokenDenylist: "ADAPTERS_ACCESS_TOKEN_DENYLIST",
|
|
41
|
-
replaySeenSet: "ADAPTERS_REPLAY_SEEN_SET",
|
|
42
|
-
consentStore: "ADAPTERS_CONSENT_STORE",
|
|
43
|
-
federationTokenStore: "ADAPTERS_FEDERATION_TOKEN_STORE",
|
|
44
|
-
federationGrantStore: "ADAPTERS_FEDERATION_GRANT_STORE",
|
|
45
|
-
federationGrantIntentStore: "ADAPTERS_FEDERATION_GRANT_INTENT_STORE",
|
|
46
|
-
mfaFactorStore: "ADAPTERS_MFA_FACTOR_STORE",
|
|
47
|
-
mfaTransactionStore: "ADAPTERS_MFA_TRANSACTION_STORE",
|
|
48
|
-
codeRepository: "ADAPTERS_CODE_REPOSITORY",
|
|
49
|
-
clientRepository: "ADAPTERS_CLIENT_REPOSITORY",
|
|
50
|
-
userRepository: "ADAPTERS_USER_REPOSITORY",
|
|
51
|
-
auditSink: "ADAPTERS_AUDIT_SINK",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
/** Each selection's old path, and its key under `adapters`. */
|
|
55
|
-
const MOVED: readonly (readonly [string, keyof Adapters])[] = [
|
|
56
|
-
["rateLimiter.adapter", "rateLimiter"],
|
|
57
|
-
["userSessionStores.adapter", "userSessionStores"],
|
|
58
|
-
["accessTokenDenylist.adapter", "accessTokenDenylist"],
|
|
59
|
-
["replaySeenSet.adapter", "replaySeenSet"],
|
|
60
|
-
["consentStore.adapter", "consentStore"],
|
|
61
|
-
["federationTokenStore.type", "federationTokenStore"],
|
|
62
|
-
["federationGrantStore.adapter", "federationGrantStore"],
|
|
63
|
-
["federationGrantIntentStore.adapter", "federationGrantIntentStore"],
|
|
64
|
-
["mfaFactorStore.adapter", "mfaFactorStore"],
|
|
65
|
-
["mfaTransactionStore.adapter", "mfaTransactionStore"],
|
|
66
|
-
["oauth.code.adapter", "codeRepository"],
|
|
67
|
-
["repositories.code.type", "codeRepository"],
|
|
68
|
-
["repositories.client.type", "clientRepository"],
|
|
69
|
-
["repositories.user.type", "userRepository"],
|
|
70
|
-
["audit.sink.type", "auditSink"],
|
|
71
|
-
];
|
|
72
|
-
|
|
73
|
-
/** Each variable renamed with a selection, and the selection's key under `adapters`. */
|
|
74
|
-
const RENAMED: readonly (readonly [string, keyof Adapters])[] = [
|
|
75
|
-
["RATE_LIMITER_ADAPTER", "rateLimiter"],
|
|
76
|
-
["USER_SESSION_STORES_ADAPTER", "userSessionStores"],
|
|
77
|
-
["ACCESS_TOKEN_DENYLIST_ADAPTER", "accessTokenDenylist"],
|
|
78
|
-
["REPLAY_SEEN_SET_ADAPTER", "replaySeenSet"],
|
|
79
|
-
["CONSENT_STORE_ADAPTER", "consentStore"],
|
|
80
|
-
["FEDERATION_TOKEN_STORE_TYPE", "federationTokenStore"],
|
|
81
|
-
["FEDERATION_GRANT_STORE_ADAPTER", "federationGrantStore"],
|
|
82
|
-
["FEDERATION_GRANT_INTENT_STORE_ADAPTER", "federationGrantIntentStore"],
|
|
83
|
-
["MFA_FACTOR_STORE_ADAPTER", "mfaFactorStore"],
|
|
84
|
-
["MFA_TRANSACTION_STORE_ADAPTER", "mfaTransactionStore"],
|
|
85
|
-
["OAUTH_CODE_ADAPTER", "codeRepository"],
|
|
86
|
-
["CLIENT_CODE_TYPE", "codeRepository"],
|
|
87
|
-
["CLIENT_TYPE", "clientRepository"],
|
|
88
|
-
["CLIENT_USER_TYPE", "userRepository"],
|
|
89
|
-
["AUDIT_SINK_TYPE", "auditSink"],
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
/** The value at a dotted `path` of `config`, read as own properties; `undefined` when absent. */
|
|
93
|
-
function valueAt(config: unknown, path: string): unknown {
|
|
94
|
-
let cursor: unknown = config;
|
|
95
|
-
for (const key of path.split(".")) {
|
|
96
|
-
if (typeof cursor !== "object" || cursor === null || !Object.hasOwn(cursor, key)) {
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
|
99
|
-
cursor = (cursor as Record<string, unknown>)[key];
|
|
100
|
-
}
|
|
101
|
-
return cursor;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* `adapters` from `resolved` — the template's own layers over its
|
|
106
|
-
* `config/reference.conf` — under `env`, the environment they were
|
|
107
|
-
* substituted with, parsed with the template's schema. Refuses, each with a
|
|
108
|
-
* `BootError` (`bootRefusal.mts`), a selection still written at the path it
|
|
109
|
-
* moved from, naming its new path and variable (`config-path-relocated`);
|
|
110
|
-
* then a variable renamed with one (`refuseRenamedVariables`); then a value
|
|
111
|
-
* or a key the schema refuses, naming its path under `adapters`
|
|
112
|
-
* (`config-validation-failed`).
|
|
113
|
-
*/
|
|
114
|
-
export function readAdapters(
|
|
115
|
-
resolved: Readonly<Record<string, unknown>>,
|
|
116
|
-
env: Readonly<Record<string, string>>,
|
|
117
|
-
): Adapters {
|
|
118
|
-
const moved = MOVED.filter(([from]) => valueAt(resolved, from) !== undefined).map(
|
|
119
|
-
([from, key]) => ({
|
|
120
|
-
module: ADAPTERS_SECTION,
|
|
121
|
-
from,
|
|
122
|
-
to: `${ADAPTERS_SECTION}.${key}`,
|
|
123
|
-
environmentVariable: VARIABLES[key],
|
|
124
|
-
}),
|
|
125
|
-
);
|
|
126
|
-
if (moved.length > 0) {
|
|
127
|
-
throw pathsRelocated(
|
|
128
|
-
`Configuration sets ${moved.length} path(s) that moved: ${moved
|
|
129
|
-
.map(
|
|
130
|
-
({ from, to, environmentVariable }) =>
|
|
131
|
-
`${from} has moved to ${to}; see the upgrade guide (docs/upgrading-from-v0.16.0.md). Write it there (environment variable ${environmentVariable}) and remove this field from your config (or unset the environment variable that sets it).`,
|
|
132
|
-
)
|
|
133
|
-
.join(" ")}`,
|
|
134
|
-
moved,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
refuseRenamedVariables(
|
|
138
|
-
env,
|
|
139
|
-
RENAMED.map(([from, key]) => ({
|
|
140
|
-
module: ADAPTERS_SECTION,
|
|
141
|
-
from,
|
|
142
|
-
to: VARIABLES[key],
|
|
143
|
-
path: `${ADAPTERS_SECTION}.${key}`,
|
|
144
|
-
})),
|
|
145
|
-
);
|
|
146
|
-
const result = adaptersSchema.safeParse(resolved[ADAPTERS_SECTION]);
|
|
147
|
-
if (!result.success) {
|
|
148
|
-
const issues = issuesAt([ADAPTERS_SECTION], result.error.issues);
|
|
149
|
-
throw configRefused(
|
|
150
|
-
`Config validation failed — ${issues.length} issue(s) found: ${issues
|
|
151
|
-
.map((issue) => {
|
|
152
|
-
const unknown =
|
|
153
|
-
issue.code === "unrecognized_keys" ? ` ${JSON.stringify(issue.keys)}` : "";
|
|
154
|
-
return `${issue.path.map(String).join(".")}: ${issue.message}${unknown}`;
|
|
155
|
-
})
|
|
156
|
-
.join("; ")}`,
|
|
157
|
-
issues,
|
|
158
|
-
[{ module: ADAPTERS_SECTION, schemaPath: ADAPTERS_SECTION }],
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
return result.data;
|
|
162
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
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
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { createApp } from "@o3co/auth-provider-core";
|
|
18
|
-
import express from "express";
|
|
19
|
-
import { buildModules } from "./buildModules.mjs";
|
|
20
|
-
import {
|
|
21
|
-
configDefaultsFor,
|
|
22
|
-
readLogging,
|
|
23
|
-
readOwnLayers,
|
|
24
|
-
readSwitches,
|
|
25
|
-
resolveConfigPaths,
|
|
26
|
-
resolveForBoot,
|
|
27
|
-
type Switches,
|
|
28
|
-
} from "./configPath.mjs";
|
|
29
|
-
import { listen } from "./listen.mjs";
|
|
30
|
-
import { createAppLogger } from "./logger.mjs";
|
|
31
|
-
import { createMetrics } from "./metrics.mjs";
|
|
32
|
-
import { mountRoutes } from "./routes.mjs";
|
|
33
|
-
import { securityHeaders } from "./securityHeaders.mjs";
|
|
34
|
-
import { installGracefulShutdown } from "./shutdown.mjs";
|
|
35
|
-
|
|
36
|
-
// Step 1: the configuration, in two phases (`configPath.mts`; template
|
|
37
|
-
// README, "Environment-specific config overlay").
|
|
38
|
-
// ENV = CONFIG_ENV || NODE_ENV || "development"; missing {ENV}.conf is fatal.
|
|
39
|
-
const env = process.env.CONFIG_ENV || process.env.NODE_ENV || "development";
|
|
40
|
-
const configDir = new URL("../config/", import.meta.url);
|
|
41
|
-
const configDirPath = fileURLToPath(configDir);
|
|
42
|
-
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDirPath, env);
|
|
43
|
-
// Read once, so both phases read the same thing.
|
|
44
|
-
const own = readOwnLayers([envConfPath, applicationConfPath]);
|
|
45
|
-
// Phase one: what the template reads before it knows its modules, only for
|
|
46
|
-
// those choices — its own `adapters` and MFA switch (`mfaMode`, MFA_MODE),
|
|
47
|
-
// and whether federation grants are installed.
|
|
48
|
-
const switches: Switches = readSwitches(own);
|
|
49
|
-
|
|
50
|
-
// From the `logging` module's section, so the level holds from the first line;
|
|
51
|
-
// wired into `bootstrapComponents` so every module logs through it (template
|
|
52
|
-
// README, "Logging").
|
|
53
|
-
const logger = createAppLogger(readLogging(own));
|
|
54
|
-
|
|
55
|
-
await (async (): Promise<void> => {
|
|
56
|
-
// Step 2: Create the Express app and apply base security middleware.
|
|
57
|
-
// `trust proxy` is set from the `http` module's `httpSettings` once boot
|
|
58
|
-
// has it (step 3), before a request can arrive.
|
|
59
|
-
const app = express();
|
|
60
|
-
app.use(securityHeaders());
|
|
61
|
-
|
|
62
|
-
// Timing middleware goes ahead of everything so the histogram covers the
|
|
63
|
-
// whole stack — including responses produced by middleware that short-circuits
|
|
64
|
-
// before any route runs, such as the rate limiter's 429s.
|
|
65
|
-
const metrics = createMetrics();
|
|
66
|
-
app.use(metrics.middleware);
|
|
67
|
-
|
|
68
|
-
// Step 3: boot the auth pipeline. `buildModules` is the single source of
|
|
69
|
-
// truth for the module list and its order; `bootstrapComponents` carries
|
|
70
|
-
// only host-environment values, every other component comes from the
|
|
71
|
-
// modules. `environment` is the name the config was selected by: the Redis
|
|
72
|
-
// federation-token store's plaintext guard reads it as well as NODE_ENV,
|
|
73
|
-
// so `CONFIG_ENV=production` is production to the guard too.
|
|
74
|
-
//
|
|
75
|
-
// Phase two: `createApp` parses the configuration as resolved once, with
|
|
76
|
-
// every loaded module's schema (`configPath.mts`), and names each section
|
|
77
|
-
// nothing loaded reads by the configuration's defaults. From here on the
|
|
78
|
-
// template reads the parsed configuration.
|
|
79
|
-
const modules = buildModules(switches, { environment: env });
|
|
80
|
-
const handle = await createApp({
|
|
81
|
-
modules,
|
|
82
|
-
bootstrapComponents: {
|
|
83
|
-
config: resolveForBoot(own, modules, switches),
|
|
84
|
-
configDefaults: configDefaultsFor(modules),
|
|
85
|
-
pathResolver: import.meta.resolve,
|
|
86
|
-
logger,
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
// The `http` module's settings: core's slot and the host's own.
|
|
90
|
-
const { httpSettings, httpHostSettings } = handle.components;
|
|
91
|
-
if (httpSettings === undefined || httpHostSettings === undefined) {
|
|
92
|
-
throw new Error("createApp booted without the http module's settings");
|
|
93
|
-
}
|
|
94
|
-
// `false` | `true` | a hop count | a list of IPs / CIDR ranges / named
|
|
95
|
-
// ranges: the shapes `trust proxy` understands, validated at boot so a
|
|
96
|
-
// typo'd range fails there rather than silently never matching. Prefer
|
|
97
|
-
// naming the proxy: `true` believes a forwarded client address from anyone
|
|
98
|
-
// who can reach this process, and every IP-keyed rate limit buckets on
|
|
99
|
-
// `req.ip`.
|
|
100
|
-
app.set("trust proxy", httpSettings.trustProxy);
|
|
101
|
-
|
|
102
|
-
// Step 4: the host routes (liveness, readiness, metrics), then the composed
|
|
103
|
-
// auth router, then the terminal error handler; `routes.mts` says why in
|
|
104
|
-
// that order.
|
|
105
|
-
mountRoutes(app, {
|
|
106
|
-
router: handle.router,
|
|
107
|
-
probes: handle.readinessProbes,
|
|
108
|
-
readinessTimeoutMs: httpHostSettings.readinessTimeoutMs,
|
|
109
|
-
metrics,
|
|
110
|
-
logger,
|
|
111
|
-
});
|
|
112
|
-
// Step 5: start the HTTP server. A port that cannot be bound fails boot
|
|
113
|
-
// with that error (`listen.mts`).
|
|
114
|
-
const server = await listen(app, httpHostSettings.port, logger);
|
|
115
|
-
|
|
116
|
-
// Step 6: graceful shutdown (`shutdown.mts`). Size `drainTimeoutMs` below
|
|
117
|
-
// your orchestrator's kill grace period. Cleanup gets at least the
|
|
118
|
-
// allowance the modules registered with their cleanups (45 s with
|
|
119
|
-
// federation grants on under the shipped budgets) rather than only the
|
|
120
|
-
// drain's ten; the template README, "Shutdown guarantees", sizes the grace
|
|
121
|
-
// period for it.
|
|
122
|
-
installGracefulShutdown(server, {
|
|
123
|
-
logger,
|
|
124
|
-
cleanup: () => handle.dispose(),
|
|
125
|
-
cleanupAllowanceMs: () => handle.cleanupAllowanceMs,
|
|
126
|
-
});
|
|
127
|
-
})();
|