@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.
Files changed (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. 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
- })();