@o3co/create-auth-provider 0.17.0-rc.2 → 0.17.0-rc.4
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.ja.md +3 -1
- package/README.md +5 -2
- package/package.json +3 -3
- package/templates/standalone/.env.example +7 -2
- package/templates/standalone/README.ja.md +17 -11
- package/templates/standalone/README.md +47 -21
- package/templates/standalone/config/application.conf +1 -0
- package/templates/standalone/config/clients.yaml.example +8 -0
- package/templates/standalone/config/development.conf +2 -2
- package/templates/standalone/config/mailpit.conf +10 -5
- package/templates/standalone/config/reference.conf +9 -3
- package/templates/standalone/docker-compose.mailpit.yml +3 -0
- package/templates/standalone/package.json +6 -6
- package/templates/standalone/src/__tests__/adapters.test.mts +12 -6
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +13 -5
- package/templates/standalone/src/__tests__/config-notices.test.mts +4 -1
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +9 -6
- package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +91 -87
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -0
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -14
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +79 -21
- package/templates/standalone/src/__tests__/own-modules.test.mts +38 -12
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +4 -0
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +10 -3
- package/templates/standalone/src/__tests__/sections.test.mts +24 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +4 -0
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +8 -4
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +2 -1
- package/templates/standalone/src/adapters.mts +2 -2
- package/templates/standalone/src/mfaSwitch.mts +6 -7
- package/templates/standalone/src/modules.mts +21 -9
- package/templates/standalone/src/rootRenames.mts +12 -13
- package/templates/standalone/src/sections.mts +12 -2
- package/templates/standalone/tests/package-lock.json +95 -95
- package/templates/standalone/tests/package.json +1 -1
- package/templates/versions.json +18 -18
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* The development compose run, `docker-compose.yml`, with MFA
|
|
19
|
-
* template ships it, and the Mailpit
|
|
20
|
-
* development run whose mail goes
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* The development compose run, `docker-compose.yml`, with MFA as the
|
|
19
|
+
* configuration's switch says (on as the template ships it), and the Mailpit
|
|
20
|
+
* overlay, `docker-compose.mailpit.yml`: a development run whose mail goes
|
|
21
|
+
* through the SMTP sender to Mailpit. It is an overlay on
|
|
22
|
+
* `docker-compose.yml`, so a plain `docker compose up` starts no Mailpit, and
|
|
23
|
+
* the production file names none. The compose files are read as
|
|
23
24
|
* the fixed-shape files they are, and the process each run describes is booted
|
|
24
25
|
* from the shipped files, as `app.mts` boots them, under the environment the
|
|
25
26
|
* run sets: `.env.example`'s lines, then each compose file's `environment`, the
|
|
@@ -44,6 +45,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
44
45
|
import { buildModules } from "#/buildModules.mjs";
|
|
45
46
|
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
|
|
46
47
|
import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
|
|
48
|
+
import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
|
|
47
49
|
|
|
48
50
|
vi.mock("redis", () => ({
|
|
49
51
|
createClient: vi.fn(() => ({
|
|
@@ -74,6 +76,10 @@ vi.mock("ioredis", () => {
|
|
|
74
76
|
quit: async () => "OK",
|
|
75
77
|
disconnect: () => undefined,
|
|
76
78
|
ping: async () => "PONG",
|
|
79
|
+
// The Redis stores' eviction gate reads the policy from INFO memory: a
|
|
80
|
+
// default server's.
|
|
81
|
+
info: async (section: string) =>
|
|
82
|
+
section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
|
|
77
83
|
};
|
|
78
84
|
const makeMockRedis = (): object =>
|
|
79
85
|
new Proxy(
|
|
@@ -245,14 +251,15 @@ describe("the process each development run describes", () => {
|
|
|
245
251
|
};
|
|
246
252
|
}
|
|
247
253
|
|
|
248
|
-
it("boots docker-compose.yml's run with MFA
|
|
254
|
+
it("boots docker-compose.yml's run with MFA as the configuration's switch says: development, the development mail sender, the MFA stores on its Redis", async () => {
|
|
249
255
|
const env = containerEnv(false);
|
|
250
256
|
// `app.mts`: CONFIG_ENV, then NODE_ENV, then development; the run sets neither.
|
|
251
257
|
expect(env.CONFIG_ENV ?? env.NODE_ENV).toBeUndefined();
|
|
252
258
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
|
|
253
259
|
const own = readOwnLayers([envConfPath, applicationConfPath], { env });
|
|
254
260
|
const switches = readSwitches(own);
|
|
255
|
-
|
|
261
|
+
const mode = configuredMfaMode("development");
|
|
262
|
+
expect(switches.mfaMode).toBe(mode);
|
|
256
263
|
// A hot reload restarts the process on every save, and Redis keeps the
|
|
257
264
|
// factors, and the sessions beside them, across it.
|
|
258
265
|
expect(switches.adapters).toMatchObject({
|
|
@@ -277,7 +284,7 @@ describe("the process each development run describes", () => {
|
|
|
277
284
|
"standard-development",
|
|
278
285
|
);
|
|
279
286
|
expect((handle.components.config as { mfa?: { mode?: unknown } } | undefined)?.mfa?.mode).toBe(
|
|
280
|
-
"
|
|
287
|
+
mode === "off" ? undefined : mode,
|
|
281
288
|
);
|
|
282
289
|
});
|
|
283
290
|
|
|
@@ -289,18 +296,27 @@ describe("the process each development run describes", () => {
|
|
|
289
296
|
expect(existsSync(resolveConfigPaths(configDir, configEnv as string).envConfPath)).toBe(true);
|
|
290
297
|
});
|
|
291
298
|
|
|
292
|
-
|
|
293
|
-
|
|
299
|
+
/** The key the operator writes into `.env` for the Mailpit run (`openssl rand -base64 32`). */
|
|
300
|
+
const OWN_MFA_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
301
|
+
|
|
302
|
+
/** The Mailpit run's modules and the configuration boot is handed, under `env`. */
|
|
303
|
+
function mailpitRun(env: Record<string, string>) {
|
|
294
304
|
const configEnv = env.CONFIG_ENV as string;
|
|
295
305
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
296
306
|
const own = readOwnLayers([envConfPath, applicationConfPath], { env });
|
|
297
307
|
const switches = readSwitches(own);
|
|
298
|
-
expect(switches.mfaMode).toBe("required");
|
|
299
|
-
|
|
300
308
|
const modules = buildModules(switches, {
|
|
301
309
|
environment: configEnv,
|
|
302
310
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
303
311
|
});
|
|
312
|
+
return { switches, modules, config: () => resolveForBoot(own, modules, switches) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
it("boots with the SMTP sender relaying to Mailpit: in the mailSender slot, with the operator's MFA key, while MFA is on (the configuration's switch); unread, with no mfa section, while it is off", async () => {
|
|
316
|
+
const env: Record<string, string> = { ...containerEnv(), MFA_ENCRYPTION_KEY: OWN_MFA_KEY };
|
|
317
|
+
const { switches, modules, config } = mailpitRun(env);
|
|
318
|
+
const mode = configuredMfaMode(env.CONFIG_ENV as string);
|
|
319
|
+
expect(switches.mfaMode).toBe(mode);
|
|
304
320
|
const names = modules.map((module) => module.name);
|
|
305
321
|
expect(names).toContain(standardSmtpMailSenderModule.name);
|
|
306
322
|
expect(names).not.toContain(
|
|
@@ -310,17 +326,68 @@ describe("the process each development run describes", () => {
|
|
|
310
326
|
const handle = await createApp({
|
|
311
327
|
modules,
|
|
312
328
|
bootstrapComponents: {
|
|
313
|
-
config:
|
|
329
|
+
config: config(),
|
|
314
330
|
pathResolver: (s: string) => s,
|
|
315
331
|
logger: createRecordingLogger(),
|
|
316
332
|
},
|
|
317
333
|
});
|
|
318
334
|
handles.push(handle);
|
|
319
|
-
expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe(
|
|
335
|
+
expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe(
|
|
336
|
+
mode === "off" ? undefined : "standard-smtp",
|
|
337
|
+
);
|
|
338
|
+
const mfa = (
|
|
339
|
+
handle.components.config as {
|
|
340
|
+
mfa?: { mode?: unknown; encryptionKeys?: { key?: unknown }[] };
|
|
341
|
+
}
|
|
342
|
+
).mfa;
|
|
343
|
+
if (mode === "off") {
|
|
344
|
+
expect(mfa).toBeUndefined();
|
|
345
|
+
} else {
|
|
346
|
+
expect(mfa?.mode).toBe(mode);
|
|
347
|
+
expect(mfa?.encryptionKeys?.map((entry) => entry.key)).toEqual([OWN_MFA_KEY]);
|
|
348
|
+
}
|
|
320
349
|
expect(
|
|
321
350
|
(handle.components.config as Record<string, unknown> | undefined)?.[
|
|
322
351
|
"standard-smtp-mail-sender"
|
|
323
352
|
],
|
|
324
353
|
).toMatchObject({ host: "localhost", port: 1025, secure: "none" });
|
|
325
354
|
});
|
|
355
|
+
|
|
356
|
+
it("does not carry the development sample key: with MFA on, without MFA_ENCRYPTION_KEY the run is refused; with it off, nothing reads a key", async () => {
|
|
357
|
+
const env = containerEnv();
|
|
358
|
+
expect(env.MFA_ENCRYPTION_KEY).toBeUndefined();
|
|
359
|
+
const { modules, config } = mailpitRun(env);
|
|
360
|
+
if (configuredMfaMode(env.CONFIG_ENV as string) === "off") {
|
|
361
|
+
const handle = await createApp({
|
|
362
|
+
modules,
|
|
363
|
+
bootstrapComponents: {
|
|
364
|
+
config: config(),
|
|
365
|
+
pathResolver: (s: string) => s,
|
|
366
|
+
logger: createRecordingLogger(),
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
handles.push(handle);
|
|
370
|
+
expect(handle.components.config).not.toHaveProperty("mfa");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const refused = await (async () => {
|
|
374
|
+
try {
|
|
375
|
+
handles.push(
|
|
376
|
+
await createApp({
|
|
377
|
+
modules,
|
|
378
|
+
bootstrapComponents: {
|
|
379
|
+
config: config(),
|
|
380
|
+
pathResolver: (s: string) => s,
|
|
381
|
+
logger: createRecordingLogger(),
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return error;
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
})();
|
|
390
|
+
expect(refused).toBeInstanceOf(Error);
|
|
391
|
+
expect(String((refused as Error).message)).toMatch(/mfa\.encryptionKeys/);
|
|
392
|
+
});
|
|
326
393
|
});
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
/**
|
|
18
18
|
* The template's MFA switch under the name production, where the MFA stores
|
|
19
19
|
* must be shared: both on Redis, as a production deployment selects them.
|
|
20
|
-
* ioredis is a stand-in, as in `all-modules-composition.multi.test.mts`:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* real server.
|
|
20
|
+
* ioredis is a stand-in, as in `all-modules-composition.multi.test.mts`: it
|
|
21
|
+
* answers `INFO memory` as a default server does, so the stores' eviction gate
|
|
22
|
+
* passes, and their persistence check names what it could not read and goes
|
|
23
|
+
* on. `packages/redis` runs the stores against a real server.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { BootError } from "@o3co/auth-provider-core";
|
|
@@ -36,6 +36,10 @@ vi.mock("ioredis", () => {
|
|
|
36
36
|
quit: async () => "OK",
|
|
37
37
|
disconnect: () => undefined,
|
|
38
38
|
ping: async () => "PONG",
|
|
39
|
+
// The Redis stores' eviction gate reads the policy from INFO memory: a
|
|
40
|
+
// default server's.
|
|
41
|
+
info: async (section: string) =>
|
|
42
|
+
section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
|
|
39
43
|
};
|
|
40
44
|
const makeMockRedis = (): object =>
|
|
41
45
|
new Proxy(
|
|
@@ -15,14 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* The template's MFA switch, `mfaMode` (`MFA_MODE`): `off
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
24
|
-
* fixture
|
|
25
|
-
*
|
|
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.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
import { generateKeyPairSync } from "node:crypto";
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
} from "@o3co/auth-provider-core";
|
|
44
45
|
import { MFA_DEVELOPMENT_SAMPLE_KEY, mfaModules, mfaResetModule } from "@o3co/auth-provider-mfa";
|
|
45
46
|
import { loginCompletionModule } from "@o3co/auth-provider-session";
|
|
47
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
46
48
|
import request from "supertest";
|
|
47
49
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
48
50
|
import { buildModules } from "#/buildModules.mjs";
|
|
@@ -53,20 +55,25 @@ import {
|
|
|
53
55
|
resolveConfigPaths,
|
|
54
56
|
resolveForBoot,
|
|
55
57
|
} from "#/configPath.mjs";
|
|
58
|
+
import { mfaModulesFor } from "#/mfaSwitch.mjs";
|
|
59
|
+
import { templateReference } from "#/modules.mjs";
|
|
56
60
|
import {
|
|
57
61
|
ALICE,
|
|
58
62
|
authorize,
|
|
59
63
|
type Composition,
|
|
64
|
+
codeFrom,
|
|
60
65
|
compose,
|
|
61
66
|
contributionNames,
|
|
62
67
|
cookiesOf,
|
|
63
68
|
createRecordingLogger,
|
|
64
69
|
ISSUER,
|
|
70
|
+
login,
|
|
65
71
|
ownFiles,
|
|
66
72
|
type RecordingLogger,
|
|
67
73
|
SINGLE_ENV,
|
|
68
74
|
WEB,
|
|
69
75
|
} from "./all-modules-composition.fixture.mjs";
|
|
76
|
+
import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
|
|
70
77
|
|
|
71
78
|
/** 32 bytes, base64: a key of the deployment's own for the MFA key ring. */
|
|
72
79
|
const MFA_KEY = Buffer.alloc(32, 9).toString("base64");
|
|
@@ -133,17 +140,22 @@ const textOf = (err: unknown): string => {
|
|
|
133
140
|
const warned = (logger: RecordingLogger, event: string) =>
|
|
134
141
|
logger.lines.filter((line) => line.level === "warn" && line.args[1] === event);
|
|
135
142
|
|
|
136
|
-
/** `env` with MFA_MODE unset: the switch as the
|
|
143
|
+
/** `env` with MFA_MODE unset: the switch as the configuration's files write it (`configuredMfaMode`). */
|
|
137
144
|
const unset = (env: Readonly<Record<string, string>>): Record<string, string> => {
|
|
138
145
|
const { MFA_MODE: _unset, ...rest } = env;
|
|
139
146
|
return rest;
|
|
140
147
|
};
|
|
141
148
|
|
|
142
149
|
describe("the switch is the template's own key, mfaMode, bound to MFA_MODE", () => {
|
|
143
|
-
it("
|
|
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", () => {
|
|
144
156
|
const switches = (env: Readonly<Record<string, string>>) =>
|
|
145
157
|
readSwitches(readOwnLayers(ownFiles(), { env })).mfaMode;
|
|
146
|
-
expect(switches(unset(SINGLE_ENV))).toBe("
|
|
158
|
+
expect(switches(unset(SINGLE_ENV))).toBe(configuredMfaMode("production"));
|
|
147
159
|
for (const mode of ["off", "optional", "required"] as const) {
|
|
148
160
|
expect(switches({ ...SINGLE_ENV, MFA_MODE: mode })).toBe(mode);
|
|
149
161
|
}
|
|
@@ -200,22 +212,37 @@ describe("the switch is the template's own key, mfaMode, bound to MFA_MODE", ()
|
|
|
200
212
|
});
|
|
201
213
|
});
|
|
202
214
|
|
|
203
|
-
describe("MFA_MODE unset: the
|
|
204
|
-
|
|
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 () => {
|
|
205
219
|
current = await boot({ env: unset(on("required")) });
|
|
206
220
|
const names = current.modules.map((m) => m.name);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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);
|
|
210
229
|
expect(current.config.core?.sessionRequirements).toEqual({
|
|
211
230
|
expected: ["mfa"],
|
|
212
231
|
secondFactorAuthority: "mfa",
|
|
213
232
|
});
|
|
214
|
-
expect((current.config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe(
|
|
233
|
+
expect((current.config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe(mode);
|
|
215
234
|
});
|
|
216
235
|
|
|
217
|
-
it("interrupts a password login, opening no session /authorize accepts", async () => {
|
|
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 () => {
|
|
218
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
|
+
}
|
|
219
246
|
const csrf = await request(current.app).get("/session/csrf");
|
|
220
247
|
const res = await request(current.app)
|
|
221
248
|
.post("/session/login")
|
|
@@ -402,6 +429,24 @@ describe("the MFA stores in memory: development and test alone", () => {
|
|
|
402
429
|
expect((err as BootError).reason).toBe("config-validation-failed");
|
|
403
430
|
});
|
|
404
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
|
+
|
|
405
450
|
it.each(["development", "test"])("lets them in under %s", async (environment) => {
|
|
406
451
|
current = await compose({ env: on("required"), environment });
|
|
407
452
|
expect(current.modules.map((m) => m.name)).toContain(memoryMfaFactorStoreModule.name);
|
|
@@ -739,14 +784,27 @@ describe("a deployment that takes MFA's modules out of buildModules() under MFA_
|
|
|
739
784
|
});
|
|
740
785
|
|
|
741
786
|
describe("the shipped configuration with nothing set about MFA", () => {
|
|
742
|
-
it("boots under CONFIG_ENV=development with
|
|
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");
|
|
743
789
|
const { logger, config } = await bootShipped(unset(SHIPPED_ENV), "development");
|
|
744
|
-
|
|
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);
|
|
745
797
|
expect(config.core?.sessionRequirements?.expected).toContain("mfa");
|
|
746
798
|
expect(warned(logger, "mfa_development_sample_key_in_use")).toHaveLength(1);
|
|
747
799
|
});
|
|
748
800
|
|
|
749
|
-
it("is refused
|
|
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
|
+
}
|
|
750
808
|
let err: unknown;
|
|
751
809
|
await bootShipped(unset(SHIPPED_ENV), "production").catch((caught: unknown) => {
|
|
752
810
|
err = caught;
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* environment and HOCON overrides, through the template's real path:
|
|
23
23
|
* `readOwnLayers`, then `resolveForBoot` over every loaded module's
|
|
24
24
|
* reference, then `createApp`. A path a section moved from refuses boot
|
|
25
|
-
* naming its new path, and a variable renamed with it refuses boot
|
|
26
|
-
*
|
|
25
|
+
* naming its new path, and a variable renamed with it refuses boot while its
|
|
26
|
+
* old name is set, whatever its new name holds.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { generateKeyPairSync } from "node:crypto";
|
|
@@ -332,8 +332,19 @@ describe("logging", () => {
|
|
|
332
332
|
});
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
-
it("
|
|
336
|
-
|
|
335
|
+
it("refuses LOG_LEVEL beside LOGGING_LEVEL at the same value", async () => {
|
|
336
|
+
await expect(
|
|
337
|
+
bootTemplate({ env: { LOG_LEVEL: "debug", LOGGING_LEVEL: "debug" } }),
|
|
338
|
+
).rejects.toMatchObject({
|
|
339
|
+
details: {
|
|
340
|
+
reason: "environment-variable-renamed",
|
|
341
|
+
renamed: [{ module: "logging", from: "LOG_LEVEL", state: "different" }],
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("boots with LOGGING_LEVEL alone", async () => {
|
|
347
|
+
const handle = await bootTemplate({ env: { LOGGING_LEVEL: "debug" } });
|
|
337
348
|
await handle.dispose();
|
|
338
349
|
});
|
|
339
350
|
});
|
|
@@ -947,17 +958,32 @@ describe("key-store", () => {
|
|
|
947
958
|
});
|
|
948
959
|
});
|
|
949
960
|
|
|
950
|
-
it("
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
961
|
+
it("refuses each old name beside its new one at the same value", async () => {
|
|
962
|
+
await expect(
|
|
963
|
+
bootTemplate({
|
|
964
|
+
env: {
|
|
965
|
+
OAUTH_JWT_PRIVATE_KEY: signingKey.privateKey,
|
|
966
|
+
OAUTH_JWT_PUBLIC_KEY: signingKey.publicKey,
|
|
967
|
+
OAUTH_JWT_KID: "k-both",
|
|
968
|
+
KEY_STORE_LOCAL_KID: "k-both",
|
|
969
|
+
},
|
|
970
|
+
}),
|
|
971
|
+
).rejects.toMatchObject({
|
|
972
|
+
details: {
|
|
973
|
+
reason: "environment-variable-renamed",
|
|
974
|
+
renamed: expect.arrayContaining([
|
|
975
|
+
expect.objectContaining({ from: "OAUTH_JWT_PRIVATE_KEY", state: "different" }),
|
|
976
|
+
expect.objectContaining({ from: "OAUTH_JWT_PUBLIC_KEY", state: "different" }),
|
|
977
|
+
expect.objectContaining({ from: "OAUTH_JWT_KID", state: "different" }),
|
|
978
|
+
]),
|
|
957
979
|
},
|
|
958
980
|
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("boots with each new name alone", async () => {
|
|
984
|
+
const handle = await bootTemplate({ env: { KEY_STORE_LOCAL_KID: "k-new" } });
|
|
959
985
|
try {
|
|
960
|
-
expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-
|
|
986
|
+
expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-new");
|
|
961
987
|
} finally {
|
|
962
988
|
await handle.dispose();
|
|
963
989
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
* `redis-clients.assumeNoEviction` (`REDIS_CLIENTS_ASSUME_NO_EVICTION`), on a
|
|
19
|
+
* Redis that will not report its eviction policy: every store on Redis, as a
|
|
20
|
+
* multi-replica deployment selects them. ioredis is a stand-in that refuses
|
|
21
|
+
* `INFO` and `CONFIG` as an ACL-restricted user's connection does; every
|
|
22
|
+
* other command resolves to nothing. node-redis and connect-redis, behind
|
|
23
|
+
* express-session's store, are stand-ins too, as in
|
|
24
|
+
* `all-modules-composition.multi.test.mts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { BootError } from "@o3co/auth-provider-core";
|
|
28
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
29
|
+
import {
|
|
30
|
+
type Composition,
|
|
31
|
+
compose,
|
|
32
|
+
MULTI_ENV,
|
|
33
|
+
SINGLE_ENV,
|
|
34
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
35
|
+
|
|
36
|
+
vi.mock("redis", () => ({
|
|
37
|
+
createClient: vi.fn(() => ({
|
|
38
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
quit: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
ping: vi.fn().mockResolvedValue("PONG"),
|
|
41
|
+
on: vi.fn(),
|
|
42
|
+
})),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("connect-redis", async () => {
|
|
46
|
+
const { EventEmitter } = await import("node:events");
|
|
47
|
+
// express-session subscribes to store events, so the stand-in is an emitter.
|
|
48
|
+
return {
|
|
49
|
+
RedisStore: class MockRedisStore extends EventEmitter {
|
|
50
|
+
get(): unknown {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
set(): void {}
|
|
54
|
+
destroy(): void {}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
vi.mock("ioredis", () => {
|
|
60
|
+
const refused = async (): Promise<never> => {
|
|
61
|
+
throw Object.assign(new Error("NOPERM this user has no permissions to run this command"), {
|
|
62
|
+
name: "ReplyError",
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const explicit: Record<string, unknown> = {
|
|
66
|
+
on: () => undefined,
|
|
67
|
+
quit: async () => "OK",
|
|
68
|
+
disconnect: () => undefined,
|
|
69
|
+
ping: async () => "PONG",
|
|
70
|
+
info: refused,
|
|
71
|
+
config: refused,
|
|
72
|
+
};
|
|
73
|
+
const makeMockRedis = (): object =>
|
|
74
|
+
new Proxy(
|
|
75
|
+
{},
|
|
76
|
+
{
|
|
77
|
+
get(_target, prop) {
|
|
78
|
+
// A function-valued `then` would make the instance a thenable.
|
|
79
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
80
|
+
if (prop === "duplicate") return makeMockRedis;
|
|
81
|
+
if (prop in explicit) return explicit[prop];
|
|
82
|
+
return async () => null;
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
function MockRedis(): object {
|
|
87
|
+
return makeMockRedis();
|
|
88
|
+
}
|
|
89
|
+
return { Redis: MockRedis, default: MockRedis };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let current: Composition | undefined;
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await current?.handle.dispose();
|
|
96
|
+
current = undefined;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** MFA required, both MFA stores on Redis, a key of the deployment's own and an SMTP relay. */
|
|
100
|
+
const MFA_ON_REDIS: Readonly<Record<string, string>> = {
|
|
101
|
+
MFA_MODE: "required",
|
|
102
|
+
ADAPTERS_MFA_FACTOR_STORE: "redis",
|
|
103
|
+
ADAPTERS_MFA_TRANSACTION_STORE: "redis",
|
|
104
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
105
|
+
MFA_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"),
|
|
106
|
+
STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
|
|
107
|
+
STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const composing = (env: Readonly<Record<string, string>>): Promise<unknown> =>
|
|
111
|
+
compose({ env, environment: "production", shippedRefreshTokenFamilyStore: true }).then(
|
|
112
|
+
(composition) => {
|
|
113
|
+
current = composition;
|
|
114
|
+
return undefined;
|
|
115
|
+
},
|
|
116
|
+
(caught: unknown) => caught,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
describe("a Redis that will not report its eviction policy", () => {
|
|
120
|
+
it.each([
|
|
121
|
+
["unset", MULTI_ENV],
|
|
122
|
+
["false", { ...MULTI_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
|
|
123
|
+
])(
|
|
124
|
+
"refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming the store",
|
|
125
|
+
async (_label, env) => {
|
|
126
|
+
const err = await composing(env);
|
|
127
|
+
expect(err).toBeInstanceOf(BootError);
|
|
128
|
+
expect(err).toMatchObject({
|
|
129
|
+
reason: "provides-factory-failed",
|
|
130
|
+
cause: { name: "RedisStoreEvictableError", maxmemoryPolicy: undefined },
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
it("boots every store with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
|
|
136
|
+
expect(
|
|
137
|
+
await composing({ ...MULTI_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
|
|
138
|
+
).toBeUndefined();
|
|
139
|
+
expect(current?.modules.map((module) => module.name)).toEqual(
|
|
140
|
+
expect.arrayContaining([
|
|
141
|
+
"redis-attempt-counter",
|
|
142
|
+
"redis-session-stores",
|
|
143
|
+
"redis-federation-token-store",
|
|
144
|
+
]),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("boots with redis-clients.assumeNoEviction = true written in HOCON", async () => {
|
|
149
|
+
const composition = await compose({
|
|
150
|
+
env: MULTI_ENV,
|
|
151
|
+
environment: "production",
|
|
152
|
+
shippedRefreshTokenFamilyStore: true,
|
|
153
|
+
operatorHocon: "redis-clients.assumeNoEviction = true\n",
|
|
154
|
+
});
|
|
155
|
+
current = composition;
|
|
156
|
+
expect(composition.handle).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("the MFA stores on a Redis that will not report its eviction policy", () => {
|
|
161
|
+
/** Only the MFA stores on Redis: a refusal can come from no other store. */
|
|
162
|
+
const MFA_ONLY = { ...SINGLE_ENV, ...MFA_ON_REDIS };
|
|
163
|
+
|
|
164
|
+
it.each([
|
|
165
|
+
["unset", MFA_ONLY],
|
|
166
|
+
["false", { ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
|
|
167
|
+
])(
|
|
168
|
+
"refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming an MFA store",
|
|
169
|
+
async (_label, env) => {
|
|
170
|
+
const err = await composing(env);
|
|
171
|
+
expect(err).toBeInstanceOf(BootError);
|
|
172
|
+
expect(err).toMatchObject({
|
|
173
|
+
reason: "provides-factory-failed",
|
|
174
|
+
cause: {
|
|
175
|
+
name: "RedisStoreEvictableError",
|
|
176
|
+
reason: expect.stringMatching(/^mfa-(factor|transaction)-store-evictable$/),
|
|
177
|
+
maxmemoryPolicy: undefined,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
it("boots both MFA stores with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
|
|
184
|
+
expect(
|
|
185
|
+
await composing({ ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
|
|
186
|
+
).toBeUndefined();
|
|
187
|
+
expect(current?.modules.map((module) => module.name)).toEqual(
|
|
188
|
+
expect.arrayContaining(["redis-mfa-factor-store", "redis-mfa-transaction-store"]),
|
|
189
|
+
);
|
|
190
|
+
expect(current?.handle.components.mfaFactorStore?.kind).toBe("redis");
|
|
191
|
+
expect(current?.handle.components.mfaTransactionStore?.kind).toBe("redis");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it.each([
|
|
195
|
+
["unset", { ...MULTI_ENV, ...MFA_ON_REDIS }],
|
|
196
|
+
["false", { ...MULTI_ENV, ...MFA_ON_REDIS, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
|
|
197
|
+
])(
|
|
198
|
+
"refuses every store on Redis with MFA on, REDIS_CLIENTS_ASSUME_NO_EVICTION %s",
|
|
199
|
+
async (_label, env) => {
|
|
200
|
+
expect(await composing(env)).toMatchObject({
|
|
201
|
+
reason: "provides-factory-failed",
|
|
202
|
+
cause: { name: "RedisStoreEvictableError", maxmemoryPolicy: undefined },
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
it("boots every store on Redis with MFA on and REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
|
|
208
|
+
expect(
|
|
209
|
+
await composing({ ...MULTI_ENV, ...MFA_ON_REDIS, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
|
|
210
|
+
).toBeUndefined();
|
|
211
|
+
expect(current?.handle.components.mfaFactorStore?.kind).toBe("redis");
|
|
212
|
+
expect(current?.handle.components.mfaTransactionStore?.kind).toBe("redis");
|
|
213
|
+
});
|
|
214
|
+
});
|