@o3co/create-auth-provider 0.17.0-rc.2 → 0.17.0-rc.3
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 +1 -1
- package/templates/standalone/.env.example +7 -2
- package/templates/standalone/README.ja.md +14 -10
- package/templates/standalone/README.md +26 -20
- 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 +1 -1
- 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 +89 -86
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -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__/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 +12 -6
- package/templates/standalone/src/rootRenames.mts +12 -13
- package/templates/standalone/src/sections.mts +12 -2
- package/templates/versions.json +18 -18
|
@@ -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
|
+
});
|
|
@@ -102,6 +102,10 @@ vi.mock("ioredis", () => {
|
|
|
102
102
|
quit: async () => "OK",
|
|
103
103
|
disconnect: () => undefined,
|
|
104
104
|
ping: async () => "PONG",
|
|
105
|
+
// The Redis stores' eviction gate reads the policy from INFO memory: a
|
|
106
|
+
// default server's.
|
|
107
|
+
info: async (section: string) =>
|
|
108
|
+
section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
|
|
105
109
|
};
|
|
106
110
|
const makeMockRedis = (): object =>
|
|
107
111
|
new Proxy(
|
|
@@ -32,6 +32,7 @@ import { buildModules } from "../buildModules.mjs";
|
|
|
32
32
|
import { readOwnLayers, readSwitches, resolveConfigPaths } from "../configPath.mjs";
|
|
33
33
|
import { templateReference } from "../modules.mjs";
|
|
34
34
|
import { type Adapters, repositoriesSectionSchema } from "../sections.mjs";
|
|
35
|
+
import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
|
|
35
36
|
|
|
36
37
|
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
37
38
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -355,8 +356,8 @@ function dotenvExample(): Record<string, string> {
|
|
|
355
356
|
return env;
|
|
356
357
|
}
|
|
357
358
|
|
|
358
|
-
describe("the production compose leaves MFA
|
|
359
|
-
it("reads the switch as
|
|
359
|
+
describe("the production compose leaves MFA to the configuration's switch, for the deployment to decide", () => {
|
|
360
|
+
it("reads the switch as the configuration's files write it: on, a deployment that sets nothing about MFA is refused naming MFA_MODE=off; off, it installs nothing of MFA", () => {
|
|
360
361
|
// `environment:` wins over `env_file`, so the compose file's block is
|
|
361
362
|
// laid over the `.env` copied from `.env.example`.
|
|
362
363
|
const env = { ...dotenvExample(), ...bootableEnv("/docker-compose.production.yml") };
|
|
@@ -365,8 +366,14 @@ describe("the production compose leaves MFA on, for the deployment to decide", (
|
|
|
365
366
|
expect(configEnv).toBe("production");
|
|
366
367
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
367
368
|
const switches = readSwitches(readOwnLayers([envConfPath, applicationConfPath], { env }));
|
|
368
|
-
|
|
369
|
+
const mode = configuredMfaMode(configEnv);
|
|
370
|
+
expect(switches.mfaMode).toBe(mode);
|
|
369
371
|
|
|
372
|
+
if (mode === "off") {
|
|
373
|
+
const names = buildModules(switches, { environment: configEnv }).map((m) => m.name);
|
|
374
|
+
expect(names.filter((name) => /mfa/i.test(name))).toEqual([]);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
370
377
|
let err: unknown;
|
|
371
378
|
try {
|
|
372
379
|
buildModules(switches, { environment: configEnv });
|
|
@@ -443,12 +443,36 @@ describe("redis-clients", () => {
|
|
|
443
443
|
expect(redisClientsSectionSchema.parse({ url: "redis://r:6379", password: "p" })).toEqual({
|
|
444
444
|
url: "redis://r:6379",
|
|
445
445
|
password: "p",
|
|
446
|
+
assumeNoEviction: false,
|
|
446
447
|
});
|
|
447
448
|
expect(redisClientsSectionSchema.parse({ url: "redis://r:6379" })).toEqual({
|
|
448
449
|
url: "redis://r:6379",
|
|
450
|
+
assumeNoEviction: false,
|
|
449
451
|
});
|
|
450
452
|
});
|
|
451
453
|
|
|
454
|
+
it.each([
|
|
455
|
+
[true, true],
|
|
456
|
+
["true", true],
|
|
457
|
+
["1", true],
|
|
458
|
+
["false", false],
|
|
459
|
+
["", false],
|
|
460
|
+
])("reads assumeNoEviction %j as %s", (given, read) => {
|
|
461
|
+
expect(
|
|
462
|
+
redisClientsSectionSchema.parse({ url: "redis://r:6379", assumeNoEviction: given })
|
|
463
|
+
.assumeNoEviction,
|
|
464
|
+
).toBe(read);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("refuses an assumeNoEviction it cannot read as a boolean, naming the key", () => {
|
|
468
|
+
const parsed = redisClientsSectionSchema.safeParse({
|
|
469
|
+
url: "redis://r:6379",
|
|
470
|
+
assumeNoEviction: "yes",
|
|
471
|
+
});
|
|
472
|
+
expect(parsed.success).toBe(false);
|
|
473
|
+
expect(parsed.error?.issues[0]?.path).toEqual(["assumeNoEviction"]);
|
|
474
|
+
});
|
|
475
|
+
|
|
452
476
|
it.each([
|
|
453
477
|
["no URL", {}],
|
|
454
478
|
["a null URL", { url: null }],
|
|
@@ -74,6 +74,10 @@ vi.mock("ioredis", () => {
|
|
|
74
74
|
quit: async () => "OK",
|
|
75
75
|
disconnect: () => undefined,
|
|
76
76
|
ping: async () => "PONG",
|
|
77
|
+
// The Redis stores' eviction gate reads the policy from INFO memory: a
|
|
78
|
+
// default server's.
|
|
79
|
+
info: async (section: string) =>
|
|
80
|
+
section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
|
|
77
81
|
};
|
|
78
82
|
const makeMockRedis = (): object =>
|
|
79
83
|
new Proxy(
|