@o3co/create-auth-provider 0.9.0 → 0.11.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 +110 -7
- package/package.json +4 -4
- package/templates/standalone/.env.example +49 -5
- package/templates/standalone/Dockerfile +59 -16
- package/templates/standalone/README.ja.md +92 -13
- package/templates/standalone/README.md +358 -18
- package/templates/standalone/config/application.conf +132 -4
- package/templates/standalone/config/clients.yaml.example +56 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +132 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +15 -20
- package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -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__/scaffold-assets.test.mts +117 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +213 -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 +78 -10
- 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 +319 -33
- package/templates/standalone/src/shutdown.mts +162 -0
- 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 +12 -11
|
@@ -14,16 +14,23 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import {
|
|
18
|
-
|
|
17
|
+
import {
|
|
18
|
+
type AppConfig,
|
|
19
|
+
AppConfigSchema,
|
|
20
|
+
createApp,
|
|
21
|
+
createHealthcheckRouter,
|
|
22
|
+
createReadinessRouter,
|
|
23
|
+
} from "@o3co/auth-provider-core";
|
|
19
24
|
import { parseFile } from "@o3co/ts.hocon";
|
|
20
25
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
21
26
|
import express from "express";
|
|
22
27
|
import helmet from "helmet";
|
|
23
|
-
|
|
24
|
-
import logger from "#/logger.mjs";
|
|
25
28
|
import { buildModules } from "./buildModules.mjs";
|
|
26
29
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
|
|
30
|
+
import { createAppLogger } from "./logger.mjs";
|
|
31
|
+
import { createMetrics } from "./metrics.mjs";
|
|
32
|
+
import { installGracefulShutdown } from "./shutdown.mjs";
|
|
33
|
+
import { createTerminalErrorHandler } from "./terminalError.mjs";
|
|
27
34
|
|
|
28
35
|
// Step 1: Load and validate application config (HOCON → Zod schema).
|
|
29
36
|
// ENV = CONFIG_ENV || NODE_ENV || "development"; missing {ENV}.conf is fatal.
|
|
@@ -44,9 +51,23 @@ const config: AppConfig = validate(
|
|
|
44
51
|
AppConfigSchema,
|
|
45
52
|
);
|
|
46
53
|
|
|
54
|
+
// The logger is built from config so its level is operator-controlled, and it
|
|
55
|
+
// is wired into `bootstrapComponents` so every module that declares
|
|
56
|
+
// `optional: ["logger"]` receives this one rather than falling back to its own
|
|
57
|
+
// default. Without that slot filled, configuring a level here would change
|
|
58
|
+
// nothing about what the modules emit.
|
|
59
|
+
const logger = createAppLogger(config);
|
|
60
|
+
|
|
47
61
|
await (async (): Promise<void> => {
|
|
48
62
|
// Step 2: Create the Express app and apply base security middleware.
|
|
49
63
|
const app = express();
|
|
64
|
+
// `false` | `true` | a hop count | a list of IPs / CIDR ranges / named
|
|
65
|
+
// ranges — the schema hands Express exactly the shapes `trust proxy`
|
|
66
|
+
// already understands, validated at boot so a typo'd range fails here
|
|
67
|
+
// rather than becoming a rule that silently never matches (#292). Prefer
|
|
68
|
+
// naming the proxy: `true` believes a forwarded client address from anyone
|
|
69
|
+
// who can reach this process, and `req.ip` is what every IP-keyed rate
|
|
70
|
+
// limit is bucketed on.
|
|
50
71
|
app.set("trust proxy", config.http.trustProxy);
|
|
51
72
|
app.use(
|
|
52
73
|
helmet({
|
|
@@ -59,6 +80,12 @@ await (async (): Promise<void> => {
|
|
|
59
80
|
}),
|
|
60
81
|
);
|
|
61
82
|
|
|
83
|
+
// Timing middleware goes ahead of everything so the histogram covers the
|
|
84
|
+
// whole stack — including responses produced by middleware that short-circuits
|
|
85
|
+
// before any route runs, such as the rate limiter's 429s.
|
|
86
|
+
const metrics = createMetrics();
|
|
87
|
+
app.use(metrics.middleware);
|
|
88
|
+
|
|
62
89
|
// Step 3: Boot the auth pipeline.
|
|
63
90
|
// D-5: express-session middleware is now wired by `sessionStoreModule`
|
|
64
91
|
// inside the boot planner. Mount order is enforced by declarationIndex
|
|
@@ -77,17 +104,49 @@ await (async (): Promise<void> => {
|
|
|
77
104
|
bootstrapComponents: {
|
|
78
105
|
config,
|
|
79
106
|
pathResolver: import.meta.resolve,
|
|
107
|
+
logger,
|
|
80
108
|
},
|
|
81
109
|
});
|
|
82
110
|
|
|
83
|
-
// Step 4: Wire host-level routes
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
111
|
+
// Step 4: Wire host-level routes before the auth router so they remain
|
|
112
|
+
// reachable even when the auth pipeline is degraded — which is exactly when
|
|
113
|
+
// an operator needs an answer from them.
|
|
114
|
+
//
|
|
115
|
+
// Liveness: the process is up and its event loop is turning. Deliberately
|
|
116
|
+
// static — restarting the process would not bring Redis back, so a Redis
|
|
117
|
+
// outage must not read as "this container is broken, kill it".
|
|
118
|
+
app.use(createHealthcheckRouter(express));
|
|
119
|
+
|
|
120
|
+
// Readiness: can this replica serve right now? Redis backs sessions,
|
|
121
|
+
// authorization codes and refresh-token families in the deployable
|
|
122
|
+
// defaults, so a replica that has lost it answers 503 here and should be
|
|
123
|
+
// taken out of rotation. Probes are contributed by the builders that own
|
|
124
|
+
// each connection; a memory-only deployment registers none and is always
|
|
125
|
+
// ready.
|
|
126
|
+
app.use(
|
|
127
|
+
createReadinessRouter(express, {
|
|
128
|
+
probes: handle.readinessProbes,
|
|
129
|
+
timeoutMs: config.http.readinessTimeoutMs,
|
|
130
|
+
logger,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Prometheus scrape endpoint. Same reasoning as the probes above: it has to
|
|
135
|
+
// keep answering while the auth pipeline is degraded, because that is when
|
|
136
|
+
// the series matter.
|
|
137
|
+
app.use(
|
|
138
|
+
metrics.route(express, {
|
|
139
|
+
probes: handle.readinessProbes,
|
|
140
|
+
probeTimeoutMs: config.http.readinessTimeoutMs,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
88
143
|
|
|
89
144
|
// Step 5: Mount the composed auth router and start the HTTP server.
|
|
90
145
|
app.use(handle.router);
|
|
146
|
+
// Terminal error handler LAST (#293 item 8): Express routes an error only
|
|
147
|
+
// to handlers registered after the route that threw it. See
|
|
148
|
+
// `terminalError.mts` for what it catches and why.
|
|
149
|
+
app.use(createTerminalErrorHandler(logger));
|
|
91
150
|
const server = app.listen(config.http.port, (): void => {
|
|
92
151
|
logger.info(`Server is running on http://localhost:${config.http.port}`);
|
|
93
152
|
});
|
|
@@ -95,5 +154,14 @@ await (async (): Promise<void> => {
|
|
|
95
154
|
// Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
|
|
96
155
|
// per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
|
|
97
156
|
// (Redis clients, interval timers).
|
|
98
|
-
|
|
157
|
+
//
|
|
158
|
+
// #290: in the template rather than a dependency, because "does SIGTERM
|
|
159
|
+
// wait for in-flight requests, and for how long?" has to be answerable
|
|
160
|
+
// from the code you deploy. See `shutdown.mts` for the stated guarantees
|
|
161
|
+
// and for the deadline the previous implementation did not have. Size
|
|
162
|
+
// `drainTimeoutMs` below your orchestrator's kill grace period.
|
|
163
|
+
installGracefulShutdown(server, {
|
|
164
|
+
logger,
|
|
165
|
+
cleanup: () => handle.dispose(),
|
|
166
|
+
});
|
|
99
167
|
})();
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
defaultRefreshTokenFamilyRotationModule,
|
|
20
20
|
jwksModule,
|
|
21
21
|
type Module,
|
|
22
|
+
memoryAccessTokenDenylistModule,
|
|
22
23
|
memoryRateLimiterModule,
|
|
23
24
|
} from "@o3co/auth-provider-core";
|
|
24
25
|
import { googleFederationModule } from "@o3co/auth-provider-federation-google";
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
oauthSessionModule,
|
|
29
30
|
} from "@o3co/auth-provider-oauth";
|
|
30
31
|
import {
|
|
32
|
+
redisAccessTokenDenylistModule,
|
|
31
33
|
redisCodeRepositoryModule,
|
|
32
34
|
redisRateLimiterModule,
|
|
33
35
|
redisRefreshTokenFamilyStoreModule,
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
} from "@o3co/auth-provider-redis";
|
|
36
38
|
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
37
39
|
import {
|
|
40
|
+
auditSinkModule,
|
|
38
41
|
federationTokenStoreModule,
|
|
39
42
|
googleFederationConfigModule,
|
|
40
43
|
inMemoryCodeRepositoryModule,
|
|
@@ -65,6 +68,18 @@ export interface BuildModulesOverrides {
|
|
|
65
68
|
* is dropped from the manifest unless the override list includes it.
|
|
66
69
|
*/
|
|
67
70
|
readonly refreshTokenFamilyModules?: readonly Module[];
|
|
71
|
+
/**
|
|
72
|
+
* #287: override the module filling the `auditSink` slot. Tests substitute
|
|
73
|
+
* a sink they can assert on; the default writes the audit trail to stdout
|
|
74
|
+
* and is what a deployment gets.
|
|
75
|
+
*
|
|
76
|
+
* The override REPLACES the default — it does not add a second provider,
|
|
77
|
+
* which would be a boot-time slot collision. There is intentionally no way
|
|
78
|
+
* to pass "no audit sink": #304's sink policy is that the trail is always
|
|
79
|
+
* wired, and a composition that genuinely wants events discarded says so by
|
|
80
|
+
* providing a sink that discards them.
|
|
81
|
+
*/
|
|
82
|
+
readonly auditSinkModule?: Module;
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
/**
|
|
@@ -95,6 +110,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
95
110
|
// single ioredis socket per replica. Memory-only deployments skip it.
|
|
96
111
|
const rateLimiterAdapter = config.rateLimiter?.adapter ?? "memory";
|
|
97
112
|
const userSessionStoresAdapter = config.userSessionStores?.adapter ?? "memory";
|
|
113
|
+
// #277: the RFC 7009 access-token denylist. Unlike the switches above there
|
|
114
|
+
// is no "no denylist" branch — `oauthModule` reads the `accessTokenDenylist`
|
|
115
|
+
// slot because it mounts `/oauth/revoke`, and core's boot validator refuses
|
|
116
|
+
// a composition that reads the slot with nothing filling it, since the
|
|
117
|
+
// endpoint would answer 200 while the token kept working. The switch here is
|
|
118
|
+
// only over WHICH denylist.
|
|
119
|
+
const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
|
|
98
120
|
|
|
99
121
|
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
100
122
|
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
@@ -131,7 +153,8 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
131
153
|
refreshTokenFamilyUsesRedis ||
|
|
132
154
|
rateLimiterAdapter === "redis" ||
|
|
133
155
|
userSessionStoresAdapter === "redis" ||
|
|
134
|
-
codeRepositoryAdapter === "redis"
|
|
156
|
+
codeRepositoryAdapter === "redis" ||
|
|
157
|
+
accessTokenDenylistAdapter === "redis";
|
|
135
158
|
|
|
136
159
|
// Federation-token store is always wired; only the 4 user-session
|
|
137
160
|
// stores switch on the adapter. Pre-Wave-5d the redis branch dropped
|
|
@@ -156,6 +179,15 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
156
179
|
? [redisCodeRepositoryModule]
|
|
157
180
|
: [inMemoryCodeRepositoryModule];
|
|
158
181
|
|
|
182
|
+
// #277: mutually-exclusive denylist pair, same shape as the three switches
|
|
183
|
+
// above. The memory branch is a dev convenience and nothing more: it forks
|
|
184
|
+
// per replica, so `deployment.mode = "multi"` refuses it by name (see core's
|
|
185
|
+
// replica-safety guard). The template's own application.conf ships `"redis"`.
|
|
186
|
+
const accessTokenDenylistModules: Module[] =
|
|
187
|
+
accessTokenDenylistAdapter === "redis"
|
|
188
|
+
? [redisAccessTokenDenylistModule]
|
|
189
|
+
: [memoryAccessTokenDenylistModule];
|
|
190
|
+
|
|
159
191
|
return [
|
|
160
192
|
// D-5: sessionStoreModule wires the express-session middleware into the
|
|
161
193
|
// boot-planner-managed lifecycle. **Mount order is enforced by this
|
|
@@ -177,6 +209,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
177
209
|
...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
|
|
178
210
|
overrides.keyStoreModule ?? keyStoreModule,
|
|
179
211
|
overrides.repositoriesModule ?? repositoriesModule,
|
|
212
|
+
// #287: the audit sink, always wired. Unlike every switch above there is
|
|
213
|
+
// no "off" branch — the routes' security events (`token.issued.failure`,
|
|
214
|
+
// `authorize.rejected`, `rate_limit.unavailable`, …) went nowhere in the
|
|
215
|
+
// shipped artifact because the slot was empty and `emitAuditEvent`
|
|
216
|
+
// no-ops when it is. Which sink is a config question
|
|
217
|
+
// (`audit.sink.type`); whether there is one is not.
|
|
218
|
+
overrides.auditSinkModule ?? auditSinkModule,
|
|
180
219
|
// Shared ioredis clients — only when at least one consumer adapter
|
|
181
220
|
// actually needs Redis. Memory-only deployments skip this so they
|
|
182
221
|
// don't open an unused socket.
|
|
@@ -192,6 +231,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
192
231
|
...rateLimiterModules,
|
|
193
232
|
// OAuth code repository: redis (multi-replica) or memory (single-instance).
|
|
194
233
|
...codeRepositoryModules,
|
|
234
|
+
// RFC 7009 access-token denylist: redis (shared revocations) or memory
|
|
235
|
+
// (single-instance dev). Always wired — see the switch above.
|
|
236
|
+
...accessTokenDenylistModules,
|
|
195
237
|
// RT family store: redis by default (closes OR-1); override path
|
|
196
238
|
// swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
|
|
197
239
|
...refreshTokenFamilyModules,
|
|
@@ -31,8 +31,8 @@ const REFERENCE_CONF_SUBPATH = "@o3co/auth-provider-core/reference.conf";
|
|
|
31
31
|
* 3-tier HOCON precedence chain (reference.conf → application.conf → {env}.conf).
|
|
32
32
|
*
|
|
33
33
|
* Primary path: `import.meta.resolve` (Node.js Stability 1.2 RC, unflagged
|
|
34
|
-
* since Node 18.19.0 / 20.6.0 —
|
|
35
|
-
*
|
|
34
|
+
* since Node 18.19.0 / 20.6.0 — well below this scope's Node 22 engine floor,
|
|
35
|
+
* so it is always available). Fallback: `createRequire(import.meta.url).resolve(...)`. The
|
|
36
36
|
* fallback covers two edge cases:
|
|
37
37
|
*
|
|
38
38
|
* 1. A future Node release deprecates or alters the sync form of
|
|
@@ -13,6 +13,94 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import type { AppConfig, AuditSink, Logger } from "@o3co/auth-provider-core";
|
|
17
|
+
import { pino, stdSerializers } from "pino";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The composition root's logger, and the value wired into the `logger`
|
|
21
|
+
* ComponentMap slot that every module reads.
|
|
22
|
+
*
|
|
23
|
+
* pino, emitting newline-delimited JSON on stdout — the shape every log
|
|
24
|
+
* aggregator ingests without a parser, and the reason `Logger` was given
|
|
25
|
+
* pino's two-overload call signature in the first place. A pino instance
|
|
26
|
+
* therefore satisfies `Logger` structurally, with no adapter.
|
|
27
|
+
*
|
|
28
|
+
* Two things this closes:
|
|
29
|
+
*
|
|
30
|
+
* - **The slot was never filled.** Only a value satisfying `Logger` can occupy
|
|
31
|
+
* it, and the logger this template used before exposes four methods with a
|
|
32
|
+
* narrower call shape. So every module that declares `optional: ["logger"]`
|
|
33
|
+
* — oauth, session, dpop, mtls, token-exchange — silently fell back to its
|
|
34
|
+
* own `consoleLogger` default, and nothing an operator configured here
|
|
35
|
+
* reached any of them.
|
|
36
|
+
* - **`logging.level` is honoured.** pino drops sub-threshold calls before
|
|
37
|
+
* formatting, so `trace` / `debug` cost nothing in production rather than
|
|
38
|
+
* being emitted and filtered downstream.
|
|
39
|
+
*
|
|
40
|
+
* `silent` is pino's own name for "emit nothing", so the config vocabulary
|
|
41
|
+
* maps across unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export function createAppLogger(config: AppConfig): Logger {
|
|
44
|
+
return pino({
|
|
45
|
+
name: "provider",
|
|
46
|
+
level: config.logging.level,
|
|
47
|
+
// `err` is pino's conventional key for an Error, and every structured
|
|
48
|
+
// event in this stack uses it — `logger.error({ err }, "…_error")`.
|
|
49
|
+
// Without the serialiser an Error stringifies to `{}` and the stack is
|
|
50
|
+
// lost exactly where it is needed.
|
|
51
|
+
serializers: { err: stdSerializers.err },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The stream the audit trail is written on: `name: "audit"`, newline-delimited
|
|
57
|
+
* JSON on stdout, in the same pino envelope as every other line this template
|
|
58
|
+
* emits. One output style, so an aggregator ingests security events and
|
|
59
|
+
* application logs through one parser and separates them on `name`.
|
|
60
|
+
*
|
|
61
|
+
* **It takes no config, and its level is fixed.** That is the point rather
|
|
62
|
+
* than an oversight. `logging.level` is a diagnostics knob — `warn` is an
|
|
63
|
+
* ordinary production setting and `silent` a legitimate one — and an audit
|
|
64
|
+
* trail is evidence, not diagnostics. Routing audit events through the
|
|
65
|
+
* application logger would mean either of those settings silently deleting
|
|
66
|
+
* the record of who authenticated, what was issued, and what was refused:
|
|
67
|
+
* the same drop #287 is about, reached from the operator's side instead of
|
|
68
|
+
* the scaffold's. Where audit events go is chosen by `audit.sink.type`, and
|
|
69
|
+
* that selector has no "none" (#304).
|
|
70
|
+
*
|
|
71
|
+
* A second pino instance rather than a child of the app logger: a child
|
|
72
|
+
* inherits its parent's level, and depending on a level-override subtlety to
|
|
73
|
+
* keep the audit trail alive is exactly the kind of thing that stops being
|
|
74
|
+
* true in a later refactor without anything failing.
|
|
75
|
+
*/
|
|
76
|
+
export function createAuditLogger(): Logger {
|
|
77
|
+
return pino({
|
|
78
|
+
name: "audit",
|
|
79
|
+
level: "info",
|
|
80
|
+
serializers: { err: stdSerializers.err },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `AuditSink` writing each event through `auditLogger` — the `"logger"` sink
|
|
86
|
+
* kind this template registers and ships as its default.
|
|
87
|
+
*
|
|
88
|
+
* The event is nested under `audit` rather than spread at the top level:
|
|
89
|
+
* `level`, `time`, `name` and `msg` belong to the log envelope, and a future
|
|
90
|
+
* audit field colliding with one of them would corrupt the line for every
|
|
91
|
+
* consumer. The event type doubles as the message so operators alert on the
|
|
92
|
+
* name — `token.issued.failure`, `authorize.rejected` — the same way they
|
|
93
|
+
* already alert on `session_store_redis_error`.
|
|
94
|
+
*
|
|
95
|
+
* Errors are core's problem, not this function's: `emitAuditEvent` dispatches
|
|
96
|
+
* without awaiting and swallows rejections, because audit recording must never
|
|
97
|
+
* add latency to (or fail) an auth flow.
|
|
98
|
+
*/
|
|
99
|
+
export function createLoggerAuditSink(auditLogger: Logger): AuditSink {
|
|
100
|
+
return {
|
|
101
|
+
kind: "logger",
|
|
102
|
+
async record(event) {
|
|
103
|
+
auditLogger.info({ audit: event }, event.type);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|