@o3co/create-auth-provider 0.13.0 → 0.15.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 (27) hide show
  1. package/package.json +2 -2
  2. package/templates/standalone/.env.example +27 -0
  3. package/templates/standalone/Dockerfile +1 -1
  4. package/templates/standalone/README.ja.md +80 -2
  5. package/templates/standalone/README.md +160 -4
  6. package/templates/standalone/config/application.conf +56 -1
  7. package/templates/standalone/docker-compose.production.yml +7 -0
  8. package/templates/standalone/docker-compose.yml +7 -0
  9. package/templates/standalone/package.json +4 -3
  10. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
  11. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +62 -3
  12. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
  13. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
  14. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
  15. package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
  16. package/templates/standalone/src/__tests__/replica-safety.test.mts +58 -0
  17. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +37 -0
  18. package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
  19. package/templates/standalone/src/__tests__/smoke.test.mts +1 -0
  20. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
  21. package/templates/standalone/src/app.mts +9 -1
  22. package/templates/standalone/src/buildModules.mts +110 -6
  23. package/templates/standalone/src/modules.mts +96 -6
  24. package/templates/standalone/src/shutdown.mts +70 -4
  25. package/templates/standalone/vitest.config.mts +6 -0
  26. package/templates/standalone/vitest.supertest-loopback.mts +250 -0
  27. package/templates/versions.json +15 -14
@@ -0,0 +1,101 @@
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
+ * `oauth.accessToken.expiresIn` / OAUTH_ACCESS_TOKEN_EXPIRES_IN is a deprecated
19
+ * alias of `defaultExpiresIn`, and the composition says so once — the OR-9
20
+ * shape `repositories.code.type` already has.
21
+ *
22
+ * The line has to be exact in both directions. Core's `reference.conf` keeps
23
+ * the shipped lifetime on the deprecated key, so "the alias supplied the
24
+ * default" describes every deployment that set nothing; warning on that would
25
+ * train operators to ignore the line. Only an override of the old key is
26
+ * something to move.
27
+ */
28
+
29
+ import { fileURLToPath } from "node:url";
30
+ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
31
+ import { parseFile } from "@o3co/ts.hocon";
32
+ import { validate } from "@o3co/ts.hocon/zod";
33
+ import { afterEach, describe, expect, it, vi } from "vitest";
34
+ import { buildModules } from "../buildModules.mjs";
35
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
36
+
37
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
38
+
39
+ const REQUIRED_ENV = {
40
+ OAUTH_JWT_SECRET: "access-token-alias.at-least-32-bytes.ok",
41
+ OAUTH_JWT_ISSUER: "https://auth.test",
42
+ SESSION_SECRET: "access-token-alias-session.at-least-32-bytes.ok",
43
+ };
44
+
45
+ /** The shipped layers, resolved the way `app.mts` resolves them. */
46
+ function loadShipped(env: Record<string, string> = {}): AppConfig {
47
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
48
+ const resolvedEnv = { ...REQUIRED_ENV, ...env };
49
+ return validate(
50
+ parseFile(envConfPath, { env: resolvedEnv })
51
+ .withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
52
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: resolvedEnv })),
53
+ AppConfigSchema,
54
+ );
55
+ }
56
+
57
+ /** Every console warning `buildModules` emits about the deprecated key. */
58
+ function aliasWarnings(config: AppConfig): string[] {
59
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
60
+ buildModules(config);
61
+ return warn.mock.calls
62
+ .map((call) => String(call[0]))
63
+ .filter((message) => message.includes("oauth.accessToken.expiresIn"));
64
+ }
65
+
66
+ describe("the deprecated access-token lifetime alias", () => {
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ it("says nothing for a deployment that sets no lifetime at all", () => {
72
+ // Also what keeps the template's idea of "the shipped value" honest: it
73
+ // is measured against core's real `reference.conf`, so a change to the
74
+ // shipped literal fails here rather than warning every deployment.
75
+ expect(aliasWarnings(loadShipped())).toEqual([]);
76
+ });
77
+
78
+ it("says nothing for a deployment on the new variable", () => {
79
+ expect(aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900" }))).toEqual(
80
+ [],
81
+ );
82
+ });
83
+
84
+ it("warns once when OAUTH_ACCESS_TOKEN_EXPIRES_IN still decides the default", () => {
85
+ const warnings = aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }));
86
+ expect(warnings).toHaveLength(1);
87
+ expect(warnings[0]).toMatch(/deprecated/);
88
+ expect(warnings[0]).toMatch(/oauth\.accessToken\.defaultExpiresIn/);
89
+ expect(warnings[0]).toMatch(/OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/);
90
+ });
91
+
92
+ it("warns for a hand-built configuration that overrides only the old key", () => {
93
+ const config = loadShipped();
94
+ expect(
95
+ aliasWarnings({
96
+ ...config,
97
+ oauth: { ...config.oauth, accessToken: { expiresIn: 900 } },
98
+ }),
99
+ ).toHaveLength(1);
100
+ });
101
+ });
@@ -17,7 +17,11 @@
17
17
  import { existsSync, readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
- import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
20
+ import {
21
+ type AppConfig,
22
+ AppConfigSchema,
23
+ resolveAccessTokenLifetime,
24
+ } from "@o3co/auth-provider-core";
21
25
  import { parseFile } from "@o3co/ts.hocon";
22
26
  import { validate } from "@o3co/ts.hocon/zod";
23
27
  import { describe, expect, it } from "vitest";
@@ -83,6 +87,10 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
83
87
  OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
84
88
 
85
89
  // --- oauth tokens / policy ----------------------------------------
90
+ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
91
+ OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
92
+ // Deprecated alias of the default. Exported alongside the new variable on
93
+ // purpose, with a different value: the new variable must win.
86
94
  OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
87
95
  OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
88
96
  OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
@@ -151,7 +159,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
151
159
  ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
152
160
  // #484: the replay seen-set behind private_key_jwt client authentication.
153
161
  REPLAY_SEEN_SET_ADAPTER: "redis",
154
- CONSENT_STORE_ADAPTER: "none",
162
+ // #561: the consent stores' shared backend and its namespace.
163
+ CONSENT_STORE_ADAPTER: "redis",
164
+ REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
155
165
  REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
156
166
  REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
157
167
  REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
@@ -164,6 +174,20 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
164
174
  REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
165
175
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
166
176
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
177
+ // #593: federation grants — a user's standing consent that a client may
178
+ // obtain upstream tokens without them. The overrides `reference.conf`
179
+ // declares, and (slice 7) the two adapter switches the template composes
180
+ // the feature from.
181
+ FEDERATION_GRANTS_ENABLED: "true",
182
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
183
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
184
+ FEDERATION_GRANTS_ENCRYPTION_MODE: "required",
185
+ FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION: "false",
186
+ // Slice 6: acquisition's two deployment decisions — whether the callback
187
+ // refuses an upstream account linked to another user, and the consent page.
188
+ FEDERATION_GRANTS_IDENTITY_LOOKUP: "required",
189
+ FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
190
+ REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX: "fg:",
167
191
 
168
192
  // --- federation ---------------------------------------------------
169
193
  FEDERATIONS_GOOGLE_ENABLED: "true",
@@ -185,6 +209,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
185
209
  CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
186
210
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
187
211
  CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
212
+ // #613: the Store's identity lookup for federation grants (D7 check 5).
213
+ CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
214
+ "https://users.example.com/find-subject-by-federated-identity",
188
215
  CLIENT_USER_TIMEOUT: "5000",
189
216
  CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
190
217
  CLIENT_CODE_TYPE: "redis",
@@ -313,6 +340,10 @@ describe("#288: the shipped config boots with every documented override supplied
313
340
  expect(config.oauth.resourceIndicator?.enabled).toBe(true);
314
341
  expect(config.federations.google?.enabled).toBe(true);
315
342
  expect(config.federations.oidc?.enabled).toBe(true);
343
+ // #593: the newest one, and the one where a leftover string would be
344
+ // read as "on" by a truthiness check and as "off" by `=== true` — a
345
+ // feature whose whole default is off.
346
+ expect(config.federationGrants?.enabled).toBe(true);
316
347
  });
317
348
 
318
349
  it("turns every non-boolean override into its declared type", () => {
@@ -320,11 +351,19 @@ describe("#288: the shipped config boots with every documented override supplied
320
351
  expect(config.http.port).toBe(3000);
321
352
  expect(config.http.readinessTimeoutMs).toBe(1500);
322
353
  expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
323
- expect(config.oauth.accessToken.expiresIn).toBe(3600);
354
+ // The new default wins over the deprecated variable, and the parsed
355
+ // config mirrors it onto the old key for readers that predate the split.
356
+ expect(resolveAccessTokenLifetime(config)).toEqual({
357
+ defaultExpiresIn: 900,
358
+ maxExpiresIn: 7200,
359
+ });
360
+ expect(config.oauth.accessToken.expiresIn).toBe(900);
324
361
  expect(config.oauth.refreshToken.expiresIn).toBe(86400);
325
362
  expect(config.session.maxAge).toBe(3600000);
326
363
  expect(config.session.csrf?.ttlSeconds).toBe(7200);
327
364
  expect(config.oauth.nonce?.maxLength).toBe(256);
365
+ expect(config.consentStore?.adapter).toBe("redis");
366
+ expect(config.redisConsentStore?.keyPrefix).toBe("tenant-a:consent:");
328
367
  // #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
329
368
  expect(config.oauth.clientIdMetadataDocuments).toEqual({
330
369
  enabled: true,
@@ -508,6 +547,26 @@ describe("#288: the shipped config boots with every documented override supplied
508
547
  ).toThrow(/allowUnmarkedClients/);
509
548
  });
510
549
 
550
+ it("refuses an access-token default above the max, naming both keys", () => {
551
+ expect(() =>
552
+ buildResolvedConfig({
553
+ ...DOCUMENTED_ENV,
554
+ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
555
+ OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
556
+ }),
557
+ ).toThrow(/defaultExpiresIn.*maxExpiresIn/s);
558
+ });
559
+
560
+ for (const name of [
561
+ "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
562
+ "OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
563
+ "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
564
+ ]) {
565
+ it(`refuses an empty ${name} rather than minting already-expired tokens`, () => {
566
+ expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, [name]: "" })).toThrow();
567
+ });
568
+ }
569
+
511
570
  it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
512
571
  // Pinned alongside the boolean cases because it is the same trap
513
572
  // read from the other side: for a *number*, empty means fail loudly.
@@ -0,0 +1,384 @@
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
+ // The routes precede oauthModule's body parsers (the package README's
261
+ // mounting-order rule), and the browser half sits after the session
262
+ // middleware by its own `after`.
263
+ expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
264
+
265
+ handleRef = await boot(config, true);
266
+ const app = express().use(handleRef.router);
267
+ // Mounted: the JSON router answers for itself (client authentication),
268
+ // not express's 404.
269
+ expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(401);
270
+ expect(handleRef.components.subjectRevocationService).toBeDefined();
271
+ });
272
+
273
+ it("selects the Redis stores under the all-Redis environment, provides their clients, and boots under multi", async () => {
274
+ const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON });
275
+ const modules = modulesFor(config);
276
+ const installed = modules.map((m) => m.name);
277
+ expect(installed).toEqual(
278
+ expect.arrayContaining([
279
+ "redis-federation-grant-store",
280
+ "redis-federation-grant-intent-store",
281
+ ]),
282
+ );
283
+ expect(installed).not.toContain("core-federation-grant-store-memory");
284
+ expect(installed).not.toContain("core-federation-grant-intent-store-memory");
285
+ const clients = modules.find((m) => m.name === "standalone:redis-clients");
286
+ expect(Object.keys(clients?.provides ?? {})).toEqual(
287
+ expect.arrayContaining(["federationGrantStoreClient", "federationGrantIntentStoreClient"]),
288
+ );
289
+
290
+ handleRef = await boot(config);
291
+ expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
292
+ });
293
+
294
+ it("gives cleanup the documented 45 seconds under the shipped budgets, and nothing while off", () => {
295
+ // The allowance is derived from reference.conf's budgets; if those move,
296
+ // the number every README states moves with them, and this says so.
297
+ expect(cleanupAllowanceFor(resolveConfig({ ...BASE_ENV, ...GRANTS_ON }))).toEqual({
298
+ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
299
+ });
300
+ expect(cleanupAllowanceFor(resolveConfig(BASE_ENV))).toEqual({});
301
+ });
302
+
303
+ it("adds the shared Redis client for the grant stores alone", () => {
304
+ // Every other adapter on memory and the RT family store overridden: only
305
+ // the two grant switches ask for Redis, and that is enough.
306
+ const config = resolveConfig({
307
+ ...BASE_ENV,
308
+ ...GRANTS_ON,
309
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
310
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
311
+ });
312
+ expect(names(config, true)).toContain("standalone:redis-clients");
313
+ });
314
+
315
+ it.each([
316
+ ["FEDERATION_GRANT_STORE_ADAPTER", "core-federation-grant-store-memory"],
317
+ ["FEDERATION_GRANT_INTENT_STORE_ADAPTER", "core-federation-grant-intent-store-memory"],
318
+ ])("%s=memory is refused under multi, naming %s", async (variable, module) => {
319
+ const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON, [variable]: "memory" });
320
+ await expect(boot(config)).rejects.toMatchObject({
321
+ name: "BootError",
322
+ reason: "replica-unsafe-adapter",
323
+ details: { modules: [module] },
324
+ });
325
+ });
326
+
327
+ it("refuses Redis grants beside memory user-session stores, naming the boundary (D13)", async () => {
328
+ // The grants would outlive the process; the boundary that ends them
329
+ // would not. The routes module refuses the pairing on a single replica
330
+ // too, and its message names the remedy.
331
+ const config = resolveConfig({
332
+ ...BASE_ENV,
333
+ ...GRANTS_ON,
334
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
335
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
336
+ });
337
+ let error: unknown;
338
+ try {
339
+ handleRef = await boot(config);
340
+ } catch (caught) {
341
+ error = caught;
342
+ }
343
+ expect(messageChain(error)).toMatch(/subject boundary is kept in "memory"/);
344
+ });
345
+
346
+ it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it (#473)", async () => {
347
+ // The federation-token store's guard reads `environment` beside
348
+ // NODE_ENV so that CONFIG_ENV=production is production to it; the grant
349
+ // store's must too, or a deployment selecting its config by CONFIG_ENV
350
+ // with NODE_ENV unset would keep its refresh tokens in plaintext.
351
+ const config = resolveConfig({
352
+ ...BASE_ENV,
353
+ ...GRANTS_ON,
354
+ USER_SESSION_STORES_ADAPTER: "redis",
355
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
356
+ FEDERATION_GRANTS_ENCRYPTION_MODE: "allow-plaintext",
357
+ });
358
+ let error: unknown;
359
+ try {
360
+ handleRef = await boot(config, false, "production");
361
+ } catch (caught) {
362
+ error = caught;
363
+ }
364
+ expect(messageChain(error)).toMatch(/federation-grants[\s\S]*allow-plaintext[\s\S]*production/);
365
+ // The same config under the test environment boots: it is the name that
366
+ // selected the config, not a hard refusal of plaintext.
367
+ handleRef = await boot(config);
368
+ expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
369
+ });
370
+
371
+ it("keeps Redis grants beside memory intents on a single replica", async () => {
372
+ // A restart loses flows in progress and no established grant.
373
+ const config = resolveConfig({
374
+ ...BASE_ENV,
375
+ ...GRANTS_ON,
376
+ USER_SESSION_STORES_ADAPTER: "redis",
377
+ FEDERATION_GRANT_STORE_ADAPTER: "redis",
378
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
379
+ });
380
+ handleRef = await boot(config);
381
+ expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
382
+ expect(names(config)).toContain("core-federation-grant-intent-store-memory");
383
+ });
384
+ });