@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.
@@ -4,71 +4,112 @@
4
4
  /*
5
5
  * Smoke tests for the standalone template.
6
6
  *
7
- * These tests verify that the app created by the standalone template boots correctly
8
- * and that core HTTP endpoints respond as expected.
7
+ * These tests boot the app from `config/application.conf` — the file the image
8
+ * ships and an operator deploys — read through `loadAppConfig`, the same
9
+ * function `main.mts` calls. Nothing here restates the policy: the thing under
10
+ * test is the thing that ships.
9
11
  *
10
- * The composition here intentionally mirrors main.mts — using builtinCollectorsModule
11
- * and registry keys that match application.conf — so that renames or interface changes
12
- * in the builtins package are caught here before reaching production.
12
+ * That is the point of #113. The suite used to assemble its own config, minus
13
+ * the one collector that made the shipped file deny every request, and stayed
14
+ * green while the product was non-functional. A config the tests hand-write can
15
+ * agree with `application.conf` only by vigilance, and it stopped agreeing.
13
16
  *
14
- * Note: ResourceActionPermissionRuleCollector is omitted from the smoke config because
15
- * it requires a permission store (e.g. StaticPermissionCollector) to grant any access.
16
- * The scope-based check via ResourceActionScopeRuleCollector is sufficient to exercise
17
- * the allow/deny paths in isolation.
17
+ * The three values the file deliberately does not carry — the HS256 secret, the
18
+ * issuer and the audience — come from the environment here exactly as they do
19
+ * in a deployment. A credential must never be written in a config file, so
20
+ * supplying them is loading the shipped config, not editing it.
18
21
  */
22
+ import { fileURLToPath } from "node:url";
19
23
  import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
20
- import {
21
- AppConfigSchema,
22
- builtinKeyResolversModule,
23
- createApp,
24
- } from "@o3co/auth.policy-verifier.server";
24
+ import { consoleLogger } from "@o3co/auth.policy-verifier.core";
25
+ import type { AppConfig } from "@o3co/auth.policy-verifier.server";
26
+ import { builtinKeyResolversModule, createApp } from "@o3co/auth.policy-verifier.server";
25
27
  import { SignJWT } from "jose";
26
28
  import request from "supertest";
27
29
  import { describe, expect, it } from "vitest";
30
+ import { loadAppConfig } from "../loadConfig.js";
28
31
 
29
- const JWT_SECRET = "standalone-smoke-secret";
32
+ /** 64 hex characters — 32 decoded bytes, the entropy floor #114 enforces. */
33
+ const JWT_SECRET = "11".repeat(32);
30
34
  const secretKey = new TextEncoder().encode(JWT_SECRET);
35
+ const ISSUER = "https://issuer.test";
36
+ const AUDIENCE = "https://api.test";
37
+
38
+ const configDirPath = fileURLToPath(new URL("../../config/", import.meta.url));
31
39
 
32
40
  async function signToken(payload: Record<string, unknown>): Promise<string> {
33
- return new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setIssuedAt().sign(secretKey);
41
+ return (
42
+ new SignJWT(payload)
43
+ .setProtectedHeader({ alg: "HS256", typ: "at+jwt" })
44
+ .setIssuedAt()
45
+ // iat and exp are both mandatory now (#110).
46
+ .setExpirationTime("1h")
47
+ .setIssuer(ISSUER)
48
+ .setAudience(AUDIENCE)
49
+ .sign(secretKey)
50
+ );
34
51
  }
35
52
 
36
- // Config mirrors the structure of application.conf, using the same builtin registry keys.
37
- // PayloadScopeCollector extracts scopes from the JWT "scope" claim.
38
- // ResourceActionScopeRuleCollector requires scope "<action>:<resourceType>" to be present.
39
- // DotNotationResourceParser is the default and matches the omitted resource.parser in application.conf.
40
- const baseConfig = AppConfigSchema.parse({
41
- oauth: { jwt: { secret: JWT_SECRET, validate: true } },
42
- attribute: {
43
- collectors: [
44
- { collector: "PayloadScopeCollector" },
45
- { collector: "PayloadSubjectIdCollector" },
46
- ],
47
- },
48
- rule: {
49
- collectors: [{ collector: "ResourceActionScopeRuleCollector" }],
50
- },
51
- // resource.parser defaults to "DotNotationResourceParser" — matches application.conf omission
52
- });
53
+ /** The three values `application.conf` leaves to the deployment. */
54
+ const DEPLOYMENT_ENV = {
55
+ OAUTH_JWT_SECRET: JWT_SECRET,
56
+ OAUTH_JWT_ISSUER: ISSUER,
57
+ OAUTH_JWT_AUDIENCE: AUDIENCE,
58
+ } as const;
59
+
60
+ /**
61
+ * The shipped config, loaded the way the container loads it: the development
62
+ * overlay resolved against `config/application.conf`, with the deployment's
63
+ * three values in the environment.
64
+ *
65
+ * `process.env` is process-wide, and a test file does not own it. So each
66
+ * variable's prior value is **restored**, not deleted — and "was absent" is
67
+ * restored as absent rather than as `""`, which is a different state this very
68
+ * config distinguishes (an exported-empty credential is refused rather than read
69
+ * as "unset"). Deleting unconditionally would hand any file sharing the worker a
70
+ * value this one happened to clear.
71
+ *
72
+ * The window is nil as well as tidy: every step here is synchronous —
73
+ * `loadAppConfig` parses and validates without awaiting — and this runs at module
74
+ * scope, so no other file's code can be scheduled between the assignment and the
75
+ * restore.
76
+ */
77
+ function loadShippedConfig(): AppConfig {
78
+ const saved: Array<[key: string, previous: string | undefined]> = Object.keys(DEPLOYMENT_ENV).map(
79
+ (key) => [key, process.env[key]],
80
+ );
81
+ Object.assign(process.env, DEPLOYMENT_ENV);
82
+ try {
83
+ return loadAppConfig(configDirPath, "development");
84
+ } finally {
85
+ for (const [key, previous] of saved) {
86
+ if (previous === undefined) delete process.env[key];
87
+ else process.env[key] = previous;
88
+ }
89
+ }
90
+ }
91
+
92
+ const shippedConfig = loadShippedConfig();
93
+
94
+ /** The composition `main.mts` builds, over whichever config is handed in. */
95
+ function createShippedApp(config: AppConfig = shippedConfig) {
96
+ return createApp({
97
+ pathResolver: (s: string) => s,
98
+ config,
99
+ modules: [builtinCollectorsModule, builtinKeyResolversModule],
100
+ });
101
+ }
53
102
 
54
103
  describe("standalone smoke", () => {
55
104
  it("GET /healthcheck returns 200", async () => {
56
- const app = await createApp({
57
- pathResolver: (s: string) => s,
58
- config: baseConfig,
59
- modules: [builtinCollectorsModule, builtinKeyResolversModule],
60
- });
105
+ const app = await createShippedApp();
61
106
 
62
107
  const res = await request(app).get("/healthcheck");
63
108
  expect(res.status).toBe(200);
64
109
  });
65
110
 
66
111
  it("POST /verify with sufficient scope returns 200 (allow)", async () => {
67
- const app = await createApp({
68
- pathResolver: (s: string) => s,
69
- config: baseConfig,
70
- modules: [builtinCollectorsModule, builtinKeyResolversModule],
71
- });
112
+ const app = await createShippedApp();
72
113
 
73
114
  // PayloadScopeCollector extracts "read:document" from the JWT scope claim.
74
115
  // ResourceActionScopeRuleCollector requires scope "<action>:<resourceType>".
@@ -83,12 +124,122 @@ describe("standalone smoke", () => {
83
124
  expect(res.body.decision).toBe("allow");
84
125
  });
85
126
 
127
+ it("POST /verify with a scopeless token returns 403 (deny)", async () => {
128
+ const app = await createShippedApp();
129
+
130
+ // A validly-signed token carrying no scope claim asserts no capability, so a
131
+ // scope-only pipeline must deny it rather than collect zero rules and allow.
132
+ const token = await signToken({ sub: "user-1" });
133
+ const res = await request(app)
134
+ .post("/verify")
135
+ .set("Authorization", `Bearer ${token}`)
136
+ .send({ resource: "document", action: "read" });
137
+
138
+ expect(res.status).toBe(403);
139
+ expect(res.body.decision).toBe("deny");
140
+ });
141
+
142
+ it("POST /verify with an id_token signed by the same key returns 401", async () => {
143
+ const app = await createShippedApp();
144
+
145
+ // The paired provider signs id_tokens with the same key as access tokens;
146
+ // only the typ header separates them.
147
+ const token = await new SignJWT({ scope: "read:document" })
148
+ .setProtectedHeader({ alg: "HS256", typ: "id+jwt" })
149
+ .setIssuedAt()
150
+ .setExpirationTime("1h")
151
+ .setIssuer(ISSUER)
152
+ .setAudience(AUDIENCE)
153
+ .sign(secretKey);
154
+ const res = await request(app)
155
+ .post("/verify")
156
+ .set("Authorization", `Bearer ${token}`)
157
+ .send({ resource: "document", action: "read" });
158
+
159
+ expect(res.status).toBe(401);
160
+ expect(res.body.code).toBe("invalid_token");
161
+ });
162
+
163
+ it("POST /verify/batch decides every entry in one round trip", async () => {
164
+ const app = await createShippedApp();
165
+
166
+ const token = await signToken({ sub: "user-1", scope: "read:document" });
167
+ const res = await request(app)
168
+ .post("/verify/batch")
169
+ .set("Authorization", `Bearer ${token}`)
170
+ .send({
171
+ decisions: [
172
+ { resource: "document", action: "read" },
173
+ { resource: "document", action: "write" },
174
+ ],
175
+ });
176
+
177
+ expect(res.status).toBe(200);
178
+ expect(res.body.decisions.map((d: { decision: string }) => d.decision)).toEqual([
179
+ "allow",
180
+ "deny",
181
+ ]);
182
+ expect(res.body.decisions[0].subject).toBe("user-1");
183
+ });
184
+
185
+ it("POST /verify with a differently-cased scope returns 403 (deny)", async () => {
186
+ const app = await createShippedApp();
187
+
188
+ // Scope values are case-sensitive opaque strings (RFC 6749 §3.3), so
189
+ // "read:DOCUMENT" must not satisfy the required "read:document".
190
+ const token = await signToken({ scope: "read:DOCUMENT" });
191
+ const res = await request(app)
192
+ .post("/verify")
193
+ .set("Authorization", `Bearer ${token}`)
194
+ .send({ resource: "document", action: "read" });
195
+
196
+ expect(res.status).toBe(403);
197
+ expect(res.body.decision).toBe("deny");
198
+ });
199
+
200
+ it("POST /verify with a bare scope returns 403 unless the rewrite is opted into", async () => {
201
+ const app = await createShippedApp();
202
+
203
+ // A bare "document" is not rewritten to "read:document" by default.
204
+ const token = await signToken({ scope: "document" });
205
+ const res = await request(app)
206
+ .post("/verify")
207
+ .set("Authorization", `Bearer ${token}`)
208
+ .send({ resource: "document", action: "read" });
209
+
210
+ expect(res.status).toBe(403);
211
+ expect(res.body.decision).toBe("deny");
212
+ });
213
+
214
+ it("POST /verify allows a bare scope once allowBareScopeRewrite is configured", async () => {
215
+ // The one config in this file that is deliberately NOT the shipped one:
216
+ // it exercises the opt-in application.conf documents in a comment beside
217
+ // the collector, which is the only path a deployment has to re-enable the
218
+ // old rewrite. It is derived from the shipped config rather than written
219
+ // out, so the single key under test is the single thing that differs.
220
+ const rewritingConfig: AppConfig = {
221
+ ...shippedConfig,
222
+ rule: {
223
+ ...shippedConfig.rule,
224
+ collectors: [
225
+ { collector: "ResourceActionScopeRuleCollector", allowBareScopeRewrite: true },
226
+ ],
227
+ },
228
+ };
229
+ const app = await createShippedApp(rewritingConfig);
230
+
231
+ const token = await signToken({ scope: "document" });
232
+ const res = await request(app)
233
+ .post("/verify")
234
+ .set("Authorization", `Bearer ${token}`)
235
+ .send({ resource: "document", action: "read" });
236
+
237
+ expect(res.status).toBe(200);
238
+ expect(res.body.decision).toBe("allow");
239
+ });
240
+
86
241
  it("POST /verify with insufficient scope returns 403 (deny)", async () => {
87
- const app = await createApp({
88
- pathResolver: (s: string) => s,
89
- config: baseConfig,
90
- modules: [builtinCollectorsModule, builtinKeyResolversModule],
91
- });
242
+ const app = await createShippedApp();
92
243
 
93
244
  // Token has "write:document" but action is "read", so HasScope("read:document") fails.
94
245
  const token = await signToken({ scope: "write:document" });
@@ -101,3 +252,58 @@ describe("standalone smoke", () => {
101
252
  expect(res.body.decision).toBe("deny");
102
253
  });
103
254
  });
255
+
256
+ /*
257
+ * #111. The template is the deployable shape, so these assert what an operator
258
+ * of *this* composition actually gets — not what the library can be wired to do.
259
+ */
260
+ describe("standalone observability", () => {
261
+ it("GET /metrics serves the Prometheus text format with the decision counters", async () => {
262
+ const app = await createShippedApp();
263
+
264
+ const token = await signToken({ sub: "user-1", scope: "read:document" });
265
+ await request(app)
266
+ .post("/verify")
267
+ .set("Authorization", `Bearer ${token}`)
268
+ .send({ resource: "document", action: "read" });
269
+
270
+ const res = await request(app).get("/metrics");
271
+ expect(res.status).toBe(200);
272
+ expect(res.headers["content-type"]).toContain("text/plain");
273
+ expect(res.text).toContain('auth_decisions_total{decision="allow"} 1');
274
+ expect(res.text).toContain("auth_policy_verifier_process_cpu_user_seconds_total");
275
+ });
276
+
277
+ it("emits the per-decision audit line through the injected logger", async () => {
278
+ // main.mts injects pino here; a capturing stand-in proves the line reaches
279
+ // whatever the composition root wired, at the level `logging.level` gates.
280
+ const lines: Array<{ obj: Record<string, unknown>; msg?: string }> = [];
281
+ const app = await createApp({
282
+ pathResolver: (s: string) => s,
283
+ config: shippedConfig,
284
+ modules: [builtinCollectorsModule, builtinKeyResolversModule],
285
+ logger: {
286
+ ...consoleLogger,
287
+ info(obj: Record<string, unknown> | string, msg?: string) {
288
+ if (typeof obj === "object") lines.push({ obj, msg });
289
+ },
290
+ },
291
+ });
292
+
293
+ const token = await signToken({ sub: "user-1", scope: "read:document" });
294
+ await request(app)
295
+ .post("/verify")
296
+ .set("Authorization", `Bearer ${token}`)
297
+ .set("x-request-id", "trace-1")
298
+ .send({ resource: "document:9", action: "read" });
299
+
300
+ const decision = lines.find((l) => l.msg === "decision");
301
+ expect(decision?.obj).toMatchObject({
302
+ sub: "user-1",
303
+ resource: "document:9",
304
+ action: "read",
305
+ decision: "allow",
306
+ requestId: "trace-1",
307
+ });
308
+ });
309
+ });
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import path from "node:path";
5
+
6
+ export interface ResolvedConfigPaths {
7
+ readonly applicationConfPath: string;
8
+ readonly envConfPath: string;
9
+ }
10
+
11
+ export function resolveConfigPaths(configDirPath: string, env: string): ResolvedConfigPaths {
12
+ const normalizedConfigDir = path.resolve(configDirPath);
13
+ const applicationConfPath = path.join(normalizedConfigDir, "application.conf");
14
+ const envConfPath = path.resolve(normalizedConfigDir, `${env}.conf`);
15
+ if (path.dirname(envConfPath) !== normalizedConfigDir) {
16
+ throw new Error(
17
+ `Invalid config environment name: "${env}" resolves outside ${normalizedConfigDir}`,
18
+ );
19
+ }
20
+ return { applicationConfPath, envConfPath };
21
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { type AppConfig, AppConfigSchema } from "@o3co/auth.policy-verifier.server";
5
+ import { parseFile } from "@o3co/ts.hocon";
6
+ import { validate } from "@o3co/ts.hocon/zod";
7
+ import { resolveConfigPaths } from "./configPath.js";
8
+
9
+ /**
10
+ * Loads the validated application config for `env` from `configDirPath`.
11
+ *
12
+ * The `<env>.conf` overlay is layered over `application.conf`. An overlay that
13
+ * declares nothing (the shipped development/production files are comments
14
+ * only) is an empty HOCON document and falls through to the base layer.
15
+ */
16
+ export function loadAppConfig(configDirPath: string, env: string): AppConfig {
17
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDirPath, env);
18
+ return validate(
19
+ parseFile(envConfPath).withFallback(parseFile(applicationConfPath)),
20
+ AppConfigSchema,
21
+ );
22
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { AppConfig } from "@o3co/auth.policy-verifier.server";
5
+ import { pino, stdSerializers } from "pino";
6
+
7
+ /**
8
+ * The composition root's logger, injected into `createApp` so the verify
9
+ * router's failure events (`jwt_token_rejected`, `jwt_verification_unavailable`,
10
+ * `verify_internal_error`) reach an aggregator-ready sink (#107).
11
+ *
12
+ * pino, emitting newline-delimited JSON on stdout — the shape every log
13
+ * aggregator ingests without a parser, and the reason the `Logger` port carries
14
+ * pino's two-overload call signature. A pino instance therefore satisfies the
15
+ * port structurally, with no adapter. pino drops sub-threshold calls before
16
+ * formatting, so `logging.level` is honoured at zero cost for the levels it
17
+ * excludes.
18
+ *
19
+ * The same wiring exists in the auth.provider standalone template; keeping the
20
+ * two composition roots symmetric is what lets one aggregator pipeline serve
21
+ * the whole stack.
22
+ */
23
+ export function createAppLogger(config: AppConfig) {
24
+ return pino({
25
+ name: "policy-verifier",
26
+ level: config.logging.level,
27
+ // `err` is pino's conventional key for an Error, and every structured
28
+ // event in this stack uses it — `logger.error({ err }, "…_error")`.
29
+ // Without the serialiser an Error stringifies to `{}` and the stack is
30
+ // lost exactly where it is needed.
31
+ serializers: { err: stdSerializers.err },
32
+ });
33
+ }
@@ -4,28 +4,25 @@
4
4
  /*
5
5
  * Standalone entrypoint — creates and starts the policy-verifier server.
6
6
  */
7
- import { resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
8
  import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
9
- import {
10
- AppConfigSchema,
11
- builtinKeyResolversModule,
12
- createApp,
13
- } from "@o3co/auth.policy-verifier.server";
14
- import { createLogger, gracefulShutdown } from "@o3co/auth.utils";
15
- import { parseFile } from "@o3co/ts.hocon";
16
- import { validate } from "@o3co/ts.hocon/zod";
9
+ import { builtinKeyResolversModule, createApp } from "@o3co/auth.policy-verifier.server";
10
+ import { gracefulShutdown } from "@o3co/auth.utils";
11
+ import { loadAppConfig } from "./loadConfig.js";
12
+ import { createAppLogger } from "./logger.js";
17
13
 
18
- const logger = createLogger("policy-verifier");
14
+ const env = process.env.CONFIG_ENV || process.env.NODE_ENV || "development";
15
+ const configDir = new URL("../config/", import.meta.url);
16
+ const configDirPath = fileURLToPath(configDir);
19
17
 
20
- const config = validate(
21
- parseFile(resolve(import.meta.dirname, "../config/application.conf")),
22
- AppConfigSchema,
23
- );
18
+ const config = loadAppConfig(configDirPath, env);
19
+ const logger = createAppLogger(config);
24
20
 
25
21
  const app = await createApp({
26
22
  pathResolver: import.meta.resolve,
27
23
  config,
28
24
  modules: [builtinCollectorsModule, builtinKeyResolversModule],
25
+ logger,
29
26
  });
30
27
 
31
28
  const server = app.listen(config.http.port, config.http.hostname, () => {
@@ -22,6 +22,7 @@
22
22
  ],
23
23
  "exclude": [
24
24
  "node_modules",
25
- "dist"
25
+ "dist",
26
+ "src/**/__tests__/**"
26
27
  ]
27
28
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true
5
+ },
6
+ "include": ["src/**/*"],
7
+ "exclude": ["node_modules", "dist"]
8
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "@o3co/auth.policy-verifier.core": "0.0.0",
3
- "@o3co/auth.policy-verifier.builtins": "0.0.0",
4
- "@o3co/auth.policy-verifier.server": "0.0.0"
2
+ "@o3co/auth.policy-verifier.core": "0.5.0",
3
+ "@o3co/auth.policy-verifier.builtins": "0.5.0",
4
+ "@o3co/auth.policy-verifier.server": "0.5.0"
5
5
  }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=index.test.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.test.d.mts","sourceRoot":"","sources":["../../src/__tests__/index.test.mts"],"names":[],"mappings":""}