@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
|
@@ -15,21 +15,27 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
-
import {
|
|
18
|
+
import type { AppConfig } from "@o3co/auth-provider-core";
|
|
19
|
+
import { type OAuthSection, oauthEndpointsModule } from "@o3co/auth-provider-oauth";
|
|
19
20
|
import { parseFile } from "@o3co/ts.hocon";
|
|
20
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
21
21
|
import { describe, expect, it } from "vitest";
|
|
22
22
|
import { readAdapters } from "../adapters.mjs";
|
|
23
23
|
import { buildModules } from "../buildModules.mjs";
|
|
24
|
-
import {
|
|
25
|
-
|
|
24
|
+
import {
|
|
25
|
+
readOwnLayers,
|
|
26
|
+
readSwitches,
|
|
27
|
+
resolveConfigPaths,
|
|
28
|
+
resolveForBoot,
|
|
29
|
+
type Switches,
|
|
30
|
+
} from "../configPath.mjs";
|
|
26
31
|
import { templateReference } from "../modules.mjs";
|
|
32
|
+
import { type BothPhases, withSwitches } from "./library-references.fixture.mjs";
|
|
27
33
|
|
|
28
34
|
// config/ is two levels above this test file:
|
|
29
35
|
// src/__tests__/ → src/ → standalone/ → config/
|
|
30
36
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
31
37
|
|
|
32
|
-
// Provide
|
|
38
|
+
// Provide the secrets the template's files require. These are
|
|
33
39
|
// test-only values — no real keys are embedded here. SESSION_STORE_SECRET
|
|
34
40
|
// carries a 256-bit entropy floor, so these clear it (the '.' characters keep
|
|
35
41
|
// them outside the base64 alphabet, so the UTF-8 length is what counts).
|
|
@@ -40,30 +46,32 @@ const testEnv = {
|
|
|
40
46
|
};
|
|
41
47
|
|
|
42
48
|
/**
|
|
43
|
-
* The three tiers under `env`,
|
|
44
|
-
*
|
|
45
|
-
*
|
|
49
|
+
* The three tiers under `env`, as `app.mts` reads them: what phase one reads
|
|
50
|
+
* of the template's own layers, laid over what phase two hands boot — the
|
|
51
|
+
* own layers over the reference of every package the chosen modules come
|
|
52
|
+
* from, resolved and unparsed.
|
|
46
53
|
*/
|
|
47
|
-
function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}):
|
|
54
|
+
function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): BothPhases {
|
|
48
55
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, env);
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
const own = readOwnLayers([envConfPath, applicationConfPath], {
|
|
57
|
+
env: { ...testEnv, ...extraEnv },
|
|
58
|
+
});
|
|
59
|
+
const switches = readSwitches(own);
|
|
60
|
+
return withSwitches(
|
|
61
|
+
resolveForBoot(own, buildModules(switches), switches) as unknown as Record<string, unknown>,
|
|
62
|
+
switches,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `oauth.resourceIndicator` as the section's owner, the oauth module, parses it at boot. */
|
|
67
|
+
function parsedResourceIndicator(config: AppConfig): { readonly enabled?: unknown } | undefined {
|
|
68
|
+
const schema = oauthEndpointsModule.section?.schema;
|
|
69
|
+
if (schema === undefined) throw new Error("the oauth module declares no section");
|
|
70
|
+
return (schema.parse(config.oauth) as { resourceIndicator?: { enabled?: unknown } })
|
|
71
|
+
.resourceIndicator;
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
// The oauth-authorization module's section
|
|
66
|
-
// key read before the modules are chosen (`grants`), kept as written: the
|
|
74
|
+
// The oauth-authorization module's section as resolved, unparsed: the
|
|
67
75
|
// runtime shape is a string from env substitution, a boolean from a literal.
|
|
68
76
|
type GrantEntry = { enabled?: unknown };
|
|
69
77
|
const grants = (config: AppConfig) =>
|
|
@@ -71,6 +79,9 @@ const grants = (config: AppConfig) =>
|
|
|
71
79
|
| Record<string, GrantEntry | undefined>
|
|
72
80
|
| undefined) ?? {};
|
|
73
81
|
|
|
82
|
+
/** `oauth {}` as the resolution leaves it, read as the shape the oauth module parses. */
|
|
83
|
+
const oauthOf = (config: AppConfig): OAuthSection => config.oauth as OAuthSection;
|
|
84
|
+
|
|
74
85
|
describe("three-tier HOCON resolution (env → application.conf → reference.conf)", () => {
|
|
75
86
|
it("template application.conf wins over reference.conf for grant.enabled", () => {
|
|
76
87
|
const config = buildResolvedConfig("development");
|
|
@@ -83,15 +94,15 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
83
94
|
const config = buildResolvedConfig("development");
|
|
84
95
|
// nonce.maxLength is library-owned in both layers (template doesn't
|
|
85
96
|
// override it). Verifies precedence falls through.
|
|
86
|
-
expect(config.
|
|
97
|
+
expect(oauthOf(config).nonce?.maxLength).toBe(256);
|
|
87
98
|
});
|
|
88
99
|
|
|
89
100
|
it("env var at template layer can disable a template-enabled grant (precedence: env-override line must be repeated)", () => {
|
|
90
101
|
// The env-override line is repeated at the template layer alongside
|
|
91
102
|
// `enabled = true`; without it, the package reference's substitution is
|
|
92
|
-
// shadowed by the template's literal `true`. The value stays a string
|
|
93
|
-
//
|
|
94
|
-
//
|
|
103
|
+
// shadowed by the template's literal `true`. The value stays a string
|
|
104
|
+
// until boot: the module reads the switch with its own schema, `"false"`
|
|
105
|
+
// as off.
|
|
95
106
|
const config = buildResolvedConfig("development", {
|
|
96
107
|
OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
|
|
97
108
|
});
|
|
@@ -132,7 +143,8 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
132
143
|
// schema coercion (coerceBooleanFromEnv) can produce a boolean value.
|
|
133
144
|
// Without the HOCON block the field resolves to undefined.
|
|
134
145
|
const config = buildResolvedConfig("development");
|
|
135
|
-
expect(config.
|
|
146
|
+
expect(oauthOf(config).resourceIndicator?.enabled).toBe(false);
|
|
147
|
+
expect(parsedResourceIndicator(config)?.enabled).toBe(false);
|
|
136
148
|
});
|
|
137
149
|
|
|
138
150
|
it("env var OAUTH_RESOURCE_INDICATOR_ENABLED=true reaches resolved config and coerces to boolean true", () => {
|
|
@@ -143,7 +155,8 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
143
155
|
const config = buildResolvedConfig("development", {
|
|
144
156
|
OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
|
|
145
157
|
});
|
|
146
|
-
expect(config.
|
|
158
|
+
expect(oauthOf(config).resourceIndicator?.enabled).toBe("true");
|
|
159
|
+
expect(parsedResourceIndicator(config)?.enabled).toBe(true);
|
|
147
160
|
});
|
|
148
161
|
|
|
149
162
|
// The selection is the composition root's own, `adapters.auditSink`,
|
|
@@ -190,7 +203,7 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
190
203
|
|
|
191
204
|
it("resolves oauth.revocation.accessToken to denylist with nothing set", () => {
|
|
192
205
|
const config = buildResolvedConfig("production");
|
|
193
|
-
expect(config.
|
|
206
|
+
expect(oauthOf(config).revocation?.accessToken).toBe("denylist");
|
|
194
207
|
});
|
|
195
208
|
|
|
196
209
|
it("picks the replica-safe denylist module for that resolved config", () => {
|
|
@@ -221,8 +234,11 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
221
234
|
});
|
|
222
235
|
const modules = buildModules(config);
|
|
223
236
|
expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
|
|
237
|
+
// Each module's section at its name: a declaration made from the
|
|
238
|
+
// section is answered for it.
|
|
239
|
+
const sections = config as unknown as Record<string, unknown>;
|
|
224
240
|
for (const module of modules) {
|
|
225
|
-
expect(replicaUnsafeReason(module), module.name).toBeUndefined();
|
|
241
|
+
expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
|
|
226
242
|
}
|
|
227
243
|
});
|
|
228
244
|
|
|
@@ -239,7 +255,7 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
239
255
|
const config = buildResolvedConfig("production", {
|
|
240
256
|
OAUTH_REVOCATION_ACCESS_TOKEN: "unsupported",
|
|
241
257
|
});
|
|
242
|
-
expect(config.
|
|
258
|
+
expect(oauthOf(config).revocation?.accessToken).toBe("unsupported");
|
|
243
259
|
});
|
|
244
260
|
});
|
|
245
261
|
});
|
|
@@ -17,52 +17,58 @@
|
|
|
17
17
|
/**
|
|
18
18
|
* The template reads its configuration in two phases, as `app.mts` does:
|
|
19
19
|
*
|
|
20
|
-
* 1. `readSwitches` — its own files over
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* transport settings beside them, unparsed;
|
|
20
|
+
* 1. `readSwitches` — its own files over the template's own `reference.conf`
|
|
21
|
+
* — reads only what the template reads before it knows its modules: the
|
|
22
|
+
* composition root's own `adapters` and `mfaMode`, with the template's
|
|
23
|
+
* schema (`adapters.test.mts`, `mfa-switch.test.mts`), the Store transport
|
|
24
|
+
* settings beside them, unparsed, and whether federation grants are
|
|
25
|
+
* installed, from `federation-grants.enabled` read with core's
|
|
26
|
+
* `coerceBooleanFromEnv` (the log level is the `logging` module's
|
|
27
|
+
* section, which `readLogging` reads with that module's schema:
|
|
28
|
+
* `own-modules.test.mts`);
|
|
30
29
|
* 2. `resolveForBoot` — its own files over the `reference.conf` of every
|
|
31
30
|
* package its modules come from, core's last — handed to `createApp`
|
|
32
31
|
* unparsed, which parses it once with every loaded module's schema.
|
|
33
32
|
*
|
|
34
|
-
* Phase one must read
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* only in phase two) does not
|
|
33
|
+
* Phase one must read `federation-grants.enabled` as the federation-grants
|
|
34
|
+
* module's own schema and boot's parse do, and install the module for a value
|
|
35
|
+
* they refuse, so that boot refuses it; and it reads nothing else, so that a
|
|
36
|
+
* section a package's reference completes (known only in phase two) does not
|
|
37
|
+
* refuse it.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
41
41
|
import { tmpdir } from "node:os";
|
|
42
42
|
import { join } from "node:path";
|
|
43
43
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
BootError,
|
|
46
|
+
createApp,
|
|
47
|
+
defineModule,
|
|
48
|
+
type Module,
|
|
49
|
+
moduleReferences,
|
|
50
|
+
} from "@o3co/auth-provider-core";
|
|
51
|
+
import {
|
|
52
|
+
federationGrantsConfigSchema,
|
|
53
|
+
federationGrantsModules,
|
|
54
|
+
} from "@o3co/auth-provider-federation-grants";
|
|
55
|
+
import { oauthEndpointsModule } from "@o3co/auth-provider-oauth";
|
|
45
56
|
import {
|
|
46
57
|
redisFederationGrantIntentStoreModule,
|
|
47
58
|
redisFederationGrantStoreModule,
|
|
48
59
|
} from "@o3co/auth-provider-redis";
|
|
49
|
-
import { parseFile } from "@o3co/ts.hocon";
|
|
50
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
51
60
|
import { describe, expect, it } from "vitest";
|
|
52
61
|
import { ADAPTERS_SECTION } from "../adapters.mjs";
|
|
53
62
|
import { buildModules } from "../buildModules.mjs";
|
|
54
63
|
import {
|
|
55
|
-
expectedSessionRequirements,
|
|
56
64
|
readOwnLayers,
|
|
57
65
|
readSwitches,
|
|
58
66
|
resolveConfigPaths,
|
|
59
67
|
resolveForBoot,
|
|
60
68
|
resolveLayers,
|
|
61
|
-
SWITCHES,
|
|
62
69
|
type Switches,
|
|
63
70
|
} from "../configPath.mjs";
|
|
64
71
|
import { MFA_SWITCH } from "../mfaSwitch.mjs";
|
|
65
|
-
import { templateReference } from "../modules.mjs";
|
|
66
72
|
|
|
67
73
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
68
74
|
|
|
@@ -93,22 +99,24 @@ const ENVIRONMENTS: Readonly<Record<string, Readonly<Record<string, string>>>> =
|
|
|
93
99
|
},
|
|
94
100
|
};
|
|
95
101
|
|
|
102
|
+
/** What phase one answers: the composition root's own keys, and the one switch of a module's section it decides by. */
|
|
103
|
+
const PHASE_ONE_PATHS: readonly string[] = [
|
|
104
|
+
ADAPTERS_SECTION,
|
|
105
|
+
MFA_SWITCH,
|
|
106
|
+
"storeTransport",
|
|
107
|
+
"federation-grants.enabled",
|
|
108
|
+
];
|
|
109
|
+
|
|
96
110
|
const ownFiles = (environment: string): string[] => {
|
|
97
111
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, environment);
|
|
98
112
|
return [envConfPath, applicationConfPath];
|
|
99
113
|
};
|
|
100
114
|
|
|
101
|
-
/**
|
|
102
|
-
function
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
return
|
|
106
|
-
read(top)
|
|
107
|
-
.withFallback(read(application))
|
|
108
|
-
.withFallback(read(fileURLToPath(templateReference())))
|
|
109
|
-
.withFallback(read(fileURLToPath(coreReference()))),
|
|
110
|
-
AppConfigSchema,
|
|
111
|
-
);
|
|
115
|
+
/** `federation-grants.enabled` as the federation-grants module's own schema parses the same layers. */
|
|
116
|
+
function ownerReads(environment: string, env: Readonly<Record<string, string>>): unknown {
|
|
117
|
+
const own = readOwnLayers(ownFiles(environment), { env });
|
|
118
|
+
const resolved = resolveLayers(own, moduleReferences(federationGrantsModules));
|
|
119
|
+
return federationGrantsConfigSchema.parse(resolved["federation-grants"])?.enabled;
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/** The value at a dotted path, or `undefined`. */
|
|
@@ -145,27 +153,125 @@ function recording(config: unknown): { readonly config: Switches; readonly reads
|
|
|
145
153
|
return { config: wrap(config as object, "") as Switches, reads };
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
describe("phase one reads
|
|
156
|
+
describe("phase one reads federation-grants.enabled as the federation-grants module reads it", () => {
|
|
149
157
|
for (const environment of ["development", "production"]) {
|
|
150
158
|
for (const [name, env] of Object.entries(ENVIRONMENTS)) {
|
|
151
159
|
it(`${environment}, ${name}`, () => {
|
|
152
|
-
const before = preParsed(environment, env);
|
|
153
160
|
const switches = readSwitches(readOwnLayers(ownFiles(environment), { env }));
|
|
154
|
-
|
|
155
|
-
(path) =>
|
|
156
|
-
JSON.stringify(valueAt(before, path)) !== JSON.stringify(valueAt(switches, path)),
|
|
157
|
-
);
|
|
158
|
-
expect(changed).toEqual([]);
|
|
161
|
+
expect(switches["federation-grants"]?.enabled).toBe(ownerReads(environment, env) ?? false);
|
|
159
162
|
});
|
|
160
163
|
}
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
it.each([
|
|
167
|
+
["true", true],
|
|
168
|
+
["1", true],
|
|
169
|
+
[" TRUE ", true],
|
|
170
|
+
["false", false],
|
|
171
|
+
["0", false],
|
|
172
|
+
["", false],
|
|
173
|
+
])("reads FEDERATION_GRANTS_ENABLED=%j as the module's schema does: %s", (value, enabled) => {
|
|
174
|
+
const env = { ...REQUIRED_ENV, FEDERATION_GRANTS_ENABLED: value };
|
|
175
|
+
const switches = readSwitches(readOwnLayers(ownFiles("production"), { env }));
|
|
176
|
+
expect(switches["federation-grants"]?.enabled).toBe(enabled);
|
|
177
|
+
expect(ownerReads("production", env)).toBe(enabled);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it.each([
|
|
181
|
+
["true", true],
|
|
182
|
+
["false", false],
|
|
183
|
+
])("reads federation-grants.enabled = %s written in HOCON", (literal, enabled) => {
|
|
184
|
+
const operator = operatorLayer(`federation-grants.enabled = ${literal}\n`);
|
|
185
|
+
const switches = readSwitches(
|
|
186
|
+
readOwnLayers([operator, ...ownFiles("production")], { env: REQUIRED_ENV }),
|
|
187
|
+
);
|
|
188
|
+
expect(switches["federation-grants"]?.enabled).toBe(enabled);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it.each([
|
|
192
|
+
'federation-grants = "x"',
|
|
193
|
+
"federation-grants = null",
|
|
194
|
+
"federation-grants.enabled = 1",
|
|
195
|
+
"federation-grants.enabled = null",
|
|
196
|
+
])("installs the modules for %s, which the module's schema refuses", (hocon) => {
|
|
197
|
+
const operator = operatorLayer(`${hocon}\n`);
|
|
198
|
+
const own = readOwnLayers([operator, ...ownFiles("production")], {
|
|
199
|
+
env: { ...REQUIRED_ENV, MFA_MODE: "off" },
|
|
200
|
+
});
|
|
201
|
+
const resolved = resolveLayers(own, moduleReferences(federationGrantsModules));
|
|
202
|
+
expect(federationGrantsConfigSchema.safeParse(resolved["federation-grants"]).success).toBe(
|
|
203
|
+
false,
|
|
204
|
+
);
|
|
205
|
+
const switches = readSwitches(own);
|
|
206
|
+
expect(switches["federation-grants"]?.enabled).toBe(true);
|
|
207
|
+
expect(
|
|
208
|
+
buildModules(switches, { environment: "production" }).map((module) => module.name),
|
|
209
|
+
).toContain("federation-grants");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("leaves the modules out for an empty section at the old path, federationGrants {}, which sets nothing", () => {
|
|
213
|
+
const operator = operatorLayer("federationGrants {}\n");
|
|
214
|
+
const switches = readSwitches(
|
|
215
|
+
readOwnLayers([operator, ...ownFiles("production")], {
|
|
216
|
+
env: { ...REQUIRED_ENV, MFA_MODE: "off" },
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
expect(switches["federation-grants"]?.enabled).toBe(false);
|
|
220
|
+
expect(
|
|
221
|
+
buildModules(switches, { environment: "production" }).map((module) => module.name),
|
|
222
|
+
).not.toContain("federation-grants");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("reads the switch unwritten as off", () => {
|
|
226
|
+
const operator = operatorLayer('mfaMode = "off"\n');
|
|
227
|
+
const switches = readSwitches(readOwnLayers([operator], { env: {} }));
|
|
228
|
+
expect(switches["federation-grants"]?.enabled).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("installs the modules for a value the module's schema refuses, so that boot refuses it naming the key", async () => {
|
|
232
|
+
const env = { ...REQUIRED_ENV, MFA_MODE: "off", FEDERATION_GRANTS_ENABLED: "yes" };
|
|
233
|
+
expect(() => ownerReads("production", env)).toThrow();
|
|
234
|
+
const own = readOwnLayers(ownFiles("production"), { env });
|
|
235
|
+
const switches = readSwitches(own);
|
|
236
|
+
expect(switches["federation-grants"]?.enabled).toBe(true);
|
|
237
|
+
const modules = buildModules(switches, { environment: "production" });
|
|
238
|
+
expect(modules.map((module) => module.name)).toContain("federation-grants");
|
|
239
|
+
const err = await createApp({
|
|
240
|
+
modules,
|
|
241
|
+
bootstrapComponents: {
|
|
242
|
+
config: resolveForBoot(own, modules, switches),
|
|
243
|
+
pathResolver: (s: string) => s,
|
|
244
|
+
},
|
|
245
|
+
}).then(
|
|
246
|
+
async (handle) => {
|
|
247
|
+
await handle.dispose();
|
|
248
|
+
return undefined;
|
|
249
|
+
},
|
|
250
|
+
(caught: unknown) => caught,
|
|
251
|
+
);
|
|
252
|
+
expect(err).toBeInstanceOf(BootError);
|
|
253
|
+
expect((err as BootError).reason).toBe("config-validation-failed");
|
|
254
|
+
expect((err as BootError).message).toContain("federation-grants.enabled");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("installs the modules for a setting at the section's old path, which boot refuses naming the new one", () => {
|
|
258
|
+
const operator = operatorLayer("federationGrants.enabled = true\n");
|
|
259
|
+
const switches = readSwitches(
|
|
260
|
+
readOwnLayers([operator, ...ownFiles("production")], {
|
|
261
|
+
env: { ...REQUIRED_ENV, MFA_MODE: "off" },
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
expect(switches["federation-grants"]?.enabled).toBe(true);
|
|
265
|
+
expect(
|
|
266
|
+
buildModules(switches, { environment: "production" }).map((module) => module.name),
|
|
267
|
+
).toContain("federation-grants");
|
|
268
|
+
});
|
|
269
|
+
|
|
163
270
|
it("reads the MFA switch as its own mfaMode, as the environment sets it, and no key of the MFA module's", () => {
|
|
164
|
-
expect(SWITCHES.filter((path) => path.split(".")[0] === "mfa")).toEqual([]);
|
|
165
271
|
for (const [name, env] of Object.entries(ENVIRONMENTS)) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
);
|
|
272
|
+
const switches = readSwitches(readOwnLayers(ownFiles("production"), { env }));
|
|
273
|
+
expect(switches.mfaMode, name).toBe(env.MFA_MODE ?? "required");
|
|
274
|
+
expect(switches, name).not.toHaveProperty("mfa");
|
|
169
275
|
}
|
|
170
276
|
});
|
|
171
277
|
});
|
|
@@ -176,26 +282,30 @@ describe("phase one reads its switches and nothing else", () => {
|
|
|
176
282
|
>;
|
|
177
283
|
|
|
178
284
|
it("accepts a section a package's reference completes, which only phase two layers", () => {
|
|
179
|
-
// The device grant's
|
|
180
|
-
//
|
|
181
|
-
const partial = operatorLayer(
|
|
182
|
-
"device-grant.rateLimit.limit = 10\nwebauthn.rateLimit.authenticationOptions.limit = 10\n",
|
|
183
|
-
);
|
|
285
|
+
// The device grant's reference ships `windowSeconds`; boot layers it,
|
|
286
|
+
// and accepts what the operator wrote.
|
|
287
|
+
const partial = operatorLayer("device-grant.rateLimit.limit = 10\n");
|
|
184
288
|
expect(() =>
|
|
185
289
|
readSwitches(readOwnLayers([partial, ...ownFiles("production")], { env })),
|
|
186
290
|
).not.toThrow();
|
|
187
291
|
});
|
|
188
292
|
|
|
293
|
+
it("answers the composition root's own keys and federation-grants.enabled, nothing of core's section or any other module's", () => {
|
|
294
|
+
const switches = readSwitches(readOwnLayers(ownFiles("production"), { env }));
|
|
295
|
+
expect(Object.keys(switches).sort()).toEqual(
|
|
296
|
+
[ADAPTERS_SECTION, MFA_SWITCH, "storeTransport", "federation-grants"].sort(),
|
|
297
|
+
);
|
|
298
|
+
expect(Object.keys(switches["federation-grants"] ?? {})).toEqual(["enabled"]);
|
|
299
|
+
});
|
|
300
|
+
|
|
189
301
|
it("reads, before boot, only paths among its switches", () => {
|
|
190
302
|
const { config, reads } = recording(
|
|
191
303
|
readSwitches(readOwnLayers(ownFiles("production"), { env })),
|
|
192
304
|
);
|
|
193
|
-
// What `app.mts` reads of phase one: the modules
|
|
194
|
-
// composition expects of session admission.
|
|
195
|
-
expectedSessionRequirements(config);
|
|
305
|
+
// What `app.mts` reads of phase one before boot: the modules.
|
|
196
306
|
buildModules(config, { environment: "production" });
|
|
197
307
|
const covered = (path: string) =>
|
|
198
|
-
|
|
308
|
+
PHASE_ONE_PATHS.some(
|
|
199
309
|
(switchPath) =>
|
|
200
310
|
path === switchPath ||
|
|
201
311
|
path.startsWith(`${switchPath}.`) ||
|
|
@@ -205,6 +315,25 @@ describe("phase one reads its switches and nothing else", () => {
|
|
|
205
315
|
expect([...reads].filter((path) => !covered(path))).toEqual([]);
|
|
206
316
|
});
|
|
207
317
|
|
|
318
|
+
it("reads no federation entry: core dispatches each by its type at boot", () => {
|
|
319
|
+
const federationEnvs: readonly Readonly<Record<string, string>>[] = [
|
|
320
|
+
{},
|
|
321
|
+
{ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" },
|
|
322
|
+
];
|
|
323
|
+
for (const federations of federationEnvs) {
|
|
324
|
+
const { config, reads } = recording(
|
|
325
|
+
readSwitches(readOwnLayers(ownFiles("production"), { env: { ...env, ...federations } })),
|
|
326
|
+
);
|
|
327
|
+
expect(config).not.toHaveProperty("core");
|
|
328
|
+
buildModules(config, { environment: "production" });
|
|
329
|
+
expect(
|
|
330
|
+
[...reads].filter(
|
|
331
|
+
(path) => path === "core.federations" || path.startsWith("core.federations."),
|
|
332
|
+
),
|
|
333
|
+
).toEqual([]);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
208
337
|
it("still refuses a switch it reads that the schema refuses, naming it", () => {
|
|
209
338
|
const bad = operatorLayer('adapters.rateLimiter = "carrier-pigeon"\n');
|
|
210
339
|
expect(() => readSwitches(readOwnLayers([bad, ...ownFiles("production")], { env }))).toThrow(
|
|
@@ -228,7 +357,7 @@ describe("phase two: what createApp is handed", () => {
|
|
|
228
357
|
const reference = join(dir, "reference.conf");
|
|
229
358
|
writeFileSync(
|
|
230
359
|
reference,
|
|
231
|
-
'widget { size = 3 }\noauth.revocation.accessToken = "widget-revocation"\
|
|
360
|
+
'widget { size = 3 }\noauth.revocation.accessToken = "widget-revocation"\ncore.tokenBinding.dispatchPolicy = "widget-policy"\n',
|
|
232
361
|
);
|
|
233
362
|
return {
|
|
234
363
|
name: "widget",
|
|
@@ -247,7 +376,9 @@ describe("phase two: what createApp is handed", () => {
|
|
|
247
376
|
// The template's application.conf wins over a package's reference…
|
|
248
377
|
expect(resolved.oauth?.revocation).toEqual({ accessToken: "denylist" });
|
|
249
378
|
// …and a package's reference over core's.
|
|
250
|
-
expect(
|
|
379
|
+
expect(
|
|
380
|
+
(resolved.core?.tokenBinding as { dispatchPolicy?: unknown } | undefined)?.dispatchPolicy,
|
|
381
|
+
).toBe("widget-policy");
|
|
251
382
|
});
|
|
252
383
|
|
|
253
384
|
it("layers no reference a loaded module does not declare", () => {
|
|
@@ -292,18 +423,29 @@ describe("phase two refuses the Redis grant store's key prefix moved while the i
|
|
|
292
423
|
};
|
|
293
424
|
|
|
294
425
|
/** What phase two refused with. */
|
|
295
|
-
const refusal = (...args: Parameters<typeof resolve>):
|
|
426
|
+
const refusal = (...args: Parameters<typeof resolve>): BootError => {
|
|
296
427
|
try {
|
|
297
428
|
resolve(...args);
|
|
298
429
|
} catch (err) {
|
|
299
|
-
if (err instanceof
|
|
430
|
+
if (err instanceof BootError) return err;
|
|
300
431
|
throw err;
|
|
301
432
|
}
|
|
302
433
|
throw new Error("phase two resolved");
|
|
303
434
|
};
|
|
304
435
|
|
|
305
436
|
it("the grant store's set and the intent store's left alone: refused, naming both keys and both variables and quoting no value", () => {
|
|
306
|
-
const { message } = refusal({ [GRANT]: "t1:fg:" });
|
|
437
|
+
const { message, reason, details } = refusal({ [GRANT]: "t1:fg:" });
|
|
438
|
+
expect(reason).toBe("config-validation-failed");
|
|
439
|
+
expect(details).toMatchObject({
|
|
440
|
+
issues: [{ code: "custom", path: ["redis-federation-grant-intent-store", "keyPrefix"] }],
|
|
441
|
+
modules: [
|
|
442
|
+
{
|
|
443
|
+
module: "redis-federation-grant-intent-store",
|
|
444
|
+
schemaPath: "redis-federation-grant-intent-store",
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
expect(JSON.stringify(details)).not.toContain("fg:");
|
|
307
449
|
expect(message).toContain(`redis-federation-grant-store.keyPrefix (${GRANT})`);
|
|
308
450
|
expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
|
|
309
451
|
expect(message).not.toContain("fg:");
|
|
@@ -341,11 +483,23 @@ describe("phase two refuses the Redis grant store's key prefix moved while the i
|
|
|
341
483
|
|
|
342
484
|
it("grants kept in memory and intents on Redis: the grant store's key at its old path, which no loaded module relocates, refused naming the intent store's", () => {
|
|
343
485
|
const operator = operatorLayer('redisFederationGrantStore.keyPrefix = "t1:fg:"\n');
|
|
344
|
-
const { message } = refusal(
|
|
486
|
+
const { message, reason, details } = refusal(
|
|
345
487
|
{ [INTENT]: "t1:fgi:" },
|
|
346
488
|
[redisFederationGrantIntentStoreModule],
|
|
347
489
|
[operator, ...ownFiles("development")],
|
|
348
490
|
);
|
|
491
|
+
expect(reason).toBe("config-path-relocated");
|
|
492
|
+
expect(details).toEqual({
|
|
493
|
+
reason: "config-path-relocated",
|
|
494
|
+
relocated: [
|
|
495
|
+
{
|
|
496
|
+
module: "redis-federation-grant-intent-store",
|
|
497
|
+
from: "redisFederationGrantStore.keyPrefix",
|
|
498
|
+
to: "redis-federation-grant-intent-store.keyPrefix",
|
|
499
|
+
environmentVariable: INTENT,
|
|
500
|
+
},
|
|
501
|
+
],
|
|
502
|
+
});
|
|
349
503
|
expect(message).toContain("redisFederationGrantStore.keyPrefix");
|
|
350
504
|
expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
|
|
351
505
|
expect(message).not.toContain("fg:");
|
|
@@ -363,25 +517,25 @@ describe("both phases read one snapshot of the composition's own layers", () =>
|
|
|
363
517
|
|
|
364
518
|
it("sees a file's first contents in both phases, though it is replaced between them", () => {
|
|
365
519
|
// Mounted configuration is commonly replaced atomically: read twice,
|
|
366
|
-
// boot could parse the
|
|
367
|
-
//
|
|
368
|
-
const operator = operatorLayer(
|
|
520
|
+
// boot could parse the federation-grants routes off while phase one
|
|
521
|
+
// installed them, and nothing would refuse the disagreement.
|
|
522
|
+
const operator = operatorLayer("federation-grants.enabled = true\n");
|
|
369
523
|
const own = readOwnLayers([operator, ...ownFiles("production")], { env });
|
|
370
|
-
writeFileSync(operator,
|
|
524
|
+
writeFileSync(operator, "federation-grants.enabled = false\n");
|
|
371
525
|
const switches = readSwitches(own);
|
|
372
526
|
const resolved = resolveForBoot(own, [], switches);
|
|
373
|
-
expect(valueAt(switches, "
|
|
374
|
-
expect(valueAt(resolved, "
|
|
527
|
+
expect(valueAt(switches, "federation-grants.enabled")).toBe(true);
|
|
528
|
+
expect(valueAt(resolved, "federation-grants.enabled")).toBe(true);
|
|
375
529
|
});
|
|
376
530
|
|
|
377
531
|
it("substitutes one snapshot of the environment in both phases", () => {
|
|
378
|
-
const changing: Record<string, string> = { ...env,
|
|
532
|
+
const changing: Record<string, string> = { ...env, FEDERATION_GRANTS_ENABLED: "true" };
|
|
379
533
|
const own = readOwnLayers(ownFiles("production"), { env: changing });
|
|
380
|
-
changing.
|
|
534
|
+
changing.FEDERATION_GRANTS_ENABLED = "false";
|
|
381
535
|
const switches = readSwitches(own);
|
|
382
536
|
const resolved = resolveForBoot(own, [], switches);
|
|
383
|
-
expect(valueAt(switches, "
|
|
384
|
-
expect(valueAt(resolved, "
|
|
537
|
+
expect(valueAt(switches, "federation-grants.enabled")).toBe(true);
|
|
538
|
+
expect(valueAt(resolved, "federation-grants.enabled")).toBe("true");
|
|
385
539
|
});
|
|
386
540
|
|
|
387
541
|
it("reads the adapters from the same snapshot of the environment", () => {
|
|
@@ -401,21 +555,35 @@ describe("both phases read one snapshot of the composition's own layers", () =>
|
|
|
401
555
|
const own = readOwnLayers(ownFiles(environment), { env: variables });
|
|
402
556
|
const switches = readSwitches(own);
|
|
403
557
|
const modules = buildModules(switches, { environment });
|
|
558
|
+
const resolved = resolveForBoot(own, modules, switches);
|
|
404
559
|
const handle = await createApp({
|
|
405
|
-
|
|
560
|
+
// The oauth module's section alone, read without its factories:
|
|
561
|
+
// the template loads the module, and core refuses keys of
|
|
562
|
+
// `oauth {}` where no loaded module's section is `oauth`.
|
|
563
|
+
modules: [
|
|
564
|
+
defineModule({
|
|
565
|
+
name: oauthEndpointsModule.name,
|
|
566
|
+
...(oauthEndpointsModule.section === undefined
|
|
567
|
+
? {}
|
|
568
|
+
: { section: oauthEndpointsModule.section }),
|
|
569
|
+
}),
|
|
570
|
+
],
|
|
406
571
|
bootstrapComponents: {
|
|
407
|
-
config:
|
|
572
|
+
config: resolved,
|
|
408
573
|
pathResolver: (s: string) => s,
|
|
409
574
|
...FEDERATION_STORES,
|
|
410
575
|
} as never,
|
|
411
576
|
});
|
|
412
577
|
const parsed = handle.components.config;
|
|
413
578
|
await handle.dispose();
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
579
|
+
// Unwritten, the switch reads as off, as the shipped files leave it
|
|
580
|
+
// unless FEDERATION_GRANTS_ENABLED is set.
|
|
581
|
+
expect(valueAt(switches, "federation-grants.enabled"), `${environment}, ${name}`).toBe(
|
|
582
|
+
valueAt(parsed, "federation-grants.enabled") ?? false,
|
|
583
|
+
);
|
|
584
|
+
expect(valueAt(resolved, "core.sessionRequirements"), `${environment}, ${name}`).toEqual(
|
|
585
|
+
valueAt(parsed, "core.sessionRequirements"),
|
|
417
586
|
);
|
|
418
|
-
expect(differing, `${environment}, ${name}`).toEqual([]);
|
|
419
587
|
}
|
|
420
588
|
}
|
|
421
589
|
});
|
|
@@ -429,9 +597,7 @@ describe("both phases read one snapshot of the composition's own layers", () =>
|
|
|
429
597
|
const FEDERATION_STORES = Object.fromEntries(
|
|
430
598
|
[
|
|
431
599
|
"userSessionStore",
|
|
432
|
-
"
|
|
433
|
-
"sessionFamilyIndex",
|
|
434
|
-
"sessionFederationIndex",
|
|
600
|
+
"sessionLifecycle",
|
|
435
601
|
"federationTokenStore",
|
|
436
602
|
"refreshTokenFamilyRevocation",
|
|
437
603
|
].map((key) => [key, {}]),
|