@o3co/create-auth-provider 0.17.0 → 0.18.0-rc.1

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 (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,787 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * The 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. It also pins what the consent answer accepts under the
26
- * session module's own CSRF guard, the one the standalone composes.
27
- */
28
-
29
- import { fileURLToPath } from "node:url";
30
- import {
31
- createApp,
32
- createKeyStoreFactory,
33
- defineModule,
34
- InMemoryClientRepository,
35
- InMemoryUserRepository,
36
- memoryRefreshTokenFamilyStoreModule,
37
- registerBuiltinKeyStores,
38
- } from "@o3co/auth-provider-core";
39
- import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
40
- import { parseFile } from "@o3co/ts.hocon";
41
- import express from "express";
42
- import request from "supertest";
43
- import { afterEach, describe, expect, it, vi } from "vitest";
44
- import { buildModules } from "../buildModules.mjs";
45
- import { resolveConfigPaths } from "../configPath.mjs";
46
- import { templateReference } from "../modules.mjs";
47
- import { keyStoreSectionSchema } from "../sections.mjs";
48
- import { installGracefulShutdown } from "../shutdown.mjs";
49
- import {
50
- type BothPhases,
51
- bothPhasesOf,
52
- capturedRenames,
53
- libraryLayers,
54
- } from "./library-references.fixture.mjs";
55
-
56
- // The same stand-ins `replica-safety.test.mts` boots under: no socket opens,
57
- // and the shared clients module's readiness probe gets its PONG.
58
- vi.mock("redis", () => ({
59
- createClient: vi.fn(() => ({
60
- connect: vi.fn().mockResolvedValue(undefined),
61
- quit: vi.fn().mockResolvedValue(undefined),
62
- ping: vi.fn().mockResolvedValue("PONG"),
63
- on: vi.fn(),
64
- })),
65
- }));
66
-
67
- vi.mock("connect-redis", async () => {
68
- const { EventEmitter } = await import("node:events");
69
- return {
70
- RedisStore: class MockRedisStore extends EventEmitter {
71
- constructor(_opts: { client: unknown }) {
72
- super();
73
- }
74
- get(): unknown {
75
- return undefined;
76
- }
77
- set(): void {}
78
- destroy(): void {}
79
- },
80
- };
81
- });
82
-
83
- vi.mock("ioredis", () => {
84
- const explicit: Record<string, unknown> = {
85
- on: () => undefined,
86
- quit: async () => "OK",
87
- disconnect: () => undefined,
88
- ping: async () => "PONG",
89
- // The Redis stores' eviction gate reads the policy from INFO memory: a
90
- // default server's.
91
- info: async (section: string) =>
92
- section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
93
- };
94
- const makeMockRedis = (): object =>
95
- new Proxy(
96
- {},
97
- {
98
- get(_target, prop) {
99
- if (typeof prop !== "string" || prop === "then") return undefined;
100
- if (prop === "duplicate") return makeMockRedis;
101
- if (prop in explicit) return explicit[prop];
102
- return async () => null;
103
- },
104
- },
105
- );
106
- function MockRedis(): object {
107
- return makeMockRedis();
108
- }
109
- return { Redis: MockRedis, default: MockRedis };
110
- });
111
-
112
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
113
- const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
114
-
115
- /** A single-replica deployment with every shared store on memory, grants off, MFA off. */
116
- const BASE_ENV: Readonly<Record<string, string>> = {
117
- MFA_MODE: "off",
118
- KEY_STORE_LOCAL_ALGORITHM: "HS256",
119
- KEY_STORE_LOCAL_SECRET: "federation-grants-composition.at-least-32-bytes.ok",
120
- OAUTH_JWT_ISSUER: "https://auth.test",
121
- SESSION_STORE_SECRET: "federation-grants-composition-session.at-least-32-bytes.ok",
122
- SESSION_STORE_SECURE: "false",
123
- SESSION_STORE_NAME: "auth.session",
124
- SESSION_STORE_STORAGE_TYPE: "memory",
125
- ADAPTERS_USER_REPOSITORY: "yaml",
126
- REDIS_CLIENTS_URL: "redis://redis.test:6379",
127
- ADAPTERS_USER_SESSION_STORES: "memory",
128
- ADAPTERS_RATE_LIMITER: "memory",
129
- ADAPTERS_CODE_REPOSITORY: "memory",
130
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
131
- ADAPTERS_REPLAY_SEEN_SET: "memory",
132
- ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
133
- ADAPTERS_CONSENT_STORE: "none",
134
- ADAPTERS_FEDERATION_GRANT_STORE: "memory",
135
- ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
136
- };
137
-
138
- /** The umbrella E2E's shape: every shared store on Redis, more than one replica. */
139
- const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
140
- ...BASE_ENV,
141
- CORE_DEPLOYMENT_MODE: "multi",
142
- SESSION_STORE_STORAGE_TYPE: "redis",
143
- SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
144
- ADAPTERS_USER_SESSION_STORES: "redis",
145
- ADAPTERS_RATE_LIMITER: "redis",
146
- ADAPTERS_ATTEMPT_COUNTER: "redis",
147
- ADAPTERS_CODE_REPOSITORY: "redis",
148
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
149
- ADAPTERS_REPLAY_SEEN_SET: "redis",
150
- ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
151
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
152
- ADAPTERS_CONSENT_STORE: "redis",
153
- ADAPTERS_FEDERATION_GRANT_STORE: "redis",
154
- ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
155
- };
156
-
157
- /** What enabling the feature adds: the switch, and the page boot requires. */
158
- const GRANTS_ON: Readonly<Record<string, string>> = {
159
- FEDERATION_GRANTS_ENABLED: "true",
160
- FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
161
- };
162
-
163
- function resolveConfig(env: Record<string, string>): BothPhases {
164
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
165
- const layers = parseFile(envConfPath, { env })
166
- .withFallback(parseFile(applicationConfPath, { env }))
167
- .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
168
- .withFallback(libraryLayers(env));
169
- const config = bothPhasesOf(layers, env);
170
- // The key ring has no environment form (a list of { id, key } is HOCON's);
171
- // the Redis grant store refuses to construct without one under "required".
172
- return {
173
- ...config,
174
- // What the resolution captured of the renamed variables.
175
- "renamed-variables": capturedRenames(env),
176
- "redis-federation-grant-store": {
177
- ...(config["redis-federation-grant-store"] as object | undefined),
178
- encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
179
- },
180
- } as BothPhases;
181
- }
182
-
183
- const testRepositoriesModule = defineModule({
184
- name: "test:repositories",
185
- provides: {
186
- clientRepository: () => new InMemoryClientRepository(new Map()),
187
- userRepository: () => new InMemoryUserRepository(new Map()),
188
- },
189
- });
190
-
191
- // The template's `key-store` section, read by its own schema, without the
192
- // section's relocation bridge.
193
- const testKeyStoreModule = defineModule({
194
- name: "key-store",
195
- section: { schema: keyStoreSectionSchema },
196
- provides: {
197
- keyStore: async ({ section }) => {
198
- const factory = createKeyStoreFactory();
199
- registerBuiltinKeyStores(factory);
200
- return factory.create({
201
- type: "local",
202
- ...(section.local ?? {}),
203
- });
204
- },
205
- },
206
- });
207
-
208
- const modulesFor = (config: BothPhases, memoryOnly = false, environment?: string) =>
209
- buildModules(config, {
210
- keyStoreModule: testKeyStoreModule,
211
- repositoriesModule: testRepositoriesModule,
212
- ...(memoryOnly ? { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] } : {}),
213
- ...(environment === undefined ? {} : { environment }),
214
- });
215
-
216
- const boot = (config: BothPhases, memoryOnly = false, environment?: string) =>
217
- createApp({
218
- modules: modulesFor(config, memoryOnly, environment),
219
- bootstrapComponents: { config, pathResolver: (s) => s },
220
- });
221
-
222
- const names = (config: BothPhases, memoryOnly = false) =>
223
- modulesFor(config, memoryOnly).map((m) => m.name);
224
-
225
- /**
226
- * `app.mts`'s shutdown over `handle`, with a server double whose drain ends at
227
- * once and signals that are not the process's: the handle's own dispose and
228
- * the allowance it reports.
229
- */
230
- function shutDown(handle: Awaited<ReturnType<typeof boot>>) {
231
- const logger = {
232
- trace: vi.fn(),
233
- debug: vi.fn(),
234
- info: vi.fn(),
235
- warn: vi.fn(),
236
- error: vi.fn(),
237
- fatal: vi.fn(),
238
- child: vi.fn(),
239
- };
240
- const exit = vi.fn();
241
- const signals = new Map<string, () => void>();
242
- const server = {
243
- close: (callback?: (err?: Error) => void) => {
244
- callback?.();
245
- return server;
246
- },
247
- closeIdleConnections: () => undefined,
248
- closeAllConnections: () => undefined,
249
- };
250
- installGracefulShutdown(server as never, {
251
- logger: logger as never,
252
- cleanup: () => handle.dispose(),
253
- cleanupAllowanceMs: () => handle.cleanupAllowanceMs,
254
- exit,
255
- onSignal: (name, handler) => signals.set(name, handler),
256
- offSignal: (name) => signals.delete(name),
257
- });
258
- return { logger, exit, sigterm: () => signals.get("SIGTERM")?.() };
259
- }
260
-
261
- /**
262
- * Work the background registry holds open, as a refresh tail does, until the
263
- * returned release: the drain waits for it.
264
- */
265
- const holdTheDrainOpen = (handle: Awaited<ReturnType<typeof boot>>): (() => void) => {
266
- let release!: () => void;
267
- handle.components.federationGrantBackground?.register(
268
- new Promise<void>((resolve) => {
269
- release = () => resolve();
270
- }),
271
- );
272
- return release;
273
- };
274
-
275
- const GRANT_MODULES = [
276
- "federation-grants",
277
- "federation-grant-background",
278
- "core-federation-grant-store-memory",
279
- "core-federation-grant-intent-store-memory",
280
- "redis-federation-grant-store",
281
- "redis-federation-grant-intent-store",
282
- "subject-revocation-service",
283
- ];
284
-
285
- /** The message of a boot failure and of the module error it wraps, together. */
286
- const messageChain = (err: unknown): string => {
287
- const e = err as { message?: string; cause?: { message?: string } };
288
- return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
289
- };
290
-
291
- describe("the standalone composes federation grants from its config", () => {
292
- let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
293
-
294
- afterEach(async () => {
295
- await handleRef?.dispose();
296
- handleRef = undefined;
297
- });
298
-
299
- it("installs nothing of the feature while it is off, whatever the switches say", async () => {
300
- // Off is the default, and off must cost nothing: no store, no socket, no
301
- // boot requirement a deployment that never asked for grants would meet.
302
- const config = resolveConfig({ ...BASE_ENV, ADAPTERS_FEDERATION_GRANT_STORE: "redis" });
303
- const installed = names(config, true);
304
- for (const name of GRANT_MODULES) expect(installed).not.toContain(name);
305
- expect(installed).not.toContain("redis-clients");
306
-
307
- handleRef = await boot(config, true);
308
- const app = express().use(handleRef.router);
309
- expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(404);
310
- });
311
-
312
- it("boots on memory stores with no connection, and mounts the routes", async () => {
313
- const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
314
- const installed = names(config, true);
315
- expect(installed).toEqual(
316
- expect.arrayContaining([
317
- "federation-grant-background",
318
- "federation-grants",
319
- "core-federation-grant-store-memory",
320
- "core-federation-grant-intent-store-memory",
321
- "subject-revocation-service",
322
- ]),
323
- );
324
- expect(installed).not.toContain("redis-federation-grant-store");
325
- expect(installed).not.toContain("redis-federation-grant-intent-store");
326
- // Listed ahead of oauthEndpointsModule, as the template writes them: the
327
- // template's choice, since each module parses its own bodies and the
328
- // order does not change that; the browser half sits after the session
329
- // middleware by its own `after`.
330
- expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
331
-
332
- handleRef = await boot(config, true);
333
- const app = express().use(handleRef.router);
334
- // Mounted: the JSON router answers for itself (client authentication),
335
- // not express's 404.
336
- expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(401);
337
- expect(handleRef.components.subjectRevocationService).toBeDefined();
338
- // The connect flow's login trip is the session module's loginEntry,
339
- // built from the configured login page: the URL connect sends
340
- // a browser that is not signed in to.
341
- const page = (config.session as { loginPage: { url: string } }).loginPage.url;
342
- const back = "https://auth.example/session/federation-grants/connect?request=h";
343
- expect(handleRef.components.loginEntry?.urlFor(back)).toBe(
344
- `${page}${page.includes("?") ? "&" : "?"}redirect_to=${encodeURIComponent(back)}`,
345
- );
346
- });
347
-
348
- it("selects the Redis stores under the all-Redis environment, provides their clients, and boots under multi", async () => {
349
- const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON });
350
- const modules = modulesFor(config);
351
- const installed = modules.map((m) => m.name);
352
- expect(installed).toEqual(
353
- expect.arrayContaining([
354
- "redis-federation-grant-store",
355
- "redis-federation-grant-intent-store",
356
- ]),
357
- );
358
- expect(installed).not.toContain("core-federation-grant-store-memory");
359
- expect(installed).not.toContain("core-federation-grant-intent-store-memory");
360
- const clients = modules.find((m) => m.name === "redis-clients");
361
- expect(Object.keys(clients?.provides ?? {})).toEqual(
362
- expect.arrayContaining(["federationGrantStoreClient", "federationGrantIntentStoreClient"]),
363
- );
364
-
365
- handleRef = await boot(config);
366
- expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
367
- });
368
-
369
- it("gives cleanup the documented 45 seconds under the shipped budgets, not the drain's ten", async () => {
370
- // reference.conf's budgets: 25 s + 3 s + 5 s, plus the 12-second margin.
371
- handleRef = await boot(resolveConfig({ ...BASE_ENV, ...GRANTS_ON }), true);
372
- expect(handleRef.cleanupAllowanceMs).toBe(45_000);
373
-
374
- vi.useFakeTimers();
375
- const release = holdTheDrainOpen(handleRef);
376
- try {
377
- const { logger, exit, sigterm } = shutDown(handleRef);
378
- sigterm();
379
- await vi.advanceTimersByTimeAsync(44_999);
380
- expect(exit).not.toHaveBeenCalled();
381
- await vi.advanceTimersByTimeAsync(1);
382
- expect(logger.error).toHaveBeenCalledWith(
383
- { cleanupTimeoutMs: 45_000 },
384
- "shutdown_cleanup_timed_out",
385
- );
386
- expect(exit).toHaveBeenCalledExactlyOnceWith(1);
387
- } finally {
388
- release();
389
- vi.useRealTimers();
390
- }
391
- });
392
-
393
- it("grows the allowance with a raised refresh budget", async () => {
394
- const shipped = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
395
- // The lock must outlive the raised hard timeout, or boot refuses first.
396
- const raised = {
397
- ...shipped,
398
- "federation-grants": {
399
- ...shipped["federation-grants"],
400
- upstreamHardTimeoutMs: 60_000,
401
- refreshLockTtlMs: 65_000,
402
- },
403
- } as BothPhases;
404
- handleRef = await boot(raised, true);
405
- expect(handleRef.cleanupAllowanceMs).toBe(60_000 + 3_000 + 5_000 + 12_000);
406
-
407
- vi.useFakeTimers();
408
- const release = holdTheDrainOpen(handleRef);
409
- try {
410
- const { logger, exit, sigterm } = shutDown(handleRef);
411
- sigterm();
412
- await vi.advanceTimersByTimeAsync(79_999);
413
- expect(exit).not.toHaveBeenCalled();
414
- await vi.advanceTimersByTimeAsync(1);
415
- expect(logger.error).toHaveBeenCalledWith(
416
- { cleanupTimeoutMs: 80_000 },
417
- "shutdown_cleanup_timed_out",
418
- );
419
- } finally {
420
- release();
421
- vi.useRealTimers();
422
- }
423
- });
424
-
425
- it("reports no allowance while the feature is off, and cleanup's budget is the drain's ten seconds", async () => {
426
- handleRef = await boot(resolveConfig(BASE_ENV), true);
427
- expect(handleRef.cleanupAllowanceMs).toBeUndefined();
428
- const { logger, exit, sigterm } = shutDown(handleRef);
429
- sigterm();
430
- await vi.waitFor(() => expect(exit).toHaveBeenCalledExactlyOnceWith(0));
431
- expect(logger.info).toHaveBeenCalledWith(
432
- { drainTimeoutMs: 10_000, cleanupTimeoutMs: 10_000 },
433
- "shutdown_draining",
434
- );
435
- });
436
-
437
- it("covers a tail registered after the shutdown was installed", async () => {
438
- handleRef = await boot(resolveConfig(BASE_ENV), true);
439
- const { logger, sigterm } = shutDown(handleRef);
440
- handleRef.components.lifecycleRegistrar?.register(async () => {}, { tailMs: 60_000 });
441
- sigterm();
442
- expect(logger.info).toHaveBeenCalledWith(
443
- { drainTimeoutMs: 10_000, cleanupTimeoutMs: 60_000 },
444
- "shutdown_draining",
445
- );
446
- });
447
-
448
- it("adds the shared Redis client for the grant stores alone", () => {
449
- // Every other adapter on memory and the RT family store overridden: only
450
- // the two grant switches ask for Redis, and that is enough.
451
- const config = resolveConfig({
452
- ...BASE_ENV,
453
- ...GRANTS_ON,
454
- ADAPTERS_FEDERATION_GRANT_STORE: "redis",
455
- ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
456
- });
457
- expect(names(config, true)).toContain("redis-clients");
458
- });
459
-
460
- it.each([
461
- ["ADAPTERS_FEDERATION_GRANT_STORE", "core-federation-grant-store-memory"],
462
- ["ADAPTERS_FEDERATION_GRANT_INTENT_STORE", "core-federation-grant-intent-store-memory"],
463
- ])("%s=memory is refused under multi, naming %s", async (variable, module) => {
464
- const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON, [variable]: "memory" });
465
- await expect(boot(config)).rejects.toMatchObject({
466
- name: "BootError",
467
- reason: "replica-unsafe-adapter",
468
- details: { modules: [module] },
469
- });
470
- });
471
-
472
- it.each([
473
- [
474
- "a __Secure- name that is not secure",
475
- "name",
476
- { SESSION_STORE_NAME: "__Secure-auth.session", SESSION_STORE_SECURE: "false" },
477
- /__Secure- prefix requires session-store\.secure=true/,
478
- ],
479
- [
480
- "a name that is not an RFC 6265 token",
481
- "name",
482
- { SESSION_STORE_NAME: "auth session" },
483
- /is not a cookie name \(an RFC 6265 token\)/,
484
- ],
485
- [
486
- "a domain that is a URL",
487
- "domain",
488
- { SESSION_STORE_NAME: "auth.session", SESSION_STORE_DOMAIN: "https://auth.example.com" },
489
- /is not a cookie domain/,
490
- ],
491
- ])(
492
- "refuses at validation a session cookie no browser keeps, %s, naming session-store.%s, with or without the subject revocation service",
493
- async (_what, key, cookie, refusal) => {
494
- for (const grants of [{}, GRANTS_ON]) {
495
- const caught = await boot(resolveConfig({ ...BASE_ENV, ...grants, ...cookie }), true).then(
496
- async (handle) => {
497
- await handle.dispose();
498
- return undefined;
499
- },
500
- (err: unknown) => err,
501
- );
502
- expect(caught).toMatchObject({
503
- name: "BootError",
504
- reason: "config-validation-failed",
505
- stage: "validateManifests",
506
- details: {
507
- issues: [expect.objectContaining({ path: ["session-store", key] })],
508
- },
509
- });
510
- expect(messageChain(caught)).toMatch(refusal);
511
- }
512
- },
513
- );
514
-
515
- it.each([
516
- // `config/application.conf`'s own cookie: `__Host-`, secure, host-only.
517
- ["the template's default", { SESSION_STORE_NAME: undefined, SESSION_STORE_SECURE: undefined }],
518
- // `.env.example`'s plain-HTTP pair, which `make dev` runs with.
519
- [".env.example's", { SESSION_STORE_NAME: "auth.sid", SESSION_STORE_SECURE: "false" }],
520
- // `docker-compose.production.yml` restores the default name, secure.
521
- [
522
- "the production compose file's",
523
- { SESSION_STORE_NAME: "__Host-auth.session", SESSION_STORE_SECURE: "true" },
524
- ],
525
- // o3co/auth's `tests/docker-compose.yml`, which the umbrella E2E boots.
526
- ["the umbrella E2E's", { SESSION_STORE_NAME: "auth.session", SESSION_STORE_SECURE: "false" }],
527
- ])(
528
- "boots %s session cookie, with or without the subject revocation service",
529
- async (_what, cookie) => {
530
- const env: Record<string, string> = { ...BASE_ENV };
531
- for (const [name, value] of Object.entries(cookie)) {
532
- if (value === undefined) delete env[name];
533
- else env[name] = value;
534
- }
535
- for (const grants of [{}, GRANTS_ON]) {
536
- handleRef = await boot(resolveConfig({ ...env, ...grants }), true);
537
- expect(handleRef.routes.map((r) => r.contribution.id)).toContain("session-middleware");
538
- await handleRef.dispose();
539
- handleRef = undefined;
540
- }
541
- },
542
- );
543
-
544
- it("refuses Redis grants beside memory user-session stores, naming the boundary", async () => {
545
- // The grants would outlive the process; the boundary that ends them
546
- // would not. The routes module refuses the pairing on a single replica
547
- // too, and its message names the remedy.
548
- const config = resolveConfig({
549
- ...BASE_ENV,
550
- ...GRANTS_ON,
551
- ADAPTERS_FEDERATION_GRANT_STORE: "redis",
552
- ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
553
- });
554
- let error: unknown;
555
- try {
556
- handleRef = await boot(config);
557
- } catch (caught) {
558
- error = caught;
559
- }
560
- expect(messageChain(error)).toMatch(/subject boundary is kept in "memory"/);
561
- });
562
-
563
- it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it", async () => {
564
- // The federation-token store's guard reads `environment` beside
565
- // NODE_ENV so that CONFIG_ENV=production is production to it; the grant
566
- // store's must too, or a deployment selecting its config by CONFIG_ENV
567
- // with NODE_ENV unset would keep its refresh tokens in plaintext.
568
- const config = resolveConfig({
569
- ...BASE_ENV,
570
- ...GRANTS_ON,
571
- ADAPTERS_USER_SESSION_STORES: "redis",
572
- ADAPTERS_FEDERATION_GRANT_STORE: "redis",
573
- REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE: "allow-plaintext",
574
- });
575
- let error: unknown;
576
- try {
577
- // Under production the template installs the SMTP sender's module,
578
- // whose section the package's builder carries.
579
- handleRef = await boot(
580
- { ...config, ...standardSmtpMailSenderConfigForTests() },
581
- false,
582
- "production",
583
- );
584
- } catch (caught) {
585
- error = caught;
586
- }
587
- expect(messageChain(error)).toMatch(/federation-grants[\s\S]*allow-plaintext[\s\S]*production/);
588
- // The same config under the test environment boots: it is the name that
589
- // selected the config, not a hard refusal of plaintext.
590
- handleRef = await boot(config);
591
- expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
592
- });
593
-
594
- it("keeps Redis grants beside memory intents on a single replica", async () => {
595
- // A restart loses flows in progress and no established grant.
596
- const config = resolveConfig({
597
- ...BASE_ENV,
598
- ...GRANTS_ON,
599
- ADAPTERS_USER_SESSION_STORES: "redis",
600
- ADAPTERS_FEDERATION_GRANT_STORE: "redis",
601
- ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
602
- });
603
- handleRef = await boot(config);
604
- expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
605
- expect(names(config)).toContain("core-federation-grant-intent-store-memory");
606
- });
607
- });
608
-
609
- describe("the browser consent route parses its own body, with sessionModule listed ahead of it", () => {
610
- // `sessionModule`'s routers are mounted at `/session`, the prefix the
611
- // federation grants browser half mounts under too. If their parsers ran
612
- // for every request beneath `/session`, then with `sessionModule` listed
613
- // first the consent route's body would arrive parsed, past its 16 KiB
614
- // bound, its throttle-then-parse order and its JSON refusals.
615
- let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
616
-
617
- afterEach(async () => {
618
- await handleRef?.dispose();
619
- handleRef = undefined;
620
- });
621
-
622
- /** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
623
- const sessionFirst = (config: BothPhases) => {
624
- const modules = modulesFor(config, true);
625
- const session = modules.find((m) => m.name === "session");
626
- if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
627
- const rest = modules.filter((m) => m !== session);
628
- const at = rest.findIndex((m) => m.name.startsWith("federation-grant"));
629
- return [...rest.slice(0, at), session, ...rest.slice(at)];
630
- };
631
-
632
- const bootSessionFirst = async () => {
633
- const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
634
- handleRef = await createApp({
635
- modules: sessionFirst(config),
636
- bootstrapComponents: { config, pathResolver: (s) => s },
637
- });
638
- return express().use(handleRef.router);
639
- };
640
-
641
- const CONSENT = "/session/federation-grants/consent";
642
-
643
- it("refuses a body over 16 KiB with the route's own 413", async () => {
644
- const app = await bootSessionFirst();
645
-
646
- const res = await request(app)
647
- .post(CONSENT)
648
- .set("Content-Type", "application/json")
649
- .send(JSON.stringify({ challenge: "c", decision: "accept", pad: "a".repeat(40 * 1024) }));
650
-
651
- expect(res.status).toBe(413);
652
- expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
653
- });
654
-
655
- it("refuses malformed JSON with the route's own 400", async () => {
656
- const app = await bootSessionFirst();
657
-
658
- const res = await request(app)
659
- .post(CONSENT)
660
- .set("Content-Type", "application/json")
661
- .send("{not json");
662
-
663
- expect(res.status).toBe(400);
664
- expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
665
- expect(res.headers["cache-control"]).toContain("no-store");
666
- });
667
- });
668
-
669
- describe("the grants consent answer is held to the session module's CSRF guard", () => {
670
- // No browser is signed in here: an answer the guard accepts reaches the
671
- // route's own `401 login_required`, and one it refuses stops at its `403`.
672
- const CONSENT = "/session/federation-grants/consent";
673
- const ISSUER_ORIGIN = "https://auth.test";
674
- const SIBLING = "https://account.auth.test";
675
- /** What the deployment's proxy forwards: the origin the browser addressed. */
676
- const FORWARDED = { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "auth.test" };
677
- const CROSS_SITE = { error: "invalid_request", error_description: "cross-site answer refused" };
678
- const NO_ORIGIN = {
679
- error: "invalid_request",
680
- error_description: "no origin and no valid csrf token",
681
- };
682
-
683
- let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
684
-
685
- afterEach(async () => {
686
- await handleRef?.dispose();
687
- handleRef = undefined;
688
- });
689
-
690
- /** The standalone with grants on and `SIBLING` on `session.csrf.trustedOrigins`, behind its proxy. */
691
- const bootTrustingSibling = async () => {
692
- const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
693
- const session = config.session as { csrf?: object };
694
- const trusting = {
695
- ...config,
696
- session: { ...session, csrf: { ...session.csrf, trustedOrigins: [SIBLING] } },
697
- } as BothPhases;
698
- handleRef = await boot(trusting, true);
699
- return express().set("trust proxy", "loopback").use(handleRef.router);
700
- };
701
-
702
- /** The token `GET /session/csrf` hands out, and the `Cookie` header that carries it back. */
703
- const tokenFrom = async (app: express.Express) => {
704
- const issued = await request(app).get("/session/csrf").set(FORWARDED);
705
- expect(issued.status).toBe(200);
706
- const [cookie] = (issued.headers["set-cookie"] as unknown as string[])[0]?.split(";") ?? [];
707
- return { token: issued.body.csrf_token as string, cookie: cookie ?? "" };
708
- };
709
-
710
- const answer = (
711
- app: express.Express,
712
- headers: Readonly<Record<string, string>>,
713
- body: Readonly<Record<string, string>> = {},
714
- ) =>
715
- request(app)
716
- .post(CONSENT)
717
- .set(FORWARDED)
718
- .set({ ...headers })
719
- .type("form")
720
- .send({ challenge: "c", decision: "accept", ...body });
721
-
722
- it("accepts an Origin that is the request's own, as the proxy forwards it", async () => {
723
- const app = await bootTrustingSibling();
724
- const res = await answer(app, { Origin: ISSUER_ORIGIN, "Sec-Fetch-Site": "same-origin" });
725
- expect(res.status).toBe(401);
726
- expect(res.body.error).toBe("login_required");
727
- });
728
-
729
- it("refuses Origin: null, even with the token GET /session/csrf hands out", async () => {
730
- const app = await bootTrustingSibling();
731
- const { token, cookie } = await tokenFrom(app);
732
- const res = await answer(
733
- app,
734
- { Origin: "null", "Sec-Fetch-Site": "same-origin", Cookie: cookie },
735
- { csrf_token: token },
736
- );
737
- expect(res.status).toBe(403);
738
- expect(res.body).toEqual(CROSS_SITE);
739
- });
740
-
741
- it("refuses an answer with no Origin, no Referer and no token, and accepts one echoing the token GET /session/csrf hands out", async () => {
742
- const app = await bootTrustingSibling();
743
- const refused = await answer(app, { "Sec-Fetch-Site": "same-origin" });
744
- expect(refused.status).toBe(403);
745
- expect(refused.body).toEqual(NO_ORIGIN);
746
- const { token, cookie } = await tokenFrom(app);
747
- const accepted = await answer(app, { Cookie: cookie }, { csrf_token: token });
748
- expect(accepted.status).toBe(401);
749
- expect(accepted.body.error).toBe("login_required");
750
- });
751
-
752
- it("accepts an answer with no Origin that echoes the token GET /session/csrf hands out in the x-csrf-token header", async () => {
753
- const app = await bootTrustingSibling();
754
- const { token, cookie } = await tokenFrom(app);
755
- const res = await answer(app, { Cookie: cookie, "x-csrf-token": token });
756
- expect(res.status).toBe(401);
757
- expect(res.body.error).toBe("login_required");
758
- });
759
-
760
- it("refuses an answer with no Origin whose token is not its cookie's", async () => {
761
- const app = await bootTrustingSibling();
762
- const first = await tokenFrom(app);
763
- const second = await tokenFrom(app);
764
- const res = await answer(app, { Cookie: first.cookie }, { csrf_token: second.token });
765
- expect(res.status).toBe(403);
766
- expect(res.body).toEqual(NO_ORIGIN);
767
- });
768
-
769
- it("accepts an origin on session.csrf.trustedOrigins sending Sec-Fetch-Site: same-site", async () => {
770
- const app = await bootTrustingSibling();
771
- const res = await answer(app, { Origin: SIBLING, "Sec-Fetch-Site": "same-site" });
772
- expect(res.status).toBe(401);
773
- expect(res.body.error).toBe("login_required");
774
- });
775
-
776
- it("refuses a cross-site answer, even with the token", async () => {
777
- const app = await bootTrustingSibling();
778
- const { token, cookie } = await tokenFrom(app);
779
- const res = await answer(
780
- app,
781
- { Origin: "https://evil.test", "Sec-Fetch-Site": "cross-site", Cookie: cookie },
782
- { csrf_token: token },
783
- );
784
- expect(res.status).toBe(403);
785
- expect(res.body).toEqual(CROSS_SITE);
786
- });
787
- });