@o3co/create-auth-provider 0.11.0 → 0.12.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.
- package/package.json +1 -1
- package/templates/standalone/.env.example +13 -0
- package/templates/standalone/README.ja.md +9 -1
- package/templates/standalone/README.md +162 -20
- package/templates/standalone/config/application.conf +17 -0
- package/templates/standalone/docker-compose.production.yml +63 -4
- package/templates/standalone/docker-compose.yml +17 -0
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +67 -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 +129 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +81 -1
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +5 -1
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +88 -22
- package/templates/standalone/src/shutdown.mts +64 -7
- package/templates/versions.json +13 -12
|
@@ -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
|
+
});
|
|
@@ -26,11 +26,84 @@
|
|
|
26
26
|
|
|
27
27
|
import { existsSync, readFileSync } from "node:fs";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
|
|
30
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
31
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
29
32
|
import { describe, expect, it } from "vitest";
|
|
33
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
30
34
|
|
|
31
35
|
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
36
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
32
37
|
const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "utf8");
|
|
33
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The `environment:` entries of a compose file's `app` service, as the
|
|
41
|
+
* environment the container would actually see.
|
|
42
|
+
*
|
|
43
|
+
* A hand-rolled reader rather than a YAML dependency: these two files are
|
|
44
|
+
* fixed-shape artifacts in this repository, the block is flat `KEY: value`
|
|
45
|
+
* scalars, and adding a parser to the template's dependency tree to read its
|
|
46
|
+
* own scaffold would be a strange thing to ship to operators.
|
|
47
|
+
*
|
|
48
|
+
* Compose's `${VAR:?err}` required-variable form resolves from the operator's
|
|
49
|
+
* shell or `.env`, so there is no literal value to record — the key is mapped
|
|
50
|
+
* to `null`, which is the point of that form and what the assertions below
|
|
51
|
+
* check for.
|
|
52
|
+
*/
|
|
53
|
+
function composeAppEnvironment(rel: string): Map<string, string | null> {
|
|
54
|
+
const lines = read(rel).split("\n");
|
|
55
|
+
const start = lines.findIndex((line) => /^\s{4}environment:\s*$/.test(line));
|
|
56
|
+
const env = new Map<string, string | null>();
|
|
57
|
+
if (start === -1) return env;
|
|
58
|
+
for (const line of lines.slice(start + 1)) {
|
|
59
|
+
if (line.trim() === "" || /^\s*#/.test(line)) continue;
|
|
60
|
+
// The block ends at the first line indented no further than its own key.
|
|
61
|
+
if (!/^\s{6}\S/.test(line)) break;
|
|
62
|
+
const match = /^\s{6}([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line);
|
|
63
|
+
if (!match) continue;
|
|
64
|
+
const raw = (match[2] as string).trim().replace(/^["']|["']$/g, "");
|
|
65
|
+
env.set(match[1] as string, /^\$\{[A-Z][A-Z0-9_]*:\?/.test(raw) ? null : raw);
|
|
66
|
+
}
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve the shipped config layers under a given environment, as `src/app.mts` does. */
|
|
71
|
+
function resolveWith(env: Record<string, string>, configEnv = "production"): AppConfig {
|
|
72
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
73
|
+
return validate(
|
|
74
|
+
parseFile(envConfPath, { env })
|
|
75
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
76
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
|
|
77
|
+
AppConfigSchema,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A compose file's `environment:` block, plus the secrets no compose file
|
|
83
|
+
* carries (they come from `.env` or a compose secret) — the environment the
|
|
84
|
+
* process would boot under.
|
|
85
|
+
*
|
|
86
|
+
* The key material follows what the file itself says: the production compose
|
|
87
|
+
* points the EdDSA key paths at its mounted secrets, so leave the default
|
|
88
|
+
* algorithm alone; the dev compose supplies no key at all, so give it the
|
|
89
|
+
* HS256 shape, whose `.strict()` union member is exactly why the two cannot
|
|
90
|
+
* share one map.
|
|
91
|
+
*/
|
|
92
|
+
function bootableEnv(rel: string): Record<string, string> {
|
|
93
|
+
const env: Record<string, string> = {
|
|
94
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
95
|
+
SESSION_SECRET: "scaffold-assets-compose-session.at-least-32-bytes.ok",
|
|
96
|
+
};
|
|
97
|
+
for (const [key, value] of composeAppEnvironment(rel)) {
|
|
98
|
+
if (value !== null) env[key] = value;
|
|
99
|
+
}
|
|
100
|
+
if (env.OAUTH_JWT_PRIVATE_KEY_PATH === undefined) {
|
|
101
|
+
env.OAUTH_JWT_ALGORITHM = "HS256";
|
|
102
|
+
env.OAUTH_JWT_SECRET = "scaffold-assets-compose.at-least-32-bytes.ok";
|
|
103
|
+
}
|
|
104
|
+
return env;
|
|
105
|
+
}
|
|
106
|
+
|
|
34
107
|
describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
|
|
35
108
|
it("copies pnpm-workspace.yaml into the deps stage", () => {
|
|
36
109
|
// `create-auth-provider` generates it to carry the bcrypt
|
|
@@ -106,6 +179,62 @@ describe("#407 — the production compose matches the topology it documents", ()
|
|
|
106
179
|
});
|
|
107
180
|
});
|
|
108
181
|
|
|
182
|
+
describe("the compose files put a store and its lifetime-sibling on the same backend", () => {
|
|
183
|
+
// The production compose set `SESSION_STORAGE_TYPE: redis` and left
|
|
184
|
+
// `USER_SESSION_STORES_ADAPTER` at its `memory` default. express-session
|
|
185
|
+
// then survives a restart and the `UserSession` it points at does not, so
|
|
186
|
+
// every browser comes back `isAuthenticated` with nothing behind it and
|
|
187
|
+
// /authorize loops until the cookie is deleted by hand.
|
|
188
|
+
//
|
|
189
|
+
// `DEPLOYMENT_MODE: single` is silent about this on purpose — the replica
|
|
190
|
+
// guard answers "can these stores be shared", not "do these two stores have
|
|
191
|
+
// the same lifetime" — so nothing but this assertion stands behind it.
|
|
192
|
+
for (const file of ["/docker-compose.production.yml", "/docker-compose.yml"]) {
|
|
193
|
+
it(`${file} keeps the user-session stores with the express-session store`, () => {
|
|
194
|
+
const config = resolveWith(bootableEnv(file));
|
|
195
|
+
// Resolved through the real config layers, not read off the file:
|
|
196
|
+
// what matters is the value the process ends up with, whether the
|
|
197
|
+
// compose stated it or `config/application.conf` did.
|
|
198
|
+
expect(config.session.storage?.type).toBe("redis");
|
|
199
|
+
expect(config.userSessionStores?.adapter).toBe("redis");
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
it("the production compose leaves no store on memory while a sibling is on Redis", () => {
|
|
204
|
+
const config = resolveWith(bootableEnv("/docker-compose.production.yml"));
|
|
205
|
+
// Every store whose records must outlive one process. The federation
|
|
206
|
+
// token store is deliberately absent: this template ships every
|
|
207
|
+
// federation disabled, so nothing writes to it, and turning it on needs
|
|
208
|
+
// an AES key the compose file must not invent.
|
|
209
|
+
expect(config.session.storage?.type).toBe("redis");
|
|
210
|
+
expect(config.userSessionStores?.adapter).toBe("redis");
|
|
211
|
+
expect(config.oauth.code?.adapter).toBe("redis");
|
|
212
|
+
expect(config.accessTokenDenylist?.adapter).toBe("redis");
|
|
213
|
+
expect(config.rateLimiter?.adapter).toBe("redis");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("the production compose refuses to guess HTTP_TRUST_PROXY", () => {
|
|
218
|
+
it("names the variable and supplies no default", () => {
|
|
219
|
+
// Without it the Secure cookie is never set, the CSRF origin check 403s
|
|
220
|
+
// every browser POST, and every IP-keyed rate limit shares one bucket.
|
|
221
|
+
// With a baked-in default it would silently trust a hop the operator
|
|
222
|
+
// never chose. The `${VAR:?err}` form is the only reading that is
|
|
223
|
+
// neither: compose refuses to start until the operator names their edge.
|
|
224
|
+
const compose = composeAppEnvironment("/docker-compose.production.yml");
|
|
225
|
+
expect(compose.has("HTTP_TRUST_PROXY")).toBe(true);
|
|
226
|
+
expect(compose.get("HTTP_TRUST_PROXY")).toBeNull();
|
|
227
|
+
expect(read("/docker-compose.production.yml")).not.toMatch(
|
|
228
|
+
/HTTP_TRUST_PROXY:\s*(true|false)\b/,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("is offered in .env.example with no value", () => {
|
|
233
|
+
// The compose reads it from `.env`, which the operator copies from here.
|
|
234
|
+
expect(read("/.env.example")).toMatch(/^HTTP_TRUST_PROXY=$/m);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
109
238
|
describe("#407 — the two READMEs agree on security advice", () => {
|
|
110
239
|
it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
|
|
111
240
|
// #292 made `trust proxy` a CIDR/hop policy precisely because `true`
|