@openparachute/vault 0.4.0 → 0.4.4-rc.11

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.
@@ -10,13 +10,18 @@
10
10
  * - broad `vault:<verb>` scope rejected (forced narrowing per #180)
11
11
  * - `aud=vault.<other>` rejected (audience mismatch)
12
12
  * - JWT path rejected at the global (cross-vault) entrypoint
13
+ * - revoked jti rejected (revocation list integration; client-facing
14
+ * message is sanitized so the jti doesn't leak)
15
+ * - revocation list unavailable on cold start → fail-closed 401
13
16
  *
14
- * Each test owns a fresh `PARACHUTE_HOME` and JWKS fixture, like the auth.test
15
- * peer file. The JWKS fixture mirrors the one in hub-jwt.test.ts; duplicating
16
- * ~30 lines is cheaper than introducing a shared test-helper module.
17
+ * Each test owns a fresh `PARACHUTE_HOME` and a fake hub fixture that serves
18
+ * BOTH `/.well-known/jwks.json` and `/.well-known/parachute-revocation.json`.
19
+ * scope-guard's own unit suite covers the cache mechanics (TTL refresh,
20
+ * fail-open with last-good, single-flight); this file pins the vault-side
21
+ * wiring and the response-shape contract.
17
22
  */
18
23
 
19
- import { describe, test, expect, beforeEach, afterEach } from "bun:test";
24
+ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
20
25
  import { mkdirSync, rmSync, existsSync } from "fs";
21
26
  import { join } from "path";
22
27
  import { tmpdir } from "os";
@@ -24,7 +29,7 @@ import { generateKeyPair, exportJWK, SignJWT } from "jose";
24
29
  import { writeVaultConfig, readVaultConfig } from "./config.ts";
25
30
  import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
26
31
  import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
27
- import { resetJwksCache } from "./hub-jwt.ts";
32
+ import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
28
33
 
29
34
  interface Keypair {
30
35
  privateKey: CryptoKey;
@@ -42,24 +47,45 @@ async function makeKeypair(kid: string): Promise<Keypair> {
42
47
  };
43
48
  }
44
49
 
45
- interface JwksFixture {
50
+ interface HubFixture {
46
51
  origin: string;
52
+ /** Drive the revocation list contents; cleared by default. */
53
+ setRevoked(jtis: string[]): void;
54
+ /** When true, the revocation endpoint returns 503 — exercises fail-closed. */
55
+ setRevocationFails(fails: boolean): void;
47
56
  stop: () => void;
48
57
  }
49
58
 
50
- function startJwksFixture(keys: Keypair[]): JwksFixture {
59
+ function startHubFixture(keys: Keypair[]): HubFixture {
60
+ let revokedJtis: string[] = [];
61
+ let revocationFails = false;
51
62
  const server = Bun.serve({
52
63
  port: 0,
53
64
  fetch(req) {
54
65
  const url = new URL(req.url);
55
- if (url.pathname !== "/.well-known/jwks.json") {
56
- return new Response("not found", { status: 404 });
66
+ if (url.pathname === "/.well-known/jwks.json") {
67
+ return Response.json({ keys: keys.map((k) => k.publicJwk) });
57
68
  }
58
- return Response.json({ keys: keys.map((k) => k.publicJwk) });
69
+ if (url.pathname === "/.well-known/parachute-revocation.json") {
70
+ if (revocationFails) {
71
+ return new Response("hub down", { status: 503 });
72
+ }
73
+ return Response.json({
74
+ generated_at: new Date().toISOString(),
75
+ jtis: revokedJtis,
76
+ });
77
+ }
78
+ return new Response("not found", { status: 404 });
59
79
  },
60
80
  });
61
81
  return {
62
82
  origin: `http://127.0.0.1:${server.port}`,
83
+ setRevoked: (jtis) => {
84
+ revokedJtis = jtis;
85
+ },
86
+ setRevocationFails: (fails) => {
87
+ revocationFails = fails;
88
+ },
63
89
  stop: () => server.stop(true),
64
90
  };
65
91
  }
@@ -70,6 +96,8 @@ interface SignOpts {
70
96
  scope: string;
71
97
  sub?: string;
72
98
  ttlSeconds?: number;
99
+ /** Override the random jti — needed when a test wants to revoke this exact token. */
100
+ jti?: string;
73
101
  }
74
102
 
75
103
  async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
@@ -82,7 +110,7 @@ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
82
110
  .setAudience(opts.aud)
83
111
  .setIssuedAt(iat)
84
112
  .setExpirationTime(exp)
85
- .setJti(`jti-${Math.random().toString(36).slice(2)}`)
113
+ .setJti(opts.jti ?? `jti-${Math.random().toString(36).slice(2)}`)
86
114
  .sign(kp.privateKey);
87
115
  }
88
116
 
@@ -95,7 +123,7 @@ function bearer(token: string): Request {
95
123
  let tmpHome: string;
96
124
  let prevHome: string | undefined;
97
125
  let prevHubOrigin: string | undefined;
98
- let fixture: JwksFixture;
126
+ let fixture: HubFixture;
99
127
  let kp: Keypair;
100
128
 
101
129
  beforeEach(async () => {
@@ -109,10 +137,11 @@ beforeEach(async () => {
109
137
  clearVaultStoreCache();
110
138
 
111
139
  kp = await makeKeypair("k1");
112
- fixture = startJwksFixture([kp]);
140
+ fixture = startHubFixture([kp]);
113
141
  prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
114
142
  process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
115
143
  resetJwksCache();
144
+ resetRevocationCache();
116
145
  });
117
146
 
118
147
  afterEach(() => {
@@ -228,4 +257,104 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
228
257
  expect(body.message).toContain("/vault/<name>");
229
258
  }
230
259
  });
260
+
261
+ test("revoked jti → 401 sanitized; full diagnostic (with jti) routed to console.warn audit log", async () => {
262
+ seedVault("journal");
263
+ const revokedJti = "jti-revoked-by-operator";
264
+ fixture.setRevoked([revokedJti]);
265
+ const token = await signJwt(kp, {
266
+ iss: fixture.origin,
267
+ aud: "vault.journal",
268
+ scope: "vault:journal:read",
269
+ jti: revokedJti,
270
+ });
271
+ const config = readVaultConfig("journal")!;
272
+ const store = getVaultStore("journal");
273
+
274
+ // Spy + suppress so the assertion is the audit-trail invariant for
275
+ // this scenario, not a stderr inspection. Pattern carries to scribe/agent.
276
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
277
+ try {
278
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
279
+ expect("error" in result).toBe(true);
280
+ if ("error" in result) {
281
+ expect(result.error.status).toBe(401);
282
+ const body = (await result.error.json()) as { error: string; message: string };
283
+ expect(body.error).toBe("Unauthorized");
284
+ // Client-facing message must NOT carry the jti — that's a server-side
285
+ // audit-log concern only. See the `code === "revoked"` branch in
286
+ // authenticateHubJwt for the sanitization.
287
+ expect(body.message).toBe("token has been revoked");
288
+ expect(body.message).not.toContain(revokedJti);
289
+ }
290
+ // Audit-log invariant: console.warn fires exactly once with a message
291
+ // that carries the jti, so an operator chasing a 401 in production logs
292
+ // can correlate to which token was retired.
293
+ expect(warnSpy).toHaveBeenCalledTimes(1);
294
+ const warnArg = warnSpy.mock.calls[0]![0] as string;
295
+ expect(warnArg).toContain(revokedJti);
296
+ expect(warnArg).toContain("revoked");
297
+ } finally {
298
+ warnSpy.mockRestore();
299
+ }
300
+ });
301
+
302
+ test("non-revoked jti against populated list → still honored (happy path with active revocations)", async () => {
303
+ seedVault("journal");
304
+ fixture.setRevoked(["some-other-revoked-jti"]);
305
+ const token = await signJwt(kp, {
306
+ iss: fixture.origin,
307
+ aud: "vault.journal",
308
+ scope: "vault:journal:write",
309
+ jti: "jti-still-good",
310
+ });
311
+ const config = readVaultConfig("journal")!;
312
+ const store = getVaultStore("journal");
313
+
314
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
315
+ expect("error" in result).toBe(false);
316
+ if (!("error" in result)) {
317
+ expect(result.permission).toBe("full");
318
+ expect(result.scopes).toEqual(["vault:journal:write"]);
319
+ }
320
+ });
321
+
322
+ test("revocation list unreachable on cold start → fail-closed 401 sanitized; full diagnostic routed to console.warn", async () => {
323
+ seedVault("journal");
324
+ // Hub is reachable for JWKS but the revocation endpoint 503s. Cold cache
325
+ // + first-fetch-fail = "unknown" outcome, surfaced as
326
+ // HubJwtError(code: "revocation_unavailable"). Client gets a code-shaped
327
+ // sentence; the implementation-detail phrasing ("no last-good cache")
328
+ // stays in the server-side audit log.
329
+ fixture.setRevocationFails(true);
330
+ const token = await signJwt(kp, {
331
+ iss: fixture.origin,
332
+ aud: "vault.journal",
333
+ scope: "vault:journal:read",
334
+ });
335
+ const config = readVaultConfig("journal")!;
336
+ const store = getVaultStore("journal");
337
+
338
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
339
+ try {
340
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
341
+ expect("error" in result).toBe(true);
342
+ if ("error" in result) {
343
+ expect(result.error.status).toBe(401);
344
+ const body = (await result.error.json()) as { error: string; message: string };
345
+ // Client message: code-shaped, no internals.
346
+ expect(body.message).toBe("token cannot be validated: revocation list unavailable");
347
+ // The internal phrase "no last-good cache" is a scope-guard
348
+ // implementation detail and must not leak into the public response.
349
+ expect(body.message).not.toContain("last-good cache");
350
+ }
351
+ // Audit-log invariant: full diagnostic routed to console.warn so
352
+ // operators can distinguish cold-start from sustained outage.
353
+ expect(warnSpy).toHaveBeenCalledTimes(1);
354
+ const warnArg = warnSpy.mock.calls[0]![0] as string;
355
+ expect(warnArg).toContain("no last-good cache");
356
+ } finally {
357
+ warnSpy.mockRestore();
358
+ }
359
+ });
231
360
  });
package/src/auth.ts CHANGED
@@ -275,6 +275,35 @@ async function authenticateHubJwt(
275
275
  return { permission, scopes: claims.scopes, legacyDerived: false, scoped_tags: null, vault_name: null };
276
276
  } catch (err) {
277
277
  if (err instanceof HubJwtError) {
278
+ // Revocation-related codes get sanitized client messages: server-side
279
+ // audit log carries the full diagnostic (jti for `revoked`,
280
+ // implementation-detail phrasing for `revocation_unavailable`); the
281
+ // unauthenticated caller gets a code-shaped sentence with no internals.
282
+ // This is the inheritable pattern across vault/scribe/agent — keep all
283
+ // revocation-related diagnostics server-side. Other HubJwtError codes
284
+ // (signature, audience, expired, etc.) carry generic messages and are
285
+ // forwarded as-is; the existing test suite pins those exact strings.
286
+ if (err.code === "revoked") {
287
+ console.warn(`[auth] hub JWT rejected: ${err.message}`);
288
+ return {
289
+ error: Response.json(
290
+ { error: "Unauthorized", message: "token has been revoked" },
291
+ { status: 401 },
292
+ ),
293
+ };
294
+ }
295
+ if (err.code === "revocation_unavailable") {
296
+ console.warn(`[auth] hub JWT rejected: ${err.message}`);
297
+ return {
298
+ error: Response.json(
299
+ {
300
+ error: "Unauthorized",
301
+ message: "token cannot be validated: revocation list unavailable",
302
+ },
303
+ { status: 401 },
304
+ ),
305
+ };
306
+ }
278
307
  return { error: Response.json({ error: "Unauthorized", message: err.message }, { status: 401 }) };
279
308
  }
280
309
  // Unknown failure shape — surface the message but stay 401.