diff --git a/README.md b/README.md index 5aa19029..21d55f73 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,26 @@ 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 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. These client-level settings +are preserved when applying per-request configuration overrides. + ### Custom Headers #### Default Headers @@ -1514,4 +1534,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..f5e30682 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 = configuration.getTokenExpiryBufferSeconds(); + this.tokenExpiryJitterSeconds = configuration.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 82a2d7ee..ff754d98 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -398,8 +398,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; @@ -415,13 +414,18 @@ 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) { + 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 = configuration.getTokenExpiryBufferSeconds(); + this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds(); } private static byte[] sha256(String value) { @@ -441,12 +445,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/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/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 2629133b..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,6 +2,7 @@ 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; @@ -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 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 98d248a6..2c99010e 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -11,6 +11,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; @@ -20,6 +21,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; @@ -168,8 +171,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"; @@ -183,7 +187,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); @@ -194,7 +200,9 @@ void clientCredentials_setsAuthHeader() throws Exception { .clientId(clientId) .clientSecret(clientSecret) .apiAudience(apiAudience) - .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); @@ -203,9 +211,9 @@ 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); + apiClient.applyAuthHeader(secondBuilder, configuration.override(new ConfigurationOverride())); assertEquals( "Bearer " + exchangedToken, secondBuilder.build().headers().firstValue("Authorization").orElseThrow()); @@ -213,7 +221,36 @@ 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); + Configuration configuration = new Configuration() + .credentials(new Credentials(credentials)) + .tokenExpiryBufferSeconds(30) + .tokenExpiryJitterSeconds(10); + + 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); + + mockHttpClient + .verify() + .post(FgaConstants.TEST_ISSUER_URL + "/oauth/token") + .called(2); } @Test