@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
|
@@ -25,18 +25,20 @@
|
|
|
25
25
|
* parse has, which matters because adapter selections have no disagreement
|
|
26
26
|
* guard at boot; `two-phase-config.test.mts` pins this for the shipped
|
|
27
27
|
* environments. A loaded module's own schema may still make something else of
|
|
28
|
-
* a switch at boot.
|
|
28
|
+
* a switch at boot. Beside phase two it builds the configuration's defaults,
|
|
29
|
+
* which boot names the sections nothing loaded reads by
|
|
30
|
+
* (`configDefaultsFor`): the same references, with no file of the
|
|
31
|
+
* composition's and no environment.
|
|
29
32
|
*/
|
|
30
33
|
|
|
31
34
|
import path from "node:path";
|
|
32
35
|
import { fileURLToPath } from "node:url";
|
|
33
|
-
import { isDeepStrictEqual } from "node:util";
|
|
34
36
|
import {
|
|
35
37
|
type AppConfig,
|
|
36
|
-
|
|
38
|
+
type CoreSection,
|
|
39
|
+
coerceBooleanFromEnv,
|
|
37
40
|
type Module,
|
|
38
41
|
moduleReferences,
|
|
39
|
-
readTransitionalConfig,
|
|
40
42
|
} from "@o3co/auth-provider-core";
|
|
41
43
|
import {
|
|
42
44
|
redisFederationGrantIntentStoreModule,
|
|
@@ -44,6 +46,13 @@ import {
|
|
|
44
46
|
} from "@o3co/auth-provider-redis";
|
|
45
47
|
import { type Config, empty, parseFile } from "@o3co/ts.hocon";
|
|
46
48
|
import { ADAPTERS_SECTION, readAdapters } from "./adapters.mjs";
|
|
49
|
+
import {
|
|
50
|
+
configRefused,
|
|
51
|
+
issuesAt,
|
|
52
|
+
keyRefused,
|
|
53
|
+
pathsRelocated,
|
|
54
|
+
sectionPathRefused,
|
|
55
|
+
} from "./bootRefusal.mjs";
|
|
47
56
|
import { MFA_SWITCH, mfaSectionForBoot, oauthForBoot, readMfaSwitch } from "./mfaSwitch.mjs";
|
|
48
57
|
import { loggingModule, templateReference } from "./modules.mjs";
|
|
49
58
|
import { refuseRenamedVariables, SHIPPED_FEDERATION_RENAMES } from "./rootRenames.mjs";
|
|
@@ -135,78 +144,76 @@ export function resolveLayers(own: OwnLayers, references: readonly URL[]): Recor
|
|
|
135
144
|
return layered.toObject() as Record<string, unknown>;
|
|
136
145
|
}
|
|
137
146
|
|
|
138
|
-
/**
|
|
139
|
-
* What the template reads before it knows its modules, through core's
|
|
140
|
-
* reader: the switches `buildModules` and its module factories choose by, and
|
|
141
|
-
* the configuration's `core.sessionRequirements`, which
|
|
142
|
-
* `expectedSessionRequirements` reads (the log level is `readLogging`'s). A
|
|
143
|
-
* module a deployment adds that reads its configuration when it is built adds
|
|
144
|
-
* those paths here, or passes them to `readSwitches` as `reads`.
|
|
145
|
-
* `two-phase-config.test.mts` holds this list to what the template reads.
|
|
146
|
-
*
|
|
147
|
-
* Every path here and in `reads` must be one core's transitional base
|
|
148
|
-
* declares (a section core's schema has, or mirrors for a package), or
|
|
149
|
-
* `readSwitches` refuses it. A deployment module reading, when it is built, a
|
|
150
|
-
* key of its own that core does not mirror gets it as written: raw, an
|
|
151
|
-
* environment variable's string and all, without what only a package's
|
|
152
|
-
* `reference.conf` sets. Parse it in the module, or read it after boot.
|
|
153
|
-
*/
|
|
154
|
-
export const SWITCHES: readonly string[] = [
|
|
155
|
-
"core.sessionRequirements",
|
|
156
|
-
"core.federations",
|
|
157
|
-
"federation-grants.enabled",
|
|
158
|
-
"session-store.storage",
|
|
159
|
-
"oauth-session.enabled",
|
|
160
|
-
"oauth-authorization.grants",
|
|
161
|
-
"oauth.accessToken",
|
|
162
|
-
];
|
|
163
|
-
|
|
164
147
|
/** `core.sessionRequirements`, as the composition declares it to boot. */
|
|
165
|
-
export type SessionRequirements = NonNullable<
|
|
166
|
-
|
|
167
|
-
export interface SwitchesOptions {
|
|
168
|
-
/** Paths read beside `SWITCHES`: what a module a deployment adds reads when it is built. */
|
|
169
|
-
readonly reads?: readonly string[];
|
|
170
|
-
}
|
|
148
|
+
export type SessionRequirements = NonNullable<CoreSection["sessionRequirements"]>;
|
|
171
149
|
|
|
172
150
|
/**
|
|
173
|
-
* What phase one answers
|
|
174
|
-
*
|
|
175
|
-
* and
|
|
176
|
-
*
|
|
177
|
-
* — which the Store-backed MFA factor
|
|
151
|
+
* What phase one answers, from the composition's own layers over the
|
|
152
|
+
* template's `config/reference.conf` alone: the composition root's own
|
|
153
|
+
* `adapters` and `mfaMode`, parsed with the template's schema; the Store
|
|
154
|
+
* transport settings — the user repository's HTTP settings,
|
|
155
|
+
* `repositories.user.http`, unparsed — which the Store-backed MFA factor
|
|
156
|
+
* store is built over; and whether the federation-grants modules are
|
|
157
|
+
* installed (`federationGrantsInstalled`). Nothing else of the configuration
|
|
158
|
+
* is read before boot: a module reads its own section, `deps.section`, at
|
|
159
|
+
* boot.
|
|
178
160
|
*/
|
|
179
|
-
export type Switches =
|
|
161
|
+
export type Switches = {
|
|
180
162
|
readonly adapters: Adapters;
|
|
181
163
|
readonly mfaMode: MfaSwitch;
|
|
182
164
|
readonly storeTransport: unknown;
|
|
165
|
+
readonly "federation-grants"?: { readonly enabled?: boolean };
|
|
183
166
|
};
|
|
184
167
|
|
|
185
168
|
/**
|
|
186
|
-
* Phase one: the
|
|
187
|
-
*
|
|
188
|
-
* `readTransitionalConfig`: each parsed with the schema core declares at its
|
|
189
|
-
* path, everything else as written; and, from its own layers over the
|
|
190
|
-
* template's `config/reference.conf`, the composition root's own `adapters`
|
|
169
|
+
* Phase one: from the composition's own layers over the template's
|
|
170
|
+
* `config/reference.conf`, the composition root's own `adapters`
|
|
191
171
|
* (`readAdapters`), which refuses a selection at the path it moved from and a
|
|
192
172
|
* variable renamed with one, its own MFA switch `mfaMode` (`readMfaSwitch`),
|
|
193
|
-
*
|
|
173
|
+
* the Store transport settings, and whether federation grants are installed
|
|
174
|
+
* (`federationGrantsInstalled`). A variable the template bound for a
|
|
194
175
|
* federation it ships, renamed after the entry's path under
|
|
195
176
|
* `core.federations` (`SHIPPED_FEDERATION_RENAMES`), is refused here too.
|
|
196
|
-
* Use it for those choices only. A
|
|
197
|
-
*
|
|
198
|
-
* own files.
|
|
177
|
+
* Use it for those choices only. A default only a package's reference ships
|
|
178
|
+
* reads as unset here; the template's own files set what it reads.
|
|
199
179
|
*/
|
|
200
|
-
export function readSwitches(own: OwnLayers
|
|
180
|
+
export function readSwitches(own: OwnLayers): Switches {
|
|
201
181
|
const template = resolveLayers(own, [templateReference()]);
|
|
202
182
|
const adapters = readAdapters(template, own.env);
|
|
203
183
|
const mfaMode = readMfaSwitch(template, own.env);
|
|
204
184
|
refuseRenamedVariables(own.env, SHIPPED_FEDERATION_RENAMES);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
185
|
+
return {
|
|
186
|
+
adapters,
|
|
187
|
+
mfaMode,
|
|
188
|
+
storeTransport: storeTransportOf(template),
|
|
189
|
+
"federation-grants": { enabled: federationGrantsInstalled(template) },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether `resolved` installs the federation-grants modules: unless
|
|
195
|
+
* `federation-grants.enabled` reads as `false` with core's
|
|
196
|
+
* `coerceBooleanFromEnv`, or is not written at all. A value that does not
|
|
197
|
+
* parse installs them, so that boot refuses it at `federation-grants.enabled`
|
|
198
|
+
* rather than the feature reading as off; so does a section written as
|
|
199
|
+
* something other than a section, and any setting written at the section's
|
|
200
|
+
* old path, `federationGrants`, which boot refuses naming the new path.
|
|
201
|
+
*/
|
|
202
|
+
function federationGrantsInstalled(resolved: Readonly<Record<string, unknown>>): boolean {
|
|
203
|
+
if (setsAnything(resolved.federationGrants)) return true;
|
|
204
|
+
const section = resolved["federation-grants"];
|
|
205
|
+
if (section === undefined) return false;
|
|
206
|
+
if (!isPlainSection(section)) return true;
|
|
207
|
+
if (section.enabled === undefined) return false;
|
|
208
|
+
const enabled = coerceBooleanFromEnv.safeParse(section.enabled);
|
|
209
|
+
return !(enabled.success && enabled.data === false);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whether `value` sets anything: a value, or a section with one somewhere under it. */
|
|
213
|
+
function setsAnything(value: unknown): boolean {
|
|
214
|
+
if (value === undefined) return false;
|
|
215
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
|
|
216
|
+
return Object.values(value).some(setsAnything);
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
/** `repositories.user.http` in `resolved`, as written; `undefined` when absent. */
|
|
@@ -219,68 +226,85 @@ function storeTransportOf(resolved: Readonly<Record<string, unknown>>): unknown
|
|
|
219
226
|
/**
|
|
220
227
|
* The `logging` module's section, read before boot for the logger: the own
|
|
221
228
|
* layers over the module's reference and core's, parsed with the module's
|
|
222
|
-
* schema. A refused value is a `
|
|
229
|
+
* schema. A refused value is a `config-validation-failed` `BootError` naming
|
|
230
|
+
* its path under `logging`.
|
|
223
231
|
*/
|
|
224
232
|
export function readLogging(own: OwnLayers): LoggingSettings {
|
|
225
233
|
const resolved = resolveLayers(own, moduleReferences([loggingModule]));
|
|
226
234
|
const result = loggingSectionSchema.safeParse(resolved.logging);
|
|
227
235
|
if (!result.success) {
|
|
228
|
-
const issues = result.error.issues;
|
|
229
|
-
throw
|
|
236
|
+
const issues = issuesAt([loggingModule.name], result.error.issues);
|
|
237
|
+
throw configRefused(
|
|
230
238
|
`Config validation failed — ${issues.length} issue(s) found: ${issues
|
|
231
|
-
.map((issue) => `${
|
|
239
|
+
.map((issue) => `${issue.path.map(String).join(".")}: ${issue.message}`)
|
|
232
240
|
.join("; ")}`,
|
|
233
|
-
|
|
241
|
+
issues,
|
|
242
|
+
[{ module: loggingModule.name, schemaPath: loggingModule.name }],
|
|
234
243
|
);
|
|
235
244
|
}
|
|
236
245
|
return result.data;
|
|
237
246
|
}
|
|
238
247
|
|
|
239
248
|
/**
|
|
240
|
-
* What this composition expects of session admission
|
|
241
|
-
* configuration's `core.sessionRequirements`
|
|
242
|
-
*
|
|
243
|
-
* the list does not name it, and
|
|
244
|
-
* beside it — the switch installs the
|
|
245
|
-
* `mfa` as the second-factor
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
249
|
+
* What this composition expects of session admission: what to write over
|
|
250
|
+
* the configuration's `core.sessionRequirements` — `written`, as resolved —
|
|
251
|
+
* when the template's MFA switch, `mfaMode`, is not `off`: the configuration's
|
|
252
|
+
* `expected` with `mfa` appended when the list does not name it, and
|
|
253
|
+
* `secondFactorAuthority` written as `mfa` beside it — the switch installs the
|
|
254
|
+
* MFA module, whose requirement registers `mfa` as the second-factor
|
|
255
|
+
* authority, and boot refuses a declared name nothing registers
|
|
256
|
+
* (`session-requirement-missing`) and a declared authority the requirement of
|
|
257
|
+
* that name does not declare. Every other key written there is kept, for
|
|
258
|
+
* boot to refuse. With nothing written, `mfa` alone. With the switch on, a
|
|
259
|
+
* written `secondFactorAuthority` other than `mfa` is a
|
|
260
|
+
* `config-validation-failed` `BootError` naming the key, `mfaMode` and
|
|
261
|
+
* `MFA_MODE`, quoting nothing.
|
|
262
|
+
*
|
|
263
|
+
* `undefined` hands the section on as written: with the switch `off`, and
|
|
264
|
+
* where `written` is not a section or its `expected` is not a list of names,
|
|
265
|
+
* which boot refuses at its path. Computed here because HOCON has no
|
|
266
|
+
* conditional.
|
|
253
267
|
*/
|
|
254
268
|
export function expectedSessionRequirements(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const authority = written
|
|
269
|
+
written: unknown,
|
|
270
|
+
mfaMode: MfaSwitch,
|
|
271
|
+
): SessionRequirements | undefined {
|
|
272
|
+
if (mfaMode === "off") return undefined;
|
|
273
|
+
if (written === undefined) return { expected: ["mfa"], secondFactorAuthority: "mfa" };
|
|
274
|
+
if (!isPlainSection(written)) return undefined;
|
|
275
|
+
const authority = written.secondFactorAuthority;
|
|
262
276
|
if (authority !== undefined && authority !== "mfa") {
|
|
263
|
-
throw
|
|
277
|
+
throw keyRefused(
|
|
264
278
|
`core.sessionRequirements.secondFactorAuthority is written and names a requirement other than the one ${MFA_SWITCH} (MFA_MODE) installs as the second-factor authority: remove it, or write mfa`,
|
|
279
|
+
"core",
|
|
280
|
+
["core", "sessionRequirements", "secondFactorAuthority"],
|
|
265
281
|
);
|
|
266
282
|
}
|
|
267
|
-
const declared =
|
|
283
|
+
const declared = written.expected;
|
|
284
|
+
if (!isListOfNames(declared)) return undefined;
|
|
268
285
|
return {
|
|
269
|
-
|
|
286
|
+
...written,
|
|
287
|
+
expected: declared.includes("mfa") ? [...declared] : [...declared, "mfa"],
|
|
270
288
|
secondFactorAuthority: "mfa",
|
|
271
289
|
};
|
|
272
290
|
}
|
|
273
291
|
|
|
292
|
+
/** Whether `value` is a list of strings. */
|
|
293
|
+
function isListOfNames(value: unknown): value is readonly string[] {
|
|
294
|
+
return Array.isArray(value) && value.every((name) => typeof name === "string");
|
|
295
|
+
}
|
|
296
|
+
|
|
274
297
|
/**
|
|
275
298
|
* Whether a module in `modules` owns the top-level section `name`: its
|
|
276
|
-
* section sits there
|
|
299
|
+
* section sits there, at the module's name, or it moved from there or under
|
|
300
|
+
* it.
|
|
277
301
|
*/
|
|
278
302
|
function ownsSection(modules: readonly Module[], name: string): boolean {
|
|
279
303
|
const topOf = (path: string): string | undefined => path.split(".")[0];
|
|
280
304
|
return modules.some((module) => {
|
|
281
305
|
const section = module.section;
|
|
282
306
|
if (section === undefined) return false;
|
|
283
|
-
if (
|
|
307
|
+
if (module.name === name) return true;
|
|
284
308
|
const from = section.relocatedFrom;
|
|
285
309
|
const old = from === undefined ? [] : Array.isArray(from) ? from : Object.keys(from);
|
|
286
310
|
return old.some((path) => topOf(path) === name);
|
|
@@ -298,7 +322,7 @@ function keyPrefixOf(
|
|
|
298
322
|
}
|
|
299
323
|
|
|
300
324
|
/**
|
|
301
|
-
* Refuses, with a `
|
|
325
|
+
* Refuses, with a `BootError`, a Redis intent store left on the default key
|
|
302
326
|
* prefix where the grant store's was moved, when `modules` load the Redis
|
|
303
327
|
* intent store. The intent store's prefix is its own key, so a deployment
|
|
304
328
|
* that moved only the grant store's would keep acquisition's records in the
|
|
@@ -313,8 +337,10 @@ function keyPrefixOf(
|
|
|
313
337
|
* written at all: the intent store read that key, and no loaded module
|
|
314
338
|
* relocates it.
|
|
315
339
|
*
|
|
316
|
-
* The
|
|
317
|
-
*
|
|
340
|
+
* The first is `config-validation-failed` at the intent store's key; the
|
|
341
|
+
* second `config-path-relocated`, from the old key to the intent store's. The
|
|
342
|
+
* defaults are what the package's `reference.conf` sets with no environment.
|
|
343
|
+
* The message names the keys and variables, and quotes no value.
|
|
318
344
|
*/
|
|
319
345
|
function refuseIntentPrefixLeftAtDefault(
|
|
320
346
|
resolved: Readonly<Record<string, unknown>>,
|
|
@@ -325,14 +351,23 @@ function refuseIntentPrefixLeftAtDefault(
|
|
|
325
351
|
const loaded = (module: Module) => modules.some((m) => m.name === module.name);
|
|
326
352
|
const reference = intentStore.section?.reference;
|
|
327
353
|
if (!loaded(intentStore) || reference === undefined) return;
|
|
328
|
-
const
|
|
329
|
-
|
|
354
|
+
const intentPath = `${intentStore.name}.keyPrefix`;
|
|
355
|
+
const intentVariable = "REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX";
|
|
356
|
+
const intentKey = `${intentPath} (${intentVariable})`;
|
|
330
357
|
const oldSection = resolved.redisFederationGrantStore;
|
|
331
358
|
if (!loaded(grantStore) && isPlainSection(oldSection) && Object.hasOwn(oldSection, "keyPrefix")) {
|
|
332
|
-
throw
|
|
359
|
+
throw pathsRelocated(
|
|
333
360
|
"redisFederationGrantStore.keyPrefix is set, and no installed module reads it: the Redis " +
|
|
334
361
|
`intent store's prefix is its own key, ${intentKey}. Set that instead, and delete ` +
|
|
335
362
|
"redisFederationGrantStore.keyPrefix",
|
|
363
|
+
[
|
|
364
|
+
{
|
|
365
|
+
module: intentStore.name,
|
|
366
|
+
from: "redisFederationGrantStore.keyPrefix",
|
|
367
|
+
to: intentPath,
|
|
368
|
+
environmentVariable: intentVariable,
|
|
369
|
+
},
|
|
370
|
+
],
|
|
336
371
|
);
|
|
337
372
|
}
|
|
338
373
|
const defaults = resolveLayers({ config: empty(), env: {} }, [reference]);
|
|
@@ -342,12 +377,14 @@ function refuseIntentPrefixLeftAtDefault(
|
|
|
342
377
|
if (intent === undefined || intent !== keyPrefixOf(defaults, intentStore)) return;
|
|
343
378
|
const grantKey =
|
|
344
379
|
"redis-federation-grant-store.keyPrefix (REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX)";
|
|
345
|
-
throw
|
|
380
|
+
throw keyRefused(
|
|
346
381
|
`${grantKey} is set off its default key prefix, and ${intentKey} is left at its ` +
|
|
347
382
|
"default. The Redis intent store's prefix is its own: acquisition's records would " +
|
|
348
383
|
"stay in the default namespace, shared with every deployment on the same Redis " +
|
|
349
384
|
`database that left it there. Set ${intentKey} as well — to the grant store's prefix ` +
|
|
350
385
|
"to keep the two together, or to one of its own",
|
|
386
|
+
intentStore.name,
|
|
387
|
+
[intentStore.name, "keyPrefix"],
|
|
351
388
|
);
|
|
352
389
|
}
|
|
353
390
|
|
|
@@ -358,67 +395,44 @@ function refuseIntentPrefixLeftAtDefault(
|
|
|
358
395
|
const ROOT_SECTIONS: readonly string[] = [ADAPTERS_SECTION, MFA_SWITCH];
|
|
359
396
|
|
|
360
397
|
/**
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* layer or the environment changed is handed on, and boot names it once as a
|
|
366
|
-
* section nothing owns.
|
|
367
|
-
*/
|
|
368
|
-
function unownedTemplateDefaults(
|
|
369
|
-
resolved: Readonly<Record<string, unknown>>,
|
|
370
|
-
modules: readonly Module[],
|
|
371
|
-
): readonly string[] {
|
|
372
|
-
const defaults = resolveLayers({ config: empty(), env: {} }, [templateReference()]);
|
|
373
|
-
return Object.keys(defaults).filter(
|
|
374
|
-
(name) =>
|
|
375
|
-
name !== RENAMED_VARIABLES &&
|
|
376
|
-
!ROOT_SECTIONS.includes(name) &&
|
|
377
|
-
!ownsSection(modules, name) &&
|
|
378
|
-
isDeepStrictEqual(resolved[name], defaults[name]),
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Refuses, with a `RangeError` naming it, a module whose section is at or
|
|
384
|
-
* under one of the composition root's own sections, which phase one consumes
|
|
385
|
-
* and boot is never handed: the module would read nothing its operator wrote.
|
|
398
|
+
* Refuses, with a `module-section-path-invalid` `BootError` naming it, a
|
|
399
|
+
* module whose section — at the module's name — is one of the composition
|
|
400
|
+
* root's own sections, which phase one consumes and boot is never handed:
|
|
401
|
+
* the module would read nothing its operator wrote.
|
|
386
402
|
*/
|
|
387
403
|
function refuseModuleAtRootSections(modules: readonly Module[]): void {
|
|
388
404
|
for (const module of modules) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
405
|
+
if (module.section === undefined || !ROOT_SECTIONS.includes(module.name)) continue;
|
|
406
|
+
const problem = "the composition root's own section, which boot is not handed, is read there";
|
|
407
|
+
throw sectionPathRefused(
|
|
408
|
+
`Module "${module.name}" has its section at ${module.name}: the composition root's own section, which boot is not handed. Give the module a section of another name.`,
|
|
409
|
+
module.name,
|
|
410
|
+
module.name,
|
|
411
|
+
problem,
|
|
412
|
+
);
|
|
398
413
|
}
|
|
399
414
|
}
|
|
400
415
|
|
|
401
|
-
/** The section a resolution captures renamed variables in: never left out. */
|
|
402
|
-
const RENAMED_VARIABLES = "renamed-variables";
|
|
403
|
-
|
|
404
416
|
/**
|
|
405
417
|
* Phase two: what `createApp` parses once, with every loaded module's schema:
|
|
406
418
|
* the composition's own layers, the same read phase one had, over the
|
|
407
419
|
* `reference.conf` of every package `modules` come from, core's last,
|
|
408
|
-
* resolved and unparsed, with `core.sessionRequirements` — what
|
|
409
|
-
*
|
|
410
|
-
* the resolved section when there is one to write, and the
|
|
411
|
-
* the `oauth` acr table as the template's MFA switch
|
|
412
|
-
* (`mfaSectionForBoot`, `oauthForBoot`). Left out:
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* (`
|
|
420
|
+
* resolved and unparsed, with `core.sessionRequirements` — what the
|
|
421
|
+
* composition expects under the MFA switch (`expectedSessionRequirements`) —
|
|
422
|
+
* written over the resolved section when there is one to write, and the
|
|
423
|
+
* `mfa` section and the `oauth` acr table as the template's MFA switch
|
|
424
|
+
* decides them (`mfaSectionForBoot`, `oauthForBoot`). Left out: `adapters`
|
|
425
|
+
* and `mfaMode`, the composition root's own keys, which phase one consumed.
|
|
426
|
+
* A section a loaded package's `reference.conf` sets for a module the
|
|
427
|
+
* composition does not load is handed on as resolved: boot tells it apart by
|
|
428
|
+
* the configuration's defaults (`configDefaultsFor`).
|
|
417
429
|
*
|
|
418
|
-
* Refuses, with a `
|
|
419
|
-
* the composition root's
|
|
420
|
-
* `
|
|
421
|
-
*
|
|
430
|
+
* Refuses, each with a `BootError` (`bootRefusal.mts`), a module whose
|
|
431
|
+
* section is a section of the composition root's
|
|
432
|
+
* (`refuseModuleAtRootSections`), what
|
|
433
|
+
* `expectedSessionRequirements` refuses of `core.sessionRequirements`, what
|
|
434
|
+
* `mfaSectionForBoot` refuses of the `mfa` section, and the Redis intent
|
|
435
|
+
* store left on its default key prefix where the grant store's was moved
|
|
422
436
|
* (`refuseIntentPrefixLeftAtDefault`).
|
|
423
437
|
*
|
|
424
438
|
* Typed `AppConfig` because that is the `config` slot's type; read the parsed
|
|
@@ -427,13 +441,14 @@ const RENAMED_VARIABLES = "renamed-variables";
|
|
|
427
441
|
export function resolveForBoot(
|
|
428
442
|
own: OwnLayers,
|
|
429
443
|
modules: readonly Module[],
|
|
430
|
-
switches: Pick<Switches, "
|
|
444
|
+
switches: Pick<Switches, "mfaMode" | "adapters">,
|
|
431
445
|
): AppConfig {
|
|
432
446
|
refuseModuleAtRootSections(modules);
|
|
433
447
|
const all = resolveLayers(own, moduleReferences(modules));
|
|
434
448
|
refuseIntentPrefixLeftAtDefault(all, modules);
|
|
435
|
-
const
|
|
436
|
-
|
|
449
|
+
const layered = Object.fromEntries(
|
|
450
|
+
Object.entries(all).filter(([name]) => !ROOT_SECTIONS.includes(name)),
|
|
451
|
+
);
|
|
437
452
|
const { mfa: _decided, ...resolved } = layered;
|
|
438
453
|
const mfa = mfaSectionForBoot({
|
|
439
454
|
mode: switches.mfaMode,
|
|
@@ -444,17 +459,29 @@ export function resolveForBoot(
|
|
|
444
459
|
switches.adapters.userRepository === "http" || switches.adapters.mfaFactorStore === "store",
|
|
445
460
|
env: own.env,
|
|
446
461
|
});
|
|
447
|
-
|
|
462
|
+
// `core` written as something other than a section is handed on as
|
|
463
|
+
// written, for boot to refuse.
|
|
464
|
+
const core = isPlainSection(resolved.core) ? resolved.core : undefined;
|
|
465
|
+
const sessionRequirements =
|
|
466
|
+
resolved.core === undefined || core !== undefined
|
|
467
|
+
? expectedSessionRequirements(core?.sessionRequirements, switches.mfaMode)
|
|
468
|
+
: undefined;
|
|
448
469
|
return {
|
|
449
470
|
...resolved,
|
|
450
471
|
...(mfa === undefined ? {} : { mfa }),
|
|
451
472
|
...(resolved.oauth === undefined
|
|
452
473
|
? {}
|
|
453
474
|
: { oauth: oauthForBoot(switches.mfaMode, resolved.oauth) }),
|
|
454
|
-
...(sessionRequirements === undefined
|
|
455
|
-
? {}
|
|
456
|
-
: {
|
|
457
|
-
core: { ...(resolved.core as Record<string, unknown> | undefined), sessionRequirements },
|
|
458
|
-
}),
|
|
475
|
+
...(sessionRequirements === undefined ? {} : { core: { ...core, sessionRequirements } }),
|
|
459
476
|
} as unknown as AppConfig;
|
|
460
477
|
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The configuration's defaults, which boot names the sections nothing loaded
|
|
481
|
+
* reads by (`bootstrapComponents.configDefaults`): the `reference.conf` of
|
|
482
|
+
* every package `modules` come from, layered as `resolveForBoot` layers them
|
|
483
|
+
* (core's last), with no file of the composition's and no environment.
|
|
484
|
+
*/
|
|
485
|
+
export function configDefaultsFor(modules: readonly Module[]): Record<string, unknown> {
|
|
486
|
+
return resolveLayers({ config: empty(), env: {} }, moduleReferences(modules));
|
|
487
|
+
}
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
* it; on installs the MFA package's modules over the two MFA stores
|
|
23
23
|
* `adapters` selects.
|
|
24
24
|
*
|
|
25
|
-
* Refuses, each with a `
|
|
26
|
-
*
|
|
25
|
+
* Refuses, each before boot with a `config-validation-failed` `BootError`
|
|
26
|
+
* (`bootRefusal.mts`) that names the keys and variables and quotes no value
|
|
27
|
+
* of a mode, a timeout or a key:
|
|
27
28
|
* - a switch outside its three values, or a file's `mfaMode` that `MFA_MODE`
|
|
28
29
|
* contradicts (`readMfaSwitch`);
|
|
29
30
|
* - an MFA store kept in memory with MFA on, unless every environment name
|
|
@@ -50,6 +51,8 @@ import {
|
|
|
50
51
|
redisMfaTransactionStoreModule,
|
|
51
52
|
} from "@o3co/auth-provider-redis";
|
|
52
53
|
import { loginCompletionModule } from "@o3co/auth-provider-session";
|
|
54
|
+
import { ADAPTERS_SECTION } from "./adapters.mjs";
|
|
55
|
+
import { configRefused, customIssue, issuesAt, keyRefused } from "./bootRefusal.mjs";
|
|
53
56
|
import { type Adapters, isPlainSection, type MfaSwitch, mfaSwitchSchema } from "./sections.mjs";
|
|
54
57
|
|
|
55
58
|
/** The composition root's MFA switch, a key of its own. No module may be named after it. */
|
|
@@ -66,7 +69,7 @@ const MFA_ACR = "urn:o3co:acr:mfa";
|
|
|
66
69
|
* `config/reference.conf` — parsed with the template's schema, under `env`,
|
|
67
70
|
* the environment the layers were substituted with. A value the schema
|
|
68
71
|
* refuses, or `MFA_MODE` set to other than what a file writes over it, is a
|
|
69
|
-
* `
|
|
72
|
+
* `config-validation-failed` `BootError` naming `mfaMode` and `MFA_MODE`.
|
|
70
73
|
*/
|
|
71
74
|
export function readMfaSwitch(
|
|
72
75
|
resolved: Readonly<Record<string, unknown>>,
|
|
@@ -74,17 +77,20 @@ export function readMfaSwitch(
|
|
|
74
77
|
): MfaSwitch {
|
|
75
78
|
const result = mfaSwitchSchema.safeParse(resolved[MFA_SWITCH]);
|
|
76
79
|
if (!result.success) {
|
|
77
|
-
throw
|
|
80
|
+
throw configRefused(
|
|
78
81
|
`Config validation failed — ${MFA_SWITCH} (MFA_MODE): ${result.error.issues
|
|
79
82
|
.map((issue) => issue.message)
|
|
80
83
|
.join("; ")}`,
|
|
81
|
-
|
|
84
|
+
issuesAt([MFA_SWITCH], result.error.issues),
|
|
85
|
+
[{ module: MFA_SWITCH, schemaPath: MFA_SWITCH }],
|
|
82
86
|
);
|
|
83
87
|
}
|
|
84
88
|
const variable = env.MFA_MODE;
|
|
85
89
|
if (variable !== undefined && variable !== result.data) {
|
|
86
|
-
throw
|
|
90
|
+
throw keyRefused(
|
|
87
91
|
`MFA_MODE is set and differs from ${MFA_SWITCH}, which a configuration file writes over it: the file, not the variable, would decide whether MFA is installed. Remove ${MFA_SWITCH} from the file, or set MFA_MODE to what it says`,
|
|
92
|
+
MFA_SWITCH,
|
|
93
|
+
[MFA_SWITCH],
|
|
88
94
|
);
|
|
89
95
|
}
|
|
90
96
|
return result.data;
|
|
@@ -134,18 +140,27 @@ export function mfaModulesFor(options: {
|
|
|
134
140
|
}): Module[] {
|
|
135
141
|
if (options.mode === "off") return [];
|
|
136
142
|
const { adapters, environment } = options;
|
|
137
|
-
const inMemory =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
: []),
|
|
144
|
-
];
|
|
143
|
+
const inMemory = (
|
|
144
|
+
[
|
|
145
|
+
["mfaFactorStore", "ADAPTERS_MFA_FACTOR_STORE"],
|
|
146
|
+
["mfaTransactionStore", "ADAPTERS_MFA_TRANSACTION_STORE"],
|
|
147
|
+
] as const
|
|
148
|
+
).filter(([key]) => adapters[key] === "memory");
|
|
145
149
|
const reasons = inMemory.length === 0 ? [] : memoryRefusals(environment);
|
|
146
150
|
if (reasons.length > 0) {
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
const refused = `"memory", refused because ${reasons.join(" and ")}`;
|
|
152
|
+
const settings = inMemory
|
|
153
|
+
.map(([key, variable]) => `${ADAPTERS_SECTION}.${key} (${variable})`)
|
|
154
|
+
.join(" and ");
|
|
155
|
+
throw configRefused(
|
|
156
|
+
`MFA is on (${MFA_SWITCH}, MFA_MODE, which installs it unless set to off) and ${settings} ${inMemory.length === 1 ? "is" : "are"} ${refused}: a store in memory loses every factor, lock and recorded email proof at a restart, after which whoever holds a password can bind a factor of their own. Select "redis" (or, for the factors, "store"); memory is for development and test alone. Outside development MFA also needs MFA_ENCRYPTION_KEY, the SMTP relay (STANDARD_SMTP_MAIL_SENDER_HOST and STANDARD_SMTP_MAIL_SENDER_FROM) or your own mail sender, and an MFA page served at MFA_PAGE_URL. A deployment that wants no MFA sets MFA_MODE=off`,
|
|
157
|
+
inMemory.map(([key, variable]) =>
|
|
158
|
+
customIssue(
|
|
159
|
+
[ADAPTERS_SECTION, key],
|
|
160
|
+
`${variable} is ${refused}, with MFA on (${MFA_SWITCH}, MFA_MODE)`,
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
[{ module: ADAPTERS_SECTION, schemaPath: ADAPTERS_SECTION }],
|
|
149
164
|
);
|
|
150
165
|
}
|
|
151
166
|
return [
|
|
@@ -202,14 +217,18 @@ export function mfaSectionForBoot(options: {
|
|
|
202
217
|
}): unknown {
|
|
203
218
|
const { mode, written, resolved } = options;
|
|
204
219
|
if (written !== undefined && !isPlainSection(written)) {
|
|
205
|
-
throw
|
|
220
|
+
throw keyRefused(
|
|
206
221
|
`mfa is written as a value in the configuration; it is the MFA package's section, and ${MFA_SWITCH} (MFA_MODE) decides whether the template installs MFA. Remove mfa, and set MFA_MODE or ${MFA_SWITCH}`,
|
|
222
|
+
MFA_SECTION,
|
|
223
|
+
[MFA_SECTION],
|
|
207
224
|
);
|
|
208
225
|
}
|
|
209
226
|
const writtenMode = written === undefined ? undefined : written.mode;
|
|
210
227
|
if (writtenMode !== undefined && writtenMode !== mode) {
|
|
211
|
-
throw
|
|
228
|
+
throw keyRefused(
|
|
212
229
|
`mfa.mode is written in the configuration and differs from ${MFA_SWITCH} (MFA_MODE), which decides whether the template installs MFA and writes mfa.mode from it. Set MFA_MODE or ${MFA_SWITCH}, and remove mfa.mode`,
|
|
230
|
+
MFA_SECTION,
|
|
231
|
+
[MFA_SECTION, "mode"],
|
|
213
232
|
);
|
|
214
233
|
}
|
|
215
234
|
if (!options.owned) return undefined;
|
|
@@ -223,8 +242,10 @@ export function mfaSectionForBoot(options: {
|
|
|
223
242
|
variableKey !== MFA_DEVELOPMENT_SAMPLE_KEY &&
|
|
224
243
|
ring.some((entry) => valueAt(entry, ["key"]) === MFA_DEVELOPMENT_SAMPLE_KEY)
|
|
225
244
|
) {
|
|
226
|
-
throw
|
|
245
|
+
throw keyRefused(
|
|
227
246
|
"MFA_ENCRYPTION_KEY is set, and mfa.encryptionKeys holds the development sample key in its place: a ring a configuration file writes (config/development.conf's) wins over the variable, so the key you set would seal nothing. Write your key in place of the sample key in that ring, or unset MFA_ENCRYPTION_KEY",
|
|
247
|
+
MFA_SECTION,
|
|
248
|
+
[MFA_SECTION, "encryptionKeys"],
|
|
228
249
|
);
|
|
229
250
|
}
|
|
230
251
|
const storeTimeout = wholeNumber(section.storeTimeoutMs);
|
|
@@ -235,8 +256,10 @@ export function mfaSectionForBoot(options: {
|
|
|
235
256
|
userTimeout !== undefined &&
|
|
236
257
|
storeTimeout < userTimeout
|
|
237
258
|
) {
|
|
238
|
-
throw
|
|
259
|
+
throw keyRefused(
|
|
239
260
|
"mfa.storeTimeoutMs (MFA_STORE_TIMEOUT_MS) is below repositories.user.http.timeout (REPOSITORIES_USER_HTTP_TIMEOUT): one MFA Store call's time must cover the Store's own per-call timeout, or a factor-set write's lease can lapse while a Store call is still running. Raise MFA_STORE_TIMEOUT_MS to at least the user directory's timeout",
|
|
261
|
+
MFA_SECTION,
|
|
262
|
+
[MFA_SECTION, "storeTimeoutMs"],
|
|
240
263
|
);
|
|
241
264
|
}
|
|
242
265
|
return { ...section, mode };
|