@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,883 +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 MFA switch, `mfaMode` (`MFA_MODE`): `off` installs nothing of
19
- * MFA; `optional` and `required` install the MFA package's modules — the MFA
20
- * module, the TOTP, recovery-code and email factors, the operator reset —
21
- * with the session package's login completion and the two MFA stores
22
- * `adapters` selects, and declare `mfa` in `core.sessionRequirements.expected`.
23
- * With `MFA_MODE` unset, the switch is what the project's files write
24
- * (`configured-mfa-mode.fixture.mts`). Booted through the all-modules fixture,
25
- * and, where the shipped user repository or the development files are the
26
- * question, the way `app.mts` boots.
27
- */
28
-
29
- import { generateKeyPairSync } from "node:crypto";
30
- import { mkdtempSync, writeFileSync } from "node:fs";
31
- import { tmpdir } from "node:os";
32
- import { join } from "node:path";
33
- import { fileURLToPath } from "node:url";
34
- import {
35
- type AppConfig,
36
- BootError,
37
- createApp,
38
- defineModule,
39
- type Module,
40
- memoryMfaFactorStoreModule,
41
- memoryMfaTransactionStoreModule,
42
- memoryRefreshTokenFamilyStoreModule,
43
- type SessionRequirement,
44
- } from "@o3co/auth-provider-core";
45
- import { MFA_DEVELOPMENT_SAMPLE_KEY, mfaModules, mfaResetModule } from "@o3co/auth-provider-mfa";
46
- import { loginCompletionModule } from "@o3co/auth-provider-session";
47
- import { parseFile } from "@o3co/ts.hocon";
48
- import request from "supertest";
49
- import { afterEach, describe, expect, it, vi } from "vitest";
50
- import { buildModules } from "#/buildModules.mjs";
51
- import {
52
- configDefaultsFor,
53
- readOwnLayers,
54
- readSwitches,
55
- resolveConfigPaths,
56
- resolveForBoot,
57
- } from "#/configPath.mjs";
58
- import { mfaModulesFor } from "#/mfaSwitch.mjs";
59
- import { templateReference } from "#/modules.mjs";
60
- import {
61
- ALICE,
62
- authorize,
63
- type Composition,
64
- codeFrom,
65
- compose,
66
- contributionNames,
67
- cookiesOf,
68
- createRecordingLogger,
69
- ISSUER,
70
- login,
71
- ownFiles,
72
- type RecordingLogger,
73
- SINGLE_ENV,
74
- WEB,
75
- } from "./all-modules-composition.fixture.mjs";
76
- import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
77
-
78
- /** 32 bytes, base64: a key of the deployment's own for the MFA key ring. */
79
- const MFA_KEY = Buffer.alloc(32, 9).toString("base64");
80
-
81
- /** What a deployment exports to turn MFA on in production: the switch, its key, and the SMTP relay. */
82
- const MAIL_ENV: Readonly<Record<string, string>> = {
83
- MFA_ENCRYPTION_KEY: MFA_KEY,
84
- STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
85
- STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
86
- };
87
-
88
- const on = (mode: "optional" | "required", extra: Readonly<Record<string, string>> = {}) => ({
89
- ...SINGLE_ENV,
90
- ...MAIL_ENV,
91
- MFA_MODE: mode,
92
- ...extra,
93
- });
94
-
95
- /** Every module the switch installs, with core's memory MFA stores (the shipped selection). */
96
- const MFA_MODULE_NAMES = [
97
- ...mfaModules().map((m) => m.name),
98
- mfaResetModule.name,
99
- loginCompletionModule.name,
100
- memoryMfaFactorStoreModule.name,
101
- memoryMfaTransactionStoreModule.name,
102
- ].sort();
103
-
104
- let current: Composition | undefined;
105
- let handles: { dispose(): Promise<void> }[] = [];
106
-
107
- afterEach(async () => {
108
- await current?.handle.dispose();
109
- current = undefined;
110
- for (const handle of handles) await handle.dispose();
111
- handles = [];
112
- });
113
-
114
- /**
115
- * The fixture's composition under the name `test`: the MFA stores the shipped
116
- * selection keeps in memory are let in only where every name says
117
- * development or test.
118
- */
119
- const boot = (options: Parameters<typeof compose>[0] = {}) =>
120
- compose({ environment: "test", ...options });
121
-
122
- /** The boot's refusal, or `undefined` when it booted (kept for disposal). */
123
- const refusal = (composing: Promise<Composition>): Promise<unknown> =>
124
- composing.then(
125
- (composition) => {
126
- current = composition;
127
- return undefined;
128
- },
129
- (caught: unknown) => caught,
130
- );
131
-
132
- /** An error's message with every cause's under it. */
133
- const textOf = (err: unknown): string => {
134
- const parts: string[] = [];
135
- for (let at: unknown = err; at instanceof Error; at = at.cause) parts.push(at.message);
136
- return parts.join(" <- ");
137
- };
138
-
139
- /** The warn lines named `event`. */
140
- const warned = (logger: RecordingLogger, event: string) =>
141
- logger.lines.filter((line) => line.level === "warn" && line.args[1] === event);
142
-
143
- /** `env` with MFA_MODE unset: the switch as the configuration's files write it (`configuredMfaMode`). */
144
- const unset = (env: Readonly<Record<string, string>>): Record<string, string> => {
145
- const { MFA_MODE: _unset, ...rest } = env;
146
- return rest;
147
- };
148
-
149
- describe("the switch is the template's own key, mfaMode, bound to MFA_MODE", () => {
150
- it("ships required in the template's reference.conf, which --no-mfa leaves as it is", () => {
151
- const reference = parseFile(fileURLToPath(templateReference()), { env: {} }).toObject();
152
- expect((reference as Record<string, unknown>).mfaMode).toBe("required");
153
- });
154
-
155
- it("is what the configuration's files write unless MFA_MODE says otherwise", () => {
156
- const switches = (env: Readonly<Record<string, string>>) =>
157
- readSwitches(readOwnLayers(ownFiles(), { env })).mfaMode;
158
- expect(switches(unset(SINGLE_ENV))).toBe(configuredMfaMode("production"));
159
- for (const mode of ["off", "optional", "required"] as const) {
160
- expect(switches({ ...SINGLE_ENV, MFA_MODE: mode })).toBe(mode);
161
- }
162
- });
163
-
164
- it("refuses an MFA_MODE that is none of the three before boot, a config-validation-failed BootError naming mfaMode and MFA_MODE that quotes nothing of it", async () => {
165
- const err = await refusal(boot({ env: on("required", { MFA_MODE: "sentinel-mode" }) }));
166
- expect(err).toBeInstanceOf(BootError);
167
- expect(err).toMatchObject({
168
- reason: "config-validation-failed",
169
- details: {
170
- issues: [{ path: ["mfaMode"] }],
171
- modules: [{ module: "mfaMode", schemaPath: "mfaMode" }],
172
- },
173
- });
174
- expect((err as BootError).message).toContain("mfaMode");
175
- expect((err as BootError).message).toContain("MFA_MODE");
176
- expect((err as BootError).message).not.toContain("sentinel-mode");
177
- expect(JSON.stringify((err as BootError).details)).not.toContain("sentinel-mode");
178
- });
179
-
180
- it.each([
181
- ["off", "required"],
182
- ["required", "off"],
183
- ["optional", "required"],
184
- ])(
185
- "refuses, before boot, a file that writes mfaMode %s under MFA_MODE=%s, naming both and quoting neither",
186
- async (written, variable) => {
187
- const err = await refusal(
188
- boot({
189
- env: on("required", { MFA_MODE: variable }),
190
- operatorHocon: `mfaMode = "${written}"\n`,
191
- }),
192
- );
193
- expect(err).toBeInstanceOf(BootError);
194
- expect(err).toMatchObject({
195
- reason: "config-validation-failed",
196
- details: { issues: [{ code: "custom", path: ["mfaMode"] }] },
197
- });
198
- const message = (err as BootError).message;
199
- expect(message).toContain("mfaMode");
200
- expect(message).toContain("MFA_MODE");
201
- expect(message).not.toMatch(/"(?:off|optional|required)"/);
202
- expect(JSON.stringify((err as BootError).details)).not.toMatch(/(?:off|optional|required)"/);
203
- },
204
- );
205
-
206
- it("lets a file write mfaMode when MFA_MODE is unset, or says the same", async () => {
207
- const { MFA_MODE: _unset, ...env } = on("optional");
208
- current = await boot({ env, operatorHocon: 'mfaMode = "optional"\n' });
209
- await current.handle.dispose();
210
- current = await boot({ env: on("optional"), operatorHocon: 'mfaMode = "optional"\n' });
211
- expect(current.handle.components.sessionRequirementResolver?.get("mfa")).toBeDefined();
212
- });
213
- });
214
-
215
- describe("MFA_MODE unset: the switch the configuration's files write decides (required as the template ships it)", () => {
216
- const mode = configuredMfaMode("production");
217
-
218
- it("installs every MFA module and declares mfa while the switch is on, and nothing of MFA while it is off", async () => {
219
- current = await boot({ env: unset(on("required")) });
220
- const names = current.modules.map((m) => m.name);
221
- const installed = names.filter((name) => MFA_MODULE_NAMES.includes(name)).sort();
222
- if (mode === "off") {
223
- expect(installed).toEqual([]);
224
- expect(current.config.core?.sessionRequirements?.expected ?? []).not.toContain("mfa");
225
- expect(current.config).not.toHaveProperty("mfa");
226
- return;
227
- }
228
- expect(installed).toEqual(MFA_MODULE_NAMES);
229
- expect(current.config.core?.sessionRequirements).toEqual({
230
- expected: ["mfa"],
231
- secondFactorAuthority: "mfa",
232
- });
233
- expect((current.config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe(mode);
234
- });
235
-
236
- it("interrupts a password login while the switch is on, opening no session /authorize accepts; lets it through to a code while off", async () => {
237
- current = await boot({ env: unset(on("required")) });
238
- if (mode === "off") {
239
- const { res, cookies } = await login(current.app);
240
- expect(res.status).toBe(200);
241
- const answer = await authorize(current.app, cookies);
242
- expect(String(answer.headers.location ?? "").startsWith(WEB.redirectUri)).toBe(true);
243
- expect(codeFrom(answer)).not.toBe("");
244
- return;
245
- }
246
- const csrf = await request(current.app).get("/session/csrf");
247
- const res = await request(current.app)
248
- .post("/session/login")
249
- .set("Cookie", cookiesOf(csrf))
250
- .set(csrf.body.header_name as string, csrf.body.csrf_token as string)
251
- .type("form")
252
- .send({ username: ALICE.username, password: ALICE.password });
253
- expect(res.status).toBe(403);
254
- expect(res.body.error).toBe("mfa_enrollment_required");
255
- const answer = await authorize(current.app, [...cookiesOf(csrf), ...cookiesOf(res)]);
256
- const location = String(answer.headers.location ?? "");
257
- expect(location.startsWith(WEB.redirectUri)).toBe(false);
258
- expect(location).not.toMatch(/[?&]code=/);
259
- });
260
- });
261
-
262
- describe("MFA_MODE=off: nothing of MFA is installed", () => {
263
- it("lists no MFA module, registers no requirement, and hands boot no mfa section", async () => {
264
- current = await compose();
265
- const names = current.modules.map((m) => m.name);
266
- expect(names.filter((name) => MFA_MODULE_NAMES.includes(name))).toEqual([]);
267
- expect(current.resolved).not.toHaveProperty("mfa");
268
- expect([...(current.handle.components.sessionRequirementResolver?.entries() ?? [])]).toEqual(
269
- [],
270
- );
271
- expect(current.handle.components.mfaReset).toBeUndefined();
272
- });
273
-
274
- it("writes no urn:o3co:acr:mfa entry: discovery and the acr table are as without MFA, with no acr line at boot", async () => {
275
- current = await compose();
276
- const acrValues = (
277
- current.resolved as unknown as { oauth: { authorize?: { acrValues?: object } } }
278
- ).oauth.authorize?.acrValues;
279
- expect(acrValues ?? {}).not.toHaveProperty(["urn:o3co:acr:mfa"]);
280
- const doc = await request(current.app).get("/.well-known/openid-configuration");
281
- expect(doc.body).not.toHaveProperty("acr_values_supported");
282
- expect(
283
- current.logger.lines.filter((line) => line.args[1] === "acr_value_unsatisfiable"),
284
- ).toEqual([]);
285
- });
286
-
287
- it("boots in production with no SMTP relay: nothing reads the mail sender", async () => {
288
- current = await compose({ env: SINGLE_ENV, environment: "production" });
289
- expect(current.modules.map((m) => m.name)).toContain("standard-smtp-mail-sender");
290
- });
291
- });
292
-
293
- describe.each(["optional", "required"] as const)("MFA_MODE=%s", (mode) => {
294
- it("installs every MFA module once, the stores adapters selects, and the subject-revocation service once", async () => {
295
- current = await boot({ env: on(mode) });
296
- const names = current.modules.map((m) => m.name);
297
- expect(names.filter((name) => MFA_MODULE_NAMES.includes(name)).sort()).toEqual(
298
- MFA_MODULE_NAMES,
299
- );
300
- expect(names.filter((name) => name === "subject-revocation-service")).toHaveLength(1);
301
- expect(new Set(names).size).toBe(names.length);
302
- expect(current.handle.components.mfaReset).toBeDefined();
303
- });
304
-
305
- it("hands the deployment's logger to every MFA module that answers a request", async () => {
306
- current = await boot({ env: on(mode) });
307
- const answering = current.modules.filter(
308
- (m) => MFA_MODULE_NAMES.includes(m.name) && contributionNames(m, "routes").length > 0,
309
- );
310
- expect(answering.map((m) => m.name)).toEqual(["mfa"]);
311
- for (const module of answering) {
312
- expect([...(module.requires ?? []), ...(module.optional ?? [])], module.name).toContain(
313
- "logger",
314
- );
315
- }
316
- });
317
-
318
- it("declares mfa as the second-factor authority, which the MFA module registers, and writes mfa.mode from the switch", async () => {
319
- current = await boot({ env: on(mode) });
320
- expect(current.config.core?.sessionRequirements).toEqual({
321
- expected: ["mfa"],
322
- secondFactorAuthority: "mfa",
323
- });
324
- const mfa = current.handle.components.sessionRequirementResolver?.get("mfa");
325
- expect(mfa?.secondFactorAuthority).toBe(true);
326
- expect((current.config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe(mode);
327
- });
328
-
329
- it("keeps the configuration's own acr entries beside urn:o3co:acr:mfa, and its own urn:o3co:acr:mfa as written", async () => {
330
- current = await boot({
331
- env: on(mode),
332
- operatorHocon:
333
- 'oauth.authorize.acrValues { "urn:example:pwd" = ["pwd"], "urn:o3co:acr:mfa" = ["mfa", "pwd"] }\n',
334
- });
335
- expect(
336
- (current.resolved as unknown as { oauth: { authorize: { acrValues: object } } }).oauth
337
- .authorize.acrValues,
338
- ).toEqual({ "urn:example:pwd": ["pwd"], "urn:o3co:acr:mfa": ["mfa", "pwd"] });
339
- });
340
-
341
- it("advertises urn:o3co:acr:mfa, with no acr line at boot", async () => {
342
- current = await boot({ env: on(mode) });
343
- const doc = await request(current.app).get("/.well-known/openid-configuration");
344
- expect(doc.body.acr_values_supported).toContain("urn:o3co:acr:mfa");
345
- expect(
346
- current.logger.lines.filter((line) => line.args[1] === "acr_value_unsatisfiable"),
347
- ).toEqual([]);
348
- });
349
-
350
- it.each([
351
- ["off", {}],
352
- ["on", { MFA_EMAIL_FACTOR_ENABLED: "true" }],
353
- ])(
354
- "is refused outside development without an SMTP relay, the email factor %s, naming STANDARD_SMTP_MAIL_SENDER_HOST",
355
- async (_factor, email) => {
356
- const err = await refusal(
357
- boot({
358
- env: { ...SINGLE_ENV, MFA_MODE: mode, MFA_ENCRYPTION_KEY: MFA_KEY, ...email },
359
- }),
360
- );
361
- expect(err).toBeInstanceOf(BootError);
362
- expect(textOf(err)).toContain("STANDARD_SMTP_MAIL_SENDER_HOST");
363
- },
364
- );
365
-
366
- it("boots with the email factor on and the SMTP relay set", async () => {
367
- current = await boot({ env: on(mode, { MFA_EMAIL_FACTOR_ENABLED: "true" }) });
368
- expect(current.handle.components.mfaFactorResolver?.get("email")).toBeDefined();
369
- });
370
- });
371
-
372
- describe("the MFA stores in memory: development and test alone", () => {
373
- afterEach(() => {
374
- vi.unstubAllEnvs();
375
- });
376
-
377
- it("refuses both, before boot, under the name production: the shipped selection, naming each setting and its variable", async () => {
378
- const err = await refusal(compose({ env: on("required"), environment: "production" }));
379
- expect(err).toBeInstanceOf(BootError);
380
- expect(err).toMatchObject({
381
- reason: "config-validation-failed",
382
- details: {
383
- issues: [
384
- { code: "custom", path: ["adapters", "mfaFactorStore"] },
385
- { code: "custom", path: ["adapters", "mfaTransactionStore"] },
386
- ],
387
- modules: [{ module: "adapters", schemaPath: "adapters" }],
388
- },
389
- });
390
- const message = (err as BootError).message;
391
- for (const named of [
392
- "adapters.mfaFactorStore",
393
- "ADAPTERS_MFA_FACTOR_STORE",
394
- "adapters.mfaTransactionStore",
395
- "ADAPTERS_MFA_TRANSACTION_STORE",
396
- ]) {
397
- expect(message).toContain(named);
398
- }
399
- });
400
-
401
- it("refuses the memory transaction store alone when the factors are in the Store", async () => {
402
- const err = await refusal(
403
- compose({
404
- env: on("required", { ADAPTERS_MFA_FACTOR_STORE: "store" }),
405
- environment: "production",
406
- }),
407
- );
408
- expect(err).toBeInstanceOf(BootError);
409
- expect((err as BootError).details).toMatchObject({
410
- issues: [{ path: ["adapters", "mfaTransactionStore"] }],
411
- });
412
- expect((err as BootError).message).toContain("ADAPTERS_MFA_TRANSACTION_STORE");
413
- expect((err as BootError).message).not.toContain("ADAPTERS_MFA_FACTOR_STORE");
414
- });
415
-
416
- it.each(["CONFIG_ENV", "NODE_ENV"])(
417
- "refuses them where %s says production, though the name says test",
418
- async (variable) => {
419
- vi.stubEnv(variable, "production");
420
- const err = await refusal(boot({ env: on("required") }));
421
- expect(err).toBeInstanceOf(BootError);
422
- expect((err as BootError).message).toContain("ADAPTERS_MFA_FACTOR_STORE");
423
- },
424
- );
425
-
426
- it("refuses them under a name that is neither development nor test", async () => {
427
- const err = await refusal(compose({ env: on("required"), environment: "staging-eu" }));
428
- expect(err).toBeInstanceOf(BootError);
429
- expect((err as BootError).reason).toBe("config-validation-failed");
430
- });
431
-
432
- it("names each reason it read: no name at all, a name neither development nor test, production", () => {
433
- const memory = (environment: string | undefined) => () =>
434
- mfaModulesFor({
435
- mode: "required",
436
- adapters: { mfaFactorStore: "memory", mfaTransactionStore: "memory" },
437
- storeTransport: undefined,
438
- environment,
439
- });
440
- vi.stubEnv("CONFIG_ENV", "");
441
- vi.stubEnv("NODE_ENV", "");
442
- expect(memory(undefined)).toThrow(/refused because no environment is named/);
443
- expect(memory(" Prod ")).toThrow(/the environment "prod" is not development or test/);
444
- vi.stubEnv("NODE_ENV", "Production");
445
- expect(memory("test")).toThrow(/NODE_ENV is "production"/);
446
- vi.stubEnv("NODE_ENV", " Test\n");
447
- expect(memory("development")().map((m) => m.name)).toContain(memoryMfaFactorStoreModule.name);
448
- });
449
-
450
- it.each(["development", "test"])("lets them in under %s", async (environment) => {
451
- current = await compose({ env: on("required"), environment });
452
- expect(current.modules.map((m) => m.name)).toContain(memoryMfaFactorStoreModule.name);
453
- });
454
-
455
- it("refuses the shipped configuration with MFA_MODE=required in production, naming the store to select", async () => {
456
- await expect(bootShipped({ ...SHIPPED_ENV, ...MAIL_ENV }, "production")).rejects.toThrow(
457
- /ADAPTERS_MFA_FACTOR_STORE/,
458
- );
459
- });
460
- });
461
-
462
- describe("the MFA stores follow adapters", () => {
463
- const listed = (env: Readonly<Record<string, string>>) =>
464
- buildModules(readSwitches(readOwnLayers(ownFiles(), { env })), {
465
- environment: "production",
466
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
467
- }).map((m) => m.name);
468
-
469
- it("lists the Redis ones, and the shared Redis clients, under redis", () => {
470
- const names = listed(
471
- on("required", {
472
- ADAPTERS_MFA_FACTOR_STORE: "redis",
473
- ADAPTERS_MFA_TRANSACTION_STORE: "redis",
474
- }),
475
- );
476
- expect(names).toContain("redis-mfa-factor-store");
477
- expect(names).toContain("redis-mfa-transaction-store");
478
- expect(names).toContain("redis-clients");
479
- expect(names).not.toContain(memoryMfaFactorStoreModule.name);
480
- expect(names).not.toContain(memoryMfaTransactionStoreModule.name);
481
- });
482
-
483
- it("lists no Redis MFA store and opens no shared Redis socket for them while MFA is off", () => {
484
- const names = listed({
485
- ...SINGLE_ENV,
486
- ADAPTERS_MFA_FACTOR_STORE: "redis",
487
- ADAPTERS_MFA_TRANSACTION_STORE: "redis",
488
- });
489
- expect(names.filter((name) => /mfa/.test(name))).toEqual([]);
490
- expect(names).not.toContain("redis-clients");
491
- });
492
-
493
- it("keeps the factors in the Store under store, over the user repository's credential, and boots", async () => {
494
- current = await boot({
495
- env: on("optional", {
496
- ADAPTERS_MFA_FACTOR_STORE: "store",
497
- FOUNDATION_MFA_FACTOR_STORE_LIST_URL: "https://store.auth.test/mfa/list",
498
- FOUNDATION_MFA_FACTOR_STORE_CREATE_URL: "https://store.auth.test/mfa/create",
499
- FOUNDATION_MFA_FACTOR_STORE_UPDATE_URL: "https://store.auth.test/mfa/update",
500
- FOUNDATION_MFA_FACTOR_STORE_DELETE_URL: "https://store.auth.test/mfa/delete",
501
- REPOSITORIES_USER_HTTP_BEARER_TOKEN:
502
- "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
503
- }),
504
- });
505
- const names = current.modules.map((m) => m.name);
506
- expect(names).toContain("foundation-mfa-factor-store");
507
- expect(names).not.toContain(memoryMfaFactorStoreModule.name);
508
- });
509
-
510
- it("refuses the Store under store with a URL missing, naming it", async () => {
511
- const err = await refusal(
512
- boot({ env: on("optional", { ADAPTERS_MFA_FACTOR_STORE: "store" }) }),
513
- );
514
- expect(err).toBeInstanceOf(BootError);
515
- expect(textOf(err)).toContain("foundation-mfa-factor-store");
516
- });
517
-
518
- it("refuses the memory ones under core.deployment.mode = multi, by name", async () => {
519
- const err = await refusal(
520
- boot({
521
- env: on("required", { CORE_DEPLOYMENT_MODE: "multi" }),
522
- }),
523
- );
524
- expect(err).toBeInstanceOf(BootError);
525
- expect(textOf(err)).toContain(memoryMfaFactorStoreModule.name);
526
- });
527
- });
528
-
529
- describe("MFA_MODE=required never lets a password alone sign in", () => {
530
- it("interrupts the password login, opening no session /authorize accepts", async () => {
531
- current = await boot({ env: on("required") });
532
- const csrf = await request(current.app).get("/session/csrf");
533
- const res = await request(current.app)
534
- .post("/session/login")
535
- .set("Cookie", cookiesOf(csrf))
536
- .set(csrf.body.header_name as string, csrf.body.csrf_token as string)
537
- .type("form")
538
- .send({ username: ALICE.username, password: ALICE.password });
539
- expect(res.status).toBe(403);
540
- expect(["mfa_enrollment_required", "mfa_required"]).toContain(res.body.error);
541
- const answer = await authorize(current.app, [...cookiesOf(csrf), ...cookiesOf(res)]);
542
- const location = String(answer.headers.location ?? "");
543
- expect(location.startsWith(WEB.redirectUri)).toBe(false);
544
- expect(location).not.toMatch(/[?&]code=/);
545
- });
546
-
547
- /** A requirement a deployment registers, declaring the second-factor authority or not. */
548
- const requirement = (name: string, secondFactorAuthority: boolean): SessionRequirement => ({
549
- name,
550
- secondFactorAuthority,
551
- reach: new Set(),
552
- stepUpPage: undefined,
553
- remediations: [],
554
- hintKeys: [],
555
- admit: async () => ({ outcome: "met" }),
556
- });
557
-
558
- it("refuses a second requirement named mfa", async () => {
559
- const impostor = defineModule({
560
- name: "deployment:named-mfa",
561
- contributes: { sessionRequirements: { mfa: () => requirement("mfa", false) } },
562
- } as never);
563
- const err = await refusal(boot({ env: on("required"), extraModules: () => [impostor] }));
564
- expect(err).toBeInstanceOf(BootError);
565
- expect((err as BootError).reason).toBe("duplicate-contribute");
566
- });
567
-
568
- it("refuses a second requirement declaring the second-factor authority: duplicate-second-factor-authority", async () => {
569
- const rival = defineModule({
570
- name: "deployment:rival",
571
- requires: ["mfaFactorResolver", "mfaFactorStore", "mfaTransactionStore"],
572
- contributes: { sessionRequirements: { rival: () => requirement("rival", true) } },
573
- } as never);
574
- const err = await refusal(
575
- boot({
576
- env: on("required"),
577
- operatorHocon: 'core.sessionRequirements.expected = ["rival"]\n',
578
- extraModules: () => [rival],
579
- }),
580
- );
581
- expect(err).toBeInstanceOf(BootError);
582
- expect((err as BootError).reason).toBe("duplicate-second-factor-authority");
583
- });
584
- });
585
-
586
- describe("mfa.storeTimeoutMs", () => {
587
- /** As the MFA module reads it: an environment variable's digits are its number. */
588
- const storeTimeoutMs = (c: Composition) =>
589
- Number((c.config as unknown as { mfa: { storeTimeoutMs: unknown } }).mfa.storeTimeoutMs);
590
-
591
- it("is the MFA package's default, the user directory's default timeout, and MFA_STORE_TIMEOUT_MS sets it", async () => {
592
- current = await boot({ env: on("optional") });
593
- expect(storeTimeoutMs(current)).toBe(5000);
594
- await current.handle.dispose();
595
- current = await boot({ env: on("optional", { MFA_STORE_TIMEOUT_MS: "9000" }) });
596
- expect(storeTimeoutMs(current)).toBe(9000);
597
- });
598
-
599
- /** The factors in the Store, so one MFA Store call is a Store call. */
600
- const IN_THE_STORE = {
601
- ADAPTERS_MFA_FACTOR_STORE: "store",
602
- FOUNDATION_MFA_FACTOR_STORE_LIST_URL: "https://store.auth.test/mfa/list",
603
- FOUNDATION_MFA_FACTOR_STORE_CREATE_URL: "https://store.auth.test/mfa/create",
604
- FOUNDATION_MFA_FACTOR_STORE_UPDATE_URL: "https://store.auth.test/mfa/update",
605
- FOUNDATION_MFA_FACTOR_STORE_DELETE_URL: "https://store.auth.test/mfa/delete",
606
- };
607
-
608
- it("is refused before boot below the user directory's timeout where the Store is called, naming both settings and variables", async () => {
609
- const err = await refusal(
610
- boot({ env: on("optional", { ...IN_THE_STORE, REPOSITORIES_USER_HTTP_TIMEOUT: "8000" }) }),
611
- );
612
- expect(err).toBeInstanceOf(BootError);
613
- expect(err).toMatchObject({
614
- reason: "config-validation-failed",
615
- details: {
616
- issues: [{ code: "custom", path: ["mfa", "storeTimeoutMs"] }],
617
- modules: [{ module: "mfa", schemaPath: "mfa" }],
618
- },
619
- });
620
- const message = (err as BootError).message;
621
- for (const named of [
622
- "mfa.storeTimeoutMs",
623
- "MFA_STORE_TIMEOUT_MS",
624
- "repositories.user.http.timeout",
625
- "REPOSITORIES_USER_HTTP_TIMEOUT",
626
- ]) {
627
- expect(message).toContain(named);
628
- }
629
- });
630
-
631
- it("boots once MFA_STORE_TIMEOUT_MS covers the user directory's timeout", async () => {
632
- current = await boot({
633
- env: on("optional", {
634
- ...IN_THE_STORE,
635
- REPOSITORIES_USER_HTTP_TIMEOUT: "8000",
636
- MFA_STORE_TIMEOUT_MS: "8000",
637
- }),
638
- });
639
- expect(storeTimeoutMs(current)).toBe(8000);
640
- });
641
-
642
- it("is not held to a user directory the composition does not call", async () => {
643
- current = await boot({ env: on("optional", { REPOSITORIES_USER_HTTP_TIMEOUT: "8000" }) });
644
- expect(storeTimeoutMs(current)).toBe(5000);
645
- });
646
-
647
- it("is refused above 37 500 ms, naming the key", async () => {
648
- const err = await refusal(boot({ env: on("optional", { MFA_STORE_TIMEOUT_MS: "37501" }) }));
649
- expect(err).toBeInstanceOf(BootError);
650
- expect(textOf(err)).toContain("mfa.storeTimeoutMs");
651
- });
652
- });
653
-
654
- // ---------------------------------------------------------------------------
655
- // As app.mts boots: the shipped user repository, the development files
656
- // ---------------------------------------------------------------------------
657
-
658
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
659
- const signingKey = generateKeyPairSync("ed25519", {
660
- publicKeyEncoding: { type: "spki", format: "pem" },
661
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
662
- });
663
- const clientsFile = (() => {
664
- const file = join(mkdtempSync(join(tmpdir(), "mfa-switch-")), "clients.yaml");
665
- writeFileSync(file, "");
666
- return file;
667
- })();
668
-
669
- /** One replica, every store in memory, MFA required. */
670
- const SHIPPED_ENV: Readonly<Record<string, string>> = {
671
- OAUTH_JWT_ISSUER: ISSUER,
672
- KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
673
- KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
674
- SESSION_STORE_SECRET: "mfa-switch-session-secret.at-least-32-bytes.ok",
675
- SESSION_STORE_SECURE: "false",
676
- SESSION_STORE_NAME: "auth.session",
677
- CORE_DEPLOYMENT_MODE: "single",
678
- SESSION_STORE_STORAGE_TYPE: "memory",
679
- ADAPTERS_USER_SESSION_STORES: "memory",
680
- ADAPTERS_RATE_LIMITER: "memory",
681
- ADAPTERS_CODE_REPOSITORY: "memory",
682
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
683
- ADAPTERS_REPLAY_SEEN_SET: "memory",
684
- REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
685
- ADAPTERS_USER_REPOSITORY: "http",
686
- REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://store.auth.test/authenticate",
687
- REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://store.auth.test/by-token",
688
- MFA_MODE: "required",
689
- };
690
-
691
- /** Boots the template's own files for `configEnv` under `env`, as `app.mts` does. */
692
- async function bootShipped(
693
- env: Readonly<Record<string, string>>,
694
- configEnv: string,
695
- environment: string = configEnv,
696
- adjustModules: (modules: Module[]) => Module[] = (modules) => modules,
697
- ): Promise<{ readonly logger: RecordingLogger; readonly config: AppConfig }> {
698
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
699
- const own = readOwnLayers([envConfPath, applicationConfPath], { env });
700
- const switches = readSwitches(own);
701
- const logger = createRecordingLogger();
702
- const modules = adjustModules(
703
- buildModules(switches, {
704
- environment,
705
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
706
- }),
707
- );
708
- const handle = await createApp({
709
- modules,
710
- bootstrapComponents: {
711
- config: resolveForBoot(own, modules, switches),
712
- configDefaults: configDefaultsFor(modules),
713
- pathResolver: (s: string) => s,
714
- logger,
715
- },
716
- });
717
- handles.push(handle);
718
- const config = handle.components.config;
719
- if (config === undefined) throw new Error("createApp booted without the parsed configuration");
720
- return { logger, config };
721
- }
722
-
723
- describe("over the Store's HTTP user repository", () => {
724
- it("refuses, before boot, mfa.storeTimeoutMs below REPOSITORIES_USER_HTTP_TIMEOUT", async () => {
725
- await expect(
726
- bootShipped(
727
- { ...SHIPPED_ENV, ...MAIL_ENV, REPOSITORIES_USER_HTTP_TIMEOUT: "8000" },
728
- "production",
729
- "test",
730
- ),
731
- ).rejects.toThrow(/mfa\.storeTimeoutMs/);
732
- });
733
-
734
- it("boots without REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL, warning once that the witness is unwritable", async () => {
735
- const { logger } = await bootShipped({ ...SHIPPED_ENV, ...MAIL_ENV }, "production", "test");
736
- expect(warned(logger, "mfa_enrollment_witness_unwritable")).toHaveLength(1);
737
- });
738
-
739
- it("boots with it, and writes the witness: no such warning", async () => {
740
- const { logger } = await bootShipped(
741
- {
742
- ...SHIPPED_ENV,
743
- ...MAIL_ENV,
744
- REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL: "https://store.auth.test/mfa/enrolled",
745
- },
746
- "production",
747
- "test",
748
- );
749
- expect(warned(logger, "mfa_enrollment_witness_unwritable")).toEqual([]);
750
- });
751
- });
752
-
753
- describe("a deployment that takes MFA's modules out of buildModules() under MFA_MODE=required", () => {
754
- it("is refused at boot when it registers an mfa of its own that is not the second-factor authority", async () => {
755
- const impostor = defineModule({
756
- name: "deployment:named-mfa",
757
- contributes: {
758
- sessionRequirements: {
759
- mfa: (): SessionRequirement => ({
760
- name: "mfa",
761
- secondFactorAuthority: false,
762
- reach: new Set(),
763
- stepUpPage: undefined,
764
- remediations: [],
765
- hintKeys: [],
766
- admit: async () => ({ outcome: "met" }),
767
- }),
768
- },
769
- },
770
- } as never);
771
- let err: unknown;
772
- await bootShipped({ ...SHIPPED_ENV, ...MAIL_ENV }, "production", "test", (modules) => [
773
- ...modules.filter((m) => !MFA_MODULE_NAMES.includes(m.name)),
774
- impostor,
775
- ]).catch((caught: unknown) => {
776
- err = caught;
777
- });
778
- expect(err).toBeInstanceOf(BootError);
779
- expect((err as BootError).reason).toBe("second-factor-authority-not-declared");
780
- expect((err as BootError).details).toMatchObject({
781
- unmet: expect.arrayContaining(["not-declared"]),
782
- });
783
- });
784
- });
785
-
786
- describe("the shipped configuration with nothing set about MFA", () => {
787
- it("boots under CONFIG_ENV=development with the switch its files write: with MFA on, on the sample key, the development mail sender and the MFA stores in memory", async () => {
788
- const mode = configuredMfaMode("development");
789
- const { logger, config } = await bootShipped(unset(SHIPPED_ENV), "development");
790
- if (mode === "off") {
791
- expect(config).not.toHaveProperty("mfa");
792
- expect(config.core?.sessionRequirements?.expected ?? []).not.toContain("mfa");
793
- expect(warned(logger, "mfa_development_sample_key_in_use")).toEqual([]);
794
- return;
795
- }
796
- expect((config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe(mode);
797
- expect(config.core?.sessionRequirements?.expected).toContain("mfa");
798
- expect(warned(logger, "mfa_development_sample_key_in_use")).toHaveLength(1);
799
- });
800
-
801
- it("in production, with MFA on, is refused before boot, naming the MFA stores, MFA_ENCRYPTION_KEY, the SMTP relay and MFA_MODE=off; with it off, boots", async () => {
802
- if (configuredMfaMode("production") === "off") {
803
- const { config } = await bootShipped(unset(SHIPPED_ENV), "production");
804
- expect(config).not.toHaveProperty("mfa");
805
- expect(config.core?.sessionRequirements?.expected ?? []).not.toContain("mfa");
806
- return;
807
- }
808
- let err: unknown;
809
- await bootShipped(unset(SHIPPED_ENV), "production").catch((caught: unknown) => {
810
- err = caught;
811
- });
812
- expect(err).toBeInstanceOf(BootError);
813
- const message = (err as BootError).message;
814
- for (const named of [
815
- "ADAPTERS_MFA_FACTOR_STORE",
816
- "ADAPTERS_MFA_TRANSACTION_STORE",
817
- "MFA_ENCRYPTION_KEY",
818
- "STANDARD_SMTP_MAIL_SENDER_HOST",
819
- "STANDARD_SMTP_MAIL_SENDER_FROM",
820
- "MFA_PAGE_URL",
821
- "MFA_MODE=off",
822
- ]) {
823
- expect(message).toContain(named);
824
- }
825
- // A deployment may pass its own sender in place of the SMTP relay.
826
- expect(message).toContain("or your own mail sender");
827
- });
828
-
829
- it("boots in production under MFA_MODE=off", async () => {
830
- const { config } = await bootShipped({ ...SHIPPED_ENV, MFA_MODE: "off" }, "production");
831
- expect(config).not.toHaveProperty("mfa");
832
- expect(config.core?.sessionRequirements?.expected ?? []).not.toContain("mfa");
833
- });
834
- });
835
-
836
- describe("under CONFIG_ENV=development", () => {
837
- it("boots on the sample key config/development.conf carries, with the development mail sender, warning once", async () => {
838
- const { logger } = await bootShipped(SHIPPED_ENV, "development");
839
- expect(warned(logger, "mfa_development_sample_key_in_use")).toHaveLength(1);
840
- });
841
-
842
- it("refuses, before boot, MFA_ENCRYPTION_KEY set beside the sample key the file's ring holds, naming both and quoting neither key", async () => {
843
- let err: unknown;
844
- await bootShipped({ ...SHIPPED_ENV, MFA_ENCRYPTION_KEY: MFA_KEY }, "development").catch(
845
- (caught: unknown) => {
846
- err = caught;
847
- },
848
- );
849
- expect(err).toBeInstanceOf(BootError);
850
- expect(err).toMatchObject({
851
- reason: "config-validation-failed",
852
- details: { issues: [{ code: "custom", path: ["mfa", "encryptionKeys"] }] },
853
- });
854
- const details = JSON.stringify((err as BootError).details);
855
- expect(details).not.toContain(MFA_KEY);
856
- expect(details).not.toContain(MFA_DEVELOPMENT_SAMPLE_KEY);
857
- const message = (err as BootError).message;
858
- expect(message).toContain("MFA_ENCRYPTION_KEY");
859
- expect(message).toContain("mfa.encryptionKeys");
860
- expect(message).not.toContain(MFA_KEY);
861
- expect(message).not.toContain(MFA_DEVELOPMENT_SAMPLE_KEY);
862
- });
863
-
864
- it.each([
865
- ["set to the sample key itself", MFA_DEVELOPMENT_SAMPLE_KEY],
866
- ["exported blank", ""],
867
- ])("boots with MFA_ENCRYPTION_KEY %s", async (_label, key) => {
868
- const { logger } = await bootShipped(
869
- { ...SHIPPED_ENV, MFA_ENCRYPTION_KEY: key },
870
- "development",
871
- );
872
- expect(warned(logger, "mfa_development_sample_key_in_use")).toHaveLength(1);
873
- });
874
-
875
- it("hands boot no mfa section with MFA_MODE unset, though development.conf writes one", async () => {
876
- const { logger, config } = await bootShipped(
877
- { ...SHIPPED_ENV, MFA_MODE: "off" },
878
- "development",
879
- );
880
- expect(config).not.toHaveProperty("mfa");
881
- expect(warned(logger, "config_sections_ignored")).toEqual([]);
882
- });
883
- });