@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,288 @@
|
|
|
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
|
+
* `POST /session/logout` must invalidate the token the `session` grant minted
|
|
19
|
+
* from the same browser session.
|
|
20
|
+
*
|
|
21
|
+
* This lives here rather than in `packages/session` or `packages/oauth`
|
|
22
|
+
* because it is the one assertion neither package can make alone: the logout
|
|
23
|
+
* endpoint is in `@o3co/auth-provider-session`, the introspection and userinfo
|
|
24
|
+
* endpoints are in `@o3co/auth-provider-oauth`, and the two are siblings that
|
|
25
|
+
* may not import each other. The standalone template is the composition root
|
|
26
|
+
* where both are mounted on one app, so it is where the contract between them
|
|
27
|
+
* is testable.
|
|
28
|
+
*
|
|
29
|
+
* What it pins: #506 stamped `sid` on the `session` grant's access token and
|
|
30
|
+
* gave `/oauth/introspect` the session-liveness check `/oauth/userinfo`
|
|
31
|
+
* already ran. Both resolve the `UserSession` record — which `/session/logout`
|
|
32
|
+
* used to leave alive, because it destroyed the cookie and nothing else. So in
|
|
33
|
+
* the BFF / `auth.proxy` injection topology, whose logout IS this endpoint, an
|
|
34
|
+
* access token minted from the session kept introspecting `active: true` and
|
|
35
|
+
* kept answering at `/userinfo` for its full lifetime after the user logged
|
|
36
|
+
* out. Only `/oauth/logout` with an `id_token_hint` was ever closed by #506.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
40
|
+
import {
|
|
41
|
+
type AppConfig,
|
|
42
|
+
createApp,
|
|
43
|
+
createKeyStoreFactory,
|
|
44
|
+
createReadinessRouter,
|
|
45
|
+
defineModule,
|
|
46
|
+
InMemoryClientRepository,
|
|
47
|
+
InMemoryUserRepository,
|
|
48
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
49
|
+
registerBuiltinKeyStores,
|
|
50
|
+
} from "@o3co/auth-provider-core";
|
|
51
|
+
import express from "express";
|
|
52
|
+
import request from "supertest";
|
|
53
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
54
|
+
import { buildModules } from "../buildModules.mjs";
|
|
55
|
+
|
|
56
|
+
const keyPair = generateKeyPairSync("ed25519", {
|
|
57
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
58
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const CLIENT_ID = "bff-client";
|
|
62
|
+
const CLIENT_SECRET = "bff-secret";
|
|
63
|
+
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
64
|
+
const USERNAME = "alice";
|
|
65
|
+
const PASSWORD = "correct-horse-battery-staple";
|
|
66
|
+
|
|
67
|
+
const config: AppConfig = {
|
|
68
|
+
http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
|
|
69
|
+
logging: { level: "silent" },
|
|
70
|
+
oauth: {
|
|
71
|
+
jwt: {
|
|
72
|
+
issuer: "https://auth.test",
|
|
73
|
+
signingKey: {
|
|
74
|
+
provider: "local",
|
|
75
|
+
local: {
|
|
76
|
+
algorithm: "EdDSA",
|
|
77
|
+
kid: "v0",
|
|
78
|
+
privateKey: keyPair.privateKey,
|
|
79
|
+
publicKey: keyPair.publicKey,
|
|
80
|
+
previousKeys: [],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
accessToken: { expiresIn: 3600 },
|
|
85
|
+
refreshToken: {
|
|
86
|
+
expiresIn: 86400,
|
|
87
|
+
unknownFamilyPolicy: "reject" as const,
|
|
88
|
+
legacyRtPolicy: "reject" as const,
|
|
89
|
+
},
|
|
90
|
+
// The grant this whole test is about: it mints straight from an
|
|
91
|
+
// authenticated browser session, which is the BFF topology.
|
|
92
|
+
grants: { session: { enabled: true } },
|
|
93
|
+
oidcMode: "oidc-required",
|
|
94
|
+
code: { adapter: "memory" as const },
|
|
95
|
+
},
|
|
96
|
+
session: {
|
|
97
|
+
secret: "test-session-secret.at-least-32-bytes.ok",
|
|
98
|
+
name: "auth.sid",
|
|
99
|
+
maxAge: 3600000,
|
|
100
|
+
secure: false,
|
|
101
|
+
sameSite: "lax",
|
|
102
|
+
domain: null,
|
|
103
|
+
storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
|
|
104
|
+
},
|
|
105
|
+
rateLimit: {
|
|
106
|
+
login: { windowMs: 60000, limit: 100 },
|
|
107
|
+
failMode: "open",
|
|
108
|
+
},
|
|
109
|
+
federations: { google: { enabled: false } },
|
|
110
|
+
repositories: {
|
|
111
|
+
client: { type: "yaml", path: "./config/clients.yaml" },
|
|
112
|
+
user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
|
|
113
|
+
code: { type: "memory", defaultExpiresIn: 600 },
|
|
114
|
+
},
|
|
115
|
+
endpoints: { login: { url: "/login" } },
|
|
116
|
+
cors: { allowedOrigins: [] },
|
|
117
|
+
} as unknown as AppConfig;
|
|
118
|
+
|
|
119
|
+
const testRepositoriesModule = defineModule({
|
|
120
|
+
name: "test:repositories",
|
|
121
|
+
provides: {
|
|
122
|
+
clientRepository: () =>
|
|
123
|
+
new InMemoryClientRepository(
|
|
124
|
+
new Map([
|
|
125
|
+
[
|
|
126
|
+
CLIENT_ID,
|
|
127
|
+
{
|
|
128
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
129
|
+
clientSecret: CLIENT_SECRET,
|
|
130
|
+
allowedRedirectUris: [],
|
|
131
|
+
allowedScopes: ["openid", "profile"],
|
|
132
|
+
allowedAudiences: [],
|
|
133
|
+
allowedGrantTypes: ["session"],
|
|
134
|
+
backchannelLogoutSessionRequired: true,
|
|
135
|
+
frontchannelLogoutSessionRequired: true,
|
|
136
|
+
allowedAzpForFederationToken: false,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
]),
|
|
140
|
+
),
|
|
141
|
+
userRepository: () =>
|
|
142
|
+
new InMemoryUserRepository(
|
|
143
|
+
new Map([[USERNAME, { id: "u-alice", password: PASSWORD, email: "alice@example.com" }]]),
|
|
144
|
+
),
|
|
145
|
+
} as never,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const testKeyStoreModule = defineModule({
|
|
149
|
+
name: "test:key-store",
|
|
150
|
+
requires: ["config"] as const,
|
|
151
|
+
provides: {
|
|
152
|
+
keyStore: async ({ config: c }) => {
|
|
153
|
+
const factory = createKeyStoreFactory();
|
|
154
|
+
registerBuiltinKeyStores(factory);
|
|
155
|
+
return factory.create({
|
|
156
|
+
type: "local",
|
|
157
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("POST /session/logout invalidates the session grant's access token", () => {
|
|
164
|
+
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
165
|
+
|
|
166
|
+
afterEach(async () => {
|
|
167
|
+
await handleRef?.dispose();
|
|
168
|
+
handleRef = undefined;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
async function buildApp() {
|
|
172
|
+
const handle = await createApp({
|
|
173
|
+
modules: buildModules(config, {
|
|
174
|
+
keyStoreModule: testKeyStoreModule,
|
|
175
|
+
repositoriesModule: testRepositoriesModule,
|
|
176
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
177
|
+
}),
|
|
178
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
179
|
+
});
|
|
180
|
+
handleRef = handle;
|
|
181
|
+
|
|
182
|
+
const app = express();
|
|
183
|
+
app.use(
|
|
184
|
+
createReadinessRouter(express, {
|
|
185
|
+
probes: handle.readinessProbes,
|
|
186
|
+
timeoutMs: config.http.readinessTimeoutMs,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
app.use(handle.router);
|
|
190
|
+
return app;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drives the real browser half: fetch a CSRF pair, log in, and return the
|
|
195
|
+
* cookies plus the token the login response reissued. `/session/login`
|
|
196
|
+
* hands back a fresh CSRF cookie precisely so the follow-up logout needs no
|
|
197
|
+
* second round trip.
|
|
198
|
+
*/
|
|
199
|
+
async function login(app: express.Express) {
|
|
200
|
+
const csrfRes = await request(app).get("/session/csrf");
|
|
201
|
+
expect(csrfRes.status).toBe(200);
|
|
202
|
+
const csrfToken = csrfRes.body.csrf_token as string;
|
|
203
|
+
const headerName = csrfRes.body.header_name as string;
|
|
204
|
+
const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
|
|
205
|
+
|
|
206
|
+
const loginRes = await request(app)
|
|
207
|
+
.post("/session/login")
|
|
208
|
+
.set("Cookie", csrfCookies)
|
|
209
|
+
.set(headerName, csrfToken)
|
|
210
|
+
.type("form")
|
|
211
|
+
.send({ username: USERNAME, password: PASSWORD });
|
|
212
|
+
expect(loginRes.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
const loginCookies = loginRes.headers["set-cookie"] as unknown as string[];
|
|
215
|
+
// The session cookie comes from the login response, and so does a FRESH
|
|
216
|
+
// CSRF cookie — the login handler reissues one on the regenerated
|
|
217
|
+
// session so the follow-up logout needs no second round trip. The
|
|
218
|
+
// mechanism is a signed double-submit, so the cookie's value IS the
|
|
219
|
+
// token the header has to echo; the pre-login token no longer matches.
|
|
220
|
+
const csrfCookiePrefix = `${config.session.name}.csrf=`;
|
|
221
|
+
const reissued = loginCookies
|
|
222
|
+
.find((c) => c.startsWith(csrfCookiePrefix))
|
|
223
|
+
?.slice(csrfCookiePrefix.length)
|
|
224
|
+
.split(";")[0];
|
|
225
|
+
expect(reissued).toBeDefined();
|
|
226
|
+
return {
|
|
227
|
+
cookies: loginCookies,
|
|
228
|
+
csrfToken: decodeURIComponent(reissued as string),
|
|
229
|
+
headerName,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
it("a token minted from the session stops introspecting active after the session logs out", async () => {
|
|
234
|
+
const app = await buildApp();
|
|
235
|
+
const { cookies, csrfToken, headerName } = await login(app);
|
|
236
|
+
|
|
237
|
+
// The BFF exchanges the browser session for an access token.
|
|
238
|
+
const tokenRes = await request(app)
|
|
239
|
+
.post("/oauth/token")
|
|
240
|
+
.set("Authorization", BASIC)
|
|
241
|
+
.set("Cookie", cookies)
|
|
242
|
+
.type("form")
|
|
243
|
+
.send({ grant_type: "session", scope: "openid profile" });
|
|
244
|
+
expect(tokenRes.status).toBe(200);
|
|
245
|
+
const accessToken = tokenRes.body.access_token as string;
|
|
246
|
+
expect(typeof accessToken).toBe("string");
|
|
247
|
+
|
|
248
|
+
// Before the logout the token is a live credential at both surfaces.
|
|
249
|
+
const beforeIntrospect = await request(app)
|
|
250
|
+
.post("/oauth/introspect")
|
|
251
|
+
.set("Authorization", BASIC)
|
|
252
|
+
.type("form")
|
|
253
|
+
.send({ token: accessToken });
|
|
254
|
+
expect(beforeIntrospect.status).toBe(200);
|
|
255
|
+
expect(beforeIntrospect.body.active).toBe(true);
|
|
256
|
+
|
|
257
|
+
const beforeUserinfo = await request(app)
|
|
258
|
+
.get("/oauth/userinfo")
|
|
259
|
+
.set("Authorization", `Bearer ${accessToken}`);
|
|
260
|
+
expect(beforeUserinfo.status).toBe(200);
|
|
261
|
+
|
|
262
|
+
// The logout the BFF topology actually performs — no id_token_hint, no
|
|
263
|
+
// `/oauth/logout`, just the session endpoint the browser calls.
|
|
264
|
+
const logoutRes = await request(app)
|
|
265
|
+
.post("/session/logout")
|
|
266
|
+
.set("Cookie", cookies)
|
|
267
|
+
.set(headerName, csrfToken);
|
|
268
|
+
expect(logoutRes.status).toBe(200);
|
|
269
|
+
expect(logoutRes.body).toMatchObject({ message: "Logged out successfully" });
|
|
270
|
+
|
|
271
|
+
// After it, the same token no longer authorizes anything. This is the
|
|
272
|
+
// assertion that failed before the fix: the `UserSession` record the
|
|
273
|
+
// liveness checks resolve outlived the logout.
|
|
274
|
+
const afterIntrospect = await request(app)
|
|
275
|
+
.post("/oauth/introspect")
|
|
276
|
+
.set("Authorization", BASIC)
|
|
277
|
+
.type("form")
|
|
278
|
+
.send({ token: accessToken });
|
|
279
|
+
expect(afterIntrospect.status).toBe(200);
|
|
280
|
+
expect(afterIntrospect.body.active).toBe(false);
|
|
281
|
+
|
|
282
|
+
const afterUserinfo = await request(app)
|
|
283
|
+
.get("/oauth/userinfo")
|
|
284
|
+
.set("Authorization", `Bearer ${accessToken}`);
|
|
285
|
+
expect(afterUserinfo.status).toBe(401);
|
|
286
|
+
expect(afterUserinfo.body.error_description).toBe("session_invalid");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import type { Server } from "node:http";
|
|
34
34
|
import type { Logger } from "@o3co/auth-provider-core";
|
|
35
35
|
import { describe, expect, it, vi } from "vitest";
|
|
36
|
-
import { installGracefulShutdown } from "../shutdown.mjs";
|
|
36
|
+
import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
|
|
37
37
|
|
|
38
38
|
/** A `Server` double whose `close` callback fires only when we say so. */
|
|
39
39
|
function makeServer() {
|
|
@@ -234,6 +234,86 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
234
234
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
+
it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
|
|
238
|
+
// The guarantees above say cleanup "never wedges the process", but `finish`
|
|
239
|
+
// awaited it with no deadline — and the drain deadline is already cleared
|
|
240
|
+
// by then, so a dispose that never settles meant `exit` was never reached.
|
|
241
|
+
// The same defect was found in auth.proxy#81 and auth.policy-verifier#210,
|
|
242
|
+
// both of which took this file as their starting point.
|
|
243
|
+
vi.useFakeTimers();
|
|
244
|
+
try {
|
|
245
|
+
const { signals, finishDraining, exit, logger } = install({
|
|
246
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
247
|
+
drainTimeoutMs: 5_000,
|
|
248
|
+
});
|
|
249
|
+
signals.get("SIGTERM")?.();
|
|
250
|
+
finishDraining();
|
|
251
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
252
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
253
|
+
expect.objectContaining({ cleanupTimeoutMs: 5_000 }),
|
|
254
|
+
expect.stringContaining("cleanup timed out"),
|
|
255
|
+
);
|
|
256
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
257
|
+
} finally {
|
|
258
|
+
vi.useRealTimers();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("does not penalise a cleanup that finishes inside its budget", async () => {
|
|
263
|
+
vi.useFakeTimers();
|
|
264
|
+
try {
|
|
265
|
+
const { signals, finishDraining, exit } = install({
|
|
266
|
+
cleanup: () => Promise.resolve(),
|
|
267
|
+
drainTimeoutMs: 5_000,
|
|
268
|
+
});
|
|
269
|
+
signals.get("SIGTERM")?.();
|
|
270
|
+
finishDraining();
|
|
271
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
272
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(0);
|
|
273
|
+
} finally {
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("defers the real exit a turn so a buffered log destination can flush", async () => {
|
|
279
|
+
// The scaffolded app logs NDJSON through pino, whose default destination is
|
|
280
|
+
// not synchronous: exiting in the same tick as the last `logger.error` can
|
|
281
|
+
// drop exactly the `cleanup failed` line an operator would look for.
|
|
282
|
+
const exitProcess = vi.fn();
|
|
283
|
+
deferExit(3, exitProcess);
|
|
284
|
+
expect(exitProcess).not.toHaveBeenCalled();
|
|
285
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
286
|
+
expect(exitProcess).toHaveBeenCalledWith(3);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
|
|
290
|
+
// `exitCode` became 1 while `reason` still said "drained", so the one line
|
|
291
|
+
// an operator alerts on contradicted itself. The drain outcome is still
|
|
292
|
+
// carried, under its own key, so neither fact is lost.
|
|
293
|
+
const { signals, finishDraining, logger, exit } = install({
|
|
294
|
+
cleanup: () => Promise.reject(new Error("teardown failed")),
|
|
295
|
+
});
|
|
296
|
+
signals.get("SIGTERM")?.();
|
|
297
|
+
finishDraining();
|
|
298
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
299
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
300
|
+
{ reason: "cleanup-failed", drain: "drained", exitCode: 1 },
|
|
301
|
+
"graceful shutdown: complete",
|
|
302
|
+
);
|
|
303
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("keeps reason and drain identical when cleanup succeeds", async () => {
|
|
307
|
+
const { signals, finishDraining, logger } = install({ cleanup: () => Promise.resolve() });
|
|
308
|
+
signals.get("SIGTERM")?.();
|
|
309
|
+
finishDraining();
|
|
310
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
311
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
312
|
+
{ reason: "drained", drain: "drained", exitCode: 0 },
|
|
313
|
+
"graceful shutdown: complete",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
237
317
|
it("removes its own signal listeners once shutting down", async () => {
|
|
238
318
|
// Otherwise a repeated signal keeps re-entering a handler that has
|
|
239
319
|
// already handed the process over to `close`.
|
|
@@ -193,18 +193,23 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
193
193
|
// every in-memory shared store, so the denylist this change adds has to
|
|
194
194
|
// come out of that environment as the Redis one — from the template's
|
|
195
195
|
// own config, since the compose file names no denylist variable.
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
196
|
+
// Asked of each manifest, the way the guard does (#455), so a rename
|
|
197
|
+
// cannot quietly invalidate it and the template's own memory modules
|
|
198
|
+
// — which no name list in core covers — count too.
|
|
199
|
+
const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
|
|
199
200
|
const config = buildResolvedConfig("production", {
|
|
200
201
|
USER_SESSION_STORES_ADAPTER: "redis",
|
|
201
202
|
RATE_LIMITER_ADAPTER: "redis",
|
|
202
203
|
OAUTH_CODE_ADAPTER: "redis",
|
|
204
|
+
// #456: the federation token store defaults to memory, and the
|
|
205
|
+
// memory module declares itself replica-unsafe (#455).
|
|
206
|
+
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
207
|
+
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
|
203
208
|
});
|
|
204
|
-
const
|
|
205
|
-
expect(
|
|
206
|
-
for (const
|
|
207
|
-
expect(
|
|
209
|
+
const modules = buildModules(config);
|
|
210
|
+
expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
|
|
211
|
+
for (const module of modules) {
|
|
212
|
+
expect(replicaUnsafeReason(module), module.name).toBeUndefined();
|
|
208
213
|
}
|
|
209
214
|
});
|
|
210
215
|
|
|
@@ -99,8 +99,12 @@ await (async (): Promise<void> => {
|
|
|
99
99
|
// `buildModules` is the single source of truth for the module list — it
|
|
100
100
|
// gates federation modules on `config.federations.<name>.enabled`, which
|
|
101
101
|
// the standalone scaffold defaults to false.
|
|
102
|
+
//
|
|
103
|
+
// `environment` is the name the config above was selected by (#473): the
|
|
104
|
+
// Redis federation-token store's plaintext guard reads it in addition to
|
|
105
|
+
// NODE_ENV, so `CONFIG_ENV=production` is production to the guard too.
|
|
102
106
|
const handle = await createApp({
|
|
103
|
-
modules: buildModules(config),
|
|
107
|
+
modules: buildModules(config, { environment: env }),
|
|
104
108
|
bootstrapComponents: {
|
|
105
109
|
config,
|
|
106
110
|
pathResolver: import.meta.resolve,
|
|
@@ -31,16 +31,17 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
redisAccessTokenDenylistModule,
|
|
33
33
|
redisCodeRepositoryModule,
|
|
34
|
+
redisFederationTokenStoreModuleFor,
|
|
34
35
|
redisRateLimiterModule,
|
|
35
36
|
redisRefreshTokenFamilyStoreModule,
|
|
36
37
|
redisSessionStoresModule,
|
|
37
38
|
} from "@o3co/auth-provider-redis";
|
|
38
|
-
import { sessionModule,
|
|
39
|
+
import { sessionModule, sessionStoreModuleFor } from "@o3co/auth-provider-session";
|
|
39
40
|
import {
|
|
40
41
|
auditSinkModule,
|
|
41
|
-
federationTokenStoreModule,
|
|
42
42
|
googleFederationConfigModule,
|
|
43
43
|
inMemoryCodeRepositoryModule,
|
|
44
|
+
inMemoryFederationTokenStoreModule,
|
|
44
45
|
inMemorySessionStoresModule,
|
|
45
46
|
keyStoreModule,
|
|
46
47
|
repositoriesModule,
|
|
@@ -48,11 +49,21 @@ import {
|
|
|
48
49
|
} from "./modules.mjs";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
|
-
*
|
|
52
|
-
* implementations of the file-system-backed
|
|
53
|
-
* should not pass
|
|
52
|
+
* Overrides for the composition. All but `environment` are test-only: they let
|
|
53
|
+
* the smoke test substitute in-memory implementations of the file-system-backed
|
|
54
|
+
* modules, and production callers should not pass them — the defaults match the
|
|
55
|
+
* standalone scaffold.
|
|
54
56
|
*/
|
|
55
57
|
export interface BuildModulesOverrides {
|
|
58
|
+
/**
|
|
59
|
+
* #473: the name this deployment selected its configuration by —
|
|
60
|
+
* `CONFIG_ENV || NODE_ENV`, computed once in `app.mts` and passed here so
|
|
61
|
+
* the Redis federation-token store's `allow-plaintext` guard reads the
|
|
62
|
+
* environment the config actually came from, not `NODE_ENV` alone. Omitted,
|
|
63
|
+
* the guard falls back to `NODE_ENV` (and `deployment.mode`, which it reads
|
|
64
|
+
* off the config either way).
|
|
65
|
+
*/
|
|
66
|
+
readonly environment?: string;
|
|
56
67
|
readonly keyStoreModule?: Module;
|
|
57
68
|
readonly repositoriesModule?: Module;
|
|
58
69
|
readonly storesModule?: Module;
|
|
@@ -117,6 +128,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
117
128
|
// endpoint would answer 200 while the token kept working. The switch here is
|
|
118
129
|
// only over WHICH denylist.
|
|
119
130
|
const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
|
|
131
|
+
// #456: adapter switch for the federation token store. `"memory"` by
|
|
132
|
+
// default, the template's local-dev shape; `"redis"` mounts
|
|
133
|
+
// `redisFederationTokenStoreModule` off the shared socket — what the README
|
|
134
|
+
// promised and what the previous factory-based module never delivered: it
|
|
135
|
+
// built the Redis store without a client and failed at boot.
|
|
136
|
+
const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
|
|
120
137
|
|
|
121
138
|
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
122
139
|
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
@@ -154,12 +171,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
154
171
|
rateLimiterAdapter === "redis" ||
|
|
155
172
|
userSessionStoresAdapter === "redis" ||
|
|
156
173
|
codeRepositoryAdapter === "redis" ||
|
|
157
|
-
accessTokenDenylistAdapter === "redis"
|
|
174
|
+
accessTokenDenylistAdapter === "redis" ||
|
|
175
|
+
federationTokenStoreAdapter === "redis";
|
|
158
176
|
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
177
|
+
// The four user-session stores switch on `userSessionStores.adapter`; the
|
|
178
|
+
// federation-token store is always wired and switches on its own key
|
|
179
|
+
// below (#456). Pre-Wave-5d the redis branch dropped the
|
|
180
|
+
// federation-token-store provider entirely (Copilot review on PR #121);
|
|
181
|
+
// splitting `storesModule` fixed that boot failure.
|
|
163
182
|
const sessionStoresModules: Module[] =
|
|
164
183
|
userSessionStoresAdapter === "redis"
|
|
165
184
|
? [redisSessionStoresModule]
|
|
@@ -188,13 +207,32 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
188
207
|
? [redisAccessTokenDenylistModule]
|
|
189
208
|
: [memoryAccessTokenDenylistModule];
|
|
190
209
|
|
|
210
|
+
// #455 / #456: mutually-exclusive federation-token-store pair, the same
|
|
211
|
+
// shape as the session stores. One module per adapter, so the memory one
|
|
212
|
+
// can declare `replicaSafety` on its manifest and the Redis one can
|
|
213
|
+
// `require` its client slot. The previous single module chose the adapter
|
|
214
|
+
// at runtime under one name — the replica guard could not tell the two
|
|
215
|
+
// apart, and the Redis branch had no client to hand the builder.
|
|
216
|
+
//
|
|
217
|
+
// #473: the Redis module is built for this composition root so its
|
|
218
|
+
// plaintext guard knows which environment selected the config; it reads
|
|
219
|
+
// `deployment.mode` off the config itself.
|
|
220
|
+
const federationTokenStoreModules: Module[] =
|
|
221
|
+
federationTokenStoreAdapter === "redis"
|
|
222
|
+
? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
|
|
223
|
+
: [inMemoryFederationTokenStoreModule];
|
|
224
|
+
|
|
191
225
|
return [
|
|
192
226
|
// D-5: sessionStoreModule wires the express-session middleware into the
|
|
193
227
|
// boot-planner-managed lifecycle. **Mount order is enforced by this
|
|
194
228
|
// list position (declarationIndex tie-breaking)** — the module
|
|
195
229
|
// intentionally has no `before`/`after` clause, so it MUST be listed
|
|
196
230
|
// ahead of every session-consuming module here. Do not reorder.
|
|
197
|
-
|
|
231
|
+
//
|
|
232
|
+
// #474: built from `config` rather than the static manifest, so that
|
|
233
|
+
// `session.storage.type = "memory"` declares itself replica-unsafe and
|
|
234
|
+
// `deployment.mode = "multi"` refuses it by name like the other stores.
|
|
235
|
+
sessionStoreModuleFor(config),
|
|
198
236
|
oauthModule({ config }),
|
|
199
237
|
oauthSessionModule({ config }),
|
|
200
238
|
oauthAuthorizationModule({ config }),
|
|
@@ -220,11 +258,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
220
258
|
// actually needs Redis. Memory-only deployments skip this so they
|
|
221
259
|
// don't open an unused socket.
|
|
222
260
|
...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
|
|
223
|
-
// Federation-token store
|
|
224
|
-
// `userSessionStores.adapter` switch
|
|
225
|
-
// pre-Wave-5d
|
|
226
|
-
//
|
|
227
|
-
|
|
261
|
+
// Federation-token store: redis (multi-replica) or memory (dev). Always
|
|
262
|
+
// wired, independent of the `userSessionStores.adapter` switch — it was
|
|
263
|
+
// bundled into `storesModule` pre-Wave-5d, and the redis session-stores
|
|
264
|
+
// branch dropped the provider.
|
|
265
|
+
...federationTokenStoreModules,
|
|
228
266
|
// User-session-store family: redis (multi-replica) or memory (dev).
|
|
229
267
|
...sessionStoresModules,
|
|
230
268
|
// OAuth-endpoint rate limiter: redis (shared counters) or memory.
|