@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.3

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 (70) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +7 -5
  5. package/templates/standalone/README.ja.md +53 -25
  6. package/templates/standalone/README.md +140 -58
  7. package/templates/standalone/config/application.conf +41 -36
  8. package/templates/standalone/config/clients.yaml.example +8 -0
  9. package/templates/standalone/config/development.conf +2 -2
  10. package/templates/standalone/config/mailpit.conf +10 -5
  11. package/templates/standalone/config/reference.conf +30 -5
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/docker-compose.production.yml +5 -2
  14. package/templates/standalone/package.json +1 -1
  15. package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
  16. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  17. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  18. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
  19. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  20. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  21. package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
  22. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  23. package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
  24. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  25. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  26. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
  27. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
  28. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  29. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  30. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  31. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  32. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  33. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  34. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  35. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
  36. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  37. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  38. package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
  39. package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
  40. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  41. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  42. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  43. package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
  44. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
  45. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  46. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  47. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  48. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  49. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  50. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
  51. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  52. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  53. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  54. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
  55. package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
  56. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  57. package/templates/standalone/src/adapters.mts +31 -18
  58. package/templates/standalone/src/app.mts +6 -5
  59. package/templates/standalone/src/bootRefusal.mts +117 -0
  60. package/templates/standalone/src/buildModules.mts +46 -96
  61. package/templates/standalone/src/configPath.mts +180 -153
  62. package/templates/standalone/src/mfaSwitch.mts +49 -27
  63. package/templates/standalone/src/modules.mts +37 -219
  64. package/templates/standalone/src/rootRenames.mts +38 -22
  65. package/templates/standalone/src/sections.mts +13 -2
  66. package/templates/standalone/tests/index.test.js +4 -3
  67. package/templates/versions.json +18 -18
  68. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  69. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  70. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -28,7 +28,6 @@
28
28
 
29
29
  import { fileURLToPath } from "node:url";
30
30
  import {
31
- AppConfigSchema,
32
31
  createApp,
33
32
  createKeyStoreFactory,
34
33
  defineModule,
@@ -39,19 +38,18 @@ import {
39
38
  } from "@o3co/auth-provider-core";
40
39
  import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
41
40
  import { parseFile } from "@o3co/ts.hocon";
42
- import { validate } from "@o3co/ts.hocon/zod";
43
41
  import express from "express";
44
42
  import request from "supertest";
45
43
  import { afterEach, describe, expect, it, vi } from "vitest";
46
44
  import { buildModules } from "../buildModules.mjs";
47
- import { resolveConfigPaths, type Switches } from "../configPath.mjs";
45
+ import { resolveConfigPaths } from "../configPath.mjs";
48
46
  import { templateReference } from "../modules.mjs";
49
47
  import { installGracefulShutdown } from "../shutdown.mjs";
50
48
  import {
49
+ type BothPhases,
50
+ bothPhasesOf,
51
51
  capturedRenames,
52
52
  libraryLayers,
53
- rootSectionsOf,
54
- sectionsCoreDoesNotDeclare,
55
53
  } from "./library-references.fixture.mjs";
56
54
 
57
55
  // The same stand-ins `replica-safety.test.mts` boots under: no socket opens,
@@ -87,6 +85,10 @@ vi.mock("ioredis", () => {
87
85
  quit: async () => "OK",
88
86
  disconnect: () => undefined,
89
87
  ping: async () => "PONG",
88
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
89
+ // default server's.
90
+ info: async (section: string) =>
91
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
90
92
  };
91
93
  const makeMockRedis = (): object =>
92
94
  new Proxy(
@@ -140,6 +142,7 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
140
142
  SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
141
143
  ADAPTERS_USER_SESSION_STORES: "redis",
142
144
  ADAPTERS_RATE_LIMITER: "redis",
145
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
143
146
  ADAPTERS_CODE_REPOSITORY: "redis",
144
147
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
145
148
  ADAPTERS_REPLAY_SEEN_SET: "redis",
@@ -156,29 +159,24 @@ const GRANTS_ON: Readonly<Record<string, string>> = {
156
159
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
157
160
  };
158
161
 
159
- function resolveConfig(env: Record<string, string>): Switches {
162
+ function resolveConfig(env: Record<string, string>): BothPhases {
160
163
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
161
164
  const layers = parseFile(envConfPath, { env })
162
165
  .withFallback(parseFile(applicationConfPath, { env }))
163
166
  .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
164
167
  .withFallback(libraryLayers(env));
165
- const config = {
166
- ...sectionsCoreDoesNotDeclare(layers),
167
- ...validate(layers, AppConfigSchema),
168
- ...rootSectionsOf(layers, env),
169
- };
168
+ const config = bothPhasesOf(layers, env);
170
169
  // The key ring has no environment form (a list of { id, key } is HOCON's);
171
170
  // the Redis grant store refuses to construct without one under "required".
172
171
  return {
173
172
  ...config,
174
- // What the resolution captured of core's renamed variables, which the
175
- // schema's parse drops.
173
+ // What the resolution captured of the renamed variables.
176
174
  "renamed-variables": capturedRenames(env),
177
175
  "redis-federation-grant-store": {
178
176
  ...(config["redis-federation-grant-store"] as object | undefined),
179
177
  encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
180
178
  },
181
- } as Switches;
179
+ } as BothPhases;
182
180
  }
183
181
 
184
182
  const testRepositoriesModule = defineModule({
@@ -204,7 +202,7 @@ const testKeyStoreModule = defineModule({
204
202
  },
205
203
  });
206
204
 
207
- const modulesFor = (config: Switches, memoryOnly = false, environment?: string) =>
205
+ const modulesFor = (config: BothPhases, memoryOnly = false, environment?: string) =>
208
206
  buildModules(config, {
209
207
  keyStoreModule: testKeyStoreModule,
210
208
  repositoriesModule: testRepositoriesModule,
@@ -212,13 +210,13 @@ const modulesFor = (config: Switches, memoryOnly = false, environment?: string)
212
210
  ...(environment === undefined ? {} : { environment }),
213
211
  });
214
212
 
215
- const boot = (config: Switches, memoryOnly = false, environment?: string) =>
213
+ const boot = (config: BothPhases, memoryOnly = false, environment?: string) =>
216
214
  createApp({
217
215
  modules: modulesFor(config, memoryOnly, environment),
218
216
  bootstrapComponents: { config, pathResolver: (s) => s },
219
217
  });
220
218
 
221
- const names = (config: Switches, memoryOnly = false) =>
219
+ const names = (config: BothPhases, memoryOnly = false) =>
222
220
  modulesFor(config, memoryOnly).map((m) => m.name);
223
221
 
224
222
  /**
@@ -322,7 +320,7 @@ describe("the standalone composes federation grants from its config", () => {
322
320
  );
323
321
  expect(installed).not.toContain("redis-federation-grant-store");
324
322
  expect(installed).not.toContain("redis-federation-grant-intent-store");
325
- // Listed ahead of oauthModule, as the template writes them: the
323
+ // Listed ahead of oauthEndpointsModule, as the template writes them: the
326
324
  // template's choice, since each module parses its own bodies and the
327
325
  // order does not change that; the browser half sits after the session
328
326
  // middleware by its own `after`.
@@ -399,7 +397,7 @@ describe("the standalone composes federation grants from its config", () => {
399
397
  upstreamHardTimeoutMs: 60_000,
400
398
  refreshLockTtlMs: 65_000,
401
399
  },
402
- } as Switches;
400
+ } as BothPhases;
403
401
  handleRef = await boot(raised, true);
404
402
  expect(handleRef.cleanupAllowanceMs).toBe(60_000 + 3_000 + 5_000 + 12_000);
405
403
 
@@ -619,7 +617,7 @@ describe("the browser consent route parses its own body, with sessionModule list
619
617
  });
620
618
 
621
619
  /** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
622
- const sessionFirst = (config: Switches) => {
620
+ const sessionFirst = (config: BothPhases) => {
623
621
  const modules = modulesFor(config, true);
624
622
  const session = modules.find((m) => m.name === "session");
625
623
  if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
@@ -693,7 +691,7 @@ describe("the grants consent answer is held to the session module's CSRF guard",
693
691
  const trusting = {
694
692
  ...config,
695
693
  session: { ...session, csrf: { ...session.csrf, trustedOrigins: [SIBLING] } },
696
- } as Switches;
694
+ } as BothPhases;
697
695
  handleRef = await boot(trusting, true);
698
696
  return express().set("trust proxy", "loopback").use(handleRef.router);
699
697
  };
@@ -17,9 +17,8 @@
17
17
  /**
18
18
  * A federation grant outlives the session it was agreed through, at both HTTP
19
19
  * logout endpoints (ADR 2026-09-17-federation-grants-offline-delegation).
20
- * `/oauth/logout` runs `cascadeLogout()`; `/session/logout` has its own record
21
- * hygiene (`invalidateSessionRecords`) and reaches the grant store on no path,
22
- * by construction. A proof of the helper is not a proof of the endpoints, so
20
+ * Both close the session through core's session lifecycle, whose close work
21
+ * reaches the grant store on no path, by construction. A proof of the helper is not a proof of the endpoints, so
23
22
  * this drives both, on the standalone, as a deployment composes them: a grant
24
23
  * seeded beside a live browser session, the session's records gone afterwards,
25
24
  * the grant and its credential exactly as they were, and `/token` still
@@ -35,7 +34,6 @@ import { createHmac } from "node:crypto";
35
34
  import { fileURLToPath } from "node:url";
36
35
  import {
37
36
  type AppConfig,
38
- AppConfigSchema,
39
37
  createApp,
40
38
  createKeyStoreFactory,
41
39
  defineModule,
@@ -51,18 +49,17 @@ import {
51
49
  type UserSessionStore,
52
50
  } from "@o3co/auth-provider-core";
53
51
  import { parseFile } from "@o3co/ts.hocon";
54
- import { validate } from "@o3co/ts.hocon/zod";
55
52
  import express from "express";
56
53
  import request from "supertest";
57
54
  import { afterEach, describe, expect, it } from "vitest";
58
55
  import { buildModules } from "../buildModules.mjs";
59
- import { resolveConfigPaths, type Switches } from "../configPath.mjs";
56
+ import { resolveConfigPaths } from "../configPath.mjs";
60
57
  import { templateReference } from "../modules.mjs";
61
58
  import {
59
+ type BothPhases,
60
+ bothPhasesOf,
62
61
  capturedRenames,
63
62
  libraryLayers,
64
- rootSectionsOf,
65
- sectionsCoreDoesNotDeclare,
66
63
  } from "./library-references.fixture.mjs";
67
64
 
68
65
  const DAY = 86_400_000;
@@ -122,19 +119,16 @@ const ENV: Readonly<Record<string, string>> = {
122
119
  * that have no environment form written over it: the
123
120
  * upstream federation the connection names, and the connection.
124
121
  */
125
- function resolveConfig(): Switches {
122
+ function resolveConfig(): BothPhases {
126
123
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
127
124
  const layers = parseFile(envConfPath, { env: ENV })
128
125
  .withFallback(parseFile(applicationConfPath, { env: ENV }))
129
126
  .withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
130
127
  .withFallback(libraryLayers(ENV));
131
- const config = validate(layers, AppConfigSchema);
128
+ const config = bothPhasesOf(layers, ENV);
132
129
  return {
133
- ...sectionsCoreDoesNotDeclare(layers),
134
- ...rootSectionsOf(layers, ENV),
135
130
  ...config,
136
- // What the resolution captured of core's renamed variables, which the
137
- // schema's parse drops.
131
+ // What the resolution captured of the renamed variables.
138
132
  "renamed-variables": capturedRenames(ENV),
139
133
  core: {
140
134
  ...config.core,
@@ -174,7 +168,7 @@ function resolveConfig(): Switches {
174
168
  },
175
169
  },
176
170
  },
177
- } as Switches;
171
+ } as BothPhases;
178
172
  }
179
173
 
180
174
  const testRepositoriesModule = defineModule({
@@ -94,7 +94,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
94
94
  }
95
95
 
96
96
  it("keeps them and answers 503 when Google answers 503 with a body naming invalid_grant", async () => {
97
- const { res, sid, federationTokenStore, sessionFederationIndex } = await refreshAnswered(503, {
97
+ const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(503, {
98
98
  error: "invalid_grant",
99
99
  error_description: "Backend unavailable",
100
100
  });
@@ -107,7 +107,10 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
107
107
  expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
108
108
  STORED_REFRESH_TOKEN,
109
109
  );
110
- expect(await sessionFederationIndex.listFederations(sid)).toContain("google");
110
+ expect(await sessionLifecycle.federations(sid)).toEqual({
111
+ outcome: "listed",
112
+ federations: ["google"],
113
+ });
111
114
  });
112
115
 
113
116
  it("keeps them and answers 503 when Google answers 502 with no OAuth body at all", async () => {
@@ -120,7 +123,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
120
123
  });
121
124
 
122
125
  it("ends them on Google's structured 400 invalid_grant", async () => {
123
- const { res, sid, federationTokenStore, sessionFederationIndex } = await refreshAnswered(400, {
126
+ const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(400, {
124
127
  error: "invalid_grant",
125
128
  error_description: "Token has been expired or revoked.",
126
129
  });
@@ -130,6 +133,9 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
130
133
  expect(await federationTokenStore.get(sid, "google")).toBeNull();
131
134
  // The route never writes the session's index: the link, now holding no
132
135
  // credential, stays listed until federation logout or the session's end.
133
- expect(await sessionFederationIndex.listFederations(sid)).toContain("google");
136
+ expect(await sessionLifecycle.federations(sid)).toEqual({
137
+ outcome: "listed",
138
+ federations: ["google"],
139
+ });
134
140
  });
135
141
  });
@@ -0,0 +1,239 @@
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 are handled by the modules of their
19
+ * types. The template loads the federation types it bundles, `google` and
20
+ * `oidc`, whatever the configuration enables, and core dispatches each
21
+ * enabled `core.federations` entry to the module of its `type`, under the
22
+ * entry's name: two entries of one type are two federations. The template
23
+ * reads no entry before boot, so a malformed one is core's to refuse, at its
24
+ * path.
25
+ */
26
+
27
+ import { mkdtempSync, readdirSync, writeFileSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import request from "supertest";
32
+ import { afterEach, describe, expect, it } from "vitest";
33
+ import { buildModules } from "#/buildModules.mjs";
34
+ import { readOwnLayers, readSwitches, resolveConfigPaths, resolveLayers } from "#/configPath.mjs";
35
+ import {
36
+ type Composition,
37
+ compose,
38
+ ISSUER,
39
+ ownFiles,
40
+ resolveConfig,
41
+ SINGLE_ENV,
42
+ } from "./all-modules-composition.fixture.mjs";
43
+
44
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
45
+
46
+ /** Google's authorization endpoint, where a Google federation's start sends the browser. */
47
+ const GOOGLE_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth";
48
+
49
+ let current: Composition | undefined;
50
+
51
+ afterEach(async () => {
52
+ await current?.handle.dispose();
53
+ current = undefined;
54
+ });
55
+
56
+ /** Boots and remembers the composition, so `afterEach` disposes it. */
57
+ async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Composition> {
58
+ current = await compose(options);
59
+ return current;
60
+ }
61
+
62
+ /** What `compose` refuses under `options`. */
63
+ async function refusal(options: Parameters<typeof compose>[0]): Promise<{
64
+ readonly reason?: string;
65
+ readonly message: string;
66
+ }> {
67
+ try {
68
+ current = await compose(options);
69
+ } catch (err) {
70
+ return err as { reason?: string; message: string };
71
+ }
72
+ throw new Error("the composition booted");
73
+ }
74
+
75
+ /** `text` in a file of its own, for a layer above the composition's files. */
76
+ function hoconFile(text: string): string {
77
+ const file = join(mkdtempSync(join(tmpdir(), "federation-types-")), "operator.conf");
78
+ writeFileSync(file, text);
79
+ return file;
80
+ }
81
+
82
+ /** The module names `buildModules` lists for `env`, federation ones only. */
83
+ const federationModules = (env: Readonly<Record<string, string>>): string[] =>
84
+ buildModules(resolveConfig(env))
85
+ .map((module) => module.name)
86
+ .filter((name) => name.startsWith("federation-") && !name.startsWith("federation-grant"));
87
+
88
+ /** Where the start of federation `name` sends the browser. */
89
+ async function startOf(composed: Composition, name: string): Promise<URL> {
90
+ const res = await request(composed.app).get(`/session/oauth/federation/${name}`);
91
+ expect(res.status).toBe(302);
92
+ return new URL(res.headers.location as string);
93
+ }
94
+
95
+ describe("the federation types the template bundles", () => {
96
+ it("are listed whatever the configuration enables", () => {
97
+ const off = {
98
+ ...SINGLE_ENV,
99
+ CORE_FEDERATIONS_GOOGLE_ENABLED: "false",
100
+ CORE_FEDERATIONS_OIDC_ENABLED: "false",
101
+ };
102
+ expect(federationModules(off)).toEqual(["federation-google-type", "federation-oidc"]);
103
+ expect(federationModules(SINGLE_ENV)).toEqual(["federation-google-type", "federation-oidc"]);
104
+ });
105
+
106
+ it("handle every entry the template ships, each of which names its type", () => {
107
+ const { applicationConfPath } = resolveConfigPaths(configDir, "production");
108
+ const resolved = resolveLayers(readOwnLayers([applicationConfPath], { env: {} }), []) as {
109
+ core?: { federations?: Record<string, { type?: unknown }> };
110
+ };
111
+ const types = Object.fromEntries(
112
+ Object.entries(resolved.core?.federations ?? {}).map(([name, entry]) => [name, entry.type]),
113
+ );
114
+ expect(types).toEqual({ google: "google", oidc: "oidc" });
115
+ });
116
+
117
+ it("boot the shipped Google federation, enabled, as the provider named google that its type built", async () => {
118
+ const composed = await boot();
119
+ const names = composed.modules.map((module) => module.name);
120
+ expect(names).toContain("federation-google-type");
121
+ expect(names).not.toContain("federation-google");
122
+ expect(composed.handle.components.federationProviders?.get("google")?.name).toBe("google");
123
+
124
+ const start = await startOf(composed, "google");
125
+ expect(`${start.origin}${start.pathname}`).toBe(GOOGLE_AUTHORIZE);
126
+ expect(start.searchParams.get("client_id")).toBe(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID);
127
+ expect(start.searchParams.get("redirect_uri")).toBe(
128
+ "http://localhost:3000/session/oauth/federation/google/callback",
129
+ );
130
+ });
131
+
132
+ it("boot a second entry of type google, under its own name, beside the shipped one", async () => {
133
+ const callbackURL = `${ISSUER}/session/oauth/federation/google-work/callback`;
134
+ const composed = await boot({
135
+ operatorHocon: `core.federations.google-work {
136
+ enabled = true
137
+ type = "google"
138
+ clientId = "google-work-client"
139
+ clientSecret = "google-work-secret"
140
+ callbackURL = ${JSON.stringify(callbackURL)}
141
+ }
142
+ `,
143
+ });
144
+ const providers = composed.handle.components.federationProviders;
145
+ expect([...(providers?.keys() ?? [])].sort()).toEqual(["google", "google-work", "oidc"]);
146
+ expect(providers?.get("google-work")?.name).toBe("google-work");
147
+
148
+ const work = await startOf(composed, "google-work");
149
+ expect(`${work.origin}${work.pathname}`).toBe(GOOGLE_AUTHORIZE);
150
+ expect(work.searchParams.get("client_id")).toBe("google-work-client");
151
+ expect(work.searchParams.get("redirect_uri")).toBe(callbackURL);
152
+ const shipped = await startOf(composed, "google");
153
+ expect(shipped.searchParams.get("client_id")).toBe(
154
+ SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
155
+ );
156
+ });
157
+
158
+ it("hand an entry to the type it names, not the type its name reads as", async () => {
159
+ // `google` of type `oidc` is a generic OIDC federation named google.
160
+ const composed = await boot({
161
+ operatorHocon: `core.federations.google { type = "oidc", issuer = ${JSON.stringify(SINGLE_ENV.CORE_FEDERATIONS_OIDC_ISSUER)} }\n`,
162
+ });
163
+ const start = await startOf(composed, "google");
164
+ expect(start.origin).toBe(SINGLE_ENV.CORE_FEDERATIONS_OIDC_ISSUER);
165
+ expect(start.searchParams.get("client_id")).toBe(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID);
166
+ });
167
+
168
+ it("register the type of every entry each shipped configuration file writes", () => {
169
+ const registered = new Set(
170
+ buildModules(resolveConfig(SINGLE_ENV)).flatMap((module) =>
171
+ Object.keys(module.contributes?.federationTypes ?? {}),
172
+ ),
173
+ );
174
+ const written = readdirSync(configDir)
175
+ .filter((file) => file.endsWith(".conf"))
176
+ .flatMap((file) => {
177
+ const resolved = resolveLayers(readOwnLayers([join(configDir, file)], { env: {} }), []) as {
178
+ core?: { federations?: Record<string, { type?: unknown }> };
179
+ };
180
+ return Object.entries(resolved.core?.federations ?? {}).map(([name, entry]) => ({
181
+ file,
182
+ name,
183
+ type: entry.type,
184
+ }));
185
+ });
186
+ expect(written).not.toEqual([]);
187
+ expect(written.filter(({ type }) => typeof type !== "string" || !registered.has(type))).toEqual(
188
+ [],
189
+ );
190
+ });
191
+ });
192
+
193
+ describe("the template reads no federation entry before boot", () => {
194
+ it("passes a malformed entry through phase one, and boot refuses it at its path", async () => {
195
+ const operatorHocon = 'core.federations.okta { enabled = "maybe", type = "oidc" }\n';
196
+ expect(() =>
197
+ readSwitches(readOwnLayers([hoconFile(operatorHocon), ...ownFiles()], { env: SINGLE_ENV })),
198
+ ).not.toThrow();
199
+ const err = await refusal({ operatorHocon });
200
+ expect(err.reason).toBe("config-validation-failed");
201
+ expect(err.message).toContain("core.federations.okta.enabled");
202
+ });
203
+
204
+ it.each([
205
+ ["accessType", '"sometimes"'],
206
+ // A bare string where a list belongs: the natural typo.
207
+ ["redirectAllowlist", '"https://app.test/home"'],
208
+ ["sessionDomain", "42"],
209
+ ["requireAuthorizationResponseIss", '"no"'],
210
+ // Only an environment variable's string is read as a flag's spelling.
211
+ ["requireAuthorizationResponseIss", "1"],
212
+ ["requireAuthorizationResponseIss", "[true]"],
213
+ ])(
214
+ "leaves %s, a key of the entry's type, to the type's schema, which core refuses it by, at its path",
215
+ async (key, value) => {
216
+ const err = await refusal({ operatorHocon: `core.federations.google.${key} = ${value}\n` });
217
+ expect(err.reason).toBe("config-validation-failed");
218
+ expect(err.message).toContain(`core.federations.google.${key}`);
219
+ },
220
+ );
221
+ });
222
+
223
+ describe("an enabled Google federation's credentials", () => {
224
+ it.each([
225
+ ["unset", undefined],
226
+ ["exported empty", ""],
227
+ ])(
228
+ "refuse the boot when the client secret is %s, at its path, quoting no value of the entry",
229
+ async (_case, secret) => {
230
+ const { CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: _shipped, ...env } = SINGLE_ENV;
231
+ const err = await refusal({
232
+ env: secret === undefined ? env : { ...env, CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: secret },
233
+ });
234
+ expect(err.reason).toBe("config-validation-failed");
235
+ expect(err.message).toContain("core.federations.google.clientSecret");
236
+ expect(err.message).not.toContain(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID as string);
237
+ },
238
+ );
239
+ });