@o3co/create-auth-provider 0.17.0-rc.1 → 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 (58) hide show
  1. package/package.json +1 -1
  2. package/templates/standalone/.env.example +0 -3
  3. package/templates/standalone/README.ja.md +42 -18
  4. package/templates/standalone/README.md +120 -44
  5. package/templates/standalone/config/application.conf +40 -36
  6. package/templates/standalone/config/reference.conf +21 -2
  7. package/templates/standalone/docker-compose.production.yml +5 -2
  8. package/templates/standalone/package.json +1 -1
  9. package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
  10. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  11. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  12. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
  13. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  14. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  15. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  16. package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
  17. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  18. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
  19. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
  20. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  21. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  22. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  23. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  24. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  25. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  26. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  27. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
  28. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  29. package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
  30. package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
  31. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  32. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  33. package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
  34. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
  35. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  36. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  37. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  38. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  39. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  40. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  41. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  42. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
  43. package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
  44. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  45. package/templates/standalone/src/adapters.mts +31 -18
  46. package/templates/standalone/src/app.mts +6 -5
  47. package/templates/standalone/src/bootRefusal.mts +117 -0
  48. package/templates/standalone/src/buildModules.mts +46 -96
  49. package/templates/standalone/src/configPath.mts +180 -153
  50. package/templates/standalone/src/mfaSwitch.mts +43 -20
  51. package/templates/standalone/src/modules.mts +25 -213
  52. package/templates/standalone/src/rootRenames.mts +35 -18
  53. package/templates/standalone/src/sections.mts +1 -0
  54. package/templates/standalone/tests/index.test.js +4 -3
  55. package/templates/versions.json +18 -18
  56. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  57. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  58. 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,
@@ -140,6 +138,7 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
140
138
  SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
141
139
  ADAPTERS_USER_SESSION_STORES: "redis",
142
140
  ADAPTERS_RATE_LIMITER: "redis",
141
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
143
142
  ADAPTERS_CODE_REPOSITORY: "redis",
144
143
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
145
144
  ADAPTERS_REPLAY_SEEN_SET: "redis",
@@ -156,29 +155,24 @@ const GRANTS_ON: Readonly<Record<string, string>> = {
156
155
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
157
156
  };
158
157
 
159
- function resolveConfig(env: Record<string, string>): Switches {
158
+ function resolveConfig(env: Record<string, string>): BothPhases {
160
159
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
161
160
  const layers = parseFile(envConfPath, { env })
162
161
  .withFallback(parseFile(applicationConfPath, { env }))
163
162
  .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
164
163
  .withFallback(libraryLayers(env));
165
- const config = {
166
- ...sectionsCoreDoesNotDeclare(layers),
167
- ...validate(layers, AppConfigSchema),
168
- ...rootSectionsOf(layers, env),
169
- };
164
+ const config = bothPhasesOf(layers, env);
170
165
  // The key ring has no environment form (a list of { id, key } is HOCON's);
171
166
  // the Redis grant store refuses to construct without one under "required".
172
167
  return {
173
168
  ...config,
174
- // What the resolution captured of core's renamed variables, which the
175
- // schema's parse drops.
169
+ // What the resolution captured of the renamed variables.
176
170
  "renamed-variables": capturedRenames(env),
177
171
  "redis-federation-grant-store": {
178
172
  ...(config["redis-federation-grant-store"] as object | undefined),
179
173
  encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
180
174
  },
181
- } as Switches;
175
+ } as BothPhases;
182
176
  }
183
177
 
184
178
  const testRepositoriesModule = defineModule({
@@ -204,7 +198,7 @@ const testKeyStoreModule = defineModule({
204
198
  },
205
199
  });
206
200
 
207
- const modulesFor = (config: Switches, memoryOnly = false, environment?: string) =>
201
+ const modulesFor = (config: BothPhases, memoryOnly = false, environment?: string) =>
208
202
  buildModules(config, {
209
203
  keyStoreModule: testKeyStoreModule,
210
204
  repositoriesModule: testRepositoriesModule,
@@ -212,13 +206,13 @@ const modulesFor = (config: Switches, memoryOnly = false, environment?: string)
212
206
  ...(environment === undefined ? {} : { environment }),
213
207
  });
214
208
 
215
- const boot = (config: Switches, memoryOnly = false, environment?: string) =>
209
+ const boot = (config: BothPhases, memoryOnly = false, environment?: string) =>
216
210
  createApp({
217
211
  modules: modulesFor(config, memoryOnly, environment),
218
212
  bootstrapComponents: { config, pathResolver: (s) => s },
219
213
  });
220
214
 
221
- const names = (config: Switches, memoryOnly = false) =>
215
+ const names = (config: BothPhases, memoryOnly = false) =>
222
216
  modulesFor(config, memoryOnly).map((m) => m.name);
223
217
 
224
218
  /**
@@ -322,7 +316,7 @@ describe("the standalone composes federation grants from its config", () => {
322
316
  );
323
317
  expect(installed).not.toContain("redis-federation-grant-store");
324
318
  expect(installed).not.toContain("redis-federation-grant-intent-store");
325
- // Listed ahead of oauthModule, as the template writes them: the
319
+ // Listed ahead of oauthEndpointsModule, as the template writes them: the
326
320
  // template's choice, since each module parses its own bodies and the
327
321
  // order does not change that; the browser half sits after the session
328
322
  // middleware by its own `after`.
@@ -399,7 +393,7 @@ describe("the standalone composes federation grants from its config", () => {
399
393
  upstreamHardTimeoutMs: 60_000,
400
394
  refreshLockTtlMs: 65_000,
401
395
  },
402
- } as Switches;
396
+ } as BothPhases;
403
397
  handleRef = await boot(raised, true);
404
398
  expect(handleRef.cleanupAllowanceMs).toBe(60_000 + 3_000 + 5_000 + 12_000);
405
399
 
@@ -619,7 +613,7 @@ describe("the browser consent route parses its own body, with sessionModule list
619
613
  });
620
614
 
621
615
  /** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
622
- const sessionFirst = (config: Switches) => {
616
+ const sessionFirst = (config: BothPhases) => {
623
617
  const modules = modulesFor(config, true);
624
618
  const session = modules.find((m) => m.name === "session");
625
619
  if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
@@ -693,7 +687,7 @@ describe("the grants consent answer is held to the session module's CSRF guard",
693
687
  const trusting = {
694
688
  ...config,
695
689
  session: { ...session, csrf: { ...session.csrf, trustedOrigins: [SIBLING] } },
696
- } as Switches;
690
+ } as BothPhases;
697
691
  handleRef = await boot(trusting, true);
698
692
  return express().set("trust proxy", "loopback").use(handleRef.router);
699
693
  };
@@ -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
+ });