@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.
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 +177 -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 +43 -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
@@ -9,22 +9,63 @@
9
9
  # Secret format (only for confidential clients — basic / post):
10
10
  # - Plain text: "my-secret"
11
11
  # - Bcrypt hash: "$2b$10$..." (generate with: node -e "require('bcrypt').hash('secret',10).then(console.log)")
12
+ #
13
+ # `firstParty` (#267): REQUIRED to be `true` for a client to use `/authorize`.
14
+ # `/authorize` mints a code as soon as the session is authenticated, with no
15
+ # consent step, so a forced top-level navigation from an attacker's page
16
+ # makes a logged-in user's browser mint one for whatever client is named.
17
+ # That is defensible for clients you operate and only for those. Mark a
18
+ # client `firstParty: true` only if you would be comfortable with it
19
+ # receiving the user's identity without the user being asked.
20
+ #
21
+ # A client without it is refused at `/authorize`, as is one carrying an
22
+ # explicit `firstParty: false`. There is no opt-out: mark every client you
23
+ # operate before pointing it at `/authorize`.
24
+ #
25
+ # `allowPlainPkce` (#273): PKCE is mandatory for EVERY client — confidential
26
+ # ones included — and `S256` is the only method accepted. There is no
27
+ # server-wide setting that changes either half. This per-client flag is the
28
+ # one exception hatch, and only for the method: `allowPlainPkce: true` lets
29
+ # that one client use the RFC 7636 `plain` challenge, which stores the
30
+ # verifier itself as the challenge and so proves nothing against anyone who
31
+ # can read the authorization request. Set it only for a legacy client that
32
+ # genuinely cannot compute SHA-256, and treat it as a migration deadline.
12
33
 
13
34
  my-app:
14
35
  tokenEndpointAuthMethod: "client_secret_basic"
36
+ firstParty: true
15
37
  clientSecret: "$2b$10$REPLACE_WITH_BCRYPT_HASH"
16
38
  allowedRedirectUris:
17
39
  - "http://localhost:3000/callback"
18
40
  allowedScopes:
19
41
  - "read"
20
42
  - "write"
43
+ # What an omitted `scope` parameter grants (#396). Must be a subset of
44
+ # allowedScopes (checked at boot). Without this, a request that omits
45
+ # `scope` is refused with invalid_scope — the old behavior granted the
46
+ # ENTIRE allowlist, which made "forgot to send scope" the maximum grant.
47
+ # Declare what an unscoped request should mean, not the ceiling.
48
+ defaultScopes:
49
+ - "read"
21
50
 
22
51
  # Public client example (e.g., a single-page app or mobile app):
23
52
  # my-spa:
24
53
  # tokenEndpointAuthMethod: "none"
54
+ # firstParty: true
25
55
  # # clientSecret MUST be absent for "none" clients.
26
56
  # allowedRedirectUris:
27
57
  # - "https://app.example/callback"
28
58
  # allowedScopes:
29
59
  # - "openid"
30
60
  # - "profile"
61
+
62
+ # Legacy client that cannot compute SHA-256 (#273). Prefer fixing the client.
63
+ # my-legacy-device:
64
+ # tokenEndpointAuthMethod: "client_secret_basic"
65
+ # firstParty: true
66
+ # clientSecret: "$2b$10$REPLACE_WITH_BCRYPT_HASH"
67
+ # allowPlainPkce: true
68
+ # allowedRedirectUris:
69
+ # - "https://legacy.example/callback"
70
+ # allowedScopes:
71
+ # - "openid"
@@ -0,0 +1,116 @@
1
+ # Production compose example (#293 item 16).
2
+ #
3
+ # `docker-compose.yml` is a DEVELOPMENT file: it builds the `develop` target
4
+ # and bind-mounts ./src and ./config into the container, so copy-paste
5
+ # deploying it ships a hot-reload server running your working tree. This file
6
+ # is the deployable shape: the `runtime` target (compiled JS only, tini,
7
+ # healthcheck), no source mounts, restart policies, and a Redis that is
8
+ # reachable from the app network only and persists to a volume.
9
+ #
10
+ # openssl genpkey -algorithm ed25519 -out jwt-private.pem
11
+ # openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
12
+ # chmod 600 jwt-private.pem
13
+ # docker compose -f docker-compose.production.yml up -d --build
14
+ #
15
+ # The pem pair is required: EdDSA is the default and there is no key-material
16
+ # default, so a deployment that generates none fails at boot naming the keys to
17
+ # set. It reaches the container as a compose secret rather than through
18
+ # `config/`, which the image COPYs — see the `secrets:` block below.
19
+ #
20
+ # What this file deliberately does NOT do:
21
+ #
22
+ # - **TLS.** Terminate it in front (reverse proxy / load balancer), and set
23
+ # HTTP_TRUST_PROXY to that hop's address so `req.ip` — and every IP-keyed
24
+ # rate limit — means the client, not the proxy. See README
25
+ # "Multi-replica deployments".
26
+ # - **Scale out.** This is one replica (DEPLOYMENT_MODE=single). Before
27
+ # `--scale app=N`, set DEPLOYMENT_MODE=multi and read that same README
28
+ # section — boot will then refuse any in-memory store that must be shared.
29
+ # - **Invent secrets.** SESSION_SECRET (and any client secrets referenced by
30
+ # config/clients.yaml) come from `.env`, which is required here — chmod 600
31
+ # it, keep it out of git (`git local-files` or your secret manager of
32
+ # choice), and never bake it into the image.
33
+
34
+ services:
35
+ app:
36
+ build:
37
+ context: .
38
+ target: runtime
39
+ restart: unless-stopped
40
+ ports:
41
+ - "3000:3000"
42
+ depends_on:
43
+ redis:
44
+ condition: service_healthy
45
+ env_file:
46
+ # Required, unlike the dev file: a production boot without
47
+ # SESSION_SECRET (or with the sample one) must fail loudly here, not
48
+ # serve quietly with a guessable secret.
49
+ - path: .env
50
+ required: true
51
+ secrets:
52
+ # The signing keys. EdDSA is the default algorithm and there is no key
53
+ # material default, so the process needs the pem pair at boot — and the
54
+ # `runtime` image deliberately does not contain it: `Dockerfile` COPYs
55
+ # `config/`, so a key parked there is baked into a layer that travels
56
+ # with every image push. Compose secrets mount read-only under
57
+ # /run/secrets and are the reason the env below overrides the
58
+ # `./jwt-*.pem` paths in `.env.example`.
59
+ #
60
+ # Generate them next to this file before the first `up`:
61
+ # openssl genpkey -algorithm ed25519 -out jwt-private.pem
62
+ # openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
63
+ # chmod 600 jwt-private.pem
64
+ - jwt_private_key
65
+ - jwt_public_key
66
+ environment:
67
+ NODE_ENV: production
68
+ DEPLOYMENT_MODE: single
69
+ # Point the process at the mounted secrets rather than the working-copy
70
+ # paths `.env` carries for local runs.
71
+ OAUTH_JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private_key
72
+ OAUTH_JWT_PUBLIC_KEY_PATH: /run/secrets/jwt_public_key
73
+ # `environment` wins over `env_file`, which is the point here:
74
+ # `.env.example` ships SESSION_SECURE=false / SESSION_NAME=auth.sid so a
75
+ # plain-HTTP local run works, and this file requires that same `.env`.
76
+ # Behind the TLS this compose assumes, a non-Secure session cookie is the
77
+ # session handed to anyone who can read one plaintext hop. The default
78
+ # `__Host-` cookie name is restored with it — the prefix browsers only
79
+ # honour on a Secure cookie.
80
+ SESSION_SECURE: "true"
81
+ SESSION_NAME: __Host-auth.session
82
+ # Every Redis-backed store shares the compose-network Redis below.
83
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: redis://redis:6379
84
+ SESSION_STORAGE_TYPE: redis
85
+ SESSION_STORAGE_REDIS_URL: redis://redis:6379
86
+ # The memory rate limiter is per-process and evadable under bucket
87
+ # exhaustion (see README) — production points it at Redis.
88
+ RATE_LIMITER_ADAPTER: redis
89
+
90
+ redis:
91
+ image: redis:7.2-alpine
92
+ restart: unless-stopped
93
+ # No `ports:`, deliberately — the app reaches Redis over the compose
94
+ # network, and nothing else should. The dev file publishes 6379 to
95
+ # loopback for redis-cli convenience; a published production Redis with
96
+ # no AUTH is an incident.
97
+ command: ["redis-server", "--appendonly", "yes"]
98
+ volumes:
99
+ - redis-data:/data
100
+ healthcheck:
101
+ test: ["CMD", "redis-cli", "ping"]
102
+ interval: 5s
103
+ timeout: 3s
104
+ retries: 5
105
+
106
+ volumes:
107
+ redis-data:
108
+
109
+ secrets:
110
+ # File-backed, so the pem pair stays on the host and reaches the container
111
+ # read-only at /run/secrets. Keep both out of git alongside `.env`; a key
112
+ # committed or COPYed into the image is a key you have to rotate.
113
+ jwt_private_key:
114
+ file: ./jwt-private.pem
115
+ jwt_public_key:
116
+ file: ./jwt-public.pem
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
- "node": ">=18.19.0"
7
+ "node": ">=22.0.0"
8
8
  },
9
9
  "packageManager": "pnpm@10.29.3",
10
10
  "type": "module",
@@ -23,33 +23,29 @@
23
23
  "dependencies": {
24
24
  "@o3co/auth-provider-core": "workspace:*",
25
25
  "@o3co/auth-provider-federation-google": "workspace:*",
26
- "@o3co/auth-provider-oauth": "workspace:*",
27
- "@o3co/auth-provider-session": "workspace:*",
28
26
  "@o3co/auth-provider-foundation": "workspace:*",
27
+ "@o3co/auth-provider-oauth": "workspace:*",
29
28
  "@o3co/auth-provider-redis": "workspace:*",
29
+ "@o3co/auth-provider-session": "workspace:*",
30
30
  "@o3co/auth.utils": "^0.0.4",
31
- "@o3co/ts.hocon": "^1.2.0",
32
- "connect-redis": "^9.0.0",
31
+ "@o3co/ts.hocon": "^1.12.0",
32
+ "connect-redis": "^10.0.0",
33
33
  "express": "^5.2.1",
34
34
  "express-session": "^1.19.0",
35
- "helmet": "^8.1.0",
36
- "ioredis": "^5.11.0",
35
+ "helmet": "^8.3.0",
36
+ "ioredis": "^6.0.0",
37
37
  "pino": "^10.3.1",
38
- "redis": "^5.12.1"
38
+ "prom-client": "^15.1.3",
39
+ "redis": "^6.2.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@types/express": "^5.0.6",
42
43
  "@types/express-session": "^1",
43
- "@types/node": "^25.9.1",
44
- "@types/supertest": "^7.2.0",
44
+ "@types/node": "^26.2.0",
45
+ "@types/supertest": "^7.2.1",
45
46
  "supertest": "^7.1.0",
46
- "tsx": "^4.22.3",
47
+ "tsx": "^4.23.10",
47
48
  "typescript": "^5.9.3",
48
- "vitest": "^4.1.8"
49
- },
50
- "pnpm": {
51
- "onlyBuiltDependencies": [
52
- "bcrypt"
53
- ]
49
+ "vitest": "^4.1.10"
54
50
  }
55
51
  }
@@ -0,0 +1,343 @@
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
+ /**
18
+ * #287 — the audit-event pipeline existed and the scaffold wired no sink, so
19
+ * every security-relevant event the routes emit (`token.issued.failure`,
20
+ * `authorize.rejected`, `rate_limit.unavailable`, …) was dropped by the
21
+ * artifact operators actually deploy. `emitAuditEvent` is a no-op when the
22
+ * slot is empty, so nothing failed and nothing warned: the deployment simply
23
+ * had no audit trail.
24
+ *
25
+ * These tests pin the closure from three sides — the sink implementation, the
26
+ * module that resolves it from config, and the manifest that must contain
27
+ * exactly one provider for the slot.
28
+ */
29
+
30
+ import { generateKeyPairSync } from "node:crypto";
31
+ import { readFileSync } from "node:fs";
32
+ import {
33
+ type AppConfig,
34
+ type AuditEvent,
35
+ type AuditSink,
36
+ createApp,
37
+ createKeyStoreFactory,
38
+ defineModule,
39
+ InMemoryClientRepository,
40
+ InMemoryUserRepository,
41
+ type Logger,
42
+ memoryRefreshTokenFamilyStoreModule,
43
+ registerBuiltinKeyStores,
44
+ } from "@o3co/auth-provider-core";
45
+ import express from "express";
46
+ import request from "supertest";
47
+ import { afterEach, describe, expect, it, vi } from "vitest";
48
+ import { buildModules } from "../buildModules.mjs";
49
+ import { createAppLogger, createAuditLogger, createLoggerAuditSink } from "../logger.mjs";
50
+ import { auditSinkModule } from "../modules.mjs";
51
+
52
+ const keyPair = generateKeyPairSync("ed25519", {
53
+ publicKeyEncoding: { type: "spki", format: "pem" },
54
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
55
+ });
56
+
57
+ const baseConfig: AppConfig = {
58
+ http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
59
+ logging: { level: "silent" },
60
+ oauth: {
61
+ jwt: {
62
+ issuer: "https://auth.test",
63
+ signingKey: {
64
+ provider: "local",
65
+ local: {
66
+ algorithm: "EdDSA",
67
+ kid: "v0",
68
+ privateKey: keyPair.privateKey,
69
+ publicKey: keyPair.publicKey,
70
+ previousKeys: [],
71
+ },
72
+ },
73
+ },
74
+ accessToken: { expiresIn: 3600 },
75
+ refreshToken: {
76
+ expiresIn: 86400,
77
+ unknownFamilyPolicy: "reject" as const,
78
+ legacyRtPolicy: "reject" as const,
79
+ },
80
+ grants: {},
81
+ oidcMode: "oidc-required",
82
+ code: { adapter: "memory" as const },
83
+ },
84
+ session: {
85
+ secret: "test-session-secret.at-least-32-bytes.ok",
86
+ name: "auth.sid",
87
+ maxAge: 3600000,
88
+ secure: false,
89
+ sameSite: "lax",
90
+ domain: null,
91
+ storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
92
+ },
93
+ rateLimit: {
94
+ login: { windowMs: 60000, limit: 10 },
95
+ failMode: "open",
96
+ },
97
+ federations: { google: { enabled: false } },
98
+ repositories: {
99
+ client: { type: "yaml", path: "./config/clients.yaml" },
100
+ user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
101
+ code: { type: "memory", defaultExpiresIn: 600 },
102
+ },
103
+ endpoints: { login: { url: "/login" } },
104
+ cors: { allowedOrigins: [] },
105
+ audit: { sink: { type: "logger" } },
106
+ };
107
+
108
+ const CLIENT_ID = "audit-client";
109
+ const CLIENT_SECRET = "audit-secret";
110
+ const BASIC_AUTH = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
111
+
112
+ const testRepositoriesModule = defineModule({
113
+ name: "test:repositories",
114
+ provides: {
115
+ clientRepository: () =>
116
+ new InMemoryClientRepository(
117
+ new Map([
118
+ [
119
+ CLIENT_ID,
120
+ {
121
+ tokenEndpointAuthMethod: "client_secret_basic",
122
+ clientSecret: CLIENT_SECRET,
123
+ allowedRedirectUris: [],
124
+ allowedScopes: [],
125
+ allowedAudiences: [],
126
+ backchannelLogoutSessionRequired: true,
127
+ frontchannelLogoutSessionRequired: true,
128
+ allowedAzpForFederationToken: false,
129
+ },
130
+ ],
131
+ ]),
132
+ ),
133
+ userRepository: () => new InMemoryUserRepository(new Map()),
134
+ },
135
+ });
136
+
137
+ const testKeyStoreModule = defineModule({
138
+ name: "test:key-store",
139
+ requires: ["config"] as const,
140
+ provides: {
141
+ keyStore: async ({ config: c }) => {
142
+ const factory = createKeyStoreFactory();
143
+ registerBuiltinKeyStores(factory);
144
+ return factory.create({
145
+ type: "local",
146
+ ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
147
+ });
148
+ },
149
+ },
150
+ });
151
+
152
+ /** Minimal `Logger` double — the interface is pino's, so only what we assert on. */
153
+ function fakeLogger(): Logger & { info: ReturnType<typeof vi.fn> } {
154
+ const self = {
155
+ trace: vi.fn(),
156
+ debug: vi.fn(),
157
+ info: vi.fn(),
158
+ warn: vi.fn(),
159
+ error: vi.fn(),
160
+ fatal: vi.fn(),
161
+ child: () => self,
162
+ };
163
+ return self as unknown as Logger & { info: ReturnType<typeof vi.fn> };
164
+ }
165
+
166
+ /** Resolve `auditSink` the way the boot planner does: call the module's provider. */
167
+ async function resolveSink(config: AppConfig): Promise<AuditSink> {
168
+ const provider = auditSinkModule.provides?.auditSink;
169
+ if (!provider) throw new Error("auditSinkModule must provide the auditSink slot");
170
+ return (await provider({ config } as never)) as AuditSink;
171
+ }
172
+
173
+ describe("#287: the template's audit sink", () => {
174
+ describe("createLoggerAuditSink — one event, one line, through the app's own JSON stream", () => {
175
+ it("records the event through the injected logger", async () => {
176
+ const logger = fakeLogger();
177
+ const sink = createLoggerAuditSink(logger);
178
+ const event: AuditEvent = {
179
+ timestamp: new Date("2026-08-27T00:00:00Z"),
180
+ type: "authorize.rejected",
181
+ clientId: "c1",
182
+ subject: "u1",
183
+ details: { reason: "client_not_first_party" },
184
+ };
185
+
186
+ await sink.record(event);
187
+
188
+ expect(logger.info).toHaveBeenCalledTimes(1);
189
+ const [payload, message] = logger.info.mock.calls[0] as [Record<string, unknown>, string];
190
+ // The event type doubles as the message so an operator can alert on
191
+ // the name without a JSON path, matching how this template's other
192
+ // structured events are named.
193
+ expect(message).toBe("authorize.rejected");
194
+ expect(payload.audit).toMatchObject({
195
+ type: "authorize.rejected",
196
+ clientId: "c1",
197
+ subject: "u1",
198
+ });
199
+ });
200
+
201
+ it("nests the event under `audit` so it cannot collide with pino's own keys", async () => {
202
+ // `level`, `time`, `name` and `msg` belong to the log envelope. An
203
+ // event spread at the top level would let a future audit field
204
+ // overwrite one of them and corrupt the line for every consumer.
205
+ const logger = fakeLogger();
206
+ await createLoggerAuditSink(logger).record({
207
+ timestamp: new Date("2026-08-27T00:00:00Z"),
208
+ type: "rate_limit.unavailable",
209
+ });
210
+ const [payload] = logger.info.mock.calls[0] as [Record<string, unknown>];
211
+ expect(Object.keys(payload)).toEqual(["audit"]);
212
+ });
213
+
214
+ it("reports the sink kind it was registered under", () => {
215
+ expect(createLoggerAuditSink(fakeLogger()).kind).toBe("logger");
216
+ });
217
+ });
218
+
219
+ describe("the audit trail is not gated by logging.level", () => {
220
+ it("keeps the audit logger at info while the app logger is silenced", () => {
221
+ // An audit trail is evidence, not diagnostics. `LOG_LEVEL=warn` is an
222
+ // ordinary production setting and `silent` is a legitimate one; if
223
+ // either silenced audit events, #287 would be back — the same silent
224
+ // drop, reached from the operator's side instead of the scaffold's.
225
+ const appLogger = createAppLogger({ ...baseConfig, logging: { level: "silent" } });
226
+ const auditLogger = createAuditLogger();
227
+ expect((appLogger as unknown as { level: string }).level).toBe("silent");
228
+ expect((auditLogger as unknown as { level: string }).level).toBe("info");
229
+ });
230
+
231
+ it("names the audit stream so it is separable from application logs", () => {
232
+ const bindings = (
233
+ createAuditLogger() as unknown as { bindings(): Record<string, unknown> }
234
+ ).bindings();
235
+ expect(bindings.name).toBe("audit");
236
+ });
237
+ });
238
+
239
+ describe("auditSinkModule — resolves the sink from config", () => {
240
+ it("resolves the logger sink for the shipped default", async () => {
241
+ expect((await resolveSink(baseConfig)).kind).toBe("logger");
242
+ });
243
+
244
+ it("resolves core's built-in console sink when selected", async () => {
245
+ const sink = await resolveSink({ ...baseConfig, audit: { sink: { type: "console" } } });
246
+ expect(sink.kind).toBe("console");
247
+ });
248
+
249
+ it("still produces a sink when the audit section is absent entirely", async () => {
250
+ // The failure #287 describes is silent absence. A config that says
251
+ // nothing about auditing must land on a sink, not on `undefined`.
252
+ const { audit: _audit, ...withoutAudit } = baseConfig;
253
+ const sink = await resolveSink(withoutAudit as AppConfig);
254
+ expect(sink.kind).toBe("logger");
255
+ });
256
+
257
+ it("refuses an unknown sink type at boot, naming what is registered", async () => {
258
+ // There is no "none" — #304's sink policy. An operator who writes one
259
+ // gets a boot failure naming the sinks that exist, not a silent
260
+ // deployment with no audit trail.
261
+ await expect(
262
+ resolveSink({ ...baseConfig, audit: { sink: { type: "none" } } }),
263
+ ).rejects.toThrow(/none/);
264
+ });
265
+ });
266
+
267
+ describe("buildModules — the slot is filled in the manifest operators deploy", () => {
268
+ it("wires exactly one auditSink provider", () => {
269
+ const modules = buildModules(baseConfig, {
270
+ keyStoreModule: testKeyStoreModule,
271
+ repositoriesModule: testRepositoriesModule,
272
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
273
+ });
274
+ const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
275
+ expect(providers).toHaveLength(1);
276
+ expect(providers[0]?.name).toBe("standalone:audit-sink");
277
+ });
278
+
279
+ it("the shipped application.conf selects a sink, and never 'none'", () => {
280
+ const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
281
+ expect(conf).toMatch(/audit\s*\{[\s\S]*?sink\s*\{[\s\S]*?type\s*=\s*"logger"/);
282
+ expect(conf).not.toMatch(/type\s*=\s*"none"/);
283
+ });
284
+ });
285
+
286
+ describe("end to end: an OAuth failure reaches the sink", () => {
287
+ let handleRef: { dispose(): Promise<void> } | undefined;
288
+
289
+ afterEach(async () => {
290
+ await handleRef?.dispose();
291
+ handleRef = undefined;
292
+ });
293
+
294
+ it("records token.issued.failure for an unsupported grant_type", async () => {
295
+ // Before #287 this assertion could not fail: nothing filled the slot,
296
+ // so `emitAuditEvent` returned without recording anything.
297
+ const logger = fakeLogger();
298
+ const spyAuditModule = defineModule({
299
+ name: "standalone:audit-sink",
300
+ provides: { auditSink: () => createLoggerAuditSink(logger) },
301
+ });
302
+ const handle = await createApp({
303
+ modules: buildModules(baseConfig, {
304
+ keyStoreModule: testKeyStoreModule,
305
+ repositoriesModule: testRepositoriesModule,
306
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
307
+ auditSinkModule: spyAuditModule,
308
+ }),
309
+ bootstrapComponents: { config: baseConfig, pathResolver: (s) => s },
310
+ });
311
+ handleRef = handle;
312
+
313
+ const app = express();
314
+ app.use(handle.router);
315
+ const res = await request(app)
316
+ .post("/oauth/token")
317
+ .set("Authorization", BASIC_AUTH)
318
+ .type("form")
319
+ .send({ grant_type: "definitely-not-a-grant" });
320
+
321
+ expect(res.status).toBe(400);
322
+ // `emitAuditEvent` is fire-and-forget; let the detached promise settle.
323
+ await new Promise((r) => setImmediate(r));
324
+ const types = logger.info.mock.calls.map(
325
+ (call) => (call[0] as { audit: AuditEvent }).audit.type,
326
+ );
327
+ expect(types).toContain("token.issued.failure");
328
+ });
329
+
330
+ it("boots the shipped manifest with its own audit sink wired", async () => {
331
+ const handle = await createApp({
332
+ modules: buildModules(baseConfig, {
333
+ keyStoreModule: testKeyStoreModule,
334
+ repositoriesModule: testRepositoriesModule,
335
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
336
+ }),
337
+ bootstrapComponents: { config: baseConfig, pathResolver: (s) => s },
338
+ });
339
+ handleRef = handle;
340
+ expect(handle).toBeDefined();
341
+ });
342
+ });
343
+ });