@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,96 @@
|
|
|
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 type { Logger } from "@o3co/auth-provider-core";
|
|
17
|
+
import express from "express";
|
|
18
|
+
import request from "supertest";
|
|
19
|
+
import { describe, expect, it, vi } from "vitest";
|
|
20
|
+
import { createTerminalErrorHandler } from "../terminalError.mjs";
|
|
21
|
+
|
|
22
|
+
const makeLogger = () => {
|
|
23
|
+
const error = vi.fn();
|
|
24
|
+
const logger = {
|
|
25
|
+
error,
|
|
26
|
+
warn: vi.fn(),
|
|
27
|
+
info: vi.fn(),
|
|
28
|
+
debug: vi.fn(),
|
|
29
|
+
} as unknown as Logger;
|
|
30
|
+
return { logger, error };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const makeApp = (logger: Logger) => {
|
|
34
|
+
const app = express();
|
|
35
|
+
app.use(express.json());
|
|
36
|
+
app.post("/echo", (_req, res) => {
|
|
37
|
+
res.status(200).json({ ok: true });
|
|
38
|
+
});
|
|
39
|
+
app.get("/boom", () => {
|
|
40
|
+
throw new Error("route exploded");
|
|
41
|
+
});
|
|
42
|
+
app.use(createTerminalErrorHandler(logger));
|
|
43
|
+
return app;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("terminal error handler (#293 item 8)", () => {
|
|
47
|
+
it("answers malformed JSON with the client's 400 in the shared envelope, not Express's HTML page", async () => {
|
|
48
|
+
const { logger, error } = makeLogger();
|
|
49
|
+
const res = await request(makeApp(logger))
|
|
50
|
+
.post("/echo")
|
|
51
|
+
.set("Content-Type", "application/json")
|
|
52
|
+
.send("{not json");
|
|
53
|
+
|
|
54
|
+
expect(res.status).toBe(400);
|
|
55
|
+
expect(res.headers["content-type"]).toContain("application/json");
|
|
56
|
+
expect(res.body.error).toBe("invalid_request");
|
|
57
|
+
// The client's malformed body is not a server error worth an error line.
|
|
58
|
+
expect(error).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the body parser's own status for an over-limit body (413)", async () => {
|
|
62
|
+
const { logger } = makeLogger();
|
|
63
|
+
const app = express();
|
|
64
|
+
app.use(express.json({ limit: 16 }));
|
|
65
|
+
app.post("/echo", (_req, res) => {
|
|
66
|
+
res.status(200).json({ ok: true });
|
|
67
|
+
});
|
|
68
|
+
app.use(createTerminalErrorHandler(logger));
|
|
69
|
+
|
|
70
|
+
const res = await request(app)
|
|
71
|
+
.post("/echo")
|
|
72
|
+
.set("Content-Type", "application/json")
|
|
73
|
+
.send(JSON.stringify({ pad: "x".repeat(64) }));
|
|
74
|
+
|
|
75
|
+
expect(res.status).toBe(413);
|
|
76
|
+
expect(res.body.error).toBe("invalid_request");
|
|
77
|
+
expect(res.body.error_description).toBe("request body too large");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("answers an unexpected route throw with 500 server_error and logs it structurally", async () => {
|
|
81
|
+
const { logger, error } = makeLogger();
|
|
82
|
+
const res = await request(makeApp(logger)).get("/boom");
|
|
83
|
+
|
|
84
|
+
expect(res.status).toBe(500);
|
|
85
|
+
expect(res.body).toEqual({
|
|
86
|
+
error: "server_error",
|
|
87
|
+
error_description: "Internal server error",
|
|
88
|
+
});
|
|
89
|
+
// No stack trace on the wire — the envelope is the whole body.
|
|
90
|
+
expect(JSON.stringify(res.body)).not.toContain("route exploded");
|
|
91
|
+
expect(error).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({ endpoint: "/boom" }),
|
|
93
|
+
"unhandled_request_error",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -19,6 +19,7 @@ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
|
|
|
19
19
|
import { parseFile } from "@o3co/ts.hocon";
|
|
20
20
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
21
21
|
import { describe, expect, it } from "vitest";
|
|
22
|
+
import { buildModules } from "../buildModules.mjs";
|
|
22
23
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
23
24
|
|
|
24
25
|
// config/ is two levels above this test file:
|
|
@@ -26,10 +27,13 @@ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPa
|
|
|
26
27
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
27
28
|
|
|
28
29
|
// Provide required secrets so AppConfigSchema parse succeeds. These are
|
|
29
|
-
// test-only values — no real keys are embedded here.
|
|
30
|
+
// test-only values — no real keys are embedded here. #282: SESSION_SECRET
|
|
31
|
+
// carries a 256-bit entropy floor, so these clear it (the '.' characters keep
|
|
32
|
+
// them outside the base64 alphabet, so the UTF-8 length is what counts).
|
|
30
33
|
const testEnv = {
|
|
31
|
-
OAUTH_JWT_SECRET: "test-secret-three-tier",
|
|
32
|
-
|
|
34
|
+
OAUTH_JWT_SECRET: "test-secret-three-tier.at-least-32-bytes.ok",
|
|
35
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
36
|
+
SESSION_SECRET: "test-session-secret-three-tier.at-least-32-bytes.ok",
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): AppConfig {
|
|
@@ -75,10 +79,15 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
75
79
|
// so OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED=false reaches the resolved value.
|
|
76
80
|
// Without the repeated env line, the substitution at reference.conf is
|
|
77
81
|
// shadowed by the template's literal `true`.
|
|
78
|
-
// Note: ts.hocon substitutes env vars as strings
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
+
// Note: ts.hocon substitutes env vars as strings, and `oauth.grants` is
|
|
83
|
+
// `z.object({}).passthrough()` — an open tree each grant module's own
|
|
84
|
+
// `configSchema` validates — so no shape-walking coercion reaches this
|
|
85
|
+
// leaf and the string survives to the resolved config. #288 unified the
|
|
86
|
+
// coercion for every DECLARED boolean in the schema and deliberately
|
|
87
|
+
// left this one alone: every consumer of a grant's `enabled`
|
|
88
|
+
// (`GrantRegistry.isEnabled`, `oauthAuthorization`, `oauthSession`)
|
|
89
|
+
// already reads `true` and `"true"` as enabled and everything else,
|
|
90
|
+
// `"false"` included, as not-enabled.
|
|
82
91
|
const config = buildResolvedConfig("development", {
|
|
83
92
|
OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
|
|
84
93
|
});
|
|
@@ -114,4 +123,105 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
114
123
|
});
|
|
115
124
|
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
116
125
|
});
|
|
126
|
+
|
|
127
|
+
// #287 — the audit trail as the shipped artifact resolves it. The section is
|
|
128
|
+
// declared in core's schema for a load-bearing reason: `AppConfigSchema` is
|
|
129
|
+
// a plain `z.object` and strips what it does not declare, so an undeclared
|
|
130
|
+
// `audit` block would vanish between `parseFile` and `buildModules` and the
|
|
131
|
+
// sink selector would read `undefined` while the config sat in the file
|
|
132
|
+
// looking effective. These assertions run the real three-tier merge, so they
|
|
133
|
+
// fail if either layer stops carrying the key.
|
|
134
|
+
describe("#287: the audit sink as the shipped artifact resolves it", () => {
|
|
135
|
+
it("resolves audit.sink.type to the template's logger sink with nothing set", () => {
|
|
136
|
+
const config = buildResolvedConfig("production");
|
|
137
|
+
expect(config.audit?.sink.type).toBe("logger");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("wires exactly one auditSink provider for that resolved config", () => {
|
|
141
|
+
const modules = buildModules(buildResolvedConfig("production"));
|
|
142
|
+
const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
|
|
143
|
+
expect(providers).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("lets an operator select core's built-in console sink by env var", () => {
|
|
147
|
+
// The env-override line has to be repeated at the template layer or
|
|
148
|
+
// the literal `"logger"` shadows reference.conf's substitution — the
|
|
149
|
+
// same precedence rule the grant tests above pin.
|
|
150
|
+
const config = buildResolvedConfig("production", { AUDIT_SINK_TYPE: "console" });
|
|
151
|
+
expect(config.audit?.sink.type).toBe("console");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("reference.conf's own default is a sink, not a drop", () => {
|
|
155
|
+
// A consumer that resolves against reference.conf alone still lands on
|
|
156
|
+
// a sink. #304's sink policy is stdout JSON, never "none", and the
|
|
157
|
+
// library layer has to hold that on its own — a composition root that
|
|
158
|
+
// forgets to override it must not thereby lose its audit trail.
|
|
159
|
+
const referenceOnly = validate(
|
|
160
|
+
parseFile(resolveLibraryReferenceConfPath(), { env: testEnv }),
|
|
161
|
+
AppConfigSchema,
|
|
162
|
+
);
|
|
163
|
+
expect(referenceOnly.audit?.sink.type).toBe("console");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// #277 — this is the shape the umbrella E2E (o3co/auth) boots: the shipped
|
|
168
|
+
// application.conf, `DEPLOYMENT_MODE=multi`, and one shared ioredis socket.
|
|
169
|
+
// The assertions below are what keep that stack booting: a memory denylist
|
|
170
|
+
// under `multi` is refused by the replica-safety guard, and NO denylist is
|
|
171
|
+
// refused by the #277 guard, so the template has to land on "redis" without
|
|
172
|
+
// the deployment naming it.
|
|
173
|
+
describe("#277: access-token revocation as the shipped artifact resolves it", () => {
|
|
174
|
+
it("resolves accessTokenDenylist.adapter to redis with nothing set", () => {
|
|
175
|
+
const config = buildResolvedConfig("production");
|
|
176
|
+
expect(config.accessTokenDenylist?.adapter).toBe("redis");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("resolves oauth.revocation.accessToken to denylist with nothing set", () => {
|
|
180
|
+
const config = buildResolvedConfig("production");
|
|
181
|
+
expect(config.oauth.revocation?.accessToken).toBe("denylist");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("picks the replica-safe denylist module for that resolved config", () => {
|
|
185
|
+
const names = buildModules(buildResolvedConfig("production")).map((m) => m.name);
|
|
186
|
+
expect(names).toContain("redis-access-token-denylist");
|
|
187
|
+
expect(names).not.toContain("core-access-token-denylist-memory");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("leaves nothing replica-unsafe under the umbrella E2E's environment", async () => {
|
|
191
|
+
// The environment `o3co/auth`'s tests/docker-compose.yml sets. Under
|
|
192
|
+
// `DEPLOYMENT_MODE=multi` the replica-safety guard fails boot naming
|
|
193
|
+
// every in-memory shared store, so the denylist this change adds has to
|
|
194
|
+
// come out of that environment as the Redis one — from the template's
|
|
195
|
+
// own config, since the compose file names no denylist variable.
|
|
196
|
+
// Asserted against the guard's own list rather than a module name, so a
|
|
197
|
+
// rename cannot quietly invalidate it.
|
|
198
|
+
const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
|
|
199
|
+
const config = buildResolvedConfig("production", {
|
|
200
|
+
USER_SESSION_STORES_ADAPTER: "redis",
|
|
201
|
+
RATE_LIMITER_ADAPTER: "redis",
|
|
202
|
+
OAUTH_CODE_ADAPTER: "redis",
|
|
203
|
+
});
|
|
204
|
+
const names = buildModules(config).map((m) => m.name);
|
|
205
|
+
expect(names).toContain("redis-access-token-denylist");
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
expect(REPLICA_UNSAFE_MODULES).not.toContain(name);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("lets a single-instance deployment opt down to memory by env var", () => {
|
|
212
|
+
const config = buildResolvedConfig("production", {
|
|
213
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
214
|
+
});
|
|
215
|
+
expect(config.accessTokenDenylist?.adapter).toBe("memory");
|
|
216
|
+
const names = buildModules(config).map((m) => m.name);
|
|
217
|
+
expect(names).toContain("core-access-token-denylist-memory");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("lets a deployment declare access-token revocation unsupported by env var", () => {
|
|
221
|
+
const config = buildResolvedConfig("production", {
|
|
222
|
+
OAUTH_REVOCATION_ACCESS_TOKEN: "unsupported",
|
|
223
|
+
});
|
|
224
|
+
expect(config.oauth.revocation?.accessToken).toBe("unsupported");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
117
227
|
});
|
|
@@ -15,15 +15,22 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { gracefulShutdown } from "@o3co/auth.utils";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type AppConfig,
|
|
20
|
+
AppConfigSchema,
|
|
21
|
+
createApp,
|
|
22
|
+
createHealthcheckRouter,
|
|
23
|
+
createReadinessRouter,
|
|
24
|
+
} from "@o3co/auth-provider-core";
|
|
19
25
|
import { parseFile } from "@o3co/ts.hocon";
|
|
20
26
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
21
27
|
import express from "express";
|
|
22
28
|
import helmet from "helmet";
|
|
23
|
-
|
|
24
|
-
import logger from "#/logger.mjs";
|
|
25
29
|
import { buildModules } from "./buildModules.mjs";
|
|
26
30
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
|
|
31
|
+
import { createAppLogger } from "./logger.mjs";
|
|
32
|
+
import { createMetrics } from "./metrics.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
|
});
|
|
@@ -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
|
+
}
|