@o3co/create-auth-provider 0.15.0 → 0.17.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.
- package/README.ja.md +139 -48
- package/README.md +138 -47
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -15,45 +15,30 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* The standalone's own store modules under `core.deployment.mode = "multi"`,
|
|
19
|
+
* booted the way an operator reaches them: from the shipped HOCON with one
|
|
20
|
+
* environment variable flipped.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
22
|
+
* - Every memory branch must be refused by name, and the all-Redis
|
|
23
|
+
* environment must boot. The replica-safety guard reads each module's
|
|
24
|
+
* manifest declaration, which covers the template's own in-memory modules
|
|
25
|
+
* and express-session's store (`buildModules` builds it from config,
|
|
26
|
+
* `sessionStoreModuleFor`).
|
|
27
|
+
* - `federationTokenStore.type = "redis"` mounts
|
|
28
|
+
* `redisFederationTokenStoreModule` off the shared ioredis socket.
|
|
29
|
+
* - The Redis federation store's `allow-plaintext` guard reads the
|
|
30
|
+
* environment the config was selected by (`CONFIG_ENV || NODE_ENV`, passed
|
|
31
|
+
* through `buildModules`) and refuses under `core.deployment.mode = "multi"` in
|
|
32
|
+
* every environment.
|
|
30
33
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
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.
|
|
34
|
+
* ioredis, node-redis and connect-redis are mocked, as in
|
|
35
|
+
* `device-code-store-client-module.test.mts`: the point is composition and
|
|
36
|
+
* the boot planner's stage-1 verdict, not Redis. Nothing here issues a
|
|
37
|
+
* command.
|
|
52
38
|
*/
|
|
53
39
|
|
|
54
40
|
import { fileURLToPath } from "node:url";
|
|
55
41
|
import {
|
|
56
|
-
type AppConfig,
|
|
57
42
|
AppConfigSchema,
|
|
58
43
|
createApp,
|
|
59
44
|
createKeyStoreFactory,
|
|
@@ -64,14 +49,22 @@ import {
|
|
|
64
49
|
registerBuiltinKeyStores,
|
|
65
50
|
replicaUnsafeReason,
|
|
66
51
|
} from "@o3co/auth-provider-core";
|
|
52
|
+
import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
|
|
67
53
|
import { parseFile } from "@o3co/ts.hocon";
|
|
68
54
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
69
55
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
70
56
|
import { buildModules } from "../buildModules.mjs";
|
|
71
|
-
import { resolveConfigPaths,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
57
|
+
import { resolveConfigPaths, type Switches } from "../configPath.mjs";
|
|
58
|
+
import { templateReference } from "../modules.mjs";
|
|
59
|
+
import {
|
|
60
|
+
capturedRenames,
|
|
61
|
+
libraryLayers,
|
|
62
|
+
rootSectionsOf,
|
|
63
|
+
sectionsCoreDoesNotDeclare,
|
|
64
|
+
} from "./library-references.fixture.mjs";
|
|
65
|
+
|
|
66
|
+
// The redis session-store builder, which the baseline selects, dynamically
|
|
67
|
+
// imports these; mock them so no socket opens.
|
|
75
68
|
vi.mock("redis", () => ({
|
|
76
69
|
createClient: vi.fn(() => ({
|
|
77
70
|
connect: vi.fn().mockResolvedValue(undefined),
|
|
@@ -141,41 +134,48 @@ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
|
141
134
|
|
|
142
135
|
/**
|
|
143
136
|
* The umbrella E2E's shape (`o3co/auth` `tests/docker-compose.yml`), with
|
|
144
|
-
* every shared store on Redis
|
|
145
|
-
*
|
|
137
|
+
* every shared store on Redis, express-session's own included, and MFA off
|
|
138
|
+
* as the umbrella sets it. Each case below flips one variable off this.
|
|
146
139
|
*/
|
|
147
140
|
const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
148
|
-
|
|
149
|
-
|
|
141
|
+
MFA_MODE: "off",
|
|
142
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
143
|
+
KEY_STORE_LOCAL_SECRET: "replica-safety-test-secret.at-least-32-bytes.ok",
|
|
150
144
|
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
145
|
+
SESSION_STORE_SECRET: "replica-safety-session-secret.at-least-32-bytes.ok",
|
|
146
|
+
SESSION_STORE_SECURE: "false",
|
|
147
|
+
SESSION_STORE_NAME: "auth.session",
|
|
148
|
+
SESSION_STORE_STORAGE_TYPE: "redis",
|
|
149
|
+
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
150
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
151
|
+
CORE_DEPLOYMENT_MODE: "multi",
|
|
152
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
153
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
154
|
+
ADAPTERS_RATE_LIMITER: "redis",
|
|
155
|
+
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
156
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
157
|
+
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
158
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
|
|
165
159
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
166
|
-
//
|
|
160
|
+
// The consent step for clients that are not first-party, on the
|
|
167
161
|
// shared store — the one switch value `multi` accepts besides `none`.
|
|
168
|
-
|
|
162
|
+
ADAPTERS_CONSENT_STORE: "redis",
|
|
169
163
|
};
|
|
170
164
|
|
|
171
|
-
function resolveConfig(env: Record<string, string>):
|
|
165
|
+
function resolveConfig(env: Record<string, string>): Switches {
|
|
172
166
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
173
|
-
|
|
174
|
-
parseFile(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
167
|
+
const layers = parseFile(envConfPath, { env })
|
|
168
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
169
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
170
|
+
.withFallback(libraryLayers(env));
|
|
171
|
+
return {
|
|
172
|
+
...sectionsCoreDoesNotDeclare(layers),
|
|
173
|
+
...rootSectionsOf(layers, env),
|
|
174
|
+
...validate(layers, AppConfigSchema),
|
|
175
|
+
// What the resolution captured of core's renamed variables, which the
|
|
176
|
+
// schema's parse drops.
|
|
177
|
+
"renamed-variables": capturedRenames(env),
|
|
178
|
+
} as Switches;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/** Drops a variable, so the HOCON default takes over. */
|
|
@@ -204,20 +204,20 @@ const testKeyStoreModule = defineModule({
|
|
|
204
204
|
registerBuiltinKeyStores(factory);
|
|
205
205
|
return factory.create({
|
|
206
206
|
type: "local",
|
|
207
|
-
...((c as
|
|
207
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
208
208
|
});
|
|
209
209
|
},
|
|
210
210
|
},
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
const modulesFor = (config:
|
|
213
|
+
const modulesFor = (config: Switches, environment?: string) =>
|
|
214
214
|
buildModules(config, {
|
|
215
215
|
keyStoreModule: testKeyStoreModule,
|
|
216
216
|
repositoriesModule: testRepositoriesModule,
|
|
217
217
|
...(environment === undefined ? {} : { environment }),
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
const boot = (config:
|
|
220
|
+
const boot = (config: Switches, environment?: string) =>
|
|
221
221
|
createApp({
|
|
222
222
|
modules: modulesFor(config, environment),
|
|
223
223
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
@@ -229,7 +229,7 @@ const messageChain = (err: unknown): string => {
|
|
|
229
229
|
return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
|
|
230
230
|
};
|
|
231
231
|
|
|
232
|
-
describe('
|
|
232
|
+
describe('the standalone\'s memory modules are refused under core.deployment.mode = "multi"', () => {
|
|
233
233
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
234
234
|
|
|
235
235
|
afterEach(async () => {
|
|
@@ -238,21 +238,20 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
|
|
|
238
238
|
});
|
|
239
239
|
|
|
240
240
|
const cases: ReadonlyArray<readonly [variable: string, module: string]> = [
|
|
241
|
-
// The template's own modules
|
|
242
|
-
["
|
|
243
|
-
["
|
|
244
|
-
["
|
|
245
|
-
//
|
|
241
|
+
// The template's own modules.
|
|
242
|
+
["ADAPTERS_USER_SESSION_STORES", "standalone-in-memory-session-stores"],
|
|
243
|
+
["ADAPTERS_CODE_REPOSITORY", "standalone-in-memory-code-repository"],
|
|
244
|
+
["ADAPTERS_FEDERATION_TOKEN_STORE", "standalone-in-memory-federation-token-store"],
|
|
245
|
+
// express-session's own store. Not a module of this template but
|
|
246
246
|
// built here from its config, which is what lets the manifest declare.
|
|
247
|
-
["
|
|
248
|
-
//
|
|
249
|
-
["
|
|
250
|
-
// Core's, selected by the same kind of switch.
|
|
251
|
-
|
|
252
|
-
["
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
["REPLAY_SEEN_SET_ADAPTER", "core-replay-seen-set-memory"],
|
|
247
|
+
["SESSION_STORE_STORAGE_TYPE", "session-store"],
|
|
248
|
+
// The consent store, wired only when the switch says so.
|
|
249
|
+
["ADAPTERS_CONSENT_STORE", "core-consent-store-memory"],
|
|
250
|
+
// Core's, selected by the same kind of switch.
|
|
251
|
+
["ADAPTERS_RATE_LIMITER", "core-rate-limiter-memory"],
|
|
252
|
+
["ADAPTERS_ACCESS_TOKEN_DENYLIST", "core-access-token-denylist-memory"],
|
|
253
|
+
// The jti single-use record behind private_key_jwt client auth.
|
|
254
|
+
["ADAPTERS_REPLAY_SEEN_SET", "core-replay-seen-set-memory"],
|
|
256
255
|
];
|
|
257
256
|
|
|
258
257
|
for (const [variable, module] of cases) {
|
|
@@ -269,14 +268,14 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
|
|
|
269
268
|
it("names every memory module together when every switch is memory", async () => {
|
|
270
269
|
const config = resolveConfig({
|
|
271
270
|
...ALL_REDIS_ENV,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
271
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
272
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
273
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
274
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
275
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
276
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
277
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
278
|
+
ADAPTERS_CONSENT_STORE: "memory",
|
|
280
279
|
});
|
|
281
280
|
await expect(boot(config)).rejects.toMatchObject({
|
|
282
281
|
reason: "replica-unsafe-adapter",
|
|
@@ -287,21 +286,21 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
|
|
|
287
286
|
});
|
|
288
287
|
|
|
289
288
|
it("each standalone memory module declares its consequence on its own manifest", () => {
|
|
290
|
-
// The declaration is what the guard reads
|
|
289
|
+
// The declaration is what the guard reads, so it has to be on
|
|
291
290
|
// the module and it has to say what breaks — the guard quotes it.
|
|
292
291
|
const config = resolveConfig({
|
|
293
292
|
...ALL_REDIS_ENV,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
293
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
294
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
295
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
297
296
|
});
|
|
298
297
|
const standaloneMemoryModules = modulesFor(config).filter((m) =>
|
|
299
|
-
m.name.startsWith("standalone
|
|
298
|
+
m.name.startsWith("standalone-in-memory-"),
|
|
300
299
|
);
|
|
301
300
|
expect(standaloneMemoryModules.map((m) => m.name).sort()).toEqual([
|
|
302
|
-
"standalone
|
|
303
|
-
"standalone
|
|
304
|
-
"standalone
|
|
301
|
+
"standalone-in-memory-code-repository",
|
|
302
|
+
"standalone-in-memory-federation-token-store",
|
|
303
|
+
"standalone-in-memory-session-stores",
|
|
305
304
|
]);
|
|
306
305
|
for (const m of standaloneMemoryModules) {
|
|
307
306
|
expect(replicaUnsafeReason(m), m.name).toBeDefined();
|
|
@@ -319,7 +318,7 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
|
|
|
319
318
|
});
|
|
320
319
|
});
|
|
321
320
|
|
|
322
|
-
describe('
|
|
321
|
+
describe('adapters.federationTokenStore = "redis" in the standalone', () => {
|
|
323
322
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
324
323
|
|
|
325
324
|
afterEach(async () => {
|
|
@@ -327,13 +326,11 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
|
327
326
|
handleRef = undefined;
|
|
328
327
|
});
|
|
329
328
|
|
|
330
|
-
it("reaches
|
|
331
|
-
|
|
332
|
-
// switch had to be declared before an env var could select anything.
|
|
333
|
-
expect(resolveConfig(ALL_REDIS_ENV).federationTokenStore?.type).toBe("redis");
|
|
329
|
+
it("reaches phase one's adapters from ADAPTERS_FEDERATION_TOKEN_STORE", () => {
|
|
330
|
+
expect(resolveConfig(ALL_REDIS_ENV).adapters.federationTokenStore).toBe("redis");
|
|
334
331
|
expect(
|
|
335
|
-
resolveConfig(without(ALL_REDIS_ENV, "
|
|
336
|
-
|
|
332
|
+
resolveConfig(without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE")).adapters
|
|
333
|
+
.federationTokenStore,
|
|
337
334
|
).toBe("memory");
|
|
338
335
|
});
|
|
339
336
|
|
|
@@ -341,8 +338,8 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
|
341
338
|
const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
|
|
342
339
|
const names = modules.map((m) => m.name);
|
|
343
340
|
expect(names).toContain("redis-federation-token-store");
|
|
344
|
-
expect(names).toContain("
|
|
345
|
-
expect(names).not.toContain("standalone
|
|
341
|
+
expect(names).toContain("redis-clients");
|
|
342
|
+
expect(names).not.toContain("standalone-in-memory-federation-token-store");
|
|
346
343
|
// Exactly one provider for the slot: both modules provide it, so
|
|
347
344
|
// selecting both would be a boot-time slot collision.
|
|
348
345
|
const providers = modules.filter((m) =>
|
|
@@ -355,18 +352,18 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
|
355
352
|
// Every other store on memory, single replica: the Redis federation
|
|
356
353
|
// store still needs `federationTokenStoreClient`, which only the shared
|
|
357
354
|
// clients module provides. Without this the branch fails stage 1 with
|
|
358
|
-
// `missing-required-component
|
|
355
|
+
// `missing-required-component`.
|
|
359
356
|
const config = resolveConfig({
|
|
360
357
|
...ALL_REDIS_ENV,
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
358
|
+
CORE_DEPLOYMENT_MODE: "single",
|
|
359
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
360
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
361
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
362
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
366
363
|
});
|
|
367
364
|
const names = modulesFor(config).map((m) => m.name);
|
|
368
365
|
expect(names).toContain("redis-federation-token-store");
|
|
369
|
-
expect(names).toContain("
|
|
366
|
+
expect(names).toContain("redis-clients");
|
|
370
367
|
});
|
|
371
368
|
|
|
372
369
|
it("boots, and the resolved store is the Redis adapter", async () => {
|
|
@@ -388,20 +385,20 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
|
388
385
|
});
|
|
389
386
|
});
|
|
390
387
|
|
|
391
|
-
it("keeps the memory branch
|
|
388
|
+
it("keeps the memory branch: the default, in single mode, resolves the memory adapter", async () => {
|
|
392
389
|
const config = resolveConfig({
|
|
393
|
-
...without(ALL_REDIS_ENV, "
|
|
394
|
-
|
|
390
|
+
...without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE"),
|
|
391
|
+
CORE_DEPLOYMENT_MODE: "single",
|
|
395
392
|
});
|
|
396
393
|
const names = modulesFor(config).map((m) => m.name);
|
|
397
|
-
expect(names).toContain("standalone
|
|
394
|
+
expect(names).toContain("standalone-in-memory-federation-token-store");
|
|
398
395
|
expect(names).not.toContain("redis-federation-token-store");
|
|
399
396
|
handleRef = await boot(config);
|
|
400
397
|
expect(handleRef.components.federationTokenStore?.kind).toBe("memory");
|
|
401
398
|
});
|
|
402
399
|
});
|
|
403
400
|
|
|
404
|
-
describe('
|
|
401
|
+
describe('consentStore.adapter = "redis" in the standalone', () => {
|
|
405
402
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
406
403
|
|
|
407
404
|
afterEach(async () => {
|
|
@@ -428,16 +425,16 @@ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
|
|
|
428
425
|
it("pulls the shared clients module in for the consent stores alone", () => {
|
|
429
426
|
// Every other store on memory, single replica: the consent module still
|
|
430
427
|
// needs its two client slots, which only the shared clients module
|
|
431
|
-
// provides
|
|
428
|
+
// provides.
|
|
432
429
|
const config = resolveConfig({
|
|
433
430
|
...ALL_REDIS_ENV,
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
431
|
+
CORE_DEPLOYMENT_MODE: "single",
|
|
432
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
433
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
434
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
435
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
436
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
437
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
441
438
|
});
|
|
442
439
|
const names = buildModules(config, {
|
|
443
440
|
keyStoreModule: testKeyStoreModule,
|
|
@@ -445,17 +442,17 @@ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
|
|
|
445
442
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
446
443
|
}).map((m) => m.name);
|
|
447
444
|
expect(names).toContain("redis-consent-store");
|
|
448
|
-
expect(names).toContain("
|
|
445
|
+
expect(names).toContain("redis-clients");
|
|
449
446
|
});
|
|
450
447
|
|
|
451
|
-
it("boots under
|
|
448
|
+
it("boots under CORE_DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
|
|
452
449
|
handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
|
|
453
450
|
expect(handleRef.components.consentStore?.kind).toBe("redis");
|
|
454
451
|
expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
|
|
455
452
|
});
|
|
456
453
|
});
|
|
457
454
|
|
|
458
|
-
describe("
|
|
455
|
+
describe("the Redis federation store's plaintext guard, booted from the shipped config", () => {
|
|
459
456
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
460
457
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
461
458
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
@@ -486,36 +483,60 @@ describe("#473: the Redis federation store's plaintext guard, booted from the sh
|
|
|
486
483
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "allow-plaintext",
|
|
487
484
|
};
|
|
488
485
|
|
|
489
|
-
it('is refused under deployment.mode = "multi" in
|
|
490
|
-
// The umbrella shape with plaintext
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
486
|
+
it('is refused under core.deployment.mode = "multi" in an environment that is not production', async () => {
|
|
487
|
+
// The umbrella shape with plaintext. A multi-replica deployment is
|
|
488
|
+
// never a development box. A development configuration under multi is
|
|
489
|
+
// refused by the development mail sender first, so the environment here
|
|
490
|
+
// is another name that is not production, with the SMTP sender's section.
|
|
491
|
+
await expect(
|
|
492
|
+
boot({ ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() }, "test"),
|
|
493
|
+
).rejects.toSatisfy((err: unknown) =>
|
|
494
|
+
/allow-plaintext[\s\S]*core\.deployment\.mode is "multi"/.test(messageChain(err)),
|
|
495
495
|
);
|
|
496
496
|
expect(errorSpy).not.toHaveBeenCalled();
|
|
497
497
|
});
|
|
498
498
|
|
|
499
499
|
it("is refused when the config was selected by CONFIG_ENV=production, whatever NODE_ENV says", async () => {
|
|
500
500
|
// `app.mts` selects `production.conf` by `CONFIG_ENV || NODE_ENV` and
|
|
501
|
-
// passes that name through `buildModules`; the guard
|
|
502
|
-
// NODE_ENV alone
|
|
503
|
-
|
|
501
|
+
// passes that name through `buildModules`; the guard reads that name,
|
|
502
|
+
// not NODE_ENV alone.
|
|
503
|
+
// Under production the template installs the SMTP sender's module, whose
|
|
504
|
+
// section the package's builder carries.
|
|
505
|
+
const config = {
|
|
506
|
+
...resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" }),
|
|
507
|
+
...standardSmtpMailSenderConfigForTests(),
|
|
508
|
+
};
|
|
504
509
|
await expect(boot(config, "production")).rejects.toSatisfy((err: unknown) =>
|
|
505
|
-
/the environment is "production"/.test(messageChain(err)),
|
|
510
|
+
/allow-plaintext[\s\S]*the environment is "production"/.test(messageChain(err)),
|
|
506
511
|
);
|
|
507
512
|
});
|
|
508
513
|
|
|
509
514
|
it("boots with the plaintext warning in a development environment on a single replica", async () => {
|
|
510
|
-
const config = resolveConfig({ ...PLAINTEXT_ENV,
|
|
515
|
+
const config = resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" });
|
|
511
516
|
handleRef = await boot(config, "development");
|
|
512
517
|
expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
|
|
513
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
518
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
519
|
+
{ store: "federation-tokens", mode: "allow-plaintext" },
|
|
520
|
+
"federation_store_plaintext",
|
|
521
|
+
);
|
|
514
522
|
});
|
|
515
523
|
|
|
516
|
-
it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, at
|
|
524
|
+
it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, logged at error", async () => {
|
|
517
525
|
process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
|
|
518
|
-
|
|
519
|
-
|
|
526
|
+
// Under multi the development mail sender refuses a development
|
|
527
|
+
// configuration, so the environment here is another name that is not
|
|
528
|
+
// production, with the SMTP sender's section its module reads.
|
|
529
|
+
handleRef = await boot(
|
|
530
|
+
{ ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() },
|
|
531
|
+
"test",
|
|
532
|
+
);
|
|
533
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
534
|
+
expect.objectContaining({
|
|
535
|
+
store: "federation-tokens",
|
|
536
|
+
deploymentMode: "multi",
|
|
537
|
+
override: "FEDERATION_TOKENS_ALLOW_INSECURE",
|
|
538
|
+
}),
|
|
539
|
+
"federation_store_plaintext_override",
|
|
540
|
+
);
|
|
520
541
|
});
|
|
521
542
|
});
|
|
@@ -0,0 +1,146 @@
|
|
|
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 template's composition, booted through core's `createApp` and mounted
|
|
19
|
+
* with no terminal handler of the template's after it — the router alone, as
|
|
20
|
+
* a composition root that copies nothing of `app.mts` mounts it.
|
|
21
|
+
*
|
|
22
|
+
* The router core returns answers a body parser's refusal and any error a
|
|
23
|
+
* route lets through itself. Left to Express's final handler, a host would
|
|
24
|
+
* answer with an HTML page carrying the stack outside production, where V8's
|
|
25
|
+
* JSON error quotes the body it could not parse.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { defineModule } from "@o3co/auth-provider-core";
|
|
29
|
+
import { Router } from "express";
|
|
30
|
+
import request from "supertest";
|
|
31
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
32
|
+
import {
|
|
33
|
+
basic,
|
|
34
|
+
type Composition,
|
|
35
|
+
compose,
|
|
36
|
+
JSON_TYPE,
|
|
37
|
+
KIB,
|
|
38
|
+
M2M,
|
|
39
|
+
padJson,
|
|
40
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
41
|
+
|
|
42
|
+
let current: Composition | undefined;
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await current?.handle.dispose();
|
|
46
|
+
current = undefined;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** A route that lets an error through, contributed as a deployment adds one. */
|
|
50
|
+
const throwingRouteModule = defineModule({
|
|
51
|
+
name: "test:throwing-route",
|
|
52
|
+
contributes: {
|
|
53
|
+
routes: [
|
|
54
|
+
() => {
|
|
55
|
+
const router = Router();
|
|
56
|
+
router.get("/", () => {
|
|
57
|
+
throw new Error("a store said something: escaped-error-marker");
|
|
58
|
+
});
|
|
59
|
+
return { id: "test:throwing-route", mountPath: "/throws", handler: router };
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** The composition, the router alone mounted. */
|
|
66
|
+
const boot = async (): Promise<Composition> => {
|
|
67
|
+
current = await compose({
|
|
68
|
+
terminalErrorHandler: false,
|
|
69
|
+
extraModules: () => [throwingRouteModule],
|
|
70
|
+
});
|
|
71
|
+
return current;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The routes that parse a body, and what each needs to reach its parser. */
|
|
75
|
+
const PARSING_ROUTES = [
|
|
76
|
+
["/oauth/token", { authorization: basic(M2M) }],
|
|
77
|
+
["/oauth/introspect", { authorization: basic(M2M) }],
|
|
78
|
+
["/session/login", {}],
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
const errorLines = (c: Composition) => c.logger.lines.filter((line) => line.level === "error");
|
|
82
|
+
|
|
83
|
+
describe("the composed router, mounted alone, answers a body parser's refusal", () => {
|
|
84
|
+
it.each(PARSING_ROUTES)(
|
|
85
|
+
"%s: malformed JSON is 400 in the envelope, nothing logged",
|
|
86
|
+
async (path, headers) => {
|
|
87
|
+
const c = await boot();
|
|
88
|
+
const res = await request(c.app)
|
|
89
|
+
.post(path)
|
|
90
|
+
.set(headers)
|
|
91
|
+
.set("Content-Type", JSON_TYPE)
|
|
92
|
+
.send('{"password":"body-secret-marker');
|
|
93
|
+
|
|
94
|
+
expect(res.status).toBe(400);
|
|
95
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
96
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
|
|
97
|
+
expect(res.text).not.toContain("body-secret-marker");
|
|
98
|
+
expect(res.text).not.toMatch(/ at /);
|
|
99
|
+
expect(errorLines(c)).toEqual([]);
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
it.each(PARSING_ROUTES)(
|
|
104
|
+
"%s: a body over the parser's limit is 413 in the envelope, nothing logged",
|
|
105
|
+
async (path, headers) => {
|
|
106
|
+
const c = await boot();
|
|
107
|
+
const res = await request(c.app)
|
|
108
|
+
.post(path)
|
|
109
|
+
.set(headers)
|
|
110
|
+
.set("Content-Type", JSON_TYPE)
|
|
111
|
+
.send(padJson(200 * KIB));
|
|
112
|
+
|
|
113
|
+
expect(res.status).toBe(413);
|
|
114
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
115
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
|
|
116
|
+
expect(errorLines(c)).toEqual([]);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("the composed router, mounted alone, answers an error a route let through", () => {
|
|
122
|
+
it("is 500 server_error in the envelope, logged once at error", async () => {
|
|
123
|
+
const c = await boot();
|
|
124
|
+
const res = await request(c.app).get("/throws");
|
|
125
|
+
|
|
126
|
+
expect(res.status).toBe(500);
|
|
127
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
128
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
129
|
+
expect(res.text).not.toContain("escaped-error-marker");
|
|
130
|
+
expect(errorLines(c)).toEqual([
|
|
131
|
+
{
|
|
132
|
+
level: "error",
|
|
133
|
+
args: [
|
|
134
|
+
{
|
|
135
|
+
endpoint: "/throws",
|
|
136
|
+
err: expect.objectContaining({
|
|
137
|
+
name: "Error",
|
|
138
|
+
detail: "a store said something: escaped-error-marker",
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
"unhandled_request_error",
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
});
|