From 433155ae0c145ef1defd3f3270a8ae324627579d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:36:28 +0200 Subject: [PATCH] feat(web): add ArtifactDownloadController with HMAC URL auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New permitAll endpoint GET /api/v1/artifacts/{appVersionId}?exp&sig that the cameleer-runtime-loader init container hits to stream the deployed JAR. Auth is the HMAC-signed URL (sig + exp) — no JWT, no bootstrap token — so SecurityConfig permits the path and the controller does the verification itself. Also hardens ArtifactDownloadTokenSigner to reject null/blank jwtSecret at construction (Task 6 review feedback I-3). Wires the ArtifactDownloadTokenSigner bean in SecurityBeanConfig from ${cameleer.server.security.jwtsecret}, the same property the rest of the security stack uses. Test coverage: 200/401/404 paths via standalone-MockMvc unit test (avoids dragging in WebConfig's audit + usage interceptors that pull the full bean graph) plus the existing signer suite extended with a null/blank-secret guard test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/security/SecurityBeanConfig.java | 10 +++ .../server/app/security/SecurityConfig.java | 2 + .../app/web/ArtifactDownloadController.java | 59 +++++++++++++ .../app/web/ArtifactDownloadTokenSigner.java | 3 + .../web/ArtifactDownloadControllerTest.java | 88 +++++++++++++++++++ .../web/ArtifactDownloadTokenSignerTest.java | 11 +++ 6 files changed, 173 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadController.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadControllerTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java index d9ed209b..c4cb96a0 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java @@ -1,10 +1,14 @@ package com.cameleer.server.app.security; +import com.cameleer.server.app.web.ArtifactDownloadTokenSigner; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.time.Clock; + /** * Configuration class that creates security service beans and validates * that required security properties are set. @@ -34,6 +38,12 @@ public class SecurityBeanConfig { return new BootstrapTokenValidator(properties); } + @Bean + public ArtifactDownloadTokenSigner artifactDownloadTokenSigner( + @Value("${cameleer.server.security.jwtsecret}") String jwtSecret) { + return new ArtifactDownloadTokenSigner(jwtSecret, Clock.systemUTC()); + } + @Bean public InitializingBean bootstrapTokenValidation(SecurityProperties properties) { return () -> { diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java index afb2f453..63613e45 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java @@ -90,6 +90,8 @@ public class SecurityConfig { "/api/v1/agents/register", "/api/v1/agents/*/refresh", "/api/v1/auth/**", + // HMAC URL signature is the auth — see ArtifactDownloadController + "/api/v1/artifacts/**", "/api/v1/api-docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**", diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadController.java new file mode 100644 index 00000000..cae458c1 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadController.java @@ -0,0 +1,59 @@ +package com.cameleer.server.app.web; + +import com.cameleer.server.core.runtime.AppService; +import com.cameleer.server.core.runtime.AppVersion; +import com.cameleer.server.core.storage.ArtifactStore; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +/** + * Token-validated artifact download endpoint hit by the cameleer-runtime-loader + * init container. Auth is the HMAC sig + exp on the URL — NOT JWT/bootstrap + * token. permitAll'd in SecurityConfig because all auth is in the controller. + */ +@RestController +@RequestMapping("/api/v1/artifacts") +public class ArtifactDownloadController { + + private final AppService appService; + private final ArtifactStore artifactStore; + private final ArtifactDownloadTokenSigner signer; + + public ArtifactDownloadController(AppService appService, + ArtifactStore artifactStore, + ArtifactDownloadTokenSigner signer) { + this.appService = appService; + this.artifactStore = artifactStore; + this.signer = signer; + } + + @GetMapping("/{appVersionId}") + public ResponseEntity download(@PathVariable UUID appVersionId, + @RequestParam("exp") long exp, + @RequestParam("sig") String sig) throws IOException { + if (!signer.verify(appVersionId, exp, sig)) { + return ResponseEntity.status(401).build(); + } + AppVersion version; + try { + version = appService.getVersion(appVersionId); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + InputStream in = artifactStore.get(appService.coordinatesFor(version)); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/java-archive")) + .contentLength(version.jarSizeBytes()) + .body(new InputStreamResource(in)); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java index 2de1abf0..4a731b07 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java @@ -26,6 +26,9 @@ public class ArtifactDownloadTokenSigner { private final Clock clock; public ArtifactDownloadTokenSigner(String jwtSecret, Clock clock) { + if (jwtSecret == null || jwtSecret.isBlank()) { + throw new IllegalArgumentException("jwtSecret must not be null or blank"); + } this.key = deriveKey(jwtSecret); this.clock = clock; } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadControllerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadControllerTest.java new file mode 100644 index 00000000..5f69d19b --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadControllerTest.java @@ -0,0 +1,88 @@ +package com.cameleer.server.app.web; + +import com.cameleer.server.core.runtime.AppService; +import com.cameleer.server.core.runtime.AppVersion; +import com.cameleer.server.core.storage.ArtifactCoordinates; +import com.cameleer.server.core.storage.ArtifactStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.io.ByteArrayInputStream; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Unit-level test for {@link ArtifactDownloadController} using + * {@link MockMvcBuilders#standaloneSetup}. The auth check IS the controller — + * see the controller class doc and {@code SecurityConfig} permitAll for + * {@code /api/v1/artifacts/**} — so there is no security context to load. + * Standalone setup also avoids dragging in {@code WebConfig}'s interceptors + * (audit / usage tracking), which otherwise pull the full bean graph. + */ +class ArtifactDownloadControllerTest { + + private AppService appService; + private ArtifactStore artifactStore; + private ArtifactDownloadTokenSigner signer; + private MockMvc mvc; + + @BeforeEach + void setUp() { + appService = mock(AppService.class); + artifactStore = mock(ArtifactStore.class); + signer = mock(ArtifactDownloadTokenSigner.class); + ArtifactDownloadController controller = + new ArtifactDownloadController(appService, artifactStore, signer); + mvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void streamsBytesWhenSignatureValid() throws Exception { + UUID id = UUID.randomUUID(); + when(signer.verify(eq(id), eq(123L), eq("good"))).thenReturn(true); + AppVersion v = new AppVersion(id, UUID.randomUUID(), 1, "loc", "h", "a.jar", 5L, null, null, null); + when(appService.getVersion(id)).thenReturn(v); + when(appService.coordinatesFor(v)).thenReturn(new ArtifactCoordinates("default", v.appId(), 1)); + when(artifactStore.get(any())).thenReturn(new ByteArrayInputStream("hello".getBytes())); + + mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "good")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/java-archive")) + .andExpect(content().bytes("hello".getBytes())); + } + + @Test + void rejectsBadSignature() throws Exception { + UUID id = UUID.randomUUID(); + when(signer.verify(any(), anyLong(), any())).thenReturn(false); + + mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "bad")) + .andExpect(status().isUnauthorized()); + + // Defence-in-depth: bad sig must short-circuit before any data lookup. + verify(appService, never()).getVersion(any()); + } + + @Test + void returns404WhenArtifactMissing() throws Exception { + UUID id = UUID.randomUUID(); + when(signer.verify(any(), anyLong(), any())).thenReturn(true); + when(appService.getVersion(id)).thenThrow(new IllegalArgumentException("AppVersion not found")); + + mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "ok")) + .andExpect(status().isNotFound()); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java index 7b7cfb80..5c907c37 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java @@ -8,6 +8,7 @@ import java.time.ZoneOffset; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class ArtifactDownloadTokenSignerTest { @@ -48,6 +49,16 @@ class ArtifactDownloadTokenSignerTest { assertThat(signer.verify(id, pastExp, sig)).isFalse(); } + @Test + void rejectsNullOrBlankSecret() { + assertThatThrownBy(() -> new ArtifactDownloadTokenSigner(null, clock)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ArtifactDownloadTokenSigner("", clock)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ArtifactDownloadTokenSigner(" ", clock)) + .isInstanceOf(IllegalArgumentException.class); + } + /** Forces the constant-time-compare branch by flipping one char at the same length — * exercises {@code MessageDigest.isEqual}'s byte-by-byte path, not the length short-circuit. */ @Test