@o3co/create-auth-provider 0.15.0 → 0.17.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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  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 +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  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 +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -15,22 +15,19 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #287 — the audit-event pipeline existed and the scaffold wired no sink, so
19
- * every security-relevant event the routes emit (`token.issued.failure`,
20
- * `authorize.rejected`, `rate_limit.unavailable`, …) was dropped by the
21
- * artifact operators actually deploy. `emitAuditEvent` is a no-op when the
22
- * slot is empty, so nothing failed and nothing warned: the deployment simply
23
- * had no audit trail.
18
+ * The scaffold wires an audit sink. `emitAuditEvent` is a no-op when the slot
19
+ * is empty, so without one every security-relevant event the routes emit
20
+ * (`token.issued.failure`, `authorize.rejected`, `rate_limit.unavailable`, …)
21
+ * is dropped, and nothing fails or warns.
24
22
  *
25
- * These tests pin the closure from three sides — the sink implementation, the
26
- * module that resolves it from config, and the manifest that must contain
27
- * exactly one provider for the slot.
23
+ * Pinned from three sides: the sink implementation, the module that resolves
24
+ * it from config, and the manifest, which must contain exactly one provider
25
+ * for the slot.
28
26
  */
29
27
 
30
28
  import { generateKeyPairSync } from "node:crypto";
31
29
  import { readFileSync } from "node:fs";
32
30
  import {
33
- type AppConfig,
34
31
  type AuditEvent,
35
32
  type AuditSink,
36
33
  createApp,
@@ -42,34 +39,56 @@ import {
42
39
  memoryRefreshTokenFamilyStoreModule,
43
40
  registerBuiltinKeyStores,
44
41
  } from "@o3co/auth-provider-core";
42
+ import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
45
43
  import express from "express";
46
44
  import request from "supertest";
47
45
  import { afterEach, describe, expect, it, vi } from "vitest";
48
46
  import { buildModules } from "../buildModules.mjs";
47
+ import type { Switches } from "../configPath.mjs";
49
48
  import { createAppLogger, createAuditLogger, createLoggerAuditSink } from "../logger.mjs";
50
- import { auditSinkModule } from "../modules.mjs";
49
+ import { auditSinkModuleFor } from "../modules.mjs";
50
+ import { capturedRenames, shippedAdapters } from "./library-references.fixture.mjs";
51
51
 
52
52
  const keyPair = generateKeyPairSync("ed25519", {
53
53
  publicKeyEncoding: { type: "spki", format: "pem" },
54
54
  privateKeyEncoding: { type: "pkcs8", format: "pem" },
55
55
  });
56
56
 
57
- const baseConfig: AppConfig = {
58
- http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
57
+ /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
58
+ const baseConfig: Switches & Record<string, unknown> = {
59
+ // The shipped selections, every store in this process.
60
+ adapters: {
61
+ ...shippedAdapters(),
62
+ accessTokenDenylist: "memory",
63
+ replaySeenSet: "memory",
64
+ codeRepository: "memory",
65
+ userRepository: "yaml",
66
+ },
67
+ mfaMode: "off",
68
+ storeTransport: undefined,
69
+ // What a resolution under an environment that sets none captures of
70
+ // core's renamed variables.
71
+ ...{
72
+ "renamed-variables": capturedRenames({}),
73
+ },
74
+ http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000, cors: { allowedOrigins: [] } },
59
75
  logging: { level: "silent" },
76
+ "key-store": {
77
+ provider: "local",
78
+ local: {
79
+ algorithm: "EdDSA",
80
+ kid: "v0",
81
+ privateKey: keyPair.privateKey,
82
+ publicKey: keyPair.publicKey,
83
+ previousKeys: [],
84
+ },
85
+ },
86
+ // The shipped `application.conf` expects no session requirement (ADR
87
+ // 2026-09-28-session-admission).
88
+ ...coreConfigForTests({ federations: { google: { enabled: false } } }),
60
89
  oauth: {
61
90
  jwt: {
62
91
  issuer: "https://auth.test",
63
- signingKey: {
64
- provider: "local",
65
- local: {
66
- algorithm: "EdDSA",
67
- kid: "v0",
68
- privateKey: keyPair.privateKey,
69
- publicKey: keyPair.publicKey,
70
- previousKeys: [],
71
- },
72
- },
73
92
  },
74
93
  accessToken: { expiresIn: 3600 },
75
94
  refreshToken: {
@@ -79,9 +98,8 @@ const baseConfig: AppConfig = {
79
98
  },
80
99
  grants: {},
81
100
  oidcMode: "oidc-required",
82
- code: { adapter: "memory" as const },
83
101
  },
84
- session: {
102
+ "session-store": {
85
103
  secret: "test-session-secret.at-least-32-bytes.ok",
86
104
  name: "auth.sid",
87
105
  maxAge: 3600000,
@@ -90,19 +108,12 @@ const baseConfig: AppConfig = {
90
108
  domain: null,
91
109
  storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
92
110
  },
93
- rateLimit: {
94
- login: { windowMs: 60000, limit: 10 },
95
- failMode: "open",
96
- },
97
- federations: { google: { enabled: false } },
98
- repositories: {
99
- client: { type: "yaml", path: "./config/clients.yaml" },
100
- user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
101
- code: { type: "memory", defaultExpiresIn: 600 },
111
+ session: {
112
+ loginPage: { url: "/login" },
113
+ rateLimit: { login: { windowMs: 60000, limit: 10 } },
102
114
  },
103
- endpoints: { login: { url: "/login" } },
104
- cors: { allowedOrigins: [] },
105
- audit: { sink: { type: "logger" } },
115
+ rateLimit: { failMode: "open" },
116
+ "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
106
117
  };
107
118
 
108
119
  const CLIENT_ID = "audit-client";
@@ -143,7 +154,7 @@ const testKeyStoreModule = defineModule({
143
154
  registerBuiltinKeyStores(factory);
144
155
  return factory.create({
145
156
  type: "local",
146
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
157
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
147
158
  });
148
159
  },
149
160
  },
@@ -163,14 +174,14 @@ function fakeLogger(): Logger & { info: ReturnType<typeof vi.fn> } {
163
174
  return self as unknown as Logger & { info: ReturnType<typeof vi.fn> };
164
175
  }
165
176
 
166
- /** Resolve `auditSink` the way the boot planner does: call the module's provider. */
167
- async function resolveSink(config: AppConfig): Promise<AuditSink> {
168
- const provider = auditSinkModule.provides?.auditSink;
169
- if (!provider) throw new Error("auditSinkModule must provide the auditSink slot");
170
- return (await provider({ config } as never)) as AuditSink;
177
+ /** Resolve `auditSink` the way the boot planner does: call the module's provider with its section. */
178
+ async function resolveSink(sink: string, section?: Record<string, unknown>): Promise<AuditSink> {
179
+ const provider = auditSinkModuleFor(sink).provides?.auditSink;
180
+ if (!provider) throw new Error("the audit-sink module must provide the auditSink slot");
181
+ return (await provider({ section } as never)) as AuditSink;
171
182
  }
172
183
 
173
- describe("#287: the template's audit sink", () => {
184
+ describe("the template's audit sink", () => {
174
185
  describe("createLoggerAuditSink — one event, one line, through the app's own JSON stream", () => {
175
186
  it("records the event through the injected logger", async () => {
176
187
  const logger = fakeLogger();
@@ -218,11 +229,10 @@ describe("#287: the template's audit sink", () => {
218
229
 
219
230
  describe("the audit trail is not gated by logging.level", () => {
220
231
  it("keeps the audit logger at info while the app logger is silenced", () => {
221
- // An audit trail is evidence, not diagnostics. `LOG_LEVEL=warn` is an
222
- // ordinary production setting and `silent` is a legitimate one; if
223
- // either silenced audit events, #287 would be back — the same silent
224
- // drop, reached from the operator's side instead of the scaffold's.
225
- const appLogger = createAppLogger({ ...baseConfig, logging: { level: "silent" } });
232
+ // An audit trail is evidence, not diagnostics. `LOGGING_LEVEL=warn` is an
233
+ // ordinary production setting and `silent` is a legitimate one;
234
+ // neither may silently drop audit events.
235
+ const appLogger = createAppLogger({ level: "silent" });
226
236
  const auditLogger = createAuditLogger();
227
237
  expect((appLogger as unknown as { level: string }).level).toBe("silent");
228
238
  expect((auditLogger as unknown as { level: string }).level).toBe("info");
@@ -236,31 +246,24 @@ describe("#287: the template's audit sink", () => {
236
246
  });
237
247
  });
238
248
 
239
- describe("auditSinkModule — resolves the sink from config", () => {
240
- it("resolves the logger sink for the shipped default", async () => {
241
- expect((await resolveSink(baseConfig)).kind).toBe("logger");
249
+ describe("the audit-sink module — builds the sink adapters.auditSink selects", () => {
250
+ it("builds the logger sink for the shipped selection", async () => {
251
+ expect((await resolveSink(baseConfig.adapters.auditSink)).kind).toBe("logger");
242
252
  });
243
253
 
244
- it("resolves core's built-in console sink when selected", async () => {
245
- const sink = await resolveSink({ ...baseConfig, audit: { sink: { type: "console" } } });
246
- expect(sink.kind).toBe("console");
254
+ it("builds core's built-in console sink when selected", async () => {
255
+ expect((await resolveSink("console")).kind).toBe("console");
247
256
  });
248
257
 
249
- it("still produces a sink when the audit section is absent entirely", async () => {
250
- // The failure #287 describes is silent absence. A config that says
251
- // nothing about auditing must land on a sink, not on `undefined`.
252
- const { audit: _audit, ...withoutAudit } = baseConfig;
253
- const sink = await resolveSink(withoutAudit as AppConfig);
254
- expect(sink.kind).toBe("logger");
258
+ it("builds a sink with no audit-sink section at all", async () => {
259
+ expect((await resolveSink("logger", undefined)).kind).toBe("logger");
255
260
  });
256
261
 
257
- it("refuses an unknown sink type at boot, naming what is registered", async () => {
258
- // There is no "none" — #304's sink policy. An operator who writes one
259
- // gets a boot failure naming the sinks that exist, not a silent
260
- // deployment with no audit trail.
261
- await expect(
262
- resolveSink({ ...baseConfig, audit: { sink: { type: "none" } } }),
263
- ).rejects.toThrow(/none/);
262
+ it("refuses a sink no builder is registered under, naming it", async () => {
263
+ // There is no "none" sink. An operator who selects one gets a boot
264
+ // failure naming the sinks that exist, not a silent deployment with
265
+ // no audit trail.
266
+ await expect(resolveSink("none")).rejects.toThrow(/none/);
264
267
  });
265
268
  });
266
269
 
@@ -273,13 +276,13 @@ describe("#287: the template's audit sink", () => {
273
276
  });
274
277
  const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
275
278
  expect(providers).toHaveLength(1);
276
- expect(providers[0]?.name).toBe("standalone:audit-sink");
279
+ expect(providers[0]?.name).toBe("audit-sink");
277
280
  });
278
281
 
279
- it("the shipped application.conf selects a sink, and never 'none'", () => {
280
- const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
281
- expect(conf).toMatch(/audit\s*\{[\s\S]*?sink\s*\{[\s\S]*?type\s*=\s*"logger"/);
282
- expect(conf).not.toMatch(/type\s*=\s*"none"/);
282
+ it("the shipped reference.conf selects a sink, and never 'none'", () => {
283
+ const conf = readFileSync(new URL("../../config/reference.conf", import.meta.url), "utf8");
284
+ expect(conf).toMatch(/adapters\s*\{[\s\S]*?auditSink\s*=\s*"logger"/);
285
+ expect(conf).not.toMatch(/auditSink\s*=\s*"none"/);
283
286
  });
284
287
  });
285
288
 
@@ -292,11 +295,10 @@ describe("#287: the template's audit sink", () => {
292
295
  });
293
296
 
294
297
  it("records token.issued.failure for an unsupported grant_type", async () => {
295
- // Before #287 this assertion could not fail: nothing filled the slot,
296
- // so `emitAuditEvent` returned without recording anything.
298
+ // `emitAuditEvent` records nothing when the slot is empty.
297
299
  const logger = fakeLogger();
298
300
  const spyAuditModule = defineModule({
299
- name: "standalone:audit-sink",
301
+ name: "audit-sink",
300
302
  provides: { auditSink: () => createLoggerAuditSink(logger) },
301
303
  });
302
304
  const handle = await createApp({
@@ -16,8 +16,10 @@
16
16
 
17
17
  import { existsSync } from "node:fs";
18
18
  import { isAbsolute } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { coreReference } from "@o3co/auth-provider-core";
19
21
  import { describe, expect, it } from "vitest";
20
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
22
+ import { resolveConfigPaths } from "../configPath.mjs";
21
23
 
22
24
  describe("resolveConfigPaths", () => {
23
25
  it("accepts a configDirPath with a trailing slash (regression: fileURLToPath preserves trailing /)", () => {
@@ -51,17 +53,17 @@ describe("resolveConfigPaths", () => {
51
53
  });
52
54
  });
53
55
 
54
- describe("resolveLibraryReferenceConfPath", () => {
55
- it("returns an absolute path ending in reference.conf", () => {
56
- const path = resolveLibraryReferenceConfPath();
57
- expect(path.endsWith("reference.conf")).toBe(true);
58
- // Use `isAbsolute` rather than `startsWith("/")` so the assertion
59
- // holds on Windows (e.g. `C:\...`) as well as POSIX paths.
56
+ describe("core's reference.conf, as the template layers it", () => {
57
+ // `coreReference()` names the file `@o3co/auth-provider-core` exports as
58
+ // `./reference.conf`: the one a composition root that installed the
59
+ // package would resolve, and the bottom of every layering the template
60
+ // makes (`readSwitches`, `resolveForBoot`).
61
+ it("is the file the package exports as ./reference.conf, and it exists", () => {
62
+ const path = fileURLToPath(coreReference());
60
63
  expect(isAbsolute(path)).toBe(true);
61
- });
62
-
63
- it("points to a file that exists on disk", () => {
64
- const path = resolveLibraryReferenceConfPath();
65
64
  expect(existsSync(path)).toBe(true);
65
+ expect(path).toBe(
66
+ fileURLToPath(import.meta.resolve("@o3co/auth-provider-core/reference.conf")),
67
+ );
66
68
  });
67
69
  });
@@ -0,0 +1,226 @@
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 federations the template ships, in core's own section:
19
+ * `core.federations.google` and `core.federations.oidc`, each bound to the
20
+ * variables named after its paths (`CORE_FEDERATIONS_<NAME>_<KEY>`). Phase
21
+ * one chooses the federation modules from the map there. A variable the
22
+ * template bound before, `FEDERATIONS_<NAME>_<KEY>`, set alone or beside its
23
+ * new name at a different value is refused before any module is chosen,
24
+ * naming the new variable and path and never a value; the two at one value
25
+ * are accepted. The map written at the top level refuses boot, naming its
26
+ * paths under `core.federations`.
27
+ */
28
+
29
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { createApp } from "@o3co/auth-provider-core";
34
+ import { afterAll, describe, expect, it } from "vitest";
35
+ import { buildModules } from "#/buildModules.mjs";
36
+ import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
37
+
38
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
39
+
40
+ /** The directories the operator layers are written to, removed after the suite. */
41
+ const operatorDirs: string[] = [];
42
+ afterAll(() => {
43
+ for (const dir of operatorDirs) rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ /** The template's own files for the production environment, under an operator's layer when given. */
47
+ function ownFiles(hocon?: string): string[] {
48
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
49
+ if (hocon === undefined) return [envConfPath, applicationConfPath];
50
+ const dir = mkdtempSync(join(tmpdir(), "core-federations-"));
51
+ operatorDirs.push(dir);
52
+ const file = join(dir, "operator.conf");
53
+ writeFileSync(file, hocon);
54
+ return [file, envConfPath, applicationConfPath];
55
+ }
56
+
57
+ /** MFA, on by default, switched off: these are about the federations. */
58
+ const MFA_OFF: Readonly<Record<string, string>> = { MFA_MODE: "off" };
59
+
60
+ /** Phase one under `env` and an operator's `hocon`. */
61
+ const switchesFrom = (env: Record<string, string> = {}, hocon?: string) =>
62
+ readSwitches(readOwnLayers(ownFiles(hocon), { env: { ...MFA_OFF, ...env } }));
63
+
64
+ /** What phase one refuses under `env`. */
65
+ function refusal(env: Record<string, string>): Error {
66
+ try {
67
+ switchesFrom(env);
68
+ } catch (err) {
69
+ return err as Error;
70
+ }
71
+ throw new Error("phase one read the switches");
72
+ }
73
+
74
+ /** The federations phase one reads, by name. */
75
+ const federationsOf = (env: Record<string, string> = {}) =>
76
+ (switchesFrom(env).core as { federations?: Record<string, Record<string, unknown>> } | undefined)
77
+ ?.federations ?? {};
78
+
79
+ /** The modules phase one chooses under `env`, by name. */
80
+ const moduleNames = (env: Record<string, string>) =>
81
+ buildModules(switchesFrom(env), { environment: "production" }).map((module) => module.name);
82
+
83
+ /** Each variable the template binds: its key under the federation, the old name and the new. */
84
+ const RENAMED = [
85
+ ["google", "enabled", "FEDERATIONS_GOOGLE_ENABLED", "CORE_FEDERATIONS_GOOGLE_ENABLED", "true"],
86
+ [
87
+ "google",
88
+ "clientId",
89
+ "FEDERATIONS_GOOGLE_CLIENT_ID",
90
+ "CORE_FEDERATIONS_GOOGLE_CLIENT_ID",
91
+ "g-id",
92
+ ],
93
+ [
94
+ "google",
95
+ "clientSecret",
96
+ "FEDERATIONS_GOOGLE_CLIENT_SECRET",
97
+ "CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET",
98
+ "g-secret",
99
+ ],
100
+ [
101
+ "google",
102
+ "callbackURL",
103
+ "FEDERATIONS_GOOGLE_CALLBACK_URL",
104
+ "CORE_FEDERATIONS_GOOGLE_CALLBACK_URL",
105
+ "https://auth.test/session/oauth/federation/google/callback",
106
+ ],
107
+ [
108
+ "google",
109
+ "accessType",
110
+ "FEDERATIONS_GOOGLE_ACCESS_TYPE",
111
+ "CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE",
112
+ "online",
113
+ ],
114
+ ["oidc", "enabled", "FEDERATIONS_OIDC_ENABLED", "CORE_FEDERATIONS_OIDC_ENABLED", "true"],
115
+ ["oidc", "issuer", "FEDERATIONS_OIDC_ISSUER", "CORE_FEDERATIONS_OIDC_ISSUER", "https://idp.test"],
116
+ ["oidc", "clientId", "FEDERATIONS_OIDC_CLIENT_ID", "CORE_FEDERATIONS_OIDC_CLIENT_ID", "o-id"],
117
+ [
118
+ "oidc",
119
+ "clientSecret",
120
+ "FEDERATIONS_OIDC_CLIENT_SECRET",
121
+ "CORE_FEDERATIONS_OIDC_CLIENT_SECRET",
122
+ "o-secret",
123
+ ],
124
+ [
125
+ "oidc",
126
+ "callbackURL",
127
+ "FEDERATIONS_OIDC_CALLBACK_URL",
128
+ "CORE_FEDERATIONS_OIDC_CALLBACK_URL",
129
+ "https://auth.test/session/oauth/federation/oidc/callback",
130
+ ],
131
+ ] as const;
132
+
133
+ describe("the federations the template ships, under core.federations", () => {
134
+ it("ships google and oidc disabled, oidc of type oidc", () => {
135
+ const federations = federationsOf();
136
+ expect(federations.google?.enabled).toBe(false);
137
+ expect(federations.oidc).toMatchObject({ enabled: false, type: "oidc" });
138
+ });
139
+
140
+ it.each(RENAMED)(
141
+ "reads core.federations.%s.%s from %s's new name",
142
+ (name, key, _old, variable, value) => {
143
+ expect(String(federationsOf({ [variable]: value })[name]?.[key])).toBe(value);
144
+ },
145
+ );
146
+
147
+ it("chooses the Google federation's modules when CORE_FEDERATIONS_GOOGLE_ENABLED is true, and none while it is not", () => {
148
+ expect(moduleNames({ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" })).toContain("federation-google");
149
+ expect(moduleNames({})).not.toContain("federation-google");
150
+ });
151
+
152
+ it("chooses an OIDC federation's module for an entry of type oidc written under core.federations", () => {
153
+ const names = buildModules(
154
+ switchesFrom(
155
+ {},
156
+ 'core.federations.okta { enabled = true, type = "oidc", issuer = "https://okta.test", clientId = "c", clientSecret = "s", callbackURL = "https://auth.test/session/oauth/federation/okta/callback" }\n',
157
+ ),
158
+ { environment: "production" },
159
+ ).map((module) => module.name);
160
+ expect(names).toContain("federation-oidc-okta");
161
+ });
162
+ });
163
+
164
+ describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () => {
165
+ it.each(RENAMED)(
166
+ "core.federations.%s.%s: %s set alone is refused before any module is chosen, naming %s",
167
+ (name, key, old, variable, value) => {
168
+ const err = refusal({ [old]: value });
169
+ expect(err).toBeInstanceOf(RangeError);
170
+ expect(err.message).toContain(`${old} was renamed ${variable}`);
171
+ expect(err.message).toContain(`core.federations.${name}.${key}`);
172
+ },
173
+ );
174
+
175
+ it.each(RENAMED)(
176
+ "core.federations.%s.%s: %s beside its new name at a different value is refused, quoting neither",
177
+ (_name, _key, old, variable) => {
178
+ const err = refusal({ [old]: "old-value-5e2d", [variable]: "new-value-c81a" });
179
+ expect(err.message).toContain(old);
180
+ expect(err.message).not.toContain("old-value-5e2d");
181
+ expect(err.message).not.toContain("new-value-c81a");
182
+ },
183
+ );
184
+
185
+ it.each(RENAMED)(
186
+ "core.federations.%s.%s: %s beside its new name at the same value is read",
187
+ (name, key, old, variable, value) => {
188
+ expect(String(federationsOf({ [old]: value, [variable]: value })[name]?.[key])).toBe(value);
189
+ },
190
+ );
191
+ });
192
+
193
+ describe("the map written at the top level", () => {
194
+ it("refuses boot, naming its paths under core.federations", async () => {
195
+ const own = readOwnLayers(ownFiles("federations.okta.enabled = false\n"), {
196
+ env: {
197
+ ...MFA_OFF,
198
+ KEY_STORE_LOCAL_SECRET: "core-federations-secret.at-least-32-bytes.ok",
199
+ OAUTH_JWT_ISSUER: "https://auth.test",
200
+ SESSION_STORE_SECRET: "core-federations-session.at-least-32-bytes.ok",
201
+ },
202
+ });
203
+ const switches = readSwitches(own);
204
+ const modules = buildModules(switches, { environment: "production" });
205
+ await expect(
206
+ createApp({
207
+ modules,
208
+ bootstrapComponents: {
209
+ config: resolveForBoot(own, modules, switches),
210
+ pathResolver: (s: string) => s,
211
+ } as never,
212
+ }),
213
+ ).rejects.toMatchObject({
214
+ reason: "config-path-relocated",
215
+ details: {
216
+ relocated: [
217
+ expect.objectContaining({
218
+ from: "federations.okta.enabled",
219
+ to: "core.federations.okta.enabled",
220
+ environmentVariable: "CORE_FEDERATIONS_OKTA_ENABLED",
221
+ }),
222
+ ],
223
+ },
224
+ });
225
+ });
226
+ });