@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
|
@@ -29,14 +29,22 @@ import {
|
|
|
29
29
|
memoryRefreshTokenFamilyStoreModule,
|
|
30
30
|
registerBuiltinKeyStores,
|
|
31
31
|
} from "@o3co/auth-provider-core";
|
|
32
|
+
import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
|
|
32
33
|
import express from "express";
|
|
33
34
|
import request from "supertest";
|
|
34
|
-
import { afterEach, describe, expect, it
|
|
35
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
35
36
|
import { buildModules } from "../buildModules.mjs";
|
|
37
|
+
import type { Switches } from "../configPath.mjs";
|
|
38
|
+
import type { Adapters } from "../sections.mjs";
|
|
39
|
+
import {
|
|
40
|
+
capturedRenames,
|
|
41
|
+
inProcessAdapters,
|
|
42
|
+
shippedAdapters,
|
|
43
|
+
} from "./library-references.fixture.mjs";
|
|
36
44
|
|
|
37
45
|
const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
|
|
38
46
|
|
|
39
|
-
//
|
|
47
|
+
// The template's shipped default is EdDSA with a published JWKS, so the
|
|
40
48
|
// smoke test signs the way a real deployment of this scaffold does. Generated
|
|
41
49
|
// per run — no key material is committed.
|
|
42
50
|
const smokeKeyPair = generateKeyPairSync("ed25519", {
|
|
@@ -44,22 +52,40 @@ const smokeKeyPair = generateKeyPairSync("ed25519", {
|
|
|
44
52
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
45
53
|
});
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
/** The `http` module's section, as the hand-built configuration below carries it. */
|
|
56
|
+
const HTTP = {
|
|
57
|
+
port: 0,
|
|
58
|
+
trustProxy: false,
|
|
59
|
+
readinessTimeoutMs: 1000,
|
|
60
|
+
cors: { allowedOrigins: [] as string[] },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
|
|
64
|
+
const config: AppConfig & Record<string, unknown> = {
|
|
65
|
+
// What a resolution under an environment that sets none captures of
|
|
66
|
+
// core's renamed variables.
|
|
67
|
+
...{
|
|
68
|
+
"renamed-variables": capturedRenames({}),
|
|
69
|
+
},
|
|
70
|
+
http: HTTP,
|
|
49
71
|
logging: { level: "silent" },
|
|
72
|
+
"key-store": {
|
|
73
|
+
provider: "local",
|
|
74
|
+
local: {
|
|
75
|
+
algorithm: "EdDSA",
|
|
76
|
+
kid: "v0",
|
|
77
|
+
privateKey: smokeKeyPair.privateKey,
|
|
78
|
+
publicKey: smokeKeyPair.publicKey,
|
|
79
|
+
previousKeys: [],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
// A composition with a consumer of session admission states what it
|
|
83
|
+
// expects (ADR 2026-09-28-session-admission): the shipped
|
|
84
|
+
// `application.conf` expects none, and so does this hand-built config.
|
|
85
|
+
...coreConfigForTests({ federations: { google: { enabled: false } } }),
|
|
50
86
|
oauth: {
|
|
51
87
|
jwt: {
|
|
52
88
|
issuer: "https://auth.test",
|
|
53
|
-
signingKey: {
|
|
54
|
-
provider: "local",
|
|
55
|
-
local: {
|
|
56
|
-
algorithm: "EdDSA",
|
|
57
|
-
kid: "v0",
|
|
58
|
-
privateKey: smokeKeyPair.privateKey,
|
|
59
|
-
publicKey: smokeKeyPair.publicKey,
|
|
60
|
-
previousKeys: [],
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
89
|
},
|
|
64
90
|
accessToken: { expiresIn: 3600 },
|
|
65
91
|
refreshToken: {
|
|
@@ -69,16 +95,10 @@ const config: AppConfig = {
|
|
|
69
95
|
},
|
|
70
96
|
grants: {},
|
|
71
97
|
oidcMode: "oidc-required",
|
|
72
|
-
//
|
|
73
|
-
// flag was removed; the first-party invariant is unconditional.
|
|
74
|
-
// OR-9: explicit memory adapter for the smoke test. The legacy
|
|
75
|
-
// `repositories.code.type` fallback would also pick memory here,
|
|
76
|
-
// but the explicit setting documents the intent and avoids
|
|
77
|
-
// console.warn deprecation noise in the test output.
|
|
78
|
-
code: { adapter: "memory" as const },
|
|
98
|
+
// No `authorize` section: the first-party invariant is unconditional.
|
|
79
99
|
},
|
|
80
|
-
session: {
|
|
81
|
-
//
|
|
100
|
+
"session-store": {
|
|
101
|
+
// `session-store.secret` carries a 256-bit entropy floor.
|
|
82
102
|
secret: "test-session-secret.at-least-32-bytes.ok",
|
|
83
103
|
name: "auth.sid",
|
|
84
104
|
maxAge: 3600000,
|
|
@@ -87,53 +107,45 @@ const config: AppConfig = {
|
|
|
87
107
|
domain: null,
|
|
88
108
|
storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
|
|
89
109
|
},
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
},
|
|
94
|
-
federations: {
|
|
95
|
-
google: { enabled: false },
|
|
96
|
-
},
|
|
97
|
-
repositories: {
|
|
98
|
-
client: { type: "yaml", path: "./config/clients.yaml" },
|
|
99
|
-
user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
|
|
100
|
-
code: { type: "memory", defaultExpiresIn: 600 },
|
|
101
|
-
},
|
|
102
|
-
endpoints: {
|
|
103
|
-
login: { url: "/login" },
|
|
110
|
+
session: {
|
|
111
|
+
loginPage: { url: "/login" },
|
|
112
|
+
rateLimit: { login: { windowMs: 60000, limit: 10 } },
|
|
104
113
|
},
|
|
105
|
-
|
|
114
|
+
rateLimit: { failMode: "open" },
|
|
115
|
+
"standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
|
|
106
116
|
};
|
|
107
117
|
|
|
118
|
+
/** What phase one hands `buildModules`: the configuration, with `changes` over every store in process. */
|
|
119
|
+
const switchesWith = (changes: Partial<Adapters> = {}): Switches =>
|
|
120
|
+
({
|
|
121
|
+
...config,
|
|
122
|
+
adapters: { ...inProcessAdapters(), ...changes },
|
|
123
|
+
mfaMode: "off",
|
|
124
|
+
}) as unknown as Switches;
|
|
125
|
+
|
|
126
|
+
const switches = switchesWith();
|
|
127
|
+
|
|
108
128
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* Post-OR-9 (Wave 5d): `codeRepository` is no longer provided here — it is
|
|
114
|
-
* wired by `inMemoryCodeRepositoryModule` (or `redisCodeRepositoryModule`)
|
|
115
|
-
* via the `oauth.code.adapter` switch in `buildModules`. The smoke test
|
|
116
|
-
* config sets `oauth.code.adapter = "memory"`, so `inMemoryCodeRepositoryModule`
|
|
117
|
-
* is added automatically and provides the slot.
|
|
118
|
-
*
|
|
119
|
-
* D-6 (v0.5.1): a single confidential client is registered so the
|
|
120
|
-
* `clientAuthMw` middleware in front of `/oauth/token` has a record to
|
|
121
|
-
* authenticate against. Smoke tests that hit `/oauth/token` send
|
|
122
|
-
* `Authorization: ${SMOKE_BASIC_AUTH}`.
|
|
129
|
+
* The one confidential client, so the `clientAuthMw` middleware in front of
|
|
130
|
+
* `/oauth/token` has a record to authenticate against. Smoke tests that hit
|
|
131
|
+
* `/oauth/token` send `Authorization: ${SMOKE_BASIC_AUTH}`.
|
|
123
132
|
*/
|
|
124
133
|
const SMOKE_CLIENT_ID = "smoke-client";
|
|
125
134
|
const SMOKE_CLIENT_SECRET = "smoke-secret";
|
|
126
135
|
const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
|
|
127
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Test-only repository module in place of the file-system-backed repositories
|
|
139
|
+
* `repositoriesModule` provides in production: the smoke tests verify the
|
|
140
|
+
* boot pipeline shape, not the data layer. `codeRepository` is not provided
|
|
141
|
+
* here; the `adapters.codeRepository` selection in `buildModules` wires it.
|
|
142
|
+
*/
|
|
128
143
|
const testRepositoriesModule = defineModule({
|
|
129
144
|
name: "test:repositories",
|
|
130
145
|
provides: {
|
|
131
|
-
// `
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
// output even though they are optional on input. The fixture supplies
|
|
135
|
-
// every field explicitly so the literal satisfies the output shape
|
|
136
|
-
// without requiring a cast.
|
|
146
|
+
// The map takes `ClientEntry`, the schema's OUTPUT type, where fields
|
|
147
|
+
// with `.default(...)` are required; every field is supplied so the
|
|
148
|
+
// literal needs no cast.
|
|
137
149
|
clientRepository: () =>
|
|
138
150
|
new InMemoryClientRepository(
|
|
139
151
|
new Map([
|
|
@@ -165,7 +177,7 @@ const testKeyStoreModule = defineModule({
|
|
|
165
177
|
registerBuiltinKeyStores(factory);
|
|
166
178
|
return factory.create({
|
|
167
179
|
type: "local",
|
|
168
|
-
...((c as
|
|
180
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
169
181
|
});
|
|
170
182
|
},
|
|
171
183
|
},
|
|
@@ -176,14 +188,11 @@ describe("standalone smoke test", () => {
|
|
|
176
188
|
|
|
177
189
|
async function buildApp() {
|
|
178
190
|
const handle = await createApp({
|
|
179
|
-
modules: buildModules(
|
|
191
|
+
modules: buildModules(switches, {
|
|
180
192
|
keyStoreModule: testKeyStoreModule,
|
|
181
193
|
repositoriesModule: testRepositoriesModule,
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// with `redisRefreshTokenFamilyStoreModule`; this override replaces
|
|
185
|
-
// the pair entirely with a single memory-backed store module so no
|
|
186
|
-
// real ioredis connection is opened during CI unit tests.
|
|
194
|
+
// A memory RT family store in place of the default Redis store and
|
|
195
|
+
// its client, so no real ioredis connection is opened.
|
|
187
196
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
188
197
|
}),
|
|
189
198
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
@@ -194,7 +203,7 @@ describe("standalone smoke test", () => {
|
|
|
194
203
|
app.use(
|
|
195
204
|
createReadinessRouter(express, {
|
|
196
205
|
probes: handle.readinessProbes,
|
|
197
|
-
timeoutMs:
|
|
206
|
+
timeoutMs: HTTP.readinessTimeoutMs,
|
|
198
207
|
}),
|
|
199
208
|
);
|
|
200
209
|
app.use(handle.router);
|
|
@@ -207,16 +216,11 @@ describe("standalone smoke test", () => {
|
|
|
207
216
|
});
|
|
208
217
|
|
|
209
218
|
it("Dockerfile declares runtime metadata and runs install/build steps as node", () => {
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// default.
|
|
216
|
-
// The literal `${HTTP_PORT}` token from the Dockerfile is split across
|
|
217
|
-
// concatenations to sidestep biome's `noTemplateCurlyInString` rule
|
|
218
|
-
// (it cannot statically tell that this string is intentionally NOT
|
|
219
|
-
// a JS template placeholder — it is a Dockerfile env-substitution).
|
|
219
|
+
// HEALTHCHECK and EXPOSE must read the port from ${HTTP_PORT} so an
|
|
220
|
+
// operator overriding it keeps the app, EXPOSE and the healthcheck in
|
|
221
|
+
// sync; they are asserted by that token, not the literal port. The token
|
|
222
|
+
// is built by concatenation to sidestep biome's
|
|
223
|
+
// `noTemplateCurlyInString`: it is a Dockerfile substitution, not JS.
|
|
220
224
|
const HTTP_PORT_VAR = `$${"{"}HTTP_PORT}`;
|
|
221
225
|
expect(dockerfile).toContain("ENV HTTP_PORT=3000");
|
|
222
226
|
expect(dockerfile).toContain(`EXPOSE ${HTTP_PORT_VAR}`);
|
|
@@ -233,7 +237,7 @@ describe("standalone smoke test", () => {
|
|
|
233
237
|
/FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
|
|
234
238
|
);
|
|
235
239
|
expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
|
|
236
|
-
//
|
|
240
|
+
// Dependencies are resolved exactly once, in `deps`. The runtime
|
|
237
241
|
// stage must take its node_modules from a prune of that same tree — a
|
|
238
242
|
// second `pnpm install` would be a second resolution, so its absence
|
|
239
243
|
// after the runtime FROM is part of the contract.
|
|
@@ -248,12 +252,12 @@ describe("standalone smoke test", () => {
|
|
|
248
252
|
);
|
|
249
253
|
});
|
|
250
254
|
|
|
251
|
-
it("Dockerfile pins its mutable build inputs
|
|
255
|
+
it("Dockerfile pins its mutable build inputs", () => {
|
|
252
256
|
// Base image by digest — the tag alone is a moving pointer, and
|
|
253
257
|
// Dependabot's docker ecosystem bumps tag and digest together.
|
|
254
258
|
expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
|
|
255
259
|
// Global corepack by version — `npm install -g corepack` with no pin
|
|
256
|
-
//
|
|
260
|
+
// resolves whatever is latest at build time.
|
|
257
261
|
expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
|
|
258
262
|
// The lockfile is a required build input for --frozen-lockfile.
|
|
259
263
|
expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
|
|
@@ -290,7 +294,7 @@ describe("standalone smoke test", () => {
|
|
|
290
294
|
},
|
|
291
295
|
},
|
|
292
296
|
],
|
|
293
|
-
timeoutMs:
|
|
297
|
+
timeoutMs: HTTP.readinessTimeoutMs,
|
|
294
298
|
}),
|
|
295
299
|
);
|
|
296
300
|
const res = await request(app).get("/readyz");
|
|
@@ -300,15 +304,11 @@ describe("standalone smoke test", () => {
|
|
|
300
304
|
});
|
|
301
305
|
|
|
302
306
|
it("issuer-configured buildModules serves the advertised jwks_uri (discovery <-> JWKS presence contract)", async () => {
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// presence contract at boot (missing `jwks_uri`) — this test fails (RED) in
|
|
309
|
-
// that case. (The oauth package's integration test hand-composes both
|
|
310
|
-
// modules, so only a scaffold-level test like this can catch the
|
|
311
|
-
// composition root forgetting to wire jwksModule.)
|
|
307
|
+
// Against the REAL composition root: core's `jwksModule` contributes
|
|
308
|
+
// `jwks_uri` to discovery AND mounts the JWKS route. Without it the
|
|
309
|
+
// issuer-configured composition fails the discovery presence contract
|
|
310
|
+
// at boot. The oauth package's integration test composes both modules by
|
|
311
|
+
// hand, so only a scaffold-level test catches `buildModules` dropping it.
|
|
312
312
|
const issuerConfig: AppConfig = {
|
|
313
313
|
...config,
|
|
314
314
|
oauth: {
|
|
@@ -317,11 +317,14 @@ describe("standalone smoke test", () => {
|
|
|
317
317
|
},
|
|
318
318
|
};
|
|
319
319
|
const handle = await createApp({
|
|
320
|
-
modules: buildModules(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
320
|
+
modules: buildModules(
|
|
321
|
+
{ ...switches, oauth: issuerConfig.oauth },
|
|
322
|
+
{
|
|
323
|
+
keyStoreModule: testKeyStoreModule,
|
|
324
|
+
repositoriesModule: testRepositoriesModule,
|
|
325
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
326
|
+
},
|
|
327
|
+
),
|
|
325
328
|
bootstrapComponents: { config: issuerConfig, pathResolver: (s) => s },
|
|
326
329
|
});
|
|
327
330
|
handleRef = handle;
|
|
@@ -333,7 +336,7 @@ describe("standalone smoke test", () => {
|
|
|
333
336
|
const res = await request(app).get(jwksPath);
|
|
334
337
|
expect(res.status).toBe(200);
|
|
335
338
|
expect(Array.isArray(res.body.keys)).toBe(true);
|
|
336
|
-
//
|
|
339
|
+
// Presence is not enough — the advertised jwks_uri must
|
|
337
340
|
// publish an actual verification key, and never the private half.
|
|
338
341
|
expect(res.body.keys).toHaveLength(1);
|
|
339
342
|
expect(res.body.keys[0].alg).toBe("EdDSA");
|
|
@@ -364,27 +367,22 @@ describe("standalone smoke test", () => {
|
|
|
364
367
|
expect(res.headers["cache-control"]).not.toBe("no-store");
|
|
365
368
|
});
|
|
366
369
|
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
// bridge
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
// on the manifest list — a regression that bypasses the gate fails the
|
|
375
|
-
// `not.toContain` even if `googleFederationConfigModule` is later made
|
|
376
|
-
// tolerant of `undefined` (which would otherwise mask the gating bug).
|
|
377
|
-
// The second sub-assertion verifies the resulting handle still boots
|
|
378
|
-
// (Codex Round 1 P1 reproducibility).
|
|
370
|
+
// A freshly scaffolded app must boot under the default
|
|
371
|
+
// `core.federations.google.enabled = false`. `buildModules` includes
|
|
372
|
+
// `googleFederationModule` and its config bridge only when enabled, since
|
|
373
|
+
// the bridge throws at boot when `extractFederationSection` returns
|
|
374
|
+
// undefined. The manifest is asserted directly, so a bypassed gate fails
|
|
375
|
+
// even if the bridge is later made tolerant of `undefined`; then the
|
|
376
|
+
// handle must still boot.
|
|
379
377
|
it("boots when google federation is disabled (default scaffold config)", async () => {
|
|
380
|
-
const modules = buildModules(
|
|
378
|
+
const modules = buildModules(switches, {
|
|
381
379
|
keyStoreModule: testKeyStoreModule,
|
|
382
380
|
repositoriesModule: testRepositoriesModule,
|
|
383
381
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
384
382
|
});
|
|
385
383
|
const moduleNames = modules.map((m) => m.name);
|
|
386
|
-
expect(moduleNames).not.toContain("federation
|
|
387
|
-
expect(moduleNames).not.toContain("
|
|
384
|
+
expect(moduleNames).not.toContain("federation-google");
|
|
385
|
+
expect(moduleNames).not.toContain("google-federation-config");
|
|
388
386
|
|
|
389
387
|
const handle = await createApp({
|
|
390
388
|
modules,
|
|
@@ -394,40 +392,36 @@ describe("standalone smoke test", () => {
|
|
|
394
392
|
expect(handle).toBeDefined();
|
|
395
393
|
});
|
|
396
394
|
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
// above (which substitute the memory store for CI).
|
|
403
|
-
describe("D-2 v2 + Wave 5d: redis-clients + adapter wiring", () => {
|
|
395
|
+
// The composition root must default to the Redis-backed RT family store
|
|
396
|
+
// and the ioredis client module, NOT the in-memory store: multi-replica
|
|
397
|
+
// deployments lose RT family persistence when each replica holds families
|
|
398
|
+
// in-process. The smoke tests above exercise the memory override.
|
|
399
|
+
describe("redis-clients + adapter wiring", () => {
|
|
404
400
|
it("buildModules includes the shared redis-clients module + redis store by default", () => {
|
|
405
|
-
const modules = buildModules(
|
|
401
|
+
const modules = buildModules(switches, {
|
|
406
402
|
keyStoreModule: testKeyStoreModule,
|
|
407
403
|
repositoriesModule: testRepositoriesModule,
|
|
408
404
|
});
|
|
409
405
|
const names = modules.map((m) => m.name);
|
|
410
|
-
|
|
411
|
-
// `standalone:refresh-token-family-client` in PR1).
|
|
412
|
-
expect(names).toContain("standalone:redis-clients");
|
|
406
|
+
expect(names).toContain("redis-clients");
|
|
413
407
|
expect(names).toContain("redis-refresh-token-family-store");
|
|
414
408
|
expect(names).not.toContain("core-refresh-token-family-store-memory");
|
|
415
409
|
});
|
|
416
410
|
|
|
417
411
|
it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
|
|
418
|
-
const modules = buildModules(
|
|
412
|
+
const modules = buildModules(switches, {
|
|
419
413
|
keyStoreModule: testKeyStoreModule,
|
|
420
414
|
repositoriesModule: testRepositoriesModule,
|
|
421
415
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
422
416
|
});
|
|
423
417
|
const names = modules.map((m) => m.name);
|
|
424
418
|
expect(names).toContain("core-refresh-token-family-store-memory");
|
|
425
|
-
expect(names).not.toContain("
|
|
419
|
+
expect(names).not.toContain("redis-clients");
|
|
426
420
|
expect(names).not.toContain("redis-refresh-token-family-store");
|
|
427
421
|
});
|
|
428
422
|
|
|
429
|
-
it("buildModules wires memoryRateLimiterModule by default
|
|
430
|
-
const modules = buildModules(
|
|
423
|
+
it("buildModules wires memoryRateLimiterModule by default", () => {
|
|
424
|
+
const modules = buildModules(switches, {
|
|
431
425
|
keyStoreModule: testKeyStoreModule,
|
|
432
426
|
repositoriesModule: testRepositoriesModule,
|
|
433
427
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
@@ -437,12 +431,8 @@ describe("standalone smoke test", () => {
|
|
|
437
431
|
expect(names).not.toContain("redis-rate-limiter");
|
|
438
432
|
});
|
|
439
433
|
|
|
440
|
-
it("buildModules switches to redisRateLimiterModule when rateLimiter
|
|
441
|
-
const
|
|
442
|
-
...config,
|
|
443
|
-
rateLimiter: { adapter: "redis" as const },
|
|
444
|
-
};
|
|
445
|
-
const modules = buildModules(redisRlConfig, {
|
|
434
|
+
it("buildModules switches to redisRateLimiterModule when adapters.rateLimiter = 'redis'", () => {
|
|
435
|
+
const modules = buildModules(switchesWith({ rateLimiter: "redis" }), {
|
|
446
436
|
keyStoreModule: testKeyStoreModule,
|
|
447
437
|
repositoriesModule: testRepositoriesModule,
|
|
448
438
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
@@ -451,44 +441,35 @@ describe("standalone smoke test", () => {
|
|
|
451
441
|
expect(names).toContain("redis-rate-limiter");
|
|
452
442
|
expect(names).not.toContain("core-rate-limiter-memory");
|
|
453
443
|
// Adapter = redis pulls in the shared redis-clients module too.
|
|
454
|
-
expect(names).toContain("
|
|
444
|
+
expect(names).toContain("redis-clients");
|
|
455
445
|
});
|
|
456
446
|
|
|
457
|
-
it("buildModules switches to redisSessionStoresModule when userSessionStores
|
|
458
|
-
const
|
|
459
|
-
...config,
|
|
460
|
-
userSessionStores: { adapter: "redis" as const },
|
|
461
|
-
};
|
|
462
|
-
const modules = buildModules(redisSessConfig, {
|
|
447
|
+
it("buildModules switches to redisSessionStoresModule when adapters.userSessionStores = 'redis'", () => {
|
|
448
|
+
const modules = buildModules(switchesWith({ userSessionStores: "redis" }), {
|
|
463
449
|
keyStoreModule: testKeyStoreModule,
|
|
464
450
|
repositoriesModule: testRepositoriesModule,
|
|
465
451
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
466
452
|
});
|
|
467
453
|
const names = modules.map((m) => m.name);
|
|
468
|
-
expect(names).toContain("
|
|
469
|
-
expect(names).not.toContain("
|
|
470
|
-
expect(names).toContain("
|
|
454
|
+
expect(names).toContain("redis-session-stores");
|
|
455
|
+
expect(names).not.toContain("stores");
|
|
456
|
+
expect(names).toContain("redis-clients");
|
|
471
457
|
});
|
|
472
458
|
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
rateLimiter: { adapter: "redis" as const },
|
|
488
|
-
accessTokenDenylist: { adapter: "redis" as const },
|
|
489
|
-
consentStore: { adapter: "redis" as const },
|
|
490
|
-
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
491
|
-
};
|
|
459
|
+
// The tests above check which modules are selected, not whether the
|
|
460
|
+
// selection is satisfiable: a `*Client` slot with no provider (such as
|
|
461
|
+
// `redisSessionStoresModule`'s `subjectSessionIndexClient`) fails
|
|
462
|
+
// stage-1 boot with `missing-required-component`. The invariant the
|
|
463
|
+
// shared clients module exists for: every `*Client` slot any selected
|
|
464
|
+
// module requires is one it provides.
|
|
465
|
+
it("redis-clients provides every *Client slot the Redis branches require", () => {
|
|
466
|
+
const allRedisConfig = switchesWith({
|
|
467
|
+
userSessionStores: "redis",
|
|
468
|
+
rateLimiter: "redis",
|
|
469
|
+
accessTokenDenylist: "redis",
|
|
470
|
+
consentStore: "redis",
|
|
471
|
+
codeRepository: "redis",
|
|
472
|
+
});
|
|
492
473
|
// No `refreshTokenFamilyModules` override: the default is the Redis
|
|
493
474
|
// RT-family store, so `refreshTokenFamilyClient` is part of what the
|
|
494
475
|
// invariant covers. Still hermetic — buildModules opens no socket.
|
|
@@ -496,7 +477,7 @@ describe("standalone smoke test", () => {
|
|
|
496
477
|
keyStoreModule: testKeyStoreModule,
|
|
497
478
|
repositoriesModule: testRepositoriesModule,
|
|
498
479
|
});
|
|
499
|
-
const clients = modules.find((m) => m.name === "
|
|
480
|
+
const clients = modules.find((m) => m.name === "redis-clients");
|
|
500
481
|
expect(clients).toBeDefined();
|
|
501
482
|
const provided = new Set(Object.keys(clients?.provides ?? {}));
|
|
502
483
|
const requiredClientSlots = new Set(
|
|
@@ -508,14 +489,14 @@ describe("standalone smoke test", () => {
|
|
|
508
489
|
});
|
|
509
490
|
});
|
|
510
491
|
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
describe("
|
|
492
|
+
// Without an access-token denylist, `/oauth/revoke` would answer 200 for
|
|
493
|
+
// an access token and leave the JWT working until expiry. The template
|
|
494
|
+
// always wires one, and its own application.conf selects the Redis-backed
|
|
495
|
+
// adapter — the memory one forks per replica and `core.deployment.mode =
|
|
496
|
+
// "multi"` refuses it.
|
|
497
|
+
describe("access-token denylist wiring", () => {
|
|
517
498
|
it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
|
|
518
|
-
const modules = buildModules(
|
|
499
|
+
const modules = buildModules(switches, {
|
|
519
500
|
keyStoreModule: testKeyStoreModule,
|
|
520
501
|
repositoriesModule: testRepositoriesModule,
|
|
521
502
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
@@ -527,7 +508,7 @@ describe("standalone smoke test", () => {
|
|
|
527
508
|
});
|
|
528
509
|
|
|
529
510
|
it("defaults to the memory denylist when no adapter is configured", () => {
|
|
530
|
-
const modules = buildModules(
|
|
511
|
+
const modules = buildModules(switches, {
|
|
531
512
|
keyStoreModule: testKeyStoreModule,
|
|
532
513
|
repositoriesModule: testRepositoriesModule,
|
|
533
514
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
@@ -537,12 +518,8 @@ describe("standalone smoke test", () => {
|
|
|
537
518
|
expect(names).not.toContain("redis-access-token-denylist");
|
|
538
519
|
});
|
|
539
520
|
|
|
540
|
-
it("switches to the Redis denylist when accessTokenDenylist
|
|
541
|
-
const
|
|
542
|
-
...config,
|
|
543
|
-
accessTokenDenylist: { adapter: "redis" as const },
|
|
544
|
-
};
|
|
545
|
-
const modules = buildModules(redisDenylistConfig, {
|
|
521
|
+
it("switches to the Redis denylist when adapters.accessTokenDenylist = 'redis'", () => {
|
|
522
|
+
const modules = buildModules(switchesWith({ accessTokenDenylist: "redis" }), {
|
|
546
523
|
keyStoreModule: testKeyStoreModule,
|
|
547
524
|
repositoriesModule: testRepositoriesModule,
|
|
548
525
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
@@ -553,25 +530,24 @@ describe("standalone smoke test", () => {
|
|
|
553
530
|
// The Redis branch must also pull in the shared ioredis socket, or the
|
|
554
531
|
// `accessTokenDenylistClient` slot has no provider and boot fails on a
|
|
555
532
|
// missing component instead of on the thing the operator changed.
|
|
556
|
-
expect(names).toContain("
|
|
533
|
+
expect(names).toContain("redis-clients");
|
|
557
534
|
});
|
|
558
535
|
|
|
559
|
-
it("the
|
|
536
|
+
it("the template's reference.conf selects the replica-safe adapter", () => {
|
|
560
537
|
// The template's own config is the artifact operators deploy. It runs
|
|
561
|
-
// with `deployment.mode = multi` in the umbrella E2E, which refuses
|
|
538
|
+
// with `core.deployment.mode = multi` in the umbrella E2E, which refuses
|
|
562
539
|
// every in-memory shared store — so shipping the memory denylist here
|
|
563
540
|
// would be a boot failure in the very stack that proves the scaffold
|
|
564
541
|
// works.
|
|
565
|
-
|
|
566
|
-
expect(conf).toMatch(/accessTokenDenylist\s*\{[\s\S]*?adapter\s*=\s*"redis"/);
|
|
542
|
+
expect(shippedAdapters().accessTokenDenylist).toBe("redis");
|
|
567
543
|
});
|
|
568
544
|
|
|
569
545
|
it("keeps the composition boot-valid end to end", async () => {
|
|
570
|
-
// The core boot guard
|
|
546
|
+
// The core boot guard refuses a composition that reads
|
|
571
547
|
// the denylist slot without one. A scaffold that trips its own library's
|
|
572
548
|
// guard is not a scaffold.
|
|
573
549
|
const handle = await createApp({
|
|
574
|
-
modules: buildModules(
|
|
550
|
+
modules: buildModules(switches, {
|
|
575
551
|
keyStoreModule: testKeyStoreModule,
|
|
576
552
|
repositoriesModule: testRepositoriesModule,
|
|
577
553
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
@@ -583,81 +559,51 @@ describe("standalone smoke test", () => {
|
|
|
583
559
|
});
|
|
584
560
|
});
|
|
585
561
|
|
|
586
|
-
//
|
|
587
|
-
// the
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
it("buildModules wires inMemoryCodeRepositoryModule by default (oauth.code.adapter = 'memory')", () => {
|
|
593
|
-
const modules = buildModules(config, {
|
|
562
|
+
// The OAuth code repository's selection: the memory branch wires
|
|
563
|
+
// `inMemoryCodeRepositoryModule`; the redis branch wires
|
|
564
|
+
// `redisCodeRepositoryModule` against the shared ioredis socket.
|
|
565
|
+
describe("code-repository adapter wiring", () => {
|
|
566
|
+
it("buildModules wires inMemoryCodeRepositoryModule when adapters.codeRepository = 'memory'", () => {
|
|
567
|
+
const modules = buildModules(switches, {
|
|
594
568
|
keyStoreModule: testKeyStoreModule,
|
|
595
569
|
repositoriesModule: testRepositoriesModule,
|
|
596
570
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
597
571
|
});
|
|
598
572
|
const names = modules.map((m) => m.name);
|
|
599
|
-
expect(names).toContain("standalone
|
|
573
|
+
expect(names).toContain("standalone-in-memory-code-repository");
|
|
600
574
|
expect(names).not.toContain("redis-code-repository");
|
|
601
575
|
});
|
|
602
576
|
|
|
603
|
-
it("buildModules switches to redisCodeRepositoryModule when
|
|
604
|
-
const
|
|
605
|
-
...config,
|
|
606
|
-
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
607
|
-
};
|
|
608
|
-
const modules = buildModules(redisCodeConfig, {
|
|
577
|
+
it("buildModules switches to redisCodeRepositoryModule when adapters.codeRepository = 'redis'", () => {
|
|
578
|
+
const modules = buildModules(switchesWith({ codeRepository: "redis" }), {
|
|
609
579
|
keyStoreModule: testKeyStoreModule,
|
|
610
580
|
repositoriesModule: testRepositoriesModule,
|
|
611
581
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
612
582
|
});
|
|
613
583
|
const names = modules.map((m) => m.name);
|
|
614
584
|
expect(names).toContain("redis-code-repository");
|
|
615
|
-
expect(names).not.toContain("standalone
|
|
585
|
+
expect(names).not.toContain("standalone-in-memory-code-repository");
|
|
616
586
|
// adapter = "redis" pulls in the shared ioredis socket so the
|
|
617
587
|
// `codeRepositoryClient` slot is satisfied.
|
|
618
|
-
expect(names).toContain("
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
|
|
622
|
-
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
623
|
-
try {
|
|
624
|
-
const legacyConfig = {
|
|
625
|
-
...config,
|
|
626
|
-
oauth: { ...config.oauth, code: undefined },
|
|
627
|
-
repositories: {
|
|
628
|
-
...config.repositories,
|
|
629
|
-
code: { type: "redis" as const, defaultExpiresIn: 600 },
|
|
630
|
-
},
|
|
631
|
-
};
|
|
632
|
-
const modules = buildModules(legacyConfig, {
|
|
633
|
-
keyStoreModule: testKeyStoreModule,
|
|
634
|
-
repositoriesModule: testRepositoriesModule,
|
|
635
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
636
|
-
});
|
|
637
|
-
const names = modules.map((m) => m.name);
|
|
638
|
-
expect(names).toContain("redis-code-repository");
|
|
639
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
|
|
640
|
-
} finally {
|
|
641
|
-
warnSpy.mockRestore();
|
|
642
|
-
}
|
|
588
|
+
expect(names).toContain("redis-clients");
|
|
643
589
|
});
|
|
644
590
|
|
|
645
591
|
it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
// `
|
|
649
|
-
//
|
|
650
|
-
const modules = buildModules(
|
|
592
|
+
// The `repositories` module must NOT provide `codeRepository` — the
|
|
593
|
+
// slot is owned by `inMemoryCodeRepositoryModule` or
|
|
594
|
+
// `redisCodeRepositoryModule` exclusively, selected via
|
|
595
|
+
// `adapters.codeRepository`.
|
|
596
|
+
const modules = buildModules(switches, {
|
|
651
597
|
keyStoreModule: testKeyStoreModule,
|
|
652
|
-
//
|
|
653
|
-
//
|
|
598
|
+
// The production `repositories` module (not the test override), so
|
|
599
|
+
// its own provides are exercised.
|
|
654
600
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
655
601
|
});
|
|
656
602
|
const codeRepoProviders = modules.filter((m) =>
|
|
657
603
|
Object.keys(m.provides ?? {}).includes("codeRepository"),
|
|
658
604
|
);
|
|
659
605
|
expect(codeRepoProviders).toHaveLength(1);
|
|
660
|
-
expect(codeRepoProviders[0]?.name).toBe("standalone
|
|
606
|
+
expect(codeRepoProviders[0]?.name).toBe("standalone-in-memory-code-repository");
|
|
661
607
|
});
|
|
662
608
|
});
|
|
663
609
|
|
|
@@ -669,10 +615,10 @@ describe("standalone smoke test", () => {
|
|
|
669
615
|
registerBuiltinKeyStores(ksf);
|
|
670
616
|
const keyStore = await ksf.create({
|
|
671
617
|
type: "local",
|
|
672
|
-
...(config
|
|
618
|
+
...(config["key-store"] as { local: object }).local,
|
|
673
619
|
});
|
|
674
620
|
// The token must carry the deployment's configured `iss`: introspection
|
|
675
|
-
// pins it (RFC 9068 §4), and
|
|
621
|
+
// pins it (RFC 9068 §4), and every deployment has one.
|
|
676
622
|
const { token } = await generateToken(
|
|
677
623
|
{},
|
|
678
624
|
{
|