diff --git a/README.md b/README.md index 6644c24a..5221b18d 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,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 @@ -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). - 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..80dce5d4 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -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); } @@ -54,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()) { + if (current.isValid(this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) { return CompletableFuture.completedFuture(current.token()); } @@ -62,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()) { + if (rechecked.isValid( + this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) { return CompletableFuture.completedFuture(rechecked.token()); } 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..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,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 defaultHeaders; private TelemetryConfiguration telemetryConfiguration; @@ -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; } /** @@ -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); String overrideUserAgent = configurationOverride.getUserAgent(); result.userAgent(overrideUserAgent != null ? overrideUserAgent : userAgent); @@ -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. + * + *

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) { + 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() { + 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. + * + *

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) { + 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() { + 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..b43c566c 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java @@ -11,35 +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); - assertEquals(valid, snapshot.isValid()); + 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 46a15109..966009f6 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,12 @@ void clientCredentials_failureAsApiException() { requestBuilder.build().headers().firstValue("Authorization").isPresent()); } - @Test - void clientCredentials_setsAuthHeader() throws Exception { + // 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 { String clientId = "some-client-id"; String clientSecret = "some-client-secret"; String apiAudience = "some-audience"; @@ -202,7 +208,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,7 +221,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); @@ -222,7 +232,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,6 +242,38 @@ void clientCredentials_setsAuthHeader() throws Exception { mockHttpClient .verify() .post(String.format("%s/oauth/token", FgaConstants.TEST_ISSUER_URL)) + .called(exchanges); + } + + @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\":3600}"); + ApiClient apiClient = new ApiClient(mockHttpClientBuilder(mockHttpClient)); + + ClientCredentials credentials = new ClientCredentials() + .clientId("client") + .clientSecret("secret") + .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL); + + 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(), 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(1); } 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."); + } }