@o3co/create-auth-provider 0.17.0 → 0.18.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- package/templates/versions.json +0 -20
|
@@ -1,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
|
-
});
|