@o3co/create-auth-provider 0.16.0 → 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/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -29
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +324 -169
- package/templates/standalone/README.md +618 -208
- package/templates/standalone/config/application.conf +211 -352
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +418 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +63 -32
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
- package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
- package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
- package/templates/standalone/src/adapters.mts +162 -0
- package/templates/standalone/src/app.mts +65 -79
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +220 -309
- package/templates/standalone/src/configPath.mts +449 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +285 -0
- package/templates/standalone/src/modules.mts +423 -629
- package/templates/standalone/src/rootRenames.mts +96 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +367 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +6 -5
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
|
@@ -14,43 +14,61 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Where the template's configuration comes from: the composition's own layers
|
|
19
|
+
* (`{env}.conf` over `application.conf`), read once by `readOwnLayers` under
|
|
20
|
+
* one snapshot of the environment, and what is built from that read: the two
|
|
21
|
+
* phases, `readSwitches` and `resolveForBoot`, and the `logging` module's
|
|
22
|
+
* section the logger is built from, `readLogging` (template README,
|
|
23
|
+
* "Environment-specific config overlay"). So a file or variable changed
|
|
24
|
+
* during startup cannot split a switch phase one reads from the value boot's
|
|
25
|
+
* parse has, which matters because adapter selections have no disagreement
|
|
26
|
+
* guard at boot; `two-phase-config.test.mts` pins this for the shipped
|
|
27
|
+
* environments. A loaded module's own schema may still make something else of
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
|
|
18
34
|
import path from "node:path";
|
|
19
35
|
import { fileURLToPath } from "node:url";
|
|
36
|
+
import {
|
|
37
|
+
type AppConfig,
|
|
38
|
+
type CoreSection,
|
|
39
|
+
coerceBooleanFromEnv,
|
|
40
|
+
type Module,
|
|
41
|
+
moduleReferences,
|
|
42
|
+
} from "@o3co/auth-provider-core";
|
|
43
|
+
import {
|
|
44
|
+
redisFederationGrantIntentStoreModule,
|
|
45
|
+
redisFederationGrantStoreModule,
|
|
46
|
+
} from "@o3co/auth-provider-redis";
|
|
47
|
+
import { type Config, empty, parseFile } from "@o3co/ts.hocon";
|
|
48
|
+
import { ADAPTERS_SECTION, readAdapters } from "./adapters.mjs";
|
|
49
|
+
import {
|
|
50
|
+
configRefused,
|
|
51
|
+
issuesAt,
|
|
52
|
+
keyRefused,
|
|
53
|
+
pathsRelocated,
|
|
54
|
+
sectionPathRefused,
|
|
55
|
+
} from "./bootRefusal.mjs";
|
|
56
|
+
import { MFA_SWITCH, mfaSectionForBoot, oauthForBoot, readMfaSwitch } from "./mfaSwitch.mjs";
|
|
57
|
+
import { loggingModule, templateReference } from "./modules.mjs";
|
|
58
|
+
import { refuseRenamedVariables, SHIPPED_FEDERATION_RENAMES } from "./rootRenames.mjs";
|
|
59
|
+
import {
|
|
60
|
+
type Adapters,
|
|
61
|
+
isPlainSection,
|
|
62
|
+
type LoggingSettings,
|
|
63
|
+
loggingSectionSchema,
|
|
64
|
+
type MfaSwitch,
|
|
65
|
+
} from "./sections.mjs";
|
|
20
66
|
|
|
21
67
|
export interface ResolvedConfigPaths {
|
|
22
68
|
readonly applicationConfPath: string;
|
|
23
69
|
readonly envConfPath: string;
|
|
24
70
|
}
|
|
25
71
|
|
|
26
|
-
const REFERENCE_CONF_SUBPATH = "@o3co/auth-provider-core/reference.conf";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Returns the absolute path to the shipped `reference.conf` inside
|
|
30
|
-
* `@o3co/auth-provider-core`. This file is the bottom layer of the
|
|
31
|
-
* 3-tier HOCON precedence chain (reference.conf → application.conf → {env}.conf).
|
|
32
|
-
*
|
|
33
|
-
* Primary path: `import.meta.resolve` (Node.js Stability 1.2 RC, unflagged
|
|
34
|
-
* since Node 18.19.0 / 20.6.0 — well below this scope's Node 22 engine floor,
|
|
35
|
-
* so it is always available). Fallback: `createRequire(import.meta.url).resolve(...)`. The
|
|
36
|
-
* fallback covers two edge cases:
|
|
37
|
-
*
|
|
38
|
-
* 1. A future Node release deprecates or alters the sync form of
|
|
39
|
-
* `import.meta.resolve` (still labelled Stability 1.2 RC per Node docs).
|
|
40
|
-
* 2. An exotic runtime / loader where `import.meta.resolve` is not
|
|
41
|
-
* available but CommonJS-style resolution still is.
|
|
42
|
-
*
|
|
43
|
-
* Both APIs read the same `exports` map in `@o3co/auth-provider-core`'s
|
|
44
|
-
* `package.json`, so the resolved path is identical.
|
|
45
|
-
*/
|
|
46
|
-
export function resolveLibraryReferenceConfPath(): string {
|
|
47
|
-
try {
|
|
48
|
-
return fileURLToPath(import.meta.resolve(REFERENCE_CONF_SUBPATH));
|
|
49
|
-
} catch {
|
|
50
|
-
return createRequire(import.meta.url).resolve(REFERENCE_CONF_SUBPATH);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
72
|
export function resolveConfigPaths(configDirPath: string, env: string): ResolvedConfigPaths {
|
|
55
73
|
// path.resolve strips trailing slashes that fileURLToPath may preserve, so
|
|
56
74
|
// the containment check below compares equal shapes (path.dirname never
|
|
@@ -65,3 +83,405 @@ export function resolveConfigPaths(configDirPath: string, env: string): Resolved
|
|
|
65
83
|
}
|
|
66
84
|
return { applicationConfPath, envConfPath };
|
|
67
85
|
}
|
|
86
|
+
|
|
87
|
+
export interface ResolveOptions {
|
|
88
|
+
/**
|
|
89
|
+
* The environment `${?VAR}` substitutions read — a snapshot of the
|
|
90
|
+
* process's when unset. Tests pass their own.
|
|
91
|
+
*/
|
|
92
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The composition's own layers, read once: its files parsed, and the
|
|
97
|
+
* environment snapshot every layer's `${?VAR}` substitutes. Both phases are
|
|
98
|
+
* built from this one read.
|
|
99
|
+
*/
|
|
100
|
+
export interface OwnLayers {
|
|
101
|
+
/** The composition's own files, highest first, parsed and layered. */
|
|
102
|
+
readonly config: Config;
|
|
103
|
+
/** The environment the files were substituted with; the references are too. */
|
|
104
|
+
readonly env: Readonly<Record<string, string>>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read the composition's own files — highest first, `{env}.conf` then
|
|
109
|
+
* `application.conf` — once, under one snapshot of the environment
|
|
110
|
+
* (`options.env`, or the process's as it is now).
|
|
111
|
+
*/
|
|
112
|
+
export function readOwnLayers(
|
|
113
|
+
ownFiles: readonly string[],
|
|
114
|
+
options: ResolveOptions = {},
|
|
115
|
+
): OwnLayers {
|
|
116
|
+
const env: Readonly<Record<string, string>> = {
|
|
117
|
+
...(options.env ??
|
|
118
|
+
Object.fromEntries(
|
|
119
|
+
Object.entries(process.env).filter(
|
|
120
|
+
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
121
|
+
),
|
|
122
|
+
)),
|
|
123
|
+
};
|
|
124
|
+
const config = ownFiles.reduce<Config>(
|
|
125
|
+
(layered, file) => layered.withFallback(parseFile(file, { env: { ...env } })),
|
|
126
|
+
empty(),
|
|
127
|
+
);
|
|
128
|
+
return { config, env };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The composition's configuration, resolved to plain data and parsed by
|
|
133
|
+
* nothing: its own layers, read once (`readOwnLayers`), over
|
|
134
|
+
* `references`, the `reference.conf` files of the packages it loads, in the
|
|
135
|
+
* order `moduleReferences` answers them (core's last), substituted with the
|
|
136
|
+
* same environment. A path set in two layers takes the higher one's value.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveLayers(own: OwnLayers, references: readonly URL[]): Record<string, unknown> {
|
|
139
|
+
const layered = references.reduce<Config>(
|
|
140
|
+
(config, reference) =>
|
|
141
|
+
config.withFallback(parseFile(fileURLToPath(reference), { env: { ...own.env } })),
|
|
142
|
+
own.config,
|
|
143
|
+
);
|
|
144
|
+
return layered.toObject() as Record<string, unknown>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `core.sessionRequirements`, as the composition declares it to boot. */
|
|
148
|
+
export type SessionRequirements = NonNullable<CoreSection["sessionRequirements"]>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
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.
|
|
160
|
+
*/
|
|
161
|
+
export type Switches = {
|
|
162
|
+
readonly adapters: Adapters;
|
|
163
|
+
readonly mfaMode: MfaSwitch;
|
|
164
|
+
readonly storeTransport: unknown;
|
|
165
|
+
readonly "federation-grants"?: { readonly enabled?: boolean };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Phase one: from the composition's own layers over the template's
|
|
170
|
+
* `config/reference.conf`, the composition root's own `adapters`
|
|
171
|
+
* (`readAdapters`), which refuses a selection at the path it moved from and a
|
|
172
|
+
* variable renamed with one, its own MFA switch `mfaMode` (`readMfaSwitch`),
|
|
173
|
+
* the Store transport settings, and whether federation grants are installed
|
|
174
|
+
* (`federationGrantsInstalled`). A variable the template bound for a
|
|
175
|
+
* federation it ships, renamed after the entry's path under
|
|
176
|
+
* `core.federations` (`SHIPPED_FEDERATION_RENAMES`), is refused here too.
|
|
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.
|
|
179
|
+
*/
|
|
180
|
+
export function readSwitches(own: OwnLayers): Switches {
|
|
181
|
+
const template = resolveLayers(own, [templateReference()]);
|
|
182
|
+
const adapters = readAdapters(template, own.env);
|
|
183
|
+
const mfaMode = readMfaSwitch(template, own.env);
|
|
184
|
+
refuseRenamedVariables(own.env, SHIPPED_FEDERATION_RENAMES);
|
|
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);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `repositories.user.http` in `resolved`, as written; `undefined` when absent. */
|
|
220
|
+
function storeTransportOf(resolved: Readonly<Record<string, unknown>>): unknown {
|
|
221
|
+
const repositories = resolved.repositories;
|
|
222
|
+
const user = isPlainSection(repositories) ? repositories.user : undefined;
|
|
223
|
+
return isPlainSection(user) ? user.http : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The `logging` module's section, read before boot for the logger: the own
|
|
228
|
+
* layers over the module's reference and core's, parsed with the module's
|
|
229
|
+
* schema. A refused value is a `config-validation-failed` `BootError` naming
|
|
230
|
+
* its path under `logging`.
|
|
231
|
+
*/
|
|
232
|
+
export function readLogging(own: OwnLayers): LoggingSettings {
|
|
233
|
+
const resolved = resolveLayers(own, moduleReferences([loggingModule]));
|
|
234
|
+
const result = loggingSectionSchema.safeParse(resolved.logging);
|
|
235
|
+
if (!result.success) {
|
|
236
|
+
const issues = issuesAt([loggingModule.name], result.error.issues);
|
|
237
|
+
throw configRefused(
|
|
238
|
+
`Config validation failed — ${issues.length} issue(s) found: ${issues
|
|
239
|
+
.map((issue) => `${issue.path.map(String).join(".")}: ${issue.message}`)
|
|
240
|
+
.join("; ")}`,
|
|
241
|
+
issues,
|
|
242
|
+
[{ module: loggingModule.name, schemaPath: loggingModule.name }],
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return result.data;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
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.
|
|
267
|
+
*/
|
|
268
|
+
export function expectedSessionRequirements(
|
|
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;
|
|
276
|
+
if (authority !== undefined && authority !== "mfa") {
|
|
277
|
+
throw keyRefused(
|
|
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"],
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const declared = written.expected;
|
|
284
|
+
if (!isListOfNames(declared)) return undefined;
|
|
285
|
+
return {
|
|
286
|
+
...written,
|
|
287
|
+
expected: declared.includes("mfa") ? [...declared] : [...declared, "mfa"],
|
|
288
|
+
secondFactorAuthority: "mfa",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
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
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Whether a module in `modules` owns the top-level section `name`: its
|
|
299
|
+
* section sits there, at the module's name, or it moved from there or under
|
|
300
|
+
* it.
|
|
301
|
+
*/
|
|
302
|
+
function ownsSection(modules: readonly Module[], name: string): boolean {
|
|
303
|
+
const topOf = (path: string): string | undefined => path.split(".")[0];
|
|
304
|
+
return modules.some((module) => {
|
|
305
|
+
const section = module.section;
|
|
306
|
+
if (section === undefined) return false;
|
|
307
|
+
if (module.name === name) return true;
|
|
308
|
+
const from = section.relocatedFrom;
|
|
309
|
+
const old = from === undefined ? [] : Array.isArray(from) ? from : Object.keys(from);
|
|
310
|
+
return old.some((path) => topOf(path) === name);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A section's `keyPrefix`, when the section is one of keys and the prefix a string. */
|
|
315
|
+
function keyPrefixOf(
|
|
316
|
+
config: Readonly<Record<string, unknown>>,
|
|
317
|
+
module: Module,
|
|
318
|
+
): string | undefined {
|
|
319
|
+
const section = config[module.name];
|
|
320
|
+
const prefix = isPlainSection(section) ? section.keyPrefix : undefined;
|
|
321
|
+
return typeof prefix === "string" ? prefix : undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Refuses, with a `BootError`, a Redis intent store left on the default key
|
|
326
|
+
* prefix where the grant store's was moved, when `modules` load the Redis
|
|
327
|
+
* intent store. The intent store's prefix is its own key, so a deployment
|
|
328
|
+
* that moved only the grant store's would keep acquisition's records in the
|
|
329
|
+
* default namespace, shared with every deployment on the same Redis database
|
|
330
|
+
* that left it there. Refused:
|
|
331
|
+
*
|
|
332
|
+
* - `redis-federation-grant-store.keyPrefix` off its default while
|
|
333
|
+
* `redis-federation-grant-intent-store.keyPrefix` is at its own, whether or
|
|
334
|
+
* not the Redis grant store is loaded: the package's `reference.conf`, which
|
|
335
|
+
* binds both, is layered with the intent store;
|
|
336
|
+
* - with the Redis grant store not loaded, `redisFederationGrantStore.keyPrefix`
|
|
337
|
+
* written at all: the intent store read that key, and no loaded module
|
|
338
|
+
* relocates it.
|
|
339
|
+
*
|
|
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.
|
|
344
|
+
*/
|
|
345
|
+
function refuseIntentPrefixLeftAtDefault(
|
|
346
|
+
resolved: Readonly<Record<string, unknown>>,
|
|
347
|
+
modules: readonly Module[],
|
|
348
|
+
): void {
|
|
349
|
+
const grantStore = redisFederationGrantStoreModule;
|
|
350
|
+
const intentStore = redisFederationGrantIntentStoreModule;
|
|
351
|
+
const loaded = (module: Module) => modules.some((m) => m.name === module.name);
|
|
352
|
+
const reference = intentStore.section?.reference;
|
|
353
|
+
if (!loaded(intentStore) || reference === undefined) return;
|
|
354
|
+
const intentPath = `${intentStore.name}.keyPrefix`;
|
|
355
|
+
const intentVariable = "REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX";
|
|
356
|
+
const intentKey = `${intentPath} (${intentVariable})`;
|
|
357
|
+
const oldSection = resolved.redisFederationGrantStore;
|
|
358
|
+
if (!loaded(grantStore) && isPlainSection(oldSection) && Object.hasOwn(oldSection, "keyPrefix")) {
|
|
359
|
+
throw pathsRelocated(
|
|
360
|
+
"redisFederationGrantStore.keyPrefix is set, and no installed module reads it: the Redis " +
|
|
361
|
+
`intent store's prefix is its own key, ${intentKey}. Set that instead, and delete ` +
|
|
362
|
+
"redisFederationGrantStore.keyPrefix",
|
|
363
|
+
[
|
|
364
|
+
{
|
|
365
|
+
module: intentStore.name,
|
|
366
|
+
from: "redisFederationGrantStore.keyPrefix",
|
|
367
|
+
to: intentPath,
|
|
368
|
+
environmentVariable: intentVariable,
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
const defaults = resolveLayers({ config: empty(), env: {} }, [reference]);
|
|
374
|
+
const grant = keyPrefixOf(resolved, grantStore);
|
|
375
|
+
const intent = keyPrefixOf(resolved, intentStore);
|
|
376
|
+
if (grant === undefined || grant === keyPrefixOf(defaults, grantStore)) return;
|
|
377
|
+
if (intent === undefined || intent !== keyPrefixOf(defaults, intentStore)) return;
|
|
378
|
+
const grantKey =
|
|
379
|
+
"redis-federation-grant-store.keyPrefix (REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX)";
|
|
380
|
+
throw keyRefused(
|
|
381
|
+
`${grantKey} is set off its default key prefix, and ${intentKey} is left at its ` +
|
|
382
|
+
"default. The Redis intent store's prefix is its own: acquisition's records would " +
|
|
383
|
+
"stay in the default namespace, shared with every deployment on the same Redis " +
|
|
384
|
+
`database that left it there. Set ${intentKey} as well — to the grant store's prefix ` +
|
|
385
|
+
"to keep the two together, or to one of its own",
|
|
386
|
+
intentStore.name,
|
|
387
|
+
[intentStore.name, "keyPrefix"],
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The composition root's own keys, which phase one consumes and boot is never
|
|
393
|
+
* handed: the `adapters` section and the `mfaMode` switch.
|
|
394
|
+
*/
|
|
395
|
+
const ROOT_SECTIONS: readonly string[] = [ADAPTERS_SECTION, MFA_SWITCH];
|
|
396
|
+
|
|
397
|
+
/**
|
|
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.
|
|
402
|
+
*/
|
|
403
|
+
function refuseModuleAtRootSections(modules: readonly Module[]): void {
|
|
404
|
+
for (const module of modules) {
|
|
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
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Phase two: what `createApp` parses once, with every loaded module's schema:
|
|
418
|
+
* the composition's own layers, the same read phase one had, over the
|
|
419
|
+
* `reference.conf` of every package `modules` come from, core's last,
|
|
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`).
|
|
429
|
+
*
|
|
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
|
|
436
|
+
* (`refuseIntentPrefixLeftAtDefault`).
|
|
437
|
+
*
|
|
438
|
+
* Typed `AppConfig` because that is the `config` slot's type; read the parsed
|
|
439
|
+
* configuration from `handle.components.config`, not from this.
|
|
440
|
+
*/
|
|
441
|
+
export function resolveForBoot(
|
|
442
|
+
own: OwnLayers,
|
|
443
|
+
modules: readonly Module[],
|
|
444
|
+
switches: Pick<Switches, "mfaMode" | "adapters">,
|
|
445
|
+
): AppConfig {
|
|
446
|
+
refuseModuleAtRootSections(modules);
|
|
447
|
+
const all = resolveLayers(own, moduleReferences(modules));
|
|
448
|
+
refuseIntentPrefixLeftAtDefault(all, modules);
|
|
449
|
+
const layered = Object.fromEntries(
|
|
450
|
+
Object.entries(all).filter(([name]) => !ROOT_SECTIONS.includes(name)),
|
|
451
|
+
);
|
|
452
|
+
const { mfa: _decided, ...resolved } = layered;
|
|
453
|
+
const mfa = mfaSectionForBoot({
|
|
454
|
+
mode: switches.mfaMode,
|
|
455
|
+
written: (own.config.toObject() as Record<string, unknown>).mfa,
|
|
456
|
+
resolved: all,
|
|
457
|
+
owned: ownsSection(modules, "mfa"),
|
|
458
|
+
storeCalled:
|
|
459
|
+
switches.adapters.userRepository === "http" || switches.adapters.mfaFactorStore === "store",
|
|
460
|
+
env: own.env,
|
|
461
|
+
});
|
|
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;
|
|
469
|
+
return {
|
|
470
|
+
...resolved,
|
|
471
|
+
...(mfa === undefined ? {} : { mfa }),
|
|
472
|
+
...(resolved.oauth === undefined
|
|
473
|
+
? {}
|
|
474
|
+
: { oauth: oauthForBoot(switches.mfaMode, resolved.oauth) }),
|
|
475
|
+
...(sessionRequirements === undefined ? {} : { core: { ...core, sessionRequirements } }),
|
|
476
|
+
} as unknown as AppConfig;
|
|
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,26 +22,20 @@ import type { Express } from "express";
|
|
|
22
22
|
* Start the HTTP listener, and say so once it is bound.
|
|
23
23
|
*
|
|
24
24
|
* Resolves with the server once its socket is bound, after logging
|
|
25
|
-
* `server_listening` (info) with the port the socket holds
|
|
26
|
-
* one, or the one the OS picked for `0
|
|
25
|
+
* `server_listening` (info) with the port the socket holds (the configured
|
|
26
|
+
* one, or the one the OS picked for `0`). Rejects with the server's error when
|
|
27
27
|
* the socket cannot be bound (`EADDRINUSE`, `EACCES`), and logs nothing: the
|
|
28
|
-
* composition root awaits this, so the error ends boot
|
|
29
|
-
* failure
|
|
28
|
+
* composition root awaits this, so the error ends boot like any other boot
|
|
29
|
+
* failure.
|
|
30
30
|
*
|
|
31
|
-
* The server is built
|
|
32
|
-
*
|
|
33
|
-
* Express
|
|
34
|
-
* turns a bind failure into the rejection. Once
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `loggableError` projection) and the process keeps running
|
|
38
|
-
*
|
|
39
|
-
* thrown out of the process.
|
|
40
|
-
*
|
|
41
|
-
* The standalone used to call `app.listen` with a callback that ignored its
|
|
42
|
-
* argument: Express 5 hands that callback the bind error too, so it announced
|
|
43
|
-
* a server on a port another process held and swallowed the error that should
|
|
44
|
-
* have ended the process.
|
|
31
|
+
* The server is built (`http.createServer(app)`, as `app.listen` does) and its
|
|
32
|
+
* events wired here, so nothing rests on how an Express version wires them;
|
|
33
|
+
* Express 5 hands an `app.listen` callback the bind error too. Until bound,
|
|
34
|
+
* one `error` listener turns a bind failure into the rejection. Once bound, it
|
|
35
|
+
* is replaced by the one the server keeps: a later `error` (an `accept` that
|
|
36
|
+
* fails with EMFILE) is logged as `server_error` (error, `err`: core's
|
|
37
|
+
* `loggableError` projection) and the process keeps running. Without a
|
|
38
|
+
* listener, such an error would be thrown out of the process.
|
|
45
39
|
*/
|
|
46
40
|
export function listen(app: Express, port: number, logger: Logger): Promise<Server> {
|
|
47
41
|
const server = createServer(app);
|
|
@@ -13,68 +13,42 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import type {
|
|
16
|
+
import type { AuditSink, Logger } from "@o3co/auth-provider-core";
|
|
17
17
|
import { pino, stdSerializers } from "pino";
|
|
18
|
+
import type { LoggingSettings } from "./sections.mjs";
|
|
19
|
+
|
|
20
|
+
export type { LoggingSettings } from "./sections.mjs";
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
|
-
* The composition root's logger, and the value wired into the `logger`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* therefore satisfies `Logger` structurally, with no adapter.
|
|
27
|
-
*
|
|
28
|
-
* Two things this closes:
|
|
29
|
-
*
|
|
30
|
-
* - **The slot was never filled.** Only a value satisfying `Logger` can occupy
|
|
31
|
-
* it, and the logger this template used before exposes four methods with a
|
|
32
|
-
* narrower call shape. So every module that declares `optional: ["logger"]`
|
|
33
|
-
* — oauth, session, dpop, mtls, token-exchange — silently fell back to its
|
|
34
|
-
* own `consoleLogger` default, and nothing an operator configured here
|
|
35
|
-
* reached any of them.
|
|
36
|
-
* - **`logging.level` is honoured.** pino drops sub-threshold calls before
|
|
37
|
-
* formatting, so `trace` / `debug` cost nothing in production rather than
|
|
38
|
-
* being emitted and filtered downstream.
|
|
39
|
-
*
|
|
40
|
-
* `silent` is pino's own name for "emit nothing", so the config vocabulary
|
|
41
|
-
* maps across unchanged.
|
|
23
|
+
* The composition root's logger, and the value wired into the `logger` slot
|
|
24
|
+
* every module reads: pino, newline-delimited JSON on stdout, at the
|
|
25
|
+
* `logging` section's level. `Logger` has pino's two-overload call signature, so a pino
|
|
26
|
+
* instance satisfies it with no adapter, and `silent` is pino's own name for
|
|
27
|
+
* "emit nothing", so the config vocabulary maps across unchanged. See the
|
|
28
|
+
* template README, "Logging".
|
|
42
29
|
*/
|
|
43
|
-
export function createAppLogger(
|
|
30
|
+
export function createAppLogger(logging: LoggingSettings): Logger {
|
|
44
31
|
return pino({
|
|
45
32
|
name: "provider",
|
|
46
|
-
level:
|
|
47
|
-
// `err` is
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// otherwise stringify to `{}`, the stack lost exactly where it is
|
|
53
|
-
// needed.
|
|
33
|
+
level: logging.level,
|
|
34
|
+
// `err` is the key every structured event in this stack uses
|
|
35
|
+
// (`logger.error({ err: loggableError(err) }, "…_error")`). Core's
|
|
36
|
+
// projection is plain data with no `message`, so this serialiser hands it
|
|
37
|
+
// through as it is, every cause's fields included. It is here for an
|
|
38
|
+
// Error handed in as it is, which would otherwise stringify to `{}`.
|
|
54
39
|
serializers: { err: stdSerializers.err },
|
|
55
40
|
});
|
|
56
41
|
}
|
|
57
42
|
|
|
58
43
|
/**
|
|
59
44
|
* The stream the audit trail is written on: `name: "audit"`, newline-delimited
|
|
60
|
-
* JSON on stdout
|
|
61
|
-
* emits. One output style, so an aggregator ingests security events and
|
|
62
|
-
* application logs through one parser and separates them on `name`.
|
|
63
|
-
*
|
|
64
|
-
* **It takes no config, and its level is fixed.** That is the point rather
|
|
65
|
-
* than an oversight. `logging.level` is a diagnostics knob — `warn` is an
|
|
66
|
-
* ordinary production setting and `silent` a legitimate one — and an audit
|
|
67
|
-
* trail is evidence, not diagnostics. Routing audit events through the
|
|
68
|
-
* application logger would mean either of those settings silently deleting
|
|
69
|
-
* the record of who authenticated, what was issued, and what was refused:
|
|
70
|
-
* the same drop #287 is about, reached from the operator's side instead of
|
|
71
|
-
* the scaffold's. Where audit events go is chosen by `audit.sink.type`, and
|
|
72
|
-
* that selector has no "none" (#304).
|
|
45
|
+
* JSON on stdout in the same pino envelope as every other line.
|
|
73
46
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
47
|
+
* **It takes no config, and its level is fixed**, on purpose: an audit trail
|
|
48
|
+
* is evidence, not diagnostics, and `logging.level` (where `warn` is an
|
|
49
|
+
* ordinary production setting and `silent` a legitimate one) must not delete
|
|
50
|
+
* it. See the template README, "Audit trail". A second pino instance rather
|
|
51
|
+
* than a child of the app logger, because a child inherits its parent's level.
|
|
78
52
|
*/
|
|
79
53
|
export function createAuditLogger(): Logger {
|
|
80
54
|
return pino({
|
|
@@ -85,19 +59,16 @@ export function createAuditLogger(): Logger {
|
|
|
85
59
|
}
|
|
86
60
|
|
|
87
61
|
/**
|
|
88
|
-
* `AuditSink` writing each event through `auditLogger
|
|
62
|
+
* `AuditSink` writing each event through `auditLogger`: the `"logger"` sink
|
|
89
63
|
* kind this template registers and ships as its default.
|
|
90
64
|
*
|
|
91
|
-
* The event is nested under `audit
|
|
92
|
-
* `
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* Errors are core's problem, not this function's: `emitAuditEvent` dispatches
|
|
99
|
-
* without awaiting and swallows rejections, because audit recording must never
|
|
100
|
-
* add latency to (or fail) an auth flow.
|
|
65
|
+
* The event is nested under `audit`, not spread at the top level: `level`,
|
|
66
|
+
* `time`, `name` and `msg` belong to the log envelope, and an audit field
|
|
67
|
+
* colliding with one would corrupt the line for every consumer. The event
|
|
68
|
+
* type is the message, so operators alert on the name (`token.issued.failure`,
|
|
69
|
+
* `authorize.rejected`) as on `session_store_redis_error`. Errors are core's:
|
|
70
|
+
* `emitAuditEvent` dispatches without awaiting and swallows rejections,
|
|
71
|
+
* because audit recording must never add latency to (or fail) an auth flow.
|
|
101
72
|
*/
|
|
102
73
|
export function createLoggerAuditSink(auditLogger: Logger): AuditSink {
|
|
103
74
|
return {
|