@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,382 +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 template's posture on session admission. What it expects is its
19
- * configuration's `core.sessionRequirements.expected`
20
- * — `[]` in the shipped `config/application.conf` — with `mfa` added when the
21
- * template's MFA switch, `mfaMode` (`MFA_MODE`), is not `off`
22
- * (`expectedSessionRequirements`); under that switch the template installs
23
- * the MFA module, whose requirement registers `mfa`; a value boot refuses is
24
- * handed on as written, for boot to refuse. And what it hands boot
25
- * of the MFA module's section, `mfa`: nothing unless a loaded module owns it,
26
- * its mode written from the switch when the template installs MFA, and a
27
- * mode the configuration writes that the switch does not say refused.
28
- */
29
-
30
- import { mkdtempSync, writeFileSync } from "node:fs";
31
- import { tmpdir } from "node:os";
32
- import { join } from "node:path";
33
- import { BootError, type Module } from "@o3co/auth-provider-core";
34
- import { afterEach, describe, expect, it } from "vitest";
35
- import { buildModules } from "../buildModules.mjs";
36
- import {
37
- expectedSessionRequirements,
38
- readOwnLayers,
39
- readSwitches,
40
- resolveForBoot,
41
- resolveLayers,
42
- } from "../configPath.mjs";
43
- import type { MfaSwitch } from "../sections.mjs";
44
- import {
45
- type Composition,
46
- compose,
47
- ownFiles,
48
- SINGLE_ENV,
49
- } from "./all-modules-composition.fixture.mjs";
50
-
51
- let current: Composition | undefined;
52
-
53
- afterEach(async () => {
54
- await current?.handle.dispose();
55
- current = undefined;
56
- });
57
-
58
- /** The boot's refusal, or `undefined` when it booted (kept for disposal). */
59
- const refusal = (composing: Promise<Composition>): Promise<unknown> =>
60
- composing.then(
61
- (composition) => {
62
- current = composition;
63
- return undefined;
64
- },
65
- (caught: unknown) => caught,
66
- );
67
-
68
- /** What MFA on needs in production beside the switch: a key, and a mail relay. */
69
- const MFA_ENV: Readonly<Record<string, string>> = {
70
- MFA_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"),
71
- STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
72
- STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
73
- };
74
-
75
- describe("what the template expects of session admission", () => {
76
- it("is [] in the shipped configuration under MFA_MODE=off, and the composition boots with it", async () => {
77
- const own = readOwnLayers(ownFiles(), { env: SINGLE_ENV });
78
- expect(resolveLayers(own, []).core).toMatchObject({ sessionRequirements: { expected: [] } });
79
- expect(readSwitches(own).mfaMode).toBe("off");
80
- current = await compose();
81
- expect(current.config).not.toHaveProperty("mfa");
82
- expect(current.config.core?.sessionRequirements).toEqual({ expected: [] });
83
- expect([...(current.handle.components.sessionRequirementResolver?.entries() ?? [])]).toEqual(
84
- [],
85
- );
86
- });
87
-
88
- it.each(["optional", "required"] as const)(
89
- "adds mfa under MFA_MODE=%s, which the MFA module the template installs registers",
90
- async (mode) => {
91
- current = await compose({
92
- env: { ...SINGLE_ENV, ...MFA_ENV, MFA_MODE: mode },
93
- environment: "test",
94
- });
95
- expect(current.config.core?.sessionRequirements).toEqual({
96
- expected: ["mfa"],
97
- secondFactorAuthority: "mfa",
98
- });
99
- expect(
100
- [...(current.handle.components.sessionRequirementResolver?.entries() ?? [])].map(
101
- ([name]) => name,
102
- ),
103
- ).toEqual(["mfa"]);
104
- },
105
- );
106
-
107
- it("keeps an operator's own list and adds mfa beside it, overwriting nothing", async () => {
108
- const err = await refusal(
109
- compose({
110
- env: { ...SINGLE_ENV, ...MFA_ENV, MFA_MODE: "required" },
111
- environment: "test",
112
- operatorHocon: 'core.sessionRequirements.expected = ["risk"]\n',
113
- }),
114
- );
115
- expect(err).toBeInstanceOf(BootError);
116
- expect((err as BootError).reason).toBe("session-requirement-missing");
117
- expect((err as BootError).details).toMatchObject({
118
- missing: ["risk"],
119
- declared: ["risk", "mfa"],
120
- });
121
- });
122
-
123
- it.each([
124
- ["a name, not a list", 'core.sessionRequirements.expected = "risk"\n'],
125
- ["a list holding something other than a name", "core.sessionRequirements.expected = [1]\n"],
126
- ["a key core does not declare beside the list", "core.sessionRequirements.extra = 1\n"],
127
- ])(
128
- "hands boot %s as written under the switch on, and boot refuses it naming core.sessionRequirements",
129
- async (_label, operatorHocon) => {
130
- const err = await refusal(
131
- compose({
132
- env: { ...SINGLE_ENV, ...MFA_ENV, MFA_MODE: "required" },
133
- environment: "test",
134
- operatorHocon,
135
- }),
136
- );
137
- expect(err).toBeInstanceOf(BootError);
138
- expect((err as BootError).reason).toBe("config-validation-failed");
139
- expect(String((err as BootError).message)).toContain("core.sessionRequirements");
140
- },
141
- );
142
-
143
- it("hands boot the configuration's own section as written under the switch off", () => {
144
- const own = readOwnLayers(
145
- [
146
- operatorLayer(
147
- 'core.sessionRequirements { expected = ["risk"], secondFactorAuthority = "risk" }\n',
148
- ),
149
- ...ownFiles(),
150
- ],
151
- { env: SINGLE_ENV },
152
- );
153
- const resolved = resolveForBoot(own, [], readSwitches(own)) as unknown as {
154
- readonly core?: { readonly sessionRequirements?: unknown };
155
- };
156
- expect(resolved.core?.sessionRequirements).toEqual({
157
- expected: ["risk"],
158
- secondFactorAuthority: "risk",
159
- });
160
- });
161
-
162
- it("refuses the boot when the configuration expects mfa under the switch off: nothing registers it", async () => {
163
- const err = await refusal(
164
- compose({ operatorHocon: 'core.sessionRequirements.expected = ["mfa"]\n' }),
165
- );
166
- expect(err).toBeInstanceOf(BootError);
167
- expect((err as BootError).reason).toBe("session-requirement-missing");
168
- expect((err as BootError).details).toMatchObject({
169
- configKey: "core.sessionRequirements.expected",
170
- missing: ["mfa"],
171
- });
172
- });
173
- });
174
-
175
- describe("what the template hands boot of the mfa section", () => {
176
- const ownUnder = (env: Readonly<Record<string, string>>, hocon?: string) =>
177
- readOwnLayers(hocon === undefined ? ownFiles() : [operatorLayer(hocon), ...ownFiles()], {
178
- env,
179
- });
180
- /** A module whose section is `mfa`: resolveForBoot reads its name and section alone. */
181
- const reader = { name: "mfa", section: {} } as unknown as Module;
182
- const mfaOf = (resolved: unknown) => (resolved as { mfa?: unknown }).mfa;
183
-
184
- it("hands none under the switch off unless a loaded module owns the section, whatever the configuration writes there", () => {
185
- const own = ownUnder(SINGLE_ENV, 'mfa.page.url = "/my-mfa"\nmfa.mdoe = "required"\n');
186
- const switches = readSwitches(own);
187
- expect(resolveForBoot(own, [], switches)).not.toHaveProperty("mfa");
188
- // A module the deployment adds that owns it: handed as written.
189
- expect(mfaOf(resolveForBoot(own, [reader], switches))).toEqual({
190
- page: { url: "/my-mfa" },
191
- mdoe: "required",
192
- });
193
- });
194
-
195
- it("hands it, owned by the MFA module the switch installs, with the mode written from the switch over the MFA package's default", () => {
196
- for (const mode of ["optional", "required"] as const) {
197
- // The switch written in a layer, MFA_MODE unset: the package's reference says off.
198
- const { MFA_MODE: _unset, ...env } = SINGLE_ENV;
199
- const own = ownUnder(env, `mfaMode = "${mode}"\n`);
200
- const switches = readSwitches(own);
201
- const modules = buildModules(switches, { environment: "test" });
202
- expect(mfaOf(resolveForBoot(own, modules, switches)), mode).toMatchObject({ mode });
203
- }
204
- });
205
-
206
- it.each([
207
- ["the switch off", {}, 'mfa.mode = "required"\n'],
208
- ["the switch off, the mode optional", {}, 'mfa.mode = "optional"\n'],
209
- ["MFA_MODE=optional", { MFA_MODE: "optional" }, 'mfa.mode = "required"\n'],
210
- ["MFA_MODE=required", { MFA_MODE: "required" }, 'mfa.mode = "optional"\n'],
211
- ])(
212
- "refuses, under %s, an mfa.mode the configuration writes that the switch does not say, naming both keys and quoting nothing",
213
- (_label, env, hocon) => {
214
- const own = ownUnder({ ...SINGLE_ENV, ...env }, hocon);
215
- const switches = readSwitches(own);
216
- let err: unknown;
217
- try {
218
- resolveForBoot(own, [reader], switches);
219
- } catch (caught) {
220
- err = caught;
221
- }
222
- expect(err).toBeInstanceOf(BootError);
223
- expect(err).toMatchObject({
224
- reason: "config-validation-failed",
225
- details: {
226
- issues: [{ code: "custom", path: ["mfa", "mode"] }],
227
- modules: [{ module: "mfa", schemaPath: "mfa" }],
228
- },
229
- });
230
- const message = (err as BootError).message;
231
- expect(message).toContain("mfa.mode");
232
- expect(message).toContain("mfaMode");
233
- expect(message).not.toMatch(/"(?:off|optional|required)"/);
234
- expect(JSON.stringify((err as BootError).details)).not.toMatch(/"(?:off|optional|required)"/);
235
- },
236
- );
237
-
238
- it.each([
239
- ["the switch off", {}],
240
- ["MFA_MODE=required", { MFA_MODE: "required" }],
241
- ])(
242
- "refuses, under %s, an mfa written as a value, naming mfa itself and quoting nothing",
243
- (_label, env) => {
244
- const own = ownUnder({ ...SINGLE_ENV, ...env }, 'mfa = "required"\n');
245
- let err: unknown;
246
- try {
247
- resolveForBoot(own, [reader], readSwitches(own));
248
- } catch (caught) {
249
- err = caught;
250
- }
251
- expect(err).toBeInstanceOf(BootError);
252
- expect(err).toMatchObject({
253
- reason: "config-validation-failed",
254
- details: { issues: [{ code: "custom", path: ["mfa"] }] },
255
- });
256
- const message = (err as BootError).message;
257
- expect(message).toMatch(/^mfa is written as a value/);
258
- expect(message).not.toContain("remove mfa.mode");
259
- expect(message).toContain("mfaMode");
260
- expect(message).not.toMatch(/"(?:off|optional|required)"/);
261
- },
262
- );
263
-
264
- it("accepts an mfa.mode the configuration writes that the switch says", () => {
265
- for (const [env, hocon] of [
266
- [{}, 'mfa.mode = "off"\n'],
267
- [{ MFA_MODE: "required" }, 'mfa.mode = "required"\n'],
268
- ] as const) {
269
- const own = ownUnder({ ...SINGLE_ENV, ...env }, hocon);
270
- expect(() => resolveForBoot(own, [reader], readSwitches(own)), hocon).not.toThrow();
271
- }
272
- });
273
-
274
- it("is refused through the composition as through phase two: by the template before boot, under the switch off", async () => {
275
- const err = await refusal(compose({ operatorHocon: 'mfa.mode = "required"\n' }));
276
- expect(err).toBeInstanceOf(BootError);
277
- // The template's own check, not a schema's: one custom issue, in its words.
278
- const message = /^mfa\.mode is written in the configuration and differs from mfaMode/;
279
- expect(err).toMatchObject({
280
- reason: "config-validation-failed",
281
- message: expect.stringMatching(message),
282
- details: {
283
- issues: [
284
- { code: "custom", path: ["mfa", "mode"], message: expect.stringMatching(message) },
285
- ],
286
- modules: [{ module: "mfa", schemaPath: "mfa" }],
287
- },
288
- });
289
- expect((err as BootError).details).toMatchObject({ issues: { length: 1 } });
290
- expect((err as BootError).message).toContain("mfaMode");
291
- });
292
- });
293
-
294
- describe("expectedSessionRequirements", () => {
295
- const of = (mfa: MfaSwitch, expected?: readonly string[], authority?: string) =>
296
- expectedSessionRequirements(
297
- expected === undefined && authority === undefined
298
- ? undefined
299
- : {
300
- ...(expected === undefined ? {} : { expected }),
301
- ...(authority === undefined ? {} : { secondFactorAuthority: authority }),
302
- },
303
- mfa,
304
- );
305
- const AUTHORITY = { secondFactorAuthority: "mfa" } as const;
306
-
307
- it("is the configuration's list, with mfa added once and named the second-factor authority when the switch is not off", () => {
308
- // `undefined`: the section is handed on as written.
309
- expect(of("off", ["risk"])).toBeUndefined();
310
- expect(of("optional", ["risk"])).toEqual({ expected: ["risk", "mfa"], ...AUTHORITY });
311
- expect(of("required", ["mfa"])).toEqual({ expected: ["mfa"], ...AUTHORITY });
312
- expect(of("required")).toEqual({ expected: ["mfa"], ...AUTHORITY });
313
- });
314
-
315
- it("keeps the configuration's list as written, repeats included, and adds mfa only when it is absent", () => {
316
- expect(of("required", ["risk", "risk"])).toEqual({
317
- expected: ["risk", "risk", "mfa"],
318
- ...AUTHORITY,
319
- });
320
- expect(of("required", ["mfa", "risk", "mfa"])).toEqual({
321
- expected: ["mfa", "risk", "mfa"],
322
- ...AUTHORITY,
323
- });
324
- expect(of("off", ["risk", "risk"])).toBeUndefined();
325
- });
326
-
327
- it("accepts a written second-factor authority the switch says, and keeps one written under the switch off", () => {
328
- expect(of("required", ["mfa"], "mfa")).toEqual({ expected: ["mfa"], ...AUTHORITY });
329
- expect(of("off", ["risk"], "risk")).toBeUndefined();
330
- });
331
-
332
- it("refuses, with the switch on, a written second-factor authority other than mfa, naming the key and the switch and quoting nothing", () => {
333
- let err: unknown;
334
- try {
335
- of("required", ["mfa", "sentinel-requirement"], "sentinel-requirement");
336
- } catch (caught) {
337
- err = caught;
338
- }
339
- expect(err).toBeInstanceOf(BootError);
340
- expect(err).toMatchObject({
341
- reason: "config-validation-failed",
342
- details: {
343
- issues: [
344
- { code: "custom", path: ["core", "sessionRequirements", "secondFactorAuthority"] },
345
- ],
346
- modules: [{ module: "core", schemaPath: "core" }],
347
- },
348
- });
349
- expect(JSON.stringify((err as BootError).details)).not.toContain("sentinel-requirement");
350
- const message = (err as BootError).message;
351
- expect(message).toContain("core.sessionRequirements.secondFactorAuthority");
352
- expect(message).toContain("mfaMode");
353
- expect(message).toContain("MFA_MODE");
354
- expect(message).not.toContain("sentinel-requirement");
355
- });
356
-
357
- it("declares nothing when the configuration writes no list and the switch is off, so boot's own rule for an unwritten key applies", () => {
358
- expect(of("off")).toBeUndefined();
359
- });
360
-
361
- it("hands on as written, under the switch on, what is not a section with a list of names, for boot to refuse", () => {
362
- expect(expectedSessionRequirements("mfa", "required")).toBeUndefined();
363
- expect(expectedSessionRequirements({ expected: "mfa" }, "required")).toBeUndefined();
364
- expect(expectedSessionRequirements({ expected: ["mfa", 1] }, "required")).toBeUndefined();
365
- expect(expectedSessionRequirements({}, "required")).toBeUndefined();
366
- });
367
-
368
- it("keeps, under the switch on, every other key written beside the list, for boot to refuse", () => {
369
- expect(expectedSessionRequirements({ expected: [], extra: 1 }, "required")).toEqual({
370
- expected: ["mfa"],
371
- extra: 1,
372
- ...AUTHORITY,
373
- });
374
- });
375
- });
376
-
377
- /** `text` in a file of its own, a layer above the composition's files. */
378
- function operatorLayer(text: string): string {
379
- const file = join(mkdtempSync(join(tmpdir(), "session-requirements-")), "operator.conf");
380
- writeFileSync(file, text);
381
- return file;
382
- }
@@ -1,274 +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 template's shipped files, read as `app.mts` reads them in each mode the
19
- * README documents, write no section for a module the mode does not load:
20
- * every top-level section handed to boot is a loaded module's, core's own
21
- * (`core`, `oauth`), one that sets nothing, or one equal to the
22
- * configuration's defaults (`configDefaultsFor`). That is checked here
23
- * directly, whatever core counts as read; booted, the same modes then have
24
- * core name nothing in `config_sections_ignored` or
25
- * `config_sections_not_loaded`.
26
- *
27
- * Redis is a stand-in: these boots read configuration, not stores.
28
- */
29
-
30
- import { generateKeyPairSync } from "node:crypto";
31
- import { mkdtempSync, writeFileSync } from "node:fs";
32
- import { tmpdir } from "node:os";
33
- import { join } from "node:path";
34
- import { fileURLToPath } from "node:url";
35
- import { isDeepStrictEqual } from "node:util";
36
- import { createApp, type Module } from "@o3co/auth-provider-core";
37
- import { afterEach, describe, expect, it, vi } from "vitest";
38
- import { buildModules } from "#/buildModules.mjs";
39
- import {
40
- configDefaultsFor,
41
- readOwnLayers,
42
- readSwitches,
43
- resolveConfigPaths,
44
- resolveForBoot,
45
- } from "#/configPath.mjs";
46
- import { createRecordingLogger, type RecordingLogger } from "./all-modules-composition.fixture.mjs";
47
-
48
- vi.mock("redis", () => ({
49
- createClient: vi.fn(() => ({
50
- connect: vi.fn().mockResolvedValue(undefined),
51
- quit: vi.fn().mockResolvedValue(undefined),
52
- ping: vi.fn().mockResolvedValue("PONG"),
53
- on: vi.fn(),
54
- })),
55
- }));
56
-
57
- vi.mock("connect-redis", async () => {
58
- const { EventEmitter } = await import("node:events");
59
- // express-session subscribes to store events, so the stand-in is an emitter.
60
- return {
61
- RedisStore: class MockRedisStore extends EventEmitter {
62
- get(): unknown {
63
- return undefined;
64
- }
65
- set(): void {}
66
- destroy(): void {}
67
- },
68
- };
69
- });
70
-
71
- vi.mock("ioredis", () => {
72
- const explicit: Record<string, unknown> = {
73
- on: () => undefined,
74
- quit: async () => "OK",
75
- disconnect: () => undefined,
76
- ping: async () => "PONG",
77
- // The Redis stores' eviction gate reads the policy from INFO memory: a
78
- // default server's.
79
- info: async (section: string) =>
80
- section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
81
- };
82
- const makeMockRedis = (): object =>
83
- new Proxy(
84
- {},
85
- {
86
- get(_target, prop) {
87
- // A function-valued `then` would make the instance a thenable.
88
- if (typeof prop !== "string" || prop === "then") return undefined;
89
- if (prop === "duplicate") return makeMockRedis;
90
- if (prop in explicit) return explicit[prop];
91
- return async () => null;
92
- },
93
- },
94
- );
95
- function MockRedis(): object {
96
- return makeMockRedis();
97
- }
98
- return { Redis: MockRedis, default: MockRedis };
99
- });
100
-
101
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
102
-
103
- const handles: { dispose(): Promise<void> }[] = [];
104
- afterEach(async () => {
105
- for (const handle of handles.splice(0)) await handle.dispose();
106
- });
107
-
108
- const signingKey = generateKeyPairSync("ed25519", {
109
- publicKeyEncoding: { type: "spki", format: "pem" },
110
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
111
- });
112
- const clientsFile = (() => {
113
- const file = join(mkdtempSync(join(tmpdir(), "shipped-config-notices-")), "clients.yaml");
114
- writeFileSync(file, "");
115
- return file;
116
- })();
117
-
118
- /** What `.env.example` asks every deployment for, with the Store's user directory. */
119
- const REQUIRED: Readonly<Record<string, string>> = {
120
- OAUTH_JWT_ISSUER: "https://auth.test",
121
- KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
122
- KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
123
- SESSION_STORE_SECRET: "shipped-config-notices-session.at-least-32-bytes.ok",
124
- REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
125
- REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://store.auth.test/authenticate",
126
- REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://store.auth.test/by-token",
127
- };
128
-
129
- /** What `docker-compose.yml` sets for the app beside the operator's `.env`, with `.env.example`'s Redis URLs. */
130
- const COMPOSE: Readonly<Record<string, string>> = {
131
- SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
132
- REDIS_CLIENTS_URL: "redis://redis.test:6379",
133
- ADAPTERS_USER_SESSION_STORES: "redis",
134
- ADAPTERS_MFA_FACTOR_STORE: "redis",
135
- ADAPTERS_MFA_TRANSACTION_STORE: "redis",
136
- };
137
-
138
- /** What MFA, on by default, needs outside development. */
139
- const MFA_OUTSIDE_DEVELOPMENT: Readonly<Record<string, string>> = {
140
- MFA_ENCRYPTION_KEY: "bzNjbzptZmE6c2hpcHBlZC1jb25maWctbm90aWNlcyE=",
141
- STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
142
- STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
143
- };
144
-
145
- /** One replica, as `docker-compose.production.yml` runs it. */
146
- const PRODUCTION: Readonly<Record<string, string>> = {
147
- ...REQUIRED,
148
- ...COMPOSE,
149
- ...MFA_OUTSIDE_DEVELOPMENT,
150
- CORE_DEPLOYMENT_MODE: "single",
151
- HTTP_TRUST_PROXY: "loopback",
152
- };
153
-
154
- /** The README's multi-replica checklist: every shared store on Redis. */
155
- const MULTI: Readonly<Record<string, string>> = {
156
- ...PRODUCTION,
157
- CORE_DEPLOYMENT_MODE: "multi",
158
- ADAPTERS_RATE_LIMITER: "redis",
159
- ADAPTERS_ATTEMPT_COUNTER: "redis",
160
- ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
161
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "bzNjbzpmZWRlcmF0aW9uLXRva2Vucy1ub3RpY2VzISE=",
162
- };
163
-
164
- /** Every store in memory, MFA off: one process of local work with no Redis. */
165
- const IN_MEMORY: Readonly<Record<string, string>> = {
166
- ...REQUIRED,
167
- MFA_MODE: "off",
168
- SESSION_STORE_STORAGE_TYPE: "memory",
169
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
170
- ADAPTERS_REPLAY_SEEN_SET: "memory",
171
- ADAPTERS_CODE_REPOSITORY: "memory",
172
- };
173
-
174
- /** The composition's own files for `configEnv`, read as `app.mts` reads them, and what they choose. */
175
- function readShipped(configEnv: string, env: Readonly<Record<string, string>>) {
176
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
177
- const own = readOwnLayers([envConfPath, applicationConfPath], { env });
178
- const switches = readSwitches(own);
179
- const modules = buildModules(switches, { environment: configEnv });
180
- return { own, switches, modules };
181
- }
182
-
183
- /** Boots the template's own files for `configEnv` under `env`, as `app.mts` does. */
184
- async function bootShipped(
185
- configEnv: string,
186
- env: Readonly<Record<string, string>>,
187
- ): Promise<{ readonly logger: RecordingLogger; readonly modules: readonly string[] }> {
188
- const { own, switches, modules } = readShipped(configEnv, env);
189
- const logger = createRecordingLogger();
190
- const handle = await createApp({
191
- modules,
192
- bootstrapComponents: {
193
- config: resolveForBoot(own, modules, switches),
194
- configDefaults: configDefaultsFor(modules),
195
- pathResolver: (s: string) => s,
196
- logger,
197
- },
198
- });
199
- handles.push(handle);
200
- return { logger, modules: modules.map((module) => module.name) };
201
- }
202
-
203
- /** Core's own sections. */
204
- const CORE_SECTIONS: readonly string[] = ["core", "oauth"];
205
-
206
- /**
207
- * Removed by boot before it parses the configuration: what the resolution saw
208
- * of each renamed variable.
209
- */
210
- const RENAMED_VARIABLES = "renamed-variables";
211
-
212
- /** Whether `value` sets anything: a value, or a section with one somewhere under it. */
213
- function setsAnything(value: unknown): boolean {
214
- if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
215
- return Object.values(value).some(setsAnything);
216
- }
217
-
218
- /**
219
- * The top-level sections of what `resolveForBoot` hands boot that are no
220
- * loaded module's and not core's, set something, and differ from the
221
- * configuration's defaults: settings for a module the composition does not
222
- * load.
223
- */
224
- function sectionsNothingLoadedReads(
225
- configEnv: string,
226
- env: Readonly<Record<string, string>>,
227
- ): string[] {
228
- const { own, switches, modules } = readShipped(configEnv, env);
229
- const resolved = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
230
- const defaults = configDefaultsFor(modules);
231
- const sections = new Set(
232
- modules.filter((module: Module) => module.section !== undefined).map((module) => module.name),
233
- );
234
- return Object.keys(resolved)
235
- .filter((name) => !sections.has(name) && !CORE_SECTIONS.includes(name))
236
- .filter((name) => name !== RENAMED_VARIABLES && setsAnything(resolved[name]))
237
- .filter((name) => !isDeepStrictEqual(resolved[name], defaults[name]))
238
- .sort();
239
- }
240
-
241
- /** What each warn line named `event` names. */
242
- const named = (logger: RecordingLogger, event: string): unknown[] =>
243
- logger.lines
244
- .filter((line) => line.level === "warn" && line.args[1] === event)
245
- .map((line) => line.args[0]);
246
-
247
- /** Each mode the README documents: its name, the configuration it selects, its environment. */
248
- const MODES = [
249
- ["development, as docker-compose.yml runs it", "development", { ...REQUIRED, ...COMPOSE }],
250
- ["development, every store in memory", "development", IN_MEMORY],
251
- ["production, one replica", "production", PRODUCTION],
252
- [
253
- "production, one replica, the Redis rate limiter",
254
- "production",
255
- { ...PRODUCTION, ADAPTERS_RATE_LIMITER: "redis" },
256
- ],
257
- ["production, every store in memory", "production", IN_MEMORY],
258
- ["production, more than one replica", "production", MULTI],
259
- ] as const;
260
-
261
- describe("the shipped files write no section for a module the mode does not load", () => {
262
- it.each(MODES)("%s", (_mode, configEnv, env) => {
263
- expect(sectionsNothingLoadedReads(configEnv, env)).toEqual([]);
264
- });
265
- });
266
-
267
- describe("a boot of the shipped files that changes nothing of them names no section", () => {
268
- it.each(MODES)("%s", async (_mode, configEnv, env) => {
269
- const { logger, modules } = await bootShipped(configEnv, env);
270
- expect(modules).not.toContain("federation-grants");
271
- expect(named(logger, "config_sections_ignored")).toEqual([]);
272
- expect(named(logger, "config_sections_not_loaded")).toEqual([]);
273
- });
274
- });