@o3co/create-auth-provider 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +34 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +565 -130
  7. package/templates/standalone/README.md +356 -92
  8. package/templates/standalone/config/application.conf +77 -2
  9. package/templates/standalone/docker-compose.production.yml +7 -0
  10. package/templates/standalone/docker-compose.yml +7 -0
  11. package/templates/standalone/gitignore +3 -2
  12. package/templates/standalone/package.json +3 -2
  13. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  14. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  16. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
  18. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
  19. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
  20. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  21. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
  22. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  23. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  24. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  25. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  26. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  27. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  28. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  29. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  30. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  31. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
  32. package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
  33. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  34. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  35. package/templates/standalone/src/app.mts +30 -51
  36. package/templates/standalone/src/buildModules.mts +94 -12
  37. package/templates/standalone/src/listen.mts +66 -0
  38. package/templates/standalone/src/logger.mts +7 -4
  39. package/templates/standalone/src/modules.mts +118 -12
  40. package/templates/standalone/src/routes.mts +95 -0
  41. package/templates/standalone/src/shutdown.mts +90 -18
  42. package/templates/standalone/vitest.supertest-loopback.mts +22 -3
  43. package/templates/versions.json +15 -14
  44. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  45. package/templates/standalone/src/terminalError.mts +0 -60
@@ -52,6 +52,7 @@ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPa
52
52
  const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
53
53
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
54
54
  const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url));
55
+ const readmeJaPath = fileURLToPath(new URL("../../README.ja.md", import.meta.url));
55
56
 
56
57
  /**
57
58
  * Every environment variable the shipped artifact documents, with a value in
@@ -174,12 +175,27 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
174
175
  REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
175
176
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
176
177
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
178
+ // #593: federation grants — a user's standing consent that a client may
179
+ // obtain upstream tokens without them. The overrides `reference.conf`
180
+ // declares, and (slice 7) the two adapter switches the template composes
181
+ // the feature from.
182
+ FEDERATION_GRANTS_ENABLED: "true",
183
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
184
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
185
+ FEDERATION_GRANTS_ENCRYPTION_MODE: "required",
186
+ FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION: "false",
187
+ // Slice 6: acquisition's two deployment decisions — whether the callback
188
+ // refuses an upstream account linked to another user, and the consent page.
189
+ FEDERATION_GRANTS_IDENTITY_LOOKUP: "required",
190
+ FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
191
+ REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX: "fg:",
177
192
 
178
193
  // --- federation ---------------------------------------------------
179
194
  FEDERATIONS_GOOGLE_ENABLED: "true",
180
195
  FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
181
196
  FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
182
197
  FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
198
+ FEDERATIONS_GOOGLE_ACCESS_TYPE: "online",
183
199
  // #524: the generic OIDC federation the template ships disabled.
184
200
  FEDERATIONS_OIDC_ENABLED: "true",
185
201
  FEDERATIONS_OIDC_ISSUER: "https://idp.test",
@@ -195,8 +211,13 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
195
211
  CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
196
212
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
197
213
  CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
214
+ // #613: the Store's identity lookup for federation grants (D7 check 5).
215
+ CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
216
+ "https://users.example.com/find-subject-by-federated-identity",
198
217
  CLIENT_USER_TIMEOUT: "5000",
199
218
  CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
219
+ // The credential the http user adapter presents to the Store; >= 32 bytes.
220
+ CLIENT_USER_BEARER_TOKEN: "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
200
221
  CLIENT_CODE_TYPE: "redis",
201
222
  CLIENT_CODE_DEFAULT_EXPIRES_IN: "600",
202
223
  CLIENT_CODE_ENDPOINT_URI: "redis://redis:6379",
@@ -289,11 +310,9 @@ function substitutionsIn(path: string): Set<string> {
289
310
  * The underscore is what separates an environment variable from the HTTP
290
311
  * methods in the endpoint table, which share the shouting-case shape.
291
312
  */
292
- function documentedInReadme(): Set<string> {
313
+ function documentedInReadme(path: string = readmePath): Set<string> {
293
314
  const found = new Set<string>();
294
- for (const match of readFileSync(readmePath, "utf8").matchAll(
295
- /^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm,
296
- )) {
315
+ for (const match of readFileSync(path, "utf8").matchAll(/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm)) {
297
316
  const name = match[1] as string;
298
317
  if (name.includes("_")) found.add(name);
299
318
  }
@@ -323,6 +342,10 @@ describe("#288: the shipped config boots with every documented override supplied
323
342
  expect(config.oauth.resourceIndicator?.enabled).toBe(true);
324
343
  expect(config.federations.google?.enabled).toBe(true);
325
344
  expect(config.federations.oidc?.enabled).toBe(true);
345
+ // #593: the newest one, and the one where a leftover string would be
346
+ // read as "on" by a truthiness check and as "off" by `=== true` — a
347
+ // feature whose whole default is off.
348
+ expect(config.federationGrants?.enabled).toBe(true);
326
349
  });
327
350
 
328
351
  it("turns every non-boolean override into its declared type", () => {
@@ -330,6 +353,7 @@ describe("#288: the shipped config boots with every documented override supplied
330
353
  expect(config.http.port).toBe(3000);
331
354
  expect(config.http.readinessTimeoutMs).toBe(1500);
332
355
  expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
356
+ expect(config.federations.google?.accessType).toBe("online");
333
357
  // The new default wins over the deprecated variable, and the parsed
334
358
  // config mirrors it onto the old key for readers that predate the split.
335
359
  expect(resolveAccessTokenLifetime(config)).toEqual({
@@ -501,12 +525,23 @@ describe("#288: the shipped config boots with every documented override supplied
501
525
  });
502
526
 
503
527
  it("covers every environment variable the README documents", () => {
504
- const uncovered = [...documentedInReadme()].filter(
528
+ const uncovered = [...documentedInReadme(), ...documentedInReadme(readmeJaPath)].filter(
505
529
  (name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
506
530
  );
507
531
  expect(uncovered).toEqual([]);
508
532
  });
509
533
 
534
+ it("documents the same variables in README.md and README.ja.md", () => {
535
+ // The Japanese README carries the same facts (AGENTS.md): a row added
536
+ // to one and not the other is a variable one audience never hears of.
537
+ const en = documentedInReadme();
538
+ const ja = documentedInReadme(readmeJaPath);
539
+ expect({
540
+ onlyInEnglish: [...en].filter((name) => !ja.has(name)).sort(),
541
+ onlyInJapanese: [...ja].filter((name) => !en.has(name)).sort(),
542
+ }).toEqual({ onlyInEnglish: [], onlyInJapanese: [] });
543
+ });
544
+
510
545
  it("exercises no variable the config layers no longer substitute", () => {
511
546
  // Catches the reverse drift: a README row (and a test entry) left
512
547
  // behind by a key that was removed from the HOCON.
@@ -0,0 +1,445 @@
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
+ * #593 slice 7 — the standalone composes federation grants from its config.
19
+ *
20
+ * What is under test is the WIRING: which modules the switches select, that
21
+ * the shared Redis client module provides the two slots the Redis stores
22
+ * require, and that the refusals a wrong pairing earns come from the modules
23
+ * themselves. The flows a connection needs are the package's own tests; a
24
+ * connection here would need a live IdP, because the OIDC federation
25
+ * discovers at boot.
26
+ */
27
+
28
+ import { fileURLToPath } from "node:url";
29
+ import {
30
+ type AppConfig,
31
+ AppConfigSchema,
32
+ createApp,
33
+ createKeyStoreFactory,
34
+ defineModule,
35
+ InMemoryClientRepository,
36
+ InMemoryUserRepository,
37
+ memoryRefreshTokenFamilyStoreModule,
38
+ registerBuiltinKeyStores,
39
+ } from "@o3co/auth-provider-core";
40
+ import { parseFile } from "@o3co/ts.hocon";
41
+ import { validate } from "@o3co/ts.hocon/zod";
42
+ import express from "express";
43
+ import request from "supertest";
44
+ import { afterEach, describe, expect, it, vi } from "vitest";
45
+ import { buildModules } from "../buildModules.mjs";
46
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
47
+ import { cleanupAllowanceFor, FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS } from "../shutdown.mjs";
48
+
49
+ // The same stand-ins `replica-safety.test.mts` boots under: no socket opens,
50
+ // and the shared clients module's readiness probe gets its PONG.
51
+ vi.mock("redis", () => ({
52
+ createClient: vi.fn(() => ({
53
+ connect: vi.fn().mockResolvedValue(undefined),
54
+ quit: vi.fn().mockResolvedValue(undefined),
55
+ ping: vi.fn().mockResolvedValue("PONG"),
56
+ on: vi.fn(),
57
+ })),
58
+ }));
59
+
60
+ vi.mock("connect-redis", async () => {
61
+ const { EventEmitter } = await import("node:events");
62
+ return {
63
+ RedisStore: class MockRedisStore extends EventEmitter {
64
+ constructor(_opts: { client: unknown }) {
65
+ super();
66
+ }
67
+ get(): unknown {
68
+ return undefined;
69
+ }
70
+ set(): void {}
71
+ destroy(): void {}
72
+ },
73
+ };
74
+ });
75
+
76
+ vi.mock("ioredis", () => {
77
+ const explicit: Record<string, unknown> = {
78
+ on: () => undefined,
79
+ quit: async () => "OK",
80
+ disconnect: () => undefined,
81
+ ping: async () => "PONG",
82
+ };
83
+ const makeMockRedis = (): object =>
84
+ new Proxy(
85
+ {},
86
+ {
87
+ get(_target, prop) {
88
+ if (typeof prop !== "string" || prop === "then") return undefined;
89
+ if (prop === "duplicate") return makeMockRedis;
90
+ if (prop in explicit) return explicit[prop];
91
+ return async () => null;
92
+ },
93
+ },
94
+ );
95
+ function MockRedis(): object {
96
+ return makeMockRedis();
97
+ }
98
+ return { Redis: MockRedis, default: MockRedis };
99
+ });
100
+
101
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
102
+ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
103
+
104
+ /** A single-replica deployment with every shared store on memory, grants off. */
105
+ const BASE_ENV: Readonly<Record<string, string>> = {
106
+ OAUTH_JWT_ALGORITHM: "HS256",
107
+ OAUTH_JWT_SECRET: "federation-grants-composition.at-least-32-bytes.ok",
108
+ 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",
124
+ };
125
+
126
+ /** The umbrella E2E's shape: every shared store on Redis, more than one replica. */
127
+ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
128
+ ...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",
138
+ 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",
142
+ };
143
+
144
+ /** What enabling the feature adds: the switch, and the page boot requires. */
145
+ const GRANTS_ON: Readonly<Record<string, string>> = {
146
+ FEDERATION_GRANTS_ENABLED: "true",
147
+ FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
148
+ };
149
+
150
+ function resolveConfig(env: Record<string, string>): AppConfig {
151
+ 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
+ );
158
+ // The key ring has no environment form (a list of { id, key } is HOCON's);
159
+ // the Redis grant store refuses to construct without one under "required".
160
+ return {
161
+ ...config,
162
+ federationGrants: {
163
+ ...config.federationGrants,
164
+ encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
165
+ },
166
+ } as AppConfig;
167
+ }
168
+
169
+ const testRepositoriesModule = defineModule({
170
+ name: "test:repositories",
171
+ provides: {
172
+ clientRepository: () => new InMemoryClientRepository(new Map()),
173
+ userRepository: () => new InMemoryUserRepository(new Map()),
174
+ },
175
+ });
176
+
177
+ const testKeyStoreModule = defineModule({
178
+ name: "test:key-store",
179
+ requires: ["config"] as const,
180
+ provides: {
181
+ keyStore: async ({ config: c }) => {
182
+ const factory = createKeyStoreFactory();
183
+ registerBuiltinKeyStores(factory);
184
+ return factory.create({
185
+ type: "local",
186
+ ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
187
+ });
188
+ },
189
+ },
190
+ });
191
+
192
+ const modulesFor = (config: AppConfig, memoryOnly = false, environment?: string) =>
193
+ buildModules(config, {
194
+ keyStoreModule: testKeyStoreModule,
195
+ repositoriesModule: testRepositoriesModule,
196
+ ...(memoryOnly ? { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] } : {}),
197
+ ...(environment === undefined ? {} : { environment }),
198
+ });
199
+
200
+ const boot = (config: AppConfig, memoryOnly = false, environment?: string) =>
201
+ createApp({
202
+ modules: modulesFor(config, memoryOnly, environment),
203
+ bootstrapComponents: { config, pathResolver: (s) => s },
204
+ });
205
+
206
+ const names = (config: AppConfig, memoryOnly = false) =>
207
+ modulesFor(config, memoryOnly).map((m) => m.name);
208
+
209
+ const GRANT_MODULES = [
210
+ "federation-grants",
211
+ "federation-grant-background",
212
+ "core-federation-grant-store-memory",
213
+ "core-federation-grant-intent-store-memory",
214
+ "redis-federation-grant-store",
215
+ "redis-federation-grant-intent-store",
216
+ "subject-revocation-service",
217
+ ];
218
+
219
+ /** The message of a boot failure and of the module error it wraps, together. */
220
+ const messageChain = (err: unknown): string => {
221
+ const e = err as { message?: string; cause?: { message?: string } };
222
+ return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
223
+ };
224
+
225
+ describe("#593 slice 7: the standalone composes federation grants from its config", () => {
226
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
227
+
228
+ afterEach(async () => {
229
+ await handleRef?.dispose();
230
+ handleRef = undefined;
231
+ });
232
+
233
+ it("installs nothing of the feature while it is off, whatever the switches say", async () => {
234
+ // Off is the default, and off must cost nothing: no store, no socket, no
235
+ // boot requirement a deployment that never asked for grants would meet.
236
+ const config = resolveConfig({ ...BASE_ENV, FEDERATION_GRANT_STORE_ADAPTER: "redis" });
237
+ const installed = names(config, true);
238
+ for (const name of GRANT_MODULES) expect(installed).not.toContain(name);
239
+ expect(installed).not.toContain("standalone:redis-clients");
240
+
241
+ handleRef = await boot(config, true);
242
+ const app = express().use(handleRef.router);
243
+ expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(404);
244
+ });
245
+
246
+ it("boots on memory stores with no connection, and mounts the routes", async () => {
247
+ const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
248
+ const installed = names(config, true);
249
+ expect(installed).toEqual(
250
+ expect.arrayContaining([
251
+ "federation-grant-background",
252
+ "federation-grants",
253
+ "core-federation-grant-store-memory",
254
+ "core-federation-grant-intent-store-memory",
255
+ "subject-revocation-service",
256
+ ]),
257
+ );
258
+ expect(installed).not.toContain("redis-federation-grant-store");
259
+ expect(installed).not.toContain("redis-federation-grant-intent-store");
260
+ // Listed ahead of oauthModule, as the template writes them — the
261
+ // 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`.
264
+ expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
265
+
266
+ handleRef = await boot(config, true);
267
+ const app = express().use(handleRef.router);
268
+ // Mounted: the JSON router answers for itself (client authentication),
269
+ // not express's 404.
270
+ expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(401);
271
+ expect(handleRef.components.subjectRevocationService).toBeDefined();
272
+ });
273
+
274
+ it("selects the Redis stores under the all-Redis environment, provides their clients, and boots under multi", async () => {
275
+ const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON });
276
+ const modules = modulesFor(config);
277
+ const installed = modules.map((m) => m.name);
278
+ expect(installed).toEqual(
279
+ expect.arrayContaining([
280
+ "redis-federation-grant-store",
281
+ "redis-federation-grant-intent-store",
282
+ ]),
283
+ );
284
+ expect(installed).not.toContain("core-federation-grant-store-memory");
285
+ expect(installed).not.toContain("core-federation-grant-intent-store-memory");
286
+ const clients = modules.find((m) => m.name === "standalone:redis-clients");
287
+ expect(Object.keys(clients?.provides ?? {})).toEqual(
288
+ expect.arrayContaining(["federationGrantStoreClient", "federationGrantIntentStoreClient"]),
289
+ );
290
+
291
+ handleRef = await boot(config);
292
+ expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
293
+ });
294
+
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({});
302
+ });
303
+
304
+ it("adds the shared Redis client for the grant stores alone", () => {
305
+ // Every other adapter on memory and the RT family store overridden: only
306
+ // the two grant switches ask for Redis, and that is enough.
307
+ const config = resolveConfig({
308
+ ...BASE_ENV,
309
+ ...GRANTS_ON,
310
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
311
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
312
+ });
313
+ expect(names(config, true)).toContain("standalone:redis-clients");
314
+ });
315
+
316
+ it.each([
317
+ ["FEDERATION_GRANT_STORE_ADAPTER", "core-federation-grant-store-memory"],
318
+ ["FEDERATION_GRANT_INTENT_STORE_ADAPTER", "core-federation-grant-intent-store-memory"],
319
+ ])("%s=memory is refused under multi, naming %s", async (variable, module) => {
320
+ const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON, [variable]: "memory" });
321
+ await expect(boot(config)).rejects.toMatchObject({
322
+ name: "BootError",
323
+ reason: "replica-unsafe-adapter",
324
+ details: { modules: [module] },
325
+ });
326
+ });
327
+
328
+ it("refuses Redis grants beside memory user-session stores, naming the boundary (D13)", async () => {
329
+ // The grants would outlive the process; the boundary that ends them
330
+ // would not. The routes module refuses the pairing on a single replica
331
+ // too, and its message names the remedy.
332
+ const config = resolveConfig({
333
+ ...BASE_ENV,
334
+ ...GRANTS_ON,
335
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
336
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
337
+ });
338
+ let error: unknown;
339
+ try {
340
+ handleRef = await boot(config);
341
+ } catch (caught) {
342
+ error = caught;
343
+ }
344
+ expect(messageChain(error)).toMatch(/subject boundary is kept in "memory"/);
345
+ });
346
+
347
+ it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it (#473)", async () => {
348
+ // The federation-token store's guard reads `environment` beside
349
+ // NODE_ENV so that CONFIG_ENV=production is production to it; the grant
350
+ // store's must too, or a deployment selecting its config by CONFIG_ENV
351
+ // with NODE_ENV unset would keep its refresh tokens in plaintext.
352
+ const config = resolveConfig({
353
+ ...BASE_ENV,
354
+ ...GRANTS_ON,
355
+ USER_SESSION_STORES_ADAPTER: "redis",
356
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
357
+ FEDERATION_GRANTS_ENCRYPTION_MODE: "allow-plaintext",
358
+ });
359
+ let error: unknown;
360
+ try {
361
+ handleRef = await boot(config, false, "production");
362
+ } catch (caught) {
363
+ error = caught;
364
+ }
365
+ expect(messageChain(error)).toMatch(/federation-grants[\s\S]*allow-plaintext[\s\S]*production/);
366
+ // The same config under the test environment boots: it is the name that
367
+ // selected the config, not a hard refusal of plaintext.
368
+ handleRef = await boot(config);
369
+ expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
370
+ });
371
+
372
+ it("keeps Redis grants beside memory intents on a single replica", async () => {
373
+ // A restart loses flows in progress and no established grant.
374
+ const config = resolveConfig({
375
+ ...BASE_ENV,
376
+ ...GRANTS_ON,
377
+ USER_SESSION_STORES_ADAPTER: "redis",
378
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
379
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
380
+ });
381
+ handleRef = await boot(config);
382
+ expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
383
+ expect(names(config)).toContain("core-federation-grant-intent-store-memory");
384
+ });
385
+ });
386
+
387
+ describe("the browser consent route parses its own body, with sessionModule listed ahead of it", () => {
388
+ // `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.
393
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
394
+
395
+ afterEach(async () => {
396
+ await handleRef?.dispose();
397
+ handleRef = undefined;
398
+ });
399
+
400
+ /** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
401
+ const sessionFirst = (config: AppConfig) => {
402
+ const modules = modulesFor(config, true);
403
+ const session = modules.find((m) => m.name === "session");
404
+ if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
405
+ const rest = modules.filter((m) => m !== session);
406
+ const at = rest.findIndex((m) => m.name.startsWith("federation-grant"));
407
+ return [...rest.slice(0, at), session, ...rest.slice(at)];
408
+ };
409
+
410
+ const bootSessionFirst = async () => {
411
+ const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
412
+ handleRef = await createApp({
413
+ modules: sessionFirst(config),
414
+ bootstrapComponents: { config, pathResolver: (s) => s },
415
+ });
416
+ return express().use(handleRef.router);
417
+ };
418
+
419
+ const CONSENT = "/session/federation-grants/consent";
420
+
421
+ it("refuses a body over 16 KiB with the route's own 413", async () => {
422
+ const app = await bootSessionFirst();
423
+
424
+ const res = await request(app)
425
+ .post(CONSENT)
426
+ .set("Content-Type", "application/json")
427
+ .send(JSON.stringify({ challenge: "c", decision: "accept", pad: "a".repeat(40 * 1024) }));
428
+
429
+ expect(res.status).toBe(413);
430
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
431
+ });
432
+
433
+ it("refuses malformed JSON with the route's own 400", async () => {
434
+ const app = await bootSessionFirst();
435
+
436
+ const res = await request(app)
437
+ .post(CONSENT)
438
+ .set("Content-Type", "application/json")
439
+ .send("{not json");
440
+
441
+ expect(res.status).toBe(400);
442
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
443
+ expect(res.headers["cache-control"]).toContain("no-store");
444
+ });
445
+ });