@o3co/create-auth-provider 0.14.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.
@@ -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
+ });