@o3co/create-auth-provider 0.17.0 → 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 (114) 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 -912
  19. package/templates/standalone/README.md +0 -1536
  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 -124
  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 -427
  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 -56
  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 -1188
  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 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. 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
- }