@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.
- package/README.ja.md +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +34 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +565 -130
- package/templates/standalone/README.md +356 -92
- package/templates/standalone/config/application.conf +77 -2
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +3 -2
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
- package/templates/standalone/src/__tests__/listen.test.mts +128 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
- package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
- package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
- package/templates/standalone/src/app.mts +30 -51
- package/templates/standalone/src/buildModules.mts +94 -12
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +118 -12
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +90 -18
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- 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(
|
|
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
|
+
});
|