diff --git a/lib-httpx/README.md b/lib-httpx/README.md index aec7cf9..a20f6bf 100644 --- a/lib-httpx/README.md +++ b/lib-httpx/README.md @@ -21,11 +21,13 @@ dependencies { - **Retry Logic**: Automatic retry for configurable HTTP status codes (default: 429, 500, 502, 503, 504) - **Authentication Support**: Built-in support for JWT Bearer tokens and HTTP Basic authentication - **JWT Token Refresh**: Automatic JWT token refresh when receiving 401 Unauthorized responses with configurable cookie policies +- **Multi-Session Auth**: Support for multiple concurrent authentication sessions with per-request token management +- **Custom Token Storage**: Pluggable token store interface for distributed deployments (Redis, database, etc.) - **WWW-Authenticate Support**: Automatic handling of HTTP authentication challenges (Basic and Bearer schemes) - **Anonymous Authentication**: Fallback to anonymous authentication when credentials aren't provided - **Configurable**: Customizable retry policies, timeouts, token refresh, authentication settings, and cookie policies - **Generic Integration**: Compatible with any `Retryable.Config` for flexible retry configuration -- **Thread-safe**: Safe for concurrent use +- **Thread-safe**: Safe for concurrent use with atomic token refresh coordination - **Async Support**: Support for both synchronous and asynchronous requests ## Usage @@ -139,6 +141,69 @@ HxClient client = HxClient.newBuilder().config(config).build(); - **Basic**: Uses empty credentials (base64 encoded `:`) - **Bearer**: Attempts to get anonymous tokens from the authentication endpoint using OAuth2 flow +### Multi-Session Authentication + +For applications managing multiple users or authentication contexts, use `HxAuth` to handle per-request authentication with automatic token refresh: + +```java +// Create a shared client with refresh URL configured +HxClient client = HxClient.newBuilder() + .refreshTokenUrl("https://api.example.com/oauth/token") + .build(); + +// Create auth for each user session +HxAuth user1Auth = HxAuth.of("user1.jwt.token", "user1-refresh-token"); +HxAuth user2Auth = HxAuth.of("user2.jwt.token", "user2-refresh-token"); + +// Make requests with per-user authentication +HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://api.example.com/data")) + .GET() + .build(); + +HttpResponse response1 = client.send(request, user1Auth, HttpResponse.BodyHandlers.ofString()); +HttpResponse response2 = client.send(request, user2Auth, HttpResponse.BodyHandlers.ofString()); + +// Async requests also supported +CompletableFuture> future = client.sendAsync(request, user1Auth, HttpResponse.BodyHandlers.ofString()); +``` + +**Features:** +- Each `HxAuth` maintains its own token pair (access + refresh) +- Automatic token refresh on 401 responses, scoped to each auth session +- Thread-safe concurrent refresh coordination per auth key +- Tokens are identified by SHA-256 hash of the access token + +#### Custom Token Store + +By default, tokens are stored in an in-memory `ConcurrentHashMap`. For distributed deployments, provide a custom `HxTokenStore`: + +```java +// Implement custom store (e.g., Redis-backed) +public class RedisTokenStore implements HxTokenStore { + @Override + public HxAuth get(String key) { /* Redis GET */ } + + @Override + public void put(String key, HxAuth auth) { /* Redis SET */ } + + @Override + public HxAuth remove(String key) { /* Redis DEL */ } + + @Override + public HxAuth putIfAbsent(HxAuth auth) { + // Use Redis SETNX for atomic operation + } +} + +// Use custom store +HxTokenStore customStore = new RedisTokenStore(); +HxClient client = HxClient.newBuilder() + .tokenStore(customStore) + .refreshTokenUrl("https://api.example.com/oauth/token") + .build(); +``` + ### Custom Retry Configuration ```java @@ -238,6 +303,7 @@ HxClient client = HxClient.newBuilder() | `refreshToken` | Refresh token for JWT renewal | null | | `refreshTokenUrl` | URL for token refresh requests | null | | `tokenRefreshTimeout` | Timeout for token refresh requests | 30s | +| `tokenStore` | Custom token store for multi-session authentication | HxMapTokenStore | | `basicAuthToken` | Token for HTTP Basic authentication (username:password format) | null | | `refreshCookiePolicy` | Cookie policy for JWT token refresh operations (ACCEPT_ALL, ACCEPT_NONE, ACCEPT_ORIGINAL_SERVER) | null | | `wwwAuthentication` | Enable WWW-Authenticate challenge handling | false | @@ -257,7 +323,9 @@ All classes and methods include comprehensive Javadoc documentation covering: - **`HxClient`** (Http eXtended Client): Main client class with retry, JWT, and WWW-Authenticate functionality - **`HxConfig`**: Configuration builder with all available options including Retryable.Config integration -- **`HxTokenManager`**: Thread-safe JWT token lifecycle management +- **`HxAuth`**: Immutable container for JWT access token and refresh token pairs +- **`HxTokenStore`**: Interface for pluggable token storage (default: in-memory ConcurrentHashMap) +- **`HxTokenManager`**: Thread-safe JWT token lifecycle management with multi-session support - **`AuthenticationChallenge`**: Represents a parsed WWW-Authenticate challenge - **`AuthenticationScheme`**: Enumeration of supported authentication schemes (Basic, Bearer) - **`AuthenticationCallback`**: Interface for providing authentication credentials diff --git a/lib-httpx/src/main/java/io/seqera/http/HxAuth.java b/lib-httpx/src/main/java/io/seqera/http/HxAuth.java new file mode 100644 index 0000000..895739e --- /dev/null +++ b/lib-httpx/src/main/java/io/seqera/http/HxAuth.java @@ -0,0 +1,142 @@ +/* + * Copyright 2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.http; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * Immutable container for JWT authentication credentials. + * + *

This class holds a JWT access token and an optional refresh token as a pair. + * The storage key can be computed using {@link #key(HxAuth)} which returns the + * SHA-256 hash of the access token. + * + *

Instances are created using the factory methods {@link #of(String)} or + * {@link #of(String, String)}. + * + * @author Paolo Di Tommaso + */ +public final class HxAuth { + + private final String accessToken; + private final String refreshToken; + + private HxAuth(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + /** + * Creates an HxAuth instance. + * + * @param token the authentication token + * @return a new HxAuth instance with null refresh token + */ + public static HxAuth of(String token) { + return new HxAuth(token, null); + } + + /** + * Creates an HxAuth instance. + * + * @param token the authentication token + * @param refresh the refresh token + * @return a new HxAuth instance + */ + public static HxAuth of(String token, String refresh) { + return new HxAuth(token, refresh); + } + + /** + * Computes the storage key for the given authentication object. + * + *

Returns the SHA-256 hash of the access token as a hexadecimal string. + * + * @param auth the authentication object (must not be null) + * @return the computed key as a 64-character hexadecimal string + * @throws IllegalArgumentException if auth is null + */ + public static String key(HxAuth auth) { + if (auth == null) { + throw new IllegalArgumentException("auth cannot be null"); + } + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(auth.accessToken().getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } + catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + + /** + * Computes the storage key for the given authentication object, or returns a default value if null. + * + * @param auth the authentication object, may be null + * @param defaultValue the value to return if auth is null + * @return the computed key, or defaultValue if auth is null + */ + public static String keyOrDefault(HxAuth auth, String defaultValue) { + return auth != null ? key(auth) : defaultValue; + } + + /** + * Returns the JWT access token. + * + * @return the access token + */ + public String accessToken() { + return accessToken; + } + + /** + * Returns the refresh token. + * + * @return the refresh token, or null if not set + */ + public String refreshToken() { + return refreshToken; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + HxAuth hxAuth = (HxAuth) o; + return Objects.equals(accessToken, hxAuth.accessToken) + && Objects.equals(refreshToken, hxAuth.refreshToken); + } + + @Override + public int hashCode() { + return Objects.hash(accessToken, refreshToken); + } + + @Override + public String toString() { + return "HxAuth[" + + "accessToken=" + accessToken + + ", refreshToken=" + refreshToken + + ']'; + } +} \ No newline at end of file diff --git a/lib-httpx/src/main/java/io/seqera/http/HxClient.java b/lib-httpx/src/main/java/io/seqera/http/HxClient.java index 1908867..ee9bffe 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxClient.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxClient.java @@ -156,9 +156,15 @@ public class HxClient { * If null, the default configuration will be used. */ protected HxClient(HttpClient httpClient, HxConfig config) { + this(httpClient, config, null); + } + + protected HxClient(HttpClient httpClient, HxConfig config, HxTokenStore tokenStore) { this.httpClient = httpClient; this.config = config; - this.tokenManager = new HxTokenManager(config); + this.tokenManager = tokenStore != null + ? new HxTokenManager(config, tokenStore) + : new HxTokenManager(config); } /** @@ -275,6 +281,93 @@ public HttpResponse sendAsStream(HttpRequest request) { return sendWithRetry(request, HttpResponse.BodyHandlers.ofInputStream()); } + // ======================================================================== + // Multi-user authentication methods + // ======================================================================== + + /** + * Sends an HTTP request synchronously with automatic retry logic using the specified authentication. + * + *

This method allows per-request authentication for multi-user scenarios. The token + * is retrieved from the token store (and may have been refreshed since initial creation). + * If a 401 response is received, the token will be refreshed and the request retried. + * + * @param the response body type + * @param request the HTTP request to send + * @param auth the authentication data for this request + * @param responseBodyHandler the response body handler + * @return the HTTP response + * @throws IOException if all retry attempts fail + * @throws InterruptedException if the operation is interrupted + */ + public HttpResponse send(HttpRequest request, HxAuth auth, HttpResponse.BodyHandler responseBodyHandler) + throws IOException, InterruptedException { + return sendWithRetry(request, auth, responseBodyHandler); + } + + /** + * Sends an HTTP request synchronously as String using the specified authentication. + * + * @param request the HTTP request to send + * @param auth the authentication data for this request + * @return the HTTP response with String body + */ + public HttpResponse sendAsString(HttpRequest request, HxAuth auth) { + return sendWithRetry(request, auth, HttpResponse.BodyHandlers.ofString()); + } + + /** + * Sends an HTTP request synchronously as byte array using the specified authentication. + * + * @param request the HTTP request to send + * @param auth the authentication data for this request + * @return the HTTP response with byte array body + */ + public HttpResponse sendAsBytes(HttpRequest request, HxAuth auth) { + return sendWithRetry(request, auth, HttpResponse.BodyHandlers.ofByteArray()); + } + + /** + * Sends an HTTP request synchronously as InputStream using the specified authentication. + * + * @param request the HTTP request to send + * @param auth the authentication data for this request + * @return the HTTP response with InputStream body + */ + public HttpResponse sendAsStream(HttpRequest request, HxAuth auth) { + return sendWithRetry(request, auth, HttpResponse.BodyHandlers.ofInputStream()); + } + + /** + * Sends an HTTP request asynchronously using the specified authentication. + * + * @param the response body type + * @param request the HTTP request to send + * @param auth the authentication data for this request + * @param responseBodyHandler the response body handler + * @return a CompletableFuture that will complete with the HTTP response + */ + public CompletableFuture> sendAsync(HttpRequest request, HxAuth auth, + HttpResponse.BodyHandler responseBodyHandler) { + Executor executor = httpClient.executor().orElse(java.util.concurrent.ForkJoinPool.commonPool()); + return sendWithRetryAsync(request, auth, responseBodyHandler, executor); + } + + /** + * Sends an HTTP request asynchronously using the specified authentication and executor. + * + * @param the response body type + * @param request the HTTP request to send + * @param auth the authentication data for this request + * @param responseBodyHandler the response body handler + * @param executor the executor to use for async operations + * @return a CompletableFuture that will complete with the HTTP response + */ + public CompletableFuture> sendAsync(HttpRequest request, HxAuth auth, + HttpResponse.BodyHandler responseBodyHandler, Executor executor) { + return sendWithRetryAsync(request, auth, responseBodyHandler, executor); + } + /** * Sends an HTTP request asynchronously with automatic retry logic and JWT token refresh. * @@ -322,7 +415,39 @@ public CompletableFuture> sendAsync(HttpRequest request, /** * Internal method that implements the retry logic for synchronous requests. - * + * + *

Delegates to {@link #sendWithRetry(HttpRequest, HxAuth, HttpResponse.BodyHandler)} with default auth. + * + * @param the response body type + * @param request the HTTP request to send + * @param responseBodyHandler the response body handler + * @return the HTTP response after successful execution or retry exhaustion + */ + protected HttpResponse sendWithRetry(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + return sendWithRetry(request, tokenManager.getDefaultAuth(), responseBodyHandler); + } + + /** + * Internal method that implements the retry logic for asynchronous requests. + * + *

Delegates to {@link #sendWithRetryAsync(HttpRequest, HxAuth, HttpResponse.BodyHandler, Executor)} with default auth. + * + * @param the response body type + * @param request the HTTP request to send + * @param responseBodyHandler the response body handler + * @param executor optional executor for async operations, may be null + * @return a CompletableFuture that completes with the HTTP response + */ + protected CompletableFuture> sendWithRetryAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + Executor executor) { + return sendWithRetryAsync(request, tokenManager.getDefaultAuth(), responseBodyHandler, executor); + } + + /** + * Internal method that implements the retry logic for synchronous requests. + * *

This method handles the core retry functionality including: *

    *
  • Adding authentication headers
  • @@ -330,51 +455,62 @@ public CompletableFuture> sendAsync(HttpRequest request, *
  • Attempting token refresh on 401 responses
  • *
  • Re-executing requests with refreshed tokens
  • *
- * + * + *

If auth is null, uses the default token from configuration. Otherwise uses + * the specific {@link HxAuth} for multi-user authentication scenarios. + * * @param the response body type * @param request the HTTP request to send + * @param auth the authentication data for this request, or null to use default token * @param responseBodyHandler the response body handler * @return the HTTP response after successful execution or retry exhaustion - * @throws IOException if all retry attempts fail - * @throws InterruptedException if the operation is interrupted */ - protected HttpResponse sendWithRetry(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + protected HttpResponse sendWithRetry(HttpRequest request, HxAuth auth, HttpResponse.BodyHandler responseBodyHandler) { final boolean[] tokenRefreshed = {false}; - + final Retryable> retry = Retryable.>of(config) .retryCondition(config.getRetryCondition()) .retryIf(this::shouldRetryOnResponse) .onRetry(event -> { - String message = event.getFailure() != null ? event.getFailure().getMessage() + String message = event.getFailure() != null ? event.getFailure().getMessage() : String.valueOf(event.getResult().statusCode()); log.debug("HTTP request retry attempt {}: {}", event.getAttempt(), message); }); return retry.apply(() -> { - final HttpRequest actualRequest = tokenManager.addAuthHeader(request); + final HttpRequest actualRequest = (auth != null) + ? tokenManager.addAuthHeader(request, auth) + : tokenManager.addAuthHeader(request); final HttpResponse response = httpClient.send(actualRequest, responseBodyHandler); - + if (response.statusCode() != 401 || tokenRefreshed[0]) { return response; } - - // Try JWT token refresh first if available - if (tokenManager.canRefreshToken()) { - log.debug("Received 401 status, attempting coordinated token refresh"); + + // Try JWT token refresh + final boolean canRefresh = (auth != null) ? tokenManager.canRefreshToken(auth) : tokenManager.canRefreshToken(); + if (canRefresh) { + final String debugKey = HxAuth.keyOrDefault(auth, "-"); + log.debug("Received 401 status for auth key {}, attempting token refresh", debugKey); try { - if (tokenManager.getOrRefreshTokenAsync().get()) { + final boolean refreshed = (auth != null) + ? tokenManager.getOrRefreshTokenAsync(auth).get() != null + : tokenManager.getOrRefreshTokenAsync().get(); + if (refreshed) { tokenRefreshed[0] = true; - final HttpRequest refreshedRequest = tokenManager.addAuthHeader(request); + final HttpRequest refreshedRequest = (auth != null) + ? tokenManager.addAuthHeader(request, auth) + : tokenManager.addAuthHeader(request); closeResponse(response); return httpClient.send(refreshedRequest, responseBodyHandler); } } catch (Exception e) { - log.warn("Token refresh failed: " + e.getMessage()); + log.warn("Token refresh failed for auth key {}: {}", debugKey, e.getMessage()); } } - - // Try WWW-Authenticate challenge handling if enabled - if (config.isWwwAuthenticateEnabled()) { + + // Try WWW-Authenticate challenge handling if enabled (only for default auth) + if (auth == null && config.isWwwAuthenticateEnabled()) { HttpRequest authenticatedRequest = handleWwwAuthenticate(request, response); if (authenticatedRequest != null) { log.debug("Retrying request with WWW-Authenticate challenge"); @@ -382,14 +518,14 @@ protected HttpResponse sendWithRetry(HttpRequest request, HttpResponse.Bo return httpClient.send(authenticatedRequest, responseBodyHandler); } } - + return response; }); } /** * Internal method that implements the retry logic for asynchronous requests. - * + * *

This method handles the core async retry functionality including: *

    *
  • Adding authentication headers
  • @@ -397,58 +533,70 @@ protected HttpResponse sendWithRetry(HttpRequest request, HttpResponse.Bo *
  • Attempting token refresh on 401 responses
  • *
  • Re-executing requests with refreshed tokens
  • *
- * + * + *

If auth is null, uses the default token from configuration. Otherwise uses + * the specific {@link HxAuth} for multi-user authentication scenarios. + * * @param the response body type * @param request the HTTP request to send + * @param auth the authentication data for this request, or null to use default token * @param responseBodyHandler the response body handler * @param executor optional executor for async operations, may be null * @return a CompletableFuture that completes with the HTTP response */ protected CompletableFuture> sendWithRetryAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, + HttpRequest request, + HxAuth auth, + HttpResponse.BodyHandler responseBodyHandler, Executor executor) { - + final boolean[] tokenRefreshed = {false}; - + final Retryable> retry = Retryable.>of(config) .retryCondition(config.getRetryCondition()) .retryIf(this::shouldRetryOnResponse) .onRetry(event -> { - String message = event.getFailure() != null ? event.getFailure().getMessage() + String message = event.getFailure() != null ? event.getFailure().getMessage() : String.valueOf(event.getResult().statusCode()); log.debug("HTTP async request retry attempt {}: {}", event.getAttempt(), message); }); return retry.applyAsync(() -> { - // Perform the HTTP request and coordinated JWT refresh within the retry - final HttpRequest actualRequest = tokenManager.addAuthHeader(request); - + final HttpRequest actualRequest = (auth != null) + ? tokenManager.addAuthHeader(request, auth) + : tokenManager.addAuthHeader(request); + try { final HttpResponse response = httpClient.sendAsync(actualRequest, responseBodyHandler).get(); - + if (response.statusCode() != 401 || tokenRefreshed[0]) { return response; } - - // Try JWT token refresh first if available - if (tokenManager.canRefreshToken()) { - log.debug("Received 401 status in async call, attempting coordinated token refresh"); + + // Try JWT token refresh + final boolean canRefresh = (auth != null) ? tokenManager.canRefreshToken(auth) : tokenManager.canRefreshToken(); + if (canRefresh) { + final String debugKey = HxAuth.keyOrDefault(auth, "-"); + log.debug("Received 401 status in async call for auth key {}, attempting token refresh", debugKey); try { - final boolean refreshed = tokenManager.getOrRefreshTokenAsync().get(); + final boolean refreshed = (auth != null) + ? tokenManager.getOrRefreshTokenAsync(auth).get() != null + : tokenManager.getOrRefreshTokenAsync().get(); if (refreshed) { tokenRefreshed[0] = true; - final HttpRequest refreshedRequest = tokenManager.addAuthHeader(request); + final HttpRequest refreshedRequest = (auth != null) + ? tokenManager.addAuthHeader(request, auth) + : tokenManager.addAuthHeader(request); closeResponse(response); return httpClient.sendAsync(refreshedRequest, responseBodyHandler).get(); } } catch (Exception e) { - log.warn("Async token refresh failed: " + e.getMessage()); + log.warn("Async token refresh failed for auth key {}: {}", debugKey, e.getMessage()); } } - - // Try WWW-Authenticate challenge handling if enabled - if (config.isWwwAuthenticateEnabled()) { + + // Try WWW-Authenticate challenge handling if enabled (only for default auth) + if (auth == null && config.isWwwAuthenticateEnabled()) { HttpRequest authenticatedRequest = handleWwwAuthenticate(request, response); if (authenticatedRequest != null) { log.debug("Retrying async request with WWW-Authenticate credentials"); @@ -456,10 +604,9 @@ protected CompletableFuture> sendWithRetryAsync( return httpClient.sendAsync(authenticatedRequest, responseBodyHandler).get(); } } - + return response; } catch (java.util.concurrent.ExecutionException e) { - // Unwrap ExecutionException to get the original cause for retry mechanism if (e.getCause() instanceof IOException) { throw (IOException) e.getCause(); } else if (e.getCause() instanceof InterruptedException) { @@ -859,7 +1006,8 @@ public static class Builder { private HttpClient.Builder httpClientBuilder; private HttpClient httpClient; private HxConfig.Builder configBuilder; - + private HxTokenStore tokenStore; + /** * Creates a new Builder with default settings. */ @@ -1145,7 +1293,33 @@ public Builder refreshCookiePolicy(CookiePolicy policy) { this.configBuilder.refreshCookiePolicy(policy); return this; } - + + /** + * Sets the token store for multi-session authentication. + * + *

The token store manages JWT token pairs for multiple authentication sessions. + * By default, an in-memory {@code ConcurrentHashMap}-based store is used. For distributed + * deployments, provide a custom implementation backed by Redis, a database, or other + * distributed cache. + * + *

Usage Example: + *

{@code
+         * HxTokenStore redisStore = new RedisTokenStore();
+         * HxClient client = HxClient.newBuilder()
+         *     .tokenStore(redisStore)
+         *     .refreshTokenUrl("https://api.example.com/oauth/token")
+         *     .build();
+         * }
+ * + * @param tokenStore the token store implementation + * @return this Builder instance + * @see HxTokenStore + */ + public Builder tokenStore(HxTokenStore tokenStore) { + this.tokenStore = tokenStore; + return this; + } + /** * Builds and returns a new HxClient instance. * @@ -1155,11 +1329,11 @@ public Builder refreshCookiePolicy(CookiePolicy policy) { * @return a new HxClient instance */ public HxClient build() { - final HttpClient actualHttpClient = (httpClient != null) - ? httpClient + final HttpClient actualHttpClient = (httpClient != null) + ? httpClient : httpClientBuilder.build(); final HxConfig actualConfig = configBuilder.build(); - return new HxClient(actualHttpClient, actualConfig); + return new HxClient(actualHttpClient, actualConfig, tokenStore); } } } diff --git a/lib-httpx/src/main/java/io/seqera/http/HxMapTokenStore.java b/lib-httpx/src/main/java/io/seqera/http/HxMapTokenStore.java new file mode 100644 index 0000000..099490f --- /dev/null +++ b/lib-httpx/src/main/java/io/seqera/http/HxMapTokenStore.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.http; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Default in-memory implementation of {@link HxTokenStore} using a {@link ConcurrentHashMap}. + * + *

This implementation is thread-safe and suitable for single-instance deployments. + * For distributed deployments, consider using a custom implementation backed by + * Redis, a database, or another distributed cache. + * + * @author Paolo Di Tommaso + */ +class HxMapTokenStore implements HxTokenStore { + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public HxAuth get(String key) { + return store.get(key); + } + + @Override + public void put(String key, HxAuth auth) { + store.put(key, auth); + } + + @Override + public HxAuth remove(String key) { + return store.remove(key); + } +} diff --git a/lib-httpx/src/main/java/io/seqera/http/HxTokenManager.java b/lib-httpx/src/main/java/io/seqera/http/HxTokenManager.java index 2932285..f71e3f9 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxTokenManager.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxTokenManager.java @@ -27,9 +27,11 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.regex.Pattern; + import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.slf4j.Logger; @@ -91,35 +93,42 @@ * @see HxConfig * @see HxClient */ -public class HxTokenManager { +class HxTokenManager { private static final Logger log = LoggerFactory.getLogger(HxTokenManager.class); - + private static final Pattern JWT_PATTERN = Pattern.compile("^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$"); private static final String BEARER_PREFIX = "Bearer "; + private static final String DEFAULT_TOKEN = "default-token"; private final HxConfig config; private final HttpClient refreshHttpClient; private final CookieManager cookieManager; - private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final HxTokenStore tokenStore; - private volatile String currentJwtToken; - - private volatile String currentRefreshToken; - - // Coordination for concurrent token refresh operations - private volatile CompletableFuture currentRefresh = null; + // Coordination for concurrent token refresh operations per key + private final ConcurrentMap> ongoingRefreshes = new ConcurrentHashMap<>(); public HxTokenManager(HxConfig config) { + this(config, new HxMapTokenStore()); + } + + public HxTokenManager(HxConfig config, HxTokenStore tokenStore) { this.config = config; - this.currentJwtToken = config.getJwtToken(); - this.currentRefreshToken = config.getRefreshToken(); - + this.tokenStore = tokenStore; + + // Store initial tokens from config using DEFAULT_KEY + final String jwtToken = config.getJwtToken(); + final String refreshToken = config.getRefreshToken(); + if (jwtToken != null && !jwtToken.isEmpty()) { + tokenStore.put(DEFAULT_TOKEN, HxAuth.of(jwtToken, refreshToken)); + } + // Validate JWT token refresh configuration validateTokenRefreshConfig(); - + // Create CookieManager with custom policy if provided - this.cookieManager = (config.getRefreshCookiePolicy() != null) + this.cookieManager = (config.getRefreshCookiePolicy() != null) ? new CookieManager(null, config.getRefreshCookiePolicy()) : new CookieManager(); this.refreshHttpClient = HttpClient.newBuilder() @@ -145,8 +154,10 @@ public HxTokenManager(HxConfig config) { * @throws IllegalArgumentException if the JWT configuration is incomplete or inconsistent */ private void validateTokenRefreshConfig() { - final boolean hasJwtToken = currentJwtToken != null && !currentJwtToken.trim().isEmpty(); - final boolean hasRefreshToken = currentRefreshToken != null && !currentRefreshToken.trim().isEmpty(); + final String jwtToken = getCurrentJwtToken(); + final String refreshToken = getCurrentRefreshToken(); + final boolean hasJwtToken = jwtToken != null && !jwtToken.trim().isEmpty(); + final boolean hasRefreshToken = refreshToken != null && !refreshToken.trim().isEmpty(); final boolean hasRefreshUrl = config.getRefreshTokenUrl() != null && !config.getRefreshTokenUrl().trim().isEmpty(); // Count how many refresh components we have @@ -204,34 +215,26 @@ private void validateTokenRefreshConfig() { */ public HttpRequest addAuthHeader(HttpRequest originalRequest) { // Priority 1: JWT Bearer token - final String jwtToken = getCurrentJwtToken(); - if (jwtToken != null && !jwtToken.isEmpty()) { - final String headerValue = jwtToken.startsWith(BEARER_PREFIX) ? jwtToken : BEARER_PREFIX + jwtToken; - return HttpRequest.newBuilder(originalRequest, (name, value) -> true) - .header("Authorization", headerValue) - .build(); - } + HttpRequest result = addAuthHeader(originalRequest, getDefaultAuth()); - // Priority 2: Basic authentication - if (config.getBasicAuthToken() != null && !config.getBasicAuthToken().isEmpty()) { + // Priority 2: Basic authentication fallback (if no JWT auth was applied) + if (result == originalRequest && hasBasicAuth()) { final String encodedCredentials = Base64.getEncoder().encodeToString(config.getBasicAuthToken().getBytes()); - final String headerValue = "Basic " + encodedCredentials; return HttpRequest.newBuilder(originalRequest, (name, value) -> true) - .header("Authorization", headerValue) + .header("Authorization", "Basic " + encodedCredentials) .build(); } - // No authentication configured - return originalRequest; + return result; } /** * Checks whether token refresh is possible with the current configuration. - * + * * @return true if both refresh token and refresh URL are configured, false otherwise */ public boolean canRefreshToken() { - return currentRefreshToken != null && config.getRefreshTokenUrl() != null; + return canRefreshToken(getDefaultAuth()); } /** @@ -251,22 +254,23 @@ public boolean canRefreshToken() { * @return true if token refresh was successful, false otherwise */ public boolean refreshToken() { - if (!canRefreshToken()) { - log.warn("Cannot refresh token: refreshToken={} refreshTokenUrl={}", (currentRefreshToken != null), config.getRefreshTokenUrl()); - return false; - } - - lock.writeLock().lock(); - try { - return doRefreshToken(); - } finally { - lock.writeLock().unlock(); - } + return refreshToken(getDefaultAuth()) != null; } /** * Gets or initiates a coordinated token refresh operation to prevent concurrent refreshes. - * + * + *

This method delegates to {@link #getOrRefreshTokenAsync(HxAuth)} using the default token. + * + * @return a CompletableFuture that completes with true if refresh was successful, false otherwise + */ + public CompletableFuture getOrRefreshTokenAsync() { + return getOrRefreshTokenAsync(getDefaultAuth()).thenApply(result -> result != null); + } + + /** + * Gets or initiates a coordinated token refresh operation for the given auth. + * *

This method ensures that multiple concurrent requests that need token refresh will * share a single refresh operation instead of each performing their own refresh. This: *

    @@ -275,201 +279,57 @@ public boolean refreshToken() { *
  • Improves performance under high concurrency
  • *
  • Avoids hitting rate limits on token refresh endpoints
  • *
- * - *

The coordination mechanism: - *

    - *
  • First caller starts the refresh and gets a CompletableFuture
  • - *
  • Subsequent callers get the same CompletableFuture to wait on
  • - *
  • Once refresh completes, all waiters are notified with the same result
  • - *
  • Future state is reset after completion for next refresh cycle
  • - *
- * - * @return a CompletableFuture that completes with true if refresh was successful, false otherwise + * + * @param auth the authentication data to refresh + * @return a CompletableFuture that completes with the refreshed HxAuth, or null if refresh failed */ - public CompletableFuture getOrRefreshTokenAsync() { - if (!canRefreshToken()) { - log.warn("Cannot refresh token: refreshToken={} refreshTokenUrl={}", (currentRefreshToken != null), config.getRefreshTokenUrl()); - return CompletableFuture.completedFuture(false); + public CompletableFuture getOrRefreshTokenAsync(HxAuth auth) { + if (!canRefreshToken(auth)) { + log.warn("Cannot refresh token: refreshToken={} refreshTokenUrl={}", + (auth != null && auth.refreshToken() != null), config.getRefreshTokenUrl()); + return CompletableFuture.completedFuture(null); } - // Check if there's already a refresh in progress - CompletableFuture refresh = currentRefresh; - if (refresh != null && !refresh.isDone()) { - log.trace("Token refresh already in progress, waiting for completion"); - return refresh; - } - - // Use double-checked locking to ensure only one thread starts the refresh - synchronized(this) { - refresh = currentRefresh; - if (refresh != null && !refresh.isDone()) { - log.trace("Token refresh already in progress (double-check), waiting for completion"); - return refresh; - } - - // Start new refresh operation - log.trace("Starting coordinated token refresh"); - currentRefresh = CompletableFuture.supplyAsync(() -> { - lock.writeLock().lock(); - try { - return doRefreshToken(); - } finally { - lock.writeLock().unlock(); - } - }).whenComplete((result, throwable) -> { - // Reset the currentRefresh after completion to allow future refreshes - synchronized(this) { - if (currentRefresh != null && currentRefresh.isDone()) { - currentRefresh = null; - } - } - - if (throwable != null) { - log.error("Coordinated token refresh failed: " + throwable.getMessage(), throwable); - } else { - log.trace("Coordinated token refresh completed with result: " + result); - } - }); - - return currentRefresh; - } + final String key = HxAuth.key(auth); + + // Use computeIfAbsent for atomic coordination - only first caller creates the future + return ongoingRefreshes.computeIfAbsent(key, k -> { + log.trace("Starting coordinated token refresh for key {}", k); + return CompletableFuture.supplyAsync(() -> doRefreshToken(auth)) + .whenComplete((result, throwable) -> { + // Remove from map after completion to allow future refreshes + ongoingRefreshes.remove(key); + if (throwable != null) { + log.error("Coordinated token refresh failed for key {}: {}", key, throwable.getMessage(), throwable); + } else { + log.trace("Coordinated token refresh completed for key {} with result: {}", key, (result != null)); + } + }); + }); } /** * Attempts to refresh the JWT token asynchronously using the configured refresh token. - * + * *

This method performs the same OAuth 2.0 refresh token flow as {@link #refreshToken()} * but returns a CompletableFuture for non-blocking execution. - * - *

This method is thread-safe and uses write locks to prevent concurrent modifications. - * + * * @return a CompletableFuture that completes with true if refresh was successful, false otherwise */ public CompletableFuture refreshTokenAsync() { - if (!canRefreshToken()) { - log.warn("Cannot refresh token asynchronously: refreshToken={} refreshTokenUrl={}", (currentRefreshToken != null), config.getRefreshTokenUrl()); - return CompletableFuture.completedFuture(false); - } - - return CompletableFuture.supplyAsync(() -> { - lock.writeLock().lock(); - try { - return doRefreshToken(); - } finally { - lock.writeLock().unlock(); - } - }); + return refreshTokenAsync(getDefaultAuth()).thenApply(result -> result != null); } /** * Internal method that performs the actual token refresh HTTP request. - * - *

This method: - *

    - *
  • Creates a POST request with form-urlencoded body
  • - *
  • Includes grant_type=refresh_token and the URL-encoded refresh token
  • - *
  • Sends the request with the configured timeout
  • - *
  • Handles both cookie-based and JSON response formats
  • - *
  • Updates internal token state on success
  • - *
- * - *

This method is called by both synchronous and asynchronous refresh methods - * and assumes the caller has acquired the appropriate write lock. - * + * + *

This method delegates to {@link #doRefreshTokenInternal(String, HxAuth)} using the default token. + * * @return true if the token refresh was successful, false otherwise */ protected boolean doRefreshToken() { - try { - final var refreshUrl = URI.create(config.getRefreshTokenUrl()); - log.trace("Attempting to refresh JWT token using refresh token at URL: {}", refreshUrl); - - final String body = "grant_type=refresh_token&refresh_token=" + - URLEncoder.encode(currentRefreshToken, StandardCharsets.UTF_8); - - final HttpRequest request = HttpRequest.newBuilder() - .uri(refreshUrl) - .header("Content-Type", "application/x-www-form-urlencoded") - .POST(HttpRequest.BodyPublishers.ofString(body)) - .timeout(config.getTokenRefreshTimeout()) - .build(); - - final HttpResponse response = refreshHttpClient.send(request, HttpResponse.BodyHandlers.ofString()); - log.trace("Token refresh response: [{}]", response.statusCode()); - - if (response.statusCode() == 200) { - final var result = handleRefreshResponse(response); - log.debug("JWT token refresh completed with result: {}", result); - return result; - } else { - log.warn("Token refresh failed with status {}: {}", response.statusCode(), response.body()); - return false; - } - } catch (Exception e) { - log.error("Error refreshing JWT token: " + e.getMessage(), e); - return false; - } - } - - /** - * Processes the HTTP response from a token refresh request and extracts new tokens. - * - *

This method supports multiple response formats: - *

    - *
  • Cookie-based: Extracts JWT and JWT_REFRESH_TOKEN from cookie store
  • - *
  • JSON-based: Parses JSON response body for access_token and refresh_token fields
  • - *
- * - *

The method validates extracted JWT tokens using {@link #isValidJwtToken(String)} - * and updates the internal token state atomically. - * - * @param response the HTTP response from the token refresh request - * @return true if tokens were successfully extracted and updated, false otherwise - */ - protected boolean handleRefreshResponse(HttpResponse response) { - try { - // First try to extract tokens from cookie store (same approach as TowerXAuth) - final HttpCookie authCookie = getCookie("JWT"); - final HttpCookie refreshCookie = getCookie("JWT_REFRESH_TOKEN"); - - if (authCookie != null && authCookie.getValue() != null) { - log.trace("Successfully refreshed JWT token"); - currentJwtToken = authCookie.getValue(); - - if (refreshCookie != null && refreshCookie.getValue() != null) { - log.trace("Successfully refreshed refresh token"); - currentRefreshToken = refreshCookie.getValue(); - } - - return true; - } else { - log.trace("No JWT token found in cookies, trying JSON response"); - - try { - final JsonObject jsonResponse = JsonParser.parseString(response.body()).getAsJsonObject(); - if (jsonResponse.has("access_token")) { - final String accessToken = jsonResponse.get("access_token").getAsString(); - if (isValidJwtToken(accessToken)) { - log.trace("Successfully extracted JWT token from JSON"); - currentJwtToken = accessToken; - - if (jsonResponse.has("refresh_token")) { - currentRefreshToken = jsonResponse.get("refresh_token").getAsString(); - log.trace("Successfully extracted refresh token from JSON"); - } - - return true; - } - } - } catch (Exception e) { - log.trace("Response is not valid JSON, trying to parse as form data"); - } - - return false; - } - } catch (Exception e) { - log.error("Error processing token refresh response: " + e.getMessage(), e); - return false; - } + final HxAuth auth = getDefaultAuth(); + return auth != null && doRefreshToken(auth) != null; } /** @@ -512,31 +372,23 @@ protected boolean isValidJwtToken(String token) { } /** - * Gets the current JWT token in a thread-safe manner. - * + * Gets the current JWT token. + * * @return the current JWT token, or null if not set */ public String getCurrentJwtToken() { - lock.readLock().lock(); - try { - return currentJwtToken; - } finally { - lock.readLock().unlock(); - } + final HxAuth auth = tokenStore.get(DEFAULT_TOKEN); + return auth != null ? auth.accessToken() : null; } /** - * Gets the current refresh token in a thread-safe manner. - * + * Gets the current refresh token. + * * @return the current refresh token, or null if not set */ public String getCurrentRefreshToken() { - lock.readLock().lock(); - try { - return currentRefreshToken; - } finally { - lock.readLock().unlock(); - } + final HxAuth auth = tokenStore.get(DEFAULT_TOKEN); + return auth != null ? auth.refreshToken() : null; } /** @@ -568,28 +420,252 @@ public boolean hasBasicAuth() { } /** - * Updates both JWT and refresh tokens atomically in a thread-safe manner. - * + * Updates both JWT and refresh tokens atomically. + * *

This method validates the JWT token format before updating and will not * update an invalid JWT token. The refresh token is updated if provided, * regardless of format (as refresh tokens may have different formats). - * + * * @param jwtToken the new JWT token to set, may be null * @param refreshToken the new refresh token to set, may be null */ public void updateTokens(String jwtToken, String refreshToken) { - lock.writeLock().lock(); + final HxAuth currentAuth = tokenStore.get(DEFAULT_TOKEN); + String newToken = (currentAuth != null) ? currentAuth.accessToken() : null; + String newRefresh = (currentAuth != null) ? currentAuth.refreshToken() : null; + + if (jwtToken != null && isValidJwtToken(jwtToken)) { + newToken = jwtToken; + log.trace("JWT token updated"); + } + if (refreshToken != null) { + newRefresh = refreshToken; + log.trace("Refresh token updated"); + } + + if (newToken != null) { + tokenStore.put(DEFAULT_TOKEN, HxAuth.of(newToken, newRefresh)); + } + } + + // ======================================================================== + // Multi-user token management methods + // ======================================================================== + + /** + * Gets the current authentication data for the given auth from the token store. + * + *

If the auth has been refreshed, this returns the updated tokens. If not found + * in the store, the original auth is stored and returned for future use. + * + * @param auth the original authentication data + * @return the current authentication data (may contain refreshed tokens) + */ + public HxAuth getAuth(HxAuth auth) { + if (auth == null) { + return null; + } + final String key = HxAuth.key(auth); + HxAuth stored = tokenStore.get(key); + if (stored == null) { + tokenStore.put(key, auth); + return auth; + } + return stored; + } + + /** + * Adds an Authorization header to the given HTTP request using the token from the provided {@link HxAuth}. + * + *

This method retrieves the latest token for the given auth from the token store (in case it was + * refreshed) and adds the Bearer Authorization header to the request. + * + * @param originalRequest the original HTTP request + * @param auth the authentication data containing the token + * @return a new HttpRequest with Authorization header + */ + public HttpRequest addAuthHeader(HttpRequest originalRequest, HxAuth auth) { + final HxAuth currentAuth = getAuth(auth); + final String jwtToken = currentAuth != null ? currentAuth.accessToken() : null; + + if (jwtToken != null && !jwtToken.isEmpty()) { + final String headerValue = jwtToken.startsWith(BEARER_PREFIX) ? jwtToken : BEARER_PREFIX + jwtToken; + return HttpRequest.newBuilder(originalRequest, (name, value) -> true) + .header("Authorization", headerValue) + .build(); + } + + return originalRequest; + } + + /** + * Checks whether token refresh is possible for the given auth. + * + * @param auth the authentication data + * @return true if both refresh token and refresh URL are configured, false otherwise + */ + public boolean canRefreshToken(HxAuth auth) { + if (auth == null) { + return false; + } + final HxAuth currentAuth = getAuth(auth); + return currentAuth.refreshToken() != null && config.getRefreshTokenUrl() != null; + } + + /** + * Attempts to refresh the JWT token synchronously for the given auth. + * + *

This method performs an OAuth 2.0 refresh token flow for a specific user session. + * On success, the new tokens are stored in the token store automatically. + * + * @param auth the authentication data containing the refresh token + * @return the updated {@link HxAuth} with new tokens, or null if refresh failed + */ + public HxAuth refreshToken(HxAuth auth) { + if (!canRefreshToken(auth)) { + log.warn("Cannot refresh token for auth: refreshToken={} refreshTokenUrl={}", + (auth != null && auth.refreshToken() != null), config.getRefreshTokenUrl()); + return null; + } + return doRefreshToken(auth); + } + + /** + * Attempts to refresh the JWT token asynchronously for the given auth. + * + * @param auth the authentication data containing the refresh token + * @return a CompletableFuture that completes with the updated {@link HxAuth}, or null if refresh failed + */ + public CompletableFuture refreshTokenAsync(HxAuth auth) { + if (!canRefreshToken(auth)) { + log.warn("Cannot refresh token asynchronously for auth: refreshToken={} refreshTokenUrl={}", + (auth != null && auth.refreshToken() != null), config.getRefreshTokenUrl()); + return CompletableFuture.completedFuture(null); + } + return CompletableFuture.supplyAsync(() -> doRefreshToken(auth)); + } + + /** + * Performs the actual token refresh for a specific auth. + * + *

This method delegates to {@link #doRefreshTokenInternal(String, HxAuth)} using the auth's key. + * + * @param auth the authentication data containing the refresh token + * @return the updated {@link HxAuth} with new tokens, or null if refresh failed + */ + protected HxAuth doRefreshToken(HxAuth auth) { + final HxAuth currentAuth = getAuth(auth); + return doRefreshTokenInternal(HxAuth.key(auth), currentAuth); + } + + /** + * Internal method that performs the actual token refresh HTTP request. + * + * @param key the key to use for storing the refreshed token + * @param auth the authentication data containing the refresh token + * @return the updated {@link HxAuth} with new tokens, or null if refresh failed + */ + protected HxAuth doRefreshTokenInternal(String key, HxAuth auth) { try { - if (jwtToken != null && isValidJwtToken(jwtToken)) { - this.currentJwtToken = jwtToken; - log.trace("JWT token updated"); + final var refreshUrl = URI.create(config.getRefreshTokenUrl()); + log.trace("Attempting to refresh JWT token for key {} at URL: {}", key, refreshUrl); + + final String body = "grant_type=refresh_token&refresh_token=" + + URLEncoder.encode(auth.refreshToken(), StandardCharsets.UTF_8); + + final HttpRequest request = HttpRequest.newBuilder() + .uri(refreshUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .timeout(config.getTokenRefreshTimeout()) + .build(); + + final HttpResponse response = refreshHttpClient.send(request, HttpResponse.BodyHandlers.ofString()); + log.trace("Token refresh response for key {}: [{}]", key, response.statusCode()); + + if (response.statusCode() == 200) { + final HxAuth newAuth = extractAuthFromResponse(response, auth); + if (newAuth != null) { + log.debug("JWT token refresh completed for key {}", key); + tokenStore.put(key, newAuth); + return newAuth; + } + } else { + log.warn("Token refresh failed for key {} with status {}: {}", + key, response.statusCode(), response.body()); } - if (refreshToken != null) { - this.currentRefreshToken = refreshToken; - log.trace("Refresh token updated"); + return null; + } catch (Exception e) { + log.error("Error refreshing JWT token for key {}: {}", key, e.getMessage(), e); + return null; + } + } + + /** + * Extracts tokens from the HTTP response and creates a new {@link HxAuth}. + * + * @param response the HTTP response from the token refresh request + * @param originalAuth the original authentication data (used to preserve refresh token if not returned) + * @return a new {@link HxAuth} with updated tokens, or null if extraction failed + */ + protected HxAuth extractAuthFromResponse(HttpResponse response, HxAuth originalAuth) { + try { + // First try to extract tokens from cookie store + final HttpCookie authCookie = getCookie("JWT"); + final HttpCookie refreshCookie = getCookie("JWT_REFRESH_TOKEN"); + + if (authCookie != null && authCookie.getValue() != null) { + log.trace("Successfully extracted JWT token from cookies"); + final String newToken = authCookie.getValue(); + final String newRefresh = (refreshCookie != null && refreshCookie.getValue() != null) + ? refreshCookie.getValue() + : originalAuth.refreshToken(); + return HxAuth.of(newToken, newRefresh); + } else { + log.trace("No JWT token found in cookies, trying JSON response"); + + try { + final JsonObject jsonResponse = JsonParser.parseString(response.body()).getAsJsonObject(); + if (jsonResponse.has("access_token")) { + final String accessToken = jsonResponse.get("access_token").getAsString(); + if (isValidJwtToken(accessToken)) { + log.trace("Successfully extracted JWT token from JSON"); + final String newRefresh = jsonResponse.has("refresh_token") + ? jsonResponse.get("refresh_token").getAsString() + : originalAuth.refreshToken(); + return HxAuth.of(accessToken, newRefresh); + } + } + } catch (Exception e) { + log.trace("Response is not valid JSON"); + } + + return null; } - } finally { - lock.writeLock().unlock(); + } catch (Exception e) { + log.error("Error extracting tokens from response: {}", e.getMessage(), e); + return null; } } + + /** + * Returns the token store used by this manager. + * + * @return the {@link HxTokenStore} instance + */ + public HxTokenStore getTokenStore() { + return tokenStore; + } + + /** + * Returns the default authentication data configured from HxConfig. + * + *

This is the auth stored under the default key, initialized from + * the JWT token and refresh token configured in HxConfig. + * + * @return the default {@link HxAuth}, or null if no default token is configured + */ + public HxAuth getDefaultAuth() { + return tokenStore.get(DEFAULT_TOKEN); + } } diff --git a/lib-httpx/src/main/java/io/seqera/http/HxTokenStore.java b/lib-httpx/src/main/java/io/seqera/http/HxTokenStore.java new file mode 100644 index 0000000..437cf77 --- /dev/null +++ b/lib-httpx/src/main/java/io/seqera/http/HxTokenStore.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.http; + +/** + * Interface for storing and retrieving JWT token pairs by key. + * + *

This interface allows for different storage strategies for managing + * multiple user sessions, each with their own token-refresh pair. The key + * is typically derived from the token itself (e.g., SHA-256 hash). + * + *

Implementations should be thread-safe as they may be accessed + * concurrently by multiple requests. + * + * @author Paolo Di Tommaso + */ +public interface HxTokenStore { + + /** + * Retrieves the authentication data for the given key. + * + * @param key the unique key identifying the token pair (typically SHA-256 of the token) + * @return the {@link HxAuth} containing the token pair, or null if not found + */ + HxAuth get(String key); + + /** + * Stores the authentication data for the given key. + * + * @param key the unique key identifying the token pair (typically SHA-256 of the token) + * @param auth the {@link HxAuth} containing the token pair to store + */ + void put(String key, HxAuth auth); + + /** + * Removes the authentication data for the given key. + * + * @param key the unique key identifying the token pair to remove + * @return the removed {@link HxAuth}, or null if not found + */ + HxAuth remove(String key); +} diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxAuthTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxAuthTest.groovy new file mode 100644 index 0000000..ebe1d3d --- /dev/null +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxAuthTest.groovy @@ -0,0 +1,78 @@ +/* + * Copyright 2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.http + +import spock.lang.Specification + +/** + * Unit tests for HxAuth + * + * @author Paolo Di Tommaso + */ +class HxAuthTest extends Specification { + + def 'should create auth with token only'() { + when: + def auth = HxAuth.of('my.jwt.token') + + then: + auth.accessToken() == 'my.jwt.token' + auth.refreshToken() == null + } + + def 'should create auth with token and refresh'() { + when: + def auth = HxAuth.of('my.jwt.token', 'my-refresh-token') + + then: + auth.accessToken() == 'my.jwt.token' + auth.refreshToken() == 'my-refresh-token' + } + + def 'should compute consistent key from token'() { + given: + def auth1 = HxAuth.of('my.jwt.token', 'refresh1') + def auth2 = HxAuth.of('my.jwt.token', 'refresh2') + def auth3 = HxAuth.of('different.jwt.token', 'refresh1') + + expect: + HxAuth.key(auth1) == HxAuth.key(auth2) // same token = same key + HxAuth.key(auth1) != HxAuth.key(auth3) // different token = different key + HxAuth.key(auth1).length() == 64 // SHA-256 hex = 64 chars + } + + def 'should return default key for null auth'() { + expect: + HxAuth.keyOrDefault(null, 'default') == 'default' + HxAuth.keyOrDefault(HxAuth.of('token'), 'default') != 'default' + HxAuth.keyOrDefault(null, 'custom') == 'custom' + } + + def 'should implement equals and hashCode correctly'() { + expect: + HxAuth.of(token1, refresh1) == HxAuth.of(token2, refresh2) == expected + (HxAuth.of(token1, refresh1).hashCode() == HxAuth.of(token2, refresh2).hashCode()) == expected + + where: + token1 | refresh1 | token2 | refresh2 | expected + 'a.b.c' | 'r1' | 'a.b.c' | 'r1' | true + 'a.b.c' | null | 'a.b.c' | null | true + 'a.b.c' | 'r1' | 'a.b.c' | 'r2' | false + 'a.b.c' | 'r1' | 'x.y.z' | 'r1' | false + } +} diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxClientTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxClientTest.groovy index d4ad186..808dd8d 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxClientTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxClientTest.groovy @@ -384,4 +384,17 @@ class HxClientTest extends Specification { .build() client3.config.refreshCookiePolicy == CookiePolicy.ACCEPT_ORIGINAL_SERVER } + + def 'should configure custom token store via builder'() { + given: + def customStore = new HxMapTokenStore() + + when: + def client = HxClient.newBuilder() + .tokenStore(customStore) + .build() + + then: + client.tokenManager.getTokenStore() == customStore + } } diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxMapTokenStoreTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxMapTokenStoreTest.groovy new file mode 100644 index 0000000..ca078c7 --- /dev/null +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxMapTokenStoreTest.groovy @@ -0,0 +1,57 @@ +/* + * Copyright 2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.seqera.http + +import spock.lang.Specification + +/** + * Unit tests for HxMapTokenStore + * + * @author Paolo Di Tommaso + */ +class HxMapTokenStoreTest extends Specification { + + def 'should store and retrieve auth by key'() { + given: + def store = new HxMapTokenStore() + def auth = HxAuth.of('my.jwt.token', 'refresh') + + when: + store.put('key1', auth) + + then: + store.get('key1') == auth + store.get('unknown') == null + } + + def 'should remove auth'() { + given: + def store = new HxMapTokenStore() + def auth = HxAuth.of('my.jwt.token', 'refresh') + store.put('key1', auth) + + when: + def removed = store.remove('key1') + + then: + removed == auth + store.get('key1') == null + store.remove('key1') == null + } + +} diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxTokenManagerTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxTokenManagerTest.groovy index 73b7690..4982f22 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxTokenManagerTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxTokenManagerTest.groovy @@ -280,4 +280,77 @@ class HxTokenManagerTest extends Specification { tokenManager2.cookieManager != tokenManager3.cookieManager tokenManager1.cookieManager != tokenManager3.cookieManager } + + // --- Multi-token support tests --- + + def 'getAuth should store and return auth on first call'() { + given: + def config = HxConfig.newBuilder().build() + def tokenManager = new HxTokenManager(config) + def auth = HxAuth.of('my.jwt.token', 'refresh') + + when: + def result = tokenManager.getAuth(auth) + + then: + result == auth + tokenManager.getAuth(auth) == auth // subsequent calls return same + } + + def 'getAuth should return null for null input'() { + given: + def config = HxConfig.newBuilder().build() + def tokenManager = new HxTokenManager(config) + + expect: + tokenManager.getAuth(null) == null + } + + def 'addAuthHeader should work with HxAuth parameter'() { + given: + def config = HxConfig.newBuilder().build() + def tokenManager = new HxTokenManager(config) + def auth = HxAuth.of('custom.jwt.token', 'refresh') + def request = HttpRequest.newBuilder() + .uri(URI.create('https://example.com/api')) + .GET() + .build() + + when: + def result = tokenManager.addAuthHeader(request, auth) + + then: + result.headers().firstValue('Authorization').orElse(null) == 'Bearer custom.jwt.token' + } + + def 'canRefreshToken should check HxAuth configuration'() { + given: + def configWithUrl = HxConfig.newBuilder() + .bearerToken('default.jwt.token') + .refreshToken('default-refresh') + .refreshTokenUrl('https://example.com/oauth/token') + .build() + def configWithoutUrl = HxConfig.newBuilder().build() + + expect: + new HxTokenManager(configWithUrl).canRefreshToken(HxAuth.of('a.b.c', 'refresh')) == true + new HxTokenManager(configWithUrl).canRefreshToken(HxAuth.of('a.b.c', null)) == false + new HxTokenManager(configWithUrl).canRefreshToken(null) == false + new HxTokenManager(configWithoutUrl).canRefreshToken(HxAuth.of('a.b.c', 'refresh')) == false + } + + def 'should accept custom token store'() { + given: + def customStore = new HxMapTokenStore() + def config = HxConfig.newBuilder().build() + def tokenManager = new HxTokenManager(config, customStore) + def auth = HxAuth.of('my.jwt.token', 'refresh') + + when: + tokenManager.getAuth(auth) + + then: + customStore.get(HxAuth.key(auth)) == auth + tokenManager.getTokenStore() == customStore + } }