@o3co/create-auth-provider 0.16.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 (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  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 +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  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 +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  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 +4 -9
  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 +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  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 +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  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 +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  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 +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  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 +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  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
@@ -15,22 +15,20 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #593 acceptance criterion 8, at the two HTTP logout endpoints (D14).
19
- *
20
- * A federation grant outlives the session it was agreed through. The proof of
21
- * that used to be a direct call to `cascadeLogout()` — which is the cascade
22
- * `/oauth/logout` runs, and not what `/session/logout` runs: that endpoint has
23
- * its own record hygiene (`invalidateSessionRecords`) and reaches the grant
24
- * store on no path, by construction. A proof of the helper is not a proof of
25
- * the endpoints. So this drives both, on the standalone, as a deployment
26
- * composes them: a grant seeded beside a live browser session, the session's
27
- * records gone afterwards, the grant and its credential exactly as they were,
28
- * and `/token` still disclosing.
18
+ * A federation grant outlives the session it was agreed through, at both HTTP
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
23
+ * this drives both, on the standalone, as a deployment composes them: a grant
24
+ * seeded beside a live browser session, the session's records gone afterwards,
25
+ * the grant and its credential exactly as they were, and `/token` still
26
+ * disclosing.
29
27
  *
30
28
  * The seeded grant's access token is fresh, so the disclosure is the cached
31
- * one and no upstream is asked — the OIDC federation the connection names is
32
- * configured with `discovery = false` and hand-typed endpoints so that the
33
- * deployment boots without a network.
29
+ * one and no upstream is asked; the OIDC federation the connection names has
30
+ * `discovery = false` and hand-typed endpoints so the deployment boots without
31
+ * a network.
34
32
  */
35
33
 
36
34
  import { createHmac } from "node:crypto";
@@ -44,6 +42,7 @@ import {
44
42
  type FederationTokenStore,
45
43
  federationGrantAuthorizationRevision,
46
44
  federationGrantIdentityRevision,
45
+ federationsOf,
47
46
  InMemoryClientRepository,
48
47
  InMemoryUserRepository,
49
48
  type MemoryFederationGrantStore,
@@ -57,7 +56,14 @@ import express from "express";
57
56
  import request from "supertest";
58
57
  import { afterEach, describe, expect, it } from "vitest";
59
58
  import { buildModules } from "../buildModules.mjs";
60
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
59
+ import { resolveConfigPaths, type Switches } from "../configPath.mjs";
60
+ import { templateReference } from "../modules.mjs";
61
+ import {
62
+ capturedRenames,
63
+ libraryLayers,
64
+ rootSectionsOf,
65
+ sectionsCoreDoesNotDeclare,
66
+ } from "./library-references.fixture.mjs";
61
67
 
62
68
  const DAY = 86_400_000;
63
69
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -83,69 +89,81 @@ const REFRESH_TOKEN = "SENTINEL-refresh-token";
83
89
  const ACCESS_TOKEN = "upstream-access-token";
84
90
 
85
91
  const ENV: Readonly<Record<string, string>> = {
86
- OAUTH_JWT_ALGORITHM: "HS256",
87
- OAUTH_JWT_SECRET: JWT_SECRET,
92
+ // The logout here is a session without a second factor.
93
+ MFA_MODE: "off",
94
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
95
+ KEY_STORE_LOCAL_SECRET: JWT_SECRET,
88
96
  OAUTH_JWT_ISSUER: ISSUER,
89
- SESSION_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
90
- SESSION_SECURE: "false",
91
- SESSION_NAME: "auth.session",
92
- SESSION_STORAGE_TYPE: "memory",
93
- CLIENT_USER_TYPE: "yaml",
94
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
95
- USER_SESSION_STORES_ADAPTER: "memory",
96
- RATE_LIMITER_ADAPTER: "memory",
97
- OAUTH_CODE_ADAPTER: "memory",
98
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
99
- REPLAY_SEEN_SET_ADAPTER: "memory",
100
- FEDERATION_TOKEN_STORE_TYPE: "memory",
101
- CONSENT_STORE_ADAPTER: "none",
102
- FEDERATION_GRANT_STORE_ADAPTER: "memory",
103
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
97
+ SESSION_STORE_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
98
+ SESSION_STORE_SECURE: "false",
99
+ SESSION_STORE_NAME: "auth.session",
100
+ SESSION_STORE_STORAGE_TYPE: "memory",
101
+ ADAPTERS_USER_REPOSITORY: "yaml",
102
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
103
+ ADAPTERS_USER_SESSION_STORES: "memory",
104
+ ADAPTERS_RATE_LIMITER: "memory",
105
+ ADAPTERS_CODE_REPOSITORY: "memory",
106
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
107
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
108
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
109
+ ADAPTERS_CONSENT_STORE: "none",
110
+ ADAPTERS_FEDERATION_GRANT_STORE: "memory",
111
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
104
112
  FEDERATION_GRANTS_ENABLED: "true",
105
113
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
106
114
  // The connect flow is not driven here, and the bundled repository has no
107
115
  // identity lookup; "unsupported" is the declaration a deployment without one
108
- // makes (#613), and what lets a connection be configured at all.
116
+ // makes, and what lets a connection be configured at all.
109
117
  FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
110
118
  };
111
119
 
112
120
  /**
113
121
  * The shipped configuration under the environment above, with the two lists
114
- * that have no environment form (comment 9 on #593) written over it: the
122
+ * that have no environment form written over it: the
115
123
  * upstream federation the connection names, and the connection.
116
124
  */
117
- function resolveConfig(): AppConfig {
125
+ function resolveConfig(): Switches {
118
126
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
119
- const config = validate(
120
- parseFile(envConfPath, { env: ENV })
121
- .withFallback(parseFile(applicationConfPath, { env: ENV }))
122
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: ENV })),
123
- AppConfigSchema,
124
- );
127
+ const layers = parseFile(envConfPath, { env: ENV })
128
+ .withFallback(parseFile(applicationConfPath, { env: ENV }))
129
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
130
+ .withFallback(libraryLayers(ENV));
131
+ const config = validate(layers, AppConfigSchema);
125
132
  return {
133
+ ...sectionsCoreDoesNotDeclare(layers),
134
+ ...rootSectionsOf(layers, ENV),
126
135
  ...config,
127
- federations: {
128
- ...config.federations,
129
- upstream: {
130
- enabled: true,
131
- type: "oidc",
132
- issuer: UPSTREAM,
133
- clientId: UPSTREAM_CLIENT_ID,
134
- clientSecret: "provider-secret",
135
- callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
136
- scopes: [...SCOPES],
137
- discovery: false,
138
- endpoints: {
139
- authorizationEndpoint: `${UPSTREAM}/authorize`,
140
- tokenEndpoint: `${UPSTREAM}/token`,
141
- jwksUri: `${UPSTREAM}/jwks`,
136
+ // What the resolution captured of core's renamed variables, which the
137
+ // schema's parse drops.
138
+ "renamed-variables": capturedRenames(ENV),
139
+ core: {
140
+ ...config.core,
141
+ federations: {
142
+ ...federationsOf(config),
143
+ upstream: {
144
+ enabled: true,
145
+ type: "oidc",
146
+ issuer: UPSTREAM,
147
+ clientId: UPSTREAM_CLIENT_ID,
148
+ clientSecret: "provider-secret",
149
+ callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
150
+ scopes: [...SCOPES],
151
+ discovery: false,
152
+ endpoints: {
153
+ authorizationEndpoint: `${UPSTREAM}/authorize`,
154
+ tokenEndpoint: `${UPSTREAM}/token`,
155
+ jwksUri: `${UPSTREAM}/jwks`,
156
+ },
157
+ redirectAllowlist: [],
142
158
  },
143
- redirectAllowlist: [],
144
159
  },
145
160
  },
146
- federationGrants: {
147
- ...config.federationGrants,
161
+ "redis-federation-grant-store": {
162
+ ...(config["redis-federation-grant-store"] as object | undefined),
148
163
  encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
164
+ },
165
+ "federation-grants": {
166
+ ...config["federation-grants"],
149
167
  connections: {
150
168
  [CONNECTION]: {
151
169
  federation: "upstream",
@@ -156,7 +174,7 @@ function resolveConfig(): AppConfig {
156
174
  },
157
175
  },
158
176
  },
159
- } as AppConfig;
177
+ } as Switches;
160
178
  }
161
179
 
162
180
  const testRepositoriesModule = defineModule({
@@ -198,7 +216,7 @@ const testKeyStoreModule = defineModule({
198
216
  registerBuiltinKeyStores(factory);
199
217
  return factory.create({
200
218
  type: "local",
201
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
219
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
202
220
  });
203
221
  },
204
222
  },
@@ -245,6 +263,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
245
263
  tokenType: "Bearer",
246
264
  obtainedAt: at,
247
265
  issuedLifetime: 3600,
266
+ effectiveExpiresAt: new Date(at.getTime() + 3_600_000),
248
267
  scopes: [...SCOPES],
249
268
  },
250
269
  },
@@ -284,7 +303,7 @@ const claimsOf = (jwt: string): Record<string, unknown> =>
284
303
  unknown
285
304
  >;
286
305
 
287
- describe("#593 AC8: a grant outlives the browser session at both logout endpoints (D14)", () => {
306
+ describe("a grant outlives the browser session at both logout endpoints", () => {
288
307
  let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
289
308
 
290
309
  afterEach(async () => {
@@ -331,7 +350,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
331
350
  expect(loginRes.status).toBe(200);
332
351
 
333
352
  const cookies = loginRes.headers["set-cookie"] as unknown as string[];
334
- const csrfCookiePrefix = `${config.session.name}.csrf=`;
353
+ const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
335
354
  const reissued = cookies
336
355
  .find((c) => c.startsWith(csrfCookiePrefix))
337
356
  ?.slice(csrfCookiePrefix.length)
@@ -385,18 +404,19 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
385
404
  await boot();
386
405
  const { cookies, csrfToken, headerName } = await login(app, config);
387
406
  const sid = await sidOf(app, cookies);
388
- // A session-bound upstream token beside the grant: the record #276's
389
- // guarantee is about, keyed by this session.
407
+ // A session-bound upstream token beside the grant, keyed by this
408
+ // session: a record logout must end.
390
409
  await federationTokenStore.attach(sid, "upstream", {
391
410
  accessToken: "session-bound-upstream-token",
392
411
  expiresAt: null,
393
- // Every field is a required key since the #645 follow-up; this
394
- // record carries only the access token.
412
+ // Every field is a required key; this record carries only the
413
+ // access token.
395
414
  refreshToken: undefined,
396
415
  idToken: undefined,
397
416
  tokenType: undefined,
398
417
  scope: undefined,
399
418
  grantedScope: undefined,
419
+ obtainedAt: undefined,
400
420
  });
401
421
  expect(await userSessionStore.get(sid)).not.toBeNull();
402
422
  await expectGrantUntouched(app, federationGrantStore);
@@ -407,7 +427,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
407
427
  .set(headerName, csrfToken);
408
428
  expect([200, 204]).toContain(logout.status);
409
429
 
410
- // The session-bound records are gone (#276 holds) ...
430
+ // The session-bound records are gone ...
411
431
  expect(await userSessionStore.get(sid)).toBeNull();
412
432
  expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
413
433
  // ... and the grant is not among them.
@@ -422,13 +442,14 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
422
442
  await federationTokenStore.attach(sid, "upstream", {
423
443
  accessToken: "session-bound-upstream-token",
424
444
  expiresAt: null,
425
- // Every field is a required key since the #645 follow-up; this
426
- // record carries only the access token.
445
+ // Every field is a required key; this record carries only the
446
+ // access token.
427
447
  refreshToken: undefined,
428
448
  idToken: undefined,
429
449
  tokenType: undefined,
430
450
  scope: undefined,
431
451
  grantedScope: undefined,
452
+ obtainedAt: undefined,
432
453
  });
433
454
  expect(await userSessionStore.get(sid)).not.toBeNull();
434
455
  await expectGrantUntouched(app, federationGrantStore);
@@ -16,8 +16,8 @@
16
16
 
17
17
  /**
18
18
  * `POST /oauth/federation/:name/token` ends a session's upstream tokens only
19
- * on the upstream's own verdict that the refresh token is bad — a structured
20
- * `invalid_grant` under a 4xx — and never while the upstream is down, whatever
19
+ * on the upstream's own verdict that the refresh token is bad (a structured
20
+ * `invalid_grant` under a 4xx), and never while the upstream is down, whatever
21
21
  * the body of its 5xx says. An outage is `503`, and the tokens are kept for
22
22
  * the retry.
23
23
  *
@@ -26,8 +26,7 @@
26
26
  * are the ones the library raises: a 4xx whose body names a code is a
27
27
  * `ResponseBodyError` carrying `error` and `status`; a 5xx is an
28
28
  * `OperationProcessingError` raised over the `Response`, whose body the
29
- * library does not read — and which the route used to answer `500
30
- * refresh_failed`, as if the upstream had said something it could not place.
29
+ * library does not read.
31
30
  */
32
31
 
33
32
  import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
@@ -76,6 +75,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
76
75
  tokenType: "Bearer",
77
76
  scope: "openid profile email",
78
77
  grantedScope: "openid profile email",
78
+ obtainedAt: undefined,
79
79
  },
80
80
  });
81
81
  const res = await request(session.app)
@@ -128,6 +128,8 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
128
128
  expect(res.status).toBe(410);
129
129
  expect(res.body.error).toBe("re_authentication_required");
130
130
  expect(await federationTokenStore.get(sid, "google")).toBeNull();
131
- expect(await sessionFederationIndex.listFederations(sid)).not.toContain("google");
131
+ // The route never writes the session's index: the link, now holding no
132
+ // credential, stays listed until federation logout or the session's end.
133
+ expect(await sessionFederationIndex.listFederations(sid)).toContain("google");
132
134
  });
133
135
  });
@@ -15,11 +15,10 @@
15
15
  */
16
16
 
17
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.
18
+ * The bridge forwards, beside `{ clientId, clientSecret, callbackURL }`, the
19
+ * four fields the redirect policy reads. A field it dropped would fail nothing
20
+ * at boot: the policy would be built as if the operator had not configured it.
21
+ * These tests fail if the bridge stops forwarding any of them.
23
22
  */
24
23
 
25
24
  import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
@@ -34,9 +33,11 @@ const bridge = (
34
33
  }
35
34
  ).provides.googleFederationConfig;
36
35
 
37
- /** Runs the bridge over a `federations.google` section in the flat shape. */
36
+ /** Runs the bridge over a `core.federations.google` entry in the flat shape. */
38
37
  function buildConfig(googleSection: Record<string, unknown>): GoogleProviderConfig {
39
- return bridge({ config: { federations: { google: { enabled: true, ...googleSection } } } });
38
+ return bridge({
39
+ config: { core: { federations: { google: { enabled: true, ...googleSection } } } },
40
+ });
40
41
  }
41
42
 
42
43
  const credentials = {
@@ -45,7 +46,7 @@ const credentials = {
45
46
  callbackURL: "https://auth.example.com/session/oauth/federation/google/callback",
46
47
  };
47
48
 
48
- describe("googleFederationConfigModule — redirect-policy plumbing (#278)", () => {
49
+ describe("googleFederationConfigModule — redirect-policy plumbing", () => {
49
50
  it("forwards sessionDomain, authCallbackUrl, clientUrl and redirectAllowlist", () => {
50
51
  const out = buildConfig({
51
52
  ...credentials,
@@ -97,13 +98,15 @@ describe("googleFederationConfigModule — redirect-policy plumbing (#278)", ()
97
98
  it("reads through the nested federation shape as well as the flat one", () => {
98
99
  const out = bridge({
99
100
  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,
101
+ core: {
102
+ federations: {
103
+ google: {
104
+ enabled: true,
105
+ type: "google",
106
+ sessionDomain: ".example.com",
107
+ redirectAllowlist: ["https://app.example.com/dashboard"],
108
+ google: credentials,
109
+ },
107
110
  },
108
111
  },
109
112
  },
@@ -113,7 +116,7 @@ describe("googleFederationConfigModule — redirect-policy plumbing (#278)", ()
113
116
  });
114
117
  });
115
118
 
116
- describe("googleFederationConfigModule — requireAuthorizationResponseIss (#597)", () => {
119
+ describe("googleFederationConfigModule — requireAuthorizationResponseIss", () => {
117
120
  it("is absent by default, so the provider's own default (required) applies", () => {
118
121
  expect("requireAuthorizationResponseIss" in buildConfig(credentials)).toBe(false);
119
122
  });
@@ -157,14 +160,14 @@ describe("googleFederationConfigModule — requireAuthorizationResponseIss (#597
157
160
 
158
161
  it("refuses an empty value, which elsewhere reads as false: exported-but-empty must not switch this check off", () => {
159
162
  expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: "" })).toThrow(
160
- /federations\.google\.requireAuthorizationResponseIss/,
163
+ /core\.federations\.google\.requireAuthorizationResponseIss/,
161
164
  );
162
165
  });
163
166
 
164
167
  it("refuses anything else: a typo must not silently switch the check off, or leave it on", () => {
165
168
  for (const bad of ["no", "off", 0, 1, []]) {
166
169
  expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: bad })).toThrow(
167
- /federations\.google\.requireAuthorizationResponseIss/,
170
+ /core\.federations\.google\.requireAuthorizationResponseIss/,
168
171
  );
169
172
  }
170
173
  });
@@ -192,7 +195,7 @@ describe("googleFederationConfigModule — accessType", () => {
192
195
  it("refuses anything else at boot, naming the key", () => {
193
196
  for (const bad of ["", "offine", "Online", "true", false, 1]) {
194
197
  expect(() => buildConfig({ ...credentials, accessType: bad })).toThrow(
195
- /federations\.google\.accessType/,
198
+ /core\.federations\.google\.accessType/,
196
199
  );
197
200
  }
198
201
  });
@@ -15,27 +15,23 @@
15
15
  */
16
16
 
17
17
  /**
18
- * A browser session with Google linked, on the standalone as a deployment
19
- * composes it: `oauthModule`, the session module and the real Google adapter,
20
- * booted through `createApp` under the shipped configuration.
18
+ * A browser session with Google linked, on the standalone composed as a
19
+ * deployment does: `oauthModule`, the session module and the real Google
20
+ * adapter, booted through `createApp` under the shipped configuration.
21
21
  *
22
- * The routes that act on a session's federation — the two logout routes and
23
- * `POST /oauth/federation/:name/token` — live in `@o3co/auth-provider-oauth`,
24
- * which depends on no adapter, so its own tests stand one in. What they hand
25
- * the adapter, and what they make of what its library raises, can only be
26
- * seen here.
22
+ * The two logout routes and `POST /oauth/federation/:name/token` live in
23
+ * `@o3co/auth-provider-oauth`, which depends on no adapter; only here can a
24
+ * test see what they hand the real adapter and what they make of what its
25
+ * library raises.
27
26
  *
28
- * Not `all-modules-composition.fixture.mts`: that one switches every module
29
- * on and signs with a key it keeps to itself. The tests here present an
30
- * `id_token_hint` and an access token of their own — a caller's, with the
31
- * claims the case needs — so the deployment signs with an HS256 secret this
32
- * file holds, and nothing but Google is federated.
27
+ * Unlike `all-modules-composition.fixture.mts`, the deployment signs with an
28
+ * HS256 secret this file holds, so tests can present an `id_token_hint` and
29
+ * access token of their own; nothing but Google is federated.
33
30
  */
34
31
 
35
32
  import { createHmac } from "node:crypto";
36
33
  import { fileURLToPath } from "node:url";
37
34
  import {
38
- type AppConfig,
39
35
  AppConfigSchema,
40
36
  createApp,
41
37
  createKeyStoreFactory,
@@ -59,7 +55,14 @@ import express from "express";
59
55
  import request from "supertest";
60
56
  import { expect } from "vitest";
61
57
  import { buildModules } from "#/buildModules.mjs";
62
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "#/configPath.mjs";
58
+ import { resolveConfigPaths, type Switches } from "#/configPath.mjs";
59
+ import { templateReference } from "../modules.mjs";
60
+ import {
61
+ capturedRenames,
62
+ libraryLayers,
63
+ rootSectionsOf,
64
+ sectionsCoreDoesNotDeclare,
65
+ } from "./library-references.fixture.mjs";
63
66
 
64
67
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
65
68
 
@@ -82,22 +85,24 @@ const GOOGLE_CLIENT_SECRET = "google-secret";
82
85
  const GOOGLE_CALLBACK = `${ISSUER}/session/oauth/federation/google/callback`;
83
86
 
84
87
  const ENV: Readonly<Record<string, string>> = {
85
- OAUTH_JWT_ALGORITHM: "HS256",
86
- OAUTH_JWT_SECRET: JWT_SECRET,
88
+ // A session signed in through Google alone, no second factor.
89
+ MFA_MODE: "off",
90
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
91
+ KEY_STORE_LOCAL_SECRET: JWT_SECRET,
87
92
  OAUTH_JWT_ISSUER: ISSUER,
88
- SESSION_SECRET: "google-session.fixture-session.at-least-32-bytes.ok",
89
- SESSION_SECURE: "false",
90
- SESSION_NAME: "auth.session",
91
- SESSION_STORAGE_TYPE: "memory",
92
- CLIENT_USER_TYPE: "yaml",
93
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
94
- USER_SESSION_STORES_ADAPTER: "memory",
95
- RATE_LIMITER_ADAPTER: "memory",
96
- OAUTH_CODE_ADAPTER: "memory",
97
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
98
- REPLAY_SEEN_SET_ADAPTER: "memory",
99
- FEDERATION_TOKEN_STORE_TYPE: "memory",
100
- CONSENT_STORE_ADAPTER: "none",
93
+ SESSION_STORE_SECRET: "google-session.fixture-session.at-least-32-bytes.ok",
94
+ SESSION_STORE_SECURE: "false",
95
+ SESSION_STORE_NAME: "auth.session",
96
+ SESSION_STORE_STORAGE_TYPE: "memory",
97
+ ADAPTERS_USER_REPOSITORY: "yaml",
98
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
99
+ ADAPTERS_USER_SESSION_STORES: "memory",
100
+ ADAPTERS_RATE_LIMITER: "memory",
101
+ ADAPTERS_CODE_REPOSITORY: "memory",
102
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
103
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
104
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
105
+ ADAPTERS_CONSENT_STORE: "none",
101
106
  };
102
107
 
103
108
  /**
@@ -111,24 +116,30 @@ export type GoogleWiring =
111
116
  | "shipped"
112
117
  | Omit<GoogleProviderConfig, "clientId" | "clientSecret" | "callbackURL">;
113
118
 
114
- function resolveConfig(google: GoogleWiring): AppConfig {
119
+ function resolveConfig(google: GoogleWiring): Switches {
115
120
  const env: Record<string, string> =
116
121
  google === "shipped"
117
122
  ? {
118
123
  ...ENV,
119
- FEDERATIONS_GOOGLE_ENABLED: "true",
120
- FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
121
- FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
122
- FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
124
+ CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
125
+ CORE_FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
126
+ CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
127
+ CORE_FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
123
128
  }
124
129
  : { ...ENV };
125
130
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
126
- return validate(
127
- parseFile(envConfPath, { env })
128
- .withFallback(parseFile(applicationConfPath, { env }))
129
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
130
- AppConfigSchema,
131
- ) as AppConfig;
131
+ const layers = parseFile(envConfPath, { env })
132
+ .withFallback(parseFile(applicationConfPath, { env }))
133
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
134
+ .withFallback(libraryLayers(env));
135
+ return {
136
+ ...sectionsCoreDoesNotDeclare(layers),
137
+ ...rootSectionsOf(layers, env),
138
+ ...validate(layers, AppConfigSchema),
139
+ // What the resolution captured of core's renamed variables, which the
140
+ // schema's parse drops.
141
+ "renamed-variables": capturedRenames(env),
142
+ } as Switches;
132
143
  }
133
144
 
134
145
  const testRepositoriesModule = defineModule({
@@ -170,7 +181,7 @@ const testKeyStoreModule = defineModule({
170
181
  registerBuiltinKeyStores(factory);
171
182
  return factory.create({
172
183
  type: "local",
173
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
184
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
174
185
  });
175
186
  },
176
187
  },
@@ -15,17 +15,10 @@
15
15
  */
16
16
 
17
17
  /**
18
- * The host's own routes — liveness, readiness, the metrics scrape — mounted
19
- * as the process mounts them (`mountRoutes`), and what an error one of them
20
- * lets through is answered with.
21
- *
22
- * The composed router answers its own errors with core's terminal handler.
23
- * The host's routes sat in front of a handler of the template's own, which
24
- * answered in other words ("Internal server error"), took any 4xx `status`
25
- * for the client's mistake and answered it as one, handed an error on to
26
- * Express once the headers were out (whose final handler prints the stack),
27
- * and logged the path as it arrived. Core's handler is now the one after
28
- * them too, so a deployment answers every route's error the same way.
18
+ * The host's own routes (liveness, readiness, the metrics scrape), mounted as
19
+ * the process mounts them (`mountRoutes`), and what an error one of them lets
20
+ * through is answered with: core's terminal handler, the same one behind the
21
+ * composed router, so every route's error is answered the same way.
29
22
  */
30
23
 
31
24
  import type { Logger, ReadinessProbe } from "@o3co/auth-provider-core";