Skip to content

Commit 0e79749

Browse files
author
Oxy
committed
test(security): add replay-cache expiry coverage for job token manager
1 parent 330d7da commit 0e79749

2 files changed

Lines changed: 35 additions & 3 deletions

File tree

src/phase2d.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import test from "node:test";
22
import assert from "node:assert/strict";
33
import { buildControlPlane } from "./control-plane.js";
44
import { executeRealTask } from "./node-agent/executor.js";
5+
import { JobTokenManager } from "./security.js";
56
import type { Task } from "./contracts.js";
67

78
async function bootstrapNode(app: ReturnType<typeof buildControlPlane>, nodeId: string) {
@@ -175,6 +176,24 @@ test("observability endpoints expose queue depth/latency/success ratio", async (
175176
await app.close();
176177
});
177178

179+
test("job token manager prunes replay cache after expiration", async () => {
180+
const mgr = new JobTokenManager("test-secret");
181+
const token = mgr.issue({ jobId: "job-ttl", exp: Date.now() + 30 });
182+
183+
const first = mgr.verify(token, { jobId: "job-ttl" });
184+
assert.equal(first.ok, true);
185+
assert.equal(mgr.replayCacheSize(), 1);
186+
187+
await new Promise((r) => setTimeout(r, 40));
188+
189+
// Cache entry should be pruned once expired.
190+
assert.equal(mgr.replayCacheSize(), 0);
191+
192+
const second = mgr.verify(token, { jobId: "job-ttl" });
193+
assert.equal(second.ok, false);
194+
assert.equal(second.error, "token_expired");
195+
});
196+
178197
test("failure drills: invalid payload + timeout + crash", async () => {
179198
const invalid = await executeRealTask({
180199
schemaVersion: "1.0",

src/security.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ function fromB64(data: string) {
1818

1919
export class JobTokenManager {
2020
private secret: string;
21-
private usedJti = new Set<string>();
21+
private usedJtiExp = new Map<string, number>();
2222

2323
constructor(secret?: string) {
2424
this.secret = secret ?? process.env.EDGEMESH_JOB_TOKEN_SECRET ?? "dev-secret";
@@ -51,8 +51,10 @@ export class JobTokenManager {
5151
return { ok: false as const, error: "token_payload_invalid" };
5252
}
5353

54+
this.pruneReplayCache();
55+
5456
if (Date.now() > payload.exp) return { ok: false as const, error: "token_expired" };
55-
if (this.usedJti.has(payload.jti)) return { ok: false as const, error: "token_replay" };
57+
if (this.usedJtiExp.has(payload.jti)) return { ok: false as const, error: "token_replay" };
5658
if (payload.jobId !== expected.jobId)
5759
return { ok: false as const, error: "token_job_mismatch" };
5860
if (
@@ -63,9 +65,20 @@ export class JobTokenManager {
6365
return { ok: false as const, error: "token_node_mismatch" };
6466
}
6567

66-
this.usedJti.add(payload.jti);
68+
this.usedJtiExp.set(payload.jti, payload.exp);
6769
return { ok: true as const, payload };
6870
}
71+
72+
replayCacheSize() {
73+
this.pruneReplayCache();
74+
return this.usedJtiExp.size;
75+
}
76+
77+
private pruneReplayCache(now = Date.now()) {
78+
for (const [jti, exp] of this.usedJtiExp.entries()) {
79+
if (exp <= now) this.usedJtiExp.delete(jti);
80+
}
81+
}
6982
}
7083

7184
export class NodeTrustManager {

0 commit comments

Comments
 (0)