Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

15 changes: 5 additions & 10 deletions src/main/java/dev/openfga/sdk/api/auth/AccessToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 =
Expand All @@ -54,15 +58,15 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga
public CompletableFuture<String> 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());
}

// Slow path: decide under the lock who starts the exchange.
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());
}

Expand Down
17 changes: 12 additions & 5 deletions src/main/java/dev/openfga/sdk/api/client/ApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> defaultHeaders) {
super.defaultHeaders(defaultHeaders);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> defaultHeaders;
private TelemetryConfiguration telemetryConfiguration;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<String, String> defaultHeaders) {
this.defaultHeaders = defaultHeaders;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,6 +41,9 @@ private static Stream<Arguments> 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()));
}
}
51 changes: 44 additions & 7 deletions src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -203,17 +211,46 @@ 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());

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
Expand Down