@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
|
@@ -28,7 +28,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
-
import { defineModule } from "@o3co/auth-provider-core";
|
|
31
|
+
import { BootError, defineModule } from "@o3co/auth-provider-core";
|
|
32
32
|
import { afterAll, describe, expect, it } from "vitest";
|
|
33
33
|
import { z } from "zod";
|
|
34
34
|
import { buildModules } from "#/buildModules.mjs";
|
|
@@ -60,12 +60,13 @@ const MFA_OFF: Readonly<Record<string, string>> = { MFA_MODE: "off" };
|
|
|
60
60
|
const adaptersFrom = (env: Record<string, string> = {}, hocon?: string) =>
|
|
61
61
|
readSwitches(readOwnLayers(ownFiles(hocon), { env })).adapters;
|
|
62
62
|
|
|
63
|
-
/** What phase one refuses under `env` and an operator's `hocon`. */
|
|
64
|
-
function refusal(env: Record<string, string> = {}, hocon?: string):
|
|
63
|
+
/** What phase one refuses under `env` and an operator's `hocon`: a `BootError`. */
|
|
64
|
+
function refusal(env: Record<string, string> = {}, hocon?: string): BootError {
|
|
65
65
|
try {
|
|
66
66
|
adaptersFrom(env, hocon);
|
|
67
67
|
} catch (err) {
|
|
68
|
-
|
|
68
|
+
expect(err).toBeInstanceOf(BootError);
|
|
69
|
+
return err as BootError;
|
|
69
70
|
}
|
|
70
71
|
throw new Error("phase one read the adapters");
|
|
71
72
|
}
|
|
@@ -141,6 +142,7 @@ describe("the shipped selections", () => {
|
|
|
141
142
|
it("reads every selection from the template's reference.conf, at the values the template ships", () => {
|
|
142
143
|
expect(adaptersFrom()).toEqual({
|
|
143
144
|
rateLimiter: "memory",
|
|
145
|
+
attemptCounter: "memory",
|
|
144
146
|
userSessionStores: "memory",
|
|
145
147
|
accessTokenDenylist: "redis",
|
|
146
148
|
replaySeenSet: "redis",
|
|
@@ -164,14 +166,25 @@ describe("the shipped selections", () => {
|
|
|
164
166
|
},
|
|
165
167
|
);
|
|
166
168
|
|
|
169
|
+
it("reads adapters.attemptCounter from ADAPTERS_ATTEMPT_COUNTER", () => {
|
|
170
|
+
expect(adaptersFrom({ ADAPTERS_ATTEMPT_COUNTER: "redis" }).attemptCounter).toBe("redis");
|
|
171
|
+
expect(refusal({ ADAPTERS_ATTEMPT_COUNTER: "memcached" }).message).toMatch(
|
|
172
|
+
/adapters\.attemptCounter/,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
167
176
|
it("reads a selection an operator writes in HOCON", () => {
|
|
168
177
|
expect(adaptersFrom({}, 'adapters.rateLimiter = "redis"\n').rateLimiter).toBe("redis");
|
|
169
178
|
});
|
|
170
179
|
|
|
171
180
|
it("refuses a value its schema does not know, naming the key", () => {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
);
|
|
181
|
+
const err = refusal({ ADAPTERS_RATE_LIMITER: "memcached" });
|
|
182
|
+
expect(err.message).toMatch(/adapters\.rateLimiter/);
|
|
183
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
184
|
+
expect(err.details).toMatchObject({
|
|
185
|
+
issues: [{ path: ["adapters", "rateLimiter"] }],
|
|
186
|
+
modules: [{ module: "adapters", schemaPath: "adapters" }],
|
|
187
|
+
});
|
|
175
188
|
});
|
|
176
189
|
|
|
177
190
|
it("refuses the Store for the MFA transaction store, which holds verification state, and accepts it for the factor store", () => {
|
|
@@ -198,9 +211,20 @@ describe("a path a selection moved from, written in the operator's own layer", (
|
|
|
198
211
|
"adapters.%s: refused where it was, %s's old path, naming the new path and ADAPTERS_%s",
|
|
199
212
|
(key, name, oldPath, _oldName, value) => {
|
|
200
213
|
const err = refusal({}, `${oldPath} = "${value}"\n`);
|
|
201
|
-
expect(err).toBeInstanceOf(RangeError);
|
|
202
214
|
expect(err.message).toContain(`${oldPath} has moved to adapters.${key}`);
|
|
203
215
|
expect(err.message).toContain(`ADAPTERS_${name}`);
|
|
216
|
+
expect(err.reason).toBe("config-path-relocated");
|
|
217
|
+
expect(err.details).toEqual({
|
|
218
|
+
reason: "config-path-relocated",
|
|
219
|
+
relocated: [
|
|
220
|
+
{
|
|
221
|
+
module: "adapters",
|
|
222
|
+
from: oldPath,
|
|
223
|
+
to: `adapters.${key}`,
|
|
224
|
+
environmentVariable: `ADAPTERS_${name}`,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
});
|
|
204
228
|
},
|
|
205
229
|
);
|
|
206
230
|
|
|
@@ -212,6 +236,13 @@ describe("a path a selection moved from, written in the operator's own layer", (
|
|
|
212
236
|
for (const path of ["rateLimiter.adapter", "consentStore.adapter", "audit.sink.type"]) {
|
|
213
237
|
expect(err.message).toContain(path);
|
|
214
238
|
}
|
|
239
|
+
expect(err.details).toMatchObject({
|
|
240
|
+
relocated: [
|
|
241
|
+
{ from: "rateLimiter.adapter" },
|
|
242
|
+
{ from: "consentStore.adapter" },
|
|
243
|
+
{ from: "audit.sink.type" },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
215
246
|
});
|
|
216
247
|
});
|
|
217
248
|
|
|
@@ -220,9 +251,21 @@ describe("a variable renamed with a selection", () => {
|
|
|
220
251
|
"adapters.%s: %s's old variable set alone is refused, naming ADAPTERS_%s",
|
|
221
252
|
(key, name, _oldPath, oldName, value) => {
|
|
222
253
|
const err = refusal({ [oldName]: value });
|
|
223
|
-
expect(err).toBeInstanceOf(RangeError);
|
|
224
254
|
expect(err.message).toContain(`${oldName} was renamed ADAPTERS_${name}`);
|
|
225
255
|
expect(err.message).toContain(`adapters.${key}`);
|
|
256
|
+
expect(err.reason).toBe("environment-variable-renamed");
|
|
257
|
+
expect(err.details).toEqual({
|
|
258
|
+
reason: "environment-variable-renamed",
|
|
259
|
+
renamed: [
|
|
260
|
+
{
|
|
261
|
+
module: "adapters",
|
|
262
|
+
from: oldName,
|
|
263
|
+
to: `ADAPTERS_${name}`,
|
|
264
|
+
path: `adapters.${key}`,
|
|
265
|
+
state: "unset",
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
});
|
|
226
269
|
},
|
|
227
270
|
);
|
|
228
271
|
|
|
@@ -233,6 +276,8 @@ describe("a variable renamed with a selection", () => {
|
|
|
233
276
|
expect(err.message).toContain(oldName);
|
|
234
277
|
expect(err.message).not.toContain("old-value-5e2d");
|
|
235
278
|
expect(err.message).not.toContain("new-value-c81a");
|
|
279
|
+
expect(err.details).toMatchObject({ renamed: [{ from: oldName, state: "different" }] });
|
|
280
|
+
expect(JSON.stringify(err.details)).not.toMatch(/old-value-5e2d|new-value-c81a/);
|
|
236
281
|
},
|
|
237
282
|
);
|
|
238
283
|
|
|
@@ -277,6 +322,13 @@ describe("the modules phase one chooses by the selections", () => {
|
|
|
277
322
|
expect(modules).toContain(installed);
|
|
278
323
|
expect(modules).not.toContain(absent);
|
|
279
324
|
});
|
|
325
|
+
|
|
326
|
+
it("ADAPTERS_ATTEMPT_COUNTER=redis installs redis-attempt-counter over the shared Redis socket; memory installs no counter", () => {
|
|
327
|
+
const onRedis = names({ ADAPTERS_ATTEMPT_COUNTER: "redis" });
|
|
328
|
+
expect(onRedis).toContain("redis-attempt-counter");
|
|
329
|
+
expect(onRedis).toContain("redis-clients");
|
|
330
|
+
expect(names({ ADAPTERS_ATTEMPT_COUNTER: "memory" })).not.toContain("redis-attempt-counter");
|
|
331
|
+
});
|
|
280
332
|
});
|
|
281
333
|
|
|
282
334
|
describe("the federation-grant stores", () => {
|
|
@@ -335,24 +387,33 @@ describe("boot and the section", () => {
|
|
|
335
387
|
});
|
|
336
388
|
|
|
337
389
|
it.each([
|
|
338
|
-
["named adapters", "adapters"
|
|
339
|
-
["
|
|
340
|
-
] as const)("refuses a module %s: its section would never reach boot", (_label, name
|
|
390
|
+
["named adapters", "adapters"],
|
|
391
|
+
["named mfaMode", "mfaMode"],
|
|
392
|
+
] as const)("refuses a module %s: its section would never reach boot", (_label, name) => {
|
|
341
393
|
const own = readOwnLayers(ownFiles(), { env: MFA_OFF });
|
|
342
394
|
const switches = readSwitches(own);
|
|
343
395
|
const mine = defineModule({
|
|
344
396
|
name,
|
|
345
|
-
section: {
|
|
346
|
-
schema: z.object({}).passthrough().optional(),
|
|
347
|
-
...(at === undefined ? {} : { at }),
|
|
348
|
-
},
|
|
397
|
+
section: { schema: z.object({}).passthrough().optional() },
|
|
349
398
|
});
|
|
350
|
-
|
|
399
|
+
const run = () =>
|
|
351
400
|
resolveForBoot(
|
|
352
401
|
own,
|
|
353
402
|
[...buildModules(switches, { environment: "production" }), mine],
|
|
354
403
|
switches,
|
|
355
|
-
)
|
|
356
|
-
).toThrow(new RegExp(`"${name}".*
|
|
404
|
+
);
|
|
405
|
+
expect(run).toThrow(new RegExp(`"${name}".*composition root's own section`));
|
|
406
|
+
const err = (() => {
|
|
407
|
+
try {
|
|
408
|
+
run();
|
|
409
|
+
} catch (thrown) {
|
|
410
|
+
return thrown;
|
|
411
|
+
}
|
|
412
|
+
})();
|
|
413
|
+
expect(err).toBeInstanceOf(BootError);
|
|
414
|
+
expect(err).toMatchObject({
|
|
415
|
+
reason: "module-section-path-invalid",
|
|
416
|
+
details: { module: name, at: name },
|
|
417
|
+
});
|
|
357
418
|
});
|
|
358
419
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
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 oauth package's consumers of session admission read the session
|
|
19
|
+
* lifecycle's record the template's session stores provide: a session whose
|
|
20
|
+
* record is closing is refused at `/authorize`, the code exchange and the
|
|
21
|
+
* refresh grant, while its user session is still there.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { SessionLifecycleStore, UserSessionStore } from "@o3co/auth-provider-core";
|
|
25
|
+
import request from "supertest";
|
|
26
|
+
import { describe, expect, it } from "vitest";
|
|
27
|
+
import {
|
|
28
|
+
authorize,
|
|
29
|
+
basic,
|
|
30
|
+
codeFrom,
|
|
31
|
+
compose,
|
|
32
|
+
login,
|
|
33
|
+
redeem,
|
|
34
|
+
WEB,
|
|
35
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
36
|
+
|
|
37
|
+
/** A JWT's claims, unverified. */
|
|
38
|
+
const claimsOf = (token: string): Record<string, unknown> =>
|
|
39
|
+
JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
40
|
+
|
|
41
|
+
describe("a session whose lifecycle record is closing", () => {
|
|
42
|
+
it("is refused at /authorize, the code exchange and the refresh grant", async () => {
|
|
43
|
+
const { app, handle } = await compose();
|
|
44
|
+
try {
|
|
45
|
+
const components = handle.components as Record<string, unknown>;
|
|
46
|
+
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
47
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
48
|
+
const { cookies } = await login(app);
|
|
49
|
+
const tokens = (await redeem(app, codeFrom(await authorize(app, cookies)))).body as Record<
|
|
50
|
+
string,
|
|
51
|
+
string
|
|
52
|
+
>;
|
|
53
|
+
const sid = String(claimsOf(tokens.id_token ?? "").sid);
|
|
54
|
+
const pendingCode = codeFrom(await authorize(app, cookies));
|
|
55
|
+
|
|
56
|
+
// The session's close commits, its work pending: the user session stays.
|
|
57
|
+
const session = await sessions.get(sid);
|
|
58
|
+
if (session === null) throw new Error("the login wrote no session");
|
|
59
|
+
expect((await store.open(sid, session.sub, session.expiresAt)).outcome).toBe("opened");
|
|
60
|
+
const closing = await store.beginClose(sid, {
|
|
61
|
+
cause: "rp_logout",
|
|
62
|
+
steps: ["held_open"],
|
|
63
|
+
perParticipant: [],
|
|
64
|
+
retainMs: 0,
|
|
65
|
+
});
|
|
66
|
+
expect(closing.outcome).toBe("closing");
|
|
67
|
+
expect(await sessions.get(sid)).not.toBeNull();
|
|
68
|
+
|
|
69
|
+
const again = await authorize(app, cookies);
|
|
70
|
+
// Sent to log in again, with no code.
|
|
71
|
+
expect(again.status).toBe(302);
|
|
72
|
+
expect(String(again.headers.location)).not.toMatch(/[?&]code=/);
|
|
73
|
+
|
|
74
|
+
expect((await redeem(app, pendingCode)).body).toMatchObject({ error: "invalid_grant" });
|
|
75
|
+
|
|
76
|
+
const refreshed = await request(app)
|
|
77
|
+
.post("/oauth/token")
|
|
78
|
+
.set("Authorization", basic(WEB))
|
|
79
|
+
.type("form")
|
|
80
|
+
.send({ grant_type: "refresh_token", refresh_token: tokens.refresh_token });
|
|
81
|
+
expect(refreshed.status).toBe(400);
|
|
82
|
+
expect(refreshed.body).toMatchObject({ error: "invalid_grant" });
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.dispose();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
* - the refresh-token family store under `core.deployment.mode = "single"`: memory,
|
|
32
32
|
* not the shipped Redis, so the single-replica boot opens no sockets;
|
|
33
33
|
* - upstream identity providers: core's fake OpenID Provider, through the
|
|
34
|
-
* `fetch` option the Google and OIDC
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* `fetch` option the Google and OIDC federation type modules take: each is
|
|
35
|
+
* the module `buildModules` lists, built with that option, and the entries
|
|
36
|
+
* it handles are read as shipped;
|
|
37
37
|
* - configuration with no environment form (grant connections, a key ring, a
|
|
38
38
|
* landing URL): one more HOCON layer above the shipped ones.
|
|
39
39
|
*
|
|
@@ -62,12 +62,15 @@ import {
|
|
|
62
62
|
terminalErrorHandler,
|
|
63
63
|
} from "@o3co/auth-provider-core";
|
|
64
64
|
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
65
|
+
import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
|
|
66
|
+
import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
|
|
65
67
|
import express from "express";
|
|
66
68
|
import helmet from "helmet";
|
|
67
69
|
import request from "supertest";
|
|
68
70
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
69
71
|
import { buildModules } from "#/buildModules.mjs";
|
|
70
72
|
import {
|
|
73
|
+
configDefaultsFor,
|
|
71
74
|
type OwnLayers,
|
|
72
75
|
readOwnLayers,
|
|
73
76
|
readSwitches,
|
|
@@ -75,7 +78,6 @@ import {
|
|
|
75
78
|
resolveForBoot,
|
|
76
79
|
type Switches,
|
|
77
80
|
} from "#/configPath.mjs";
|
|
78
|
-
import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
|
|
79
81
|
|
|
80
82
|
export const ISSUER = "https://auth.test";
|
|
81
83
|
const OIDC_ISSUER = "https://idp.test";
|
|
@@ -109,6 +111,7 @@ export const SINGLE_ENV: Readonly<Record<string, string>> = {
|
|
|
109
111
|
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
110
112
|
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
111
113
|
ADAPTERS_RATE_LIMITER: "memory",
|
|
114
|
+
ADAPTERS_ATTEMPT_COUNTER: "memory",
|
|
112
115
|
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
113
116
|
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
114
117
|
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
@@ -150,6 +153,8 @@ export const MULTI_ENV: Readonly<Record<string, string>> = {
|
|
|
150
153
|
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
151
154
|
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
152
155
|
ADAPTERS_RATE_LIMITER: "redis",
|
|
156
|
+
// The login's attempt counter, shared: per process, `multi` refuses the login.
|
|
157
|
+
ADAPTERS_ATTEMPT_COUNTER: "redis",
|
|
153
158
|
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
154
159
|
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
155
160
|
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
@@ -165,19 +170,19 @@ export const CONNECTION = "calendar";
|
|
|
165
170
|
|
|
166
171
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
167
172
|
|
|
173
|
+
const quoted = (value: string) => JSON.stringify(value);
|
|
174
|
+
|
|
168
175
|
/**
|
|
169
|
-
* What has no environment form — the federations' landing page
|
|
170
|
-
*
|
|
171
|
-
*
|
|
176
|
+
* What has no environment form — the federations' landing page and one grant
|
|
177
|
+
* connection — as an operator writes it: a HOCON file of their own, the
|
|
178
|
+
* highest layer. Written once per process.
|
|
172
179
|
*/
|
|
173
180
|
const OPERATOR_LAYER: string = (() => {
|
|
174
181
|
const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "operator.conf");
|
|
175
|
-
const quoted = (value: string) => JSON.stringify(value);
|
|
176
182
|
writeFileSync(
|
|
177
183
|
file,
|
|
178
184
|
`core.federations.google.clientUrl = ${quoted(FEDERATION_LANDING)}
|
|
179
185
|
core.federations.oidc.clientUrl = ${quoted(FEDERATION_LANDING)}
|
|
180
|
-
redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]
|
|
181
186
|
federation-grants {
|
|
182
187
|
connections {
|
|
183
188
|
${CONNECTION} {
|
|
@@ -201,26 +206,44 @@ function hoconFile(text: string): string {
|
|
|
201
206
|
return file;
|
|
202
207
|
}
|
|
203
208
|
|
|
204
|
-
/**
|
|
205
|
-
|
|
209
|
+
/**
|
|
210
|
+
* The Redis grant store's key ring, which has no environment form either, in
|
|
211
|
+
* a layer of its own: an operator writes it where that store is installed,
|
|
212
|
+
* since written for a store the composition does not load it reaches nothing
|
|
213
|
+
* and boot may name its section. Written once per process.
|
|
214
|
+
*/
|
|
215
|
+
const GRANT_KEY_RING_LAYER: string = (() => {
|
|
216
|
+
const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "grant-keys.conf");
|
|
217
|
+
writeFileSync(
|
|
218
|
+
file,
|
|
219
|
+
`redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]\n`,
|
|
220
|
+
);
|
|
221
|
+
return file;
|
|
222
|
+
})();
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The composition's own files under `env`, highest first: the operator's
|
|
226
|
+
* layers — the grant key ring among them where `env` turns federation grants
|
|
227
|
+
* on and puts their store on Redis — then the shipped production ones.
|
|
228
|
+
*/
|
|
229
|
+
export function ownFiles(env: Readonly<Record<string, string>> = SINGLE_ENV): string[] {
|
|
206
230
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
207
|
-
|
|
231
|
+
const grantStoreOnRedis =
|
|
232
|
+
env.FEDERATION_GRANTS_ENABLED === "true" && env.ADAPTERS_FEDERATION_GRANT_STORE === "redis";
|
|
233
|
+
const keyRing = grantStoreOnRedis ? [GRANT_KEY_RING_LAYER] : [];
|
|
234
|
+
return [OPERATOR_LAYER, ...keyRing, envConfPath, applicationConfPath];
|
|
208
235
|
}
|
|
209
236
|
|
|
210
237
|
/**
|
|
211
|
-
* Phase one, as `app.mts` reads it: the switches `buildModules` chooses
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* `reference.conf`. What the composition expects of session admission is
|
|
215
|
-
* derived from it (`resolveForBoot`), after `config` adjusts it as an
|
|
216
|
-
* operator's layer would.
|
|
238
|
+
* Phase one, as `app.mts` reads it: the switches `buildModules` chooses the
|
|
239
|
+
* modules by, from the composition's own files under `env` over the
|
|
240
|
+
* template's `reference.conf`.
|
|
217
241
|
*/
|
|
218
242
|
export function resolveConfig(
|
|
219
243
|
env: Readonly<Record<string, string>>,
|
|
220
|
-
|
|
221
|
-
own: OwnLayers = readOwnLayers(ownFiles(), { env }),
|
|
244
|
+
own: OwnLayers = readOwnLayers(ownFiles(env), { env }),
|
|
222
245
|
): Switches {
|
|
223
|
-
return readSwitches(own
|
|
246
|
+
return readSwitches(own);
|
|
224
247
|
}
|
|
225
248
|
|
|
226
249
|
// ---------------------------------------------------------------------------
|
|
@@ -345,15 +368,41 @@ let upstreams: Promise<{ fakes: Upstreams; reset: () => void }> | undefined;
|
|
|
345
368
|
* run if one did.
|
|
346
369
|
*/
|
|
347
370
|
export async function sharedUpstreams(): Promise<Upstreams> {
|
|
371
|
+
const { fakes, reset } = await madeUpstreams();
|
|
372
|
+
reset();
|
|
373
|
+
return fakes;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The two fake upstreams, made on first use and never reset here. */
|
|
377
|
+
function madeUpstreams(): Promise<{ fakes: Upstreams; reset: () => void }> {
|
|
348
378
|
upstreams ??= createUpstreams().then((fakes) => ({
|
|
349
379
|
fakes,
|
|
350
380
|
reset: resettable(fakes.oidc, fakes.google),
|
|
351
381
|
}));
|
|
352
|
-
|
|
353
|
-
reset();
|
|
354
|
-
return fakes;
|
|
382
|
+
return upstreams;
|
|
355
383
|
}
|
|
356
384
|
|
|
385
|
+
/** A `fetch` that reaches the shared fake upstream `name`. */
|
|
386
|
+
const upstreamFetch =
|
|
387
|
+
(name: keyof Upstreams): typeof fetch =>
|
|
388
|
+
async (input, init) =>
|
|
389
|
+
(await madeUpstreams()).fakes[name].fetch(input, init);
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The federation type modules `buildModules` lists, by name, each built as
|
|
393
|
+
* the template builds it but with a `fetch` that reaches its fake upstream.
|
|
394
|
+
*/
|
|
395
|
+
const UPSTREAM_TYPE_MODULES: ReadonlyMap<string, () => Module> = new Map([
|
|
396
|
+
[
|
|
397
|
+
googleFederationTypeModule().name,
|
|
398
|
+
() => googleFederationTypeModule({ fetch: upstreamFetch("google") }),
|
|
399
|
+
],
|
|
400
|
+
[
|
|
401
|
+
oidcFederationTypeModule().name,
|
|
402
|
+
() => oidcFederationTypeModule({ fetch: upstreamFetch("oidc") }),
|
|
403
|
+
],
|
|
404
|
+
]);
|
|
405
|
+
|
|
357
406
|
/**
|
|
358
407
|
* Snapshots each fake's settings (every property that is not a function) and
|
|
359
408
|
* returns what puts them back: primitives reassigned, objects restored in
|
|
@@ -432,52 +481,6 @@ async function createUpstreams(): Promise<Upstreams> {
|
|
|
432
481
|
};
|
|
433
482
|
}
|
|
434
483
|
|
|
435
|
-
/**
|
|
436
|
-
* What one of the template's config bridges provides for `config`. Read
|
|
437
|
-
* through a record, not the typed slot: a program that loads this file without
|
|
438
|
-
* a federation package's ComponentMap augmentation (`tools/composition` does)
|
|
439
|
-
* has no key to name.
|
|
440
|
-
*/
|
|
441
|
-
const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
|
|
442
|
-
const provider = (module.provides as Record<string, unknown> | undefined)?.[slot];
|
|
443
|
-
if (typeof provider !== "function") throw new Error(`${module.name} provides no ${slot}`);
|
|
444
|
-
return (provider as (deps: { config: AppConfig }) => T)({ config });
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* The two federation config slots, read by the template's own bridges
|
|
449
|
-
* (`oidcFederationConfigModule`, `googleFederationConfigModule`), with each
|
|
450
|
-
* adapter's `fetch` pointed at its fake upstream. Only for the federations the
|
|
451
|
-
* config enables — which is when `buildModules` lists the bridges.
|
|
452
|
-
*/
|
|
453
|
-
async function federationOverrides(
|
|
454
|
-
config: Switches,
|
|
455
|
-
upstreams: Upstreams,
|
|
456
|
-
): Promise<Record<string, unknown>> {
|
|
457
|
-
const overrides: Record<string, unknown> = {};
|
|
458
|
-
const modules = buildModules(config).map((m) => m.name);
|
|
459
|
-
if (modules.includes(oidcFederationConfigModule.name)) {
|
|
460
|
-
const oidc = bridged<Record<string, object>>(
|
|
461
|
-
oidcFederationConfigModule,
|
|
462
|
-
"oidcFederationConfigs",
|
|
463
|
-
config,
|
|
464
|
-
);
|
|
465
|
-
overrides.oidcFederationConfigs = Object.fromEntries(
|
|
466
|
-
Object.entries(oidc).map(([name, entry]) => [
|
|
467
|
-
name,
|
|
468
|
-
{ ...entry, fetch: upstreams.oidc.fetch },
|
|
469
|
-
]),
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
if (modules.includes(googleFederationConfigModule.name)) {
|
|
473
|
-
overrides.googleFederationConfig = {
|
|
474
|
-
...bridged<object>(googleFederationConfigModule, "googleFederationConfig", config),
|
|
475
|
-
fetch: upstreams.google.fetch,
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
return overrides;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
484
|
// ---------------------------------------------------------------------------
|
|
482
485
|
// Logger spy
|
|
483
486
|
// ---------------------------------------------------------------------------
|
|
@@ -588,30 +591,24 @@ export interface ComposeOptions {
|
|
|
588
591
|
readonly environment?: string;
|
|
589
592
|
/** HOCON an operator writes above the composition's own files, read in both phases. */
|
|
590
593
|
readonly operatorHocon?: string;
|
|
591
|
-
/**
|
|
592
|
-
* Paths read before boot beside the template's switches: what a module
|
|
593
|
-
* `extraModules` adds reads when it is built (`readSwitches`'s `reads`).
|
|
594
|
-
*/
|
|
595
|
-
readonly reads?: readonly string[];
|
|
596
594
|
/** The deployment's own mail sender modules, handed to `buildModules` as `mailSenderModules`. */
|
|
597
595
|
readonly mailSenderModules?: readonly Module[];
|
|
598
596
|
/** Modules added after the template's own, before the order and the outage apply. */
|
|
599
597
|
readonly extraModules?: (config: Switches) => readonly Module[];
|
|
600
|
-
/** Components laid over the boot's
|
|
598
|
+
/** Components laid over the boot's (`overrideComponents`). */
|
|
601
599
|
readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
|
|
602
600
|
/** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
|
|
603
601
|
readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
|
|
604
602
|
/** Users beside the fixture's own, keyed by username. */
|
|
605
603
|
readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
|
|
606
604
|
/**
|
|
607
|
-
* Adjust
|
|
608
|
-
* layer would
|
|
609
|
-
* handed, as resolved.
|
|
605
|
+
* Adjust what `createApp` is handed, as resolved, as an operator's own
|
|
606
|
+
* layer would.
|
|
610
607
|
*/
|
|
611
|
-
readonly config?: (config:
|
|
608
|
+
readonly config?: (config: AppConfig) => AppConfig;
|
|
612
609
|
/**
|
|
613
|
-
* Adjust phase one's switches
|
|
614
|
-
*
|
|
610
|
+
* Adjust phase one's switches: what only phase one reads, such as the
|
|
611
|
+
* Store transport settings a hand-built root passes.
|
|
615
612
|
*/
|
|
616
613
|
readonly switches?: (switches: Switches) => Switches;
|
|
617
614
|
readonly order?: ModuleOrder;
|
|
@@ -639,7 +636,7 @@ export function composedModules(config: Switches, options: ComposeOptions = {}):
|
|
|
639
636
|
...(options.shippedRefreshTokenFamilyStore
|
|
640
637
|
? {}
|
|
641
638
|
: { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
|
|
642
|
-
}),
|
|
639
|
+
}).map((module) => UPSTREAM_TYPE_MODULES.get(module.name)?.() ?? module),
|
|
643
640
|
...(options.extraModules?.(config) ?? []),
|
|
644
641
|
];
|
|
645
642
|
if (options.outage) modules = withOutage(modules, options.outage.slot, options.outage.outage);
|
|
@@ -667,35 +664,32 @@ export interface Composition {
|
|
|
667
664
|
*/
|
|
668
665
|
export async function compose(options: ComposeOptions = {}): Promise<Composition> {
|
|
669
666
|
const env = options.env ?? SINGLE_ENV;
|
|
670
|
-
const adjust = <C extends AppConfig>(config: C): C =>
|
|
671
|
-
options.config ? (options.config(config as unknown as Switches) as unknown as C) : config;
|
|
672
667
|
// The composition's own layers, read once for both phases, as `app.mts`
|
|
673
668
|
// reads them. Phase one: the switches the modules are chosen by.
|
|
674
669
|
const own = readOwnLayers(
|
|
675
670
|
options.operatorHocon === undefined
|
|
676
|
-
? ownFiles()
|
|
677
|
-
: [hoconFile(options.operatorHocon), ...ownFiles()],
|
|
671
|
+
? ownFiles(env)
|
|
672
|
+
: [hoconFile(options.operatorHocon), ...ownFiles(env)],
|
|
678
673
|
{ env },
|
|
679
674
|
);
|
|
680
|
-
const switches = resolveConfig(env,
|
|
681
|
-
const config = options.switches ? options.switches(
|
|
675
|
+
const switches = resolveConfig(env, own);
|
|
676
|
+
const config = options.switches ? options.switches(switches) : switches;
|
|
682
677
|
const fakes = await sharedUpstreams();
|
|
683
678
|
const modules = composedModules(config, options);
|
|
684
679
|
const logger = createRecordingLogger();
|
|
685
680
|
// Phase two: the configuration as resolved over every loaded package's
|
|
686
|
-
// reference.conf, which createApp parses once.
|
|
687
|
-
const
|
|
681
|
+
// reference.conf, which createApp parses once, and its defaults.
|
|
682
|
+
const forBoot = resolveForBoot(own, modules, config);
|
|
683
|
+
const resolved = options.config ? options.config(forBoot) : forBoot;
|
|
688
684
|
const handle = await createApp({
|
|
689
685
|
modules,
|
|
690
686
|
bootstrapComponents: {
|
|
691
687
|
config: resolved,
|
|
688
|
+
configDefaults: configDefaultsFor(modules),
|
|
692
689
|
pathResolver: (s) => s,
|
|
693
690
|
logger,
|
|
694
691
|
},
|
|
695
|
-
overrideComponents: {
|
|
696
|
-
...(await federationOverrides(config, fakes)),
|
|
697
|
-
...options.extraOverrides?.(config),
|
|
698
|
-
} as never,
|
|
692
|
+
overrideComponents: { ...options.extraOverrides?.(config) } as never,
|
|
699
693
|
});
|
|
700
694
|
const parsed = handle.components.config;
|
|
701
695
|
if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
|
|
@@ -1056,6 +1050,11 @@ export interface OutageCase<C extends Composition = Composition> {
|
|
|
1056
1050
|
readonly storeFieldNotRequired?: string;
|
|
1057
1051
|
/** The predicates the composition breaks today, each naming its defect. */
|
|
1058
1052
|
readonly defects?: Partial<Record<OutagePredicate, string>>;
|
|
1053
|
+
/**
|
|
1054
|
+
* The warn lines a `no-warn` defect writes, pinned exactly while it stands,
|
|
1055
|
+
* so the defect covers those lines and no other.
|
|
1056
|
+
*/
|
|
1057
|
+
readonly defectWarns?: readonly string[];
|
|
1059
1058
|
}
|
|
1060
1059
|
|
|
1061
1060
|
/** The same defect text for every predicate that depends on the one missing line. */
|
|
@@ -1086,6 +1085,14 @@ export function describeOutages<C extends Composition>(
|
|
|
1086
1085
|
): void {
|
|
1087
1086
|
describe(title, () => {
|
|
1088
1087
|
for (const c of cases) {
|
|
1088
|
+
if (
|
|
1089
|
+
c.defectWarns !== undefined &&
|
|
1090
|
+
(c.defects?.["no-warn"] === undefined || c.defectWarns.length === 0)
|
|
1091
|
+
) {
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
`${c.module}: ${c.slot} down at ${c.surface}: defectWarns pins the lines of a no-warn defect, so it needs defects["no-warn"] and at least one line`,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1089
1096
|
describe(`${c.module}: ${c.slot} down at ${c.surface}`, () => {
|
|
1090
1097
|
let res: request.Response;
|
|
1091
1098
|
let lines: LogLine[] = [];
|
|
@@ -1165,13 +1172,20 @@ export function describeOutages<C extends Composition>(
|
|
|
1165
1172
|
expect(err).toMatchObject({ name: expect.any(String) });
|
|
1166
1173
|
expect(err).not.toBeInstanceOf(Error);
|
|
1167
1174
|
});
|
|
1168
|
-
|
|
1169
|
-
|
|
1175
|
+
const outageWarns = () =>
|
|
1176
|
+
lines
|
|
1170
1177
|
.filter((line) => line.level === "warn")
|
|
1171
1178
|
.map((line) => (typeof line.args[1] === "string" ? line.args[1] : String(line.args[0])))
|
|
1172
1179
|
.filter((event) => !(c.unrelatedWarns ?? []).includes(event));
|
|
1173
|
-
|
|
1180
|
+
check("no-warn", "writes no warn line for it", () => {
|
|
1181
|
+
expect(outageWarns()).toEqual([]);
|
|
1174
1182
|
});
|
|
1183
|
+
if (c.defectWarns !== undefined) {
|
|
1184
|
+
const pinned = c.defectWarns;
|
|
1185
|
+
it(`writes exactly the warn lines its no-warn defect names: ${pinned.join(", ")}`, () => {
|
|
1186
|
+
expect(outageWarns()).toEqual(pinned);
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1175
1189
|
});
|
|
1176
1190
|
}
|
|
1177
1191
|
});
|