@o3co/create-auth-provider 0.10.0 → 0.12.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/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11 -1
- package/package.json +2 -2
- package/templates/standalone/.env.example +19 -1
- package/templates/standalone/Dockerfile +9 -1
- package/templates/standalone/README.ja.md +24 -4
- package/templates/standalone/README.md +175 -21
- package/templates/standalone/config/application.conf +28 -0
- package/templates/standalone/config/clients.yaml.example +15 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +80 -5
- package/templates/standalone/docker-compose.yml +24 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +5 -6
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -4
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +246 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +16 -3
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +128 -24
- package/templates/standalone/src/shutdown.mts +219 -0
- package/templates/versions.json +13 -11
|
@@ -0,0 +1,456 @@
|
|
|
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
|
+
* #455 / #456 — the standalone's own store modules under
|
|
19
|
+
* `deployment.mode = "multi"`, booted the way an operator reaches them: from
|
|
20
|
+
* the shipped HOCON with one environment variable flipped.
|
|
21
|
+
*
|
|
22
|
+
* #455: the replica-safety guard keyed on core's module names, and this
|
|
23
|
+
* template wires its *own* in-memory modules for three of the stores —
|
|
24
|
+
* session stores, the code repository, the federation token store — under
|
|
25
|
+
* names the guard had never heard of. `DEPLOYMENT_MODE=multi` with
|
|
26
|
+
* `USER_SESSION_STORES_ADAPTER=memory` booted, and the refusal the README
|
|
27
|
+
* promises did not fire for exactly the stores that fork per replica the
|
|
28
|
+
* worst. Every memory branch is booted here under `"multi"` and must be
|
|
29
|
+
* refused by name; the all-Redis environment must boot.
|
|
30
|
+
*
|
|
31
|
+
* #456: `federationTokenStore.type = "redis"` was documented and could not
|
|
32
|
+
* boot — the module went through the adapter factory without ever handing
|
|
33
|
+
* the builder a client. The Redis branch now mounts
|
|
34
|
+
* `redisFederationTokenStoreModule` off the shared ioredis socket; the boot
|
|
35
|
+
* below is the test the README sentence never had.
|
|
36
|
+
*
|
|
37
|
+
* #474: express-session's own store was the one memory store the guard could
|
|
38
|
+
* not see — `SESSION_STORAGE_TYPE=memory` is config, and `sessionStoreModule`'s
|
|
39
|
+
* static manifest carried no declaration. `buildModules` now builds it from
|
|
40
|
+
* the config (`sessionStoreModuleFor`), so it is refused by name like the rest.
|
|
41
|
+
*
|
|
42
|
+
* #473: the Redis federation store's `allow-plaintext` guard keyed off
|
|
43
|
+
* `NODE_ENV` alone. It now reads the environment the config was selected by
|
|
44
|
+
* (`CONFIG_ENV || NODE_ENV`, passed through `buildModules`) and refuses under
|
|
45
|
+
* `deployment.mode = "multi"` in every environment.
|
|
46
|
+
*
|
|
47
|
+
* ioredis is mocked, as in `device-code-store-client-module.test.mts`: the
|
|
48
|
+
* point is composition and the boot planner's stage-1 verdict, not Redis.
|
|
49
|
+
* Nothing here issues a command. node-redis and connect-redis — the session
|
|
50
|
+
* store's own connection — are mocked for the same reason, since #474 puts
|
|
51
|
+
* `SESSION_STORAGE_TYPE=redis` in the baseline.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
import {
|
|
56
|
+
type AppConfig,
|
|
57
|
+
AppConfigSchema,
|
|
58
|
+
createApp,
|
|
59
|
+
createKeyStoreFactory,
|
|
60
|
+
defineModule,
|
|
61
|
+
InMemoryClientRepository,
|
|
62
|
+
InMemoryUserRepository,
|
|
63
|
+
registerBuiltinKeyStores,
|
|
64
|
+
replicaUnsafeReason,
|
|
65
|
+
} from "@o3co/auth-provider-core";
|
|
66
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
67
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
68
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
69
|
+
import { buildModules } from "../buildModules.mjs";
|
|
70
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
71
|
+
|
|
72
|
+
// The redis session-store builder dynamically imports these (#474 puts the
|
|
73
|
+
// Redis session store in the baseline); mock them so no socket opens.
|
|
74
|
+
vi.mock("redis", () => ({
|
|
75
|
+
createClient: vi.fn(() => ({
|
|
76
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
quit: vi.fn().mockResolvedValue(undefined),
|
|
78
|
+
ping: vi.fn().mockResolvedValue("PONG"),
|
|
79
|
+
on: vi.fn(),
|
|
80
|
+
})),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
vi.mock("connect-redis", async () => {
|
|
84
|
+
const { EventEmitter } = await import("node:events");
|
|
85
|
+
// express-session subscribes to store events, so the fake must be an
|
|
86
|
+
// EventEmitter rather than a plain object.
|
|
87
|
+
return {
|
|
88
|
+
RedisStore: class MockRedisStore extends EventEmitter {
|
|
89
|
+
constructor(_opts: { client: unknown }) {
|
|
90
|
+
super();
|
|
91
|
+
}
|
|
92
|
+
get(): unknown {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
set(): void {}
|
|
96
|
+
destroy(): void {}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
vi.mock("ioredis", () => {
|
|
102
|
+
// Every command resolves to nothing. A boot that reached Redis would be a
|
|
103
|
+
// boot doing work at stage 2 that belongs in an integration test; the
|
|
104
|
+
// stand-in only has to be constructible, quit cleanly, and answer `ping`
|
|
105
|
+
// for the readiness probe the shared clients module registers.
|
|
106
|
+
const explicit: Record<string, unknown> = {
|
|
107
|
+
on: () => undefined,
|
|
108
|
+
quit: async () => "OK",
|
|
109
|
+
disconnect: () => undefined,
|
|
110
|
+
ping: async () => "PONG",
|
|
111
|
+
};
|
|
112
|
+
const makeMockRedis = (): object =>
|
|
113
|
+
new Proxy(
|
|
114
|
+
{},
|
|
115
|
+
{
|
|
116
|
+
get(_target, prop) {
|
|
117
|
+
// A `then` that is a function would make the instance a
|
|
118
|
+
// thenable and hang anything that awaits it.
|
|
119
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
120
|
+
if (prop === "duplicate") return makeMockRedis;
|
|
121
|
+
if (prop in explicit) return explicit[prop];
|
|
122
|
+
return async () => null;
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
// The template does `new Redis(url, options)`. A plain function that
|
|
127
|
+
// returns an object yields that object under `new`, so no class — and no
|
|
128
|
+
// constructor returning a value — is needed for the stand-in.
|
|
129
|
+
function MockRedis(): object {
|
|
130
|
+
return makeMockRedis();
|
|
131
|
+
}
|
|
132
|
+
return { Redis: MockRedis, default: MockRedis };
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// config/ is two levels above this test file: src/__tests__/ → src/ → standalone/
|
|
136
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
137
|
+
|
|
138
|
+
/** 32 bytes, base64 — what `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` carries. */
|
|
139
|
+
const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The umbrella E2E's shape (`o3co/auth` `tests/docker-compose.yml`), with
|
|
143
|
+
* every shared store on Redis — express-session's own included, since #474
|
|
144
|
+
* put it under the guard. Each case below flips one variable off this.
|
|
145
|
+
*/
|
|
146
|
+
const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
147
|
+
OAUTH_JWT_ALGORITHM: "HS256",
|
|
148
|
+
OAUTH_JWT_SECRET: "replica-safety-test-secret.at-least-32-bytes.ok",
|
|
149
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
150
|
+
SESSION_SECRET: "replica-safety-session-secret.at-least-32-bytes.ok",
|
|
151
|
+
SESSION_SECURE: "false",
|
|
152
|
+
SESSION_NAME: "auth.session",
|
|
153
|
+
SESSION_STORAGE_TYPE: "redis",
|
|
154
|
+
SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
155
|
+
CLIENT_USER_TYPE: "yaml",
|
|
156
|
+
DEPLOYMENT_MODE: "multi",
|
|
157
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
|
|
158
|
+
USER_SESSION_STORES_ADAPTER: "redis",
|
|
159
|
+
RATE_LIMITER_ADAPTER: "redis",
|
|
160
|
+
OAUTH_CODE_ADAPTER: "redis",
|
|
161
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
|
|
162
|
+
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
163
|
+
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function resolveConfig(env: Record<string, string>): AppConfig {
|
|
167
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
168
|
+
return validate(
|
|
169
|
+
parseFile(envConfPath, { env })
|
|
170
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
171
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
|
|
172
|
+
AppConfigSchema,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Drops a variable, so the HOCON default takes over. */
|
|
177
|
+
function without(env: Record<string, string>, ...names: string[]): Record<string, string> {
|
|
178
|
+
const copy = { ...env };
|
|
179
|
+
for (const name of names) delete copy[name];
|
|
180
|
+
return copy;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The file-system-backed modules are replaced as in `smoke.test.mts`: the
|
|
184
|
+
// boot under test is the store wiring, not the client registry on disk.
|
|
185
|
+
const testRepositoriesModule = defineModule({
|
|
186
|
+
name: "test:repositories",
|
|
187
|
+
provides: {
|
|
188
|
+
clientRepository: () => new InMemoryClientRepository(new Map()),
|
|
189
|
+
userRepository: () => new InMemoryUserRepository(new Map()),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const testKeyStoreModule = defineModule({
|
|
194
|
+
name: "test:key-store",
|
|
195
|
+
requires: ["config"] as const,
|
|
196
|
+
provides: {
|
|
197
|
+
keyStore: async ({ config: c }) => {
|
|
198
|
+
const factory = createKeyStoreFactory();
|
|
199
|
+
registerBuiltinKeyStores(factory);
|
|
200
|
+
return factory.create({
|
|
201
|
+
type: "local",
|
|
202
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const modulesFor = (config: AppConfig, environment?: string) =>
|
|
209
|
+
buildModules(config, {
|
|
210
|
+
keyStoreModule: testKeyStoreModule,
|
|
211
|
+
repositoriesModule: testRepositoriesModule,
|
|
212
|
+
...(environment === undefined ? {} : { environment }),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const boot = (config: AppConfig, environment?: string) =>
|
|
216
|
+
createApp({
|
|
217
|
+
modules: modulesFor(config, environment),
|
|
218
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** The message of a boot failure and of the module error it wraps, together. */
|
|
222
|
+
const messageChain = (err: unknown): string => {
|
|
223
|
+
const e = err as { message?: string; cause?: { message?: string } };
|
|
224
|
+
return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
describe('#455: the standalone\'s memory modules are refused under deployment.mode = "multi"', () => {
|
|
228
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
229
|
+
|
|
230
|
+
afterEach(async () => {
|
|
231
|
+
await handleRef?.dispose();
|
|
232
|
+
handleRef = undefined;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const cases: ReadonlyArray<readonly [variable: string, module: string]> = [
|
|
236
|
+
// The template's own modules — the three the guard could not see.
|
|
237
|
+
["USER_SESSION_STORES_ADAPTER", "standalone:in-memory-session-stores"],
|
|
238
|
+
["OAUTH_CODE_ADAPTER", "standalone:in-memory-code-repository"],
|
|
239
|
+
["FEDERATION_TOKEN_STORE_TYPE", "standalone:in-memory-federation-token-store"],
|
|
240
|
+
// #474: express-session's own store. Not a module of this template but
|
|
241
|
+
// built here from its config, which is what lets the manifest declare.
|
|
242
|
+
["SESSION_STORAGE_TYPE", "sessionStoreModule"],
|
|
243
|
+
// Core's, selected by the same kind of switch. The guard knew these by
|
|
244
|
+
// name; pinned so the name table's departure (#455) did not lose them.
|
|
245
|
+
["RATE_LIMITER_ADAPTER", "core-rate-limiter-memory"],
|
|
246
|
+
["ACCESS_TOKEN_DENYLIST_ADAPTER", "core-access-token-denylist-memory"],
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (const [variable, module] of cases) {
|
|
250
|
+
it(`${variable}=memory is refused, naming ${module}`, async () => {
|
|
251
|
+
const config = resolveConfig({ ...ALL_REDIS_ENV, [variable]: "memory" });
|
|
252
|
+
await expect(boot(config)).rejects.toMatchObject({
|
|
253
|
+
name: "BootError",
|
|
254
|
+
reason: "replica-unsafe-adapter",
|
|
255
|
+
details: { modules: [module] },
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
it("names every memory module together when every switch is memory", async () => {
|
|
261
|
+
const config = resolveConfig({
|
|
262
|
+
...ALL_REDIS_ENV,
|
|
263
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
264
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
265
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
266
|
+
SESSION_STORAGE_TYPE: "memory",
|
|
267
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
268
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
269
|
+
});
|
|
270
|
+
await expect(boot(config)).rejects.toMatchObject({
|
|
271
|
+
reason: "replica-unsafe-adapter",
|
|
272
|
+
details: {
|
|
273
|
+
modules: expect.arrayContaining(cases.map(([, module]) => module)),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("each standalone memory module declares its consequence on its own manifest", () => {
|
|
279
|
+
// The declaration is what the guard reads (#455), so it has to be on
|
|
280
|
+
// the module and it has to say what breaks — the guard quotes it.
|
|
281
|
+
const config = resolveConfig({
|
|
282
|
+
...ALL_REDIS_ENV,
|
|
283
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
284
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
285
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
286
|
+
});
|
|
287
|
+
const standaloneMemoryModules = modulesFor(config).filter((m) =>
|
|
288
|
+
m.name.startsWith("standalone:in-memory-"),
|
|
289
|
+
);
|
|
290
|
+
expect(standaloneMemoryModules.map((m) => m.name).sort()).toEqual([
|
|
291
|
+
"standalone:in-memory-code-repository",
|
|
292
|
+
"standalone:in-memory-federation-token-store",
|
|
293
|
+
"standalone:in-memory-session-stores",
|
|
294
|
+
]);
|
|
295
|
+
for (const m of standaloneMemoryModules) {
|
|
296
|
+
expect(replicaUnsafeReason(m), m.name).toBeDefined();
|
|
297
|
+
expect((replicaUnsafeReason(m) ?? "").length, m.name).toBeGreaterThan(40);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("boots the all-Redis environment, with nothing declaring replica-unsafe state", async () => {
|
|
302
|
+
const config = resolveConfig(ALL_REDIS_ENV);
|
|
303
|
+
for (const m of modulesFor(config)) {
|
|
304
|
+
expect(replicaUnsafeReason(m), m.name).toBeUndefined();
|
|
305
|
+
}
|
|
306
|
+
handleRef = await boot(config);
|
|
307
|
+
expect(handleRef).toBeDefined();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
312
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
313
|
+
|
|
314
|
+
afterEach(async () => {
|
|
315
|
+
await handleRef?.dispose();
|
|
316
|
+
handleRef = undefined;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("reaches the resolved config from FEDERATION_TOKEN_STORE_TYPE", () => {
|
|
320
|
+
// `AppConfigSchema` strips top-level keys it does not declare; the
|
|
321
|
+
// switch had to be declared before an env var could select anything.
|
|
322
|
+
expect(resolveConfig(ALL_REDIS_ENV).federationTokenStore?.type).toBe("redis");
|
|
323
|
+
expect(
|
|
324
|
+
resolveConfig(without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE")).federationTokenStore
|
|
325
|
+
?.type,
|
|
326
|
+
).toBe("memory");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("selects the Redis module and the shared clients module, not the memory one", () => {
|
|
330
|
+
const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
|
|
331
|
+
const names = modules.map((m) => m.name);
|
|
332
|
+
expect(names).toContain("redis-federation-token-store");
|
|
333
|
+
expect(names).toContain("standalone:redis-clients");
|
|
334
|
+
expect(names).not.toContain("standalone:in-memory-federation-token-store");
|
|
335
|
+
// Exactly one provider for the slot: both modules provide it, so
|
|
336
|
+
// selecting both would be a boot-time slot collision.
|
|
337
|
+
const providers = modules.filter((m) =>
|
|
338
|
+
Object.keys(m.provides ?? {}).includes("federationTokenStore"),
|
|
339
|
+
);
|
|
340
|
+
expect(providers.map((m) => m.name)).toEqual(["redis-federation-token-store"]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("pulls the shared clients module in for the federation store alone", () => {
|
|
344
|
+
// Every other store on memory, single replica: the Redis federation
|
|
345
|
+
// store still needs `federationTokenStoreClient`, which only the shared
|
|
346
|
+
// clients module provides. Without this the branch fails stage 1 with
|
|
347
|
+
// `missing-required-component` — the #439 shape, once more.
|
|
348
|
+
const config = resolveConfig({
|
|
349
|
+
...ALL_REDIS_ENV,
|
|
350
|
+
DEPLOYMENT_MODE: "single",
|
|
351
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
352
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
353
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
354
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
355
|
+
});
|
|
356
|
+
const names = modulesFor(config).map((m) => m.name);
|
|
357
|
+
expect(names).toContain("redis-federation-token-store");
|
|
358
|
+
expect(names).toContain("standalone:redis-clients");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("boots, and the resolved store is the Redis adapter", async () => {
|
|
362
|
+
handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
|
|
363
|
+
const store = handleRef.components.federationTokenStore;
|
|
364
|
+
expect(store?.kind).toBe("redis");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("fails at boot, naming the key, when the encryption key is missing", async () => {
|
|
368
|
+
// The store encrypts long-lived IdP refresh tokens at rest; a Redis
|
|
369
|
+
// branch with no key is a misconfiguration to refuse at boot, not a
|
|
370
|
+
// store that works until the first federation login.
|
|
371
|
+
const config = resolveConfig(
|
|
372
|
+
without(ALL_REDIS_ENV, "REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY"),
|
|
373
|
+
);
|
|
374
|
+
await expect(boot(config)).rejects.toSatisfy((err: unknown) => {
|
|
375
|
+
const e = err as { message?: string; cause?: { message?: string } };
|
|
376
|
+
return /encryption\.key/.test(`${e.message ?? ""} ${e.cause?.message ?? ""}`);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("keeps the memory branch as it was: the default, in single mode, resolves the memory adapter", async () => {
|
|
381
|
+
const config = resolveConfig({
|
|
382
|
+
...without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE"),
|
|
383
|
+
DEPLOYMENT_MODE: "single",
|
|
384
|
+
});
|
|
385
|
+
const names = modulesFor(config).map((m) => m.name);
|
|
386
|
+
expect(names).toContain("standalone:in-memory-federation-token-store");
|
|
387
|
+
expect(names).not.toContain("redis-federation-token-store");
|
|
388
|
+
handleRef = await boot(config);
|
|
389
|
+
expect(handleRef.components.federationTokenStore?.kind).toBe("memory");
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
|
|
394
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
395
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
396
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
397
|
+
let origInsecure: string | undefined;
|
|
398
|
+
|
|
399
|
+
beforeEach(() => {
|
|
400
|
+
// vitest runs with NODE_ENV=test: not a production name, so every
|
|
401
|
+
// refusal below comes from what the standalone passes, not from NODE_ENV.
|
|
402
|
+
expect(process.env.NODE_ENV).not.toMatch(/^(production|staging)$/);
|
|
403
|
+
origInsecure = process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
|
|
404
|
+
delete process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
|
|
405
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
406
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
afterEach(async () => {
|
|
410
|
+
await handleRef?.dispose();
|
|
411
|
+
handleRef = undefined;
|
|
412
|
+
if (origInsecure === undefined) delete process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
|
|
413
|
+
else process.env.FEDERATION_TOKENS_ALLOW_INSECURE = origInsecure;
|
|
414
|
+
warnSpy.mockRestore();
|
|
415
|
+
errorSpy.mockRestore();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
/** All-Redis, with the federation store told to skip encryption. */
|
|
419
|
+
const PLAINTEXT_ENV: Readonly<Record<string, string>> = {
|
|
420
|
+
...without(ALL_REDIS_ENV, "REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY"),
|
|
421
|
+
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "allow-plaintext",
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
it('is refused under deployment.mode = "multi" in a development environment', async () => {
|
|
425
|
+
// The umbrella shape with plaintext: refused before #473 only if
|
|
426
|
+
// NODE_ENV happened to say production. A multi-replica deployment is
|
|
427
|
+
// never a development box.
|
|
428
|
+
await expect(boot(resolveConfig(PLAINTEXT_ENV), "development")).rejects.toSatisfy(
|
|
429
|
+
(err: unknown) => /deployment\.mode is "multi"/.test(messageChain(err)),
|
|
430
|
+
);
|
|
431
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("is refused when the config was selected by CONFIG_ENV=production, whatever NODE_ENV says", async () => {
|
|
435
|
+
// `app.mts` selects `production.conf` by `CONFIG_ENV || NODE_ENV` and
|
|
436
|
+
// passes that name through `buildModules`; the guard used to read
|
|
437
|
+
// NODE_ENV alone and would have let this boot.
|
|
438
|
+
const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
|
|
439
|
+
await expect(boot(config, "production")).rejects.toSatisfy((err: unknown) =>
|
|
440
|
+
/the environment is "production"/.test(messageChain(err)),
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("boots with the plaintext warning in a development environment on a single replica", async () => {
|
|
445
|
+
const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
|
|
446
|
+
handleRef = await boot(config, "development");
|
|
447
|
+
expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
|
|
448
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("allow-plaintext"));
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, at CRITICAL", async () => {
|
|
452
|
+
process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
|
|
453
|
+
handleRef = await boot(resolveConfig(PLAINTEXT_ENV), "development");
|
|
454
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("CRITICAL"));
|
|
455
|
+
});
|
|
456
|
+
});
|