1717
1818package io .seqera .http ;
1919
20+ import java .net .CookieManager ;
21+ import java .net .HttpCookie ;
2022import java .net .URI ;
23+ import java .net .URLEncoder ;
2124import java .net .http .HttpClient ;
2225import java .net .http .HttpRequest ;
2326import java .net .http .HttpResponse ;
24- import java .net .URLEncoder ;
2527import java .nio .charset .StandardCharsets ;
2628import java .util .Base64 ;
2729import 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