@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.
Files changed (110) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -913
  19. package/templates/standalone/README.md +0 -1537
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -118
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -424
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -55
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1192
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -346
  39. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  40. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  41. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  42. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  43. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  44. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  45. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
  46. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
  47. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
  48. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  49. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  50. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
  51. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  52. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  53. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  54. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
  55. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  56. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  57. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  58. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  59. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  60. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  61. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  62. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  63. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  64. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
  65. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  66. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
  67. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  68. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  69. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  70. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
  71. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  72. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  73. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  74. package/templates/standalone/src/__tests__/sections.test.mts +0 -504
  75. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
  76. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
  77. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  78. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  79. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  80. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  81. package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
  82. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
  83. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
  84. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  85. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  86. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  87. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
  88. package/templates/standalone/src/adapters.mts +0 -162
  89. package/templates/standalone/src/app.mts +0 -136
  90. package/templates/standalone/src/bootRefusal.mts +0 -117
  91. package/templates/standalone/src/buildModules.mts +0 -358
  92. package/templates/standalone/src/configPath.mts +0 -487
  93. package/templates/standalone/src/listen.mts +0 -60
  94. package/templates/standalone/src/logger.mts +0 -80
  95. package/templates/standalone/src/metrics.mts +0 -167
  96. package/templates/standalone/src/mfaSwitch.mts +0 -284
  97. package/templates/standalone/src/modules.mts +0 -705
  98. package/templates/standalone/src/rootRenames.mts +0 -95
  99. package/templates/standalone/src/routes.mts +0 -89
  100. package/templates/standalone/src/sections.mts +0 -377
  101. package/templates/standalone/src/shutdown.mts +0 -213
  102. package/templates/standalone/tests/Makefile +0 -5
  103. package/templates/standalone/tests/index.test.js +0 -283
  104. package/templates/standalone/tests/package-lock.json +0 -1617
  105. package/templates/standalone/tests/package.json +0 -16
  106. package/templates/standalone/tests/vitest.config.mjs +0 -26
  107. package/templates/standalone/tsconfig.json +0 -31
  108. package/templates/standalone/vitest.config.mts +0 -31
  109. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  110. 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
- }