Skip to content

Commit 5f0f806

Browse files
pditommasoclaude
andcommitted
Improve JWT token refresh robustness and validation
- Add comprehensive validation for JWT token refresh configuration patterns - Enhance token extraction with cookie manager support and fallback to JSON parsing - Improve error handling and logging consistency in refresh operations - Add extensive test coverage for configuration validation scenarios - Reduce log noise by changing debug messages to trace level 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cda039b commit 5f0f806

File tree

2 files changed

+186
-71
lines changed

2 files changed

+186
-71
lines changed

lib-httpx/src/main/java/io/seqera/http/HxTokenManager.java

Lines changed: 103 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717

1818
package io.seqera.http;
1919

20+
import java.net.CookieManager;
21+
import java.net.HttpCookie;
2022
import java.net.URI;
23+
import java.net.URLEncoder;
2124
import java.net.http.HttpClient;
2225
import java.net.http.HttpRequest;
2326
import java.net.http.HttpResponse;
24-
import java.net.URLEncoder;
2527
import java.nio.charset.StandardCharsets;
2628
import java.util.Base64;
2729
import java.util.concurrent.CompletableFuture;
@@ -98,6 +100,7 @@ public class HxTokenManager {
98100

99101
private final HxConfig config;
100102
private final HttpClient refreshHttpClient;
103+
private final CookieManager cookieManager;
101104
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
102105

103106
private volatile String currentJwtToken;
@@ -111,11 +114,65 @@ public HxTokenManager(HxConfig config) {
111114
this.config = config;
112115
this.currentJwtToken = config.getJwtToken();
113116
this.currentRefreshToken = config.getRefreshToken();
117+
118+
// Validate JWT token refresh configuration
119+
validateTokenRefreshConfig();
120+
121+
this.cookieManager = new CookieManager();
114122
this.refreshHttpClient = HttpClient.newBuilder()
123+
.version(HttpClient.Version.HTTP_1_1)
124+
.followRedirects(HttpClient.Redirect.NORMAL)
125+
.cookieHandler(cookieManager)
115126
.connectTimeout(config.getTokenRefreshTimeout())
116127
.build();
117128
}
118129

130+
/**
131+
* Validates that JWT token refresh configuration follows the allowed patterns.
132+
*
133+
* <p>This method enforces that JWT configuration must be one of:
134+
* <ul>
135+
* <li><strong>JWT only</strong>: Just JWT token (no refresh capability)</li>
136+
* <li><strong>Complete JWT refresh</strong>: JWT token + refresh token + refresh URL (full refresh capability)</li>
137+
* <li><strong>No JWT</strong>: No JWT components at all</li>
138+
* </ul>
139+
*
140+
* <p>Partial configurations are not allowed as they would lead to confusing runtime behavior.
141+
*
142+
* @throws IllegalArgumentException if the JWT configuration is incomplete or inconsistent
143+
*/
144+
private void validateTokenRefreshConfig() {
145+
final boolean hasJwtToken = currentJwtToken != null && !currentJwtToken.trim().isEmpty();
146+
final boolean hasRefreshToken = currentRefreshToken != null && !currentRefreshToken.trim().isEmpty();
147+
final boolean hasRefreshUrl = config.getRefreshTokenUrl() != null && !config.getRefreshTokenUrl().trim().isEmpty();
148+
149+
// Count how many refresh components we have
150+
int refreshComponents = 0;
151+
if (hasRefreshToken) refreshComponents++;
152+
if (hasRefreshUrl) refreshComponents++;
153+
154+
// Valid configurations:
155+
// 1. JWT only: hasJwtToken=true, refreshComponents=0
156+
// 2. Complete refresh: hasJwtToken=true, refreshComponents=2
157+
// 3. No JWT: hasJwtToken=false, refreshComponents=0
158+
159+
if (hasJwtToken) {
160+
if (refreshComponents == 0) {
161+
log.trace("JWT token configured without refresh capability");
162+
} else if (refreshComponents == 2) {
163+
log.trace("JWT token refresh is fully configured and ready");
164+
} else {
165+
throw new IllegalArgumentException("JWT token refresh configuration is incomplete. Either provide only JWT token, or provide JWT token + refresh token + refresh URL");
166+
}
167+
} else {
168+
if (refreshComponents == 0) {
169+
log.trace("No JWT token authentication configured");
170+
} else {
171+
throw new IllegalArgumentException("Refresh components are configured without JWT token. Either remove refresh components or add JWT token");
172+
}
173+
}
174+
}
175+
119176
/**
120177
* Adds an Authorization header to the given HTTP request using the configured authentication method.
121178
*
@@ -192,8 +249,7 @@ public boolean canRefreshToken() {
192249
*/
193250
public boolean refreshToken() {
194251
if (!canRefreshToken()) {
195-
log.warn("Cannot refresh token: refreshToken={} refreshTokenUrl={}",
196-
(currentRefreshToken != null), config.getRefreshTokenUrl());
252+
log.warn("Cannot refresh token: refreshToken={} refreshTokenUrl={}", (currentRefreshToken != null), config.getRefreshTokenUrl());
197253
return false;
198254
}
199255

@@ -229,28 +285,27 @@ public boolean refreshToken() {
229285
*/
230286
public CompletableFuture<Boolean> getOrRefreshTokenAsync() {
231287
if (!canRefreshToken()) {
232-
log.warn("Cannot refresh token: refreshToken={} refreshTokenUrl={}",
233-
(currentRefreshToken != null), config.getRefreshTokenUrl());
288+
log.warn("Cannot refresh token: refreshToken={} refreshTokenUrl={}", (currentRefreshToken != null), config.getRefreshTokenUrl());
234289
return CompletableFuture.completedFuture(false);
235290
}
236291

237292
// Check if there's already a refresh in progress
238293
CompletableFuture<Boolean> refresh = currentRefresh;
239294
if (refresh != null && !refresh.isDone()) {
240-
log.debug("Token refresh already in progress, waiting for completion");
295+
log.trace("Token refresh already in progress, waiting for completion");
241296
return refresh;
242297
}
243298

244299
// Use double-checked locking to ensure only one thread starts the refresh
245300
synchronized(this) {
246301
refresh = currentRefresh;
247302
if (refresh != null && !refresh.isDone()) {
248-
log.debug("Token refresh already in progress (double-check), waiting for completion");
303+
log.trace("Token refresh already in progress (double-check), waiting for completion");
249304
return refresh;
250305
}
251306

252307
// Start new refresh operation
253-
log.debug("Starting coordinated token refresh");
308+
log.trace("Starting coordinated token refresh");
254309
currentRefresh = CompletableFuture.supplyAsync(() -> {
255310
lock.writeLock().lock();
256311
try {
@@ -269,7 +324,7 @@ public CompletableFuture<Boolean> getOrRefreshTokenAsync() {
269324
if (throwable != null) {
270325
log.error("Coordinated token refresh failed: " + throwable.getMessage(), throwable);
271326
} else {
272-
log.debug("Coordinated token refresh completed with result: " + result);
327+
log.trace("Coordinated token refresh completed with result: " + result);
273328
}
274329
});
275330

@@ -289,8 +344,7 @@ public CompletableFuture<Boolean> getOrRefreshTokenAsync() {
289344
*/
290345
public CompletableFuture<Boolean> refreshTokenAsync() {
291346
if (!canRefreshToken()) {
292-
log.warn("Cannot refresh token asynchronously: refreshToken={} refreshTokenUrl={}",
293-
(currentRefreshToken != null), config.getRefreshTokenUrl());
347+
log.warn("Cannot refresh token asynchronously: refreshToken={} refreshTokenUrl={}", (currentRefreshToken != null), config.getRefreshTokenUrl());
294348
return CompletableFuture.completedFuture(false);
295349
}
296350

@@ -323,21 +377,26 @@ public CompletableFuture<Boolean> refreshTokenAsync() {
323377
*/
324378
protected boolean doRefreshToken() {
325379
try {
326-
log.debug("Attempting to refresh JWT token using refresh token");
380+
final var refreshUrl = URI.create(config.getRefreshTokenUrl());
381+
log.trace("Attempting to refresh JWT token using refresh token at URL: {}", refreshUrl);
382+
383+
final String body = "grant_type=refresh_token&refresh_token=" +
384+
URLEncoder.encode(currentRefreshToken, StandardCharsets.UTF_8);
327385

328-
final String body = "grant_type=refresh_token&refresh_token=" +
329-
URLEncoder.encode(currentRefreshToken, StandardCharsets.UTF_8.toString());
330386
final HttpRequest request = HttpRequest.newBuilder()
331-
.uri(URI.create(config.getRefreshTokenUrl()))
387+
.uri(refreshUrl)
332388
.header("Content-Type", "application/x-www-form-urlencoded")
333389
.POST(HttpRequest.BodyPublishers.ofString(body))
334390
.timeout(config.getTokenRefreshTimeout())
335391
.build();
336392

337393
final HttpResponse<String> response = refreshHttpClient.send(request, HttpResponse.BodyHandlers.ofString());
394+
log.trace("Token refresh response: [{}]", response.statusCode());
338395

339396
if (response.statusCode() == 200) {
340-
return handleRefreshResponse(response);
397+
final var result = handleRefreshResponse(response);
398+
log.debug("JWT token refresh completed with result: {}", result);
399+
return result;
341400
} else {
342401
log.warn("Token refresh failed with status {}: {}", response.statusCode(), response.body());
343402
return false;
@@ -353,7 +412,7 @@ protected boolean doRefreshToken() {
353412
*
354413
* <p>This method supports multiple response formats:
355414
* <ul>
356-
* <li><strong>Cookie-based</strong>: Extracts JWT and JWT_REFRESH_TOKEN from Set-Cookie headers</li>
415+
* <li><strong>Cookie-based</strong>: Extracts JWT and JWT_REFRESH_TOKEN from cookie store</li>
357416
* <li><strong>JSON-based</strong>: Parses JSON response body for access_token and refresh_token fields</li>
358417
* </ul>
359418
*
@@ -365,44 +424,43 @@ protected boolean doRefreshToken() {
365424
*/
366425
protected boolean handleRefreshResponse(HttpResponse<String> response) {
367426
try {
368-
final String newJwtToken = extractTokenFromCookies(response, "JWT");
369-
final String newRefreshToken = extractTokenFromCookies(response, "JWT_REFRESH_TOKEN");
427+
// First try to extract tokens from cookie store (same approach as TowerXAuth)
428+
final HttpCookie authCookie = getCookie("JWT");
429+
final HttpCookie refreshCookie = getCookie("JWT_REFRESH_TOKEN");
370430

371-
if (newJwtToken != null) {
431+
if (authCookie != null && authCookie.getValue() != null) {
372432
log.trace("Successfully refreshed JWT token");
373-
currentJwtToken = newJwtToken;
433+
currentJwtToken = authCookie.getValue();
374434

375-
if (newRefreshToken != null) {
435+
if (refreshCookie != null && refreshCookie.getValue() != null) {
376436
log.trace("Successfully refreshed refresh token");
377-
currentRefreshToken = newRefreshToken;
378-
} else {
379-
log.debug("No new refresh token in response, keeping existing one");
437+
currentRefreshToken = refreshCookie.getValue();
380438
}
381439

382440
return true;
383441
} else {
384-
log.warn("No JWT token found in refresh response");
385-
442+
log.trace("No JWT token found in cookies, trying JSON response");
443+
386444
try {
387445
final JsonObject jsonResponse = JsonParser.parseString(response.body()).getAsJsonObject();
388446
if (jsonResponse.has("access_token")) {
389447
final String accessToken = jsonResponse.get("access_token").getAsString();
390448
if (isValidJwtToken(accessToken)) {
391-
log.trace("Successfully extracted JWT token from JSON response");
449+
log.trace("Successfully extracted JWT token from JSON");
392450
currentJwtToken = accessToken;
393-
451+
394452
if (jsonResponse.has("refresh_token")) {
395453
currentRefreshToken = jsonResponse.get("refresh_token").getAsString();
396-
log.trace("Successfully extracted refresh token from JSON response");
454+
log.trace("Successfully extracted refresh token from JSON");
397455
}
398-
456+
399457
return true;
400458
}
401459
}
402-
} catch (Exception jsonEx) {
403-
log.debug("Response is not valid JSON, trying to parse as form data");
460+
} catch (Exception e) {
461+
log.trace("Response is not valid JSON, trying to parse as form data");
404462
}
405-
463+
406464
return false;
407465
}
408466
} catch (Exception e) {
@@ -412,34 +470,21 @@ protected boolean handleRefreshResponse(HttpResponse<String> response) {
412470
}
413471

414472
/**
415-
* Extracts a token value from HTTP cookies in the response.
473+
* Extracts a cookie from the cookie store by name (same approach as TowerXAuth).
416474
*
417-
* <p>This method searches through all Set-Cookie headers for a cookie with the
418-
* specified name and returns its value. The cookie value is extracted from the
419-
* first occurrence of "cookieName=value" format, ignoring any cookie attributes
420-
* like Path, HttpOnly, etc.
475+
* <p>This method searches through the cookie store for a cookie with the
476+
* specified name and returns the HttpCookie object if found.
421477
*
422-
* @param response the HTTP response containing Set-Cookie headers
423478
* @param cookieName the name of the cookie to extract (e.g., "JWT", "JWT_REFRESH_TOKEN")
424-
* @return the cookie value if found, null otherwise
479+
* @return the HttpCookie if found, null otherwise
425480
*/
426-
protected String extractTokenFromCookies(HttpResponse<String> response, String cookieName) {
427-
return response.headers().allValues("Set-Cookie").stream()
428-
.filter(cookie -> cookie.startsWith(cookieName + "="))
429-
.map(cookie -> {
430-
final String[] parts = cookie.split(";");
431-
if (parts.length > 0) {
432-
final String cookieValue = parts[0];
433-
final int equalIndex = cookieValue.indexOf('=');
434-
if (equalIndex > 0 && equalIndex < cookieValue.length() - 1) {
435-
return cookieValue.substring(equalIndex + 1);
436-
}
437-
}
438-
return null;
439-
})
440-
.filter(value -> value != null)
441-
.findFirst()
442-
.orElse(null);
481+
protected HttpCookie getCookie(String cookieName) {
482+
for (HttpCookie cookie : cookieManager.getCookieStore().getCookies()) {
483+
if (cookieName.equals(cookie.getName())) {
484+
return cookie;
485+
}
486+
}
487+
return null;
443488
}
444489

445490
/**

0 commit comments

Comments
 (0)