@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
@@ -0,0 +1,326 @@
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 development compose run, `docker-compose.yml`, with MFA on as the
19
+ * template ships it, and the Mailpit overlay, `docker-compose.mailpit.yml`: a
20
+ * development run whose mail goes through the SMTP sender to Mailpit. It is an
21
+ * overlay on `docker-compose.yml`, so a plain `docker compose up` starts no
22
+ * Mailpit, and the production file names none. The compose files are read as
23
+ * the fixed-shape files they are, and the process each run describes is booted
24
+ * from the shipped files, as `app.mts` boots them, under the environment the
25
+ * run sets: `.env.example`'s lines, then each compose file's `environment`, the
26
+ * overlay's last. Redis is a stand-in: nothing here issues a command.
27
+ */
28
+
29
+ import { generateKeyPairSync } from "node:crypto";
30
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import {
35
+ createApp,
36
+ type MailSender,
37
+ memoryRefreshTokenFamilyStoreModule,
38
+ } from "@o3co/auth-provider-core";
39
+ import {
40
+ standardDevelopmentMailSenderModule,
41
+ standardSmtpMailSenderModule,
42
+ } from "@o3co/auth-provider-standard";
43
+ import { afterEach, describe, expect, it, vi } from "vitest";
44
+ import { buildModules } from "#/buildModules.mjs";
45
+ import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
46
+ import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
47
+
48
+ vi.mock("redis", () => ({
49
+ createClient: vi.fn(() => ({
50
+ connect: vi.fn().mockResolvedValue(undefined),
51
+ quit: vi.fn().mockResolvedValue(undefined),
52
+ ping: vi.fn().mockResolvedValue("PONG"),
53
+ on: vi.fn(),
54
+ })),
55
+ }));
56
+
57
+ vi.mock("connect-redis", async () => {
58
+ const { EventEmitter } = await import("node:events");
59
+ // express-session subscribes to store events, so the stand-in is an emitter.
60
+ return {
61
+ RedisStore: class MockRedisStore extends EventEmitter {
62
+ get(): unknown {
63
+ return undefined;
64
+ }
65
+ set(): void {}
66
+ destroy(): void {}
67
+ },
68
+ };
69
+ });
70
+
71
+ vi.mock("ioredis", () => {
72
+ const explicit: Record<string, unknown> = {
73
+ on: () => undefined,
74
+ quit: async () => "OK",
75
+ disconnect: () => undefined,
76
+ ping: async () => "PONG",
77
+ };
78
+ const makeMockRedis = (): object =>
79
+ new Proxy(
80
+ {},
81
+ {
82
+ get(_target, prop) {
83
+ // A function-valued `then` would make the instance a thenable.
84
+ if (typeof prop !== "string" || prop === "then") return undefined;
85
+ if (prop === "duplicate") return makeMockRedis;
86
+ if (prop in explicit) return explicit[prop];
87
+ return async () => null;
88
+ },
89
+ },
90
+ );
91
+ function MockRedis(): object {
92
+ return makeMockRedis();
93
+ }
94
+ return { Redis: MockRedis, default: MockRedis };
95
+ });
96
+
97
+ const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
98
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
99
+ const read = (rel: string): string => readFileSync(join(standaloneDir, rel), "utf8");
100
+
101
+ const OVERLAY = "docker-compose.mailpit.yml";
102
+
103
+ /** The lines of a service's block in a compose file, comments and blank lines left out. */
104
+ function serviceBlock(rel: string, name: string): string[] {
105
+ const lines = read(rel).split("\n");
106
+ const services = lines.findIndex((line) => /^services:\s*$/.test(line));
107
+ const start = lines.findIndex(
108
+ (line, at) => at > services && new RegExp(`^ {2}${name}:\\s*$`).test(line),
109
+ );
110
+ if (services === -1 || start === -1) return [];
111
+ const block: string[] = [];
112
+ for (const line of lines.slice(start + 1)) {
113
+ if (line.trim() === "" || /^\s*#/.test(line)) continue;
114
+ if (!/^ {4}/.test(line)) break;
115
+ block.push(line);
116
+ }
117
+ return block;
118
+ }
119
+
120
+ const unquote = (text: string): string => text.trim().replace(/^["']|["']$/g, "");
121
+
122
+ /** A scalar key of a service's block. */
123
+ function scalar(block: readonly string[], key: string): string | undefined {
124
+ for (const line of block) {
125
+ const match = new RegExp(`^ {4}${key}:\\s*(\\S.*)$`).exec(line);
126
+ if (match) return unquote(match[1] as string);
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ /** The entries under a key of a service's block, each trimmed. */
132
+ function entries(block: readonly string[], key: string): string[] {
133
+ const at = block.findIndex((line) => new RegExp(`^ {4}${key}:\\s*$`).test(line));
134
+ if (at === -1) return [];
135
+ const found: string[] = [];
136
+ for (const line of block.slice(at + 1)) {
137
+ if (!/^ {6}/.test(line)) break;
138
+ found.push(line.trim());
139
+ }
140
+ return found;
141
+ }
142
+
143
+ /** The `- "…"` entries of a list, unquoted. */
144
+ const listOf = (lines: readonly string[]): string[] =>
145
+ lines.map((line) => unquote(line.replace(/^-\s*/, "")));
146
+
147
+ /** `.env.example`'s uncommented lines: what a `.env` copied from it sets. */
148
+ function dotenvExample(): Record<string, string> {
149
+ const env: Record<string, string> = {};
150
+ for (const line of read(".env.example").split("\n")) {
151
+ const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
152
+ if (match) env[match[1] as string] = match[2] as string;
153
+ }
154
+ return env;
155
+ }
156
+
157
+ /**
158
+ * A service's `environment`, interpolated as compose does from the project's
159
+ * `.env`: `${VAR:-default}` is the variable when it is set and not empty, the
160
+ * default otherwise.
161
+ */
162
+ function environmentOf(
163
+ rel: string,
164
+ service: string,
165
+ dotenv: Readonly<Record<string, string>>,
166
+ ): Record<string, string> {
167
+ const env: Record<string, string> = {};
168
+ for (const line of entries(serviceBlock(rel, service), "environment")) {
169
+ const match = /^([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line);
170
+ if (!match) continue;
171
+ env[match[1] as string] = unquote(match[2] as string).replace(
172
+ /\$\{([A-Z][A-Z0-9_]*):-([^}]*)\}/g,
173
+ (_, name: string, fallback: string) => (dotenv[name] ? dotenv[name] : fallback),
174
+ );
175
+ }
176
+ return env;
177
+ }
178
+
179
+ describe("Mailpit runs only in the overlay", () => {
180
+ it("is not a service of the development or the production compose file", () => {
181
+ expect(serviceBlock("docker-compose.yml", "mailpit")).toEqual([]);
182
+ expect(serviceBlock("docker-compose.production.yml", "mailpit")).toEqual([]);
183
+ expect(read("docker-compose.production.yml")).not.toMatch(/mailpit/i);
184
+ });
185
+
186
+ it("runs a pinned Mailpit image in the app's network namespace", () => {
187
+ const mailpit = serviceBlock(OVERLAY, "mailpit");
188
+ expect(scalar(mailpit, "image")).toMatch(/^axllent\/mailpit:v\d+\.\d+\.\d+$/);
189
+ expect(scalar(mailpit, "network_mode")).toBe("service:app");
190
+ });
191
+
192
+ it("publishes Mailpit's web UI on loopback, from the app service that owns the namespace", () => {
193
+ expect(listOf(entries(serviceBlock(OVERLAY, "app"), "ports"))).toContain("127.0.0.1:8025:8025");
194
+ });
195
+
196
+ it("points the SMTP sender at Mailpit over the app's loopback, in plaintext", () => {
197
+ expect(environmentOf(OVERLAY, "app", {})).toMatchObject({
198
+ STANDARD_SMTP_MAIL_SENDER_HOST: "localhost",
199
+ STANDARD_SMTP_MAIL_SENDER_PORT: "1025",
200
+ STANDARD_SMTP_MAIL_SENDER_SECURE: "none",
201
+ });
202
+ });
203
+ });
204
+
205
+ describe("the process each development run describes", () => {
206
+ const handles: { dispose(): Promise<void> }[] = [];
207
+ afterEach(async () => {
208
+ for (const handle of handles.splice(0)) await handle.dispose();
209
+ });
210
+
211
+ const signingKey = generateKeyPairSync("ed25519", {
212
+ publicKeyEncoding: { type: "spki", format: "pem" },
213
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
214
+ });
215
+ const clientsFile = (() => {
216
+ const file = join(mkdtempSync(join(tmpdir(), "mailpit-compose-")), "clients.yaml");
217
+ writeFileSync(file, "");
218
+ return file;
219
+ })();
220
+
221
+ /** What the operator writes into `.env` beside `.env.example`'s lines: its required blanks. */
222
+ const OPERATOR: Readonly<Record<string, string>> = {
223
+ OAUTH_JWT_ISSUER: "http://localhost:3000",
224
+ KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
225
+ KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
226
+ SESSION_STORE_SECRET: "mailpit-compose-session-secret.at-least-32-bytes.ok",
227
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://users.test/authenticate",
228
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://users.test/by-token",
229
+ REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
230
+ };
231
+
232
+ /**
233
+ * The container's environment: `.env`, then each compose file's
234
+ * `environment`, the overlay's last unless `overlay` is false.
235
+ */
236
+ function containerEnv(overlay = true): Record<string, string> {
237
+ const dotenv: Record<string, string> = { ...dotenvExample(), ...OPERATOR };
238
+ // The pem pair is given inline here; the paths name files this test has not written.
239
+ delete dotenv.KEY_STORE_LOCAL_PRIVATE_KEY_PATH;
240
+ delete dotenv.KEY_STORE_LOCAL_PUBLIC_KEY_PATH;
241
+ return {
242
+ ...Object.fromEntries(Object.entries(dotenv).filter(([, value]) => value !== "")),
243
+ ...environmentOf("docker-compose.yml", "app", dotenv),
244
+ ...(overlay ? environmentOf(OVERLAY, "app", dotenv) : {}),
245
+ };
246
+ }
247
+
248
+ it("boots docker-compose.yml's run with MFA required: development, the development mail sender, the MFA stores on its Redis", async () => {
249
+ const env = containerEnv(false);
250
+ // `app.mts`: CONFIG_ENV, then NODE_ENV, then development; the run sets neither.
251
+ expect(env.CONFIG_ENV ?? env.NODE_ENV).toBeUndefined();
252
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
253
+ const own = readOwnLayers([envConfPath, applicationConfPath], { env });
254
+ const switches = readSwitches(own);
255
+ expect(switches.mfaMode).toBe("required");
256
+ // A hot reload restarts the process on every save, and Redis keeps the
257
+ // factors, and the sessions beside them, across it.
258
+ expect(switches.adapters).toMatchObject({
259
+ mfaFactorStore: "redis",
260
+ mfaTransactionStore: "redis",
261
+ });
262
+
263
+ const modules = buildModules(switches, {
264
+ environment: "development",
265
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
266
+ });
267
+ const handle = await createApp({
268
+ modules,
269
+ bootstrapComponents: {
270
+ config: resolveForBoot(own, modules, switches),
271
+ pathResolver: (s: string) => s,
272
+ logger: createRecordingLogger(),
273
+ },
274
+ });
275
+ handles.push(handle);
276
+ expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe(
277
+ "standard-development",
278
+ );
279
+ expect((handle.components.config as { mfa?: { mode?: unknown } } | undefined)?.mfa?.mode).toBe(
280
+ "required",
281
+ );
282
+ });
283
+
284
+ it("selects a configuration that is not development, so the SMTP sender is installed", () => {
285
+ const configEnv = containerEnv().CONFIG_ENV;
286
+ expect(configEnv).toBeDefined();
287
+ expect(configEnv).not.toBe("development");
288
+ // A missing `{ENV}.conf` is fatal at boot.
289
+ expect(existsSync(resolveConfigPaths(configDir, configEnv as string).envConfPath)).toBe(true);
290
+ });
291
+
292
+ it("boots with MFA on and the SMTP sender in the mailSender slot, relaying to Mailpit", async () => {
293
+ const env = containerEnv();
294
+ const configEnv = env.CONFIG_ENV as string;
295
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
296
+ const own = readOwnLayers([envConfPath, applicationConfPath], { env });
297
+ const switches = readSwitches(own);
298
+ expect(switches.mfaMode).toBe("required");
299
+
300
+ const modules = buildModules(switches, {
301
+ environment: configEnv,
302
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
303
+ });
304
+ const names = modules.map((module) => module.name);
305
+ expect(names).toContain(standardSmtpMailSenderModule.name);
306
+ expect(names).not.toContain(
307
+ standardDevelopmentMailSenderModule({ environment: "development" }).name,
308
+ );
309
+
310
+ const handle = await createApp({
311
+ modules,
312
+ bootstrapComponents: {
313
+ config: resolveForBoot(own, modules, switches),
314
+ pathResolver: (s: string) => s,
315
+ logger: createRecordingLogger(),
316
+ },
317
+ });
318
+ handles.push(handle);
319
+ expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe("standard-smtp");
320
+ expect(
321
+ (handle.components.config as Record<string, unknown> | undefined)?.[
322
+ "standard-smtp-mail-sender"
323
+ ],
324
+ ).toMatchObject({ host: "localhost", port: 1025, secure: "none" });
325
+ });
326
+ });
@@ -41,7 +41,7 @@ function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[
41
41
  * Starts `app` on 127.0.0.1, the address the raw-socket tests dial. A hostless
42
42
  * `listen(0)` binds `[::]`, and on macOS the port it gets can be one another
43
43
  * process holds on 127.0.0.1 — the request then reaches that process and can
44
- * hang until the test times out (#556).
44
+ * hang until the test times out.
45
45
  */
46
46
  function listenOnLoopback(app: express.Express): Promise<ReturnType<express.Express["listen"]>> {
47
47
  return new Promise((resolve) => {
@@ -137,14 +137,9 @@ describe("GET /metrics", () => {
137
137
  it("counts a request whose client disconnects before the response finishes", async () => {
138
138
  // `close` fires without `finish` when a client or proxy gives up
139
139
  // mid-handler, and those are disproportionately the slow and failing
140
- // requests RED metrics exist to surface.
141
- //
142
- // Sequenced on the server's own events, never on a fixed wait: the abort
143
- // goes out once the handler holds the request, and /metrics is read once
144
- // the response has closed. A 30 ms sleep either side raced a loaded
145
- // machine — an abort sent before the request reached its route was
146
- // counted as `unmatched`, or not at all, and one whose close had not yet
147
- // been processed was read as nothing.
140
+ // requests RED metrics exist to surface. Sequenced on the server's own
141
+ // events, never on a fixed wait: the abort goes out once the handler
142
+ // holds the request, and /metrics is read once the response has closed.
148
143
  const metrics = createMetrics();
149
144
  const app = express();
150
145
  app.use(metrics.middleware);
@@ -0,0 +1,122 @@
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 MFA routes' budget the template ships: every `/session/mfa` POST is
19
+ * limited under the prefix `mfa` by the wired limiter alone, and the
20
+ * template's own `reference.conf` gives both limiters `limits.mfa`, 60
21
+ * requests per 300 s. Followed through both configuration phases and
22
+ * `createApp` to the limiter each module builds, in every environment the
23
+ * template ships a file for.
24
+ */
25
+
26
+ import { fileURLToPath } from "node:url";
27
+ import {
28
+ createApp,
29
+ defineModule,
30
+ memoryRateLimiterModule,
31
+ type RateLimiter,
32
+ } from "@o3co/auth-provider-core";
33
+ import { redisRateLimiterModule } from "@o3co/auth-provider-redis";
34
+ import { afterEach, describe, expect, it } from "vitest";
35
+ import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "../configPath.mjs";
36
+ import { loggingModule } from "../modules.mjs";
37
+
38
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
39
+
40
+ /** The secrets `application.conf` substitutes: test-only values past the entropy floor. */
41
+ const SECRETS = {
42
+ KEY_STORE_LOCAL_SECRET: "test-secret-mfa-routes-budget.at-least-32-bytes.ok",
43
+ OAUTH_JWT_ISSUER: "https://auth.test",
44
+ SESSION_STORE_SECRET: "test-session-secret-mfa-routes-budget.at-least-32-bytes.ok",
45
+ MFA_MODE: "off",
46
+ };
47
+
48
+ /** The windows, in seconds, the Redis limiter asked its client to count over. */
49
+ const windows: number[] = [];
50
+
51
+ /** The limiter a consumer of the slot is handed, as the MFA routes are. */
52
+ let consumed: RateLimiter | undefined;
53
+ const consumerModule = defineModule({
54
+ name: "test:rate-limiter-consumer",
55
+ requires: ["rateLimiter"] as const,
56
+ contributes: {
57
+ grantMiddleware: [
58
+ ({ rateLimiter }) => {
59
+ consumed = rateLimiter;
60
+ return null;
61
+ },
62
+ ],
63
+ },
64
+ });
65
+
66
+ const handles: { dispose(): Promise<void> }[] = [];
67
+ afterEach(async () => {
68
+ await Promise.all(handles.splice(0).map((handle) => handle.dispose()));
69
+ consumed = undefined;
70
+ windows.splice(0);
71
+ });
72
+
73
+ /** The shipped configuration for `environment`, booted with the limiter `adapter` selects. */
74
+ async function shippedLimiter(
75
+ environment: string,
76
+ adapter: "memory" | "redis",
77
+ ): Promise<RateLimiter> {
78
+ const env = { ...SECRETS, ADAPTERS_RATE_LIMITER: adapter };
79
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, environment);
80
+ const own = readOwnLayers([envConfPath, applicationConfPath], { env });
81
+ const switches = readSwitches(own);
82
+ expect(switches.adapters.rateLimiter).toBe(adapter);
83
+ const limiterModule = adapter === "redis" ? redisRateLimiterModule : memoryRateLimiterModule;
84
+ const modules = [limiterModule, consumerModule];
85
+ const handle = await createApp({
86
+ modules,
87
+ bootstrapComponents: {
88
+ config: resolveForBoot(own, [...modules, loggingModule], switches),
89
+ pathResolver: (s: string) => s,
90
+ // Counts one request per key, the way an untouched Redis counter would.
91
+ rateLimiterClient: {
92
+ incrementWithTtl: async (_key: string, windowSeconds: number) => {
93
+ windows.push(windowSeconds);
94
+ return 1;
95
+ },
96
+ } as never,
97
+ },
98
+ });
99
+ handles.push(handle);
100
+ if (consumed === undefined) throw new Error("the boot handed no consumer the limiter");
101
+ return consumed;
102
+ }
103
+
104
+ describe("the MFA routes' budget the template ships", () => {
105
+ it.each([
106
+ ["development", "memory"],
107
+ ["development", "redis"],
108
+ ["production", "memory"],
109
+ ["production", "redis"],
110
+ ] as const)("is 60 per 300 s under %s on the %s limiter", async (environment, adapter) => {
111
+ const limiter = await shippedLimiter(environment, adapter);
112
+ const before = Date.now();
113
+ const decision = await limiter.check("mfa:ip:203.0.113.7", { ip: "203.0.113.7" });
114
+ expect(decision.allowed).toBe(true);
115
+ expect(decision.limit).toBe(60);
116
+ const windowSeconds =
117
+ adapter === "redis"
118
+ ? windows[0]
119
+ : Math.round(((decision.resetAt?.getTime() ?? 0) - before) / 1000);
120
+ expect(windowSeconds).toBe(300);
121
+ });
122
+ });
@@ -0,0 +1,123 @@
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 under the name production, where the MFA stores
19
+ * must be shared: both on Redis, as a production deployment selects them.
20
+ * ioredis is a stand-in, as in `all-modules-composition.multi.test.mts`:
21
+ * nothing here issues a command, and the stores' durability check names what
22
+ * it could not read and goes on. `packages/redis` runs the stores against a
23
+ * real server.
24
+ */
25
+
26
+ import { BootError } from "@o3co/auth-provider-core";
27
+ import { MFA_DEVELOPMENT_SAMPLE_KEY } from "@o3co/auth-provider-mfa";
28
+ import { afterEach, describe, expect, it, vi } from "vitest";
29
+ import { type Composition, compose, SINGLE_ENV } from "./all-modules-composition.fixture.mjs";
30
+
31
+ vi.mock("ioredis", () => {
32
+ // Constructible, quits cleanly, answers the readiness probe's `ping`;
33
+ // every other command resolves to nothing.
34
+ const explicit: Record<string, unknown> = {
35
+ on: () => undefined,
36
+ quit: async () => "OK",
37
+ disconnect: () => undefined,
38
+ ping: async () => "PONG",
39
+ };
40
+ const makeMockRedis = (): object =>
41
+ new Proxy(
42
+ {},
43
+ {
44
+ get(_target, prop) {
45
+ // A function-valued `then` would make the instance a thenable.
46
+ if (typeof prop !== "string" || prop === "then") return undefined;
47
+ if (prop === "duplicate") return makeMockRedis;
48
+ if (prop in explicit) return explicit[prop];
49
+ return async () => null;
50
+ },
51
+ },
52
+ );
53
+ function MockRedis(): object {
54
+ return makeMockRedis();
55
+ }
56
+ return { Redis: MockRedis, default: MockRedis };
57
+ });
58
+
59
+ let current: Composition | undefined;
60
+
61
+ afterEach(async () => {
62
+ await current?.handle.dispose();
63
+ current = undefined;
64
+ });
65
+
66
+ /** MFA required in production, both MFA stores on Redis, a key of the deployment's own and an SMTP relay. */
67
+ const PRODUCTION_ENV: Readonly<Record<string, string>> = {
68
+ ...SINGLE_ENV,
69
+ MFA_MODE: "required",
70
+ ADAPTERS_MFA_FACTOR_STORE: "redis",
71
+ ADAPTERS_MFA_TRANSACTION_STORE: "redis",
72
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
73
+ MFA_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"),
74
+ STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
75
+ STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
76
+ };
77
+
78
+ /** An error's message with every cause's under it. */
79
+ const textOf = (err: unknown): string => {
80
+ const parts: string[] = [];
81
+ for (let at: unknown = err; at instanceof Error; at = at.cause) parts.push(at.message);
82
+ return parts.join(" <- ");
83
+ };
84
+
85
+ const refusal = (composing: Promise<Composition>): Promise<unknown> =>
86
+ composing.then(
87
+ (composition) => {
88
+ current = composition;
89
+ return undefined;
90
+ },
91
+ (caught: unknown) => caught,
92
+ );
93
+
94
+ describe("MFA_MODE=required under the name production, the MFA stores on Redis", () => {
95
+ it("boots, the requirement registered as the second-factor authority", async () => {
96
+ current = await compose({ env: PRODUCTION_ENV, environment: "production" });
97
+ const names = current.modules.map((m) => m.name);
98
+ expect(names).toContain("redis-mfa-factor-store");
99
+ expect(names).toContain("redis-mfa-transaction-store");
100
+ expect(
101
+ current.handle.components.sessionRequirementResolver?.get("mfa")?.secondFactorAuthority,
102
+ ).toBe(true);
103
+ });
104
+
105
+ it("refuses the development sample key, naming MFA_ENCRYPTION_KEY and quoting no key", async () => {
106
+ const err = await refusal(
107
+ compose({
108
+ env: { ...PRODUCTION_ENV, MFA_ENCRYPTION_KEY: MFA_DEVELOPMENT_SAMPLE_KEY },
109
+ environment: "production",
110
+ }),
111
+ );
112
+ expect(err).toBeInstanceOf(BootError);
113
+ expect(textOf(err)).toContain("MFA_ENCRYPTION_KEY");
114
+ expect(textOf(err)).not.toContain(MFA_DEVELOPMENT_SAMPLE_KEY);
115
+ });
116
+
117
+ it("refuses without an SMTP relay, naming STANDARD_SMTP_MAIL_SENDER_HOST", async () => {
118
+ const { STANDARD_SMTP_MAIL_SENDER_HOST: _host, ...env } = PRODUCTION_ENV;
119
+ const err = await refusal(compose({ env, environment: "production" }));
120
+ expect(err).toBeInstanceOf(BootError);
121
+ expect(textOf(err)).toContain("STANDARD_SMTP_MAIL_SENDER_HOST");
122
+ });
123
+ });