--- phase: 04-security plan: 02 type: execute wave: 2 depends_on: ["04-01"] files_modified: - cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java - cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java - cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java - cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentSseController.java - cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java - cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java - cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java - cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java - cameleer-server-app/src/test/java/com/cameleer/server/app/security/BootstrapTokenIT.java - cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java - cameleer-server-app/src/test/java/com/cameleer/server/app/security/TestSecurityConfig.java autonomous: true requirements: - SECU-01 - SECU-02 - SECU-05 must_haves: truths: - "All API endpoints except health, register, and docs reject requests without valid JWT" - "POST /register requires bootstrap token in Authorization header, returns JWT + refresh token + Ed25519 public key" - "POST /agents/{id}/refresh accepts refresh token and returns new access JWT" - "SSE endpoint accepts JWT via ?token= query parameter" - "Health endpoint and Swagger UI remain publicly accessible" artifacts: - path: "cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java" provides: "OncePerRequestFilter extracting JWT from header or query param" - path: "cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java" provides: "SecurityFilterChain with permitAll for public paths, authenticated for rest" - path: "cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java" provides: "Updated register endpoint with bootstrap token validation, JWT issuance, public key" key_links: - from: "JwtAuthenticationFilter" to: "JwtService.validateAndExtractAgentId" via: "Filter delegates JWT validation to service" pattern: "jwtService\\.validateAndExtractAgentId" - from: "SecurityConfig" to: "JwtAuthenticationFilter" via: "addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)" pattern: "addFilterBefore" - from: "AgentRegistrationController.register" to: "BootstrapTokenValidator.validate" via: "Validates bootstrap token before processing registration" pattern: "bootstrapTokenValidator\\.validate" - from: "AgentRegistrationController.register" to: "JwtService.createAccessToken + createRefreshToken" via: "Issues tokens in registration response" pattern: "jwtService\\.create(Access|Refresh)Token" --- Wire Spring Security into the application: JWT authentication filter, SecurityFilterChain configuration, bootstrap token validation on registration, JWT issuance in registration response, refresh endpoint, and SSE query-parameter authentication. Update existing tests to work with security enabled. Purpose: Protects all endpoints with JWT authentication while keeping public endpoints accessible and providing the full agent registration-to-authentication flow. Output: Working security filter chain with protected/public endpoints, registration returns JWT + public key, refresh flow works, all tests pass. @C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-security/04-CONTEXT.md @.planning/phases/04-security/04-RESEARCH.md @.planning/phases/04-security/04-VALIDATION.md @.planning/phases/04-security/04-01-SUMMARY.md @cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java @cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentSseController.java @cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java @cameleer-server-app/src/test/java/com/cameleer/server/app/AbstractClickHouseIT.java @cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java From core/security/JwtService.java: ```java public interface JwtService { String createAccessToken(String agentId, String group); String createRefreshToken(String agentId, String group); String validateAndExtractAgentId(String token); // access tokens only String validateRefreshToken(String token); // refresh tokens only } ``` From core/security/Ed25519SigningService.java: ```java public interface Ed25519SigningService { String sign(String payload); String getPublicKeyBase64(); } ``` From app/security/BootstrapTokenValidator.java: ```java public class BootstrapTokenValidator { public boolean validate(String provided); } ``` From app/security/SecurityProperties.java: ```java @ConfigurationProperties(prefix = "security") public class SecurityProperties { long accessTokenExpiryMs; // default 3600000 long refreshTokenExpiryMs; // default 604800000 String bootstrapToken; // from CAMELEER_AUTH_TOKEN env String bootstrapTokenPrevious; // from CAMELEER_AUTH_TOKEN_PREVIOUS env, nullable } ``` From core/agent/AgentRegistryService.java: ```java public class AgentRegistryService { public AgentInfo register(String id, String name, String group, String version, List routeIds, Map capabilities); public AgentInfo findById(String id); } ``` Task 1: SecurityFilterChain + JwtAuthenticationFilter + registration/refresh integration cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java, cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java, cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java, cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentSseController.java, cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java 1. Create `JwtAuthenticationFilter extends OncePerRequestFilter` (NOT annotated @Component -- constructed in SecurityConfig to avoid double registration): - Constructor takes `JwtService` and `AgentRegistryService` - `doFilterInternal`: extract token via `extractToken(request)`, if token present: call `jwtService.validateAndExtractAgentId(token)`, verify agent exists via `agentRegistry.findById(agentId)`, if valid set `UsernamePasswordAuthenticationToken(agentId, null, List.of())` in `SecurityContextHolder`. If any exception, log debug and do NOT set auth (Spring Security rejects). Always call `chain.doFilter(request, response)`. - `extractToken(request)`: first check `Authorization` header for `Bearer ` prefix, then check `request.getParameter("token")` for SSE query param. Return null if neither. 2. Create `SecurityConfig` as `@Configuration @EnableWebSecurity`: - Single `@Bean SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, AgentRegistryService registryService)`: - `csrf(AbstractHttpConfigurer::disable)` -- REST API, no browser forms - `sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))` - `authorizeHttpRequests`: permitAll for `/api/v1/health`, `/api/v1/agents/register`, `/api/v1/api-docs/**`, `/api/v1/swagger-ui/**`, `/swagger-ui/**`, `/v3/api-docs/**`, `/swagger-ui.html`. `anyRequest().authenticated()`. - `addFilterBefore(new JwtAuthenticationFilter(jwtService, registryService), UsernamePasswordAuthenticationFilter.class)` - Also disable default form login and httpBasic: `.formLogin(AbstractHttpConfigurer::disable).httpBasic(AbstractHttpConfigurer::disable)` 3. Update `AgentRegistrationController.register()`: - Add `BootstrapTokenValidator`, `JwtService`, `Ed25519SigningService` as constructor dependencies - Before processing registration body, extract bootstrap token from `Authorization: Bearer ` header (use `@RequestHeader("Authorization")` or extract from HttpServletRequest). If missing or invalid (`bootstrapTokenValidator.validate()` returns false), return `401 Unauthorized` with no detail body. - After successful registration, generate tokens: `jwtService.createAccessToken(agentId, group)` and `jwtService.createRefreshToken(agentId, group)` - Update response map: replace `"serverPublicKey", null` with `"serverPublicKey", ed25519SigningService.getPublicKeyBase64()`. Add `"accessToken"` and `"refreshToken"` fields. 4. Add a new refresh endpoint in `AgentRegistrationController` (or a new controller -- keep it in the same controller since it's agent auth flow): - `POST /api/v1/agents/{id}/refresh` with request body containing `{"refreshToken": "..."}`. - Validate refresh token via `jwtService.validateRefreshToken(token)`, extract agentId, verify it matches path `{id}`, verify agent exists. - Return new access token: `{"accessToken": "..."}`. - Return 401 for invalid/expired refresh token, 404 for unknown agent. - NOTE: This endpoint must be AUTHENTICATED (requires valid JWT OR the refresh token itself). Per the user decision, the refresh endpoint uses the refresh token for auth, so add `/api/v1/agents/*/refresh` to permitAll in SecurityConfig, and validate the refresh token in the controller itself. 5. Update `AgentSseController.events()`: - The SSE endpoint uses `?token=` query parameter. The `JwtAuthenticationFilter` already handles this (extracts from query param). No changes needed to the controller itself -- Spring Security handles auth via the filter. - However, verify the SSE endpoint path `/api/v1/agents/{id}/events` is NOT in permitAll (it should require JWT auth). 6. Update `WebConfig` if needed: The `ProtocolVersionInterceptor` excluded paths should align with Spring Security public paths. The SSE events path is already excluded from protocol version check (Phase 3 decision). Verify no conflicts. cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean compile -pl cameleer-server-app - SecurityConfig creates stateless filter chain with correct public/protected path split - JwtAuthenticationFilter extracts JWT from header or query param, validates, sets SecurityContext - Registration endpoint requires bootstrap token, returns accessToken + refreshToken + serverPublicKey - Refresh endpoint issues new access token from valid refresh token - Application compiles with all security wiring Task 2: Security integration tests + existing test adaptation cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/TestSecurityConfig.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/BootstrapTokenIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/OpenApiIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/HealthControllerIT.java 1. Replace the Plan 01 temporary `TestSecurityConfig` (permit-all) with real security active in tests. Remove the permit-all override so tests run with actual security enforcement. 2. Create `TestSecurityHelper` utility class in test root: - Autowire `JwtService` and `AgentRegistryService` - `registerTestAgent(String agentId)`: calls `registryService.register(agentId, "test", "test-group", "1.0", List.of(), Map.of())` and returns `jwtService.createAccessToken(agentId, "test-group")` - `authHeaders(String jwt)`: returns HttpHeaders with `Authorization: Bearer ` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json` - `bootstrapHeaders()`: returns HttpHeaders with `Authorization: Bearer test-bootstrap-token` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json` - Make it a Spring `@Component` so it can be autowired in test classes 3. Update ALL existing IT classes (17 files) to use JWT authentication: - Autowire `TestSecurityHelper` - In `@BeforeEach` or at test start, call `helper.registerTestAgent("test-agent-")` to get a JWT - Replace all `protocolHeaders()` calls with headers that include the JWT Bearer token - For HealthControllerIT and OpenApiIT: verify these still work WITHOUT JWT (they're public endpoints) - For AgentRegistrationControllerIT: update `registerAgent()` helper to use bootstrap token header, verify response now includes `accessToken`, `refreshToken`, `serverPublicKey` (non-null) 4. Create new security-specific integration tests: `SecurityFilterIT` (extends AbstractClickHouseIT): - Test: GET /api/v1/agents without JWT returns 401 or 403 - Test: GET /api/v1/agents with valid JWT returns 200 - Test: GET /api/v1/health without JWT returns 200 (public) - Test: POST /api/v1/data/executions without JWT returns 401 or 403 - Test: Request with expired JWT returns 401 or 403 - Test: Request with malformed JWT returns 401 or 403 `BootstrapTokenIT` (extends AbstractClickHouseIT): - Test: POST /register without bootstrap token returns 401 - Test: POST /register with wrong bootstrap token returns 401 - Test: POST /register with correct bootstrap token returns 200 with tokens - Test: POST /register with previous bootstrap token returns 200 (dual-token rotation) `RegistrationSecurityIT` (extends AbstractClickHouseIT): - Test: Registration response contains non-null `serverPublicKey` (Base64 string) - Test: Registration response contains `accessToken` and `refreshToken` - Test: Access token from registration can be used to access protected endpoints `JwtRefreshIT` (extends AbstractClickHouseIT): - Test: POST /agents/{id}/refresh with valid refresh token returns new access token - Test: POST /agents/{id}/refresh with expired refresh token returns 401 - Test: POST /agents/{id}/refresh with access token (wrong type) returns 401 - Test: POST /agents/{id}/refresh with mismatched agent ID returns 401 - Test: New access token from refresh can access protected endpoints cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify - All 17 existing ITs pass with JWT authentication - SecurityFilterIT: protected endpoints reject unauthenticated requests, public endpoints remain open - BootstrapTokenIT: registration requires valid bootstrap token, supports dual-token rotation - RegistrationSecurityIT: registration returns public key + tokens - JwtRefreshIT: refresh flow issues new access tokens, rejects invalid refresh tokens - Full `mvn clean verify` is green mvn clean verify All existing tests pass with JWT auth. New security ITs validate protected/public endpoint split, bootstrap token flow, registration security, and refresh flow. - Protected endpoints return 401/403 without JWT, 200 with valid JWT - Public endpoints (health, register, docs) remain accessible without JWT - Registration requires bootstrap token, returns accessToken + refreshToken + serverPublicKey - Refresh endpoint issues new access JWT from valid refresh token - SSE endpoint accepts JWT via query parameter - All 17 existing ITs adapted and passing - 4 new security ITs passing After completion, create `.planning/phases/04-security/04-02-SUMMARY.md`