diff --git a/src/main/java/preponderous/viron/config/SecurityConfig.java b/src/main/java/preponderous/viron/config/SecurityConfig.java
new file mode 100644
index 0000000..6255080
--- /dev/null
+++ b/src/main/java/preponderous/viron/config/SecurityConfig.java
@@ -0,0 +1,84 @@
+// Copyright (c) 2024 Preponderous Software
+// MIT License
+
+package preponderous.viron.config;
+
+import java.nio.charset.StandardCharsets;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * Secures the Viron REST API with JWT bearer-token authentication (issue #149).
+ *
+ * Validates tokens issued by the UserAuth service: HMAC-signed
+ * (HS256/HS384/HS512) with a shared secret supplied via the {@code JWT_SECRET}
+ * environment variable, and an {@code iss} claim matching {@code app.jwt.issuer}
+ * (default {@code userauth}). The decoder configuration mirrors UserAuth's own, so a
+ * token minted there validates here without a network call.
+ *
+ *
All endpoints require a valid bearer token except actuator health and the
+ * OpenAPI/Swagger documentation. Sessions are stateless.
+ */
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+ @Bean
+ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(csrf -> csrf.disable())
+ .sessionManagement(session ->
+ session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(HttpMethod.GET, "/actuator/health", "/actuator/health/**")
+ .permitAll()
+ .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
+ .permitAll()
+ .anyRequest().authenticated())
+ .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
+ return http.build();
+ }
+
+ /**
+ * Builds a decoder for UserAuth's HMAC-signed JWTs. Mirrors UserAuth's
+ * {@code JwtConfig}: same secret-to-key derivation, the same MAC algorithm, and
+ * rejection of any token whose issuer does not match the configured value.
+ */
+ @Bean
+ JwtDecoder jwtDecoder(
+ @Value("${app.jwt.secret}") String secret,
+ @Value("${app.jwt.algorithm:HS256}") String algorithm,
+ @Value("${app.jwt.issuer:userauth}") String issuer) {
+ MacAlgorithm macAlgorithm = MacAlgorithm.from(algorithm);
+ if (macAlgorithm == null) {
+ throw new IllegalStateException(
+ "Unsupported app.jwt.algorithm '" + algorithm
+ + "'. Supported HMAC algorithms are: HS256, HS384, HS512.");
+ }
+ // MacAlgorithm "HS256" -> JCA name "HmacSHA256"
+ String jcaName = "HmacSHA" + macAlgorithm.getName().substring(2);
+ SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), jcaName);
+ NimbusJwtDecoder decoder =
+ NimbusJwtDecoder.withSecretKey(key).macAlgorithm(macAlgorithm).build();
+ // Validate the issuer in addition to the default validators (signature, expiry).
+ OAuth2TokenValidator validator = JwtValidators.createDefaultWithIssuer(issuer);
+ decoder.setJwtValidator(validator);
+ return decoder;
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 04bccee..d38d021 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -9,4 +9,11 @@ database.dbUsername=postgres
database.dbPassword=postgres
service.vironHost=http://localhost
-service.vironPort=9999
\ No newline at end of file
+service.vironPort=9999
+
+# --- JWT authentication (validates tokens issued by the UserAuth service) ---
+# Shared HMAC secret; REQUIRED (no default — the app fails to start without it).
+# For HS256 it must be at least 32 bytes. Must match UserAuth's JWT_SECRET.
+app.jwt.secret=${JWT_SECRET}
+app.jwt.issuer=${JWT_ISSUER:userauth}
+app.jwt.algorithm=${JWT_ALGORITHM:HS256}
\ No newline at end of file
diff --git a/src/test/java/preponderous/viron/config/SecurityConfigTest.java b/src/test/java/preponderous/viron/config/SecurityConfigTest.java
new file mode 100644
index 0000000..d56b561
--- /dev/null
+++ b/src/test/java/preponderous/viron/config/SecurityConfigTest.java
@@ -0,0 +1,44 @@
+// Copyright (c) 2024 Preponderous Software
+// MIT License
+
+package preponderous.viron.config;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import preponderous.viron.database.DbInteractions;
+
+/**
+ * Verifies the JWT authentication added for issue #149: API endpoints reject
+ * unauthenticated requests, while actuator health stays public. (Authenticated
+ * access is exercised by the {@code @WithMockUser} controller tests.)
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+class SecurityConfigTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private DbInteractions dbInteractions;
+
+ @Test
+ void apiRequestWithoutTokenIsUnauthorized() throws Exception {
+ mockMvc.perform(get("/api/v1/environments"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void actuatorHealthIsPublic() throws Exception {
+ mockMvc.perform(get("/actuator/health"))
+ .andExpect(status().isOk());
+ }
+}
diff --git a/src/test/java/preponderous/viron/controllers/DebugControllerTest.java b/src/test/java/preponderous/viron/controllers/DebugControllerTest.java
index 8b9c09a..d3f24e9 100644
--- a/src/test/java/preponderous/viron/controllers/DebugControllerTest.java
+++ b/src/test/java/preponderous/viron/controllers/DebugControllerTest.java
@@ -3,6 +3,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.annotation.DirtiesContext;
@@ -33,6 +34,7 @@
@SpringBootTest
@AutoConfigureMockMvc
+@WithMockUser
@DirtiesContext
class DebugControllerTest {
@@ -204,4 +206,4 @@ private List buildLocationGrid(int rows, int columns) {
}
return locations;
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/preponderous/viron/controllers/EntityControllerTest.java b/src/test/java/preponderous/viron/controllers/EntityControllerTest.java
index fdeee4b..aa7608d 100644
--- a/src/test/java/preponderous/viron/controllers/EntityControllerTest.java
+++ b/src/test/java/preponderous/viron/controllers/EntityControllerTest.java
@@ -3,6 +3,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -29,6 +30,7 @@
@SpringBootTest
@AutoConfigureMockMvc
+@WithMockUser
@DirtiesContext
class EntityControllerTest {
@@ -318,4 +320,4 @@ void updateEntityName_RepositoryThrowsException() throws Exception {
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").isString());
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java b/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java
index ce4621d..a805dc5 100644
--- a/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java
+++ b/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java
@@ -3,6 +3,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
@@ -30,6 +31,7 @@
@SpringBootTest
@AutoConfigureMockMvc
+@WithMockUser
@DirtiesContext
class EnvironmentControllerTest {
@@ -340,4 +342,4 @@ void updateEnvironmentName_BlankName_ReturnsBadRequest() throws Exception {
verify(environmentRepository, never()).updateName(anyInt(), anyString());
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/preponderous/viron/controllers/GridControllerTest.java b/src/test/java/preponderous/viron/controllers/GridControllerTest.java
index 47ae6c9..5159842 100644
--- a/src/test/java/preponderous/viron/controllers/GridControllerTest.java
+++ b/src/test/java/preponderous/viron/controllers/GridControllerTest.java
@@ -3,6 +3,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.annotation.DirtiesContext;
@@ -25,6 +26,7 @@
@SpringBootTest
@AutoConfigureMockMvc
+@WithMockUser
@DirtiesContext
class GridControllerTest {
@@ -199,4 +201,4 @@ void getGridOfEntity_RepositoryThrowsException() throws Exception {
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").isString());
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/preponderous/viron/controllers/LocationControllerTest.java b/src/test/java/preponderous/viron/controllers/LocationControllerTest.java
index eaf5d0f..b82dcaf 100644
--- a/src/test/java/preponderous/viron/controllers/LocationControllerTest.java
+++ b/src/test/java/preponderous/viron/controllers/LocationControllerTest.java
@@ -3,6 +3,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -25,6 +26,7 @@
@SpringBootTest
@AutoConfigureMockMvc
+@WithMockUser
class LocationControllerTest {
@Autowired
@@ -340,4 +342,4 @@ void removeEntityFromCurrentLocation_RepositoryThrowsException() throws Exceptio
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").isString());
}
-}
\ No newline at end of file
+}
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
new file mode 100644
index 0000000..513b7e5
--- /dev/null
+++ b/src/test/resources/application.properties
@@ -0,0 +1,20 @@
+# Copyright (c) 2024 Preponderous Software
+# MIT License
+#
+# Test configuration. Mirrors the runtime properties and supplies a fixed JWT secret
+# so the Spring context (which now requires app.jwt.secret) starts in tests.
+
+spring.application.name=viron
+server.port=9999
+
+database.dbUrl=jdbc:postgresql://localhost:5432/postgres
+database.dbUsername=postgres
+database.dbPassword=postgres
+
+service.vironHost=http://localhost
+service.vironPort=9999
+
+# Fixed test secret (>= 32 bytes for HS256). Not a real credential.
+app.jwt.secret=viron-test-jwt-secret-0123456789-abcdefghij
+app.jwt.issuer=userauth
+app.jwt.algorithm=HS256