@o3co/create-auth-provider 0.8.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.
Files changed (31) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +99 -6
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +43 -4
  6. package/templates/standalone/Dockerfile +51 -16
  7. package/templates/standalone/README.ja.md +79 -12
  8. package/templates/standalone/README.md +345 -17
  9. package/templates/standalone/config/application.conf +121 -4
  10. package/templates/standalone/config/clients.yaml.example +41 -0
  11. package/templates/standalone/docker-compose.production.yml +116 -0
  12. package/templates/standalone/package.json +14 -18
  13. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  14. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +419 -0
  15. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  16. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  17. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  18. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  19. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  20. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  21. package/templates/standalone/src/app.mts +67 -8
  22. package/templates/standalone/src/buildModules.mts +51 -1
  23. package/templates/standalone/src/configPath.mts +2 -2
  24. package/templates/standalone/src/logger.mts +90 -2
  25. package/templates/standalone/src/metrics.mts +190 -0
  26. package/templates/standalone/src/modules.mts +281 -33
  27. package/templates/standalone/src/terminalError.mts +60 -0
  28. package/templates/standalone/tests/index.test.js +69 -5
  29. package/templates/standalone/tsconfig.json +9 -1
  30. package/templates/standalone/vitest.config.mts +8 -0
  31. 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
- SESSION_SECRET: "test-session-secret-three-tier",
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; the schema (passthrough)
79
- // preserves the string. The assertion checks the string form so that
80
- // the precedence invariant is testable now; a future schema-coercion PR
81
- // (separate from this one) will make this resolve to boolean false.
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 { type AppConfig, AppConfigSchema, createApp } from "@o3co/auth-provider-core";
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 (healthcheck) before the auth router so
84
- // they remain reachable even when the auth pipeline is degraded.
85
- app.get("/_healthcheck", (_req, res) => {
86
- res.status(200).json({ status: "ok" });
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
  });
@@ -17,7 +17,9 @@ import {
17
17
  type AppConfig,
18
18
  defaultRefreshTokenFamilyRevocationModule,
19
19
  defaultRefreshTokenFamilyRotationModule,
20
+ jwksModule,
20
21
  type Module,
22
+ memoryAccessTokenDenylistModule,
21
23
  memoryRateLimiterModule,
22
24
  } from "@o3co/auth-provider-core";
23
25
  import { googleFederationModule } from "@o3co/auth-provider-federation-google";
@@ -27,6 +29,7 @@ import {
27
29
  oauthSessionModule,
28
30
  } from "@o3co/auth-provider-oauth";
29
31
  import {
32
+ redisAccessTokenDenylistModule,
30
33
  redisCodeRepositoryModule,
31
34
  redisRateLimiterModule,
32
35
  redisRefreshTokenFamilyStoreModule,
@@ -34,6 +37,7 @@ import {
34
37
  } from "@o3co/auth-provider-redis";
35
38
  import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
36
39
  import {
40
+ auditSinkModule,
37
41
  federationTokenStoreModule,
38
42
  googleFederationConfigModule,
39
43
  inMemoryCodeRepositoryModule,
@@ -64,6 +68,18 @@ export interface BuildModulesOverrides {
64
68
  * is dropped from the manifest unless the override list includes it.
65
69
  */
66
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;
67
83
  }
68
84
 
69
85
  /**
@@ -94,6 +110,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
94
110
  // single ioredis socket per replica. Memory-only deployments skip it.
95
111
  const rateLimiterAdapter = config.rateLimiter?.adapter ?? "memory";
96
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";
97
120
 
98
121
  // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
99
122
  // authoritative switch; the legacy `repositories.code.type = "redis"`
@@ -130,7 +153,8 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
130
153
  refreshTokenFamilyUsesRedis ||
131
154
  rateLimiterAdapter === "redis" ||
132
155
  userSessionStoresAdapter === "redis" ||
133
- codeRepositoryAdapter === "redis";
156
+ codeRepositoryAdapter === "redis" ||
157
+ accessTokenDenylistAdapter === "redis";
134
158
 
135
159
  // Federation-token store is always wired; only the 4 user-session
136
160
  // stores switch on the adapter. Pre-Wave-5d the redis branch dropped
@@ -155,6 +179,15 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
155
179
  ? [redisCodeRepositoryModule]
156
180
  : [inMemoryCodeRepositoryModule];
157
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
+
158
191
  return [
159
192
  // D-5: sessionStoreModule wires the express-session middleware into the
160
193
  // boot-planner-managed lifecycle. **Mount order is enforced by this
@@ -165,10 +198,24 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
165
198
  oauthModule({ config }),
166
199
  oauthSessionModule({ config }),
167
200
  oauthAuthorizationModule({ config }),
201
+ // JWKS publishing — always wired (depends only on config + keyStore).
202
+ // Contributed by core's `jwksModule`, NOT oauthModule: a provider that
203
+ // signs tokens must publish its verification keys regardless of OIDC
204
+ // issuer config. Co-installed with oauthModule so the discovery
205
+ // `jwks_uri` (advertised by oauthModule when an issuer is set) always
206
+ // resolves to this mounted route.
207
+ jwksModule,
168
208
  sessionModule,
169
209
  ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
170
210
  overrides.keyStoreModule ?? keyStoreModule,
171
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,
172
219
  // Shared ioredis clients — only when at least one consumer adapter
173
220
  // actually needs Redis. Memory-only deployments skip this so they
174
221
  // don't open an unused socket.
@@ -184,6 +231,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
184
231
  ...rateLimiterModules,
185
232
  // OAuth code repository: redis (multi-replica) or memory (single-instance).
186
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,
187
237
  // RT family store: redis by default (closes OR-1); override path
188
238
  // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
189
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 — the engine floor of every package in this
35
- * scope). Fallback: `createRequire(import.meta.url).resolve(...)`. The
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 { createLogger } from "@o3co/auth.utils";
16
+ import type { AppConfig, AuditSink, Logger } from "@o3co/auth-provider-core";
17
+ import { pino, stdSerializers } from "pino";
17
18
 
18
- export default createLogger("provider");
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
+ }