@o3co/create-auth-provider 0.9.0 → 0.10.0

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 (31) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +99 -6
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +43 -4
  6. package/templates/standalone/Dockerfile +51 -16
  7. package/templates/standalone/README.ja.md +79 -12
  8. package/templates/standalone/README.md +345 -17
  9. package/templates/standalone/config/application.conf +121 -4
  10. package/templates/standalone/config/clients.yaml.example +41 -0
  11. package/templates/standalone/docker-compose.production.yml +116 -0
  12. package/templates/standalone/package.json +14 -18
  13. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  14. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +419 -0
  15. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  16. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  17. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  18. package/templates/standalone/src/__tests__/smoke.test.mts +177 -11
  19. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  20. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  21. package/templates/standalone/src/app.mts +67 -8
  22. package/templates/standalone/src/buildModules.mts +43 -1
  23. package/templates/standalone/src/configPath.mts +2 -2
  24. package/templates/standalone/src/logger.mts +90 -2
  25. package/templates/standalone/src/metrics.mts +190 -0
  26. package/templates/standalone/src/modules.mts +281 -33
  27. package/templates/standalone/src/terminalError.mts +60 -0
  28. package/templates/standalone/tests/index.test.js +69 -5
  29. package/templates/standalone/tsconfig.json +9 -1
  30. package/templates/standalone/vitest.config.mts +8 -0
  31. package/templates/versions.json +11 -11
@@ -0,0 +1,419 @@
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
+ import { readFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
20
+ import { parseFile } from "@o3co/ts.hocon";
21
+ import { validate } from "@o3co/ts.hocon/zod";
22
+ import { describe, expect, it } from "vitest";
23
+ import { buildModules } from "../buildModules.mjs";
24
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
25
+
26
+ /**
27
+ * #288 — boot the shipped config with EVERY documented override supplied the
28
+ * way an operator actually supplies one: as a string.
29
+ *
30
+ * HOCON substitutes `${?VAR}` as a string, always. Whether that string is
31
+ * usable depends entirely on the schema leaf it lands on, and the schema had
32
+ * two answers to that question — an explicit `coerceBooleanFromEnv` on two
33
+ * fields, and a bare `z.boolean()` everywhere else that only worked because
34
+ * `@o3co/ts.hocon`'s zod bridge coerces a bare boolean leaf it can *reach*.
35
+ * The bridge stops at anything that is not a `ZodObject` shape (or an
36
+ * optional/nullable/default/catch/readonly wrapper around one), so wrapping a
37
+ * section in `z.preprocess(...)` silently took the override away — which is
38
+ * precisely what had happened to `OAUTH_JWT_LEGACY_TYP_ACCEPT`.
39
+ *
40
+ * This suite is the thing that stops the two paths diverging again. It is not
41
+ * a sample of interesting variables: `covers every documented override` below
42
+ * fails when a `${?VAR}` is added to either config layer, or documented in the
43
+ * README, without being exercised here.
44
+ */
45
+
46
+ // config/ is two levels above this test file: src/__tests__/ → src/ → standalone/
47
+ const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
48
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
49
+ const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url));
50
+
51
+ /**
52
+ * Every environment variable the shipped artifact documents, with a value in
53
+ * the shape an operator would supply. Strings throughout — that is the whole
54
+ * point of the exercise.
55
+ *
56
+ * `OAUTH_JWT_ALGORITHM` is `EdDSA` here so the asymmetric key variables can be
57
+ * in the same map: the HS256 branch of `signingKey.local` is a `.strict()`
58
+ * discriminated-union member and refuses `privateKeyPath` and friends by
59
+ * design. The HS256 shape gets its own test below.
60
+ */
61
+ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
62
+ // --- http ---------------------------------------------------------
63
+ HTTP_PORT: "3000",
64
+ HTTP_TRUST_PROXY: "10.0.0.0/8,loopback",
65
+ HTTP_READINESS_TIMEOUT_MS: "1500",
66
+
67
+ // --- logging ------------------------------------------------------
68
+ LOG_LEVEL: "debug",
69
+
70
+ // --- oauth.jwt ----------------------------------------------------
71
+ OAUTH_JWT_ISSUER: "https://auth.test",
72
+ OAUTH_JWT_SIGNING_KEY_PROVIDER: "local",
73
+ OAUTH_JWT_ALGORITHM: "EdDSA",
74
+ OAUTH_JWT_KID: "v1",
75
+ OAUTH_JWT_SECRET: "documented-env-secret.at-least-32-bytes.ok",
76
+ OAUTH_JWT_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nMC4=\n-----END PRIVATE KEY-----",
77
+ OAUTH_JWT_PRIVATE_KEY_PATH: "./config/jwt-private.pem",
78
+ OAUTH_JWT_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----\nMCo=\n-----END PUBLIC KEY-----",
79
+ OAUTH_JWT_PUBLIC_KEY_PATH: "./config/jwt-public.pem",
80
+ // The override this issue was filed over: a bare `z.boolean()` behind a
81
+ // `z.preprocess` wrapper the hocon bridge cannot see through.
82
+ OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
83
+
84
+ // --- oauth tokens / policy ----------------------------------------
85
+ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
86
+ OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
87
+ OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
88
+ OAUTH_OIDC_MODE: "dual",
89
+ OAUTH_CODE_ADAPTER: "redis",
90
+ OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
91
+ OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
92
+ OAUTH_NONCE_MAX_LENGTH: "256",
93
+ OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
94
+ OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
95
+ OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
96
+
97
+ // --- oauth.grants -------------------------------------------------
98
+ OAUTH_GRANTS_SESSION_ENABLED: "false",
99
+ OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
100
+ OAUTH_GRANTS_REFRESH_TOKEN_ENABLED: "true",
101
+ OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
102
+ // #273 tombstone: inert, but a still-exported value must reach the boot
103
+ // warning rather than failing parse.
104
+ OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256: "true",
105
+
106
+ // --- session ------------------------------------------------------
107
+ SESSION_SECRET: "documented-env-session-secret.at-least-32-bytes.ok",
108
+ SESSION_NAME: "auth.session",
109
+ SESSION_MAX_AGE: "3600000",
110
+ SESSION_SECURE: "false",
111
+ SESSION_SAME_SITE: "lax",
112
+ SESSION_DOMAIN: "auth.example.com",
113
+ SESSION_CSRF_TTL_SECONDS: "7200",
114
+ SESSION_STORAGE_TYPE: "redis",
115
+ SESSION_STORAGE_REDIS_URL: "redis://redis:6379",
116
+ SESSION_STORAGE_REDIS_PASSWORD: "session-store-password",
117
+
118
+ // --- rate limiting ------------------------------------------------
119
+ RATE_LIMIT_FAIL_MODE: "open",
120
+ RATE_LIMITER_ADAPTER: "redis",
121
+ MEMORY_RATE_LIMITER_MAX_BUCKETS: "10000",
122
+
123
+ // --- audit --------------------------------------------------------
124
+ // #287: selects the sink builder; "console" is the registered builtin.
125
+ // There is deliberately no "none" — an unknown type fails boot in
126
+ // buildModules (pinned by audit-sink.test.mts), not at config parse,
127
+ // because the schema keeps `audit.sink.type` an open string so
128
+ // out-of-tree sinks need no schema change here.
129
+ AUDIT_SINK_TYPE: "console",
130
+
131
+ // --- shared stores ------------------------------------------------
132
+ DEPLOYMENT_MODE: "multi",
133
+ USER_SESSION_STORES_ADAPTER: "redis",
134
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
135
+ REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
136
+ REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
137
+ REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
138
+ REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
139
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
140
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD: "rt-family-password",
141
+
142
+ // --- federation ---------------------------------------------------
143
+ FEDERATIONS_GOOGLE_ENABLED: "true",
144
+ FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
145
+ FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
146
+ FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
147
+
148
+ // --- repositories -------------------------------------------------
149
+ CLIENT_TYPE: "yaml",
150
+ CLIENT_PATH: "./config/clients.yaml",
151
+ CLIENT_USER_TYPE: "yaml",
152
+ CLIENT_USER_PATH: "./config/users.yaml",
153
+ CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
154
+ CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
155
+ CLIENT_USER_TIMEOUT: "5000",
156
+ CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
157
+ CLIENT_CODE_TYPE: "redis",
158
+ CLIENT_CODE_DEFAULT_EXPIRES_IN: "600",
159
+ CLIENT_CODE_ENDPOINT_URI: "redis://redis:6379",
160
+ CLIENT_CODE_PASSWORD: "code-store-password",
161
+ CLIENT_CODE_KEY_PREFIX: "oauth:code:",
162
+
163
+ // --- endpoints ----------------------------------------------------
164
+ ENDPOINTS_LOGIN_URL: "/login",
165
+ };
166
+
167
+ /**
168
+ * Substitutions that must NOT appear in `DOCUMENTED_ENV`, and why. Each one is
169
+ * a variable whose documented behaviour is to *fail* boot, so setting it in
170
+ * the all-overrides case would assert the opposite of the contract.
171
+ */
172
+ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
173
+ OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
174
+ "#330 tombstone — any value must fail boot with migration instructions",
175
+ };
176
+
177
+ /**
178
+ * The provider environment `o3co/auth`'s `tests/docker-compose.yml` sets,
179
+ * transcribed. The umbrella E2E boots the shipped template with exactly this,
180
+ * so a parse failure here is a red umbrella build that this repository can see
181
+ * first. `SESSION_SECURE=false` is the one it cannot run without: the suite
182
+ * speaks plain HTTP.
183
+ */
184
+ const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
185
+ OAUTH_JWT_ALGORITHM: "HS256",
186
+ OAUTH_JWT_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
187
+ OAUTH_JWT_ISSUER: "https://auth.e2e.test",
188
+ SESSION_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
189
+ SESSION_SECURE: "false",
190
+ SESSION_NAME: "auth.session",
191
+ DEPLOYMENT_MODE: "multi",
192
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
193
+ SESSION_STORAGE_REDIS_URL: "redis://redis:6379",
194
+ USER_SESSION_STORES_ADAPTER: "redis",
195
+ RATE_LIMITER_ADAPTER: "redis",
196
+ OAUTH_CODE_ADAPTER: "redis",
197
+ CLIENT_USER_TYPE: "yaml",
198
+ OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
199
+ OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
200
+ OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256: "true",
201
+ };
202
+
203
+ function buildResolvedConfig(env: Record<string, string>, configEnv = "production"): AppConfig {
204
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
205
+ const libraryReferencePath = resolveLibraryReferenceConfPath();
206
+ return validate(
207
+ parseFile(envConfPath, { env })
208
+ .withFallback(parseFile(applicationConfPath, { env }))
209
+ .withFallback(parseFile(libraryReferencePath, { env })),
210
+ AppConfigSchema,
211
+ );
212
+ }
213
+
214
+ /** Every `${?VAR}` in a HOCON layer, ignoring commented-out lines. */
215
+ function substitutionsIn(path: string): Set<string> {
216
+ const found = new Set<string>();
217
+ for (const line of readFileSync(path, "utf8").split("\n")) {
218
+ const code = line.replace(/(^|\s)(#|\/\/).*$/, "");
219
+ for (const match of code.matchAll(/\$\{\?([A-Z][A-Z0-9_]*)\}/g)) {
220
+ found.add(match[1] as string);
221
+ }
222
+ }
223
+ return found;
224
+ }
225
+
226
+ /**
227
+ * Every variable named in a leading `| \`VAR\` |` cell of a README table.
228
+ *
229
+ * The underscore is what separates an environment variable from the HTTP
230
+ * methods in the endpoint table, which share the shouting-case shape.
231
+ */
232
+ function documentedInReadme(): Set<string> {
233
+ const found = new Set<string>();
234
+ for (const match of readFileSync(readmePath, "utf8").matchAll(
235
+ /^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm,
236
+ )) {
237
+ const name = match[1] as string;
238
+ if (name.includes("_")) found.add(name);
239
+ }
240
+ return found;
241
+ }
242
+
243
+ function liveSubstitutions(): Set<string> {
244
+ const { applicationConfPath } = resolveConfigPaths(configDir, "production");
245
+ return new Set([
246
+ ...substitutionsIn(resolveLibraryReferenceConfPath()),
247
+ ...substitutionsIn(applicationConfPath),
248
+ ]);
249
+ }
250
+
251
+ describe("#288: the shipped config boots with every documented override supplied as a string", () => {
252
+ it("parses with every documented environment variable set", () => {
253
+ expect(() => buildResolvedConfig(DOCUMENTED_ENV)).not.toThrow();
254
+ });
255
+
256
+ it("turns every boolean override into an actual boolean", () => {
257
+ const config = buildResolvedConfig(DOCUMENTED_ENV);
258
+ // Each of these arrives from HOCON as a string. A leftover string is
259
+ // not a cosmetic defect: `=== true` is how the runtime reads them.
260
+ expect(config.session.secure).toBe(false);
261
+ expect(config.oauth.jwt.legacyTypAccept).toBe(true);
262
+ expect(config.oauth.requireEmailVerified).toBe(true);
263
+ expect(config.oauth.resourceIndicator?.enabled).toBe(true);
264
+ expect(config.federations.google?.enabled).toBe(true);
265
+ });
266
+
267
+ it("turns every non-boolean override into its declared type", () => {
268
+ const config = buildResolvedConfig(DOCUMENTED_ENV);
269
+ expect(config.http.port).toBe(3000);
270
+ expect(config.http.readinessTimeoutMs).toBe(1500);
271
+ expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
272
+ expect(config.oauth.accessToken.expiresIn).toBe(3600);
273
+ expect(config.oauth.refreshToken.expiresIn).toBe(86400);
274
+ expect(config.session.maxAge).toBe(3600000);
275
+ expect(config.session.csrf?.ttlSeconds).toBe(7200);
276
+ expect(config.oauth.nonce?.maxLength).toBe(256);
277
+ });
278
+
279
+ it("parses the HS256 shape, whose strict union refuses asymmetric key fields", () => {
280
+ // `OAUTH_JWT_ALGORITHM=HS256` is not a variation on the map above: the
281
+ // HS256 member of the signingKey union is `.strict()`, so a deployment
282
+ // that switches algorithm must also stop exporting the key-file
283
+ // variables. Worth pinning — it is the umbrella E2E's shape.
284
+ const { OAUTH_JWT_PRIVATE_KEY, OAUTH_JWT_PRIVATE_KEY_PATH, ...rest } = DOCUMENTED_ENV;
285
+ void OAUTH_JWT_PRIVATE_KEY;
286
+ void OAUTH_JWT_PRIVATE_KEY_PATH;
287
+ const { OAUTH_JWT_PUBLIC_KEY, OAUTH_JWT_PUBLIC_KEY_PATH, ...hs256 } = rest;
288
+ void OAUTH_JWT_PUBLIC_KEY;
289
+ void OAUTH_JWT_PUBLIC_KEY_PATH;
290
+ const config = buildResolvedConfig({ ...hs256, OAUTH_JWT_ALGORITHM: "HS256" });
291
+ expect(config.oauth.jwt.signingKey.local?.algorithm).toBe("HS256");
292
+ });
293
+
294
+ it("parses the environment the umbrella E2E boots, with SESSION_SECURE=false as a string", () => {
295
+ const config = buildResolvedConfig(UMBRELLA_E2E_ENV);
296
+ expect(config.session.secure).toBe(false);
297
+ expect(config.oauth.requireEmailVerified).toBe(true);
298
+ expect(config.oauth.resourceIndicator?.enabled).toBe(true);
299
+ expect(config.deployment?.mode).toBe("multi");
300
+ });
301
+
302
+ it("wires nothing replica-unsafe for the umbrella E2E environment", async () => {
303
+ // `DEPLOYMENT_MODE=multi` makes the provider audit its own store wiring
304
+ // at boot, so a config that parses but wires a memory store still fails
305
+ // there. Assert against the guard's own list rather than module names.
306
+ const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
307
+ for (const name of buildModules(buildResolvedConfig(UMBRELLA_E2E_ENV)).map((m) => m.name)) {
308
+ expect(REPLICA_UNSAFE_MODULES).not.toContain(name);
309
+ }
310
+ });
311
+
312
+ describe("boolean overrides accept the spellings an operator writes", () => {
313
+ const cases: ReadonlyArray<[string, boolean]> = [
314
+ ["true", true],
315
+ ["TRUE", true],
316
+ ["1", true],
317
+ ["false", false],
318
+ ["0", false],
319
+ // An exported-but-empty variable — the `.env` / compose / ConfigMap
320
+ // shape. Reads as false, matching what #292 decided for trustProxy.
321
+ ["", false],
322
+ ];
323
+
324
+ for (const [supplied, expected] of cases) {
325
+ it(`SESSION_SECURE=${JSON.stringify(supplied)} resolves to ${expected}`, () => {
326
+ const config = buildResolvedConfig({
327
+ ...DOCUMENTED_ENV,
328
+ SESSION_SECURE: supplied,
329
+ SESSION_SAME_SITE: "lax",
330
+ });
331
+ expect(config.session.secure).toBe(expected);
332
+ });
333
+
334
+ it(`OAUTH_JWT_LEGACY_TYP_ACCEPT=${JSON.stringify(supplied)} resolves to ${expected}`, () => {
335
+ const config = buildResolvedConfig({
336
+ ...DOCUMENTED_ENV,
337
+ OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
338
+ });
339
+ expect(config.oauth.jwt.legacyTypAccept).toBe(expected);
340
+ });
341
+ }
342
+
343
+ it("fails boot on a spelling it does not recognise rather than guessing", () => {
344
+ expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, SESSION_SECURE: "ture" })).toThrow(
345
+ /true.*false/s,
346
+ );
347
+ });
348
+
349
+ it("refuses SESSION_SAME_SITE=none unless SESSION_SECURE is on", () => {
350
+ // The #282 guard reads the coerced value, so it has to keep firing
351
+ // for the string form an environment variable actually delivers.
352
+ expect(() =>
353
+ buildResolvedConfig({
354
+ ...DOCUMENTED_ENV,
355
+ SESSION_SAME_SITE: "none",
356
+ SESSION_SECURE: "false",
357
+ }),
358
+ ).toThrow(/SESSION_SECURE=true/);
359
+ expect(() =>
360
+ buildResolvedConfig({
361
+ ...DOCUMENTED_ENV,
362
+ SESSION_SAME_SITE: "none",
363
+ SESSION_SECURE: "true",
364
+ SESSION_NAME: "auth.session",
365
+ }),
366
+ ).not.toThrow();
367
+ });
368
+ });
369
+
370
+ describe("the documented surface and this suite cannot drift apart", () => {
371
+ it("covers every substitution the shipped config layers declare", () => {
372
+ const uncovered = [...liveSubstitutions()].filter(
373
+ (name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
374
+ );
375
+ expect(uncovered).toEqual([]);
376
+ });
377
+
378
+ it("covers every environment variable the README documents", () => {
379
+ const uncovered = [...documentedInReadme()].filter(
380
+ (name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
381
+ );
382
+ expect(uncovered).toEqual([]);
383
+ });
384
+
385
+ it("exercises no variable the config layers no longer substitute", () => {
386
+ // Catches the reverse drift: a README row (and a test entry) left
387
+ // behind by a key that was removed from the HOCON.
388
+ const live = liveSubstitutions();
389
+ const dead = Object.keys(DOCUMENTED_ENV).filter((name) => !live.has(name));
390
+ expect(dead).toEqual([]);
391
+ });
392
+ });
393
+
394
+ describe("variables whose documented behaviour is to fail boot", () => {
395
+ it("still refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS (#330 tombstone)", () => {
396
+ expect(() =>
397
+ buildResolvedConfig({
398
+ ...DOCUMENTED_ENV,
399
+ OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
400
+ }),
401
+ ).toThrow(/allowUnmarkedClients/);
402
+ });
403
+
404
+ it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
405
+ // Pinned alongside the boolean cases because it is the same trap
406
+ // read from the other side: for a *number*, empty means fail loudly.
407
+ expect(() =>
408
+ buildResolvedConfig({ ...DOCUMENTED_ENV, SESSION_CSRF_TTL_SECONDS: "" }),
409
+ ).toThrow();
410
+ });
411
+ });
412
+
413
+ it("resolves against the shipped standalone template, not a fixture", () => {
414
+ // Guards the guard: if the paths above ever stop pointing at the real
415
+ // artifact these tests would pass while testing nothing.
416
+ expect(standaloneDir).toMatch(/templates[/\\]standalone[/\\]?$/);
417
+ expect(liveSubstitutions().size).toBeGreaterThan(40);
418
+ });
419
+ });
@@ -0,0 +1,114 @@
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
+ * #278: the bridge used to return only `{ clientId, clientSecret, callbackURL }`,
19
+ * dropping the four fields the redirect policy reads. Nothing failed — the
20
+ * policy was simply constructed with an empty config, and the generated app
21
+ * silently ran without a redirect allowlist. These tests fail if the bridge
22
+ * stops forwarding any of them.
23
+ */
24
+
25
+ import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
26
+ import { describe, expect, it } from "vitest";
27
+ import { googleFederationConfigModule } from "../modules.mjs";
28
+
29
+ type ConfigFactory = (deps: { config: unknown }) => GoogleProviderConfig;
30
+
31
+ const bridge = (
32
+ googleFederationConfigModule as unknown as {
33
+ provides: { googleFederationConfig: ConfigFactory };
34
+ }
35
+ ).provides.googleFederationConfig;
36
+
37
+ /** Runs the bridge over a `federations.google` section in the flat shape. */
38
+ function buildConfig(googleSection: Record<string, unknown>): GoogleProviderConfig {
39
+ return bridge({ config: { federations: { google: { enabled: true, ...googleSection } } } });
40
+ }
41
+
42
+ const credentials = {
43
+ clientId: "id",
44
+ clientSecret: "secret",
45
+ callbackURL: "https://auth.example.com/session/oauth/federation/google/callback",
46
+ };
47
+
48
+ describe("googleFederationConfigModule — redirect-policy plumbing (#278)", () => {
49
+ it("forwards sessionDomain, authCallbackUrl, clientUrl and redirectAllowlist", () => {
50
+ const out = buildConfig({
51
+ ...credentials,
52
+ sessionDomain: ".example.com",
53
+ authCallbackUrl: "https://app.example.com/auth/callback",
54
+ clientUrl: "https://app.example.com/",
55
+ redirectAllowlist: ["https://app.example.com/dashboard"],
56
+ });
57
+
58
+ expect(out).toMatchObject({
59
+ ...credentials,
60
+ sessionDomain: ".example.com",
61
+ authCallbackUrl: "https://app.example.com/auth/callback",
62
+ clientUrl: "https://app.example.com/",
63
+ redirectAllowlist: ["https://app.example.com/dashboard"],
64
+ });
65
+ });
66
+
67
+ it("omits the optional fields when the operator did not configure them", () => {
68
+ const out = buildConfig(credentials);
69
+
70
+ expect(out).toEqual(credentials);
71
+ expect("sessionDomain" in out).toBe(false);
72
+ expect("authCallbackUrl" in out).toBe(false);
73
+ expect("clientUrl" in out).toBe(false);
74
+ expect("redirectAllowlist" in out).toBe(false);
75
+ });
76
+
77
+ it("rejects a redirectAllowlist that is not an array", () => {
78
+ expect(() =>
79
+ buildConfig({ ...credentials, redirectAllowlist: "https://app.example.com/dashboard" }),
80
+ ).toThrow(/redirectAllowlist/);
81
+ });
82
+
83
+ it("rejects a redirectAllowlist holding a non-string", () => {
84
+ expect(() => buildConfig({ ...credentials, redirectAllowlist: [42] })).toThrow(
85
+ /redirectAllowlist/,
86
+ );
87
+ });
88
+
89
+ it("rejects a non-string sessionDomain rather than forwarding it", () => {
90
+ expect(() => buildConfig({ ...credentials, sessionDomain: 42 })).toThrow(/sessionDomain/);
91
+ });
92
+
93
+ it("still rejects a section missing the credentials", () => {
94
+ expect(() => buildConfig({ clientId: "id" })).toThrow(/clientId, clientSecret, callbackURL/);
95
+ });
96
+
97
+ it("reads through the nested federation shape as well as the flat one", () => {
98
+ const out = bridge({
99
+ config: {
100
+ federations: {
101
+ google: {
102
+ enabled: true,
103
+ type: "google",
104
+ sessionDomain: ".example.com",
105
+ redirectAllowlist: ["https://app.example.com/dashboard"],
106
+ google: credentials,
107
+ },
108
+ },
109
+ },
110
+ });
111
+ expect(out.sessionDomain).toBe(".example.com");
112
+ expect(out.redirectAllowlist).toEqual(["https://app.example.com/dashboard"]);
113
+ });
114
+ });