@o3co/create-auth-provider 0.17.0-rc.4 → 0.18.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.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -913
- package/templates/standalone/README.md +0 -1537
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -118
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -424
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -55
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1192
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -346
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -504
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -136
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -377
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- package/templates/versions.json +0 -20
|
@@ -1,167 +0,0 @@
|
|
|
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
|
-
import type { ReadinessProbe } from "@o3co/auth-provider-core";
|
|
18
|
-
import { runReadinessProbes } from "@o3co/auth-provider-core";
|
|
19
|
-
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
|
|
20
|
-
import { collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client";
|
|
21
|
-
|
|
22
|
-
/**
|
|
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`.
|
|
29
|
-
*/
|
|
30
|
-
const KNOWN_METHODS = new Set([
|
|
31
|
-
"GET",
|
|
32
|
-
"HEAD",
|
|
33
|
-
"POST",
|
|
34
|
-
"PUT",
|
|
35
|
-
"PATCH",
|
|
36
|
-
"DELETE",
|
|
37
|
-
"OPTIONS",
|
|
38
|
-
"TRACE",
|
|
39
|
-
"CONNECT",
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
function methodLabel(req: Request): string {
|
|
43
|
-
return KNOWN_METHODS.has(req.method) ? req.method : "other";
|
|
44
|
-
}
|
|
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
|
-
*/
|
|
53
|
-
function routeLabel(req: Request): string {
|
|
54
|
-
const route = (req as Request & { route?: { path?: string } }).route?.path;
|
|
55
|
-
if (typeof route === "string" && route.length > 0) {
|
|
56
|
-
return req.baseUrl ? `${req.baseUrl}${route === "/" ? "" : route}` : route;
|
|
57
|
-
}
|
|
58
|
-
return "unmatched";
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface MetricsRouteOptions {
|
|
62
|
-
/**
|
|
63
|
-
* Readiness probes, sampled on each scrape to publish a per-dependency
|
|
64
|
-
* up/down gauge. Pass `handle.readinessProbes`.
|
|
65
|
-
*/
|
|
66
|
-
readonly probes: readonly ReadinessProbe[];
|
|
67
|
-
/** Per-probe deadline, in milliseconds. Use the `http` module's `readinessTimeoutMs`. */
|
|
68
|
-
readonly probeTimeoutMs: number;
|
|
69
|
-
/** Route path. Defaults to `/metrics`. */
|
|
70
|
-
readonly path?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface Metrics {
|
|
74
|
-
/**
|
|
75
|
-
* Mount FIRST, ahead of every route: it times the whole downstream stack,
|
|
76
|
-
* and it records on the response's `finish` event so a request that ends in
|
|
77
|
-
* an error handler is still counted.
|
|
78
|
-
*/
|
|
79
|
-
readonly middleware: RequestHandler;
|
|
80
|
-
/**
|
|
81
|
-
* Mounts the scrape endpoint.
|
|
82
|
-
*
|
|
83
|
-
* The probes arrive here rather than at construction because the two halves
|
|
84
|
-
* are needed at different moments: the middleware has to be mounted before
|
|
85
|
-
* the app boots, and the probes only exist afterwards, on the `AppHandle`.
|
|
86
|
-
*/
|
|
87
|
-
readonly route: (express: { Router: () => Router }, options: MetricsRouteOptions) => Router;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
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".
|
|
96
|
-
*/
|
|
97
|
-
export function createMetrics(): Metrics {
|
|
98
|
-
const registry = new Registry();
|
|
99
|
-
collectDefaultMetrics({ register: registry, prefix: "auth_provider_" });
|
|
100
|
-
|
|
101
|
-
const requestDuration = new Histogram({
|
|
102
|
-
name: "http_request_duration_seconds",
|
|
103
|
-
help: "HTTP request latency in seconds, by method, route and status.",
|
|
104
|
-
labelNames: ["method", "route", "status"] as const,
|
|
105
|
-
// Tuned for an IdP: token and authorize calls sit in the low tens of
|
|
106
|
-
// milliseconds when healthy, and the tail is what an outage moves.
|
|
107
|
-
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
|
108
|
-
registers: [registry],
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
|
|
112
|
-
const endTimer = requestDuration.startTimer();
|
|
113
|
-
let observed = false;
|
|
114
|
-
// Both terminal events, with a guard, rather than `finish` alone.
|
|
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.
|
|
120
|
-
const observe = () => {
|
|
121
|
-
if (observed) return;
|
|
122
|
-
observed = true;
|
|
123
|
-
endTimer({
|
|
124
|
-
method: methodLabel(req),
|
|
125
|
-
route: routeLabel(req),
|
|
126
|
-
status: String(res.statusCode),
|
|
127
|
-
});
|
|
128
|
-
};
|
|
129
|
-
res.once("finish", observe);
|
|
130
|
-
res.once("close", observe);
|
|
131
|
-
next();
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const dependencyUp = new Gauge({
|
|
135
|
-
name: "auth_dependency_up",
|
|
136
|
-
help: "1 when a backing dependency answered its readiness probe, 0 otherwise.",
|
|
137
|
-
labelNames: ["dependency"] as const,
|
|
138
|
-
registers: [registry],
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
// Shared across scrapes so a partitioned backend does not accumulate one
|
|
142
|
-
// in-flight command per scrape (see `runReadinessProbes`).
|
|
143
|
-
const inFlight = new Map<string, Promise<unknown>>();
|
|
144
|
-
|
|
145
|
-
const route = (express: { Router: () => Router }, opts: MetricsRouteOptions): Router => {
|
|
146
|
-
const router = express.Router();
|
|
147
|
-
router.get(opts.path ?? "/metrics", async (_req: Request, res: Response) => {
|
|
148
|
-
// Sampled per scrape rather than held as state: a cached value would
|
|
149
|
-
// report a dependency healthy for as long as the cache lives, which is
|
|
150
|
-
// exactly the window an incident lives in.
|
|
151
|
-
const report = await runReadinessProbes(opts.probes, {
|
|
152
|
-
timeoutMs: opts.probeTimeoutMs,
|
|
153
|
-
inFlight,
|
|
154
|
-
});
|
|
155
|
-
for (const check of report.checks) {
|
|
156
|
-
dependencyUp.set({ dependency: check.name }, check.ok ? 1 : 0);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
res.setHeader("Content-Type", registry.contentType);
|
|
160
|
-
res.setHeader("Cache-Control", "no-store");
|
|
161
|
-
res.send(await registry.metrics());
|
|
162
|
-
});
|
|
163
|
-
return router;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
return { middleware, route };
|
|
167
|
-
}
|
|
@@ -1,284 +0,0 @@
|
|
|
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 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:
|
|
28
|
-
* - a switch outside its three values, or a file's `mfaMode` that `MFA_MODE`
|
|
29
|
-
* contradicts (`readMfaSwitch`);
|
|
30
|
-
* - an MFA store kept in memory with MFA on, unless every environment name
|
|
31
|
-
* it reads says development or test (`mfaModulesFor`) — the first refusal a
|
|
32
|
-
* deployment that sets nothing about MFA meets outside development, so it
|
|
33
|
-
* also names what MFA needs there and `MFA_MODE=off`;
|
|
34
|
-
* - an `mfa.mode` the configuration writes that the switch does not say;
|
|
35
|
-
* `MFA_ENCRYPTION_KEY` set beside a ring that holds the development sample
|
|
36
|
-
* key in its place; and `mfa.storeTimeoutMs` below the user directory's
|
|
37
|
-
* timeout where the Store is called (`mfaSectionForBoot`).
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import {
|
|
41
|
-
isDevelopmentEnvironment,
|
|
42
|
-
type Module,
|
|
43
|
-
memoryMfaFactorStoreModule,
|
|
44
|
-
memoryMfaTransactionStoreModule,
|
|
45
|
-
productionEnvironmentIn,
|
|
46
|
-
readEnvironmentName,
|
|
47
|
-
} from "@o3co/auth-provider-core";
|
|
48
|
-
import { foundationMfaFactorStoreModule } from "@o3co/auth-provider-foundation";
|
|
49
|
-
import { MFA_DEVELOPMENT_SAMPLE_KEY, mfaModules, mfaResetModule } from "@o3co/auth-provider-mfa";
|
|
50
|
-
import {
|
|
51
|
-
redisMfaFactorStoreModule,
|
|
52
|
-
redisMfaTransactionStoreModule,
|
|
53
|
-
} from "@o3co/auth-provider-redis";
|
|
54
|
-
import { loginCompletionModule } from "@o3co/auth-provider-session";
|
|
55
|
-
import { ADAPTERS_SECTION } from "./adapters.mjs";
|
|
56
|
-
import { configRefused, customIssue, issuesAt, keyRefused } from "./bootRefusal.mjs";
|
|
57
|
-
import { type Adapters, isPlainSection, type MfaSwitch, mfaSwitchSchema } from "./sections.mjs";
|
|
58
|
-
|
|
59
|
-
/** The composition root's MFA switch, a key of its own. No module may be named after it. */
|
|
60
|
-
export const MFA_SWITCH = "mfaMode";
|
|
61
|
-
|
|
62
|
-
/** The MFA module's section, which the switch decides. */
|
|
63
|
-
const MFA_SECTION = "mfa";
|
|
64
|
-
|
|
65
|
-
/** The `acr` the template advertises while MFA is on, met by a verified second factor. */
|
|
66
|
-
const MFA_ACR = "urn:o3co:acr:mfa";
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* `mfaMode` from `resolved` — the template's own layers over its
|
|
70
|
-
* `config/reference.conf` — parsed with the template's schema, under `env`,
|
|
71
|
-
* the environment the layers were substituted with. A value the schema
|
|
72
|
-
* refuses, or `MFA_MODE` set to other than what a file writes over it, is a
|
|
73
|
-
* `config-validation-failed` `BootError` naming `mfaMode` and `MFA_MODE`.
|
|
74
|
-
*/
|
|
75
|
-
export function readMfaSwitch(
|
|
76
|
-
resolved: Readonly<Record<string, unknown>>,
|
|
77
|
-
env: Readonly<Record<string, string>>,
|
|
78
|
-
): MfaSwitch {
|
|
79
|
-
const result = mfaSwitchSchema.safeParse(resolved[MFA_SWITCH]);
|
|
80
|
-
if (!result.success) {
|
|
81
|
-
throw configRefused(
|
|
82
|
-
`Config validation failed — ${MFA_SWITCH} (MFA_MODE): ${result.error.issues
|
|
83
|
-
.map((issue) => issue.message)
|
|
84
|
-
.join("; ")}`,
|
|
85
|
-
issuesAt([MFA_SWITCH], result.error.issues),
|
|
86
|
-
[{ module: MFA_SWITCH, schemaPath: MFA_SWITCH }],
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
const variable = env.MFA_MODE;
|
|
90
|
-
if (variable !== undefined && variable !== result.data) {
|
|
91
|
-
throw keyRefused(
|
|
92
|
-
`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`,
|
|
93
|
-
MFA_SWITCH,
|
|
94
|
-
[MFA_SWITCH],
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
return result.data;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Why the MFA stores may not be kept in memory under `environment` and
|
|
102
|
-
* `CONFIG_ENV` and `NODE_ENV` wherever they are set, as core's
|
|
103
|
-
* `isDevelopmentEnvironment` reads them: each name that is not development
|
|
104
|
-
* or test, or that nothing is named. None lifts another's.
|
|
105
|
-
*/
|
|
106
|
-
function memoryRefusals(environment: string | undefined): string[] {
|
|
107
|
-
const named: [string, string][] =
|
|
108
|
-
environment === undefined ? [] : [["the environment", environment]];
|
|
109
|
-
for (const variable of ["CONFIG_ENV", "NODE_ENV"] as const) {
|
|
110
|
-
const value = process.env[variable];
|
|
111
|
-
if (readEnvironmentName(value) !== undefined) named.push([variable, value as string]);
|
|
112
|
-
}
|
|
113
|
-
if (named.length === 0) return ["no environment is named"];
|
|
114
|
-
return named.flatMap(([label, value]) => {
|
|
115
|
-
if (isDevelopmentEnvironment([value])) return [];
|
|
116
|
-
const production = productionEnvironmentIn([value]);
|
|
117
|
-
if (production !== undefined) return [`${label} is "${production}"`];
|
|
118
|
-
return [`${label} "${readEnvironmentName(value)}" is not development or test`];
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* The modules the switch installs: none under `off`; otherwise the MFA
|
|
124
|
-
* package's (`mfaModules`, `mfaResetModule`), the session package's
|
|
125
|
-
* `loginCompletionModule`, and the two MFA stores `adapters` selects — on
|
|
126
|
-
* Redis, in the Store over `storeTransport` (the factors only), or in memory.
|
|
127
|
-
* A store in memory loses every factor, lock, hold and recorded email proof
|
|
128
|
-
* at a restart, after which whoever holds a password can bind a factor of
|
|
129
|
-
* their own: it is refused, naming each such setting and its variable,
|
|
130
|
-
* unless `environment`, and `CONFIG_ENV` and `NODE_ENV` where set, each say
|
|
131
|
-
* development or test. Under `core.deployment.mode = "multi"` core refuses
|
|
132
|
-
* the memory stores by name as well.
|
|
133
|
-
*/
|
|
134
|
-
export function mfaModulesFor(options: {
|
|
135
|
-
readonly mode: MfaSwitch;
|
|
136
|
-
readonly adapters: Pick<Adapters, "mfaFactorStore" | "mfaTransactionStore">;
|
|
137
|
-
readonly storeTransport: unknown;
|
|
138
|
-
readonly environment: string | undefined;
|
|
139
|
-
}): Module[] {
|
|
140
|
-
if (options.mode === "off") return [];
|
|
141
|
-
const { adapters, environment } = options;
|
|
142
|
-
const inMemory = (
|
|
143
|
-
[
|
|
144
|
-
["mfaFactorStore", "ADAPTERS_MFA_FACTOR_STORE"],
|
|
145
|
-
["mfaTransactionStore", "ADAPTERS_MFA_TRANSACTION_STORE"],
|
|
146
|
-
] as const
|
|
147
|
-
).filter(([key]) => adapters[key] === "memory");
|
|
148
|
-
const reasons = inMemory.length === 0 ? [] : memoryRefusals(environment);
|
|
149
|
-
if (reasons.length > 0) {
|
|
150
|
-
const refused = `"memory", refused because ${reasons.join(" and ")}`;
|
|
151
|
-
const settings = inMemory
|
|
152
|
-
.map(([key, variable]) => `${ADAPTERS_SECTION}.${key} (${variable})`)
|
|
153
|
-
.join(" and ");
|
|
154
|
-
throw configRefused(
|
|
155
|
-
`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`,
|
|
156
|
-
inMemory.map(([key, variable]) =>
|
|
157
|
-
customIssue(
|
|
158
|
-
[ADAPTERS_SECTION, key],
|
|
159
|
-
`${variable} is ${refused}, with MFA on (${MFA_SWITCH}, MFA_MODE)`,
|
|
160
|
-
),
|
|
161
|
-
),
|
|
162
|
-
[{ module: ADAPTERS_SECTION, schemaPath: ADAPTERS_SECTION }],
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
return [
|
|
166
|
-
adapters.mfaFactorStore === "redis"
|
|
167
|
-
? redisMfaFactorStoreModule
|
|
168
|
-
: adapters.mfaFactorStore === "store"
|
|
169
|
-
? foundationMfaFactorStoreModule({ storeTransport: options.storeTransport })
|
|
170
|
-
: memoryMfaFactorStoreModule,
|
|
171
|
-
adapters.mfaTransactionStore === "redis"
|
|
172
|
-
? redisMfaTransactionStoreModule
|
|
173
|
-
: memoryMfaTransactionStoreModule,
|
|
174
|
-
...mfaModules(environment === undefined ? {} : { environment }),
|
|
175
|
-
mfaResetModule,
|
|
176
|
-
loginCompletionModule,
|
|
177
|
-
];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** A whole number as configuration carries one: a number, or an environment variable's digits. */
|
|
181
|
-
function wholeNumber(value: unknown): number | undefined {
|
|
182
|
-
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
183
|
-
return typeof value === "string" && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** The value at `path` of `tree`, through sections of keys alone. */
|
|
187
|
-
function valueAt(tree: unknown, path: readonly string[]): unknown {
|
|
188
|
-
let cursor: unknown = tree;
|
|
189
|
-
for (const key of path) {
|
|
190
|
-
if (!isPlainSection(cursor)) return undefined;
|
|
191
|
-
cursor = cursor[key];
|
|
192
|
-
}
|
|
193
|
-
return cursor;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* What boot is handed of the `mfa` section under the switch `mode`;
|
|
198
|
-
* `undefined` hands none. None unless a loaded module owns it (`owned`):
|
|
199
|
-
* what the configuration writes there — `config/development.conf`'s key ring
|
|
200
|
-
* included — sets nothing while the switch installs no MFA. Owned, it is
|
|
201
|
-
* `resolved`'s section, with `mfa.mode` written from the switch when the
|
|
202
|
-
* switch installs MFA. Refuses (see the file header), whatever the switch, an
|
|
203
|
-
* `mfa.mode` the composition's own layers (`written`, their `mfa`) write that
|
|
204
|
-
* the switch does not say, or an `mfa` written as a value; with MFA on, a
|
|
205
|
-
* ring holding the development sample key while `MFA_ENCRYPTION_KEY` names
|
|
206
|
-
* another, and, where the Store is called (`storeCalled`), `mfa.storeTimeoutMs`
|
|
207
|
-
* below `repositories.user.http.timeout`.
|
|
208
|
-
*/
|
|
209
|
-
export function mfaSectionForBoot(options: {
|
|
210
|
-
readonly mode: MfaSwitch;
|
|
211
|
-
readonly written: unknown;
|
|
212
|
-
readonly resolved: Readonly<Record<string, unknown>>;
|
|
213
|
-
readonly owned: boolean;
|
|
214
|
-
readonly storeCalled: boolean;
|
|
215
|
-
readonly env: Readonly<Record<string, string>>;
|
|
216
|
-
}): unknown {
|
|
217
|
-
const { mode, written, resolved } = options;
|
|
218
|
-
if (written !== undefined && !isPlainSection(written)) {
|
|
219
|
-
throw keyRefused(
|
|
220
|
-
`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}`,
|
|
221
|
-
MFA_SECTION,
|
|
222
|
-
[MFA_SECTION],
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
const writtenMode = written === undefined ? undefined : written.mode;
|
|
226
|
-
if (writtenMode !== undefined && writtenMode !== mode) {
|
|
227
|
-
throw keyRefused(
|
|
228
|
-
`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`,
|
|
229
|
-
MFA_SECTION,
|
|
230
|
-
[MFA_SECTION, "mode"],
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
if (!options.owned) return undefined;
|
|
234
|
-
const section = resolved[MFA_SECTION];
|
|
235
|
-
if (mode === "off" || !isPlainSection(section)) return section;
|
|
236
|
-
// Exported but blank names no key.
|
|
237
|
-
const variableKey = options.env.MFA_ENCRYPTION_KEY?.trim() ?? "";
|
|
238
|
-
const ring = Array.isArray(section.encryptionKeys) ? section.encryptionKeys : [];
|
|
239
|
-
if (
|
|
240
|
-
variableKey !== "" &&
|
|
241
|
-
variableKey !== MFA_DEVELOPMENT_SAMPLE_KEY &&
|
|
242
|
-
ring.some((entry) => valueAt(entry, ["key"]) === MFA_DEVELOPMENT_SAMPLE_KEY)
|
|
243
|
-
) {
|
|
244
|
-
throw keyRefused(
|
|
245
|
-
"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",
|
|
246
|
-
MFA_SECTION,
|
|
247
|
-
[MFA_SECTION, "encryptionKeys"],
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
const storeTimeout = wholeNumber(section.storeTimeoutMs);
|
|
251
|
-
const userTimeout = wholeNumber(valueAt(resolved, ["repositories", "user", "http", "timeout"]));
|
|
252
|
-
if (
|
|
253
|
-
options.storeCalled &&
|
|
254
|
-
storeTimeout !== undefined &&
|
|
255
|
-
userTimeout !== undefined &&
|
|
256
|
-
storeTimeout < userTimeout
|
|
257
|
-
) {
|
|
258
|
-
throw keyRefused(
|
|
259
|
-
"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",
|
|
260
|
-
MFA_SECTION,
|
|
261
|
-
[MFA_SECTION, "storeTimeoutMs"],
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
return { ...section, mode };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* `oauth` with `urn:o3co:acr:mfa = ["mfa"]` added to its `acr` table while
|
|
269
|
-
* the switch installs MFA, unless the configuration writes that entry
|
|
270
|
-
* itself; as it is otherwise, so a composition without MFA advertises
|
|
271
|
-
* exactly what it would without the switch.
|
|
272
|
-
*/
|
|
273
|
-
export function oauthForBoot(mode: MfaSwitch, oauth: unknown): unknown {
|
|
274
|
-
if (mode === "off" || !isPlainSection(oauth)) return oauth;
|
|
275
|
-
const authorize = oauth.authorize ?? {};
|
|
276
|
-
const acrValues = isPlainSection(authorize) ? (authorize.acrValues ?? {}) : undefined;
|
|
277
|
-
// A shape the oauth module's schema refuses is left for it to name.
|
|
278
|
-
if (!isPlainSection(authorize) || !isPlainSection(acrValues)) return oauth;
|
|
279
|
-
if (Object.hasOwn(acrValues, MFA_ACR)) return oauth;
|
|
280
|
-
return {
|
|
281
|
-
...oauth,
|
|
282
|
-
authorize: { ...authorize, acrValues: { ...acrValues, [MFA_ACR]: ["mfa"] } },
|
|
283
|
-
};
|
|
284
|
-
}
|