From 501caed45f3d038a451b4b79ebb71ac0a17bde36 Mon Sep 17 00:00:00 2001 From: Taha El Amine Kassabi Date: Thu, 10 Sep 2026 15:42:18 +0200 Subject: [PATCH 1/4] feat: configure client-credentials token refresh timing Allow setting the expiry buffer and jitter while preserving existing defaults. --- README.md | 19 +++++++- .../dev/openfga/sdk/api/auth/AccessToken.java | 15 +++---- .../openfga/sdk/api/auth/OAuth2Client.java | 8 +++- .../dev/openfga/sdk/api/client/ApiClient.java | 11 ++++- .../api/configuration/ClientCredentials.java | 36 +++++++++++++++ .../openfga/sdk/api/auth/AccessTokenTest.java | 6 ++- .../openfga/sdk/api/client/ApiClientTest.java | 45 ++++++++++++++++--- .../configuration/ClientCredentialsTest.java | 6 +++ 8 files changed, 125 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 6644c24a..373c8211 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,24 @@ public class Example { } ``` +#### Token refresh timing + +Client-credentials tokens are cached until their expiry minus a buffer and random jitter. +Both settings default to 300 seconds; jitter is sampled from zero up to, but excluding, +the configured value on each validity check. For short-lived tokens, configure a smaller window: + +```java +var credentials = new ClientCredentials() + .clientId(System.getenv("FGA_CLIENT_ID")) + .clientSecret(System.getenv("FGA_CLIENT_SECRET")) + .apiTokenIssuer(System.getenv("FGA_API_TOKEN_ISSUER")) + .tokenExpiryBufferSeconds(30) + .tokenExpiryJitterSeconds(5); +``` + +Values must be non-negative. Set jitter to zero to disable it. Keep the combined window +below the token lifetime to allow cached tokens to be reused. + ### Custom Headers #### Default Headers @@ -1507,4 +1525,3 @@ See [CONTRIBUTING](./CONTRIBUTING.md) for details. This project is licensed under the Apache-2.0 license. See the [LICENSE](https://github.com/openfga/java-sdk/blob/main/LICENSE) file for more info. The code in this repo was auto generated by [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) from a template based on the [Java template](https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator/src/main/resources/Java), licensed under the [Apache License 2.0](https://github.com/OpenAPITools/openapi-generator/blob/master/LICENSE). - diff --git a/src/main/java/dev/openfga/sdk/api/auth/AccessToken.java b/src/main/java/dev/openfga/sdk/api/auth/AccessToken.java index 3732af0a..840f1cd0 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/AccessToken.java +++ b/src/main/java/dev/openfga/sdk/api/auth/AccessToken.java @@ -2,7 +2,6 @@ import static dev.openfga.sdk.util.StringUtil.isNullOrWhitespace; -import dev.openfga.sdk.constants.FgaConstants; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.concurrent.ThreadLocalRandom; @@ -13,17 +12,13 @@ * even if there is some clock skew or delay between retrieval and use. */ record AccessToken(String token, Instant expiresAt) { - private static final int TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; - // We add some jitter so that token refreshes are less likely to collide - private static final int TOKEN_EXPIRY_JITTER_IN_SEC = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; - static final AccessToken EMPTY = new AccessToken(null, null); AccessToken { expiresAt = expiresAt != null ? expiresAt.truncatedTo(ChronoUnit.SECONDS) : null; } - boolean isValid() { + boolean isValid(int bufferSeconds, int jitterSeconds) { if (isNullOrWhitespace(token)) { return false; } @@ -33,11 +28,11 @@ boolean isValid() { return true; } - // A token should be considered valid until 5 minutes before the expiry with some jitter - // to account for multiple calls to `isValid` at the same time and prevent multiple refresh calls + // Refresh before expiry, with optional jitter to spread refreshes across clients. Instant expiresWithLeeway = expiresAt - .minusSeconds(TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC) - .minusSeconds(ThreadLocalRandom.current().nextInt(TOKEN_EXPIRY_JITTER_IN_SEC)) + .minusSeconds(bufferSeconds) + .minusSeconds( + jitterSeconds == 0 ? 0 : ThreadLocalRandom.current().nextInt(jitterSeconds)) .truncatedTo(ChronoUnit.SECONDS); return Instant.now().truncatedTo(ChronoUnit.SECONDS).isBefore(expiresWithLeeway); diff --git a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java index 166f3514..cdf17225 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -21,6 +21,8 @@ public class OAuth2Client { private final CredentialsFlowRequest authRequest; private final Configuration config; private final Telemetry telemetry; + private final int tokenExpiryBufferSeconds; + private final int tokenExpiryJitterSeconds; /** * Initializes a new instance of the {@link OAuth2Client} class @@ -29,6 +31,8 @@ public class OAuth2Client { */ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws FgaInvalidParameterException { var clientCredentials = configuration.getCredentials().getClientCredentials(); + this.tokenExpiryBufferSeconds = clientCredentials.getTokenExpiryBufferSeconds(); + this.tokenExpiryJitterSeconds = clientCredentials.getTokenExpiryJitterSeconds(); this.apiClient = apiClient; this.authRequest = @@ -54,7 +58,7 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga public CompletableFuture getAccessToken() throws FgaInvalidParameterException, ApiException { // Fast path (lock-free): return cached token if still valid. AccessToken current = snapshot.get(); - if (current.isValid()) { + if (current.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) { return CompletableFuture.completedFuture(current.token()); } @@ -62,7 +66,7 @@ public CompletableFuture getAccessToken() throws FgaInvalidParameterExce synchronized (this) { // Double-check: another thread may have refreshed while we waited. AccessToken rechecked = snapshot.get(); - if (rechecked.isValid()) { + if (rechecked.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) { return CompletableFuture.completedFuture(rechecked.token()); } diff --git a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java index d46eaab5..bb0292ee 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -381,6 +381,8 @@ private static final class CredentialsCacheKey { private final String apiTokenIssuer; private final String apiAudience; private final String scopes; + private final int tokenExpiryBufferSeconds; + private final int tokenExpiryJitterSeconds; CredentialsCacheKey(ClientCredentials cc) { this.clientId = cc.getClientId(); @@ -388,6 +390,8 @@ private static final class CredentialsCacheKey { this.apiTokenIssuer = cc.getApiTokenIssuer(); this.apiAudience = cc.getApiAudience(); this.scopes = cc.getScopes(); + this.tokenExpiryBufferSeconds = cc.getTokenExpiryBufferSeconds(); + this.tokenExpiryJitterSeconds = cc.getTokenExpiryJitterSeconds(); } private static byte[] sha256(String value) { @@ -407,12 +411,15 @@ public boolean equals(Object o) { && Arrays.equals(clientSecretHash, that.clientSecretHash) && Objects.equals(apiTokenIssuer, that.apiTokenIssuer) && Objects.equals(apiAudience, that.apiAudience) - && Objects.equals(scopes, that.scopes); + && Objects.equals(scopes, that.scopes) + && tokenExpiryBufferSeconds == that.tokenExpiryBufferSeconds + && tokenExpiryJitterSeconds == that.tokenExpiryJitterSeconds; } @Override public int hashCode() { - int result = Objects.hash(clientId, apiTokenIssuer, apiAudience, scopes); + int result = Objects.hash( + clientId, apiTokenIssuer, apiAudience, scopes, tokenExpiryBufferSeconds, tokenExpiryJitterSeconds); result = 31 * result + Arrays.hashCode(clientSecretHash); return result; } diff --git a/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java index dfcac13a..17ef3f6e 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java @@ -2,6 +2,7 @@ import static dev.openfga.sdk.util.Validation.assertParamExists; +import dev.openfga.sdk.constants.FgaConstants; import dev.openfga.sdk.errors.FgaInvalidParameterException; import dev.openfga.sdk.util.StringUtil; @@ -11,6 +12,8 @@ public class ClientCredentials { private String apiTokenIssuer; private String apiAudience; private String scopes; + private int tokenExpiryBufferSeconds = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; + private int tokenExpiryJitterSeconds = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; public ClientCredentials() {} @@ -64,4 +67,37 @@ public ClientCredentials scopes(String scopes) { public String getScopes() { return this.scopes; } + + /** + * Sets how many seconds before expiry a cached token requires refresh. Defaults to 300. + * @throws IllegalArgumentException if seconds is negative. + */ + public ClientCredentials tokenExpiryBufferSeconds(int seconds) { + if (seconds < 0) { + throw new IllegalArgumentException("tokenExpiryBufferSeconds must be non-negative"); + } + this.tokenExpiryBufferSeconds = seconds; + return this; + } + + public int getTokenExpiryBufferSeconds() { + return this.tokenExpiryBufferSeconds; + } + + /** + * Sets the exclusive upper bound of additional random seconds subtracted on each expiry check. + * Defaults to 300. Set to zero to disable jitter. + * @throws IllegalArgumentException if seconds is negative. + */ + public ClientCredentials tokenExpiryJitterSeconds(int seconds) { + if (seconds < 0) { + throw new IllegalArgumentException("tokenExpiryJitterSeconds must be non-negative"); + } + this.tokenExpiryJitterSeconds = seconds; + return this; + } + + public int getTokenExpiryJitterSeconds() { + return this.tokenExpiryJitterSeconds; + } } diff --git a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java index 2629133b..07d0b762 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import dev.openfga.sdk.api.configuration.ClientCredentials; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.stream.Stream; @@ -40,6 +41,9 @@ private static Stream expTimeAndResults() { @ParameterizedTest(name = "{0}") void testTokenValid(String name, Instant exp, boolean valid) { AccessToken snapshot = new AccessToken("token", exp); - assertEquals(valid, snapshot.isValid()); + var defaults = new ClientCredentials(); + assertEquals( + valid, + snapshot.isValid(defaults.getTokenExpiryBufferSeconds(), defaults.getTokenExpiryJitterSeconds())); } } diff --git a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java index 46a15109..cbbab22b 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -22,6 +22,8 @@ import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import tools.jackson.databind.ObjectMapper; @@ -187,8 +189,9 @@ void clientCredentials_failureAsApiException() { requestBuilder.build().headers().firstValue("Authorization").isPresent()); } - @Test - void clientCredentials_setsAuthHeader() throws Exception { + @ParameterizedTest + @CsvSource({"3600,300,300,1", "300,300,300,2", "300,30,0,1", "300,30,10,1", "20,30,0,2", "300,0,0,1"}) + void clientCredentials_setsAuthHeader(int lifetime, int buffer, int jitter, int exchanges) throws Exception { String clientId = "some-client-id"; String clientSecret = "some-client-secret"; String apiAudience = "some-audience"; @@ -202,7 +205,9 @@ void clientCredentials_setsAuthHeader() throws Exception { containsString("client_secret=" + clientSecret), containsString("audience=" + apiAudience), containsString("grant_type=client_credentials"))) - .doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken)); + .doReturn( + 200, + String.format("{\"access_token\":\"%s\",\"expires_in\":%d}", exchangedToken, lifetime)); HttpClient.Builder mockBuilder = mockHttpClientBuilder(mockHttpClient); ApiClient apiClient = new ApiClient(mockBuilder); @@ -213,6 +218,8 @@ void clientCredentials_setsAuthHeader() throws Exception { .clientId(clientId) .clientSecret(clientSecret) .apiAudience(apiAudience) + .tokenExpiryBufferSeconds(buffer) + .tokenExpiryJitterSeconds(jitter) .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL))); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); @@ -222,7 +229,7 @@ void clientCredentials_setsAuthHeader() throws Exception { "Bearer " + exchangedToken, requestBuilder.build().headers().firstValue("Authorization").orElseThrow()); - // A second call should reuse the cached token and not hit the issuer again. + // Reuse the token only while it is outside the configured refresh window. HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); apiClient.applyAuthHeader(secondBuilder, configuration); assertEquals( @@ -232,7 +239,35 @@ void clientCredentials_setsAuthHeader() throws Exception { mockHttpClient .verify() .post(String.format("%s/oauth/token", FgaConstants.TEST_ISSUER_URL)) - .called(1); + .called(exchanges); + } + + @ParameterizedTest + @CsvSource({"31,10", "30,11"}) + void clientCredentials_differentRefreshSettings_useSeparateCaches(int buffer, int jitter) throws Exception { + HttpClientMock mockHttpClient = new HttpClientMock(); + mockHttpClient + .onPost(FgaConstants.TEST_ISSUER_URL + "/oauth/token") + .doReturn(200, "{\"access_token\":\"token\",\"expires_in\":300}"); + ApiClient apiClient = new ApiClient(mockHttpClientBuilder(mockHttpClient)); + ClientCredentials credentials = new ClientCredentials() + .clientId("client") + .clientSecret("secret") + .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL) + .tokenExpiryBufferSeconds(30) + .tokenExpiryJitterSeconds(10); + Configuration configuration = new Configuration().credentials(new Credentials(credentials)); + + apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); + apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); + credentials.tokenExpiryBufferSeconds(buffer).tokenExpiryJitterSeconds(jitter); + apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); + apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); + + mockHttpClient + .verify() + .post(FgaConstants.TEST_ISSUER_URL + "/oauth/token") + .called(2); } @Test diff --git a/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java b/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java index 58ece583..e5bc9d50 100644 --- a/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java +++ b/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java @@ -14,6 +14,12 @@ public class ClientCredentialsTest { private static final String VALID_API_TOKEN_ISSUER = "tokenissuer.fga.example"; private static final String VALID_API_AUDIENCE = "audience"; + @Test + void negativeTokenRefreshSettingsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new ClientCredentials().tokenExpiryBufferSeconds(-1)); + assertThrows(IllegalArgumentException.class, () -> new ClientCredentials().tokenExpiryJitterSeconds(-1)); + } + @Test public void assertValid_allValid() throws FgaInvalidParameterException { // When From 7b53faa6b58c2f642a86c0a0e2bb22ed6e7788ad Mon Sep 17 00:00:00 2001 From: Taha El Amine Kassabi Date: Thu, 10 Sep 2026 15:59:22 +0200 Subject: [PATCH 2/4] refactor: keep token refresh policy in client configuration Keep ClientCredentials limited to token request parameters. Preserve refresh policy through request overrides. --- README.md | 12 +++--- .../openfga/sdk/api/auth/OAuth2Client.java | 4 +- .../dev/openfga/sdk/api/client/ApiClient.java | 10 ++--- .../configuration/ClientConfiguration.java | 12 ++++++ .../api/configuration/ClientCredentials.java | 36 ----------------- .../sdk/api/configuration/Configuration.java | 39 +++++++++++++++++++ .../openfga/sdk/api/auth/AccessTokenTest.java | 4 +- .../openfga/sdk/api/client/ApiClientTest.java | 16 ++++---- .../configuration/ClientCredentialsTest.java | 6 --- .../api/configuration/ConfigurationTest.java | 6 +++ 10 files changed, 82 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 373c8211..5221b18d 100644 --- a/README.md +++ b/README.md @@ -255,16 +255,18 @@ Both settings default to 300 seconds; jitter is sampled from zero up to, but exc the configured value on each validity check. For short-lived tokens, configure a smaller window: ```java -var credentials = new ClientCredentials() - .clientId(System.getenv("FGA_CLIENT_ID")) - .clientSecret(System.getenv("FGA_CLIENT_SECRET")) - .apiTokenIssuer(System.getenv("FGA_API_TOKEN_ISSUER")) +var config = new ClientConfiguration() + .credentials(new Credentials(new ClientCredentials() + .clientId(System.getenv("FGA_CLIENT_ID")) + .clientSecret(System.getenv("FGA_CLIENT_SECRET")) + .apiTokenIssuer(System.getenv("FGA_API_TOKEN_ISSUER")))) .tokenExpiryBufferSeconds(30) .tokenExpiryJitterSeconds(5); ``` Values must be non-negative. Set jitter to zero to disable it. Keep the combined window -below the token lifetime to allow cached tokens to be reused. +below the token lifetime to allow cached tokens to be reused. These client-level settings +are preserved when applying per-request configuration overrides. ### Custom Headers diff --git a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java index cdf17225..f5e30682 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -31,8 +31,8 @@ public class OAuth2Client { */ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws FgaInvalidParameterException { var clientCredentials = configuration.getCredentials().getClientCredentials(); - this.tokenExpiryBufferSeconds = clientCredentials.getTokenExpiryBufferSeconds(); - this.tokenExpiryJitterSeconds = clientCredentials.getTokenExpiryJitterSeconds(); + this.tokenExpiryBufferSeconds = configuration.getTokenExpiryBufferSeconds(); + this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds(); this.apiClient = apiClient; this.authRequest = diff --git a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java index bb0292ee..12051bde 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -364,8 +364,7 @@ public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration co } private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException { - ClientCredentials cc = configuration.getCredentials().getClientCredentials(); - CredentialsCacheKey key = new CredentialsCacheKey(cc); + CredentialsCacheKey key = new CredentialsCacheKey(configuration); OAuth2Client existing = oAuth2Clients.get(key); if (existing != null) { return existing; @@ -384,14 +383,15 @@ private static final class CredentialsCacheKey { private final int tokenExpiryBufferSeconds; private final int tokenExpiryJitterSeconds; - CredentialsCacheKey(ClientCredentials cc) { + CredentialsCacheKey(Configuration configuration) { + ClientCredentials cc = configuration.getCredentials().getClientCredentials(); this.clientId = cc.getClientId(); this.clientSecretHash = sha256(cc.getClientSecret()); this.apiTokenIssuer = cc.getApiTokenIssuer(); this.apiAudience = cc.getApiAudience(); this.scopes = cc.getScopes(); - this.tokenExpiryBufferSeconds = cc.getTokenExpiryBufferSeconds(); - this.tokenExpiryJitterSeconds = cc.getTokenExpiryJitterSeconds(); + this.tokenExpiryBufferSeconds = configuration.getTokenExpiryBufferSeconds(); + this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds(); } private static byte[] sha256(String value) { diff --git a/src/main/java/dev/openfga/sdk/api/configuration/ClientConfiguration.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientConfiguration.java index 98d5a6c6..c49da64c 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientConfiguration.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientConfiguration.java @@ -129,6 +129,18 @@ public ClientConfiguration telemetryConfiguration(TelemetryConfiguration telemet return this; } + @Override + public ClientConfiguration tokenExpiryBufferSeconds(int seconds) { + super.tokenExpiryBufferSeconds(seconds); + return this; + } + + @Override + public ClientConfiguration tokenExpiryJitterSeconds(int seconds) { + super.tokenExpiryJitterSeconds(seconds); + return this; + } + @Override public ClientConfiguration defaultHeaders(java.util.Map defaultHeaders) { super.defaultHeaders(defaultHeaders); diff --git a/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java index 17ef3f6e..dfcac13a 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java @@ -2,7 +2,6 @@ import static dev.openfga.sdk.util.Validation.assertParamExists; -import dev.openfga.sdk.constants.FgaConstants; import dev.openfga.sdk.errors.FgaInvalidParameterException; import dev.openfga.sdk.util.StringUtil; @@ -12,8 +11,6 @@ public class ClientCredentials { private String apiTokenIssuer; private String apiAudience; private String scopes; - private int tokenExpiryBufferSeconds = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; - private int tokenExpiryJitterSeconds = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; public ClientCredentials() {} @@ -67,37 +64,4 @@ public ClientCredentials scopes(String scopes) { public String getScopes() { return this.scopes; } - - /** - * Sets how many seconds before expiry a cached token requires refresh. Defaults to 300. - * @throws IllegalArgumentException if seconds is negative. - */ - public ClientCredentials tokenExpiryBufferSeconds(int seconds) { - if (seconds < 0) { - throw new IllegalArgumentException("tokenExpiryBufferSeconds must be non-negative"); - } - this.tokenExpiryBufferSeconds = seconds; - return this; - } - - public int getTokenExpiryBufferSeconds() { - return this.tokenExpiryBufferSeconds; - } - - /** - * Sets the exclusive upper bound of additional random seconds subtracted on each expiry check. - * Defaults to 300. Set to zero to disable jitter. - * @throws IllegalArgumentException if seconds is negative. - */ - public ClientCredentials tokenExpiryJitterSeconds(int seconds) { - if (seconds < 0) { - throw new IllegalArgumentException("tokenExpiryJitterSeconds must be non-negative"); - } - this.tokenExpiryJitterSeconds = seconds; - return this; - } - - public int getTokenExpiryJitterSeconds() { - return this.tokenExpiryJitterSeconds; - } } diff --git a/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java b/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java index 2a736c61..43eeb6bd 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java @@ -36,6 +36,8 @@ public class Configuration implements BaseConfiguration { private Duration connectTimeout; private int maxRetries; private Duration minimumRetryDelay; + private int tokenExpiryBufferSeconds = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; + private int tokenExpiryJitterSeconds = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; private Map defaultHeaders; private TelemetryConfiguration telemetryConfiguration; @@ -87,6 +89,8 @@ public Configuration override(ConfigurationOverride configurationOverride) { Credentials overrideCredentials = configurationOverride.getCredentials(); result.credentials(overrideCredentials != null ? overrideCredentials : credentials); + result.tokenExpiryBufferSeconds(tokenExpiryBufferSeconds); + result.tokenExpiryJitterSeconds(tokenExpiryJitterSeconds); String overrideUserAgent = configurationOverride.getUserAgent(); result.userAgent(overrideUserAgent != null ? overrideUserAgent : userAgent); @@ -303,6 +307,41 @@ public Duration getMinimumRetryDelay() { return minimumRetryDelay; } + /** + * Sets how many seconds before expiry a cached client-credentials token requires refresh. + * Defaults to 300. This is a client-level setting, preserved by request overrides. + * @throws IllegalArgumentException if seconds is negative. + */ + public Configuration tokenExpiryBufferSeconds(int seconds) { + if (seconds < 0) { + throw new IllegalArgumentException("tokenExpiryBufferSeconds must be non-negative"); + } + this.tokenExpiryBufferSeconds = seconds; + return this; + } + + public int getTokenExpiryBufferSeconds() { + return this.tokenExpiryBufferSeconds; + } + + /** + * Sets the exclusive upper bound of additional random seconds subtracted on each token expiry check. + * Defaults to 300. Set to zero to disable jitter. This is a client-level setting, + * preserved by request overrides. + * @throws IllegalArgumentException if seconds is negative. + */ + public Configuration tokenExpiryJitterSeconds(int seconds) { + if (seconds < 0) { + throw new IllegalArgumentException("tokenExpiryJitterSeconds must be non-negative"); + } + this.tokenExpiryJitterSeconds = seconds; + return this; + } + + public int getTokenExpiryJitterSeconds() { + return this.tokenExpiryJitterSeconds; + } + public Configuration defaultHeaders(Map defaultHeaders) { this.defaultHeaders = defaultHeaders; return this; diff --git a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java index 07d0b762..6e04371c 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java @@ -2,7 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -import dev.openfga.sdk.api.configuration.ClientCredentials; +import dev.openfga.sdk.api.configuration.Configuration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.stream.Stream; @@ -41,7 +41,7 @@ private static Stream expTimeAndResults() { @ParameterizedTest(name = "{0}") void testTokenValid(String name, Instant exp, boolean valid) { AccessToken snapshot = new AccessToken("token", exp); - var defaults = new ClientCredentials(); + var defaults = new Configuration(); assertEquals( valid, snapshot.isValid(defaults.getTokenExpiryBufferSeconds(), defaults.getTokenExpiryJitterSeconds())); diff --git a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java index cbbab22b..1f7e0567 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -12,6 +12,7 @@ import dev.openfga.sdk.api.configuration.ApiToken; import dev.openfga.sdk.api.configuration.ClientCredentials; import dev.openfga.sdk.api.configuration.Configuration; +import dev.openfga.sdk.api.configuration.ConfigurationOverride; import dev.openfga.sdk.api.configuration.Credentials; import dev.openfga.sdk.constants.FgaConstants; import dev.openfga.sdk.errors.ApiException; @@ -218,9 +219,9 @@ void clientCredentials_setsAuthHeader(int lifetime, int buffer, int jitter, int .clientId(clientId) .clientSecret(clientSecret) .apiAudience(apiAudience) - .tokenExpiryBufferSeconds(buffer) - .tokenExpiryJitterSeconds(jitter) - .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL))); + .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL))) + .tokenExpiryBufferSeconds(buffer) + .tokenExpiryJitterSeconds(jitter); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); apiClient.applyAuthHeader(requestBuilder, configuration); @@ -231,7 +232,7 @@ void clientCredentials_setsAuthHeader(int lifetime, int buffer, int jitter, int // Reuse the token only while it is outside the configured refresh window. HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); - apiClient.applyAuthHeader(secondBuilder, configuration); + apiClient.applyAuthHeader(secondBuilder, configuration.override(new ConfigurationOverride())); assertEquals( "Bearer " + exchangedToken, secondBuilder.build().headers().firstValue("Authorization").orElseThrow()); @@ -253,14 +254,15 @@ void clientCredentials_differentRefreshSettings_useSeparateCaches(int buffer, in ClientCredentials credentials = new ClientCredentials() .clientId("client") .clientSecret("secret") - .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL) + .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL); + Configuration configuration = new Configuration() + .credentials(new Credentials(credentials)) .tokenExpiryBufferSeconds(30) .tokenExpiryJitterSeconds(10); - Configuration configuration = new Configuration().credentials(new Credentials(credentials)); apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); - credentials.tokenExpiryBufferSeconds(buffer).tokenExpiryJitterSeconds(jitter); + configuration.tokenExpiryBufferSeconds(buffer).tokenExpiryJitterSeconds(jitter); apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); diff --git a/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java b/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java index e5bc9d50..58ece583 100644 --- a/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java +++ b/src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java @@ -14,12 +14,6 @@ public class ClientCredentialsTest { private static final String VALID_API_TOKEN_ISSUER = "tokenissuer.fga.example"; private static final String VALID_API_AUDIENCE = "audience"; - @Test - void negativeTokenRefreshSettingsAreRejected() { - assertThrows(IllegalArgumentException.class, () -> new ClientCredentials().tokenExpiryBufferSeconds(-1)); - assertThrows(IllegalArgumentException.class, () -> new ClientCredentials().tokenExpiryJitterSeconds(-1)); - } - @Test public void assertValid_allValid() throws FgaInvalidParameterException { // When diff --git a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java index b2f6e1d3..c621f33a 100644 --- a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java +++ b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java @@ -16,6 +16,12 @@ class ConfigurationTest { private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); private static final Map DEFAULT_HEADERS = Map.of(); + @Test + void negativeTokenRefreshSettingsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new Configuration().tokenExpiryBufferSeconds(-1)); + assertThrows(IllegalArgumentException.class, () -> new Configuration().tokenExpiryJitterSeconds(-1)); + } + @Test void apiUrl_nullDefaults() throws FgaInvalidParameterException { // Given From 68e3af3864ce040c10d158c2c078436e1544a109 Mon Sep 17 00:00:00 2001 From: Taha El Amine Kassabi Date: Thu, 10 Sep 2026 16:04:08 +0200 Subject: [PATCH 3/4] test: remove trivial refresh setting validation test --- .../openfga/sdk/api/configuration/ConfigurationTest.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java index c621f33a..b2f6e1d3 100644 --- a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java +++ b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java @@ -16,12 +16,6 @@ class ConfigurationTest { private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); private static final Map DEFAULT_HEADERS = Map.of(); - @Test - void negativeTokenRefreshSettingsAreRejected() { - assertThrows(IllegalArgumentException.class, () -> new Configuration().tokenExpiryBufferSeconds(-1)); - assertThrows(IllegalArgumentException.class, () -> new Configuration().tokenExpiryJitterSeconds(-1)); - } - @Test void apiUrl_nullDefaults() throws FgaInvalidParameterException { // Given From a103ac00435d85a37502e098da1e6a04af64aae8 Mon Sep 17 00:00:00 2001 From: Taha El Amine Kassabi Date: Fri, 6 Nov 2026 20:25:33 +0400 Subject: [PATCH 4/4] refactor: share cached tokens across refresh timing Key the OAuth client cache by credentials only, so configurations that differ only in refresh timing share one token. Keep the timing in the OAuth client's configuration, document the limits and sharing on the setters, and cover the settings in ConfigurationTest and AccessTokenTest. --- .../openfga/sdk/api/auth/OAuth2Client.java | 13 +- .../dev/openfga/sdk/api/client/ApiClient.java | 17 +-- .../sdk/api/configuration/Configuration.java | 37 +++++- .../openfga/sdk/api/auth/AccessTokenTest.java | 80 +++++++++--- .../openfga/sdk/api/client/ApiClientTest.java | 31 +++-- .../api/configuration/ConfigurationTest.java | 122 ++++++++++++++++++ 6 files changed, 251 insertions(+), 49 deletions(-) diff --git a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java index f5e30682..80dce5d4 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -21,8 +21,6 @@ public class OAuth2Client { private final CredentialsFlowRequest authRequest; private final Configuration config; private final Telemetry telemetry; - private final int tokenExpiryBufferSeconds; - private final int tokenExpiryJitterSeconds; /** * Initializes a new instance of the {@link OAuth2Client} class @@ -31,8 +29,6 @@ public class OAuth2Client { */ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws FgaInvalidParameterException { var clientCredentials = configuration.getCredentials().getClientCredentials(); - this.tokenExpiryBufferSeconds = configuration.getTokenExpiryBufferSeconds(); - this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds(); this.apiClient = apiClient; this.authRequest = @@ -44,7 +40,9 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga .connectTimeout(configuration.getConnectTimeout()) .maxRetries(configuration.getMaxRetries()) .minimumRetryDelay(configuration.getMinimumRetryDelay()) - .telemetryConfiguration(configuration.getTelemetryConfiguration()); + .telemetryConfiguration(configuration.getTelemetryConfiguration()) + .tokenExpiryBufferSeconds(configuration.getTokenExpiryBufferSeconds()) + .tokenExpiryJitterSeconds(configuration.getTokenExpiryJitterSeconds()); this.telemetry = new Telemetry(this.config); } @@ -58,7 +56,7 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga public CompletableFuture getAccessToken() throws FgaInvalidParameterException, ApiException { // Fast path (lock-free): return cached token if still valid. AccessToken current = snapshot.get(); - if (current.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) { + if (current.isValid(this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) { return CompletableFuture.completedFuture(current.token()); } @@ -66,7 +64,8 @@ public CompletableFuture getAccessToken() throws FgaInvalidParameterExce synchronized (this) { // Double-check: another thread may have refreshed while we waited. AccessToken rechecked = snapshot.get(); - if (rechecked.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) { + if (rechecked.isValid( + this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) { return CompletableFuture.completedFuture(rechecked.token()); } diff --git a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java index 12051bde..d46eaab5 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -364,7 +364,8 @@ public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration co } private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException { - CredentialsCacheKey key = new CredentialsCacheKey(configuration); + ClientCredentials cc = configuration.getCredentials().getClientCredentials(); + CredentialsCacheKey key = new CredentialsCacheKey(cc); OAuth2Client existing = oAuth2Clients.get(key); if (existing != null) { return existing; @@ -380,18 +381,13 @@ private static final class CredentialsCacheKey { private final String apiTokenIssuer; private final String apiAudience; private final String scopes; - private final int tokenExpiryBufferSeconds; - private final int tokenExpiryJitterSeconds; - CredentialsCacheKey(Configuration configuration) { - ClientCredentials cc = configuration.getCredentials().getClientCredentials(); + CredentialsCacheKey(ClientCredentials cc) { this.clientId = cc.getClientId(); this.clientSecretHash = sha256(cc.getClientSecret()); this.apiTokenIssuer = cc.getApiTokenIssuer(); this.apiAudience = cc.getApiAudience(); this.scopes = cc.getScopes(); - this.tokenExpiryBufferSeconds = configuration.getTokenExpiryBufferSeconds(); - this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds(); } private static byte[] sha256(String value) { @@ -411,15 +407,12 @@ public boolean equals(Object o) { && Arrays.equals(clientSecretHash, that.clientSecretHash) && Objects.equals(apiTokenIssuer, that.apiTokenIssuer) && Objects.equals(apiAudience, that.apiAudience) - && Objects.equals(scopes, that.scopes) - && tokenExpiryBufferSeconds == that.tokenExpiryBufferSeconds - && tokenExpiryJitterSeconds == that.tokenExpiryJitterSeconds; + && Objects.equals(scopes, that.scopes); } @Override public int hashCode() { - int result = Objects.hash( - clientId, apiTokenIssuer, apiAudience, scopes, tokenExpiryBufferSeconds, tokenExpiryJitterSeconds); + int result = Objects.hash(clientId, apiTokenIssuer, apiAudience, scopes); result = 31 * result + Arrays.hashCode(clientSecretHash); return result; } diff --git a/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java b/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java index 43eeb6bd..9804f696 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java @@ -36,8 +36,8 @@ public class Configuration implements BaseConfiguration { private Duration connectTimeout; private int maxRetries; private Duration minimumRetryDelay; - private int tokenExpiryBufferSeconds = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; - private int tokenExpiryJitterSeconds = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; + private int tokenExpiryBufferSeconds; + private int tokenExpiryJitterSeconds; private Map defaultHeaders; private TelemetryConfiguration telemetryConfiguration; @@ -48,6 +48,8 @@ public Configuration() { this.connectTimeout = FgaConstants.DEFAULT_CONNECTION_TIMEOUT_IN_MS; this.maxRetries = FgaConstants.DEFAULT_MAX_RETRY; this.minimumRetryDelay = FgaConstants.DEFAULT_MIN_WAIT_IN_MS; + this.tokenExpiryBufferSeconds = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; + this.tokenExpiryJitterSeconds = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; } /** @@ -89,6 +91,8 @@ public Configuration override(ConfigurationOverride configurationOverride) { Credentials overrideCredentials = configurationOverride.getCredentials(); result.credentials(overrideCredentials != null ? overrideCredentials : credentials); + // Token refresh timing is intentionally client-level only and not part of ConfigurationOverride: + // the access token is shared per identity, so per-request timing would have no well-defined meaning. result.tokenExpiryBufferSeconds(tokenExpiryBufferSeconds); result.tokenExpiryJitterSeconds(tokenExpiryJitterSeconds); @@ -310,6 +314,18 @@ public Duration getMinimumRetryDelay() { /** * Sets how many seconds before expiry a cached client-credentials token requires refresh. * Defaults to 300. This is a client-level setting, preserved by request overrides. + * + *

Set to 0 to refresh only at the exact expiry instant, which removes the margin that guards + * against clock skew and in-flight request latency. Together with {@code tokenExpiryJitterSeconds} + * this should stay well below the token's lifetime: if their sum meets or exceeds it, every token + * is treated as already-expired and re-exchanged on effectively every call. + * + *

The token cache is keyed by credentials only. Configurations that share an {@link + * dev.openfga.sdk.api.client.ApiClient} and the same credentials share one cached token, refreshed + * with the timing of the configuration that first requested it. + * + * @param seconds the refresh buffer in seconds; must be non-negative. + * @return This Configuration instance for method chaining. * @throws IllegalArgumentException if seconds is negative. */ public Configuration tokenExpiryBufferSeconds(int seconds) { @@ -320,6 +336,9 @@ public Configuration tokenExpiryBufferSeconds(int seconds) { return this; } + /** + * @return The number of seconds before expiry at which a cached client-credentials token is refreshed. + */ public int getTokenExpiryBufferSeconds() { return this.tokenExpiryBufferSeconds; } @@ -328,6 +347,17 @@ public int getTokenExpiryBufferSeconds() { * Sets the exclusive upper bound of additional random seconds subtracted on each token expiry check. * Defaults to 300. Set to zero to disable jitter. This is a client-level setting, * preserved by request overrides. + * + *

Jitter spreads token refreshes across clients to avoid a thundering herd. Together with + * {@code tokenExpiryBufferSeconds} it should stay well below the token's lifetime: if their sum + * meets or exceeds it, every token is treated as already-expired and re-exchanged on effectively + * every call. + * + *

Like the buffer, this applies per cached token: configurations sharing an {@link + * dev.openfga.sdk.api.client.ApiClient} and the same credentials use the timing of the first one. + * + * @param seconds the exclusive upper bound of random jitter in seconds; must be non-negative. + * @return This Configuration instance for method chaining. * @throws IllegalArgumentException if seconds is negative. */ public Configuration tokenExpiryJitterSeconds(int seconds) { @@ -338,6 +368,9 @@ public Configuration tokenExpiryJitterSeconds(int seconds) { return this; } + /** + * @return The exclusive upper bound, in seconds, of random jitter applied to token expiry checks. + */ public int getTokenExpiryJitterSeconds() { return this.tokenExpiryJitterSeconds; } diff --git a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java index 6e04371c..b43c566c 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -import dev.openfga.sdk.api.configuration.Configuration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.stream.Stream; @@ -12,38 +11,89 @@ class AccessTokenTest { + private static final int DEFAULT_BUFFER = 300; + private static final int DEFAULT_JITTER = 300; + private static Stream expTimeAndResults() { + Instant now = Instant.now(); return Stream.of( - Arguments.of("Expires in 1 hour should be valid", Instant.now().plus(1, ChronoUnit.HOURS), true), + // Default timing (300s buffer, up to 300s jitter) + Arguments.of( + "Expires in 1 hour should be valid", + now.plus(1, ChronoUnit.HOURS), + DEFAULT_BUFFER, + DEFAULT_JITTER, + true), Arguments.of( - "Expires in 15 minutes should be valid", Instant.now().plus(15, ChronoUnit.MINUTES), true), - Arguments.of("No expiry value should be valid", null, true), + "Expires in 15 minutes should be valid", + now.plus(15, ChronoUnit.MINUTES), + DEFAULT_BUFFER, + DEFAULT_JITTER, + true), + Arguments.of("No expiry value should be valid", null, DEFAULT_BUFFER, DEFAULT_JITTER, true), Arguments.of( - "Expired 1 hour ago should not be valid", Instant.now().minus(1, ChronoUnit.HOURS), false), + "Expired 1 hour ago should not be valid", + now.minus(1, ChronoUnit.HOURS), + DEFAULT_BUFFER, + DEFAULT_JITTER, + false), Arguments.of( "Expired 10 minutes ago should not be valid", - Instant.now().minus(10, ChronoUnit.MINUTES), + now.minus(10, ChronoUnit.MINUTES), + DEFAULT_BUFFER, + DEFAULT_JITTER, false), Arguments.of( "Expired 5 minutes ago should not be valid", - Instant.now().minus(5, ChronoUnit.MINUTES), + now.minus(5, ChronoUnit.MINUTES), + DEFAULT_BUFFER, + DEFAULT_JITTER, false), Arguments.of( "Expires in 5 minutes should not be valid", - Instant.now().plus(5, ChronoUnit.MINUTES), + now.plus(5, ChronoUnit.MINUTES), + DEFAULT_BUFFER, + DEFAULT_JITTER, + false), + Arguments.of( + "Expires in 1 minute should not be valid", + now.plus(1, ChronoUnit.MINUTES), + DEFAULT_BUFFER, + DEFAULT_JITTER, false), + Arguments.of("Expires now should not be valid", now, DEFAULT_BUFFER, DEFAULT_JITTER, false), + // Non-default timing + Arguments.of( + "Expires in 5 minutes with 400s buffer should not be valid", + now.plus(5, ChronoUnit.MINUTES), + 400, + 0, + false), + Arguments.of( + "Expires in 5 minutes with 30s buffer should be valid", + now.plus(5, ChronoUnit.MINUTES), + 30, + 0, + true), + Arguments.of( + "Expires in 5 minutes with no buffer and 300s jitter should be valid", + now.plus(5, ChronoUnit.MINUTES), + 0, + 300, + true), Arguments.of( - "Expires in 1 minute should not be valid", Instant.now().plus(1, ChronoUnit.MINUTES), false), - Arguments.of("Expires now should not be valid", Instant.now(), false)); + "Expires in 1 minute with no buffer or jitter should be valid", + now.plus(1, ChronoUnit.MINUTES), + 0, + 0, + true), + Arguments.of("Expires now with no buffer or jitter should not be valid", now, 0, 0, false)); } @MethodSource("expTimeAndResults") @ParameterizedTest(name = "{0}") - void testTokenValid(String name, Instant exp, boolean valid) { + void testTokenValid(String name, Instant exp, int bufferSeconds, int jitterSeconds, boolean valid) { AccessToken snapshot = new AccessToken("token", exp); - var defaults = new Configuration(); - assertEquals( - valid, - snapshot.isValid(defaults.getTokenExpiryBufferSeconds(), defaults.getTokenExpiryJitterSeconds())); + assertEquals(valid, snapshot.isValid(bufferSeconds, jitterSeconds)); } } diff --git a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java index 1f7e0567..966009f6 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -12,7 +12,6 @@ import dev.openfga.sdk.api.configuration.ApiToken; import dev.openfga.sdk.api.configuration.ClientCredentials; import dev.openfga.sdk.api.configuration.Configuration; -import dev.openfga.sdk.api.configuration.ConfigurationOverride; import dev.openfga.sdk.api.configuration.Credentials; import dev.openfga.sdk.constants.FgaConstants; import dev.openfga.sdk.errors.ApiException; @@ -190,6 +189,9 @@ void clientCredentials_failureAsApiException() { requestBuilder.build().headers().firstValue("Authorization").isPresent()); } + // Columns: token lifetime (s), refresh buffer (s), refresh jitter (s), expected token exchanges over two calls. + // A second exchange happens when the cached token is already in its refresh window. Rows are chosen so the + // random jitter draw cannot change the outcome. @ParameterizedTest @CsvSource({"3600,300,300,1", "300,300,300,2", "300,30,0,1", "300,30,10,1", "20,30,0,2", "300,0,0,1"}) void clientCredentials_setsAuthHeader(int lifetime, int buffer, int jitter, int exchanges) throws Exception { @@ -232,7 +234,7 @@ void clientCredentials_setsAuthHeader(int lifetime, int buffer, int jitter, int // Reuse the token only while it is outside the configured refresh window. HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); - apiClient.applyAuthHeader(secondBuilder, configuration.override(new ConfigurationOverride())); + apiClient.applyAuthHeader(secondBuilder, configuration); assertEquals( "Bearer " + exchangedToken, secondBuilder.build().headers().firstValue("Authorization").orElseThrow()); @@ -243,33 +245,36 @@ void clientCredentials_setsAuthHeader(int lifetime, int buffer, int jitter, int .called(exchanges); } - @ParameterizedTest - @CsvSource({"31,10", "30,11"}) - void clientCredentials_differentRefreshSettings_useSeparateCaches(int buffer, int jitter) throws Exception { + @Test + void clientCredentials_differentRefreshSettings_shareOneCache() throws Exception { HttpClientMock mockHttpClient = new HttpClientMock(); mockHttpClient .onPost(FgaConstants.TEST_ISSUER_URL + "/oauth/token") - .doReturn(200, "{\"access_token\":\"token\",\"expires_in\":300}"); + .doReturn(200, "{\"access_token\":\"token\",\"expires_in\":3600}"); ApiClient apiClient = new ApiClient(mockHttpClientBuilder(mockHttpClient)); + ClientCredentials credentials = new ClientCredentials() .clientId("client") .clientSecret("secret") .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL); - Configuration configuration = new Configuration() + + Configuration first = new Configuration() .credentials(new Credentials(credentials)) .tokenExpiryBufferSeconds(30) .tokenExpiryJitterSeconds(10); + Configuration second = new Configuration() + .credentials(new Credentials(credentials)) + .tokenExpiryBufferSeconds(300) + .tokenExpiryJitterSeconds(0); - apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); - apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); - configuration.tokenExpiryBufferSeconds(buffer).tokenExpiryJitterSeconds(jitter); - apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); - apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration); + apiClient.applyAuthHeader(HttpRequest.newBuilder(), first); + apiClient.applyAuthHeader(HttpRequest.newBuilder(), second); + // Same credentials share one cached client and token, regardless of refresh timing. mockHttpClient .verify() .post(FgaConstants.TEST_ISSUER_URL + "/oauth/token") - .called(2); + .called(1); } @Test diff --git a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java index b2f6e1d3..c5c48e59 100644 --- a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java +++ b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java @@ -333,4 +333,126 @@ void minimumRetryDelay_hasDefaultValue() { assertNotNull(defaultDelay, "minimumRetryDelay should have a default value"); assertEquals(Duration.ofMillis(100), defaultDelay, "Default minimumRetryDelay should be 100ms"); } + + @Test + void tokenExpiryBufferSeconds_validValue() { + // Given + Configuration config = new Configuration(); + + // When + Configuration result = config.tokenExpiryBufferSeconds(42); + + // Then + assertEquals(42, result.getTokenExpiryBufferSeconds()); + assertSame(config, result, "Should return the same Configuration instance for method chaining"); + } + + @Test + void tokenExpiryBufferSeconds_zero() { + // Given + Configuration config = new Configuration(); + + // When + Configuration result = config.tokenExpiryBufferSeconds(0); + + // Then + assertEquals(0, result.getTokenExpiryBufferSeconds()); + assertSame(config, result, "Should return the same Configuration instance for method chaining"); + } + + @Test + void tokenExpiryBufferSeconds_negativeValue_throwsException() { + // Given + Configuration config = new Configuration(); + + // When & Then + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> config.tokenExpiryBufferSeconds(-1), + "Should throw IllegalArgumentException for negative value"); + + assertEquals("tokenExpiryBufferSeconds must be non-negative", exception.getMessage()); + assertEquals(300, config.getTokenExpiryBufferSeconds(), "A rejected value should leave the setting unchanged"); + } + + @Test + void tokenExpiryBufferSeconds_hasDefaultValue() { + // Given + Configuration config = new Configuration(); + + // When + int defaultValue = config.getTokenExpiryBufferSeconds(); + + // Then + assertEquals(300, defaultValue, "Default tokenExpiryBufferSeconds should be 300"); + } + + @Test + void tokenExpiryJitterSeconds_validValue() { + // Given + Configuration config = new Configuration(); + + // When + Configuration result = config.tokenExpiryJitterSeconds(42); + + // Then + assertEquals(42, result.getTokenExpiryJitterSeconds()); + assertSame(config, result, "Should return the same Configuration instance for method chaining"); + } + + @Test + void tokenExpiryJitterSeconds_zero() { + // Given + Configuration config = new Configuration(); + + // When + Configuration result = config.tokenExpiryJitterSeconds(0); + + // Then + assertEquals(0, result.getTokenExpiryJitterSeconds()); + assertSame(config, result, "Should return the same Configuration instance for method chaining"); + } + + @Test + void tokenExpiryJitterSeconds_negativeValue_throwsException() { + // Given + Configuration config = new Configuration(); + + // When & Then + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> config.tokenExpiryJitterSeconds(-1), + "Should throw IllegalArgumentException for negative value"); + + assertEquals("tokenExpiryJitterSeconds must be non-negative", exception.getMessage()); + assertEquals(300, config.getTokenExpiryJitterSeconds(), "A rejected value should leave the setting unchanged"); + } + + @Test + void tokenExpiryJitterSeconds_hasDefaultValue() { + // Given + Configuration config = new Configuration(); + + // When + int defaultValue = config.getTokenExpiryJitterSeconds(); + + // Then + assertEquals(300, defaultValue, "Default tokenExpiryJitterSeconds should be 300"); + } + + @Test + void override_preservesTokenExpirySettings() { + // Given + Configuration original = + new Configuration().tokenExpiryBufferSeconds(42).tokenExpiryJitterSeconds(7); + + // When + Configuration result = original.override(new ConfigurationOverride()); + + // Then + assertEquals(42, result.getTokenExpiryBufferSeconds()); + assertEquals(7, result.getTokenExpiryJitterSeconds()); + assertEquals(42, original.getTokenExpiryBufferSeconds(), "The original buffer should be unmodified."); + assertEquals(7, original.getTokenExpiryJitterSeconds(), "The original jitter should be unmodified."); + } }