@o3co/create-auth-provider 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -6
- package/dist/internal/template-filter.d.mts +17 -0
- package/dist/internal/template-filter.d.mts.map +1 -0
- package/dist/internal/template-filter.mjs +45 -0
- package/package.json +4 -1
- package/templates/standalone/README.md +17 -0
- package/templates/standalone/config/application.conf +141 -3
- package/templates/standalone/config/clients.yaml.example +17 -1
- package/templates/standalone/docker-compose.test.yml +1 -1
- package/templates/standalone/docker-compose.yml +1 -1
- package/templates/standalone/package.json +12 -3
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +180 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +217 -8
- package/templates/standalone/src/app.mts +13 -40
- package/templates/standalone/src/buildModules.mts +120 -5
- package/templates/standalone/src/modules.mts +239 -24
- package/templates/versions.json +8 -8
- package/dist/__tests__/index.test.d.mts +0 -2
- package/dist/__tests__/index.test.d.mts.map +0 -1
- package/dist/__tests__/index.test.mjs +0 -240
|
@@ -21,13 +21,13 @@ import {
|
|
|
21
21
|
defineModule,
|
|
22
22
|
generateToken,
|
|
23
23
|
InMemoryClientRepository,
|
|
24
|
-
InMemoryCodeRepository,
|
|
25
24
|
InMemoryUserRepository,
|
|
25
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
26
26
|
registerBuiltinKeyStores,
|
|
27
27
|
} from "@o3co/auth-provider-core";
|
|
28
28
|
import express from "express";
|
|
29
29
|
import request from "supertest";
|
|
30
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
30
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
31
31
|
import { buildModules } from "../buildModules.mjs";
|
|
32
32
|
|
|
33
33
|
const config: AppConfig = {
|
|
@@ -40,13 +40,22 @@ const config: AppConfig = {
|
|
|
40
40
|
algorithm: "HS256",
|
|
41
41
|
secret: "test-secret-for-smoke-test",
|
|
42
42
|
kid: "v0",
|
|
43
|
-
|
|
43
|
+
previousSecrets: [],
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
46
|
},
|
|
47
47
|
accessToken: { expiresIn: 3600 },
|
|
48
|
-
refreshToken: {
|
|
48
|
+
refreshToken: {
|
|
49
|
+
expiresIn: 86400,
|
|
50
|
+
unknownFamilyPolicy: "reject" as const,
|
|
51
|
+
legacyRtPolicy: "reject" as const,
|
|
52
|
+
},
|
|
49
53
|
grants: {},
|
|
54
|
+
// OR-9: explicit memory adapter for the smoke test. The legacy
|
|
55
|
+
// `repositories.code.type` fallback would also pick memory here,
|
|
56
|
+
// but the explicit setting documents the intent and avoids
|
|
57
|
+
// console.warn deprecation noise in the test output.
|
|
58
|
+
code: { adapter: "memory" as const },
|
|
50
59
|
},
|
|
51
60
|
session: {
|
|
52
61
|
secret: "test-session-secret",
|
|
@@ -58,6 +67,7 @@ const config: AppConfig = {
|
|
|
58
67
|
},
|
|
59
68
|
rateLimit: {
|
|
60
69
|
login: { windowMs: 60000, limit: 10 },
|
|
70
|
+
failMode: "open",
|
|
61
71
|
},
|
|
62
72
|
federations: {
|
|
63
73
|
google: { enabled: false },
|
|
@@ -69,8 +79,6 @@ const config: AppConfig = {
|
|
|
69
79
|
},
|
|
70
80
|
endpoints: {
|
|
71
81
|
login: { url: "/login" },
|
|
72
|
-
client: { url: undefined },
|
|
73
|
-
authCallback: { url: undefined },
|
|
74
82
|
},
|
|
75
83
|
cors: { allowedOrigins: [] },
|
|
76
84
|
};
|
|
@@ -79,13 +87,50 @@ const config: AppConfig = {
|
|
|
79
87
|
* Test-only repository module — bypasses the file-system-backed repositories
|
|
80
88
|
* that `repositoriesModule` provides in production. Smoke tests use in-memory
|
|
81
89
|
* empty repositories to verify the boot pipeline shape, not the data layer.
|
|
90
|
+
*
|
|
91
|
+
* Post-OR-9 (Wave 5d): `codeRepository` is no longer provided here — it is
|
|
92
|
+
* wired by `inMemoryCodeRepositoryModule` (or `redisCodeRepositoryModule`)
|
|
93
|
+
* via the `oauth.code.adapter` switch in `buildModules`. The smoke test
|
|
94
|
+
* config sets `oauth.code.adapter = "memory"`, so `inMemoryCodeRepositoryModule`
|
|
95
|
+
* is added automatically and provides the slot.
|
|
96
|
+
*
|
|
97
|
+
* D-6 (v0.5.1): a single confidential client is registered so the
|
|
98
|
+
* `clientAuthMw` middleware in front of `/oauth/token` has a record to
|
|
99
|
+
* authenticate against. Smoke tests that hit `/oauth/token` send
|
|
100
|
+
* `Authorization: ${SMOKE_BASIC_AUTH}`.
|
|
82
101
|
*/
|
|
102
|
+
const SMOKE_CLIENT_ID = "smoke-client";
|
|
103
|
+
const SMOKE_CLIENT_SECRET = "smoke-secret";
|
|
104
|
+
const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
|
|
105
|
+
|
|
83
106
|
const testRepositoriesModule = defineModule({
|
|
84
107
|
name: "test:repositories",
|
|
85
108
|
provides: {
|
|
86
|
-
|
|
109
|
+
// `InMemoryClientRepository` constructor parameter `Map<string, ClientEntry>`
|
|
110
|
+
// uses `ClientEntry = z.infer<typeof ClientEntrySchema>` (the schema's
|
|
111
|
+
// OUTPUT type) — fields with `.default(...)` are non-optional in the
|
|
112
|
+
// output even though they are optional on input. The fixture supplies
|
|
113
|
+
// every field explicitly so the literal satisfies the output shape
|
|
114
|
+
// without requiring a cast.
|
|
115
|
+
clientRepository: () =>
|
|
116
|
+
new InMemoryClientRepository(
|
|
117
|
+
new Map([
|
|
118
|
+
[
|
|
119
|
+
SMOKE_CLIENT_ID,
|
|
120
|
+
{
|
|
121
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
122
|
+
clientSecret: SMOKE_CLIENT_SECRET,
|
|
123
|
+
allowedRedirectUris: [],
|
|
124
|
+
allowedScopes: [],
|
|
125
|
+
allowedAudiences: [],
|
|
126
|
+
backchannelLogoutSessionRequired: true,
|
|
127
|
+
frontchannelLogoutSessionRequired: true,
|
|
128
|
+
allowedAzpForFederationToken: false,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
]),
|
|
132
|
+
),
|
|
87
133
|
userRepository: () => new InMemoryUserRepository(new Map()),
|
|
88
|
-
codeRepository: () => new InMemoryCodeRepository(),
|
|
89
134
|
},
|
|
90
135
|
});
|
|
91
136
|
|
|
@@ -112,6 +157,12 @@ describe("standalone smoke test", () => {
|
|
|
112
157
|
modules: buildModules(config, {
|
|
113
158
|
keyStoreModule: testKeyStoreModule,
|
|
114
159
|
repositoriesModule: testRepositoriesModule,
|
|
160
|
+
// D-2 v2: smoke tests stay in-memory for the RT family store.
|
|
161
|
+
// Production composition root pairs `refreshTokenFamilyClientModule`
|
|
162
|
+
// with `redisRefreshTokenFamilyStoreModule`; this override replaces
|
|
163
|
+
// the pair entirely with a single memory-backed store module so no
|
|
164
|
+
// real ioredis connection is opened during CI unit tests.
|
|
165
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
115
166
|
}),
|
|
116
167
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
117
168
|
});
|
|
@@ -141,6 +192,7 @@ describe("standalone smoke test", () => {
|
|
|
141
192
|
handleRef = handle;
|
|
142
193
|
const res = await request(app)
|
|
143
194
|
.post("/oauth/token")
|
|
195
|
+
.set("Authorization", SMOKE_BASIC_AUTH)
|
|
144
196
|
.type("form")
|
|
145
197
|
.send({ grant_type: "unsupported" });
|
|
146
198
|
expect(res.status).toBe(400);
|
|
@@ -151,6 +203,7 @@ describe("standalone smoke test", () => {
|
|
|
151
203
|
handleRef = handle;
|
|
152
204
|
const res = await request(app)
|
|
153
205
|
.post("/oauth/token")
|
|
206
|
+
.set("Authorization", SMOKE_BASIC_AUTH)
|
|
154
207
|
.type("form")
|
|
155
208
|
.send({ grant_type: "unsupported" });
|
|
156
209
|
expect(res.status).toBe(400);
|
|
@@ -173,6 +226,7 @@ describe("standalone smoke test", () => {
|
|
|
173
226
|
const modules = buildModules(config, {
|
|
174
227
|
keyStoreModule: testKeyStoreModule,
|
|
175
228
|
repositoriesModule: testRepositoriesModule,
|
|
229
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
176
230
|
});
|
|
177
231
|
const moduleNames = modules.map((m) => m.name);
|
|
178
232
|
expect(moduleNames).not.toContain("federation:google");
|
|
@@ -186,6 +240,161 @@ describe("standalone smoke test", () => {
|
|
|
186
240
|
expect(handle).toBeDefined();
|
|
187
241
|
});
|
|
188
242
|
|
|
243
|
+
// D-2 v2 / OR-1 closure: the standalone composition root must default to the
|
|
244
|
+
// Redis-backed RT family store + ioredis client module pair, NOT the
|
|
245
|
+
// in-memory store. Multi-replica deployments lose RT family persistence
|
|
246
|
+
// when each replica holds families in-process; this test guards the
|
|
247
|
+
// production default. The override path is exercised by the smoke tests
|
|
248
|
+
// above (which substitute the memory store for CI).
|
|
249
|
+
describe("D-2 v2 + Wave 5d: redis-clients + adapter wiring", () => {
|
|
250
|
+
it("buildModules includes the shared redis-clients module + redis store by default", () => {
|
|
251
|
+
const modules = buildModules(config, {
|
|
252
|
+
keyStoreModule: testKeyStoreModule,
|
|
253
|
+
repositoriesModule: testRepositoriesModule,
|
|
254
|
+
});
|
|
255
|
+
const names = modules.map((m) => m.name);
|
|
256
|
+
// Wave 5d: single shared ioredis client module (was per-purpose
|
|
257
|
+
// `standalone:refresh-token-family-client` in PR1).
|
|
258
|
+
expect(names).toContain("standalone:redis-clients");
|
|
259
|
+
expect(names).toContain("redis-refresh-token-family-store");
|
|
260
|
+
expect(names).not.toContain("core-refresh-token-family-store-memory");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
|
|
264
|
+
const modules = buildModules(config, {
|
|
265
|
+
keyStoreModule: testKeyStoreModule,
|
|
266
|
+
repositoriesModule: testRepositoriesModule,
|
|
267
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
268
|
+
});
|
|
269
|
+
const names = modules.map((m) => m.name);
|
|
270
|
+
expect(names).toContain("core-refresh-token-family-store-memory");
|
|
271
|
+
expect(names).not.toContain("standalone:redis-clients");
|
|
272
|
+
expect(names).not.toContain("redis-refresh-token-family-store");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("buildModules wires memoryRateLimiterModule by default (closes IH-14)", () => {
|
|
276
|
+
const modules = buildModules(config, {
|
|
277
|
+
keyStoreModule: testKeyStoreModule,
|
|
278
|
+
repositoriesModule: testRepositoriesModule,
|
|
279
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
280
|
+
});
|
|
281
|
+
const names = modules.map((m) => m.name);
|
|
282
|
+
expect(names).toContain("core-rate-limiter-memory");
|
|
283
|
+
expect(names).not.toContain("redis-rate-limiter");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("buildModules switches to redisRateLimiterModule when rateLimiter.adapter = 'redis'", () => {
|
|
287
|
+
const redisRlConfig = {
|
|
288
|
+
...config,
|
|
289
|
+
rateLimiter: { adapter: "redis" as const },
|
|
290
|
+
};
|
|
291
|
+
const modules = buildModules(redisRlConfig, {
|
|
292
|
+
keyStoreModule: testKeyStoreModule,
|
|
293
|
+
repositoriesModule: testRepositoriesModule,
|
|
294
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
295
|
+
});
|
|
296
|
+
const names = modules.map((m) => m.name);
|
|
297
|
+
expect(names).toContain("redis-rate-limiter");
|
|
298
|
+
expect(names).not.toContain("core-rate-limiter-memory");
|
|
299
|
+
// Adapter = redis pulls in the shared redis-clients module too.
|
|
300
|
+
expect(names).toContain("standalone:redis-clients");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("buildModules switches to redisSessionStoresModule when userSessionStores.adapter = 'redis'", () => {
|
|
304
|
+
const redisSessConfig = {
|
|
305
|
+
...config,
|
|
306
|
+
userSessionStores: { adapter: "redis" as const },
|
|
307
|
+
};
|
|
308
|
+
const modules = buildModules(redisSessConfig, {
|
|
309
|
+
keyStoreModule: testKeyStoreModule,
|
|
310
|
+
repositoriesModule: testRepositoriesModule,
|
|
311
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
312
|
+
});
|
|
313
|
+
const names = modules.map((m) => m.name);
|
|
314
|
+
expect(names).toContain("redisSessionStores");
|
|
315
|
+
expect(names).not.toContain("standalone:stores");
|
|
316
|
+
expect(names).toContain("standalone:redis-clients");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// OR-9 (Wave 5d): adapter switch for the OAuth code repository. Closes
|
|
321
|
+
// the deferred Phase 10 module-pattern wrapper for `codeRepository`. The
|
|
322
|
+
// memory branch wires `inMemoryCodeRepositoryModule`; the redis branch
|
|
323
|
+
// wires `redisCodeRepositoryModule` against the shared ioredis socket
|
|
324
|
+
// (same `standaloneRedisClientsModule` as the RT family / rate limiter).
|
|
325
|
+
describe("OR-9: code-repository adapter wiring", () => {
|
|
326
|
+
it("buildModules wires inMemoryCodeRepositoryModule by default (oauth.code.adapter = 'memory')", () => {
|
|
327
|
+
const modules = buildModules(config, {
|
|
328
|
+
keyStoreModule: testKeyStoreModule,
|
|
329
|
+
repositoriesModule: testRepositoriesModule,
|
|
330
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
331
|
+
});
|
|
332
|
+
const names = modules.map((m) => m.name);
|
|
333
|
+
expect(names).toContain("standalone:in-memory-code-repository");
|
|
334
|
+
expect(names).not.toContain("redis-code-repository");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("buildModules switches to redisCodeRepositoryModule when oauth.code.adapter = 'redis'", () => {
|
|
338
|
+
const redisCodeConfig = {
|
|
339
|
+
...config,
|
|
340
|
+
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
341
|
+
};
|
|
342
|
+
const modules = buildModules(redisCodeConfig, {
|
|
343
|
+
keyStoreModule: testKeyStoreModule,
|
|
344
|
+
repositoriesModule: testRepositoriesModule,
|
|
345
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
346
|
+
});
|
|
347
|
+
const names = modules.map((m) => m.name);
|
|
348
|
+
expect(names).toContain("redis-code-repository");
|
|
349
|
+
expect(names).not.toContain("standalone:in-memory-code-repository");
|
|
350
|
+
// adapter = "redis" pulls in the shared ioredis socket so the
|
|
351
|
+
// `codeRepositoryClient` slot is satisfied.
|
|
352
|
+
expect(names).toContain("standalone:redis-clients");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
|
|
356
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
357
|
+
try {
|
|
358
|
+
const legacyConfig = {
|
|
359
|
+
...config,
|
|
360
|
+
oauth: { ...config.oauth, code: undefined },
|
|
361
|
+
repositories: {
|
|
362
|
+
...config.repositories,
|
|
363
|
+
code: { type: "redis" as const, defaultExpiresIn: 600 },
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
const modules = buildModules(legacyConfig, {
|
|
367
|
+
keyStoreModule: testKeyStoreModule,
|
|
368
|
+
repositoriesModule: testRepositoriesModule,
|
|
369
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
370
|
+
});
|
|
371
|
+
const names = modules.map((m) => m.name);
|
|
372
|
+
expect(names).toContain("redis-code-repository");
|
|
373
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
|
|
374
|
+
} finally {
|
|
375
|
+
warnSpy.mockRestore();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
|
|
380
|
+
// Regression guard: `repositoriesModule` must NOT provide
|
|
381
|
+
// `codeRepository` post-OR-9 — the slot is owned by
|
|
382
|
+
// `inMemoryCodeRepositoryModule` or `redisCodeRepositoryModule`
|
|
383
|
+
// exclusively, selected via `oauth.code.adapter`.
|
|
384
|
+
const modules = buildModules(config, {
|
|
385
|
+
keyStoreModule: testKeyStoreModule,
|
|
386
|
+
// Use the production repositoriesModule (not the test override)
|
|
387
|
+
// so we exercise its post-OR-9 provides-shape.
|
|
388
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
389
|
+
});
|
|
390
|
+
const codeRepoProviders = modules.filter((m) =>
|
|
391
|
+
Object.keys(m.provides ?? {}).includes("codeRepository"),
|
|
392
|
+
);
|
|
393
|
+
expect(codeRepoProviders).toHaveLength(1);
|
|
394
|
+
expect(codeRepoProviders[0]?.name).toBe("standalone:in-memory-code-repository");
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
189
398
|
it("POST /oauth/introspect returns iat in active token response", async () => {
|
|
190
399
|
const { app, handle } = await buildApp();
|
|
191
400
|
handleRef = handle;
|
|
@@ -16,14 +16,9 @@
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { gracefulShutdown } from "@o3co/auth.utils";
|
|
18
18
|
import { type AppConfig, AppConfigSchema, createApp } from "@o3co/auth-provider-core";
|
|
19
|
-
import {
|
|
20
|
-
createSessionStoreFactory,
|
|
21
|
-
registerBuiltinSessionStores,
|
|
22
|
-
} from "@o3co/auth-provider-session";
|
|
23
19
|
import { parseFile } from "@o3co/ts.hocon";
|
|
24
20
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
25
21
|
import express from "express";
|
|
26
|
-
import session from "express-session";
|
|
27
22
|
import helmet from "helmet";
|
|
28
23
|
|
|
29
24
|
import logger from "#/logger.mjs";
|
|
@@ -56,36 +51,14 @@ await (async (): Promise<void> => {
|
|
|
56
51
|
}),
|
|
57
52
|
);
|
|
58
53
|
|
|
59
|
-
// Step 3:
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const sessionStore = await sessionStoreFactory.create({
|
|
68
|
-
type: sessionStorageSlice.type,
|
|
69
|
-
...((sessionStorageSlice[sessionStorageSlice.type] ?? {}) as Record<string, unknown>),
|
|
70
|
-
});
|
|
71
|
-
app.use(
|
|
72
|
-
session({
|
|
73
|
-
secret: config.session.secret,
|
|
74
|
-
resave: false,
|
|
75
|
-
saveUninitialized: false,
|
|
76
|
-
store: sessionStore,
|
|
77
|
-
cookie: {
|
|
78
|
-
path: "/",
|
|
79
|
-
httpOnly: true,
|
|
80
|
-
secure: config.session.secure,
|
|
81
|
-
maxAge: config.session.maxAge,
|
|
82
|
-
sameSite: config.session.sameSite,
|
|
83
|
-
domain: config.session.domain || undefined,
|
|
84
|
-
},
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
// Step 4: Boot the auth pipeline. bootstrapComponents carries only host-
|
|
54
|
+
// Step 3: Boot the auth pipeline.
|
|
55
|
+
// D-5: express-session middleware is now wired by `sessionStoreModule`
|
|
56
|
+
// inside the boot planner. Mount order is enforced by declarationIndex
|
|
57
|
+
// tie-breaking — sessionStoreModule MUST come first in `buildModules(...)`
|
|
58
|
+
// so the middleware initialises `req.session` for every downstream route.
|
|
59
|
+
// The connect-redis client's lifetime is owned by the planner via
|
|
60
|
+
// `BuilderContext.lifecycle` and drained on `handle.dispose()`.
|
|
61
|
+
// bootstrapComponents carries only host-
|
|
89
62
|
// environment values (config + pathResolver per A2-γ §4 worked example);
|
|
90
63
|
// every other component flows through composition-root-local modules.
|
|
91
64
|
// `buildModules` is the single source of truth for the module list — it
|
|
@@ -99,20 +72,20 @@ await (async (): Promise<void> => {
|
|
|
99
72
|
},
|
|
100
73
|
});
|
|
101
74
|
|
|
102
|
-
// Step
|
|
75
|
+
// Step 4: Wire host-level routes (healthcheck) before the auth router so
|
|
103
76
|
// they remain reachable even when the auth pipeline is degraded.
|
|
104
77
|
app.get("/_healthcheck", (_req, res) => {
|
|
105
78
|
res.status(200).json({ status: "ok" });
|
|
106
79
|
});
|
|
107
80
|
|
|
108
|
-
// Step
|
|
81
|
+
// Step 5: Mount the composed auth router and start the HTTP server.
|
|
109
82
|
app.use(handle.router);
|
|
110
83
|
const server = app.listen(config.http.port, (): void => {
|
|
111
84
|
logger.info(`Server is running on http://localhost:${config.http.port}`);
|
|
112
85
|
});
|
|
113
86
|
|
|
114
|
-
// Step
|
|
115
|
-
// per-component cleanup (per A2-β §8.1)
|
|
116
|
-
//
|
|
87
|
+
// Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
|
|
88
|
+
// per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
|
|
89
|
+
// (Redis clients, interval timers).
|
|
117
90
|
gracefulShutdown(server, () => handle.dispose());
|
|
118
91
|
})();
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
defaultRefreshTokenFamilyRevocationModule,
|
|
19
19
|
defaultRefreshTokenFamilyRotationModule,
|
|
20
20
|
type Module,
|
|
21
|
-
|
|
21
|
+
memoryRateLimiterModule,
|
|
22
22
|
} from "@o3co/auth-provider-core";
|
|
23
23
|
import { googleFederationModule } from "@o3co/auth-provider-federation-google";
|
|
24
24
|
import {
|
|
@@ -26,12 +26,21 @@ import {
|
|
|
26
26
|
oauthModule,
|
|
27
27
|
oauthSessionModule,
|
|
28
28
|
} from "@o3co/auth-provider-oauth";
|
|
29
|
-
import { sessionModule } from "@o3co/auth-provider-session";
|
|
30
29
|
import {
|
|
30
|
+
redisCodeRepositoryModule,
|
|
31
|
+
redisRateLimiterModule,
|
|
32
|
+
redisRefreshTokenFamilyStoreModule,
|
|
33
|
+
redisSessionStoresModule,
|
|
34
|
+
} from "@o3co/auth-provider-redis";
|
|
35
|
+
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
36
|
+
import {
|
|
37
|
+
federationTokenStoreModule,
|
|
31
38
|
googleFederationConfigModule,
|
|
39
|
+
inMemoryCodeRepositoryModule,
|
|
40
|
+
inMemorySessionStoresModule,
|
|
32
41
|
keyStoreModule,
|
|
33
42
|
repositoriesModule,
|
|
34
|
-
|
|
43
|
+
standaloneRedisClientsModule,
|
|
35
44
|
} from "./modules.mjs";
|
|
36
45
|
|
|
37
46
|
/**
|
|
@@ -43,6 +52,18 @@ export interface BuildModulesOverrides {
|
|
|
43
52
|
readonly keyStoreModule?: Module;
|
|
44
53
|
readonly repositoriesModule?: Module;
|
|
45
54
|
readonly storesModule?: Module;
|
|
55
|
+
/**
|
|
56
|
+
* D-2 v2 / Wave 5d: override the RT family store module + (when adapter
|
|
57
|
+
* is `"redis"`) the bundled redis-clients + redis-store pair. Default
|
|
58
|
+
* production manifest is `[standaloneRedisClientsModule, redisRefreshTokenFamilyStoreModule]`
|
|
59
|
+
* (single shared ioredis socket per replica via the F4 PR2 unification).
|
|
60
|
+
*
|
|
61
|
+
* Smoke tests / unit tests that don't want to open an ioredis connection
|
|
62
|
+
* pass `[memoryRefreshTokenFamilyStoreModule]` here. The override REPLACES
|
|
63
|
+
* the entire group — when the override is provided, `standaloneRedisClientsModule`
|
|
64
|
+
* is dropped from the manifest unless the override list includes it.
|
|
65
|
+
*/
|
|
66
|
+
readonly refreshTokenFamilyModules?: readonly Module[];
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
/**
|
|
@@ -62,7 +83,85 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
62
83
|
const googleEnabled =
|
|
63
84
|
(config.federations?.google as { enabled?: boolean } | undefined)?.enabled === true;
|
|
64
85
|
|
|
86
|
+
// Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
|
|
87
|
+
// the OAuth-endpoint rate limiter, the user-session-store family, AND
|
|
88
|
+
// the OAuth code repository. The RT family store always uses Redis in
|
|
89
|
+
// the production manifest (D-2 v2 / OR-1) unless
|
|
90
|
+
// `overrides.refreshTokenFamilyModules` swaps it out. When ANY consumer
|
|
91
|
+
// adapter is `"redis"` (or the RT family override includes a
|
|
92
|
+
// Redis-backed store module), the shared `standaloneRedisClientsModule`
|
|
93
|
+
// is added once and provides every per-purpose ComponentMap slot from a
|
|
94
|
+
// single ioredis socket per replica. Memory-only deployments skip it.
|
|
95
|
+
const rateLimiterAdapter = config.rateLimiter?.adapter ?? "memory";
|
|
96
|
+
const userSessionStoresAdapter = config.userSessionStores?.adapter ?? "memory";
|
|
97
|
+
|
|
98
|
+
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
99
|
+
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
100
|
+
// path is honored for one release with a deprecation warn so existing
|
|
101
|
+
// operators relying on `CLIENT_CODE_TYPE=redis` env-var overrides keep
|
|
102
|
+
// working until they migrate. Removed in v0.6.
|
|
103
|
+
const oauthCodeAdapter = config.oauth?.code?.adapter;
|
|
104
|
+
const legacyCodeType = (config.repositories?.code as { type?: string } | undefined)?.type;
|
|
105
|
+
let codeRepositoryAdapter: "memory" | "redis";
|
|
106
|
+
if (oauthCodeAdapter !== undefined) {
|
|
107
|
+
codeRepositoryAdapter = oauthCodeAdapter;
|
|
108
|
+
} else if (legacyCodeType === "redis") {
|
|
109
|
+
console.warn(
|
|
110
|
+
'[buildModules] `repositories.code.type = "redis"` is deprecated; use ' +
|
|
111
|
+
'`oauth.code.adapter = "redis"` instead (will be removed in v0.6).',
|
|
112
|
+
);
|
|
113
|
+
codeRepositoryAdapter = "redis";
|
|
114
|
+
} else {
|
|
115
|
+
codeRepositoryAdapter = "memory";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
|
|
119
|
+
redisRefreshTokenFamilyStoreModule,
|
|
120
|
+
];
|
|
121
|
+
// Detect whether the RT-family override (if any) keeps the Redis-backed
|
|
122
|
+
// store. Default (no override) uses Redis. An override that includes the
|
|
123
|
+
// Redis store module without also wiring `standaloneRedisClientsModule`
|
|
124
|
+
// would otherwise boot-fail on the missing `refreshTokenFamilyClient`
|
|
125
|
+
// component (Copilot review on PR #121).
|
|
126
|
+
const refreshTokenFamilyUsesRedis = refreshTokenFamilyModules.some(
|
|
127
|
+
(m) => m.name === "redis-refresh-token-family-store",
|
|
128
|
+
);
|
|
129
|
+
const usingRedisAnywhere =
|
|
130
|
+
refreshTokenFamilyUsesRedis ||
|
|
131
|
+
rateLimiterAdapter === "redis" ||
|
|
132
|
+
userSessionStoresAdapter === "redis" ||
|
|
133
|
+
codeRepositoryAdapter === "redis";
|
|
134
|
+
|
|
135
|
+
// Federation-token store is always wired; only the 4 user-session
|
|
136
|
+
// stores switch on the adapter. Pre-Wave-5d the redis branch dropped
|
|
137
|
+
// the federation-token-store provider entirely (Copilot review on
|
|
138
|
+
// PR #121); splitting `storesModule` fixes that boot failure.
|
|
139
|
+
const sessionStoresModules: Module[] =
|
|
140
|
+
userSessionStoresAdapter === "redis"
|
|
141
|
+
? [redisSessionStoresModule]
|
|
142
|
+
: overrides.storesModule
|
|
143
|
+
? [overrides.storesModule]
|
|
144
|
+
: [inMemorySessionStoresModule];
|
|
145
|
+
|
|
146
|
+
const rateLimiterModules: Module[] =
|
|
147
|
+
rateLimiterAdapter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
|
|
148
|
+
|
|
149
|
+
// OR-9: code-repository module — mutually exclusive memory/redis pair.
|
|
150
|
+
// Same pattern as sessionStoresModules + rateLimiterModules. The two
|
|
151
|
+
// modules provide the same `codeRepository` slot; including both would
|
|
152
|
+
// be a boot-time slot collision.
|
|
153
|
+
const codeRepositoryModules: Module[] =
|
|
154
|
+
codeRepositoryAdapter === "redis"
|
|
155
|
+
? [redisCodeRepositoryModule]
|
|
156
|
+
: [inMemoryCodeRepositoryModule];
|
|
157
|
+
|
|
65
158
|
return [
|
|
159
|
+
// D-5: sessionStoreModule wires the express-session middleware into the
|
|
160
|
+
// boot-planner-managed lifecycle. **Mount order is enforced by this
|
|
161
|
+
// list position (declarationIndex tie-breaking)** — the module
|
|
162
|
+
// intentionally has no `before`/`after` clause, so it MUST be listed
|
|
163
|
+
// ahead of every session-consuming module here. Do not reorder.
|
|
164
|
+
sessionStoreModule,
|
|
66
165
|
oauthModule({ config }),
|
|
67
166
|
oauthSessionModule({ config }),
|
|
68
167
|
oauthAuthorizationModule({ config }),
|
|
@@ -70,8 +169,24 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
70
169
|
...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
|
|
71
170
|
overrides.keyStoreModule ?? keyStoreModule,
|
|
72
171
|
overrides.repositoriesModule ?? repositoriesModule,
|
|
73
|
-
|
|
74
|
-
|
|
172
|
+
// Shared ioredis clients — only when at least one consumer adapter
|
|
173
|
+
// actually needs Redis. Memory-only deployments skip this so they
|
|
174
|
+
// don't open an unused socket.
|
|
175
|
+
...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
|
|
176
|
+
// Federation-token store — always wired; independent of the
|
|
177
|
+
// `userSessionStores.adapter` switch. Was bundled into `storesModule`
|
|
178
|
+
// pre-Wave-5d; split so the redis session-stores branch doesn't
|
|
179
|
+
// drop the provider.
|
|
180
|
+
federationTokenStoreModule,
|
|
181
|
+
// User-session-store family: redis (multi-replica) or memory (dev).
|
|
182
|
+
...sessionStoresModules,
|
|
183
|
+
// OAuth-endpoint rate limiter: redis (shared counters) or memory.
|
|
184
|
+
...rateLimiterModules,
|
|
185
|
+
// OAuth code repository: redis (multi-replica) or memory (single-instance).
|
|
186
|
+
...codeRepositoryModules,
|
|
187
|
+
// RT family store: redis by default (closes OR-1); override path
|
|
188
|
+
// swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
|
|
189
|
+
...refreshTokenFamilyModules,
|
|
75
190
|
defaultRefreshTokenFamilyRotationModule,
|
|
76
191
|
defaultRefreshTokenFamilyRevocationModule,
|
|
77
192
|
];
|