@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1
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 +139 -48
- package/README.md +138 -47
- 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 +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- 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 +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- package/templates/standalone/tests/index.test.js +2 -2
- 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__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -20,23 +20,12 @@ import type { NextFunction, Request, RequestHandler, Response, Router } from "ex
|
|
|
20
20
|
import { collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* classic way to take Prometheus down with the thing that was supposed to
|
|
30
|
-
* watch it. Unmatched requests collapse to a single `unmatched` bucket.
|
|
31
|
-
*/
|
|
32
|
-
/**
|
|
33
|
-
* Method label, bounded to the methods this server can actually serve.
|
|
34
|
-
*
|
|
35
|
-
* `req.method` is attacker-controlled: Node's HTTP parser accepts any valid
|
|
36
|
-
* token, so `FOO` and `M000001` reach here as readily as `GET`. Each distinct
|
|
37
|
-
* value would mint a fresh histogram child carrying every bucket — the same
|
|
38
|
-
* unbounded-cardinality failure {@link routeLabel} guards against, one label
|
|
39
|
-
* over, and reachable without any access to `/metrics` itself.
|
|
23
|
+
* Method label, bounded to the methods this server can serve. `req.method` is
|
|
24
|
+
* attacker-controlled (Node's HTTP parser accepts any valid token, so `FOO`
|
|
25
|
+
* reaches here as readily as `GET`), and each distinct value would mint a
|
|
26
|
+
* histogram child carrying every bucket: the unbounded cardinality
|
|
27
|
+
* {@link routeLabel} guards against, reachable without any access to
|
|
28
|
+
* `/metrics`.
|
|
40
29
|
*/
|
|
41
30
|
const KNOWN_METHODS = new Set([
|
|
42
31
|
"GET",
|
|
@@ -54,6 +43,13 @@ function methodLabel(req: Request): string {
|
|
|
54
43
|
return KNOWN_METHODS.has(req.method) ? req.method : "other";
|
|
55
44
|
}
|
|
56
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Route label for a request, which must be **bounded**: labelling by
|
|
48
|
+
* `req.path` would mint a time series per distinct URL, and this server's
|
|
49
|
+
* paths carry opaque values (`/oauth/authorize` query state, 404 probes from
|
|
50
|
+
* the internet). Express fills `req.route` only once a handler has matched;
|
|
51
|
+
* unmatched requests collapse to a single `unmatched` bucket.
|
|
52
|
+
*/
|
|
57
53
|
function routeLabel(req: Request): string {
|
|
58
54
|
const route = (req as Request & { route?: { path?: string } }).route?.path;
|
|
59
55
|
if (typeof route === "string" && route.length > 0) {
|
|
@@ -68,7 +64,7 @@ export interface MetricsRouteOptions {
|
|
|
68
64
|
* up/down gauge. Pass `handle.readinessProbes`.
|
|
69
65
|
*/
|
|
70
66
|
readonly probes: readonly ReadinessProbe[];
|
|
71
|
-
/** Per-probe deadline, in milliseconds. Use `
|
|
67
|
+
/** Per-probe deadline, in milliseconds. Use the `http` module's `readinessTimeoutMs`. */
|
|
72
68
|
readonly probeTimeoutMs: number;
|
|
73
69
|
/** Route path. Defaults to `/metrics`. */
|
|
74
70
|
readonly path?: string;
|
|
@@ -92,29 +88,11 @@ export interface Metrics {
|
|
|
92
88
|
}
|
|
93
89
|
|
|
94
90
|
/**
|
|
95
|
-
* Prometheus metrics for the standalone provider
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* "Redis-backed code repository is gone" without grepping container logs, and
|
|
101
|
-
* no latency series to alert on at all.
|
|
102
|
-
*
|
|
103
|
-
* - `http_request_duration_seconds` — histogram by method, bounded route, and
|
|
104
|
-
* status. Carries request rate, error rate and latency in one series, which
|
|
105
|
-
* is the whole RED method.
|
|
106
|
-
* - `auth_dependency_up` — 1/0 per readiness probe, sampled at scrape time.
|
|
107
|
-
* This is the series that separates "Redis is gone" from "the app is slow",
|
|
108
|
-
* and it reuses the probes the builders already registered rather than
|
|
109
|
-
* inventing a second notion of what this deployment depends on.
|
|
110
|
-
* - Node process defaults (event-loop lag, heap, GC, handles) under the
|
|
111
|
-
* `auth_provider_` prefix.
|
|
112
|
-
*
|
|
113
|
-
* Deliberately not here yet: rate-limiter fail-closed counts and audit-sink
|
|
114
|
-
* drops. Both happen inside `@o3co/auth-provider-core` and neither is
|
|
115
|
-
* observable from the composition root today; counting them needs a metrics
|
|
116
|
-
* hook in core (`BuilderContext` already reserves `metrics`) rather than
|
|
117
|
-
* something this file can reach.
|
|
91
|
+
* Prometheus metrics for the standalone provider: the request-duration
|
|
92
|
+
* histogram (request rate, error rate and latency in one series),
|
|
93
|
+
* `auth_dependency_up` per readiness probe, sampled at scrape time, and Node
|
|
94
|
+
* process defaults under the `auth_provider_` prefix. What each answers, and
|
|
95
|
+
* what is not published yet, is in the template README, "Metrics".
|
|
118
96
|
*/
|
|
119
97
|
export function createMetrics(): Metrics {
|
|
120
98
|
const registry = new Registry();
|
|
@@ -134,12 +112,11 @@ export function createMetrics(): Metrics {
|
|
|
134
112
|
const endTimer = requestDuration.startTimer();
|
|
135
113
|
let observed = false;
|
|
136
114
|
// Both terminal events, with a guard, rather than `finish` alone.
|
|
137
|
-
// `finish` covers every response the server completes
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// them from both the rate and the latency series.
|
|
115
|
+
// `finish` covers every response the server completes, an error
|
|
116
|
+
// handler's included, with `req.route` populated by then; but a client or
|
|
117
|
+
// proxy that disconnects mid-handler emits `close` WITHOUT `finish`, and
|
|
118
|
+
// those are disproportionately the slow and failing requests RED metrics
|
|
119
|
+
// exist to surface.
|
|
143
120
|
const observe = () => {
|
|
144
121
|
if (observed) return;
|
|
145
122
|
observed = true;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The template's MFA switch, the composition root's own key `mfaMode`
|
|
19
|
+
* (`MFA_MODE`), `required` unless set otherwise: reading it before the
|
|
20
|
+
* modules are chosen, the modules it installs, and what it hands boot of the
|
|
21
|
+
* MFA module's section. Off installs nothing of MFA and hands boot nothing of
|
|
22
|
+
* it; on installs the MFA package's modules over the two MFA stores
|
|
23
|
+
* `adapters` selects.
|
|
24
|
+
*
|
|
25
|
+
* Refuses, each with a `RangeError` before boot that names the keys and
|
|
26
|
+
* variables and quotes no value of a mode, a timeout or a key:
|
|
27
|
+
* - a switch outside its three values, or a file's `mfaMode` that `MFA_MODE`
|
|
28
|
+
* contradicts (`readMfaSwitch`);
|
|
29
|
+
* - an MFA store kept in memory with MFA on, unless every environment name
|
|
30
|
+
* it reads says development or test (`mfaModulesFor`) — the first refusal a
|
|
31
|
+
* deployment that sets nothing about MFA meets outside development, so it
|
|
32
|
+
* also names what MFA needs there and `MFA_MODE=off`;
|
|
33
|
+
* - an `mfa.mode` the configuration writes that the switch does not say;
|
|
34
|
+
* `MFA_ENCRYPTION_KEY` set beside a ring that holds the development sample
|
|
35
|
+
* key in its place; and `mfa.storeTimeoutMs` below the user directory's
|
|
36
|
+
* timeout where the Store is called (`mfaSectionForBoot`).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
type Module,
|
|
41
|
+
memoryMfaFactorStoreModule,
|
|
42
|
+
memoryMfaTransactionStoreModule,
|
|
43
|
+
productionEnvironmentIn,
|
|
44
|
+
readEnvironmentName,
|
|
45
|
+
} from "@o3co/auth-provider-core";
|
|
46
|
+
import { foundationMfaFactorStoreModule } from "@o3co/auth-provider-foundation";
|
|
47
|
+
import { MFA_DEVELOPMENT_SAMPLE_KEY, mfaModules, mfaResetModule } from "@o3co/auth-provider-mfa";
|
|
48
|
+
import {
|
|
49
|
+
redisMfaFactorStoreModule,
|
|
50
|
+
redisMfaTransactionStoreModule,
|
|
51
|
+
} from "@o3co/auth-provider-redis";
|
|
52
|
+
import { loginCompletionModule } from "@o3co/auth-provider-session";
|
|
53
|
+
import { type Adapters, isPlainSection, type MfaSwitch, mfaSwitchSchema } from "./sections.mjs";
|
|
54
|
+
|
|
55
|
+
/** The composition root's MFA switch, a key of its own. No module may be named after it. */
|
|
56
|
+
export const MFA_SWITCH = "mfaMode";
|
|
57
|
+
|
|
58
|
+
/** The MFA module's section, which the switch decides. */
|
|
59
|
+
const MFA_SECTION = "mfa";
|
|
60
|
+
|
|
61
|
+
/** The `acr` the template advertises while MFA is on, met by a verified second factor. */
|
|
62
|
+
const MFA_ACR = "urn:o3co:acr:mfa";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `mfaMode` from `resolved` — the template's own layers over its
|
|
66
|
+
* `config/reference.conf` — parsed with the template's schema, under `env`,
|
|
67
|
+
* the environment the layers were substituted with. A value the schema
|
|
68
|
+
* refuses, or `MFA_MODE` set to other than what a file writes over it, is a
|
|
69
|
+
* `RangeError` naming `mfaMode` and `MFA_MODE`.
|
|
70
|
+
*/
|
|
71
|
+
export function readMfaSwitch(
|
|
72
|
+
resolved: Readonly<Record<string, unknown>>,
|
|
73
|
+
env: Readonly<Record<string, string>>,
|
|
74
|
+
): MfaSwitch {
|
|
75
|
+
const result = mfaSwitchSchema.safeParse(resolved[MFA_SWITCH]);
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
throw new RangeError(
|
|
78
|
+
`Config validation failed — ${MFA_SWITCH} (MFA_MODE): ${result.error.issues
|
|
79
|
+
.map((issue) => issue.message)
|
|
80
|
+
.join("; ")}`,
|
|
81
|
+
{ cause: result.error },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const variable = env.MFA_MODE;
|
|
85
|
+
if (variable !== undefined && variable !== result.data) {
|
|
86
|
+
throw new RangeError(
|
|
87
|
+
`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`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return result.data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The names the memory MFA stores are let in under. */
|
|
94
|
+
const DEVELOPMENT_NAMES: ReadonlySet<string> = new Set(["development", "test"]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Why the MFA stores may not be kept in memory under `environment` and
|
|
98
|
+
* `CONFIG_ENV` and `NODE_ENV` wherever they are set: each name that is not
|
|
99
|
+
* development or test, or that nothing is named. None lifts another's.
|
|
100
|
+
*/
|
|
101
|
+
function memoryRefusals(environment: string | undefined): string[] {
|
|
102
|
+
const named: [string, string][] =
|
|
103
|
+
environment === undefined ? [] : [["the environment", environment]];
|
|
104
|
+
for (const variable of ["CONFIG_ENV", "NODE_ENV"] as const) {
|
|
105
|
+
const value = process.env[variable];
|
|
106
|
+
if (readEnvironmentName(value) !== undefined) named.push([variable, value as string]);
|
|
107
|
+
}
|
|
108
|
+
if (named.length === 0) return ["no environment is named"];
|
|
109
|
+
return named.flatMap(([label, value]) => {
|
|
110
|
+
const production = productionEnvironmentIn([value]);
|
|
111
|
+
if (production !== undefined) return [`${label} is "${production}"`];
|
|
112
|
+
const name = readEnvironmentName(value) ?? "";
|
|
113
|
+
return DEVELOPMENT_NAMES.has(name) ? [] : [`${label} "${name}" is not development or test`];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The modules the switch installs: none under `off`; otherwise the MFA
|
|
119
|
+
* package's (`mfaModules`, `mfaResetModule`), the session package's
|
|
120
|
+
* `loginCompletionModule`, and the two MFA stores `adapters` selects — on
|
|
121
|
+
* Redis, in the Store over `storeTransport` (the factors only), or in memory.
|
|
122
|
+
* A store in memory loses every factor, lock, hold and recorded email proof
|
|
123
|
+
* at a restart, after which whoever holds a password can bind a factor of
|
|
124
|
+
* their own: it is refused, naming each such setting and its variable,
|
|
125
|
+
* unless `environment`, and `CONFIG_ENV` and `NODE_ENV` where set, each say
|
|
126
|
+
* development or test. Under `core.deployment.mode = "multi"` core refuses
|
|
127
|
+
* the memory stores by name as well.
|
|
128
|
+
*/
|
|
129
|
+
export function mfaModulesFor(options: {
|
|
130
|
+
readonly mode: MfaSwitch;
|
|
131
|
+
readonly adapters: Pick<Adapters, "mfaFactorStore" | "mfaTransactionStore">;
|
|
132
|
+
readonly storeTransport: unknown;
|
|
133
|
+
readonly environment: string | undefined;
|
|
134
|
+
}): Module[] {
|
|
135
|
+
if (options.mode === "off") return [];
|
|
136
|
+
const { adapters, environment } = options;
|
|
137
|
+
const inMemory = [
|
|
138
|
+
...(adapters.mfaFactorStore === "memory"
|
|
139
|
+
? ["adapters.mfaFactorStore (ADAPTERS_MFA_FACTOR_STORE)"]
|
|
140
|
+
: []),
|
|
141
|
+
...(adapters.mfaTransactionStore === "memory"
|
|
142
|
+
? ["adapters.mfaTransactionStore (ADAPTERS_MFA_TRANSACTION_STORE)"]
|
|
143
|
+
: []),
|
|
144
|
+
];
|
|
145
|
+
const reasons = inMemory.length === 0 ? [] : memoryRefusals(environment);
|
|
146
|
+
if (reasons.length > 0) {
|
|
147
|
+
throw new RangeError(
|
|
148
|
+
`MFA is on (${MFA_SWITCH}, MFA_MODE, which installs it unless set to off) and ${inMemory.join(" and ")} ${inMemory.length === 1 ? "is" : "are"} "memory", refused because ${reasons.join(" and ")}: 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`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return [
|
|
152
|
+
adapters.mfaFactorStore === "redis"
|
|
153
|
+
? redisMfaFactorStoreModule
|
|
154
|
+
: adapters.mfaFactorStore === "store"
|
|
155
|
+
? foundationMfaFactorStoreModule({ storeTransport: options.storeTransport })
|
|
156
|
+
: memoryMfaFactorStoreModule,
|
|
157
|
+
adapters.mfaTransactionStore === "redis"
|
|
158
|
+
? redisMfaTransactionStoreModule
|
|
159
|
+
: memoryMfaTransactionStoreModule,
|
|
160
|
+
...mfaModules(environment === undefined ? {} : { environment }),
|
|
161
|
+
mfaResetModule,
|
|
162
|
+
loginCompletionModule,
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A whole number as configuration carries one: a number, or an environment variable's digits. */
|
|
167
|
+
function wholeNumber(value: unknown): number | undefined {
|
|
168
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
169
|
+
return typeof value === "string" && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The value at `path` of `tree`, through sections of keys alone. */
|
|
173
|
+
function valueAt(tree: unknown, path: readonly string[]): unknown {
|
|
174
|
+
let cursor: unknown = tree;
|
|
175
|
+
for (const key of path) {
|
|
176
|
+
if (!isPlainSection(cursor)) return undefined;
|
|
177
|
+
cursor = cursor[key];
|
|
178
|
+
}
|
|
179
|
+
return cursor;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What boot is handed of the `mfa` section under the switch `mode`;
|
|
184
|
+
* `undefined` hands none. None unless a loaded module owns it (`owned`):
|
|
185
|
+
* what the configuration writes there — `config/development.conf`'s key ring
|
|
186
|
+
* included — sets nothing while the switch installs no MFA. Owned, it is
|
|
187
|
+
* `resolved`'s section, with `mfa.mode` written from the switch when the
|
|
188
|
+
* switch installs MFA. Refuses (see the file header), whatever the switch, an
|
|
189
|
+
* `mfa.mode` the composition's own layers (`written`, their `mfa`) write that
|
|
190
|
+
* the switch does not say, or an `mfa` written as a value; with MFA on, a
|
|
191
|
+
* ring holding the development sample key while `MFA_ENCRYPTION_KEY` names
|
|
192
|
+
* another, and, where the Store is called (`storeCalled`), `mfa.storeTimeoutMs`
|
|
193
|
+
* below `repositories.user.http.timeout`.
|
|
194
|
+
*/
|
|
195
|
+
export function mfaSectionForBoot(options: {
|
|
196
|
+
readonly mode: MfaSwitch;
|
|
197
|
+
readonly written: unknown;
|
|
198
|
+
readonly resolved: Readonly<Record<string, unknown>>;
|
|
199
|
+
readonly owned: boolean;
|
|
200
|
+
readonly storeCalled: boolean;
|
|
201
|
+
readonly env: Readonly<Record<string, string>>;
|
|
202
|
+
}): unknown {
|
|
203
|
+
const { mode, written, resolved } = options;
|
|
204
|
+
if (written !== undefined && !isPlainSection(written)) {
|
|
205
|
+
throw new RangeError(
|
|
206
|
+
`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}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const writtenMode = written === undefined ? undefined : written.mode;
|
|
210
|
+
if (writtenMode !== undefined && writtenMode !== mode) {
|
|
211
|
+
throw new RangeError(
|
|
212
|
+
`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`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (!options.owned) return undefined;
|
|
216
|
+
const section = resolved[MFA_SECTION];
|
|
217
|
+
if (mode === "off" || !isPlainSection(section)) return section;
|
|
218
|
+
// Exported but blank names no key.
|
|
219
|
+
const variableKey = options.env.MFA_ENCRYPTION_KEY?.trim() ?? "";
|
|
220
|
+
const ring = Array.isArray(section.encryptionKeys) ? section.encryptionKeys : [];
|
|
221
|
+
if (
|
|
222
|
+
variableKey !== "" &&
|
|
223
|
+
variableKey !== MFA_DEVELOPMENT_SAMPLE_KEY &&
|
|
224
|
+
ring.some((entry) => valueAt(entry, ["key"]) === MFA_DEVELOPMENT_SAMPLE_KEY)
|
|
225
|
+
) {
|
|
226
|
+
throw new RangeError(
|
|
227
|
+
"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",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const storeTimeout = wholeNumber(section.storeTimeoutMs);
|
|
231
|
+
const userTimeout = wholeNumber(valueAt(resolved, ["repositories", "user", "http", "timeout"]));
|
|
232
|
+
if (
|
|
233
|
+
options.storeCalled &&
|
|
234
|
+
storeTimeout !== undefined &&
|
|
235
|
+
userTimeout !== undefined &&
|
|
236
|
+
storeTimeout < userTimeout
|
|
237
|
+
) {
|
|
238
|
+
throw new RangeError(
|
|
239
|
+
"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",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return { ...section, mode };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `oauth` with `urn:o3co:acr:mfa = ["mfa"]` added to its `acr` table while
|
|
247
|
+
* the switch installs MFA, unless the configuration writes that entry
|
|
248
|
+
* itself; as it is otherwise, so a composition without MFA advertises
|
|
249
|
+
* exactly what it would without the switch.
|
|
250
|
+
*/
|
|
251
|
+
export function oauthForBoot(mode: MfaSwitch, oauth: unknown): unknown {
|
|
252
|
+
if (mode === "off" || !isPlainSection(oauth)) return oauth;
|
|
253
|
+
const authorize = oauth.authorize ?? {};
|
|
254
|
+
const acrValues = isPlainSection(authorize) ? (authorize.acrValues ?? {}) : undefined;
|
|
255
|
+
// A shape the oauth module's schema refuses is left for it to name.
|
|
256
|
+
if (!isPlainSection(authorize) || !isPlainSection(acrValues)) return oauth;
|
|
257
|
+
if (Object.hasOwn(acrValues, MFA_ACR)) return oauth;
|
|
258
|
+
return {
|
|
259
|
+
...oauth,
|
|
260
|
+
authorize: { ...authorize, acrValues: { ...acrValues, [MFA_ACR]: ["mfa"] } },
|
|
261
|
+
};
|
|
262
|
+
}
|