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 @@ -248,6 +248,26 @@ public class Example {
}
```

#### Token refresh timing
Comment thread
dpkass marked this conversation as resolved.
Resolved

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 @@ -1507,4 +1527,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))
Comment thread
dpkass marked this conversation as resolved.
Resolved
.truncatedTo(ChronoUnit.SECONDS);

return Instant.now().truncatedTo(ChronoUnit.SECONDS).isBefore(expiresWithLeeway);
Expand Down
9 changes: 6 additions & 3 deletions src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,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);
}

Expand All @@ -54,15 +56,16 @@ 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(this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) {
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(
this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) {
return CompletableFuture.completedFuture(rechecked.token());
}

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;
private int tokenExpiryJitterSeconds;
private Map<String, String> defaultHeaders;
private TelemetryConfiguration telemetryConfiguration;

Expand All @@ -46,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;
}

/**
Expand Down Expand Up @@ -87,6 +91,10 @@ 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);
Comment thread
dpkass marked this conversation as resolved.
Resolved
Comment thread
dpkass marked this conversation as resolved.
Resolved

String overrideUserAgent = configurationOverride.getUserAgent();
result.userAgent(overrideUserAgent != null ? overrideUserAgent : userAgent);
Expand Down Expand Up @@ -303,6 +311,70 @@ 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.
*
* <p>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.
*
* <p>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.
*/
Comment thread
dpkass marked this conversation as resolved.
Resolved
public Configuration tokenExpiryBufferSeconds(int seconds) {
Comment thread
dpkass marked this conversation as resolved.
Resolved
if (seconds < 0) {
throw new IllegalArgumentException("tokenExpiryBufferSeconds must be non-negative");
}
this.tokenExpiryBufferSeconds = seconds;
return this;
}

/**
* @return The number of seconds before expiry at which a cached client-credentials token is refreshed.
*/
public int getTokenExpiryBufferSeconds() {
Comment thread
dpkass marked this conversation as resolved.
Resolved
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.
*
* <p>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.
*
* <p>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.
Comment thread
dpkass marked this conversation as resolved.
Resolved
*/
public Configuration tokenExpiryJitterSeconds(int seconds) {
if (seconds < 0) {
throw new IllegalArgumentException("tokenExpiryJitterSeconds must be non-negative");
}
this.tokenExpiryJitterSeconds = seconds;
return this;
}

/**
* @return The exclusive upper bound, in seconds, of random jitter applied to token expiry checks.
*/
public int getTokenExpiryJitterSeconds() {
Comment thread
dpkass marked this conversation as resolved.
Resolved
return this.tokenExpiryJitterSeconds;
}

public Configuration defaultHeaders(Map<String, String> defaultHeaders) {
this.defaultHeaders = defaultHeaders;
return this;
Expand Down
76 changes: 65 additions & 11 deletions src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,89 @@

class AccessTokenTest {

private static final int DEFAULT_BUFFER = 300;
private static final int DEFAULT_JITTER = 300;

private static Stream<Arguments> 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);
assertEquals(valid, snapshot.isValid());
assertEquals(valid, snapshot.isValid(bufferSeconds, jitterSeconds));
}
}
Loading