@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,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("
|
|
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: "
|
|
44
|
-
secret: "test-secret-for-smoke-test",
|
|
56
|
+
algorithm: "EdDSA",
|
|
45
57
|
kid: "v0",
|
|
46
|
-
|
|
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
|
-
|
|
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.
|
|
177
|
-
|
|
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(
|
|
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]*
|
|
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
|
-
{
|
|
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)
|