@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,20 +15,19 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #593 slice 7 — the standalone composes federation grants from its config.
18
+ * The standalone composes federation grants from its config.
19
19
  *
20
20
  * What is under test is the WIRING: which modules the switches select, that
21
21
  * the shared Redis client module provides the two slots the Redis stores
22
22
  * require, and that the refusals a wrong pairing earns come from the modules
23
23
  * themselves. The flows a connection needs are the package's own tests; a
24
24
  * connection here would need a live IdP, because the OIDC federation
25
- * discovers at boot.
25
+ * discovers at boot. It also pins what the consent answer accepts under the
26
+ * session module's own CSRF guard, the one the standalone composes.
26
27
  */
27
28
 
28
29
  import { fileURLToPath } from "node:url";
29
30
  import {
30
- type AppConfig,
31
- AppConfigSchema,
32
31
  createApp,
33
32
  createKeyStoreFactory,
34
33
  defineModule,
@@ -37,14 +36,21 @@ import {
37
36
  memoryRefreshTokenFamilyStoreModule,
38
37
  registerBuiltinKeyStores,
39
38
  } from "@o3co/auth-provider-core";
39
+ import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
40
40
  import { parseFile } from "@o3co/ts.hocon";
41
- import { validate } from "@o3co/ts.hocon/zod";
42
41
  import express from "express";
43
42
  import request from "supertest";
44
43
  import { afterEach, describe, expect, it, vi } from "vitest";
45
44
  import { buildModules } from "../buildModules.mjs";
46
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
47
- import { cleanupAllowanceFor, FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS } from "../shutdown.mjs";
45
+ import { resolveConfigPaths } from "../configPath.mjs";
46
+ import { templateReference } from "../modules.mjs";
47
+ import { installGracefulShutdown } from "../shutdown.mjs";
48
+ import {
49
+ type BothPhases,
50
+ bothPhasesOf,
51
+ capturedRenames,
52
+ libraryLayers,
53
+ } from "./library-references.fixture.mjs";
48
54
 
49
55
  // The same stand-ins `replica-safety.test.mts` boots under: no socket opens,
50
56
  // and the shared clients module's readiness probe gets its PONG.
@@ -101,44 +107,46 @@ vi.mock("ioredis", () => {
101
107
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
102
108
  const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
103
109
 
104
- /** A single-replica deployment with every shared store on memory, grants off. */
110
+ /** A single-replica deployment with every shared store on memory, grants off, MFA off. */
105
111
  const BASE_ENV: Readonly<Record<string, string>> = {
106
- OAUTH_JWT_ALGORITHM: "HS256",
107
- OAUTH_JWT_SECRET: "federation-grants-composition.at-least-32-bytes.ok",
112
+ MFA_MODE: "off",
113
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
114
+ KEY_STORE_LOCAL_SECRET: "federation-grants-composition.at-least-32-bytes.ok",
108
115
  OAUTH_JWT_ISSUER: "https://auth.test",
109
- SESSION_SECRET: "federation-grants-composition-session.at-least-32-bytes.ok",
110
- SESSION_SECURE: "false",
111
- SESSION_NAME: "auth.session",
112
- SESSION_STORAGE_TYPE: "memory",
113
- CLIENT_USER_TYPE: "yaml",
114
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
115
- USER_SESSION_STORES_ADAPTER: "memory",
116
- RATE_LIMITER_ADAPTER: "memory",
117
- OAUTH_CODE_ADAPTER: "memory",
118
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
119
- REPLAY_SEEN_SET_ADAPTER: "memory",
120
- FEDERATION_TOKEN_STORE_TYPE: "memory",
121
- CONSENT_STORE_ADAPTER: "none",
122
- FEDERATION_GRANT_STORE_ADAPTER: "memory",
123
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
116
+ SESSION_STORE_SECRET: "federation-grants-composition-session.at-least-32-bytes.ok",
117
+ SESSION_STORE_SECURE: "false",
118
+ SESSION_STORE_NAME: "auth.session",
119
+ SESSION_STORE_STORAGE_TYPE: "memory",
120
+ ADAPTERS_USER_REPOSITORY: "yaml",
121
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
122
+ ADAPTERS_USER_SESSION_STORES: "memory",
123
+ ADAPTERS_RATE_LIMITER: "memory",
124
+ ADAPTERS_CODE_REPOSITORY: "memory",
125
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
126
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
127
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
128
+ ADAPTERS_CONSENT_STORE: "none",
129
+ ADAPTERS_FEDERATION_GRANT_STORE: "memory",
130
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
124
131
  };
125
132
 
126
133
  /** The umbrella E2E's shape: every shared store on Redis, more than one replica. */
127
134
  const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
128
135
  ...BASE_ENV,
129
- DEPLOYMENT_MODE: "multi",
130
- SESSION_STORAGE_TYPE: "redis",
131
- SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
132
- USER_SESSION_STORES_ADAPTER: "redis",
133
- RATE_LIMITER_ADAPTER: "redis",
134
- OAUTH_CODE_ADAPTER: "redis",
135
- ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
136
- REPLAY_SEEN_SET_ADAPTER: "redis",
137
- FEDERATION_TOKEN_STORE_TYPE: "redis",
136
+ CORE_DEPLOYMENT_MODE: "multi",
137
+ SESSION_STORE_STORAGE_TYPE: "redis",
138
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
139
+ ADAPTERS_USER_SESSION_STORES: "redis",
140
+ ADAPTERS_RATE_LIMITER: "redis",
141
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
142
+ ADAPTERS_CODE_REPOSITORY: "redis",
143
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
144
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
145
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
138
146
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
139
- CONSENT_STORE_ADAPTER: "redis",
140
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
141
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
147
+ ADAPTERS_CONSENT_STORE: "redis",
148
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
149
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
142
150
  };
143
151
 
144
152
  /** What enabling the feature adds: the switch, and the page boot requires. */
@@ -147,23 +155,24 @@ const GRANTS_ON: Readonly<Record<string, string>> = {
147
155
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
148
156
  };
149
157
 
150
- function resolveConfig(env: Record<string, string>): AppConfig {
158
+ function resolveConfig(env: Record<string, string>): BothPhases {
151
159
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
152
- const config = validate(
153
- parseFile(envConfPath, { env })
154
- .withFallback(parseFile(applicationConfPath, { env }))
155
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
156
- AppConfigSchema,
157
- );
160
+ const layers = parseFile(envConfPath, { env })
161
+ .withFallback(parseFile(applicationConfPath, { env }))
162
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
163
+ .withFallback(libraryLayers(env));
164
+ const config = bothPhasesOf(layers, env);
158
165
  // The key ring has no environment form (a list of { id, key } is HOCON's);
159
166
  // the Redis grant store refuses to construct without one under "required".
160
167
  return {
161
168
  ...config,
162
- federationGrants: {
163
- ...config.federationGrants,
169
+ // What the resolution captured of the renamed variables.
170
+ "renamed-variables": capturedRenames(env),
171
+ "redis-federation-grant-store": {
172
+ ...(config["redis-federation-grant-store"] as object | undefined),
164
173
  encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
165
174
  },
166
- } as AppConfig;
175
+ } as BothPhases;
167
176
  }
168
177
 
169
178
  const testRepositoriesModule = defineModule({
@@ -183,13 +192,13 @@ const testKeyStoreModule = defineModule({
183
192
  registerBuiltinKeyStores(factory);
184
193
  return factory.create({
185
194
  type: "local",
186
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
195
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
187
196
  });
188
197
  },
189
198
  },
190
199
  });
191
200
 
192
- const modulesFor = (config: AppConfig, memoryOnly = false, environment?: string) =>
201
+ const modulesFor = (config: BothPhases, memoryOnly = false, environment?: string) =>
193
202
  buildModules(config, {
194
203
  keyStoreModule: testKeyStoreModule,
195
204
  repositoriesModule: testRepositoriesModule,
@@ -197,15 +206,65 @@ const modulesFor = (config: AppConfig, memoryOnly = false, environment?: string)
197
206
  ...(environment === undefined ? {} : { environment }),
198
207
  });
199
208
 
200
- const boot = (config: AppConfig, memoryOnly = false, environment?: string) =>
209
+ const boot = (config: BothPhases, memoryOnly = false, environment?: string) =>
201
210
  createApp({
202
211
  modules: modulesFor(config, memoryOnly, environment),
203
212
  bootstrapComponents: { config, pathResolver: (s) => s },
204
213
  });
205
214
 
206
- const names = (config: AppConfig, memoryOnly = false) =>
215
+ const names = (config: BothPhases, memoryOnly = false) =>
207
216
  modulesFor(config, memoryOnly).map((m) => m.name);
208
217
 
218
+ /**
219
+ * `app.mts`'s shutdown over `handle`, with a server double whose drain ends at
220
+ * once and signals that are not the process's: the handle's own dispose and
221
+ * the allowance it reports.
222
+ */
223
+ function shutDown(handle: Awaited<ReturnType<typeof boot>>) {
224
+ const logger = {
225
+ trace: vi.fn(),
226
+ debug: vi.fn(),
227
+ info: vi.fn(),
228
+ warn: vi.fn(),
229
+ error: vi.fn(),
230
+ fatal: vi.fn(),
231
+ child: vi.fn(),
232
+ };
233
+ const exit = vi.fn();
234
+ const signals = new Map<string, () => void>();
235
+ const server = {
236
+ close: (callback?: (err?: Error) => void) => {
237
+ callback?.();
238
+ return server;
239
+ },
240
+ closeIdleConnections: () => undefined,
241
+ closeAllConnections: () => undefined,
242
+ };
243
+ installGracefulShutdown(server as never, {
244
+ logger: logger as never,
245
+ cleanup: () => handle.dispose(),
246
+ cleanupAllowanceMs: () => handle.cleanupAllowanceMs,
247
+ exit,
248
+ onSignal: (name, handler) => signals.set(name, handler),
249
+ offSignal: (name) => signals.delete(name),
250
+ });
251
+ return { logger, exit, sigterm: () => signals.get("SIGTERM")?.() };
252
+ }
253
+
254
+ /**
255
+ * Work the background registry holds open, as a refresh tail does, until the
256
+ * returned release: the drain waits for it.
257
+ */
258
+ const holdTheDrainOpen = (handle: Awaited<ReturnType<typeof boot>>): (() => void) => {
259
+ let release!: () => void;
260
+ handle.components.federationGrantBackground?.register(
261
+ new Promise<void>((resolve) => {
262
+ release = () => resolve();
263
+ }),
264
+ );
265
+ return release;
266
+ };
267
+
209
268
  const GRANT_MODULES = [
210
269
  "federation-grants",
211
270
  "federation-grant-background",
@@ -222,7 +281,7 @@ const messageChain = (err: unknown): string => {
222
281
  return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
223
282
  };
224
283
 
225
- describe("#593 slice 7: the standalone composes federation grants from its config", () => {
284
+ describe("the standalone composes federation grants from its config", () => {
226
285
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
227
286
 
228
287
  afterEach(async () => {
@@ -233,10 +292,10 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
233
292
  it("installs nothing of the feature while it is off, whatever the switches say", async () => {
234
293
  // Off is the default, and off must cost nothing: no store, no socket, no
235
294
  // boot requirement a deployment that never asked for grants would meet.
236
- const config = resolveConfig({ ...BASE_ENV, FEDERATION_GRANT_STORE_ADAPTER: "redis" });
295
+ const config = resolveConfig({ ...BASE_ENV, ADAPTERS_FEDERATION_GRANT_STORE: "redis" });
237
296
  const installed = names(config, true);
238
297
  for (const name of GRANT_MODULES) expect(installed).not.toContain(name);
239
- expect(installed).not.toContain("standalone:redis-clients");
298
+ expect(installed).not.toContain("redis-clients");
240
299
 
241
300
  handleRef = await boot(config, true);
242
301
  const app = express().use(handleRef.router);
@@ -257,10 +316,10 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
257
316
  );
258
317
  expect(installed).not.toContain("redis-federation-grant-store");
259
318
  expect(installed).not.toContain("redis-federation-grant-intent-store");
260
- // Listed ahead of oauthModule, as the template writes them — the
319
+ // Listed ahead of oauthEndpointsModule, as the template writes them: the
261
320
  // template's choice, since each module parses its own bodies and the
262
- // order no longer changes that; the browser half sits after the
263
- // session middleware by its own `after`.
321
+ // order does not change that; the browser half sits after the session
322
+ // middleware by its own `after`.
264
323
  expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
265
324
 
266
325
  handleRef = await boot(config, true);
@@ -269,6 +328,14 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
269
328
  // not express's 404.
270
329
  expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(401);
271
330
  expect(handleRef.components.subjectRevocationService).toBeDefined();
331
+ // The connect flow's login trip is the session module's loginEntry,
332
+ // built from the configured login page: the URL connect sends
333
+ // a browser that is not signed in to.
334
+ const page = (config.session as { loginPage: { url: string } }).loginPage.url;
335
+ const back = "https://auth.example/session/federation-grants/connect?request=h";
336
+ expect(handleRef.components.loginEntry?.urlFor(back)).toBe(
337
+ `${page}${page.includes("?") ? "&" : "?"}redirect_to=${encodeURIComponent(back)}`,
338
+ );
272
339
  });
273
340
 
274
341
  it("selects the Redis stores under the all-Redis environment, provides their clients, and boots under multi", async () => {
@@ -283,7 +350,7 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
283
350
  );
284
351
  expect(installed).not.toContain("core-federation-grant-store-memory");
285
352
  expect(installed).not.toContain("core-federation-grant-intent-store-memory");
286
- const clients = modules.find((m) => m.name === "standalone:redis-clients");
353
+ const clients = modules.find((m) => m.name === "redis-clients");
287
354
  expect(Object.keys(clients?.provides ?? {})).toEqual(
288
355
  expect.arrayContaining(["federationGrantStoreClient", "federationGrantIntentStoreClient"]),
289
356
  );
@@ -292,13 +359,83 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
292
359
  expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
293
360
  });
294
361
 
295
- it("gives cleanup the documented 45 seconds under the shipped budgets, and nothing while off", () => {
296
- // The allowance is derived from reference.conf's budgets; if those move,
297
- // the number every README states moves with them, and this says so.
298
- expect(cleanupAllowanceFor(resolveConfig({ ...BASE_ENV, ...GRANTS_ON }))).toEqual({
299
- cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
300
- });
301
- expect(cleanupAllowanceFor(resolveConfig(BASE_ENV))).toEqual({});
362
+ it("gives cleanup the documented 45 seconds under the shipped budgets, not the drain's ten", async () => {
363
+ // reference.conf's budgets: 25 s + 3 s + 5 s, plus the 12-second margin.
364
+ handleRef = await boot(resolveConfig({ ...BASE_ENV, ...GRANTS_ON }), true);
365
+ expect(handleRef.cleanupAllowanceMs).toBe(45_000);
366
+
367
+ vi.useFakeTimers();
368
+ const release = holdTheDrainOpen(handleRef);
369
+ try {
370
+ const { logger, exit, sigterm } = shutDown(handleRef);
371
+ sigterm();
372
+ await vi.advanceTimersByTimeAsync(44_999);
373
+ expect(exit).not.toHaveBeenCalled();
374
+ await vi.advanceTimersByTimeAsync(1);
375
+ expect(logger.error).toHaveBeenCalledWith(
376
+ { cleanupTimeoutMs: 45_000 },
377
+ "shutdown_cleanup_timed_out",
378
+ );
379
+ expect(exit).toHaveBeenCalledExactlyOnceWith(1);
380
+ } finally {
381
+ release();
382
+ vi.useRealTimers();
383
+ }
384
+ });
385
+
386
+ it("grows the allowance with a raised refresh budget", async () => {
387
+ const shipped = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
388
+ // The lock must outlive the raised hard timeout, or boot refuses first.
389
+ const raised = {
390
+ ...shipped,
391
+ "federation-grants": {
392
+ ...shipped["federation-grants"],
393
+ upstreamHardTimeoutMs: 60_000,
394
+ refreshLockTtlMs: 65_000,
395
+ },
396
+ } as BothPhases;
397
+ handleRef = await boot(raised, true);
398
+ expect(handleRef.cleanupAllowanceMs).toBe(60_000 + 3_000 + 5_000 + 12_000);
399
+
400
+ vi.useFakeTimers();
401
+ const release = holdTheDrainOpen(handleRef);
402
+ try {
403
+ const { logger, exit, sigterm } = shutDown(handleRef);
404
+ sigterm();
405
+ await vi.advanceTimersByTimeAsync(79_999);
406
+ expect(exit).not.toHaveBeenCalled();
407
+ await vi.advanceTimersByTimeAsync(1);
408
+ expect(logger.error).toHaveBeenCalledWith(
409
+ { cleanupTimeoutMs: 80_000 },
410
+ "shutdown_cleanup_timed_out",
411
+ );
412
+ } finally {
413
+ release();
414
+ vi.useRealTimers();
415
+ }
416
+ });
417
+
418
+ it("reports no allowance while the feature is off, and cleanup's budget is the drain's ten seconds", async () => {
419
+ handleRef = await boot(resolveConfig(BASE_ENV), true);
420
+ expect(handleRef.cleanupAllowanceMs).toBeUndefined();
421
+ const { logger, exit, sigterm } = shutDown(handleRef);
422
+ sigterm();
423
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledExactlyOnceWith(0));
424
+ expect(logger.info).toHaveBeenCalledWith(
425
+ { drainTimeoutMs: 10_000, cleanupTimeoutMs: 10_000 },
426
+ "shutdown_draining",
427
+ );
428
+ });
429
+
430
+ it("covers a tail registered after the shutdown was installed", async () => {
431
+ handleRef = await boot(resolveConfig(BASE_ENV), true);
432
+ const { logger, sigterm } = shutDown(handleRef);
433
+ handleRef.components.lifecycleRegistrar?.register(async () => {}, { tailMs: 60_000 });
434
+ sigterm();
435
+ expect(logger.info).toHaveBeenCalledWith(
436
+ { drainTimeoutMs: 10_000, cleanupTimeoutMs: 60_000 },
437
+ "shutdown_draining",
438
+ );
302
439
  });
303
440
 
304
441
  it("adds the shared Redis client for the grant stores alone", () => {
@@ -307,15 +444,15 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
307
444
  const config = resolveConfig({
308
445
  ...BASE_ENV,
309
446
  ...GRANTS_ON,
310
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
311
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
447
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
448
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
312
449
  });
313
- expect(names(config, true)).toContain("standalone:redis-clients");
450
+ expect(names(config, true)).toContain("redis-clients");
314
451
  });
315
452
 
316
453
  it.each([
317
- ["FEDERATION_GRANT_STORE_ADAPTER", "core-federation-grant-store-memory"],
318
- ["FEDERATION_GRANT_INTENT_STORE_ADAPTER", "core-federation-grant-intent-store-memory"],
454
+ ["ADAPTERS_FEDERATION_GRANT_STORE", "core-federation-grant-store-memory"],
455
+ ["ADAPTERS_FEDERATION_GRANT_INTENT_STORE", "core-federation-grant-intent-store-memory"],
319
456
  ])("%s=memory is refused under multi, naming %s", async (variable, module) => {
320
457
  const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON, [variable]: "memory" });
321
458
  await expect(boot(config)).rejects.toMatchObject({
@@ -325,15 +462,87 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
325
462
  });
326
463
  });
327
464
 
328
- it("refuses Redis grants beside memory user-session stores, naming the boundary (D13)", async () => {
465
+ it.each([
466
+ [
467
+ "a __Secure- name that is not secure",
468
+ "name",
469
+ { SESSION_STORE_NAME: "__Secure-auth.session", SESSION_STORE_SECURE: "false" },
470
+ /__Secure- prefix requires session-store\.secure=true/,
471
+ ],
472
+ [
473
+ "a name that is not an RFC 6265 token",
474
+ "name",
475
+ { SESSION_STORE_NAME: "auth session" },
476
+ /is not a cookie name \(an RFC 6265 token\)/,
477
+ ],
478
+ [
479
+ "a domain that is a URL",
480
+ "domain",
481
+ { SESSION_STORE_NAME: "auth.session", SESSION_STORE_DOMAIN: "https://auth.example.com" },
482
+ /is not a cookie domain/,
483
+ ],
484
+ ])(
485
+ "refuses at validation a session cookie no browser keeps, %s, naming session-store.%s, with or without the subject revocation service",
486
+ async (_what, key, cookie, refusal) => {
487
+ for (const grants of [{}, GRANTS_ON]) {
488
+ const caught = await boot(resolveConfig({ ...BASE_ENV, ...grants, ...cookie }), true).then(
489
+ async (handle) => {
490
+ await handle.dispose();
491
+ return undefined;
492
+ },
493
+ (err: unknown) => err,
494
+ );
495
+ expect(caught).toMatchObject({
496
+ name: "BootError",
497
+ reason: "config-validation-failed",
498
+ stage: "validateManifests",
499
+ details: {
500
+ issues: [expect.objectContaining({ path: ["session-store", key] })],
501
+ },
502
+ });
503
+ expect(messageChain(caught)).toMatch(refusal);
504
+ }
505
+ },
506
+ );
507
+
508
+ it.each([
509
+ // `config/application.conf`'s own cookie: `__Host-`, secure, host-only.
510
+ ["the template's default", { SESSION_STORE_NAME: undefined, SESSION_STORE_SECURE: undefined }],
511
+ // `.env.example`'s plain-HTTP pair, which `make dev` runs with.
512
+ [".env.example's", { SESSION_STORE_NAME: "auth.sid", SESSION_STORE_SECURE: "false" }],
513
+ // `docker-compose.production.yml` restores the default name, secure.
514
+ [
515
+ "the production compose file's",
516
+ { SESSION_STORE_NAME: "__Host-auth.session", SESSION_STORE_SECURE: "true" },
517
+ ],
518
+ // o3co/auth's `tests/docker-compose.yml`, which the umbrella E2E boots.
519
+ ["the umbrella E2E's", { SESSION_STORE_NAME: "auth.session", SESSION_STORE_SECURE: "false" }],
520
+ ])(
521
+ "boots %s session cookie, with or without the subject revocation service",
522
+ async (_what, cookie) => {
523
+ const env: Record<string, string> = { ...BASE_ENV };
524
+ for (const [name, value] of Object.entries(cookie)) {
525
+ if (value === undefined) delete env[name];
526
+ else env[name] = value;
527
+ }
528
+ for (const grants of [{}, GRANTS_ON]) {
529
+ handleRef = await boot(resolveConfig({ ...env, ...grants }), true);
530
+ expect(handleRef.routes.map((r) => r.contribution.id)).toContain("session-middleware");
531
+ await handleRef.dispose();
532
+ handleRef = undefined;
533
+ }
534
+ },
535
+ );
536
+
537
+ it("refuses Redis grants beside memory user-session stores, naming the boundary", async () => {
329
538
  // The grants would outlive the process; the boundary that ends them
330
539
  // would not. The routes module refuses the pairing on a single replica
331
540
  // too, and its message names the remedy.
332
541
  const config = resolveConfig({
333
542
  ...BASE_ENV,
334
543
  ...GRANTS_ON,
335
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
336
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
544
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
545
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
337
546
  });
338
547
  let error: unknown;
339
548
  try {
@@ -344,7 +553,7 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
344
553
  expect(messageChain(error)).toMatch(/subject boundary is kept in "memory"/);
345
554
  });
346
555
 
347
- it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it (#473)", async () => {
556
+ it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it", async () => {
348
557
  // The federation-token store's guard reads `environment` beside
349
558
  // NODE_ENV so that CONFIG_ENV=production is production to it; the grant
350
559
  // store's must too, or a deployment selecting its config by CONFIG_ENV
@@ -352,13 +561,19 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
352
561
  const config = resolveConfig({
353
562
  ...BASE_ENV,
354
563
  ...GRANTS_ON,
355
- USER_SESSION_STORES_ADAPTER: "redis",
356
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
357
- FEDERATION_GRANTS_ENCRYPTION_MODE: "allow-plaintext",
564
+ ADAPTERS_USER_SESSION_STORES: "redis",
565
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
566
+ REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE: "allow-plaintext",
358
567
  });
359
568
  let error: unknown;
360
569
  try {
361
- handleRef = await boot(config, false, "production");
570
+ // Under production the template installs the SMTP sender's module,
571
+ // whose section the package's builder carries.
572
+ handleRef = await boot(
573
+ { ...config, ...standardSmtpMailSenderConfigForTests() },
574
+ false,
575
+ "production",
576
+ );
362
577
  } catch (caught) {
363
578
  error = caught;
364
579
  }
@@ -374,9 +589,9 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
374
589
  const config = resolveConfig({
375
590
  ...BASE_ENV,
376
591
  ...GRANTS_ON,
377
- USER_SESSION_STORES_ADAPTER: "redis",
378
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
379
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
592
+ ADAPTERS_USER_SESSION_STORES: "redis",
593
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
594
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
380
595
  });
381
596
  handleRef = await boot(config);
382
597
  expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
@@ -386,10 +601,10 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
386
601
 
387
602
  describe("the browser consent route parses its own body, with sessionModule listed ahead of it", () => {
388
603
  // `sessionModule`'s routers are mounted at `/session`, the prefix the
389
- // federation grants browser half mounts under too. Their parsers ran for
390
- // every request beneath `/session`, so with `sessionModule` listed first
391
- // the consent route's body arrived parsed — past its 16 KiB bound, its
392
- // throttle-then-parse order and its JSON refusals.
604
+ // federation grants browser half mounts under too. If their parsers ran
605
+ // for every request beneath `/session`, then with `sessionModule` listed
606
+ // first the consent route's body would arrive parsed, past its 16 KiB
607
+ // bound, its throttle-then-parse order and its JSON refusals.
393
608
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
394
609
 
395
610
  afterEach(async () => {
@@ -398,7 +613,7 @@ describe("the browser consent route parses its own body, with sessionModule list
398
613
  });
399
614
 
400
615
  /** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
401
- const sessionFirst = (config: AppConfig) => {
616
+ const sessionFirst = (config: BothPhases) => {
402
617
  const modules = modulesFor(config, true);
403
618
  const session = modules.find((m) => m.name === "session");
404
619
  if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
@@ -443,3 +658,123 @@ describe("the browser consent route parses its own body, with sessionModule list
443
658
  expect(res.headers["cache-control"]).toContain("no-store");
444
659
  });
445
660
  });
661
+
662
+ describe("the grants consent answer is held to the session module's CSRF guard", () => {
663
+ // No browser is signed in here: an answer the guard accepts reaches the
664
+ // route's own `401 login_required`, and one it refuses stops at its `403`.
665
+ const CONSENT = "/session/federation-grants/consent";
666
+ const ISSUER_ORIGIN = "https://auth.test";
667
+ const SIBLING = "https://account.auth.test";
668
+ /** What the deployment's proxy forwards: the origin the browser addressed. */
669
+ const FORWARDED = { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "auth.test" };
670
+ const CROSS_SITE = { error: "invalid_request", error_description: "cross-site answer refused" };
671
+ const NO_ORIGIN = {
672
+ error: "invalid_request",
673
+ error_description: "no origin and no valid csrf token",
674
+ };
675
+
676
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
677
+
678
+ afterEach(async () => {
679
+ await handleRef?.dispose();
680
+ handleRef = undefined;
681
+ });
682
+
683
+ /** The standalone with grants on and `SIBLING` on `session.csrf.trustedOrigins`, behind its proxy. */
684
+ const bootTrustingSibling = async () => {
685
+ const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
686
+ const session = config.session as { csrf?: object };
687
+ const trusting = {
688
+ ...config,
689
+ session: { ...session, csrf: { ...session.csrf, trustedOrigins: [SIBLING] } },
690
+ } as BothPhases;
691
+ handleRef = await boot(trusting, true);
692
+ return express().set("trust proxy", "loopback").use(handleRef.router);
693
+ };
694
+
695
+ /** The token `GET /session/csrf` hands out, and the `Cookie` header that carries it back. */
696
+ const tokenFrom = async (app: express.Express) => {
697
+ const issued = await request(app).get("/session/csrf").set(FORWARDED);
698
+ expect(issued.status).toBe(200);
699
+ const [cookie] = (issued.headers["set-cookie"] as unknown as string[])[0]?.split(";") ?? [];
700
+ return { token: issued.body.csrf_token as string, cookie: cookie ?? "" };
701
+ };
702
+
703
+ const answer = (
704
+ app: express.Express,
705
+ headers: Readonly<Record<string, string>>,
706
+ body: Readonly<Record<string, string>> = {},
707
+ ) =>
708
+ request(app)
709
+ .post(CONSENT)
710
+ .set(FORWARDED)
711
+ .set({ ...headers })
712
+ .type("form")
713
+ .send({ challenge: "c", decision: "accept", ...body });
714
+
715
+ it("accepts an Origin that is the request's own, as the proxy forwards it", async () => {
716
+ const app = await bootTrustingSibling();
717
+ const res = await answer(app, { Origin: ISSUER_ORIGIN, "Sec-Fetch-Site": "same-origin" });
718
+ expect(res.status).toBe(401);
719
+ expect(res.body.error).toBe("login_required");
720
+ });
721
+
722
+ it("refuses Origin: null, even with the token GET /session/csrf hands out", async () => {
723
+ const app = await bootTrustingSibling();
724
+ const { token, cookie } = await tokenFrom(app);
725
+ const res = await answer(
726
+ app,
727
+ { Origin: "null", "Sec-Fetch-Site": "same-origin", Cookie: cookie },
728
+ { csrf_token: token },
729
+ );
730
+ expect(res.status).toBe(403);
731
+ expect(res.body).toEqual(CROSS_SITE);
732
+ });
733
+
734
+ it("refuses an answer with no Origin, no Referer and no token, and accepts one echoing the token GET /session/csrf hands out", async () => {
735
+ const app = await bootTrustingSibling();
736
+ const refused = await answer(app, { "Sec-Fetch-Site": "same-origin" });
737
+ expect(refused.status).toBe(403);
738
+ expect(refused.body).toEqual(NO_ORIGIN);
739
+ const { token, cookie } = await tokenFrom(app);
740
+ const accepted = await answer(app, { Cookie: cookie }, { csrf_token: token });
741
+ expect(accepted.status).toBe(401);
742
+ expect(accepted.body.error).toBe("login_required");
743
+ });
744
+
745
+ it("accepts an answer with no Origin that echoes the token GET /session/csrf hands out in the x-csrf-token header", async () => {
746
+ const app = await bootTrustingSibling();
747
+ const { token, cookie } = await tokenFrom(app);
748
+ const res = await answer(app, { Cookie: cookie, "x-csrf-token": token });
749
+ expect(res.status).toBe(401);
750
+ expect(res.body.error).toBe("login_required");
751
+ });
752
+
753
+ it("refuses an answer with no Origin whose token is not its cookie's", async () => {
754
+ const app = await bootTrustingSibling();
755
+ const first = await tokenFrom(app);
756
+ const second = await tokenFrom(app);
757
+ const res = await answer(app, { Cookie: first.cookie }, { csrf_token: second.token });
758
+ expect(res.status).toBe(403);
759
+ expect(res.body).toEqual(NO_ORIGIN);
760
+ });
761
+
762
+ it("accepts an origin on session.csrf.trustedOrigins sending Sec-Fetch-Site: same-site", async () => {
763
+ const app = await bootTrustingSibling();
764
+ const res = await answer(app, { Origin: SIBLING, "Sec-Fetch-Site": "same-site" });
765
+ expect(res.status).toBe(401);
766
+ expect(res.body.error).toBe("login_required");
767
+ });
768
+
769
+ it("refuses a cross-site answer, even with the token", async () => {
770
+ const app = await bootTrustingSibling();
771
+ const { token, cookie } = await tokenFrom(app);
772
+ const res = await answer(
773
+ app,
774
+ { Origin: "https://evil.test", "Sec-Fetch-Site": "cross-site", Cookie: cookie },
775
+ { csrf_token: token },
776
+ );
777
+ expect(res.status).toBe(403);
778
+ expect(res.body).toEqual(CROSS_SITE);
779
+ });
780
+ });