feat(04-02): wire Spring Security filter chain with JWT auth, bootstrap registration, and refresh endpoint

- JwtAuthenticationFilter extracts JWT from Authorization header or query param, validates via JwtService
- SecurityConfig creates stateless SecurityFilterChain with public/protected endpoint split
- AgentRegistrationController requires bootstrap token, returns accessToken + refreshToken + serverPublicKey
- New POST /agents/{id}/refresh endpoint issues new access JWT from valid refresh token

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 20:13:53 +01:00
parent b3b4e62d34
commit 387e2e66b2
3 changed files with 208 additions and 5 deletions

View File

@@ -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<String> register(@RequestBody String body) throws JsonProcessingException {
@ApiResponse(responseCode = "401", description = "Missing or invalid bootstrap token")
public ResponseEntity<String> 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<String, Object> 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<String> 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<String, Object> response = new LinkedHashMap<>();
response.put("accessToken", newAccessToken);
return ResponseEntity.ok(objectMapper.writeValueAsString(response));
}