@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.2
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/package.json +1 -1
- package/templates/standalone/.env.example +0 -3
- package/templates/standalone/README.ja.md +42 -18
- package/templates/standalone/README.md +120 -44
- package/templates/standalone/config/application.conf +40 -36
- package/templates/standalone/config/reference.conf +21 -2
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
- package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
- package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
- package/templates/standalone/src/adapters.mts +31 -18
- package/templates/standalone/src/app.mts +6 -5
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +46 -96
- package/templates/standalone/src/configPath.mts +180 -153
- package/templates/standalone/src/mfaSwitch.mts +43 -20
- package/templates/standalone/src/modules.mts +25 -213
- package/templates/standalone/src/rootRenames.mts +35 -18
- package/templates/standalone/src/sections.mts +1 -0
- package/templates/standalone/tests/index.test.js +4 -3
- package/templates/versions.json +18 -18
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* — `[]` in the shipped `config/application.conf` — with `mfa` added when the
|
|
21
21
|
* template's MFA switch, `mfaMode` (`MFA_MODE`), is not `off`
|
|
22
22
|
* (`expectedSessionRequirements`); under that switch the template installs
|
|
23
|
-
* the MFA module, whose requirement registers `mfa
|
|
23
|
+
* the MFA module, whose requirement registers `mfa`; a value boot refuses is
|
|
24
|
+
* handed on as written, for boot to refuse. And what it hands boot
|
|
24
25
|
* of the MFA module's section, `mfa`: nothing unless a loaded module owns it,
|
|
25
26
|
* its mode written from the switch when the template installs MFA, and a
|
|
26
27
|
* mode the configuration writes that the switch does not say refused.
|
|
@@ -38,8 +39,8 @@ import {
|
|
|
38
39
|
readSwitches,
|
|
39
40
|
resolveForBoot,
|
|
40
41
|
resolveLayers,
|
|
41
|
-
type Switches,
|
|
42
42
|
} from "../configPath.mjs";
|
|
43
|
+
import type { MfaSwitch } from "../sections.mjs";
|
|
43
44
|
import {
|
|
44
45
|
type Composition,
|
|
45
46
|
compose,
|
|
@@ -119,6 +120,45 @@ describe("what the template expects of session admission", () => {
|
|
|
119
120
|
});
|
|
120
121
|
});
|
|
121
122
|
|
|
123
|
+
it.each([
|
|
124
|
+
["a name, not a list", 'core.sessionRequirements.expected = "risk"\n'],
|
|
125
|
+
["a list holding something other than a name", "core.sessionRequirements.expected = [1]\n"],
|
|
126
|
+
["a key core does not declare beside the list", "core.sessionRequirements.extra = 1\n"],
|
|
127
|
+
])(
|
|
128
|
+
"hands boot %s as written under the switch on, and boot refuses it naming core.sessionRequirements",
|
|
129
|
+
async (_label, operatorHocon) => {
|
|
130
|
+
const err = await refusal(
|
|
131
|
+
compose({
|
|
132
|
+
env: { ...SINGLE_ENV, ...MFA_ENV, MFA_MODE: "required" },
|
|
133
|
+
environment: "test",
|
|
134
|
+
operatorHocon,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
expect(err).toBeInstanceOf(BootError);
|
|
138
|
+
expect((err as BootError).reason).toBe("config-validation-failed");
|
|
139
|
+
expect(String((err as BootError).message)).toContain("core.sessionRequirements");
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
it("hands boot the configuration's own section as written under the switch off", () => {
|
|
144
|
+
const own = readOwnLayers(
|
|
145
|
+
[
|
|
146
|
+
operatorLayer(
|
|
147
|
+
'core.sessionRequirements { expected = ["risk"], secondFactorAuthority = "risk" }\n',
|
|
148
|
+
),
|
|
149
|
+
...ownFiles(),
|
|
150
|
+
],
|
|
151
|
+
{ env: SINGLE_ENV },
|
|
152
|
+
);
|
|
153
|
+
const resolved = resolveForBoot(own, [], readSwitches(own)) as unknown as {
|
|
154
|
+
readonly core?: { readonly sessionRequirements?: unknown };
|
|
155
|
+
};
|
|
156
|
+
expect(resolved.core?.sessionRequirements).toEqual({
|
|
157
|
+
expected: ["risk"],
|
|
158
|
+
secondFactorAuthority: "risk",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
122
162
|
it("refuses the boot when the configuration expects mfa under the switch off: nothing registers it", async () => {
|
|
123
163
|
const err = await refusal(
|
|
124
164
|
compose({ operatorHocon: 'core.sessionRequirements.expected = ["mfa"]\n' }),
|
|
@@ -179,11 +219,19 @@ describe("what the template hands boot of the mfa section", () => {
|
|
|
179
219
|
} catch (caught) {
|
|
180
220
|
err = caught;
|
|
181
221
|
}
|
|
182
|
-
expect(err).toBeInstanceOf(
|
|
183
|
-
|
|
222
|
+
expect(err).toBeInstanceOf(BootError);
|
|
223
|
+
expect(err).toMatchObject({
|
|
224
|
+
reason: "config-validation-failed",
|
|
225
|
+
details: {
|
|
226
|
+
issues: [{ code: "custom", path: ["mfa", "mode"] }],
|
|
227
|
+
modules: [{ module: "mfa", schemaPath: "mfa" }],
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const message = (err as BootError).message;
|
|
184
231
|
expect(message).toContain("mfa.mode");
|
|
185
232
|
expect(message).toContain("mfaMode");
|
|
186
233
|
expect(message).not.toMatch(/"(?:off|optional|required)"/);
|
|
234
|
+
expect(JSON.stringify((err as BootError).details)).not.toMatch(/"(?:off|optional|required)"/);
|
|
187
235
|
},
|
|
188
236
|
);
|
|
189
237
|
|
|
@@ -200,8 +248,12 @@ describe("what the template hands boot of the mfa section", () => {
|
|
|
200
248
|
} catch (caught) {
|
|
201
249
|
err = caught;
|
|
202
250
|
}
|
|
203
|
-
expect(err).toBeInstanceOf(
|
|
204
|
-
|
|
251
|
+
expect(err).toBeInstanceOf(BootError);
|
|
252
|
+
expect(err).toMatchObject({
|
|
253
|
+
reason: "config-validation-failed",
|
|
254
|
+
details: { issues: [{ code: "custom", path: ["mfa"] }] },
|
|
255
|
+
});
|
|
256
|
+
const message = (err as BootError).message;
|
|
205
257
|
expect(message).toMatch(/^mfa is written as a value/);
|
|
206
258
|
expect(message).not.toContain("remove mfa.mode");
|
|
207
259
|
expect(message).toContain("mfaMode");
|
|
@@ -219,33 +271,42 @@ describe("what the template hands boot of the mfa section", () => {
|
|
|
219
271
|
}
|
|
220
272
|
});
|
|
221
273
|
|
|
222
|
-
it("is refused through the composition as through phase two: before boot, under the switch off", async () => {
|
|
274
|
+
it("is refused through the composition as through phase two: by the template before boot, under the switch off", async () => {
|
|
223
275
|
const err = await refusal(compose({ operatorHocon: 'mfa.mode = "required"\n' }));
|
|
224
|
-
expect(err).toBeInstanceOf(
|
|
225
|
-
|
|
226
|
-
|
|
276
|
+
expect(err).toBeInstanceOf(BootError);
|
|
277
|
+
// The template's own check, not a schema's: one custom issue, in its words.
|
|
278
|
+
const message = /^mfa\.mode is written in the configuration and differs from mfaMode/;
|
|
279
|
+
expect(err).toMatchObject({
|
|
280
|
+
reason: "config-validation-failed",
|
|
281
|
+
message: expect.stringMatching(message),
|
|
282
|
+
details: {
|
|
283
|
+
issues: [
|
|
284
|
+
{ code: "custom", path: ["mfa", "mode"], message: expect.stringMatching(message) },
|
|
285
|
+
],
|
|
286
|
+
modules: [{ module: "mfa", schemaPath: "mfa" }],
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
expect((err as BootError).details).toMatchObject({ issues: { length: 1 } });
|
|
290
|
+
expect((err as BootError).message).toContain("mfaMode");
|
|
227
291
|
});
|
|
228
292
|
});
|
|
229
293
|
|
|
230
294
|
describe("expectedSessionRequirements", () => {
|
|
231
|
-
const of = (mfa:
|
|
232
|
-
expectedSessionRequirements(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
? {}
|
|
295
|
+
const of = (mfa: MfaSwitch, expected?: readonly string[], authority?: string) =>
|
|
296
|
+
expectedSessionRequirements(
|
|
297
|
+
expected === undefined && authority === undefined
|
|
298
|
+
? undefined
|
|
236
299
|
: {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
},
|
|
243
|
-
}),
|
|
244
|
-
} as unknown as Switches);
|
|
300
|
+
...(expected === undefined ? {} : { expected }),
|
|
301
|
+
...(authority === undefined ? {} : { secondFactorAuthority: authority }),
|
|
302
|
+
},
|
|
303
|
+
mfa,
|
|
304
|
+
);
|
|
245
305
|
const AUTHORITY = { secondFactorAuthority: "mfa" } as const;
|
|
246
306
|
|
|
247
307
|
it("is the configuration's list, with mfa added once and named the second-factor authority when the switch is not off", () => {
|
|
248
|
-
|
|
308
|
+
// `undefined`: the section is handed on as written.
|
|
309
|
+
expect(of("off", ["risk"])).toBeUndefined();
|
|
249
310
|
expect(of("optional", ["risk"])).toEqual({ expected: ["risk", "mfa"], ...AUTHORITY });
|
|
250
311
|
expect(of("required", ["mfa"])).toEqual({ expected: ["mfa"], ...AUTHORITY });
|
|
251
312
|
expect(of("required")).toEqual({ expected: ["mfa"], ...AUTHORITY });
|
|
@@ -260,15 +321,12 @@ describe("expectedSessionRequirements", () => {
|
|
|
260
321
|
expected: ["mfa", "risk", "mfa"],
|
|
261
322
|
...AUTHORITY,
|
|
262
323
|
});
|
|
263
|
-
expect(of("off", ["risk", "risk"])).
|
|
324
|
+
expect(of("off", ["risk", "risk"])).toBeUndefined();
|
|
264
325
|
});
|
|
265
326
|
|
|
266
327
|
it("accepts a written second-factor authority the switch says, and keeps one written under the switch off", () => {
|
|
267
328
|
expect(of("required", ["mfa"], "mfa")).toEqual({ expected: ["mfa"], ...AUTHORITY });
|
|
268
|
-
expect(of("off", ["risk"], "risk")).
|
|
269
|
-
expected: ["risk"],
|
|
270
|
-
secondFactorAuthority: "risk",
|
|
271
|
-
});
|
|
329
|
+
expect(of("off", ["risk"], "risk")).toBeUndefined();
|
|
272
330
|
});
|
|
273
331
|
|
|
274
332
|
it("refuses, with the switch on, a written second-factor authority other than mfa, naming the key and the switch and quoting nothing", () => {
|
|
@@ -278,8 +336,18 @@ describe("expectedSessionRequirements", () => {
|
|
|
278
336
|
} catch (caught) {
|
|
279
337
|
err = caught;
|
|
280
338
|
}
|
|
281
|
-
expect(err).toBeInstanceOf(
|
|
282
|
-
|
|
339
|
+
expect(err).toBeInstanceOf(BootError);
|
|
340
|
+
expect(err).toMatchObject({
|
|
341
|
+
reason: "config-validation-failed",
|
|
342
|
+
details: {
|
|
343
|
+
issues: [
|
|
344
|
+
{ code: "custom", path: ["core", "sessionRequirements", "secondFactorAuthority"] },
|
|
345
|
+
],
|
|
346
|
+
modules: [{ module: "core", schemaPath: "core" }],
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
expect(JSON.stringify((err as BootError).details)).not.toContain("sentinel-requirement");
|
|
350
|
+
const message = (err as BootError).message;
|
|
283
351
|
expect(message).toContain("core.sessionRequirements.secondFactorAuthority");
|
|
284
352
|
expect(message).toContain("mfaMode");
|
|
285
353
|
expect(message).toContain("MFA_MODE");
|
|
@@ -289,6 +357,21 @@ describe("expectedSessionRequirements", () => {
|
|
|
289
357
|
it("declares nothing when the configuration writes no list and the switch is off, so boot's own rule for an unwritten key applies", () => {
|
|
290
358
|
expect(of("off")).toBeUndefined();
|
|
291
359
|
});
|
|
360
|
+
|
|
361
|
+
it("hands on as written, under the switch on, what is not a section with a list of names, for boot to refuse", () => {
|
|
362
|
+
expect(expectedSessionRequirements("mfa", "required")).toBeUndefined();
|
|
363
|
+
expect(expectedSessionRequirements({ expected: "mfa" }, "required")).toBeUndefined();
|
|
364
|
+
expect(expectedSessionRequirements({ expected: ["mfa", 1] }, "required")).toBeUndefined();
|
|
365
|
+
expect(expectedSessionRequirements({}, "required")).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("keeps, under the switch on, every other key written beside the list, for boot to refuse", () => {
|
|
369
|
+
expect(expectedSessionRequirements({ expected: [], extra: 1 }, "required")).toEqual({
|
|
370
|
+
expected: ["mfa"],
|
|
371
|
+
extra: 1,
|
|
372
|
+
...AUTHORITY,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
292
375
|
});
|
|
293
376
|
|
|
294
377
|
/** `text` in a file of its own, a layer above the composition's files. */
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The template's shipped files, read as `app.mts` reads them in each mode the
|
|
19
|
+
* README documents, write no section for a module the mode does not load:
|
|
20
|
+
* every top-level section handed to boot is a loaded module's, core's own
|
|
21
|
+
* (`core`, `oauth`), one that sets nothing, or one equal to the
|
|
22
|
+
* configuration's defaults (`configDefaultsFor`). That is checked here
|
|
23
|
+
* directly, whatever core counts as read; booted, the same modes then have
|
|
24
|
+
* core name nothing in `config_sections_ignored` or
|
|
25
|
+
* `config_sections_not_loaded`.
|
|
26
|
+
*
|
|
27
|
+
* Redis is a stand-in: these boots read configuration, not stores.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
31
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { isDeepStrictEqual } from "node:util";
|
|
36
|
+
import { createApp, type Module } from "@o3co/auth-provider-core";
|
|
37
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
38
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
39
|
+
import {
|
|
40
|
+
configDefaultsFor,
|
|
41
|
+
readOwnLayers,
|
|
42
|
+
readSwitches,
|
|
43
|
+
resolveConfigPaths,
|
|
44
|
+
resolveForBoot,
|
|
45
|
+
} from "#/configPath.mjs";
|
|
46
|
+
import { createRecordingLogger, type RecordingLogger } from "./all-modules-composition.fixture.mjs";
|
|
47
|
+
|
|
48
|
+
vi.mock("redis", () => ({
|
|
49
|
+
createClient: vi.fn(() => ({
|
|
50
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
quit: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
ping: vi.fn().mockResolvedValue("PONG"),
|
|
53
|
+
on: vi.fn(),
|
|
54
|
+
})),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock("connect-redis", async () => {
|
|
58
|
+
const { EventEmitter } = await import("node:events");
|
|
59
|
+
// express-session subscribes to store events, so the stand-in is an emitter.
|
|
60
|
+
return {
|
|
61
|
+
RedisStore: class MockRedisStore extends EventEmitter {
|
|
62
|
+
get(): unknown {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
set(): void {}
|
|
66
|
+
destroy(): void {}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
vi.mock("ioredis", () => {
|
|
72
|
+
const explicit: Record<string, unknown> = {
|
|
73
|
+
on: () => undefined,
|
|
74
|
+
quit: async () => "OK",
|
|
75
|
+
disconnect: () => undefined,
|
|
76
|
+
ping: async () => "PONG",
|
|
77
|
+
};
|
|
78
|
+
const makeMockRedis = (): object =>
|
|
79
|
+
new Proxy(
|
|
80
|
+
{},
|
|
81
|
+
{
|
|
82
|
+
get(_target, prop) {
|
|
83
|
+
// A function-valued `then` would make the instance a thenable.
|
|
84
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
85
|
+
if (prop === "duplicate") return makeMockRedis;
|
|
86
|
+
if (prop in explicit) return explicit[prop];
|
|
87
|
+
return async () => null;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
function MockRedis(): object {
|
|
92
|
+
return makeMockRedis();
|
|
93
|
+
}
|
|
94
|
+
return { Redis: MockRedis, default: MockRedis };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
98
|
+
|
|
99
|
+
const handles: { dispose(): Promise<void> }[] = [];
|
|
100
|
+
afterEach(async () => {
|
|
101
|
+
for (const handle of handles.splice(0)) await handle.dispose();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const signingKey = generateKeyPairSync("ed25519", {
|
|
105
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
106
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
107
|
+
});
|
|
108
|
+
const clientsFile = (() => {
|
|
109
|
+
const file = join(mkdtempSync(join(tmpdir(), "shipped-config-notices-")), "clients.yaml");
|
|
110
|
+
writeFileSync(file, "");
|
|
111
|
+
return file;
|
|
112
|
+
})();
|
|
113
|
+
|
|
114
|
+
/** What `.env.example` asks every deployment for, with the Store's user directory. */
|
|
115
|
+
const REQUIRED: Readonly<Record<string, string>> = {
|
|
116
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
117
|
+
KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
|
|
118
|
+
KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
|
|
119
|
+
SESSION_STORE_SECRET: "shipped-config-notices-session.at-least-32-bytes.ok",
|
|
120
|
+
REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
|
|
121
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://store.auth.test/authenticate",
|
|
122
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://store.auth.test/by-token",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** What `docker-compose.yml` sets for the app beside the operator's `.env`, with `.env.example`'s Redis URLs. */
|
|
126
|
+
const COMPOSE: Readonly<Record<string, string>> = {
|
|
127
|
+
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
128
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
129
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
130
|
+
ADAPTERS_MFA_FACTOR_STORE: "redis",
|
|
131
|
+
ADAPTERS_MFA_TRANSACTION_STORE: "redis",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** What MFA, on by default, needs outside development. */
|
|
135
|
+
const MFA_OUTSIDE_DEVELOPMENT: Readonly<Record<string, string>> = {
|
|
136
|
+
MFA_ENCRYPTION_KEY: "bzNjbzptZmE6c2hpcHBlZC1jb25maWctbm90aWNlcyE=",
|
|
137
|
+
STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
|
|
138
|
+
STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** One replica, as `docker-compose.production.yml` runs it. */
|
|
142
|
+
const PRODUCTION: Readonly<Record<string, string>> = {
|
|
143
|
+
...REQUIRED,
|
|
144
|
+
...COMPOSE,
|
|
145
|
+
...MFA_OUTSIDE_DEVELOPMENT,
|
|
146
|
+
CORE_DEPLOYMENT_MODE: "single",
|
|
147
|
+
HTTP_TRUST_PROXY: "loopback",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** The README's multi-replica checklist: every shared store on Redis. */
|
|
151
|
+
const MULTI: Readonly<Record<string, string>> = {
|
|
152
|
+
...PRODUCTION,
|
|
153
|
+
CORE_DEPLOYMENT_MODE: "multi",
|
|
154
|
+
ADAPTERS_RATE_LIMITER: "redis",
|
|
155
|
+
ADAPTERS_ATTEMPT_COUNTER: "redis",
|
|
156
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
|
|
157
|
+
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "bzNjbzpmZWRlcmF0aW9uLXRva2Vucy1ub3RpY2VzISE=",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Every store in memory, MFA off: one process of local work with no Redis. */
|
|
161
|
+
const IN_MEMORY: Readonly<Record<string, string>> = {
|
|
162
|
+
...REQUIRED,
|
|
163
|
+
MFA_MODE: "off",
|
|
164
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
165
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
166
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
167
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** The composition's own files for `configEnv`, read as `app.mts` reads them, and what they choose. */
|
|
171
|
+
function readShipped(configEnv: string, env: Readonly<Record<string, string>>) {
|
|
172
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
173
|
+
const own = readOwnLayers([envConfPath, applicationConfPath], { env });
|
|
174
|
+
const switches = readSwitches(own);
|
|
175
|
+
const modules = buildModules(switches, { environment: configEnv });
|
|
176
|
+
return { own, switches, modules };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Boots the template's own files for `configEnv` under `env`, as `app.mts` does. */
|
|
180
|
+
async function bootShipped(
|
|
181
|
+
configEnv: string,
|
|
182
|
+
env: Readonly<Record<string, string>>,
|
|
183
|
+
): Promise<{ readonly logger: RecordingLogger; readonly modules: readonly string[] }> {
|
|
184
|
+
const { own, switches, modules } = readShipped(configEnv, env);
|
|
185
|
+
const logger = createRecordingLogger();
|
|
186
|
+
const handle = await createApp({
|
|
187
|
+
modules,
|
|
188
|
+
bootstrapComponents: {
|
|
189
|
+
config: resolveForBoot(own, modules, switches),
|
|
190
|
+
configDefaults: configDefaultsFor(modules),
|
|
191
|
+
pathResolver: (s: string) => s,
|
|
192
|
+
logger,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
handles.push(handle);
|
|
196
|
+
return { logger, modules: modules.map((module) => module.name) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Core's own sections. */
|
|
200
|
+
const CORE_SECTIONS: readonly string[] = ["core", "oauth"];
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Removed by boot before it parses the configuration: what the resolution saw
|
|
204
|
+
* of each renamed variable.
|
|
205
|
+
*/
|
|
206
|
+
const RENAMED_VARIABLES = "renamed-variables";
|
|
207
|
+
|
|
208
|
+
/** Whether `value` sets anything: a value, or a section with one somewhere under it. */
|
|
209
|
+
function setsAnything(value: unknown): boolean {
|
|
210
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
|
|
211
|
+
return Object.values(value).some(setsAnything);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The top-level sections of what `resolveForBoot` hands boot that are no
|
|
216
|
+
* loaded module's and not core's, set something, and differ from the
|
|
217
|
+
* configuration's defaults: settings for a module the composition does not
|
|
218
|
+
* load.
|
|
219
|
+
*/
|
|
220
|
+
function sectionsNothingLoadedReads(
|
|
221
|
+
configEnv: string,
|
|
222
|
+
env: Readonly<Record<string, string>>,
|
|
223
|
+
): string[] {
|
|
224
|
+
const { own, switches, modules } = readShipped(configEnv, env);
|
|
225
|
+
const resolved = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
|
|
226
|
+
const defaults = configDefaultsFor(modules);
|
|
227
|
+
const sections = new Set(
|
|
228
|
+
modules.filter((module: Module) => module.section !== undefined).map((module) => module.name),
|
|
229
|
+
);
|
|
230
|
+
return Object.keys(resolved)
|
|
231
|
+
.filter((name) => !sections.has(name) && !CORE_SECTIONS.includes(name))
|
|
232
|
+
.filter((name) => name !== RENAMED_VARIABLES && setsAnything(resolved[name]))
|
|
233
|
+
.filter((name) => !isDeepStrictEqual(resolved[name], defaults[name]))
|
|
234
|
+
.sort();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** What each warn line named `event` names. */
|
|
238
|
+
const named = (logger: RecordingLogger, event: string): unknown[] =>
|
|
239
|
+
logger.lines
|
|
240
|
+
.filter((line) => line.level === "warn" && line.args[1] === event)
|
|
241
|
+
.map((line) => line.args[0]);
|
|
242
|
+
|
|
243
|
+
/** Each mode the README documents: its name, the configuration it selects, its environment. */
|
|
244
|
+
const MODES = [
|
|
245
|
+
["development, as docker-compose.yml runs it", "development", { ...REQUIRED, ...COMPOSE }],
|
|
246
|
+
["development, every store in memory", "development", IN_MEMORY],
|
|
247
|
+
["production, one replica", "production", PRODUCTION],
|
|
248
|
+
[
|
|
249
|
+
"production, one replica, the Redis rate limiter",
|
|
250
|
+
"production",
|
|
251
|
+
{ ...PRODUCTION, ADAPTERS_RATE_LIMITER: "redis" },
|
|
252
|
+
],
|
|
253
|
+
["production, every store in memory", "production", IN_MEMORY],
|
|
254
|
+
["production, more than one replica", "production", MULTI],
|
|
255
|
+
] as const;
|
|
256
|
+
|
|
257
|
+
describe("the shipped files write no section for a module the mode does not load", () => {
|
|
258
|
+
it.each(MODES)("%s", (_mode, configEnv, env) => {
|
|
259
|
+
expect(sectionsNothingLoadedReads(configEnv, env)).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("a boot of the shipped files that changes nothing of them names no section", () => {
|
|
264
|
+
it.each(MODES)("%s", async (_mode, configEnv, env) => {
|
|
265
|
+
const { logger, modules } = await bootShipped(configEnv, env);
|
|
266
|
+
expect(modules).not.toContain("federation-grants");
|
|
267
|
+
expect(named(logger, "config_sections_ignored")).toEqual([]);
|
|
268
|
+
expect(named(logger, "config_sections_not_loaded")).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
registerBuiltinKeyStores,
|
|
31
31
|
} from "@o3co/auth-provider-core";
|
|
32
32
|
import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
|
|
33
|
+
import type { OAuthSection } from "@o3co/auth-provider-oauth";
|
|
33
34
|
import express from "express";
|
|
34
35
|
import request from "supertest";
|
|
35
36
|
import { afterEach, describe, expect, it } from "vitest";
|
|
@@ -82,17 +83,13 @@ const config: AppConfig & Record<string, unknown> = {
|
|
|
82
83
|
// A composition with a consumer of session admission states what it
|
|
83
84
|
// expects (ADR 2026-09-28-session-admission): the shipped
|
|
84
85
|
// `application.conf` expects none, and so does this hand-built config.
|
|
85
|
-
...coreConfigForTests({ federations: { google: { enabled: false } } }),
|
|
86
|
+
...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
|
|
86
87
|
oauth: {
|
|
87
88
|
jwt: {
|
|
88
89
|
issuer: "https://auth.test",
|
|
89
90
|
},
|
|
90
|
-
accessToken: {
|
|
91
|
-
refreshToken: {
|
|
92
|
-
expiresIn: 86400,
|
|
93
|
-
unknownFamilyPolicy: "reject" as const,
|
|
94
|
-
legacyRtPolicy: "reject" as const,
|
|
95
|
-
},
|
|
91
|
+
accessToken: { defaultExpiresIn: 3600 },
|
|
92
|
+
refreshToken: { expiresIn: 86400 },
|
|
96
93
|
grants: {},
|
|
97
94
|
oidcMode: "oidc-required",
|
|
98
95
|
// No `authorize` section: the first-party invariant is unconditional.
|
|
@@ -312,19 +309,16 @@ describe("standalone smoke test", () => {
|
|
|
312
309
|
const issuerConfig: AppConfig = {
|
|
313
310
|
...config,
|
|
314
311
|
oauth: {
|
|
315
|
-
...config.oauth,
|
|
316
|
-
jwt: { ...config.oauth.jwt, issuer: "https://auth.example.com" },
|
|
312
|
+
...(config.oauth as OAuthSection),
|
|
313
|
+
jwt: { ...(config.oauth as OAuthSection).jwt, issuer: "https://auth.example.com" },
|
|
317
314
|
},
|
|
318
315
|
};
|
|
319
316
|
const handle = await createApp({
|
|
320
|
-
modules: buildModules(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
326
|
-
},
|
|
327
|
-
),
|
|
317
|
+
modules: buildModules(switches, {
|
|
318
|
+
keyStoreModule: testKeyStoreModule,
|
|
319
|
+
repositoriesModule: testRepositoriesModule,
|
|
320
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
321
|
+
}),
|
|
328
322
|
bootstrapComponents: { config: issuerConfig, pathResolver: (s) => s },
|
|
329
323
|
});
|
|
330
324
|
handleRef = handle;
|
|
@@ -368,12 +362,9 @@ describe("standalone smoke test", () => {
|
|
|
368
362
|
});
|
|
369
363
|
|
|
370
364
|
// A freshly scaffolded app must boot under the default
|
|
371
|
-
// `core.federations.google.enabled = false`.
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
// undefined. The manifest is asserted directly, so a bypassed gate fails
|
|
375
|
-
// even if the bridge is later made tolerant of `undefined`; then the
|
|
376
|
-
// handle must still boot.
|
|
365
|
+
// `core.federations.google.enabled = false`. The federation types the
|
|
366
|
+
// template bundles are loaded whatever the configuration says, and with no
|
|
367
|
+
// entry enabled they handle nothing: no federation is registered.
|
|
377
368
|
it("boots when google federation is disabled (default scaffold config)", async () => {
|
|
378
369
|
const modules = buildModules(switches, {
|
|
379
370
|
keyStoreModule: testKeyStoreModule,
|
|
@@ -381,15 +372,15 @@ describe("standalone smoke test", () => {
|
|
|
381
372
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
382
373
|
});
|
|
383
374
|
const moduleNames = modules.map((m) => m.name);
|
|
384
|
-
expect(moduleNames).
|
|
385
|
-
expect(moduleNames).
|
|
375
|
+
expect(moduleNames).toContain("federation-google-type");
|
|
376
|
+
expect(moduleNames).toContain("federation-oidc");
|
|
386
377
|
|
|
387
378
|
const handle = await createApp({
|
|
388
379
|
modules,
|
|
389
380
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
390
381
|
});
|
|
391
382
|
handleRef = handle;
|
|
392
|
-
expect(handle).
|
|
383
|
+
expect(handle.components.federationProviders?.size ?? 0).toBe(0);
|
|
393
384
|
});
|
|
394
385
|
|
|
395
386
|
// The composition root must default to the Redis-backed RT family store
|
|
@@ -452,7 +443,7 @@ describe("standalone smoke test", () => {
|
|
|
452
443
|
});
|
|
453
444
|
const names = modules.map((m) => m.name);
|
|
454
445
|
expect(names).toContain("redis-session-stores");
|
|
455
|
-
expect(names).not.toContain("stores");
|
|
446
|
+
expect(names).not.toContain("standalone-in-memory-session-stores");
|
|
456
447
|
expect(names).toContain("redis-clients");
|
|
457
448
|
});
|
|
458
449
|
|
|
@@ -626,7 +617,7 @@ describe("standalone smoke test", () => {
|
|
|
626
617
|
subject: "u1",
|
|
627
618
|
expiresIn: 3600,
|
|
628
619
|
tokenType: "at+jwt",
|
|
629
|
-
issuer: config.oauth.jwt.issuer,
|
|
620
|
+
issuer: (config.oauth as OAuthSection).jwt.issuer,
|
|
630
621
|
},
|
|
631
622
|
);
|
|
632
623
|
|
|
@@ -33,7 +33,6 @@ import { join } from "node:path";
|
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { inspect } from "node:util";
|
|
35
35
|
import {
|
|
36
|
-
AppConfigSchema,
|
|
37
36
|
createApp,
|
|
38
37
|
createKeyStoreFactory,
|
|
39
38
|
defineModule,
|
|
@@ -43,19 +42,18 @@ import {
|
|
|
43
42
|
type UserRepository,
|
|
44
43
|
} from "@o3co/auth-provider-core";
|
|
45
44
|
import { parseFile } from "@o3co/ts.hocon";
|
|
46
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
47
45
|
import express from "express";
|
|
48
46
|
import request from "supertest";
|
|
49
47
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
50
48
|
import { buildModules } from "../buildModules.mjs";
|
|
51
|
-
import { resolveConfigPaths
|
|
49
|
+
import { resolveConfigPaths } from "../configPath.mjs";
|
|
52
50
|
import { repositoriesModuleFor, templateReference } from "../modules.mjs";
|
|
53
51
|
import { repositoriesSectionSchema } from "../sections.mjs";
|
|
54
52
|
import {
|
|
53
|
+
type BothPhases,
|
|
54
|
+
bothPhasesOf,
|
|
55
55
|
capturedRenames,
|
|
56
56
|
libraryLayers,
|
|
57
|
-
rootSectionsOf,
|
|
58
|
-
sectionsCoreDoesNotDeclare,
|
|
59
57
|
} from "./library-references.fixture.mjs";
|
|
60
58
|
|
|
61
59
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -103,24 +101,21 @@ const recordingStore = async (
|
|
|
103
101
|
};
|
|
104
102
|
|
|
105
103
|
/** The shipped config for the production overlay, resolved against `env`. */
|
|
106
|
-
const resolve = (env: Record<string, string>):
|
|
104
|
+
const resolve = (env: Record<string, string>): BothPhases => {
|
|
107
105
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
108
106
|
const layers = parseFile(envConfPath, { env })
|
|
109
107
|
.withFallback(parseFile(applicationConfPath, { env }))
|
|
110
108
|
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
111
109
|
.withFallback(libraryLayers(env));
|
|
112
110
|
return {
|
|
113
|
-
...
|
|
114
|
-
|
|
115
|
-
...validate(layers, AppConfigSchema),
|
|
116
|
-
// What the resolution captured of core's renamed variables, which the
|
|
117
|
-
// schema's parse drops.
|
|
111
|
+
...bothPhasesOf(layers, env),
|
|
112
|
+
// What the resolution captured of the renamed variables.
|
|
118
113
|
"renamed-variables": capturedRenames(env),
|
|
119
|
-
} as
|
|
114
|
+
} as BothPhases;
|
|
120
115
|
};
|
|
121
116
|
|
|
122
117
|
/** The user repository the production `repositories` module builds from `config`'s section. */
|
|
123
|
-
const userRepositoryFrom = (config:
|
|
118
|
+
const userRepositoryFrom = (config: BothPhases): Promise<UserRepository> =>
|
|
124
119
|
Promise.resolve(
|
|
125
120
|
repositoriesModuleFor({
|
|
126
121
|
client: config.adapters.clientRepository,
|