From 17ef48e392672d114e4d1e13ee6d6a748baf1a2a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:44:16 +0100 Subject: [PATCH] fix: return rotated refresh token from agent token refresh endpoint Previously the refresh endpoint only returned a new accessToken, causing agents to lose their refreshToken after the first refresh cycle and forcing a full re-registration every ~2 hours. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/controller/AgentRegistrationController.java | 3 ++- .../cameleer3/server/app/dto/AgentRefreshResponse.java | 4 ++-- .../com/cameleer3/server/app/security/JwtRefreshIT.java | 2 ++ ui/src/api/openapi.json | 8 ++++++-- ui/src/api/schema.d.ts | 3 ++- 5 files changed, 14 insertions(+), 6 deletions(-) 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 64aacfe4..f2c579b0 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 @@ -159,8 +159,9 @@ public class AgentRegistrationController { List roles = result.roles().isEmpty() ? List.of("AGENT") : result.roles(); String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles); + String newRefreshToken = jwtService.createRefreshToken(agentId, agent.group(), roles); - return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken)); + return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken)); } @PostMapping("/{id}/heartbeat") diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshResponse.java index 95562087..70b16476 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshResponse.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshResponse.java @@ -3,5 +3,5 @@ package com.cameleer3.server.app.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -@Schema(description = "Refreshed access token") -public record AgentRefreshResponse(@NotNull String accessToken) {} +@Schema(description = "Refreshed access and refresh tokens") +public record AgentRefreshResponse(@NotNull String accessToken, @NotNull String refreshToken) {} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java index af033318..ba46c416 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java @@ -79,6 +79,8 @@ class JwtRefreshIT extends AbstractPostgresIT { JsonNode body = objectMapper.readTree(response.getBody()); assertThat(body.get("accessToken").asText()).isNotEmpty(); + assertThat(body.get("refreshToken").asText()).isNotEmpty(); + assertThat(body.get("refreshToken").asText()).isNotEqualTo(refreshToken); } @Test diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index 1b16c126..486ab06a 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -2913,14 +2913,18 @@ }, "AgentRefreshResponse": { "type": "object", - "description": "Refreshed access token", + "description": "Refreshed access and refresh tokens", "properties": { "accessToken": { "type": "string" + }, + "refreshToken": { + "type": "string" } }, "required": [ - "accessToken" + "accessToken", + "refreshToken" ] }, "CommandRequest": { diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 71be5cc9..8cfc2a76 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -1069,9 +1069,10 @@ export interface components { AgentRefreshRequest: { refreshToken: string; }; - /** @description Refreshed access token */ + /** @description Refreshed access and refresh tokens */ AgentRefreshResponse: { accessToken: string; + refreshToken: string; }; /** @description Command to send to agent(s) */ CommandRequest: {