@o3co/create-auth-provider 0.16.0 → 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 (100) 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 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  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 +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  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__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -15,35 +15,32 @@
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
+ * 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
22
+ * this drives both, on the standalone, as a deployment composes them: a grant
23
+ * seeded beside a live browser session, the session's records gone afterwards,
24
+ * the grant and its credential exactly as they were, and `/token` still
25
+ * disclosing.
29
26
  *
30
27
  * 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.
28
+ * one and no upstream is asked; the OIDC federation the connection names has
29
+ * `discovery = false` and hand-typed endpoints so the deployment boots without
30
+ * a network.
34
31
  */
35
32
 
36
33
  import { createHmac } from "node:crypto";
37
34
  import { fileURLToPath } from "node:url";
38
35
  import {
39
36
  type AppConfig,
40
- AppConfigSchema,
41
37
  createApp,
42
38
  createKeyStoreFactory,
43
39
  defineModule,
44
40
  type FederationTokenStore,
45
41
  federationGrantAuthorizationRevision,
46
42
  federationGrantIdentityRevision,
43
+ federationsOf,
47
44
  InMemoryClientRepository,
48
45
  InMemoryUserRepository,
49
46
  type MemoryFederationGrantStore,
@@ -52,12 +49,18 @@ import {
52
49
  type UserSessionStore,
53
50
  } from "@o3co/auth-provider-core";
54
51
  import { parseFile } from "@o3co/ts.hocon";
55
- import { validate } from "@o3co/ts.hocon/zod";
56
52
  import express from "express";
57
53
  import request from "supertest";
58
54
  import { afterEach, describe, expect, it } from "vitest";
59
55
  import { buildModules } from "../buildModules.mjs";
60
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
56
+ import { resolveConfigPaths } from "../configPath.mjs";
57
+ import { templateReference } from "../modules.mjs";
58
+ import {
59
+ type BothPhases,
60
+ bothPhasesOf,
61
+ capturedRenames,
62
+ libraryLayers,
63
+ } from "./library-references.fixture.mjs";
61
64
 
62
65
  const DAY = 86_400_000;
63
66
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -83,69 +86,78 @@ const REFRESH_TOKEN = "SENTINEL-refresh-token";
83
86
  const ACCESS_TOKEN = "upstream-access-token";
84
87
 
85
88
  const ENV: Readonly<Record<string, string>> = {
86
- OAUTH_JWT_ALGORITHM: "HS256",
87
- OAUTH_JWT_SECRET: JWT_SECRET,
89
+ // The logout here is a session without a second factor.
90
+ MFA_MODE: "off",
91
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
92
+ KEY_STORE_LOCAL_SECRET: JWT_SECRET,
88
93
  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",
94
+ SESSION_STORE_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
95
+ SESSION_STORE_SECURE: "false",
96
+ SESSION_STORE_NAME: "auth.session",
97
+ SESSION_STORE_STORAGE_TYPE: "memory",
98
+ ADAPTERS_USER_REPOSITORY: "yaml",
99
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
100
+ ADAPTERS_USER_SESSION_STORES: "memory",
101
+ ADAPTERS_RATE_LIMITER: "memory",
102
+ ADAPTERS_CODE_REPOSITORY: "memory",
103
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
104
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
105
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
106
+ ADAPTERS_CONSENT_STORE: "none",
107
+ ADAPTERS_FEDERATION_GRANT_STORE: "memory",
108
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
104
109
  FEDERATION_GRANTS_ENABLED: "true",
105
110
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
106
111
  // The connect flow is not driven here, and the bundled repository has no
107
112
  // identity lookup; "unsupported" is the declaration a deployment without one
108
- // makes (#613), and what lets a connection be configured at all.
113
+ // makes, and what lets a connection be configured at all.
109
114
  FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
110
115
  };
111
116
 
112
117
  /**
113
118
  * 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
119
+ * that have no environment form written over it: the
115
120
  * upstream federation the connection names, and the connection.
116
121
  */
117
- function resolveConfig(): AppConfig {
122
+ function resolveConfig(): BothPhases {
118
123
  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
- );
124
+ const layers = parseFile(envConfPath, { env: ENV })
125
+ .withFallback(parseFile(applicationConfPath, { env: ENV }))
126
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
127
+ .withFallback(libraryLayers(ENV));
128
+ const config = bothPhasesOf(layers, ENV);
125
129
  return {
126
130
  ...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`,
131
+ // What the resolution captured of the renamed variables.
132
+ "renamed-variables": capturedRenames(ENV),
133
+ core: {
134
+ ...config.core,
135
+ federations: {
136
+ ...federationsOf(config),
137
+ upstream: {
138
+ enabled: true,
139
+ type: "oidc",
140
+ issuer: UPSTREAM,
141
+ clientId: UPSTREAM_CLIENT_ID,
142
+ clientSecret: "provider-secret",
143
+ callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
144
+ scopes: [...SCOPES],
145
+ discovery: false,
146
+ endpoints: {
147
+ authorizationEndpoint: `${UPSTREAM}/authorize`,
148
+ tokenEndpoint: `${UPSTREAM}/token`,
149
+ jwksUri: `${UPSTREAM}/jwks`,
150
+ },
151
+ redirectAllowlist: [],
142
152
  },
143
- redirectAllowlist: [],
144
153
  },
145
154
  },
146
- federationGrants: {
147
- ...config.federationGrants,
155
+ "redis-federation-grant-store": {
156
+ ...(config["redis-federation-grant-store"] as object | undefined),
148
157
  encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
158
+ },
159
+ "federation-grants": {
160
+ ...config["federation-grants"],
149
161
  connections: {
150
162
  [CONNECTION]: {
151
163
  federation: "upstream",
@@ -156,7 +168,7 @@ function resolveConfig(): AppConfig {
156
168
  },
157
169
  },
158
170
  },
159
- } as AppConfig;
171
+ } as BothPhases;
160
172
  }
161
173
 
162
174
  const testRepositoriesModule = defineModule({
@@ -198,7 +210,7 @@ const testKeyStoreModule = defineModule({
198
210
  registerBuiltinKeyStores(factory);
199
211
  return factory.create({
200
212
  type: "local",
201
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
213
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
202
214
  });
203
215
  },
204
216
  },
@@ -245,6 +257,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
245
257
  tokenType: "Bearer",
246
258
  obtainedAt: at,
247
259
  issuedLifetime: 3600,
260
+ effectiveExpiresAt: new Date(at.getTime() + 3_600_000),
248
261
  scopes: [...SCOPES],
249
262
  },
250
263
  },
@@ -284,7 +297,7 @@ const claimsOf = (jwt: string): Record<string, unknown> =>
284
297
  unknown
285
298
  >;
286
299
 
287
- describe("#593 AC8: a grant outlives the browser session at both logout endpoints (D14)", () => {
300
+ describe("a grant outlives the browser session at both logout endpoints", () => {
288
301
  let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
289
302
 
290
303
  afterEach(async () => {
@@ -331,7 +344,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
331
344
  expect(loginRes.status).toBe(200);
332
345
 
333
346
  const cookies = loginRes.headers["set-cookie"] as unknown as string[];
334
- const csrfCookiePrefix = `${config.session.name}.csrf=`;
347
+ const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
335
348
  const reissued = cookies
336
349
  .find((c) => c.startsWith(csrfCookiePrefix))
337
350
  ?.slice(csrfCookiePrefix.length)
@@ -385,18 +398,19 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
385
398
  await boot();
386
399
  const { cookies, csrfToken, headerName } = await login(app, config);
387
400
  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.
401
+ // A session-bound upstream token beside the grant, keyed by this
402
+ // session: a record logout must end.
390
403
  await federationTokenStore.attach(sid, "upstream", {
391
404
  accessToken: "session-bound-upstream-token",
392
405
  expiresAt: null,
393
- // Every field is a required key since the #645 follow-up; this
394
- // record carries only the access token.
406
+ // Every field is a required key; this record carries only the
407
+ // access token.
395
408
  refreshToken: undefined,
396
409
  idToken: undefined,
397
410
  tokenType: undefined,
398
411
  scope: undefined,
399
412
  grantedScope: undefined,
413
+ obtainedAt: undefined,
400
414
  });
401
415
  expect(await userSessionStore.get(sid)).not.toBeNull();
402
416
  await expectGrantUntouched(app, federationGrantStore);
@@ -407,7 +421,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
407
421
  .set(headerName, csrfToken);
408
422
  expect([200, 204]).toContain(logout.status);
409
423
 
410
- // The session-bound records are gone (#276 holds) ...
424
+ // The session-bound records are gone ...
411
425
  expect(await userSessionStore.get(sid)).toBeNull();
412
426
  expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
413
427
  // ... and the grant is not among them.
@@ -422,13 +436,14 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
422
436
  await federationTokenStore.attach(sid, "upstream", {
423
437
  accessToken: "session-bound-upstream-token",
424
438
  expiresAt: null,
425
- // Every field is a required key since the #645 follow-up; this
426
- // record carries only the access token.
439
+ // Every field is a required key; this record carries only the
440
+ // access token.
427
441
  refreshToken: undefined,
428
442
  idToken: undefined,
429
443
  tokenType: undefined,
430
444
  scope: undefined,
431
445
  grantedScope: undefined,
446
+ obtainedAt: undefined,
432
447
  });
433
448
  expect(await userSessionStore.get(sid)).not.toBeNull();
434
449
  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)
@@ -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
  });
@@ -128,6 +131,11 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
128
131
  expect(res.status).toBe(410);
129
132
  expect(res.body.error).toBe("re_authentication_required");
130
133
  expect(await federationTokenStore.get(sid, "google")).toBeNull();
131
- expect(await sessionFederationIndex.listFederations(sid)).not.toContain("google");
134
+ // The route never writes the session's index: the link, now holding no
135
+ // credential, stays listed until federation logout or the session's end.
136
+ expect(await sessionLifecycle.federations(sid)).toEqual({
137
+ outcome: "listed",
138
+ federations: ["google"],
139
+ });
132
140
  });
133
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
+ });