@o3co/create-auth-provider 0.9.0 → 0.10.0
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 +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +99 -6
- package/package.json +4 -4
- package/templates/standalone/.env.example +43 -4
- package/templates/standalone/Dockerfile +51 -16
- package/templates/standalone/README.ja.md +79 -12
- package/templates/standalone/README.md +345 -17
- package/templates/standalone/config/application.conf +121 -4
- package/templates/standalone/config/clients.yaml.example +41 -0
- package/templates/standalone/docker-compose.production.yml +116 -0
- package/templates/standalone/package.json +14 -18
- package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +419 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
- package/templates/standalone/src/__tests__/smoke.test.mts +177 -11
- package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
- package/templates/standalone/src/app.mts +67 -8
- package/templates/standalone/src/buildModules.mts +43 -1
- package/templates/standalone/src/configPath.mts +2 -2
- package/templates/standalone/src/logger.mts +90 -2
- package/templates/standalone/src/metrics.mts +190 -0
- package/templates/standalone/src/modules.mts +281 -33
- package/templates/standalone/src/terminalError.mts +60 -0
- package/templates/standalone/tests/index.test.js +69 -5
- package/templates/standalone/tsconfig.json +9 -1
- package/templates/standalone/vitest.config.mts +8 -0
- package/templates/versions.json +11 -11
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
import type { ReadinessProbe } from "@o3co/auth-provider-core";
|
|
18
|
+
import { runReadinessProbes } from "@o3co/auth-provider-core";
|
|
19
|
+
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
|
|
20
|
+
import { collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Route label for a request.
|
|
24
|
+
*
|
|
25
|
+
* Express only fills `req.route` once a handler has matched, and the label has
|
|
26
|
+
* to be **bounded**: labelling by `req.path` would mint a new time series per
|
|
27
|
+
* distinct URL, and this server's paths carry opaque values (`/oauth/authorize`
|
|
28
|
+
* query state, 404 probes from the internet). An unbounded label set is the
|
|
29
|
+
* classic way to take Prometheus down with the thing that was supposed to
|
|
30
|
+
* watch it. Unmatched requests collapse to a single `unmatched` bucket.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Method label, bounded to the methods this server can actually serve.
|
|
34
|
+
*
|
|
35
|
+
* `req.method` is attacker-controlled: Node's HTTP parser accepts any valid
|
|
36
|
+
* token, so `FOO` and `M000001` reach here as readily as `GET`. Each distinct
|
|
37
|
+
* value would mint a fresh histogram child carrying every bucket — the same
|
|
38
|
+
* unbounded-cardinality failure {@link routeLabel} guards against, one label
|
|
39
|
+
* over, and reachable without any access to `/metrics` itself.
|
|
40
|
+
*/
|
|
41
|
+
const KNOWN_METHODS = new Set([
|
|
42
|
+
"GET",
|
|
43
|
+
"HEAD",
|
|
44
|
+
"POST",
|
|
45
|
+
"PUT",
|
|
46
|
+
"PATCH",
|
|
47
|
+
"DELETE",
|
|
48
|
+
"OPTIONS",
|
|
49
|
+
"TRACE",
|
|
50
|
+
"CONNECT",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function methodLabel(req: Request): string {
|
|
54
|
+
return KNOWN_METHODS.has(req.method) ? req.method : "other";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function routeLabel(req: Request): string {
|
|
58
|
+
const route = (req as Request & { route?: { path?: string } }).route?.path;
|
|
59
|
+
if (typeof route === "string" && route.length > 0) {
|
|
60
|
+
return req.baseUrl ? `${req.baseUrl}${route === "/" ? "" : route}` : route;
|
|
61
|
+
}
|
|
62
|
+
return "unmatched";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MetricsRouteOptions {
|
|
66
|
+
/**
|
|
67
|
+
* Readiness probes, sampled on each scrape to publish a per-dependency
|
|
68
|
+
* up/down gauge. Pass `handle.readinessProbes`.
|
|
69
|
+
*/
|
|
70
|
+
readonly probes: readonly ReadinessProbe[];
|
|
71
|
+
/** Per-probe deadline, in milliseconds. Use `config.http.readinessTimeoutMs`. */
|
|
72
|
+
readonly probeTimeoutMs: number;
|
|
73
|
+
/** Route path. Defaults to `/metrics`. */
|
|
74
|
+
readonly path?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Metrics {
|
|
78
|
+
/**
|
|
79
|
+
* Mount FIRST, ahead of every route: it times the whole downstream stack,
|
|
80
|
+
* and it records on the response's `finish` event so a request that ends in
|
|
81
|
+
* an error handler is still counted.
|
|
82
|
+
*/
|
|
83
|
+
readonly middleware: RequestHandler;
|
|
84
|
+
/**
|
|
85
|
+
* Mounts the scrape endpoint.
|
|
86
|
+
*
|
|
87
|
+
* The probes arrive here rather than at construction because the two halves
|
|
88
|
+
* are needed at different moments: the middleware has to be mounted before
|
|
89
|
+
* the app boots, and the probes only exist afterwards, on the `AppHandle`.
|
|
90
|
+
*/
|
|
91
|
+
readonly route: (express: { Router: () => Router }, options: MetricsRouteOptions) => Router;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Prometheus metrics for the standalone provider.
|
|
96
|
+
*
|
|
97
|
+
* What this publishes is chosen from what an operator cannot otherwise see.
|
|
98
|
+
* Before it, a degraded provider offered stderr lines and the load balancer's
|
|
99
|
+
* 5xx graph — no way to distinguish "rate limiter is failing closed" from
|
|
100
|
+
* "Redis-backed code repository is gone" without grepping container logs, and
|
|
101
|
+
* no latency series to alert on at all.
|
|
102
|
+
*
|
|
103
|
+
* - `http_request_duration_seconds` — histogram by method, bounded route, and
|
|
104
|
+
* status. Carries request rate, error rate and latency in one series, which
|
|
105
|
+
* is the whole RED method.
|
|
106
|
+
* - `auth_dependency_up` — 1/0 per readiness probe, sampled at scrape time.
|
|
107
|
+
* This is the series that separates "Redis is gone" from "the app is slow",
|
|
108
|
+
* and it reuses the probes the builders already registered rather than
|
|
109
|
+
* inventing a second notion of what this deployment depends on.
|
|
110
|
+
* - Node process defaults (event-loop lag, heap, GC, handles) under the
|
|
111
|
+
* `auth_provider_` prefix.
|
|
112
|
+
*
|
|
113
|
+
* Deliberately not here yet: rate-limiter fail-closed counts and audit-sink
|
|
114
|
+
* drops. Both happen inside `@o3co/auth-provider-core` and neither is
|
|
115
|
+
* observable from the composition root today; counting them needs a metrics
|
|
116
|
+
* hook in core (`BuilderContext` already reserves `metrics`) rather than
|
|
117
|
+
* something this file can reach.
|
|
118
|
+
*/
|
|
119
|
+
export function createMetrics(): Metrics {
|
|
120
|
+
const registry = new Registry();
|
|
121
|
+
collectDefaultMetrics({ register: registry, prefix: "auth_provider_" });
|
|
122
|
+
|
|
123
|
+
const requestDuration = new Histogram({
|
|
124
|
+
name: "http_request_duration_seconds",
|
|
125
|
+
help: "HTTP request latency in seconds, by method, route and status.",
|
|
126
|
+
labelNames: ["method", "route", "status"] as const,
|
|
127
|
+
// Tuned for an IdP: token and authorize calls sit in the low tens of
|
|
128
|
+
// milliseconds when healthy, and the tail is what an outage moves.
|
|
129
|
+
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
|
130
|
+
registers: [registry],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
|
|
134
|
+
const endTimer = requestDuration.startTimer();
|
|
135
|
+
let observed = false;
|
|
136
|
+
// Both terminal events, with a guard, rather than `finish` alone.
|
|
137
|
+
// `finish` covers every response the server completes — including ones
|
|
138
|
+
// produced by an error handler, and `req.route` is populated by then. But
|
|
139
|
+
// a client or proxy that disconnects mid-handler emits `close` WITHOUT
|
|
140
|
+
// `finish`, and those are disproportionately the slow and failing requests
|
|
141
|
+
// that RED metrics exist to surface; counting only `finish` would drop
|
|
142
|
+
// them from both the rate and the latency series.
|
|
143
|
+
const observe = () => {
|
|
144
|
+
if (observed) return;
|
|
145
|
+
observed = true;
|
|
146
|
+
endTimer({
|
|
147
|
+
method: methodLabel(req),
|
|
148
|
+
route: routeLabel(req),
|
|
149
|
+
status: String(res.statusCode),
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
res.once("finish", observe);
|
|
153
|
+
res.once("close", observe);
|
|
154
|
+
next();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const dependencyUp = new Gauge({
|
|
158
|
+
name: "auth_dependency_up",
|
|
159
|
+
help: "1 when a backing dependency answered its readiness probe, 0 otherwise.",
|
|
160
|
+
labelNames: ["dependency"] as const,
|
|
161
|
+
registers: [registry],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Shared across scrapes so a partitioned backend does not accumulate one
|
|
165
|
+
// in-flight command per scrape (see `runReadinessProbes`).
|
|
166
|
+
const inFlight = new Map<string, Promise<unknown>>();
|
|
167
|
+
|
|
168
|
+
const route = (express: { Router: () => Router }, opts: MetricsRouteOptions): Router => {
|
|
169
|
+
const router = express.Router();
|
|
170
|
+
router.get(opts.path ?? "/metrics", async (_req: Request, res: Response) => {
|
|
171
|
+
// Sampled per scrape rather than held as state: a cached value would
|
|
172
|
+
// report a dependency healthy for as long as the cache lives, which is
|
|
173
|
+
// exactly the window an incident lives in.
|
|
174
|
+
const report = await runReadinessProbes(opts.probes, {
|
|
175
|
+
timeoutMs: opts.probeTimeoutMs,
|
|
176
|
+
inFlight,
|
|
177
|
+
});
|
|
178
|
+
for (const check of report.checks) {
|
|
179
|
+
dependencyUp.set({ dependency: check.name }, check.ok ? 1 : 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
res.setHeader("Content-Type", registry.contentType);
|
|
183
|
+
res.setHeader("Cache-Control", "no-store");
|
|
184
|
+
res.send(await registry.metrics());
|
|
185
|
+
});
|
|
186
|
+
return router;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return { middleware, route };
|
|
190
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import {
|
|
18
18
|
type AppConfig,
|
|
19
|
+
consoleLogger,
|
|
20
|
+
createAuditSinkFactory,
|
|
19
21
|
createFederationTokenStoreFactory,
|
|
20
22
|
createInMemorySessionFamilyIndex,
|
|
21
23
|
createInMemorySessionFederationIndex,
|
|
@@ -25,7 +27,10 @@ import {
|
|
|
25
27
|
createRepositoryFactories,
|
|
26
28
|
defineModule,
|
|
27
29
|
type LifecycleRegistrar,
|
|
30
|
+
type Logger,
|
|
28
31
|
type Module,
|
|
32
|
+
type ReadinessRegistrar,
|
|
33
|
+
registerBuiltinAuditSinks,
|
|
29
34
|
registerBuiltinFederationTokenStores,
|
|
30
35
|
registerBuiltinKeyStores,
|
|
31
36
|
} from "@o3co/auth-provider-core";
|
|
@@ -34,16 +39,20 @@ import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
|
|
|
34
39
|
import { redisFederationTokenStoreBuilder } from "@o3co/auth-provider-redis";
|
|
35
40
|
import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
|
|
36
41
|
import { extractFederationSection } from "@o3co/auth-provider-session";
|
|
37
|
-
// Named import is required here, not default.
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
42
|
+
// Named import is required here, not default. ioredis is CJS and its entry
|
|
43
|
+
// does `module.exports = Redis` with the class re-exported as both `default`
|
|
44
|
+
// and `Redis`; under `module: "nodenext"` with esModuleInterop the default
|
|
45
|
+
// import therefore resolves to the module-exports namespace object rather
|
|
46
|
+
// than the class, and `new Redis(...)` raises TS2351 "not constructable".
|
|
47
|
+
// Still true on ioredis 6 — verified against 6.0.0, not inherited from the
|
|
48
|
+
// ioredis 5 era this note was first written in.
|
|
41
49
|
// The 11 default-import sites elsewhere in the repo all live under
|
|
42
50
|
// `packages/redis/__tests__/` which `packages/redis/tsconfig.json`
|
|
43
51
|
// explicitly excludes from strict tsc build (vitest's vue-tsc is more
|
|
44
52
|
// permissive about CJS interop). Standalone production source is built
|
|
45
53
|
// with strict nodenext, so the named import is the right shape here.
|
|
46
54
|
import { Redis } from "ioredis";
|
|
55
|
+
import { createAuditLogger, createLoggerAuditSink } from "./logger.mjs";
|
|
47
56
|
|
|
48
57
|
/**
|
|
49
58
|
* Helper: turn a v0.4.x { type, [type]: {...} } adapter-config slice into the
|
|
@@ -218,6 +227,66 @@ export const federationTokenStoreModule: Module = defineModule({
|
|
|
218
227
|
},
|
|
219
228
|
});
|
|
220
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Audit-sink module — fills the `auditSink` slot every route that emits a
|
|
232
|
+
* security event reads (#287).
|
|
233
|
+
*
|
|
234
|
+
* The slot is `optional` on `oauthModule`, `sessionModule` and `webauthnModule`,
|
|
235
|
+
* and `emitAuditEvent` is a no-op when it is empty. That combination is why the
|
|
236
|
+
* gap was invisible: the scaffold wired no sink, so `token.issued.failure`,
|
|
237
|
+
* `authorize.rejected` / `authorize.granted`, `logout.cascade_failed` and the
|
|
238
|
+
* shared rate-limit guard's `rate_limit.unavailable` were all discarded by the
|
|
239
|
+
* deployable artifact, with nothing failing and nothing warning. This module is
|
|
240
|
+
* always in the manifest for that reason — there is no "no audit sink" branch,
|
|
241
|
+
* the way there is a memory/redis branch for the state stores.
|
|
242
|
+
*
|
|
243
|
+
* Two sink kinds are registered:
|
|
244
|
+
*
|
|
245
|
+
* - `"logger"` (the template's default) — one NDJSON line per event on stdout,
|
|
246
|
+
* in the same pino envelope as every other line this template emits, on a
|
|
247
|
+
* stream named `audit` whose level is fixed. See `createAuditLogger`.
|
|
248
|
+
* - `"console"` — core's built-in, from `registerBuiltinAuditSinks`: the bare
|
|
249
|
+
* event as one JSON object per line on stdout, with no log envelope. For a
|
|
250
|
+
* pipeline that wants the event and nothing else.
|
|
251
|
+
*
|
|
252
|
+
* A deployment with a real sink (SIEM, log pipeline, message bus) registers its
|
|
253
|
+
* builder here and names it in `audit.sink.type`; its options ride along in the
|
|
254
|
+
* same config block (`flattenAdapterConfig` unwraps `sink { type = "x", x { … } }`).
|
|
255
|
+
*
|
|
256
|
+
* One thing to know before writing such a builder: `createAuditSinkFactory()`
|
|
257
|
+
* takes no `BuilderContext`, so a sink builder receives `{}` and the
|
|
258
|
+
* `ctx.lifecycle?.register(…)` / `ctx.readiness?.register(…)` calls that
|
|
259
|
+
* CONTRIBUTING.md requires of a builder opening a connection are silent no-ops
|
|
260
|
+
* here. A sink holding a socket must own its cleanup another way until that
|
|
261
|
+
* factory takes a context.
|
|
262
|
+
*
|
|
263
|
+
* There is deliberately no `"none"`. An unknown type — including the `"none"`
|
|
264
|
+
* an operator might reasonably guess at — fails boot naming the sinks that
|
|
265
|
+
* exist, rather than producing a deployment with no audit trail (#304).
|
|
266
|
+
*/
|
|
267
|
+
export const auditSinkModule: Module = defineModule({
|
|
268
|
+
name: "standalone:audit-sink",
|
|
269
|
+
requires: ["config"] as const,
|
|
270
|
+
provides: {
|
|
271
|
+
auditSink: async ({ config }) => {
|
|
272
|
+
const factory = createAuditSinkFactory();
|
|
273
|
+
registerBuiltinAuditSinks(factory);
|
|
274
|
+
factory.register("logger", () => createLoggerAuditSink(createAuditLogger()));
|
|
275
|
+
const slice = (config as AppConfig).audit?.sink;
|
|
276
|
+
// Absence lands on the default sink rather than on `undefined`: a
|
|
277
|
+
// config that says nothing about auditing is not a config asking for
|
|
278
|
+
// the events to be dropped. The literal default lives in HOCON
|
|
279
|
+
// (`reference.conf` / `application.conf`); this is the floor under a
|
|
280
|
+
// hand-built config that never met either.
|
|
281
|
+
return factory.create(
|
|
282
|
+
slice
|
|
283
|
+
? flattenAdapterConfig(slice as { type: string } & Record<string, unknown>)
|
|
284
|
+
: { type: "logger" },
|
|
285
|
+
);
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
221
290
|
/**
|
|
222
291
|
* @deprecated since Wave 5d (F4 PR2): split into `inMemorySessionStoresModule`
|
|
223
292
|
* + `federationTokenStoreModule`. Re-exported for backward compatibility
|
|
@@ -235,10 +304,10 @@ export const storesModule: Module = defineModule({
|
|
|
235
304
|
|
|
236
305
|
/**
|
|
237
306
|
* Shared ioredis clients module — opens ONE long-lived ioredis connection
|
|
238
|
-
* per replica, wraps it via `makeIoredisClients()` (returns
|
|
307
|
+
* per replica, wraps it via `makeIoredisClients()` (returns 11 typed
|
|
239
308
|
* per-purpose clients), and exposes the slots consumed by the standalone's
|
|
240
309
|
* Redis-backed adapters (refresh-token-family + 4 user-session stores +
|
|
241
|
-
* rate limiter).
|
|
310
|
+
* rate limiter + code repository + access-token denylist).
|
|
242
311
|
*
|
|
243
312
|
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
244
313
|
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
@@ -273,26 +342,46 @@ export const storesModule: Module = defineModule({
|
|
|
273
342
|
export const standaloneRedisClientsModule: Module = defineModule({
|
|
274
343
|
name: "standalone:redis-clients",
|
|
275
344
|
requires: ["config"] as const,
|
|
276
|
-
optional: ["lifecycleRegistrar"] as const,
|
|
345
|
+
optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
|
|
277
346
|
provides: {
|
|
278
|
-
refreshTokenFamilyClient: async ({
|
|
279
|
-
|
|
347
|
+
refreshTokenFamilyClient: async ({
|
|
348
|
+
config,
|
|
349
|
+
lifecycleRegistrar,
|
|
350
|
+
readinessRegistrar,
|
|
351
|
+
logger,
|
|
352
|
+
}) => {
|
|
353
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
354
|
+
.refreshTokenFamilyClient;
|
|
280
355
|
},
|
|
281
|
-
userSessionStoreClient: async ({ config, lifecycleRegistrar }) => {
|
|
282
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar)
|
|
356
|
+
userSessionStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
357
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
358
|
+
.userSessionStoreClient;
|
|
283
359
|
},
|
|
284
|
-
sessionRPRegistryClient: async ({ config, lifecycleRegistrar }) => {
|
|
285
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar)
|
|
360
|
+
sessionRPRegistryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
361
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
362
|
+
.sessionRPRegistryClient;
|
|
286
363
|
},
|
|
287
|
-
sessionFamilyIndexClient: async ({
|
|
288
|
-
|
|
364
|
+
sessionFamilyIndexClient: async ({
|
|
365
|
+
config,
|
|
366
|
+
lifecycleRegistrar,
|
|
367
|
+
readinessRegistrar,
|
|
368
|
+
logger,
|
|
369
|
+
}) => {
|
|
370
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
371
|
+
.sessionFamilyIndexClient;
|
|
289
372
|
},
|
|
290
|
-
sessionFederationIndexClient: async ({
|
|
291
|
-
|
|
373
|
+
sessionFederationIndexClient: async ({
|
|
374
|
+
config,
|
|
375
|
+
lifecycleRegistrar,
|
|
376
|
+
readinessRegistrar,
|
|
377
|
+
logger,
|
|
378
|
+
}) => {
|
|
379
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
292
380
|
.sessionFederationIndexClient;
|
|
293
381
|
},
|
|
294
|
-
rateLimiterClient: async ({ config, lifecycleRegistrar }) => {
|
|
295
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar)
|
|
382
|
+
rateLimiterClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
383
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
384
|
+
.rateLimiterClient;
|
|
296
385
|
},
|
|
297
386
|
// OR-9: code-repository client wired off the same shared ioredis
|
|
298
387
|
// socket. `redisCodeRepositoryModule` consumes this slot when
|
|
@@ -300,12 +389,105 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
300
389
|
// high-volume — sharing the connection avoids opening a separate
|
|
301
390
|
// socket while the per-purpose typed wrapper keeps the Redis
|
|
302
391
|
// command surface consumed by RedisCodeRepository explicit.
|
|
303
|
-
codeRepositoryClient: async ({ config, lifecycleRegistrar }) => {
|
|
304
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar)
|
|
392
|
+
codeRepositoryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
393
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
394
|
+
.codeRepositoryClient;
|
|
395
|
+
},
|
|
396
|
+
// #277: access-token denylist client, off the same shared socket. A
|
|
397
|
+
// denylist is only worth having if every replica reads the same one —
|
|
398
|
+
// that is the entire reason `redisAccessTokenDenylistModule` exists — so
|
|
399
|
+
// it belongs on the connection the rest of the shared state already uses
|
|
400
|
+
// rather than opening a second one.
|
|
401
|
+
accessTokenDenylistClient: async ({
|
|
402
|
+
config,
|
|
403
|
+
lifecycleRegistrar,
|
|
404
|
+
readinessRegistrar,
|
|
405
|
+
logger,
|
|
406
|
+
}) => {
|
|
407
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
408
|
+
.accessTokenDenylistClient;
|
|
305
409
|
},
|
|
306
410
|
},
|
|
307
411
|
});
|
|
308
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Failure-timing options for the one shared ioredis socket (#286).
|
|
415
|
+
*
|
|
416
|
+
* On the driver's defaults this connection had no `commandTimeout` at all, so
|
|
417
|
+
* a partition did not produce errors — it produced *waiting*. Every `/token`
|
|
418
|
+
* request that touched Redis parked in the offline queue while the socket
|
|
419
|
+
* reconnected, and because nothing threw, the `rateLimit.failMode = "closed"`
|
|
420
|
+
* policy (`createRateLimitGuard`, OR-5) never engaged: it only fires when
|
|
421
|
+
* `limiter.check()` rejects. The load the policy exists to shed kept arriving
|
|
422
|
+
* and kept accumulating, and memory and sockets grew until ioredis's 20th
|
|
423
|
+
* reconnect attempt finally flushed the queue — tens of seconds later.
|
|
424
|
+
*
|
|
425
|
+
* The values below are chosen against the actual command surface of this
|
|
426
|
+
* socket, which is entirely O(1) primitives and small paged scans
|
|
427
|
+
* (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/`SSCAN`/`ZADD`/…).
|
|
428
|
+
* There is no blocking command here, so no legitimate command has any reason
|
|
429
|
+
* to take a meaningful fraction of a second.
|
|
430
|
+
*
|
|
431
|
+
* - `commandTimeout: 1000` — the hard ceiling on any single command. ioredis
|
|
432
|
+
* arms this timer in `sendCommand`, *before* the writability check, so it
|
|
433
|
+
* bounds a command that went into the offline queue exactly as it bounds one
|
|
434
|
+
* already on the wire. That is what makes the queued case fail rather than
|
|
435
|
+
* hang, and it is also the only guard that survives the nastiest partition
|
|
436
|
+
* shape: a zombie TCP connection where no `close` event ever fires, so the
|
|
437
|
+
* reconnect path described next is never entered at all.
|
|
438
|
+
* - `maxRetriesPerRequest: 3` — on the fourth reconnect attempt (roughly
|
|
439
|
+
* 0.4–1 s in on ioredis 6's jittered exponential backoff) the whole queue is
|
|
440
|
+
* failed in one go with `MaxRetriesPerRequestError`, rather than on the
|
|
441
|
+
* twentieth as the default has it. `commandTimeout` alone bounds each command's
|
|
442
|
+
* *latency*, but the queue would still grow to (request rate × 1 s) entries
|
|
443
|
+
* for as long as the outage lasted; this bounds its *depth*. Three rather
|
|
444
|
+
* than one so an ordinary sub-second reconnect blip is ridden out silently.
|
|
445
|
+
* - `connectTimeout: 5000` — halves the driver's 10 s default. A dropped SYN
|
|
446
|
+
* (a partition that black-holes packets rather than refusing them) is the
|
|
447
|
+
* only case that reaches this ceiling; a real connect on any deployment
|
|
448
|
+
* topology this template targets completes in well under a second.
|
|
449
|
+
* - `enableOfflineQueue: true` — the driver default, declared explicitly
|
|
450
|
+
* because it is a decision, not an inherited value. See below.
|
|
451
|
+
* - `lazyConnect: false` — also the driver default (still, on ioredis 6),
|
|
452
|
+
* declared so the boot-time-connect contract survives a future flip.
|
|
453
|
+
*
|
|
454
|
+
* **Why the offline queue stays on.** Turning it off is the sharper answer for
|
|
455
|
+
* the rate limiter specifically: a command issued while the socket is down
|
|
456
|
+
* rejects immediately instead of after `commandTimeout`, so the fail-closed
|
|
457
|
+
* policy sheds load at the first request rather than one second per request
|
|
458
|
+
* later. But `enableOfflineQueue` is a per-**connection** option, and this
|
|
459
|
+
* template runs every purpose off one socket — refresh-token families, the
|
|
460
|
+
* four user-session stores, the authorization-code repository, the
|
|
461
|
+
* access-token denylist and the rate limiter all come out of a single
|
|
462
|
+
* `makeIoredisClients(io)` call (pinned by "one connection in, one connection
|
|
463
|
+
* used" in `packages/redis/__tests__/ioredis.test.mts`). There is no way to
|
|
464
|
+
* express "off here, on there" without opening a second socket, which is the
|
|
465
|
+
* connection-pool pressure the shared-socket design was built to avoid.
|
|
466
|
+
*
|
|
467
|
+
* So the choice is one setting for all eleven purposes, and `false` is the
|
|
468
|
+
* wrong one to pick for all eleven: it converts every reconnect — including
|
|
469
|
+
* the sub-second blip of a routine managed-Redis failover, during which
|
|
470
|
+
* ioredis would have recovered transparently — into hard errors on session
|
|
471
|
+
* lookup, code redemption and refresh rotation. A failed refresh rotation is
|
|
472
|
+
* not a retryable blip to the end user; it is a re-login.
|
|
473
|
+
*
|
|
474
|
+
* `true` costs the rate limiter up to `commandTimeout` of delay before it
|
|
475
|
+
* starts shedding, and that is the honest trade. It is affordable precisely
|
|
476
|
+
* because `commandTimeout` exists: the pile-up is now bounded by
|
|
477
|
+
* (request rate × 1 s) and self-draining, rather than unbounded and growing.
|
|
478
|
+
* A deployment that wants the sharper behavior gives the rate limiter its own
|
|
479
|
+
* connection in its own composition root — the per-purpose client interfaces
|
|
480
|
+
* exist for exactly that, and it is a deliberate second socket rather than a
|
|
481
|
+
* silent global.
|
|
482
|
+
*/
|
|
483
|
+
const SHARED_REDIS_TIMEOUTS = {
|
|
484
|
+
commandTimeout: 1_000,
|
|
485
|
+
connectTimeout: 5_000,
|
|
486
|
+
maxRetriesPerRequest: 3,
|
|
487
|
+
enableOfflineQueue: true,
|
|
488
|
+
lazyConnect: false,
|
|
489
|
+
} as const;
|
|
490
|
+
|
|
309
491
|
// Module-scoped cache so each `provides.*` factory invocation reuses the
|
|
310
492
|
// same `Redis` instance (and its `makeIoredisClients()` derivation) within
|
|
311
493
|
// a single createApp() invocation. The boot planner calls each `provides`
|
|
@@ -324,7 +506,10 @@ const clientsCache = new WeakMap<LifecycleRegistrar, ReturnType<typeof makeIored
|
|
|
324
506
|
function getOrCreateClients(
|
|
325
507
|
config: AppConfig,
|
|
326
508
|
lifecycleRegistrar: LifecycleRegistrar | undefined,
|
|
509
|
+
readinessRegistrar?: ReadinessRegistrar,
|
|
510
|
+
injectedLogger?: Logger,
|
|
327
511
|
): ReturnType<typeof makeIoredisClients> {
|
|
512
|
+
const logger = injectedLogger ?? consoleLogger;
|
|
328
513
|
const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
|
|
329
514
|
if (cached) return cached;
|
|
330
515
|
|
|
@@ -339,29 +524,57 @@ function getOrCreateClients(
|
|
|
339
524
|
}
|
|
340
525
|
const password = typeof cfg.password === "string" ? cfg.password : undefined;
|
|
341
526
|
|
|
342
|
-
|
|
343
|
-
// documents the boot-time-connect contract so a future ioredis version
|
|
344
|
-
// flip does not silently change the failure mode.
|
|
345
|
-
const io = new Redis(cfg.url, { password, lazyConnect: false });
|
|
527
|
+
const io = new Redis(cfg.url, { password, ...SHARED_REDIS_TIMEOUTS });
|
|
346
528
|
|
|
347
|
-
// Attach error handler so unhandled "error" events
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// swap `console.error` for the Logger interface once a v0.5.2 polish
|
|
351
|
-
// PR lands.
|
|
529
|
+
// Attach an error handler so unhandled "error" events do not crash the
|
|
530
|
+
// process. Initial connection failures surface here; downstream adapter
|
|
531
|
+
// operations then fail visibly.
|
|
352
532
|
io.on("error", (err: unknown) => {
|
|
353
|
-
|
|
533
|
+
logger.error({ err }, "standalone_redis_clients_error");
|
|
354
534
|
});
|
|
355
535
|
|
|
356
536
|
lifecycleRegistrar?.register(async () => {
|
|
357
537
|
await io.quit();
|
|
358
538
|
});
|
|
359
539
|
|
|
360
|
-
|
|
540
|
+
// One probe for the one socket. Registering per consumed slot would report
|
|
541
|
+
// the same connection six times; the cache above means this runs once per
|
|
542
|
+
// boot, on the call that actually constructs the client.
|
|
543
|
+
readinessRegistrar?.register({
|
|
544
|
+
name: "redis",
|
|
545
|
+
check: () => io.ping(),
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// The wrapper opens its own connections for refresh rotation
|
|
549
|
+
// (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
|
|
550
|
+
// `io`; passing the logger lets those report through the same channel.
|
|
551
|
+
const clients = makeIoredisClients(io, { logger });
|
|
361
552
|
if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
|
|
362
553
|
return clients;
|
|
363
554
|
}
|
|
364
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Reads an optional string field off a federation slice.
|
|
558
|
+
*
|
|
559
|
+
* A field the operator did not set stays absent rather than becoming
|
|
560
|
+
* `undefined`, so `"sessionDomain" in config` still distinguishes the two. A
|
|
561
|
+
* field that is present but not a string throws: HOCON hands through whatever
|
|
562
|
+
* shape the file holds, and silently ignoring `sessionDomain = 42` would leave
|
|
563
|
+
* the redirect policy running with one fewer constraint than the operator
|
|
564
|
+
* wrote down.
|
|
565
|
+
*/
|
|
566
|
+
function optionalString(
|
|
567
|
+
slice: Record<string, unknown>,
|
|
568
|
+
field: string,
|
|
569
|
+
): Record<string, string> | Record<string, never> {
|
|
570
|
+
const value = slice[field];
|
|
571
|
+
if (value === undefined || value === null) return {};
|
|
572
|
+
if (typeof value !== "string") {
|
|
573
|
+
throw new Error(`federations.google.${field} must be a string when present`);
|
|
574
|
+
}
|
|
575
|
+
return { [field]: value };
|
|
576
|
+
}
|
|
577
|
+
|
|
365
578
|
/**
|
|
366
579
|
* Google federation config bridge — supplies the typed `googleFederationConfig`
|
|
367
580
|
* ComponentMap slot from the `config.federations.google` slice.
|
|
@@ -369,6 +582,15 @@ function getOrCreateClients(
|
|
|
369
582
|
* Per `@o3co/auth-provider-federation-google` README. Per-federation modules
|
|
370
583
|
* (Phase 7 A5) consume this slot; the bridge is the standalone composition
|
|
371
584
|
* root's responsibility because the slot's content is consumer-specific.
|
|
585
|
+
*
|
|
586
|
+
* #278: this used to return `{ clientId, clientSecret, callbackURL }` and stop
|
|
587
|
+
* there, dropping `redirectAllowlist` / `sessionDomain` / `authCallbackUrl` /
|
|
588
|
+
* `clientUrl` on the floor. Nothing complained — `googleFederationModule` hands
|
|
589
|
+
* this same object to `createFederationRedirectPolicy`, so the redirect policy
|
|
590
|
+
* was built from a config with none of the fields it reads, and a deployment
|
|
591
|
+
* that had configured them ran as though it had not. The generated app is the
|
|
592
|
+
* shape most operators start from, so the safe path has to be the one that
|
|
593
|
+
* works by filling in the config file.
|
|
372
594
|
*/
|
|
373
595
|
export const googleFederationConfigModule: Module = defineModule({
|
|
374
596
|
name: "standalone:google-federation-config",
|
|
@@ -393,7 +615,33 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
393
615
|
"federations.google requires clientId, clientSecret, callbackURL when enabled",
|
|
394
616
|
);
|
|
395
617
|
}
|
|
396
|
-
|
|
618
|
+
|
|
619
|
+
// The allowlist is checked for shape here and for content by
|
|
620
|
+
// `createFederationRedirectPolicy`, which owns the URL rules. This
|
|
621
|
+
// only has to establish that HOCON produced a list of strings —
|
|
622
|
+
// `redirectAllowlist = "https://…"` (a bare string, the natural typo)
|
|
623
|
+
// would otherwise reach the policy as a config it cannot read.
|
|
624
|
+
const rawAllowlist = slice.redirectAllowlist;
|
|
625
|
+
let redirectAllowlist: Record<string, readonly string[]> | Record<string, never> = {};
|
|
626
|
+
if (rawAllowlist !== undefined && rawAllowlist !== null) {
|
|
627
|
+
if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
"federations.google.redirectAllowlist must be a list of URL strings, " +
|
|
630
|
+
'e.g. ["https://app.example.com/welcome"]',
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
redirectAllowlist = { redirectAllowlist: rawAllowlist as readonly string[] };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
clientId,
|
|
638
|
+
clientSecret,
|
|
639
|
+
callbackURL,
|
|
640
|
+
...redirectAllowlist,
|
|
641
|
+
...optionalString(slice, "sessionDomain"),
|
|
642
|
+
...optionalString(slice, "authCallbackUrl"),
|
|
643
|
+
...optionalString(slice, "clientUrl"),
|
|
644
|
+
};
|
|
397
645
|
},
|
|
398
646
|
},
|
|
399
647
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
import { errorEnvelope, type Logger } from "@o3co/auth-provider-core";
|
|
17
|
+
import type { ErrorRequestHandler } from "express";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Terminal Express error handler (#293 item 8).
|
|
21
|
+
*
|
|
22
|
+
* Anything a route threw past its own try/catch — and every body-parser
|
|
23
|
+
* rejection, which fires before any route runs — used to fall through to
|
|
24
|
+
* Express's default handler: an HTML 500 outside the structured-log pipeline,
|
|
25
|
+
* carrying a stack trace outside production. A client that parses only the
|
|
26
|
+
* RFC 6749 §5.2 JSON envelope this surface answers with everywhere else has
|
|
27
|
+
* no way to read that page.
|
|
28
|
+
*
|
|
29
|
+
* Body-parser failures (malformed JSON/form, over-limit, bad charset) carry
|
|
30
|
+
* their own 4xx `status` and are the client's fault: keep the status, wrap it
|
|
31
|
+
* in the shared envelope, and do not log them as server errors. Everything
|
|
32
|
+
* else is logged with the request path and answered `500 server_error`.
|
|
33
|
+
*
|
|
34
|
+
* Mount it AFTER every route (`app.use(handle.router)` included) — Express
|
|
35
|
+
* routes errors only to handlers registered later.
|
|
36
|
+
*/
|
|
37
|
+
export const createTerminalErrorHandler = (logger: Logger): ErrorRequestHandler => {
|
|
38
|
+
return (err, req, res, next) => {
|
|
39
|
+
// A failure after the response started is not ours to rewrite; handing
|
|
40
|
+
// it back lets Express close the connection.
|
|
41
|
+
if (res.headersSent) {
|
|
42
|
+
next(err);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const status = (err as { status?: unknown; statusCode?: unknown } | null) ?? {};
|
|
46
|
+
const httpStatus =
|
|
47
|
+
typeof status.status === "number"
|
|
48
|
+
? status.status
|
|
49
|
+
: typeof status.statusCode === "number"
|
|
50
|
+
? status.statusCode
|
|
51
|
+
: undefined;
|
|
52
|
+
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
|
|
53
|
+
const description = httpStatus === 413 ? "request body too large" : "malformed request body";
|
|
54
|
+
res.status(httpStatus).json(errorEnvelope("invalid_request", description));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
logger.error({ err, endpoint: req.path }, "unhandled_request_error");
|
|
58
|
+
res.status(500).json(errorEnvelope("server_error", "Internal server error"));
|
|
59
|
+
};
|
|
60
|
+
};
|