@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
@@ -0,0 +1,231 @@
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 { request as httpRequest } from "node:http";
18
+ import express from "express";
19
+ import request from "supertest";
20
+ import { describe, expect, it, vi } from "vitest";
21
+ import { createMetrics } from "../metrics.mjs";
22
+
23
+ function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[1]["probes"] = []) {
24
+ const metrics = createMetrics();
25
+ const app = express();
26
+ app.use(metrics.middleware);
27
+ app.use(metrics.route(express, { probes, probeTimeoutMs: 100 }));
28
+
29
+ const api = express.Router();
30
+ api.get("/widgets/:id", (_req, res) => {
31
+ res.status(200).json({ ok: true });
32
+ });
33
+ api.get("/boom", (_req, res) => {
34
+ res.status(500).json({ ok: false });
35
+ });
36
+ app.use("/api", api);
37
+ return app;
38
+ }
39
+
40
+ describe("GET /metrics", () => {
41
+ it("serves the Prometheus text exposition format", async () => {
42
+ const res = await request(buildApp()).get("/metrics");
43
+
44
+ expect(res.status).toBe(200);
45
+ expect(res.headers["content-type"]).toContain("text/plain");
46
+ // Node process defaults are namespaced so they cannot collide with
47
+ // anything else scraped from the same job.
48
+ expect(res.text).toContain("auth_provider_process_cpu_user_seconds_total");
49
+ });
50
+
51
+ it("records latency by method, route and status", async () => {
52
+ const app = buildApp();
53
+ await request(app).get("/api/widgets/42");
54
+ await request(app).get("/api/boom");
55
+
56
+ const res = await request(app).get("/metrics");
57
+
58
+ expect(res.text).toContain('route="/api/widgets/:id"');
59
+ expect(res.text).toContain('status="200"');
60
+ expect(res.text).toContain('status="500"');
61
+ });
62
+
63
+ it("labels by the route pattern, not the URL, so the label set stays bounded", async () => {
64
+ // Labelling by path would mint a series per id and eventually take down
65
+ // the system that is supposed to be watching this one.
66
+ const app = buildApp();
67
+ for (const id of ["1", "2", "3"]) {
68
+ await request(app).get(`/api/widgets/${id}`);
69
+ }
70
+
71
+ const res = await request(app).get("/metrics");
72
+ const series = res.text
73
+ .split("\n")
74
+ .filter((l) => l.startsWith("http_request_duration_seconds_count"));
75
+
76
+ expect(series.some((l) => l.includes('route="/api/widgets/:id"'))).toBe(true);
77
+ expect(res.text).not.toContain('route="/api/widgets/1"');
78
+ });
79
+
80
+ it("collapses unmatched requests into a single bucket", async () => {
81
+ // 404 probes from the internet carry attacker-chosen paths.
82
+ const app = buildApp();
83
+ await request(app).get(`/nope/${"x".repeat(50)}`);
84
+
85
+ const res = await request(app).get("/metrics");
86
+
87
+ expect(res.text).toContain('route="unmatched"');
88
+ expect(res.text).not.toContain("xxxxx");
89
+ });
90
+
91
+ it("collapses unknown HTTP methods into one label", async () => {
92
+ // req.method is attacker-controlled — Node's HTTP parser accepts any
93
+ // valid token, so `FOO` and `M000001` arrive as readily as `GET`. An
94
+ // unbounded method label is the same cardinality bomb as an unbounded
95
+ // route label, reachable without any access to /metrics. Sent over a real
96
+ // socket because supertest cannot express a non-standard method.
97
+ const app = buildApp();
98
+ const server = app.listen(0);
99
+ const port = (server.address() as { port: number }).port;
100
+ try {
101
+ for (const method of ["FOO", "M000001", "PURGE"]) {
102
+ await new Promise<void>((resolve) => {
103
+ const req = httpRequest(
104
+ { host: "127.0.0.1", port, path: "/api/widgets/1", method },
105
+ (res) => {
106
+ res.resume();
107
+ res.once("end", resolve);
108
+ },
109
+ );
110
+ req.once("error", () => resolve());
111
+ req.end();
112
+ });
113
+ }
114
+
115
+ const res = await request(app).get("/metrics");
116
+
117
+ expect(res.text).toContain('method="other"');
118
+ expect(res.text).not.toContain('method="M000001"');
119
+ expect(res.text).not.toContain('method="FOO"');
120
+ } finally {
121
+ server.close();
122
+ }
123
+ });
124
+
125
+ it("counts a request whose client disconnects before the response finishes", async () => {
126
+ // `close` fires without `finish` when a client or proxy gives up
127
+ // mid-handler, and those are disproportionately the slow and failing
128
+ // requests RED metrics exist to surface.
129
+ const metrics = createMetrics();
130
+ const app = express();
131
+ app.use(metrics.middleware);
132
+ app.use(metrics.route(express, { probes: [], probeTimeoutMs: 100 }));
133
+ app.get("/slow", (_req, res) => {
134
+ // Never responds; the test aborts the socket instead.
135
+ void res;
136
+ });
137
+
138
+ const server = app.listen(0);
139
+ const port = (server.address() as { port: number }).port;
140
+ try {
141
+ const controller = new AbortController();
142
+ const pending = fetch(`http://127.0.0.1:${port}/slow`, {
143
+ signal: controller.signal,
144
+ }).catch(() => undefined);
145
+ await new Promise((resolve) => setTimeout(resolve, 30));
146
+ controller.abort();
147
+ await pending;
148
+ await new Promise((resolve) => setTimeout(resolve, 30));
149
+
150
+ const res = await request(app).get("/metrics");
151
+ const slow = res.text
152
+ .split("\n")
153
+ .filter((l) => l.startsWith("http_request_duration_seconds_count"))
154
+ .filter((l) => l.includes('route="/slow"'));
155
+ expect(slow.length).toBeGreaterThan(0);
156
+ } finally {
157
+ server.close();
158
+ }
159
+ });
160
+
161
+ it("observes a response exactly once even though both finish and close fire", async () => {
162
+ const app = buildApp();
163
+ await request(app).get("/api/widgets/1");
164
+
165
+ const res = await request(app).get("/metrics");
166
+ const line = res.text
167
+ .split("\n")
168
+ .find(
169
+ (l) =>
170
+ l.startsWith("http_request_duration_seconds_count") &&
171
+ l.includes('route="/api/widgets/:id"'),
172
+ );
173
+ expect(line?.trim().endsWith(" 1")).toBe(true);
174
+ });
175
+
176
+ it("publishes a per-dependency gauge from the readiness probes", async () => {
177
+ const app = buildApp([
178
+ { name: "redis", check: async () => "PONG" },
179
+ {
180
+ name: "session-store",
181
+ check: async () => {
182
+ throw new Error("ECONNREFUSED");
183
+ },
184
+ },
185
+ ]);
186
+
187
+ const res = await request(app).get("/metrics");
188
+
189
+ expect(res.text).toContain('auth_dependency_up{dependency="redis"} 1');
190
+ expect(res.text).toContain('auth_dependency_up{dependency="session-store"} 0');
191
+ });
192
+
193
+ it("re-samples dependencies on every scrape instead of caching a verdict", async () => {
194
+ let healthy = true;
195
+ const app = buildApp([
196
+ {
197
+ name: "redis",
198
+ check: async () => {
199
+ if (!healthy) throw new Error("down");
200
+ return "PONG";
201
+ },
202
+ },
203
+ ]);
204
+
205
+ expect((await request(app).get("/metrics")).text).toContain(
206
+ 'auth_dependency_up{dependency="redis"} 1',
207
+ );
208
+ healthy = false;
209
+ expect((await request(app).get("/metrics")).text).toContain(
210
+ 'auth_dependency_up{dependency="redis"} 0',
211
+ );
212
+ });
213
+
214
+ it("joins an in-flight probe rather than issuing one per scrape", async () => {
215
+ // A partitioned backend never answers, and a scrape loop would otherwise
216
+ // stack one command per scrape against the dependency already in trouble.
217
+ const check = vi.fn(() => new Promise(() => {}));
218
+ const app = buildApp([{ name: "redis", check }]);
219
+
220
+ await request(app).get("/metrics");
221
+ await request(app).get("/metrics");
222
+
223
+ expect(check).toHaveBeenCalledTimes(1);
224
+ });
225
+
226
+ it("is never cached", async () => {
227
+ const res = await request(buildApp()).get("/metrics");
228
+
229
+ expect(res.headers["cache-control"]).toBe("no-store");
230
+ });
231
+ });
@@ -31,6 +31,7 @@ vi.mock("ioredis", () => {
31
31
  }
32
32
  on = vi.fn();
33
33
  quit = vi.fn(async () => "OK" as const);
34
+ ping = vi.fn(async () => "PONG" as const);
34
35
  // Minimal command surface — `makeIoredisClients` only invokes these
35
36
  // during construction in some paths; tests never exercise the wrapped
36
37
  // client itself, so most stubs are unused.
@@ -168,7 +169,50 @@ describe("D-2 / standaloneRedisClientsModule", () => {
168
169
  expect(onSpies[0]).toHaveBeenCalledWith("error", expect.any(Function));
169
170
  });
170
171
 
171
- it("declares 'config' as required and 'lifecycleRegistrar' as optional", async () => {
172
+ it("bounds every command and every connect attempt (#286)", async () => {
173
+ // Without these the driver's defaults apply: no command timeout at all,
174
+ // a 10s connect timeout, and 20 reconnect attempts before a queued
175
+ // command is failed. A partition therefore parks in-flight `/token`
176
+ // requests indefinitely instead of erroring, which is what stops the
177
+ // fail-closed rate-limit policy from ever shedding load.
178
+ const { standaloneRedisClientsModule } = await importModule();
179
+ const provides = (
180
+ standaloneRedisClientsModule as unknown as {
181
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
182
+ }
183
+ ).provides;
184
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
185
+
186
+ const options = redisCtorCalls[0]?.options;
187
+ expect(options?.commandTimeout).toBe(1_000);
188
+ expect(options?.connectTimeout).toBe(5_000);
189
+ expect(options?.maxRetriesPerRequest).toBe(3);
190
+ // `lazyConnect: false` is the driver default, but it is declared in
191
+ // SHARED_REDIS_TIMEOUTS as a load-bearing decision (connect at boot,
192
+ // not at first command) — pin it so a future flip has to come here.
193
+ expect(options?.lazyConnect).toBe(false);
194
+ });
195
+
196
+ it("keeps the offline queue ON, explicitly, because one socket serves every purpose (#286)", async () => {
197
+ // `enableOfflineQueue` is a per-CONNECTION option and every adapter in
198
+ // this template draws from the single socket built below, so the
199
+ // "off for the rate limiter, on elsewhere" split is not expressible
200
+ // here. Asserting the explicit `true` rather than accepting the
201
+ // driver default is the point: the value is a decision (see the
202
+ // comment in `getOrCreateClients`), and a future change that wants
203
+ // `false` has to come through this test.
204
+ const { standaloneRedisClientsModule } = await importModule();
205
+ const provides = (
206
+ standaloneRedisClientsModule as unknown as {
207
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
208
+ }
209
+ ).provides;
210
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
211
+
212
+ expect(redisCtorCalls[0]?.options?.enableOfflineQueue).toBe(true);
213
+ });
214
+
215
+ it("declares 'config' as required and both registrars as optional", async () => {
172
216
  const { standaloneRedisClientsModule } = await importModule();
173
217
  const m = standaloneRedisClientsModule as {
174
218
  requires?: readonly string[];
@@ -176,5 +220,36 @@ describe("D-2 / standaloneRedisClientsModule", () => {
176
220
  };
177
221
  expect(m.requires).toContain("config");
178
222
  expect(m.optional).toContain("lifecycleRegistrar");
223
+ expect(m.optional).toContain("readinessRegistrar");
224
+ });
225
+
226
+ it("registers one readiness probe for the shared connection, not one per slot", async () => {
227
+ // Every Redis-backed adapter draws from a single socket, so a probe per
228
+ // consumed slot would report the same connection six times.
229
+ const probes: Array<{ name: string; check: () => Promise<unknown> }> = [];
230
+ const readinessRegistrar = { register: (p: (typeof probes)[number]) => probes.push(p) };
231
+
232
+ const { standaloneRedisClientsModule } = await importModule();
233
+ const provides = (
234
+ standaloneRedisClientsModule as unknown as {
235
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
236
+ }
237
+ ).provides;
238
+ const lifecycleRegistrar: LifecycleRegistrar = { register: () => {} };
239
+
240
+ await provides.refreshTokenFamilyClient({
241
+ config: { ...baseConfig },
242
+ lifecycleRegistrar,
243
+ readinessRegistrar,
244
+ });
245
+ await provides.rateLimiterClient({
246
+ config: { ...baseConfig },
247
+ lifecycleRegistrar,
248
+ readinessRegistrar,
249
+ });
250
+
251
+ expect(redisCtorCalls).toHaveLength(1);
252
+ expect(probes.map((p) => p.name)).toEqual(["redis"]);
253
+ await expect(probes[0]?.check()).resolves.toBe("PONG");
179
254
  });
180
255
  });
@@ -14,11 +14,14 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
+ import { generateKeyPairSync } from "node:crypto";
17
18
  import { readFileSync } from "node:fs";
18
19
  import {
19
20
  type AppConfig,
20
21
  createApp,
22
+ createHealthcheckRouter,
21
23
  createKeyStoreFactory,
24
+ createReadinessRouter,
22
25
  defineModule,
23
26
  generateToken,
24
27
  InMemoryClientRepository,
@@ -33,17 +36,28 @@ import { buildModules } from "../buildModules.mjs";
33
36
 
34
37
  const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
35
38
 
39
+ // #282: the template's shipped default is EdDSA with a published JWKS, so the
40
+ // smoke test signs the way a real deployment of this scaffold does. Generated
41
+ // per run — no key material is committed.
42
+ const smokeKeyPair = generateKeyPairSync("ed25519", {
43
+ publicKeyEncoding: { type: "spki", format: "pem" },
44
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
45
+ });
46
+
36
47
  const config: AppConfig = {
37
- http: { port: 0, trustProxy: false },
48
+ http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
49
+ logging: { level: "silent" },
38
50
  oauth: {
39
51
  jwt: {
52
+ issuer: "https://auth.test",
40
53
  signingKey: {
41
54
  provider: "local",
42
55
  local: {
43
- algorithm: "HS256",
44
- secret: "test-secret-for-smoke-test",
56
+ algorithm: "EdDSA",
45
57
  kid: "v0",
46
- previousSecrets: [],
58
+ privateKey: smokeKeyPair.privateKey,
59
+ publicKey: smokeKeyPair.publicKey,
60
+ previousKeys: [],
47
61
  },
48
62
  },
49
63
  },
@@ -55,6 +69,8 @@ const config: AppConfig = {
55
69
  },
56
70
  grants: {},
57
71
  oidcMode: "oidc-required",
72
+ // #330: no `authorize` section — the `allowUnmarkedClients` migration
73
+ // flag was removed; the first-party invariant is unconditional.
58
74
  // OR-9: explicit memory adapter for the smoke test. The legacy
59
75
  // `repositories.code.type` fallback would also pick memory here,
60
76
  // but the explicit setting documents the intent and avoids
@@ -62,7 +78,8 @@ const config: AppConfig = {
62
78
  code: { adapter: "memory" as const },
63
79
  },
64
80
  session: {
65
- secret: "test-session-secret",
81
+ // #282: `session.secret` carries a 256-bit entropy floor.
82
+ secret: "test-session-secret.at-least-32-bytes.ok",
66
83
  name: "auth.sid",
67
84
  maxAge: 3600000,
68
85
  secure: false,
@@ -173,9 +190,13 @@ describe("standalone smoke test", () => {
173
190
  });
174
191
 
175
192
  const app = express();
176
- app.get("/_healthcheck", (_req, res) => {
177
- res.status(200).json({ status: "ok" });
178
- });
193
+ app.use(createHealthcheckRouter(express));
194
+ app.use(
195
+ createReadinessRouter(express, {
196
+ probes: handle.readinessProbes,
197
+ timeoutMs: config.http.readinessTimeoutMs,
198
+ }),
199
+ );
179
200
  app.use(handle.router);
180
201
  return { app, handle };
181
202
  }
@@ -208,13 +229,36 @@ describe("standalone smoke test", () => {
208
229
  expect(dockerfile).toMatch(
209
230
  /CMD\s+wget\s+-q\s+-O\s+\/dev\/null\s+"http:\/\/localhost:[$]\{HTTP_PORT\}\/_healthcheck"\s+\|\|\s+exit\s+1/,
210
231
  );
211
- expect(dockerfile).toMatch(/FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install/);
232
+ expect(dockerfile).toMatch(
233
+ /FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
234
+ );
212
235
  expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
236
+ // #289: dependencies are resolved exactly once, in `deps`. The runtime
237
+ // stage must take its node_modules from a prune of that same tree — a
238
+ // second `pnpm install` would be a second resolution, so its absence
239
+ // after the runtime FROM is part of the contract.
240
+ expect(dockerfile).toMatch(
241
+ /FROM deps AS prod-deps[\s\S]*USER node[\s\S]*RUN pnpm prune --prod/,
242
+ );
213
243
  expect(dockerfile).toMatch(
214
- /FROM node-base AS runtime[\s\S]*USER node[\s\S]*RUN pnpm install --prod/,
244
+ /FROM node-base AS runtime[\s\S]*COPY --from=prod-deps[\s\S]*USER node/,
245
+ );
246
+ expect(dockerfile.slice(dockerfile.indexOf("FROM node-base AS runtime"))).not.toContain(
247
+ "pnpm install",
215
248
  );
216
249
  });
217
250
 
251
+ it("Dockerfile pins its mutable build inputs (#289)", () => {
252
+ // Base image by digest — the tag alone is a moving pointer, and
253
+ // Dependabot's docker ecosystem bumps tag and digest together.
254
+ expect(dockerfile).toMatch(/FROM node:24-alpine@sha256:[0-9a-f]{64} AS node-base/);
255
+ // Global corepack by version — `npm install -g corepack` with no pin
256
+ // resolved whatever was latest at build time.
257
+ expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
258
+ // The lockfile is a required build input for --frozen-lockfile.
259
+ expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
260
+ });
261
+
218
262
  it("GET /_healthcheck returns 200", async () => {
219
263
  const { app, handle } = await buildApp();
220
264
  handleRef = handle;
@@ -222,6 +266,39 @@ describe("standalone smoke test", () => {
222
266
  expect(res.status).toBe(200);
223
267
  });
224
268
 
269
+ it("GET /readyz returns 200 for a memory-only composition with no probes", async () => {
270
+ // These smoke modules are all in-memory, so nothing registers a probe.
271
+ // Readiness must not invent a dependency that is not wired.
272
+ const { app, handle } = await buildApp();
273
+ handleRef = handle;
274
+ const res = await request(app).get("/readyz");
275
+ expect(res.status).toBe(200);
276
+ expect(res.body).toEqual({ status: "ready", checks: [] });
277
+ });
278
+
279
+ it("GET /readyz returns 503 naming the dependency when a probe fails", async () => {
280
+ const { handle } = await buildApp();
281
+ handleRef = handle;
282
+ const app = express();
283
+ app.use(
284
+ createReadinessRouter(express, {
285
+ probes: [
286
+ {
287
+ name: "redis",
288
+ check: async () => {
289
+ throw new Error("ECONNREFUSED");
290
+ },
291
+ },
292
+ ],
293
+ timeoutMs: config.http.readinessTimeoutMs,
294
+ }),
295
+ );
296
+ const res = await request(app).get("/readyz");
297
+ expect(res.status).toBe(503);
298
+ expect(res.body.status).toBe("unready");
299
+ expect(res.body.checks[0].name).toBe("redis");
300
+ });
301
+
225
302
  it("issuer-configured buildModules serves the advertised jwks_uri (discovery <-> JWKS presence contract)", async () => {
226
303
  // Guards the cross-module contract end-to-end against the REAL composition
227
304
  // root: core's `jwksModule` contributes `jwks_uri` to discovery AND mounts
@@ -256,6 +333,12 @@ describe("standalone smoke test", () => {
256
333
  const res = await request(app).get(jwksPath);
257
334
  expect(res.status).toBe(200);
258
335
  expect(Array.isArray(res.body.keys)).toBe(true);
336
+ // #282: presence is no longer enough — the advertised jwks_uri must
337
+ // publish an actual verification key, and never the private half.
338
+ expect(res.body.keys).toHaveLength(1);
339
+ expect(res.body.keys[0].alg).toBe("EdDSA");
340
+ expect(res.body.keys[0].kid).toBe("v0");
341
+ expect(res.body.keys[0].d).toBeUndefined();
259
342
  });
260
343
 
261
344
  it("POST /oauth/token with unsupported grant_type returns 400", async () => {
@@ -388,6 +471,81 @@ describe("standalone smoke test", () => {
388
471
  });
389
472
  });
390
473
 
474
+ // #277: the scaffold used to wire NO access-token denylist, so the
475
+ // `/oauth/revoke` it shipped answered 200 for an access token and left the
476
+ // JWT working until expiry. The template now always wires one, and its own
477
+ // application.conf selects the Redis-backed adapter — the memory one forks
478
+ // per replica and `deployment.mode = "multi"` refuses it.
479
+ describe("#277: access-token denylist wiring", () => {
480
+ it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
481
+ const modules = buildModules(config, {
482
+ keyStoreModule: testKeyStoreModule,
483
+ repositoriesModule: testRepositoriesModule,
484
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
485
+ });
486
+ const providesDenylist = modules.filter((m) =>
487
+ Object.keys(m.provides ?? {}).includes("accessTokenDenylist"),
488
+ );
489
+ expect(providesDenylist).toHaveLength(1);
490
+ });
491
+
492
+ it("defaults to the memory denylist when no adapter is configured", () => {
493
+ const modules = buildModules(config, {
494
+ keyStoreModule: testKeyStoreModule,
495
+ repositoriesModule: testRepositoriesModule,
496
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
497
+ });
498
+ const names = modules.map((m) => m.name);
499
+ expect(names).toContain("core-access-token-denylist-memory");
500
+ expect(names).not.toContain("redis-access-token-denylist");
501
+ });
502
+
503
+ it("switches to the Redis denylist when accessTokenDenylist.adapter = 'redis'", () => {
504
+ const redisDenylistConfig = {
505
+ ...config,
506
+ accessTokenDenylist: { adapter: "redis" as const },
507
+ };
508
+ const modules = buildModules(redisDenylistConfig, {
509
+ keyStoreModule: testKeyStoreModule,
510
+ repositoriesModule: testRepositoriesModule,
511
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
512
+ });
513
+ const names = modules.map((m) => m.name);
514
+ expect(names).toContain("redis-access-token-denylist");
515
+ expect(names).not.toContain("core-access-token-denylist-memory");
516
+ // The Redis branch must also pull in the shared ioredis socket, or the
517
+ // `accessTokenDenylistClient` slot has no provider and boot fails on a
518
+ // missing component instead of on the thing the operator changed.
519
+ expect(names).toContain("standalone:redis-clients");
520
+ });
521
+
522
+ it("the shipped application.conf selects the replica-safe adapter", () => {
523
+ // The template's own config is the artifact operators deploy. It runs
524
+ // with `deployment.mode = multi` in the umbrella E2E, which refuses
525
+ // every in-memory shared store — so shipping the memory denylist here
526
+ // would be a boot failure in the very stack that proves the scaffold
527
+ // works.
528
+ const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
529
+ expect(conf).toMatch(/accessTokenDenylist\s*\{[\s\S]*?adapter\s*=\s*"redis"/);
530
+ });
531
+
532
+ it("keeps the composition boot-valid end to end", async () => {
533
+ // The core boot guard (#277 step 13.9) refuses a composition that reads
534
+ // the denylist slot without one. A scaffold that trips its own library's
535
+ // guard is not a scaffold.
536
+ const handle = await createApp({
537
+ modules: buildModules(config, {
538
+ keyStoreModule: testKeyStoreModule,
539
+ repositoriesModule: testRepositoriesModule,
540
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
541
+ }),
542
+ bootstrapComponents: { config, pathResolver: (s) => s },
543
+ });
544
+ handleRef = handle;
545
+ expect(handle).toBeDefined();
546
+ });
547
+ });
548
+
391
549
  // OR-9 (Wave 5d): adapter switch for the OAuth code repository. Closes
392
550
  // the deferred Phase 10 module-pattern wrapper for `codeRepository`. The
393
551
  // memory branch wires `inMemoryCodeRepositoryModule`; the redis branch
@@ -476,9 +634,17 @@ describe("standalone smoke test", () => {
476
634
  type: "local",
477
635
  ...(config.oauth.jwt.signingKey.local ?? {}),
478
636
  });
637
+ // The token must carry the deployment's configured `iss`: introspection
638
+ // pins it (RFC 9068 §4), and since #266 every deployment has one.
479
639
  const { token } = await generateToken(
480
640
  {},
481
- { keyStore, subject: "u1", expiresIn: 3600, tokenType: "at+jwt" },
641
+ {
642
+ keyStore,
643
+ subject: "u1",
644
+ expiresIn: 3600,
645
+ tokenType: "at+jwt",
646
+ issuer: config.oauth.jwt.issuer,
647
+ },
482
648
  );
483
649
 
484
650
  const res = await request(app)