diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java index 1007d686..c4e06565 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java @@ -1,15 +1,20 @@ package com.cameleer3.server.app.controller; import com.cameleer3.server.app.config.AgentRegistryConfig; +import com.cameleer3.server.app.security.BootstrapTokenValidator; import com.cameleer3.server.core.agent.AgentInfo; import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentState; +import com.cameleer3.server.core.security.Ed25519SigningService; +import com.cameleer3.server.core.security.InvalidTokenException; +import com.cameleer3.server.core.security.JwtService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; @@ -29,7 +34,7 @@ import java.util.List; import java.util.Map; /** - * Agent registration, heartbeat, and listing endpoints. + * Agent registration, heartbeat, listing, and token refresh endpoints. */ @RestController @RequestMapping("/api/v1/agents") @@ -37,25 +42,48 @@ import java.util.Map; public class AgentRegistrationController { private static final Logger log = LoggerFactory.getLogger(AgentRegistrationController.class); + private static final String BEARER_PREFIX = "Bearer "; private final AgentRegistryService registryService; private final AgentRegistryConfig config; private final ObjectMapper objectMapper; + private final BootstrapTokenValidator bootstrapTokenValidator; + private final JwtService jwtService; + private final Ed25519SigningService ed25519SigningService; public AgentRegistrationController(AgentRegistryService registryService, AgentRegistryConfig config, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + BootstrapTokenValidator bootstrapTokenValidator, + JwtService jwtService, + Ed25519SigningService ed25519SigningService) { this.registryService = registryService; this.config = config; this.objectMapper = objectMapper; + this.bootstrapTokenValidator = bootstrapTokenValidator; + this.jwtService = jwtService; + this.ed25519SigningService = ed25519SigningService; } @PostMapping("/register") @Operation(summary = "Register an agent", - description = "Registers a new agent or re-registers an existing one") + description = "Registers a new agent or re-registers an existing one. " + + "Requires bootstrap token in Authorization header.") @ApiResponse(responseCode = "200", description = "Agent registered successfully") @ApiResponse(responseCode = "400", description = "Invalid registration payload") - public ResponseEntity register(@RequestBody String body) throws JsonProcessingException { + @ApiResponse(responseCode = "401", description = "Missing or invalid bootstrap token") + public ResponseEntity register(@RequestBody String body, + HttpServletRequest request) throws JsonProcessingException { + // Validate bootstrap token + String authHeader = request.getHeader("Authorization"); + String bootstrapToken = null; + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + bootstrapToken = authHeader.substring(BEARER_PREFIX.length()); + } + if (bootstrapToken == null || !bootstrapTokenValidator.validate(bootstrapToken)) { + return ResponseEntity.status(401).build(); + } + JsonNode node = objectMapper.readTree(body); String agentId = getRequiredField(node, "agentId"); @@ -88,11 +116,61 @@ public class AgentRegistrationController { AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities); log.info("Agent registered: {} (name={}, group={})", agentId, name, group); + // Issue JWT tokens + String accessToken = jwtService.createAccessToken(agentId, group); + String refreshToken = jwtService.createRefreshToken(agentId, group); + Map response = new LinkedHashMap<>(); response.put("agentId", agent.id()); response.put("sseEndpoint", "/api/v1/agents/" + agentId + "/events"); response.put("heartbeatIntervalMs", config.getHeartbeatIntervalMs()); - response.put("serverPublicKey", null); + response.put("serverPublicKey", ed25519SigningService.getPublicKeyBase64()); + response.put("accessToken", accessToken); + response.put("refreshToken", refreshToken); + + return ResponseEntity.ok(objectMapper.writeValueAsString(response)); + } + + @PostMapping("/{id}/refresh") + @Operation(summary = "Refresh access token", + description = "Issues a new access JWT from a valid refresh token") + @ApiResponse(responseCode = "200", description = "New access token issued") + @ApiResponse(responseCode = "401", description = "Invalid or expired refresh token") + @ApiResponse(responseCode = "404", description = "Agent not found") + public ResponseEntity refresh(@PathVariable String id, + @RequestBody String body) throws JsonProcessingException { + JsonNode node = objectMapper.readTree(body); + String refreshToken = node.has("refreshToken") ? node.get("refreshToken").asText() : null; + + if (refreshToken == null || refreshToken.isBlank()) { + return ResponseEntity.status(401).build(); + } + + // Validate refresh token + String agentId; + try { + agentId = jwtService.validateRefreshToken(refreshToken); + } catch (InvalidTokenException e) { + log.debug("Refresh token validation failed: {}", e.getMessage()); + return ResponseEntity.status(401).build(); + } + + // Verify agent ID in path matches token + if (!id.equals(agentId)) { + log.debug("Refresh token agent ID mismatch: path={}, token={}", id, agentId); + return ResponseEntity.status(401).build(); + } + + // Verify agent exists + AgentInfo agent = registryService.findById(agentId); + if (agent == null) { + return ResponseEntity.notFound().build(); + } + + String newAccessToken = jwtService.createAccessToken(agentId, agent.group()); + + Map response = new LinkedHashMap<>(); + response.put("accessToken", newAccessToken); return ResponseEntity.ok(objectMapper.writeValueAsString(response)); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..6a7dc49d --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java @@ -0,0 +1,72 @@ +package com.cameleer3.server.app.security; + +import com.cameleer3.server.core.agent.AgentRegistryService; +import com.cameleer3.server.core.security.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * JWT authentication filter that extracts and validates JWT tokens from + * the {@code Authorization: Bearer} header or the {@code token} query parameter. + *

+ * Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig} + * to avoid double filter registration. + */ +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + private final AgentRegistryService agentRegistryService; + + public JwtAuthenticationFilter(JwtService jwtService, AgentRegistryService agentRegistryService) { + this.jwtService = jwtService; + this.agentRegistryService = agentRegistryService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String token = extractToken(request); + + if (token != null) { + try { + String agentId = jwtService.validateAndExtractAgentId(token); + if (agentRegistryService.findById(agentId) != null) { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(agentId, null, List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + log.debug("JWT valid but agent not found: {}", agentId); + } + } catch (Exception e) { + log.debug("JWT validation failed: {}", e.getMessage()); + } + } + + chain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + // Check Authorization header first + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + return authHeader.substring(BEARER_PREFIX.length()); + } + + // Fall back to query parameter (for SSE EventSource API) + return request.getParameter("token"); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java new file mode 100644 index 00000000..8f3b27c9 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -0,0 +1,53 @@ +package com.cameleer3.server.app.security; + +import com.cameleer3.server.core.agent.AgentRegistryService; +import com.cameleer3.server.core.security.JwtService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security configuration for JWT-based stateless authentication. + *

+ * Public endpoints: health, agent registration, refresh, API docs, Swagger UI. + * All other endpoints require a valid JWT access token. + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, + JwtService jwtService, + AgentRegistryService registryService) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/health", + "/api/v1/agents/register", + "/api/v1/agents/*/refresh", + "/api/v1/api-docs/**", + "/api/v1/swagger-ui/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore( + new JwtAuthenticationFilter(jwtService, registryService), + UsernamePasswordAuthenticationFilter.class + ); + + return http.build(); + } +}