@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -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
- * Route label for a request.
24
- *
25
- * Express only fills `req.route` once a handler has matched, and the label has
26
- * to be **bounded**: labelling by `req.path` would mint a new time series per
27
- * distinct URL, and this server's paths carry opaque values (`/oauth/authorize`
28
- * query state, 404 probes from the internet). An unbounded label set is the
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 `config.http.readinessTimeoutMs`. */
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
- * What this publishes is chosen from what an operator cannot otherwise see.
98
- * Before it, a degraded provider offered stderr lines and the load balancer's
99
- * 5xx graph — no way to distinguish "rate limiter is failing closed" from
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 — including ones
138
- // produced by an error handler, and `req.route` is populated by then. But
139
- // a client or proxy that disconnects mid-handler emits `close` WITHOUT
140
- // `finish`, and those are disproportionately the slow and failing requests
141
- // that RED metrics exist to surface; counting only `finish` would drop
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,285 @@
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
+ type Module,
42
+ memoryMfaFactorStoreModule,
43
+ memoryMfaTransactionStoreModule,
44
+ productionEnvironmentIn,
45
+ readEnvironmentName,
46
+ } from "@o3co/auth-provider-core";
47
+ import { foundationMfaFactorStoreModule } from "@o3co/auth-provider-foundation";
48
+ import { MFA_DEVELOPMENT_SAMPLE_KEY, mfaModules, mfaResetModule } from "@o3co/auth-provider-mfa";
49
+ import {
50
+ redisMfaFactorStoreModule,
51
+ redisMfaTransactionStoreModule,
52
+ } from "@o3co/auth-provider-redis";
53
+ import { loginCompletionModule } from "@o3co/auth-provider-session";
54
+ import { ADAPTERS_SECTION } from "./adapters.mjs";
55
+ import { configRefused, customIssue, issuesAt, keyRefused } from "./bootRefusal.mjs";
56
+ import { type Adapters, isPlainSection, type MfaSwitch, mfaSwitchSchema } from "./sections.mjs";
57
+
58
+ /** The composition root's MFA switch, a key of its own. No module may be named after it. */
59
+ export const MFA_SWITCH = "mfaMode";
60
+
61
+ /** The MFA module's section, which the switch decides. */
62
+ const MFA_SECTION = "mfa";
63
+
64
+ /** The `acr` the template advertises while MFA is on, met by a verified second factor. */
65
+ const MFA_ACR = "urn:o3co:acr:mfa";
66
+
67
+ /**
68
+ * `mfaMode` from `resolved` — the template's own layers over its
69
+ * `config/reference.conf` — parsed with the template's schema, under `env`,
70
+ * the environment the layers were substituted with. A value the schema
71
+ * refuses, or `MFA_MODE` set to other than what a file writes over it, is a
72
+ * `config-validation-failed` `BootError` naming `mfaMode` and `MFA_MODE`.
73
+ */
74
+ export function readMfaSwitch(
75
+ resolved: Readonly<Record<string, unknown>>,
76
+ env: Readonly<Record<string, string>>,
77
+ ): MfaSwitch {
78
+ const result = mfaSwitchSchema.safeParse(resolved[MFA_SWITCH]);
79
+ if (!result.success) {
80
+ throw configRefused(
81
+ `Config validation failed — ${MFA_SWITCH} (MFA_MODE): ${result.error.issues
82
+ .map((issue) => issue.message)
83
+ .join("; ")}`,
84
+ issuesAt([MFA_SWITCH], result.error.issues),
85
+ [{ module: MFA_SWITCH, schemaPath: MFA_SWITCH }],
86
+ );
87
+ }
88
+ const variable = env.MFA_MODE;
89
+ if (variable !== undefined && variable !== result.data) {
90
+ throw keyRefused(
91
+ `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`,
92
+ MFA_SWITCH,
93
+ [MFA_SWITCH],
94
+ );
95
+ }
96
+ return result.data;
97
+ }
98
+
99
+ /** The names the memory MFA stores are let in under. */
100
+ const DEVELOPMENT_NAMES: ReadonlySet<string> = new Set(["development", "test"]);
101
+
102
+ /**
103
+ * Why the MFA stores may not be kept in memory under `environment` and
104
+ * `CONFIG_ENV` and `NODE_ENV` wherever they are set: each name that is not
105
+ * development or test, or that nothing is named. None lifts another's.
106
+ */
107
+ function memoryRefusals(environment: string | undefined): string[] {
108
+ const named: [string, string][] =
109
+ environment === undefined ? [] : [["the environment", environment]];
110
+ for (const variable of ["CONFIG_ENV", "NODE_ENV"] as const) {
111
+ const value = process.env[variable];
112
+ if (readEnvironmentName(value) !== undefined) named.push([variable, value as string]);
113
+ }
114
+ if (named.length === 0) return ["no environment is named"];
115
+ return named.flatMap(([label, value]) => {
116
+ const production = productionEnvironmentIn([value]);
117
+ if (production !== undefined) return [`${label} is "${production}"`];
118
+ const name = readEnvironmentName(value) ?? "";
119
+ return DEVELOPMENT_NAMES.has(name) ? [] : [`${label} "${name}" is not development or test`];
120
+ });
121
+ }
122
+
123
+ /**
124
+ * The modules the switch installs: none under `off`; otherwise the MFA
125
+ * package's (`mfaModules`, `mfaResetModule`), the session package's
126
+ * `loginCompletionModule`, and the two MFA stores `adapters` selects — on
127
+ * Redis, in the Store over `storeTransport` (the factors only), or in memory.
128
+ * A store in memory loses every factor, lock, hold and recorded email proof
129
+ * at a restart, after which whoever holds a password can bind a factor of
130
+ * their own: it is refused, naming each such setting and its variable,
131
+ * unless `environment`, and `CONFIG_ENV` and `NODE_ENV` where set, each say
132
+ * development or test. Under `core.deployment.mode = "multi"` core refuses
133
+ * the memory stores by name as well.
134
+ */
135
+ export function mfaModulesFor(options: {
136
+ readonly mode: MfaSwitch;
137
+ readonly adapters: Pick<Adapters, "mfaFactorStore" | "mfaTransactionStore">;
138
+ readonly storeTransport: unknown;
139
+ readonly environment: string | undefined;
140
+ }): Module[] {
141
+ if (options.mode === "off") return [];
142
+ const { adapters, environment } = options;
143
+ const inMemory = (
144
+ [
145
+ ["mfaFactorStore", "ADAPTERS_MFA_FACTOR_STORE"],
146
+ ["mfaTransactionStore", "ADAPTERS_MFA_TRANSACTION_STORE"],
147
+ ] as const
148
+ ).filter(([key]) => adapters[key] === "memory");
149
+ const reasons = inMemory.length === 0 ? [] : memoryRefusals(environment);
150
+ if (reasons.length > 0) {
151
+ const refused = `"memory", refused because ${reasons.join(" and ")}`;
152
+ const settings = inMemory
153
+ .map(([key, variable]) => `${ADAPTERS_SECTION}.${key} (${variable})`)
154
+ .join(" and ");
155
+ throw configRefused(
156
+ `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`,
157
+ inMemory.map(([key, variable]) =>
158
+ customIssue(
159
+ [ADAPTERS_SECTION, key],
160
+ `${variable} is ${refused}, with MFA on (${MFA_SWITCH}, MFA_MODE)`,
161
+ ),
162
+ ),
163
+ [{ module: ADAPTERS_SECTION, schemaPath: ADAPTERS_SECTION }],
164
+ );
165
+ }
166
+ return [
167
+ adapters.mfaFactorStore === "redis"
168
+ ? redisMfaFactorStoreModule
169
+ : adapters.mfaFactorStore === "store"
170
+ ? foundationMfaFactorStoreModule({ storeTransport: options.storeTransport })
171
+ : memoryMfaFactorStoreModule,
172
+ adapters.mfaTransactionStore === "redis"
173
+ ? redisMfaTransactionStoreModule
174
+ : memoryMfaTransactionStoreModule,
175
+ ...mfaModules(environment === undefined ? {} : { environment }),
176
+ mfaResetModule,
177
+ loginCompletionModule,
178
+ ];
179
+ }
180
+
181
+ /** A whole number as configuration carries one: a number, or an environment variable's digits. */
182
+ function wholeNumber(value: unknown): number | undefined {
183
+ if (typeof value === "number" && Number.isInteger(value)) return value;
184
+ return typeof value === "string" && /^\d+$/.test(value) ? Number(value) : undefined;
185
+ }
186
+
187
+ /** The value at `path` of `tree`, through sections of keys alone. */
188
+ function valueAt(tree: unknown, path: readonly string[]): unknown {
189
+ let cursor: unknown = tree;
190
+ for (const key of path) {
191
+ if (!isPlainSection(cursor)) return undefined;
192
+ cursor = cursor[key];
193
+ }
194
+ return cursor;
195
+ }
196
+
197
+ /**
198
+ * What boot is handed of the `mfa` section under the switch `mode`;
199
+ * `undefined` hands none. None unless a loaded module owns it (`owned`):
200
+ * what the configuration writes there — `config/development.conf`'s key ring
201
+ * included — sets nothing while the switch installs no MFA. Owned, it is
202
+ * `resolved`'s section, with `mfa.mode` written from the switch when the
203
+ * switch installs MFA. Refuses (see the file header), whatever the switch, an
204
+ * `mfa.mode` the composition's own layers (`written`, their `mfa`) write that
205
+ * the switch does not say, or an `mfa` written as a value; with MFA on, a
206
+ * ring holding the development sample key while `MFA_ENCRYPTION_KEY` names
207
+ * another, and, where the Store is called (`storeCalled`), `mfa.storeTimeoutMs`
208
+ * below `repositories.user.http.timeout`.
209
+ */
210
+ export function mfaSectionForBoot(options: {
211
+ readonly mode: MfaSwitch;
212
+ readonly written: unknown;
213
+ readonly resolved: Readonly<Record<string, unknown>>;
214
+ readonly owned: boolean;
215
+ readonly storeCalled: boolean;
216
+ readonly env: Readonly<Record<string, string>>;
217
+ }): unknown {
218
+ const { mode, written, resolved } = options;
219
+ if (written !== undefined && !isPlainSection(written)) {
220
+ throw keyRefused(
221
+ `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}`,
222
+ MFA_SECTION,
223
+ [MFA_SECTION],
224
+ );
225
+ }
226
+ const writtenMode = written === undefined ? undefined : written.mode;
227
+ if (writtenMode !== undefined && writtenMode !== mode) {
228
+ throw keyRefused(
229
+ `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`,
230
+ MFA_SECTION,
231
+ [MFA_SECTION, "mode"],
232
+ );
233
+ }
234
+ if (!options.owned) return undefined;
235
+ const section = resolved[MFA_SECTION];
236
+ if (mode === "off" || !isPlainSection(section)) return section;
237
+ // Exported but blank names no key.
238
+ const variableKey = options.env.MFA_ENCRYPTION_KEY?.trim() ?? "";
239
+ const ring = Array.isArray(section.encryptionKeys) ? section.encryptionKeys : [];
240
+ if (
241
+ variableKey !== "" &&
242
+ variableKey !== MFA_DEVELOPMENT_SAMPLE_KEY &&
243
+ ring.some((entry) => valueAt(entry, ["key"]) === MFA_DEVELOPMENT_SAMPLE_KEY)
244
+ ) {
245
+ throw keyRefused(
246
+ "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",
247
+ MFA_SECTION,
248
+ [MFA_SECTION, "encryptionKeys"],
249
+ );
250
+ }
251
+ const storeTimeout = wholeNumber(section.storeTimeoutMs);
252
+ const userTimeout = wholeNumber(valueAt(resolved, ["repositories", "user", "http", "timeout"]));
253
+ if (
254
+ options.storeCalled &&
255
+ storeTimeout !== undefined &&
256
+ userTimeout !== undefined &&
257
+ storeTimeout < userTimeout
258
+ ) {
259
+ throw keyRefused(
260
+ "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",
261
+ MFA_SECTION,
262
+ [MFA_SECTION, "storeTimeoutMs"],
263
+ );
264
+ }
265
+ return { ...section, mode };
266
+ }
267
+
268
+ /**
269
+ * `oauth` with `urn:o3co:acr:mfa = ["mfa"]` added to its `acr` table while
270
+ * the switch installs MFA, unless the configuration writes that entry
271
+ * itself; as it is otherwise, so a composition without MFA advertises
272
+ * exactly what it would without the switch.
273
+ */
274
+ export function oauthForBoot(mode: MfaSwitch, oauth: unknown): unknown {
275
+ if (mode === "off" || !isPlainSection(oauth)) return oauth;
276
+ const authorize = oauth.authorize ?? {};
277
+ const acrValues = isPlainSection(authorize) ? (authorize.acrValues ?? {}) : undefined;
278
+ // A shape the oauth module's schema refuses is left for it to name.
279
+ if (!isPlainSection(authorize) || !isPlainSection(acrValues)) return oauth;
280
+ if (Object.hasOwn(acrValues, MFA_ACR)) return oauth;
281
+ return {
282
+ ...oauth,
283
+ authorize: { ...authorize, acrValues: { ...acrValues, [MFA_ACR]: ["mfa"] } },
284
+ };
285
+ }