@o3co/create-auth-policy-verifier 0.3.1 → 0.5.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.
@@ -0,0 +1,415 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /*
5
+ * Tests for the config loading path that main.mts boots from.
6
+ *
7
+ * These read the real files under templates/standalone/config/, so they cover
8
+ * the shipped layering rather than a hand-built object: an env overlay that
9
+ * contains only comments is an empty HOCON document, and it must fall back to
10
+ * application.conf instead of failing to parse. Nothing else in the suite
11
+ * exercises parseFile, so a HOCON parser regression is otherwise invisible
12
+ * until the container fails to start.
13
+ */
14
+ import { mkdtempSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { afterEach, describe, expect, it } from "vitest";
19
+ import { loadAppConfig } from "../loadConfig.js";
20
+
21
+ const configDirPath = fileURLToPath(new URL("../../config/", import.meta.url));
22
+
23
+ /**
24
+ * 64 hex characters — 32 decoded bytes, the entropy floor the schema enforces
25
+ * on every HS256 secret (#114). A short value now fails to load at all.
26
+ */
27
+ const TEST_SECRET = "11".repeat(32);
28
+
29
+ const envKeys = [
30
+ "OAUTH_JWT_SECRET",
31
+ "OAUTH_JWT_ISSUER",
32
+ "OAUTH_JWT_AUDIENCE",
33
+ "OAUTH_JWT_MODE",
34
+ "HTTP_HOSTNAME",
35
+ "HTTP_PORT",
36
+ "HTTP_PATH_PREFIX",
37
+ "HTTP_CALLER_AUTH_TOKEN",
38
+ "HTTP_CALLER_AUTH_HEADER",
39
+ "OAUTH_JWT_JWKS_TIMEOUT_MS",
40
+ "OAUTH_JWT_JWKS_COOLDOWN_MS",
41
+ "OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS",
42
+ "OAUTH_JWT_MAX_TOKEN_AGE_SECONDS",
43
+ "OAUTH_JWT_CLOCK_TOLERANCE_SECONDS",
44
+ "VERIFY_MAX_BATCH_SIZE",
45
+ // Every VERIFY_* the cases below set has to be listed, or it leaks into the
46
+ // tests after it. Only `VERIFY_MAX_BATCH_SIZE` was here while the #118 cases
47
+ // set five more, and the leak was invisible for as long as no later case
48
+ // loaded the shipped config and asserted on `verify` — the first one that
49
+ // did got a boot failure from an env var a test three screens up had set.
50
+ "VERIFY_MAX_BODY_BYTES",
51
+ "VERIFY_MAX_RESOURCE_LENGTH",
52
+ "VERIFY_MAX_ACTION_LENGTH",
53
+ "VERIFY_MAX_CONTEXT_ENTRIES",
54
+ "VERIFY_MAX_CONTEXT_VALUE_LENGTH",
55
+ "VERIFY_COLLECTOR_TIMEOUT_MS",
56
+ "VERIFY_COLLECTOR_DEADLINE_MS",
57
+ "VERIFY_COLLECTOR_CONCURRENCY",
58
+ ] as const;
59
+
60
+ /** application.conf leaves issuer/audience unset, so every load must supply them. */
61
+ function setRequiredEnv(): void {
62
+ process.env.OAUTH_JWT_SECRET = TEST_SECRET;
63
+ process.env.OAUTH_JWT_ISSUER = "https://issuer.test";
64
+ process.env.OAUTH_JWT_AUDIENCE = "https://api.test";
65
+ }
66
+
67
+ afterEach(() => {
68
+ for (const key of envKeys) {
69
+ delete process.env[key];
70
+ }
71
+ });
72
+
73
+ describe("loadAppConfig", () => {
74
+ // Both shipped overlays are comment-only. They parse to an empty document,
75
+ // which withFallback must resolve to the application.conf values.
76
+ it.each(["development", "production"])(
77
+ "resolves the %s overlay against application.conf",
78
+ (env) => {
79
+ setRequiredEnv();
80
+
81
+ const config = loadAppConfig(configDirPath, env);
82
+
83
+ // Loopback by default (#108): the shipped config is a sidecar config,
84
+ // and reaching the port from another host is an explicit opt-in. The
85
+ // callerAuth block exists but carries no token, which means the gate is off.
86
+ expect(config.http).toEqual({
87
+ hostname: "127.0.0.1",
88
+ port: 3000,
89
+ pathPrefix: "",
90
+ callerAuth: { header: "x-caller-token" },
91
+ });
92
+ expect(config.oauth.jwt.algorithm).toBe("HS256");
93
+ expect(config.oauth.jwt.secret).toBe(TEST_SECRET);
94
+ expect(config.oauth.jwt.mode).toBe("verify");
95
+ expect(config.oauth.jwt.issuer).toBe("https://issuer.test");
96
+ expect(config.oauth.jwt.audience).toBe("https://api.test");
97
+ expect(config.oauth.jwt.tokenType).toBe("at+jwt");
98
+ expect(config.verify.maxBatchSize).toBe(50);
99
+ },
100
+ );
101
+
102
+ it("registers the collectors declared in application.conf", () => {
103
+ setRequiredEnv();
104
+
105
+ const config = loadAppConfig(configDirPath, "development");
106
+
107
+ expect(config.attribute.collectors.map((c) => c.collector)).toEqual([
108
+ "PayloadScopeCollector",
109
+ "PayloadSubjectIdCollector",
110
+ ]);
111
+ // Scope-only, and the two lists are one decision (#113). Rule groups are
112
+ // ANDed, so a rule reading an attribute no collector above produces is a
113
+ // group nothing satisfies — which is a verifier that denies every request.
114
+ // A permission rule may only join this list together with its supplier.
115
+ expect(config.rule.collectors.map((c) => c.collector)).toEqual([
116
+ "ResourceActionScopeRuleCollector",
117
+ ]);
118
+ expect(config.resource.parser).toBe("DotNotationResourceParser");
119
+ });
120
+
121
+ it("applies the optional environment substitutions from application.conf", () => {
122
+ setRequiredEnv();
123
+ // 0.0.0.0 is the container opt-out of the loopback default — the value a
124
+ // containerised deployment (and the cross-repo E2E) has to set explicitly.
125
+ process.env.HTTP_HOSTNAME = "0.0.0.0";
126
+ process.env.HTTP_PORT = "8080";
127
+ process.env.HTTP_PATH_PREFIX = "/auth";
128
+
129
+ const config = loadAppConfig(configDirPath, "development");
130
+
131
+ expect(config.http).toMatchObject({
132
+ hostname: "0.0.0.0",
133
+ port: 8080,
134
+ pathPrefix: "/auth",
135
+ });
136
+ });
137
+
138
+ it("enables caller authentication from the environment (#108)", () => {
139
+ setRequiredEnv();
140
+ process.env.HTTP_CALLER_AUTH_TOKEN = "caller-secret";
141
+
142
+ const config = loadAppConfig(configDirPath, "development");
143
+
144
+ expect(config.http.callerAuth).toEqual({ header: "x-caller-token", token: "caller-secret" });
145
+ });
146
+
147
+ it("lets the caller-credential header be renamed for existing gateway conventions", () => {
148
+ setRequiredEnv();
149
+ process.env.HTTP_CALLER_AUTH_TOKEN = "caller-secret";
150
+ process.env.HTTP_CALLER_AUTH_HEADER = "x-api-key";
151
+
152
+ const config = loadAppConfig(configDirPath, "development");
153
+
154
+ expect(config.http.callerAuth).toEqual({ header: "x-api-key", token: "caller-secret" });
155
+ });
156
+
157
+ it("refuses to boot when the caller credential is exported empty", () => {
158
+ // The silent-failure case: `HTTP_CALLER_AUTH_TOKEN=` must not read as
159
+ // "caller auth is off".
160
+ setRequiredEnv();
161
+ process.env.HTTP_CALLER_AUTH_TOKEN = "";
162
+
163
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow(/callerAuth[.,\s\S]*token/);
164
+ });
165
+
166
+ it("selects insecure-decode mode via OAUTH_JWT_MODE, requiring no key material (#134)", () => {
167
+ // The value is the consent: only the literal string "insecure-decode"
168
+ // selects the decode-only path, so a stray boolean-ish env value cannot.
169
+ process.env.OAUTH_JWT_MODE = "insecure-decode";
170
+
171
+ const config = loadAppConfig(configDirPath, "development");
172
+
173
+ expect(config.oauth.jwt.mode).toBe("insecure-decode");
174
+ });
175
+
176
+ it("rejects a boolean-ish OAUTH_JWT_MODE left over from the removed OAUTH_JWT_VALIDATE", () => {
177
+ setRequiredEnv();
178
+ process.env.OAUTH_JWT_MODE = "false";
179
+
180
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow();
181
+ });
182
+
183
+ it("rejects an HS256 config with no secret in the environment", () => {
184
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow(
185
+ /secret is required for HS256/,
186
+ );
187
+ });
188
+
189
+ it.each([
190
+ ["a one-character OAUTH_JWT_SECRET", "s"],
191
+ ["the README's old example value", "your-secret"],
192
+ ["32 hex characters — 16 decoded bytes", "ab".repeat(16)],
193
+ ])("refuses to load %s (#114)", (_label, secret) => {
194
+ // The floor applies to the shipped config as loaded, not only to
195
+ // hand-built objects: `secret = ${?OAUTH_JWT_SECRET}` is where a weak
196
+ // value actually enters a deployment.
197
+ setRequiredEnv();
198
+ process.env.OAUTH_JWT_SECRET = secret;
199
+
200
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow(
201
+ /must carry at least 32 bytes/,
202
+ );
203
+ });
204
+
205
+ it("rejects an env name that escapes the config directory", () => {
206
+ expect(() => loadAppConfig(configDirPath, "../secrets")).toThrow(/resolves outside/);
207
+ });
208
+ });
209
+
210
+ describe("loadAppConfig — RFC 9068 requirements (#105)", () => {
211
+ it("fails to load when the issuer is not supplied", () => {
212
+ process.env.OAUTH_JWT_SECRET = TEST_SECRET;
213
+ process.env.OAUTH_JWT_AUDIENCE = "https://api.test";
214
+
215
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow();
216
+ });
217
+
218
+ it("fails to load when the audience is not supplied", () => {
219
+ process.env.OAUTH_JWT_SECRET = TEST_SECRET;
220
+ process.env.OAUTH_JWT_ISSUER = "https://issuer.test";
221
+
222
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow();
223
+ });
224
+ });
225
+
226
+ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#157)", () => {
227
+ // The knobs are read by `resolveBound` at both boundaries now, and this is the
228
+ // path that proves the schema half still works where it actually runs: HOCON
229
+ // substitutes `${?VAR}` as a STRING, and the ts.hocon zod adapter's coercion
230
+ // walk only rewrites fields it recognises as numbers. A knob it cannot see
231
+ // through must therefore coerce the string itself — which is exactly what
232
+ // `resolveBound` does, and what a hand-built config already relied on.
233
+
234
+ it("takes the numbers application.conf states when nothing overrides them", () => {
235
+ setRequiredEnv();
236
+
237
+ const config = loadAppConfig(configDirPath, "development");
238
+
239
+ expect(config.http.port).toBe(3000);
240
+ expect(config.oauth.jwt.jwksTimeoutMs).toBe(5000);
241
+ expect(config.oauth.jwt.jwksCooldownMs).toBe(30_000);
242
+ expect(config.oauth.jwt.jwksCacheMaxAgeMs).toBe(600_000);
243
+ expect(config.oauth.jwt.maxTokenAgeSeconds).toBe(86_400);
244
+ expect(config.oauth.jwt.clockToleranceSeconds).toBe(0);
245
+ expect(config.verify.maxBatchSize).toBe(50);
246
+ // The request limits (#118) travel the same path.
247
+ expect(config.verify.maxBodyBytes).toBe(65_536);
248
+ expect(config.verify.maxResourceLength).toBe(512);
249
+ expect(config.verify.maxActionLength).toBe(64);
250
+ expect(config.verify.maxContextEntries).toBe(64);
251
+ expect(config.verify.maxContextValueLength).toBe(1024);
252
+ });
253
+
254
+ it("reads every numeric knob from the environment, where each arrives as a string", () => {
255
+ setRequiredEnv();
256
+ process.env.HTTP_PORT = "8080";
257
+ process.env.OAUTH_JWT_JWKS_TIMEOUT_MS = "2500";
258
+ process.env.OAUTH_JWT_JWKS_COOLDOWN_MS = "0";
259
+ process.env.OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS = "120000";
260
+ process.env.OAUTH_JWT_MAX_TOKEN_AGE_SECONDS = "600";
261
+ process.env.OAUTH_JWT_CLOCK_TOLERANCE_SECONDS = "60";
262
+ process.env.VERIFY_MAX_BATCH_SIZE = "25";
263
+ process.env.VERIFY_MAX_BODY_BYTES = "16384";
264
+ process.env.VERIFY_MAX_RESOURCE_LENGTH = "128";
265
+ process.env.VERIFY_MAX_ACTION_LENGTH = "32";
266
+ process.env.VERIFY_MAX_CONTEXT_ENTRIES = "16";
267
+ process.env.VERIFY_MAX_CONTEXT_VALUE_LENGTH = "256";
268
+ process.env.VERIFY_COLLECTOR_TIMEOUT_MS = "750";
269
+ process.env.VERIFY_COLLECTOR_DEADLINE_MS = "1500";
270
+ process.env.VERIFY_COLLECTOR_CONCURRENCY = "4";
271
+
272
+ const config = loadAppConfig(configDirPath, "development");
273
+
274
+ expect(config.http.port).toBe(8080);
275
+ expect(config.oauth.jwt.jwksTimeoutMs).toBe(2500);
276
+ expect(config.oauth.jwt.jwksCooldownMs).toBe(0);
277
+ expect(config.oauth.jwt.jwksCacheMaxAgeMs).toBe(120_000);
278
+ expect(config.oauth.jwt.maxTokenAgeSeconds).toBe(600);
279
+ expect(config.oauth.jwt.clockToleranceSeconds).toBe(60);
280
+ expect(config.verify.maxBatchSize).toBe(25);
281
+ expect(config.verify.maxBodyBytes).toBe(16_384);
282
+ expect(config.verify.maxResourceLength).toBe(128);
283
+ expect(config.verify.maxActionLength).toBe(32);
284
+ expect(config.verify.maxContextEntries).toBe(16);
285
+ expect(config.verify.maxContextValueLength).toBe(256);
286
+ expect(config.verify.collectorTimeoutMs).toBe(750);
287
+ expect(config.verify.collectorDeadlineMs).toBe(1_500);
288
+ expect(config.verify.collectorConcurrency).toBe(4);
289
+ });
290
+
291
+ it.each([
292
+ ["HTTP_PORT", "0"],
293
+ ["HTTP_PORT", "70000"],
294
+ ["OAUTH_JWT_JWKS_TIMEOUT_MS", "0"],
295
+ ["OAUTH_JWT_JWKS_COOLDOWN_MS", "-1"],
296
+ ["OAUTH_JWT_MAX_TOKEN_AGE_SECONDS", "1.5"],
297
+ ["OAUTH_JWT_CLOCK_TOLERANCE_SECONDS", "301"],
298
+ ["VERIFY_MAX_BATCH_SIZE", "0"],
299
+ ["VERIFY_MAX_BODY_BYTES", "0"],
300
+ ["VERIFY_MAX_RESOURCE_LENGTH", "-1"],
301
+ ["VERIFY_MAX_ACTION_LENGTH", "1.5"],
302
+ ["VERIFY_MAX_CONTEXT_ENTRIES", "abc"],
303
+ ["VERIFY_MAX_CONTEXT_VALUE_LENGTH", "0"],
304
+ ["VERIFY_COLLECTOR_TIMEOUT_MS", "0"],
305
+ ["VERIFY_COLLECTOR_DEADLINE_MS", "-1"],
306
+ ["VERIFY_COLLECTOR_CONCURRENCY", "0"],
307
+ ])("refuses to boot on %s=%s", (key, value) => {
308
+ setRequiredEnv();
309
+ process.env[key] = value;
310
+
311
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow();
312
+ });
313
+
314
+ it.each([
315
+ "HTTP_PORT",
316
+ "OAUTH_JWT_JWKS_COOLDOWN_MS",
317
+ "OAUTH_JWT_CLOCK_TOLERANCE_SECONDS",
318
+ "VERIFY_MAX_BATCH_SIZE",
319
+ "VERIFY_MAX_BODY_BYTES",
320
+ "VERIFY_MAX_CONTEXT_ENTRIES",
321
+ "VERIFY_COLLECTOR_TIMEOUT_MS",
322
+ "VERIFY_COLLECTOR_CONCURRENCY",
323
+ ])("refuses %s exported empty rather than reading it as zero", (key) => {
324
+ // `VAR=` substitutes an empty string, and `Number("")` is 0 — which the
325
+ // knobs whose floor is 0 would otherwise accept as a deliberate setting.
326
+ // A zero cooldown is "refetch on every miss", the fetch storm the knob
327
+ // exists to prevent; the same silent failure `http.callerAuth.token`
328
+ // already refuses above.
329
+ setRequiredEnv();
330
+ process.env[key] = "";
331
+
332
+ expect(() => loadAppConfig(configDirPath, "development")).toThrow();
333
+ });
334
+ });
335
+
336
+ describe("loadAppConfig — a config with no verify block at all (#115, #118)", () => {
337
+ /*
338
+ * The shipped `application.conf` writes out every `verify` knob, so loading
339
+ * it exercises the schema's per-key defaults and never the block-level one.
340
+ * The block-level default is what serves the OTHER shape, and it is the
341
+ * common one: an overlay config repeats only the sections it changes, and a
342
+ * deployment that has no opinion about batch sizes or collector deadlines
343
+ * simply never writes a `verify` block.
344
+ *
345
+ * zod takes `.default(() => ({…}))` verbatim rather than parsing it back
346
+ * through the shape, so a knob missing from that literal is `undefined` for
347
+ * exactly this shape — silently, with nothing failing. That is why it is
348
+ * proved here through the real loader (`parseFile` → `withFallback` →
349
+ * `validate`) rather than inferred from the schema unit tests, which all
350
+ * write a `verify` block and would go on passing.
351
+ */
352
+ const withoutVerify = (): string => {
353
+ const dir = mkdtempSync(path.join(tmpdir(), "policy-verifier-config-"));
354
+ // The minimum a deployment must state, and not one key more. No `verify`
355
+ // block, and an env overlay that declares nothing — the shipped
356
+ // development/production files are comments only, so this is their shape.
357
+ // The `${"$"}{?VAR}` spelling keeps the HOCON env substitutions literal
358
+ // `${?VAR}` text rather than JavaScript interpolation — for the parser
359
+ // under test and for anyone skimming the fixture.
360
+ writeFileSync(
361
+ path.join(dir, "application.conf"),
362
+ `
363
+ oauth {
364
+ jwt {
365
+ algorithm = HS256
366
+ secret = ${"$"}{?OAUTH_JWT_SECRET}
367
+ issuer = ${"$"}{?OAUTH_JWT_ISSUER}
368
+ audience = ${"$"}{?OAUTH_JWT_AUDIENCE}
369
+ }
370
+ }
371
+ attribute { collectors = [ { collector = PayloadScopeCollector } ] }
372
+ rule { collectors = [ { collector = ResourceActionScopeRuleCollector } ] }
373
+ `,
374
+ );
375
+ writeFileSync(path.join(dir, "development.conf"), "# overlay declares nothing\n");
376
+ return dir;
377
+ };
378
+
379
+ it("still applies every documented verify default", () => {
380
+ setRequiredEnv();
381
+
382
+ const config = loadAppConfig(withoutVerify(), "development");
383
+
384
+ // All eleven, spelled out. A knob that reached the shape but not the
385
+ // block's `.default()` literal is `undefined` here, and `toEqual` says
386
+ // which one.
387
+ expect(config.verify).toEqual({
388
+ maxBatchSize: 50,
389
+ maxBodyBytes: 65_536,
390
+ maxResourceLength: 512,
391
+ maxActionLength: 64,
392
+ maxContextEntries: 64,
393
+ maxContextValueLength: 1_024,
394
+ collectorTimeoutMs: 2_000,
395
+ collectorDeadlineMs: 5_000,
396
+ collectorConcurrency: 8,
397
+ batchConcurrency: 8,
398
+ credentialToCollectors: "never",
399
+ });
400
+ });
401
+
402
+ it("agrees with the shipped config, which writes the same values out", () => {
403
+ // The two ways a deployment can arrive at the defaults — stating them in
404
+ // `application.conf`, or omitting the block — must not disagree. The
405
+ // shipped file is the documentation operators copy from, so a drift
406
+ // between it and the schema's fallback is a drift between what the docs
407
+ // say and what an unconfigured deployment runs.
408
+ setRequiredEnv();
409
+
410
+ const shipped = loadAppConfig(configDirPath, "development");
411
+ const omitted = loadAppConfig(withoutVerify(), "development");
412
+
413
+ expect(omitted.verify).toEqual(shipped.verify);
414
+ });
415
+ });
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /*
5
+ * The composition root's logger (#107).
6
+ *
7
+ * The template previously created its logger through `@o3co/auth.utils`, whose
8
+ * shape cannot satisfy the `Logger` port `createApp` accepts — so nothing the
9
+ * template configured ever reached the server's failure events. `createAppLogger`
10
+ * returns a pino instance (newline-delimited JSON on stdout, the shape every
11
+ * log aggregator ingests without a parser), which satisfies the port
12
+ * structurally and honours `logging.level` from the application config.
13
+ */
14
+ import type { Logger } from "@o3co/auth.policy-verifier.core";
15
+ import { AppConfigSchema } from "@o3co/auth.policy-verifier.server";
16
+ import { describe, expect, it } from "vitest";
17
+ import { createAppLogger } from "../logger.js";
18
+
19
+ function configWithLevel(level?: string) {
20
+ return AppConfigSchema.parse({
21
+ oauth: { jwt: { mode: "insecure-decode" } },
22
+ attribute: { collectors: [] },
23
+ rule: { collectors: [{ collector: "ResourceActionScopeRuleCollector" }] },
24
+ ...(level === undefined ? {} : { logging: { level } }),
25
+ });
26
+ }
27
+
28
+ describe("createAppLogger", () => {
29
+ it("honours logging.level from the application config", () => {
30
+ const logger = createAppLogger(configWithLevel("error"));
31
+ expect(logger.level).toBe("error");
32
+ });
33
+
34
+ it("defaults to info when the config does not set a level", () => {
35
+ const logger = createAppLogger(configWithLevel());
36
+ expect(logger.level).toBe("info");
37
+ });
38
+
39
+ it("identifies the emitting service in its bindings", () => {
40
+ const logger = createAppLogger(configWithLevel());
41
+ expect(logger.bindings().name).toBe("policy-verifier");
42
+ });
43
+
44
+ it("satisfies the Logger port createApp accepts (compile-time + structural check)", () => {
45
+ // The assignment is the compile-time half: a pino instance must satisfy
46
+ // the port with no adapter.
47
+ const logger: Logger = createAppLogger(configWithLevel());
48
+ expect(typeof logger.trace).toBe("function");
49
+ expect(typeof logger.debug).toBe("function");
50
+ expect(typeof logger.info).toBe("function");
51
+ expect(typeof logger.warn).toBe("function");
52
+ expect(typeof logger.error).toBe("function");
53
+ expect(typeof logger.fatal).toBe("function");
54
+ expect(typeof logger.child).toBe("function");
55
+ });
56
+ });