Skip to content

Commit bd3381a

Browse files
test(server): cover the locked migration cases for per-job proxy JWTs
Issue #1165's acceptance criteria name four migration cases; the suite covered missing scope and stale attempt but not the rest. Adds: a legacy opaque job token is rejected at the proxy (no dual-accept window), a worker-session JWT cannot authenticate against the proxy, a job JWT bound to another workspace is rejected, an expired job JWT is rejected, a pre-migration token without the explicit type header is rejected, and a worker-session token smuggling job claims is rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1bb4f0d commit bd3381a

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

server/application/src/test/java/de/tum/cit/aet/hephaestus/agent/proxy/LlmProxyIntegrationTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,39 @@ void shouldAuthenticateRunningJobTokenAgainstPersistedRouting() throws Exception
9595
.isInstanceOf(ProxyRouting.class);
9696
}
9797

98+
@Test
99+
void shouldRejectLegacyOpaqueJobToken() throws Exception {
100+
// The database-backed secret the entity still persists is a pre-migration credential; the
101+
// proxy accepts only per-job JWTs — there is deliberately no dual-accept window.
102+
AgentJob job = runningJob(true);
103+
104+
AuthenticationResult result = authenticate(job.getJobToken());
105+
106+
assertThat(result.status()).isEqualTo(401);
107+
assertThat(result.authentication()).isNull();
108+
}
109+
110+
@Test
111+
void shouldRejectWorkerSessionJwtAtTheProxy() throws Exception {
112+
runningJob(true);
113+
114+
AuthenticationResult result = authenticate(jwtIssuer.issue("worker-1").token());
115+
116+
assertThat(result.status()).isEqualTo(401);
117+
assertThat(result.authentication()).isNull();
118+
}
119+
120+
@Test
121+
void shouldRejectJobJwtBoundToAnotherWorkspace() throws Exception {
122+
AgentJob job = runningJob(true);
123+
124+
AuthenticationResult result = authenticate(
125+
jwtIssuer.issueForJob(job.getId(), workspace.getId() + 1, job.getRetryCount(), Duration.ofMinutes(5)));
126+
127+
assertThat(result.status()).isEqualTo(401);
128+
assertThat(result.authentication()).isNull();
129+
}
130+
98131
@Test
99132
void shouldFailClosedWhenCatalogModelIsDisabled() {
100133
AgentJob job = runningJob(false);

server/application/src/test/java/de/tum/cit/aet/hephaestus/core/runtime/hub/auth/WorkerJwtTest.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.time.Instant;
1616
import java.util.Base64;
1717
import java.util.List;
18+
import java.util.Map;
1819
import java.util.UUID;
1920
import java.util.stream.Stream;
2021
import org.assertj.core.api.Assertions;
@@ -68,6 +69,65 @@ void issuedJobTokenVerifiesWithRequiredClaims() {
6869
assertThat(jwt.jti()).isNotBlank();
6970
}
7071

72+
@Test
73+
void expiredJobTokenRejected() {
74+
WorkerSigningKey active = keyRing.active();
75+
Instant past = Instant.now().minusSeconds(120);
76+
String token = JWT.create()
77+
.withHeader(Map.of("kid", active.kid(), "typ", "job+jwt"))
78+
.withIssuer("hephaestus-test")
79+
.withAudience("hephaestus-worker")
80+
.withClaim("job_id", UUID.randomUUID().toString())
81+
.withClaim("workspace_id", 42L)
82+
.withClaim("attempt", 0)
83+
.withClaim("scope", List.of("llm_proxy"))
84+
.withJWTId(UUID.randomUUID().toString())
85+
.withIssuedAt(past)
86+
.withExpiresAt(past.plusSeconds(30))
87+
.sign(Algorithm.RSA256(active.publicKey(), active.privateKey()));
88+
89+
assertThatThrownBy(() -> verifier.verify(token)).isInstanceOf(WorkerJwtInvalidException.class);
90+
}
91+
92+
@Test
93+
void legacyTokenWithoutExplicitTypeRejected() {
94+
// Tokens minted before the claim-profile migration carry the library default "JWT" header
95+
// type; the one-way cut rejects them even when the signature and claims would verify.
96+
WorkerSigningKey active = keyRing.active();
97+
String token = JWT.create()
98+
.withKeyId(active.kid())
99+
.withIssuer("hephaestus-test")
100+
.withAudience("hephaestus-worker")
101+
.withSubject("worker-1")
102+
.withJWTId(UUID.randomUUID().toString())
103+
.withIssuedAt(Instant.now())
104+
.withExpiresAt(Instant.now().plusSeconds(60))
105+
.sign(Algorithm.RSA256(active.publicKey(), active.privateKey()));
106+
107+
assertThatThrownBy(() -> verifier.verify(token))
108+
.isInstanceOf(WorkerJwtInvalidException.class)
109+
.hasMessageContaining("token type");
110+
}
111+
112+
@Test
113+
void workerSessionTokenCarryingJobClaimsRejected() {
114+
WorkerSigningKey active = keyRing.active();
115+
String token = JWT.create()
116+
.withHeader(Map.of("kid", active.kid(), "typ", "worker-session+jwt"))
117+
.withIssuer("hephaestus-test")
118+
.withAudience("hephaestus-worker")
119+
.withSubject("worker-1")
120+
.withClaim("job_id", UUID.randomUUID().toString())
121+
.withJWTId(UUID.randomUUID().toString())
122+
.withIssuedAt(Instant.now())
123+
.withExpiresAt(Instant.now().plusSeconds(60))
124+
.sign(Algorithm.RSA256(active.publicKey(), active.privateKey()));
125+
126+
assertThatThrownBy(() -> verifier.verify(token))
127+
.isInstanceOf(WorkerJwtInvalidException.class)
128+
.hasMessageContaining("job claims");
129+
}
130+
71131
@Test
72132
void revokedTokenRejected() {
73133
WorkerJwtIssuer.IssuedWorkerJwt issued = issuer.issue("worker-1");

0 commit comments

Comments
 (0)