@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -0,0 +1,382 @@
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 {
54
+ type BothPhases,
55
+ bothPhasesOf,
56
+ capturedRenames,
57
+ libraryLayers,
58
+ } from "./library-references.fixture.mjs";
59
+
60
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
61
+
62
+ const ISSUER = "https://auth.test";
63
+ const CLIENT_ID = "rp";
64
+ const CLIENT_SECRET = "rp-secret-value";
65
+ const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
66
+ const REDIRECT_URI = "https://rp.example/callback";
67
+ const FEDERATION = "partner";
68
+ const CALLBACK_URL = `${ISSUER}/session/oauth/federation/${FEDERATION}/callback`;
69
+ /** What the partner IdP asserts about its own login. */
70
+ const UPSTREAM_AMR = ["mfa", "hwk"];
71
+ const MFA_ACR = "urn:example:mfa";
72
+ const FED_ACR = "urn:example:fed";
73
+
74
+ const ENV: Readonly<Record<string, string>> = {
75
+ // This provider verifies no second factor of its own here: only upstream's.
76
+ MFA_MODE: "off",
77
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
78
+ KEY_STORE_LOCAL_SECRET: "upstream-amr.acceptance.at-least-32-bytes",
79
+ OAUTH_JWT_ISSUER: ISSUER,
80
+ SESSION_STORE_SECRET: "upstream-amr.acceptance-session.at-least-32-bytes",
81
+ SESSION_STORE_SECURE: "false",
82
+ SESSION_STORE_NAME: "auth.session",
83
+ SESSION_STORE_STORAGE_TYPE: "memory",
84
+ ADAPTERS_USER_REPOSITORY: "yaml",
85
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
86
+ ADAPTERS_USER_SESSION_STORES: "memory",
87
+ ADAPTERS_RATE_LIMITER: "memory",
88
+ ADAPTERS_CODE_REPOSITORY: "memory",
89
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
90
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
91
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
92
+ ADAPTERS_CONSENT_STORE: "none",
93
+ };
94
+
95
+ /** The shipped configuration, with the partner federation and an acr table beside it. */
96
+ function resolveConfig(trustUpstreamAmr: boolean | undefined): BothPhases {
97
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
98
+ const layers = parseFile(envConfPath, { env: ENV })
99
+ .withFallback(parseFile(applicationConfPath, { env: ENV }))
100
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
101
+ .withFallback(libraryLayers(ENV));
102
+ const shipped = bothPhasesOf(layers, ENV);
103
+ return {
104
+ ...shipped,
105
+ core: {
106
+ ...shipped.core,
107
+ federations: {
108
+ ...federationsOf(shipped),
109
+ [FEDERATION]: {
110
+ enabled: true,
111
+ type: FEDERATION,
112
+ callbackURL: CALLBACK_URL,
113
+ ...(trustUpstreamAmr === undefined ? {} : { trustUpstreamAmr }),
114
+ },
115
+ },
116
+ },
117
+ oauth: {
118
+ ...(shipped.oauth as OAuthSection),
119
+ authorize: {
120
+ ...(shipped.oauth as OAuthSection).authorize,
121
+ acrValues: { [MFA_ACR]: ["mfa"], [FED_ACR]: ["fed"] },
122
+ },
123
+ },
124
+ // What the resolution captured of the renamed variables.
125
+ "renamed-variables": capturedRenames(ENV),
126
+ } as BothPhases;
127
+ }
128
+
129
+ /** An IdP that authenticates `ext-1` and says it did so with `mfa` and `hwk`. */
130
+ const partner: FederationProvider = {
131
+ name: FEDERATION,
132
+ scope: ["openid"],
133
+ buildAuthorizationUrl: ({ state }) => {
134
+ const url = new URL("https://idp.partner.example/authorize");
135
+ url.searchParams.set("state", state);
136
+ return url;
137
+ },
138
+ exchangeCode: async () => ({
139
+ issuer: "https://idp.partner.example",
140
+ sub: "ext-1",
141
+ accessToken: "upstream-at",
142
+ expiresAt: null,
143
+ amr: UPSTREAM_AMR,
144
+ }),
145
+ };
146
+
147
+ /**
148
+ * The partner federation's type: the `partner` entry is the IdP above, with a
149
+ * redirect policy that accepts every redirect and resolves the callback to `/`.
150
+ */
151
+ const partnerFederationModule = federationTypeForTests(FEDERATION, { provider: () => partner });
152
+
153
+ const testRepositoriesModule = defineModule({
154
+ name: "test:repositories",
155
+ provides: {
156
+ clientRepository: () =>
157
+ new InMemoryClientRepository(
158
+ new Map([
159
+ [
160
+ CLIENT_ID,
161
+ {
162
+ tokenEndpointAuthMethod: "client_secret_basic",
163
+ clientSecret: CLIENT_SECRET,
164
+ allowedRedirectUris: [REDIRECT_URI],
165
+ allowedScopes: ["openid"],
166
+ allowedAudiences: [],
167
+ allowedGrantTypes: ["authorization_code"],
168
+ firstParty: true,
169
+ backchannelLogoutSessionRequired: false,
170
+ frontchannelLogoutSessionRequired: false,
171
+ allowedAzpForFederationToken: false,
172
+ },
173
+ ],
174
+ ]),
175
+ ),
176
+ // The partner's `ext-1` is alice's linked identity.
177
+ userRepository: () =>
178
+ new InMemoryUserRepository(
179
+ new Map([["alice", { id: "u-alice", password: "unused", token: `${FEDERATION}:ext-1` }]]),
180
+ ),
181
+ } as never,
182
+ });
183
+
184
+ const testKeyStoreModule = defineModule({
185
+ name: "test:key-store",
186
+ requires: ["config"] as const,
187
+ provides: {
188
+ keyStore: async ({ config: c }) => {
189
+ const factory = createKeyStoreFactory();
190
+ registerBuiltinKeyStores(factory);
191
+ return factory.create({
192
+ type: "local",
193
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
194
+ });
195
+ },
196
+ },
197
+ });
198
+
199
+ /** A JWT's claims, unverified: the deployment signed it, and the test reads what it said. */
200
+ const decodeJwt = (jwt: string): Record<string, unknown> =>
201
+ JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
202
+ string,
203
+ unknown
204
+ >;
205
+
206
+ const cookiesOf = (res: request.Response): string[] =>
207
+ (res.headers["set-cookie"] as unknown as string[] | undefined) ?? [];
208
+
209
+ interface Deployment {
210
+ readonly app: express.Express;
211
+ readonly userSessionStore: UserSessionStore;
212
+ readonly dispose: () => Promise<void>;
213
+ }
214
+
215
+ async function boot(trustUpstreamAmr: boolean | undefined): Promise<Deployment> {
216
+ const config = resolveConfig(trustUpstreamAmr);
217
+ const handle = await createApp({
218
+ modules: [
219
+ ...buildModules(config, {
220
+ keyStoreModule: testKeyStoreModule,
221
+ repositoriesModule: testRepositoriesModule,
222
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
223
+ }),
224
+ partnerFederationModule,
225
+ ],
226
+ bootstrapComponents: { config, pathResolver: (s) => s },
227
+ });
228
+ const app = express();
229
+ app.use(handle.router);
230
+ return {
231
+ app,
232
+ userSessionStore: (handle.components as { userSessionStore: UserSessionStore })
233
+ .userSessionStore,
234
+ dispose: () => handle.dispose(),
235
+ };
236
+ }
237
+
238
+ /** Signs alice in through the partner federation; the browser's cookies after the callback. */
239
+ async function signInThroughPartner(app: express.Express): Promise<string[]> {
240
+ const start = await request(app).get(`/session/oauth/federation/${FEDERATION}`);
241
+ expect(start.status).toBe(302);
242
+ const state = new URL(start.headers.location as string).searchParams.get("state");
243
+ expect(state).toBeTruthy();
244
+ const callback = await request(app)
245
+ .get(`/session/oauth/federation/${FEDERATION}/callback`)
246
+ .query({ state, code: "upstream-code" })
247
+ .set("Cookie", cookiesOf(start));
248
+ expect(callback.status).toBe(302);
249
+ return cookiesOf(callback);
250
+ }
251
+
252
+ /** `/oauth/authorize` for the signed-in browser: the redirect back to the client, as parameters. */
253
+ async function authorize(
254
+ app: express.Express,
255
+ cookies: readonly string[],
256
+ acrValues: string,
257
+ ): Promise<{ readonly params: URLSearchParams; readonly verifier: string }> {
258
+ const verifier = randomBytes(32).toString("base64url");
259
+ const res = await request(app)
260
+ .get("/oauth/authorize")
261
+ .query({
262
+ response_type: "code",
263
+ client_id: CLIENT_ID,
264
+ redirect_uri: REDIRECT_URI,
265
+ scope: "openid",
266
+ state: "rp-state",
267
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
268
+ code_challenge_method: "S256",
269
+ acr_values: acrValues,
270
+ })
271
+ .set("Cookie", [...cookies]);
272
+ expect(res.status).toBe(302);
273
+ const location = new URL(res.headers.location as string);
274
+ expect(`${location.origin}${location.pathname}`).toBe(REDIRECT_URI);
275
+ return { params: location.searchParams, verifier };
276
+ }
277
+
278
+ /** The tokens `/oauth/token` mints for `code`, decoded. */
279
+ async function tokensFor(
280
+ app: express.Express,
281
+ code: string,
282
+ verifier: string,
283
+ ): Promise<{
284
+ readonly idToken: Record<string, unknown>;
285
+ readonly accessToken: Record<string, unknown>;
286
+ }> {
287
+ const res = await request(app)
288
+ .post("/oauth/token")
289
+ .set("Authorization", BASIC)
290
+ .type("form")
291
+ .send({
292
+ grant_type: "authorization_code",
293
+ code,
294
+ redirect_uri: REDIRECT_URI,
295
+ code_verifier: verifier,
296
+ });
297
+ expect(res.status).toBe(200);
298
+ return {
299
+ idToken: decodeJwt(res.body.id_token as string),
300
+ accessToken: decodeJwt(res.body.access_token as string),
301
+ };
302
+ }
303
+
304
+ describe("an upstream IdP's amr counts only for a federation that trusts it", () => {
305
+ let deployment: Deployment | undefined;
306
+
307
+ afterEach(async () => {
308
+ await deployment?.dispose();
309
+ deployment = undefined;
310
+ });
311
+
312
+ it("by default: kept apart in the session, withheld from discovery, unmet at /authorize, on no token", async () => {
313
+ deployment = await boot(undefined);
314
+ const { app, userSessionStore } = deployment;
315
+
316
+ const discovery = await request(app).get("/.well-known/openid-configuration");
317
+ // The entry only the IdP's `mfa` could meet is dropped, since that `mfa`
318
+ // is not recorded: no deployment advertises what it cannot meet.
319
+ expect(discovery.body.acr_values_supported).toEqual([FED_ACR]);
320
+
321
+ const cookies = await signInThroughPartner(app);
322
+
323
+ const unmet = await authorize(app, cookies, MFA_ACR);
324
+ expect(unmet.params.get("error")).toBe("unmet_authentication_requirements");
325
+ expect(unmet.params.get("code")).toBeNull();
326
+
327
+ const { params, verifier } = await authorize(app, cookies, FED_ACR);
328
+ const code = params.get("code");
329
+ expect(code).toBeTruthy();
330
+ const { idToken, accessToken } = await tokensFor(app, code as string, verifier);
331
+ expect(idToken.acr).toBe(FED_ACR);
332
+ expect(idToken.amr).toEqual(["fed"]);
333
+ expect(accessToken.amr).toEqual(["fed"]);
334
+
335
+ // The session keeps what the IdP said, for the record.
336
+ const session = await userSessionStore.get(idToken.sid as string);
337
+ expect(session?.amr).toEqual(["fed"]);
338
+ expect(session?.authentication).toStrictEqual({
339
+ primary: "fed",
340
+ federation: FEDERATION,
341
+ upstreamAmr: UPSTREAM_AMR,
342
+ mfaAt: undefined,
343
+ // The partner reports no `auth_time`, and the federation does not let
344
+ // its callback stand for one (`callbackMeetsFreshness`, false by default).
345
+ upstreamAuthTime: null,
346
+ });
347
+ });
348
+
349
+ it("trusted: recorded beside fed, advertised, met at /authorize and stamped", async () => {
350
+ deployment = await boot(true);
351
+ const { app, userSessionStore } = deployment;
352
+
353
+ const discovery = await request(app).get("/.well-known/openid-configuration");
354
+ expect(discovery.body.acr_values_supported).toEqual([MFA_ACR, FED_ACR]);
355
+
356
+ const cookies = await signInThroughPartner(app);
357
+ const { params, verifier } = await authorize(app, cookies, MFA_ACR);
358
+ const code = params.get("code");
359
+ expect(code).toBeTruthy();
360
+ const { idToken, accessToken } = await tokensFor(app, code as string, verifier);
361
+ expect(idToken.acr).toBe(MFA_ACR);
362
+ expect(idToken.amr).toEqual([...UPSTREAM_AMR, "fed"]);
363
+ expect(accessToken.amr).toEqual([...UPSTREAM_AMR, "fed"]);
364
+
365
+ const session = await userSessionStore.get(idToken.sid as string);
366
+ expect(session?.authentication).toStrictEqual({
367
+ primary: "fed",
368
+ federation: FEDERATION,
369
+ upstreamAmr: undefined,
370
+ mfaAt: undefined,
371
+ // The partner reports no `auth_time`, and the federation does not let
372
+ // its callback stand for one (`callbackMeetsFreshness`, false by default).
373
+ upstreamAuthTime: null,
374
+ });
375
+ });
376
+
377
+ it("says false explicitly as it says nothing: kept apart", async () => {
378
+ deployment = await boot(false);
379
+ const discovery = await request(deployment.app).get("/.well-known/openid-configuration");
380
+ expect(discovery.body.acr_values_supported).toEqual([FED_ACR]);
381
+ });
382
+ });
@@ -0,0 +1,162 @@
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 a different 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 CHANGELOG. 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
+ }