From 11ab8e40fc4d9b5403f4a03271da7676a859407f Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Tue, 21 Apr 2026 16:38:53 -0600 Subject: [PATCH 01/11] fix: Issue #330 added ApiClient#applyAuthHeader as a single entry point for attaching OAuth2 Authorization header. Includes lazy OAuth2Client initialization on detected CLIENT_CREDENTIALS use. --- .../dev/openfga/sdk/api/BaseStreamingApi.java | 2 + .../java/dev/openfga/sdk/api/OpenFgaApi.java | 38 +------- .../dev/openfga/sdk/api/client/ApiClient.java | 71 +++++++++++++++ .../api/client/ApiExecutorRequestBuilder.java | 5 +- .../openfga/sdk/api/client/ApiClientTest.java | 91 +++++++++++++++++++ .../sdk/api/client/ApiExecutorTest.java | 75 +++++++++++++++ .../sdk/api/client/OpenFgaClientTest.java | 8 +- .../api/client/StreamingApiExecutorTest.java | 34 +++++++ 8 files changed, 280 insertions(+), 44 deletions(-) diff --git a/src/main/java/dev/openfga/sdk/api/BaseStreamingApi.java b/src/main/java/dev/openfga/sdk/api/BaseStreamingApi.java index 52b25a6d..afdcfc1f 100644 --- a/src/main/java/dev/openfga/sdk/api/BaseStreamingApi.java +++ b/src/main/java/dev/openfga/sdk/api/BaseStreamingApi.java @@ -168,6 +168,8 @@ protected HttpRequest buildHttpRequest(String method, String path, Object body, byte[] bodyBytes = objectMapper.writeValueAsBytes(body); HttpRequest.Builder requestBuilder = ApiClient.requestBuilder(method, path, bodyBytes, configuration); + apiClient.applyAuthHeader(requestBuilder, configuration); + // Apply request interceptors if any var interceptor = apiClient.getRequestInterceptor(); if (interceptor != null) { diff --git a/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java b/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java index f9b780c2..33518ca4 100644 --- a/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java +++ b/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java @@ -15,7 +15,6 @@ import static dev.openfga.sdk.util.StringUtil.isNullOrWhitespace; import static dev.openfga.sdk.util.Validation.assertParamExists; -import dev.openfga.sdk.api.auth.*; import dev.openfga.sdk.api.client.*; import dev.openfga.sdk.api.configuration.*; import dev.openfga.sdk.api.model.BatchCheckRequest; @@ -69,7 +68,6 @@ public class OpenFgaApi { private final Configuration configuration; private final ApiClient apiClient; - private final OAuth2Client oAuth2Client; private final Telemetry telemetry; public OpenFgaApi(Configuration configuration) throws FgaInvalidParameterException { @@ -89,12 +87,6 @@ public OpenFgaApi(Configuration configuration, ApiClient apiClient, Telemetry te this.configuration = configuration; this.telemetry = telemetry; - if (configuration.getCredentials().getCredentialsMethod() == CredentialsMethod.CLIENT_CREDENTIALS) { - this.oAuth2Client = new OAuth2Client(configuration, apiClient); - } else { - this.oAuth2Client = null; - } - var defaultHeaders = configuration.getDefaultHeaders(); if (defaultHeaders != null) { apiClient.addRequestInterceptor(httpRequest -> defaultHeaders.forEach(httpRequest::setHeader)); @@ -1294,10 +1286,7 @@ private HttpRequest buildHttpRequestWithPublisher( httpRequest.header("Content-Type", "application/json"); httpRequest.header("Accept", "application/json"); - if (configuration.getCredentials().getCredentialsMethod() != CredentialsMethod.NONE) { - String accessToken = getAccessToken(configuration); - httpRequest.header("Authorization", "Bearer " + accessToken); - } + apiClient.applyAuthHeader(httpRequest, configuration); if (configuration.getUserAgent() != null) { httpRequest.header("User-Agent", configuration.getUserAgent()); @@ -1337,29 +1326,4 @@ private String pathWithParams(String basePath, Object... params) { } return path.toString(); } - - /** - * Get an access token. Expects that configuration is valid (meaning it can - * pass {@link Configuration#assertValid()}) and expects that if the - * CredentialsMethod is CLIENT_CREDENTIALS that a valid {@link OAuth2Client} - * has been initialized. Otherwise, it will throw an IllegalStateException. - * @throws IllegalStateException when the configuration is invalid - */ - private String getAccessToken(Configuration configuration) throws ApiException { - CredentialsMethod credentialsMethod = configuration.getCredentials().getCredentialsMethod(); - - if (credentialsMethod == CredentialsMethod.API_TOKEN) { - return configuration.getCredentials().getApiToken().getToken(); - } - - if (credentialsMethod == CredentialsMethod.CLIENT_CREDENTIALS) { - try { - return oAuth2Client.getAccessToken().get(); - } catch (Exception e) { - throw new ApiException(e); - } - } - - throw new IllegalStateException("Configuration is invalid."); - } } 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 6f247181..a742d52e 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -7,7 +7,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import dev.openfga.sdk.api.auth.OAuth2Client; import dev.openfga.sdk.api.configuration.Configuration; +import dev.openfga.sdk.api.configuration.Credentials; +import dev.openfga.sdk.api.configuration.CredentialsMethod; +import dev.openfga.sdk.errors.ApiException; import dev.openfga.sdk.errors.FgaInvalidParameterException; import dev.openfga.sdk.util.StringUtil; import java.io.InputStream; @@ -17,6 +21,8 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.openapitools.jackson.nullable.JsonNullableModule; @@ -41,6 +47,7 @@ public class ApiClient { private Consumer interceptor; private Consumer> responseInterceptor; private Consumer> asyncResponseInterceptor; + private final AtomicReference oAuth2Client = new AtomicReference<>(); /** * Create an instance of ApiClient. @@ -324,4 +331,68 @@ public ApiClient setAsyncResponseInterceptor(Consumer> inte public Consumer> getAsyncResponseInterceptor() { return asyncResponseInterceptor; } + + /** + * Applies the {@code Authorization: Bearer } header to the request builder based on the + * supplied configuration's {@link Credentials}. This is the single entry point for attaching + * auth to outbound requests across the SDK — every request builder should delegate here. + * + *
    + *
  • {@link CredentialsMethod#NONE}: no header is applied.
  • + *
  • {@link CredentialsMethod#API_TOKEN}: the static API token from the configuration is used.
  • + *
  • {@link CredentialsMethod#CLIENT_CREDENTIALS}: an {@link OAuth2Client} performs the + * client-credentials exchange and caches the token on this {@code ApiClient} until expiry. + * The client is lazily created from {@code configuration} on first use.
  • + *
+ * + * @param requestBuilder the request builder to mutate. + * @param configuration the configuration that supplies credentials. + * @throws ApiException if CLIENT_CREDENTIALS token exchange fails. + * @throws FgaInvalidParameterException if the configuration is invalid when lazily creating + * an {@link OAuth2Client}. + */ + public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration configuration) + throws ApiException, FgaInvalidParameterException { + + Credentials credentials = configuration.getCredentials(); + if (credentials == null) { + return; + } + + CredentialsMethod method = credentials.getCredentialsMethod(); + if (method == null || method == CredentialsMethod.NONE) { + return; + } + + String accessToken; + switch (method) { + case API_TOKEN: + accessToken = credentials.getApiToken().getToken(); + break; + case CLIENT_CREDENTIALS: + try { + accessToken = + ensureOAuth2Client(configuration).getAccessToken().get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ApiException(e); + } catch (ExecutionException e) { + throw new ApiException(e); + } + break; + default: + throw new IllegalStateException("Unknown credentials method: " + method); + } + + requestBuilder.header("Authorization", "Bearer " + accessToken); + } + + private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException { + OAuth2Client existing = oAuth2Client.get(); + if (existing != null) { + return existing; + } + OAuth2Client created = new OAuth2Client(configuration, this); + return oAuth2Client.compareAndSet(null, created) ? created : oAuth2Client.get(); + } } diff --git a/src/main/java/dev/openfga/sdk/api/client/ApiExecutorRequestBuilder.java b/src/main/java/dev/openfga/sdk/api/client/ApiExecutorRequestBuilder.java index 1901f6d9..9cb287b6 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiExecutorRequestBuilder.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiExecutorRequestBuilder.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import dev.openfga.sdk.api.configuration.ClientConfiguration; import dev.openfga.sdk.api.configuration.Configuration; +import dev.openfga.sdk.errors.ApiException; import dev.openfga.sdk.errors.FgaInvalidParameterException; import dev.openfga.sdk.util.StringUtil; import java.net.http.HttpRequest; @@ -192,7 +193,7 @@ String buildPath(Configuration configuration) { * Package-private — used by {@link ApiExecutor} and {@link StreamingApiExecutor}. */ HttpRequest buildHttpRequest(Configuration configuration, ApiClient apiClient) - throws FgaInvalidParameterException, JsonProcessingException { + throws ApiException, FgaInvalidParameterException, JsonProcessingException { String resolvedPath = buildPath(configuration); HttpRequest.Builder httpRequestBuilder; @@ -207,6 +208,8 @@ HttpRequest buildHttpRequest(Configuration configuration, ApiClient apiClient) headers.forEach(httpRequestBuilder::header); + apiClient.applyAuthHeader(httpRequestBuilder, configuration); + if (apiClient.getRequestInterceptor() != null) { apiClient.getRequestInterceptor().accept(httpRequestBuilder); } 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 009fe20a..31576a7f 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -1,9 +1,19 @@ package dev.openfga.sdk.api.client; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import com.pgssoft.httpclient.HttpClientMock; +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.Credentials; +import dev.openfga.sdk.constants.FgaConstants; import java.net.http.HttpClient; +import java.net.http.HttpRequest; import org.junit.jupiter.api.Test; class ApiClientTest { @@ -37,4 +47,85 @@ public void customHttpClientWithHttp2() { ; assertEquals(apiClient.getHttpClient().version(), HttpClient.Version.HTTP_2); } + + @Test + public void applyAuthHeader_none_skipsHeader() throws Exception { + Configuration configuration = + new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(new Credentials()); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); + + new ApiClient().applyAuthHeader(requestBuilder, configuration); + + assertFalse(requestBuilder.build().headers().firstValue("Authorization").isPresent()); + } + + @Test + public void applyAuthHeader_apiToken_setsBearerHeader() throws Exception { + String token = "static-api-token"; + Configuration configuration = + new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(new Credentials(new ApiToken(token))); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); + + new ApiClient().applyAuthHeader(requestBuilder, configuration); + + assertEquals( + "Bearer " + token, + requestBuilder.build().headers().firstValue("Authorization").orElseThrow()); + } + + @Test + public void applyAuthHeader_clientCredentials_exchangesAndSetsBearerHeader() throws Exception { + String clientId = "some-client-id"; + String clientSecret = "some-client-secret"; + String apiAudience = "some-audience"; + String apiTokenIssuer = "oauth2.server"; + String exchangedToken = "exchanged-access-token"; + + HttpClientMock mockHttpClient = new HttpClientMock(); + mockHttpClient + .onPost(String.format("https://%s/oauth/token", apiTokenIssuer)) + .withBody(allOf( + containsString("client_id=" + clientId), + containsString("client_secret=" + clientSecret), + containsString("audience=" + apiAudience), + containsString("grant_type=client_credentials"))) + .doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken)); + + HttpClient.Builder mockBuilder = mockBuilderReturning(mockHttpClient); + ApiClient apiClient = new ApiClient(mockBuilder); + + Configuration configuration = new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ClientCredentials() + .clientId(clientId) + .clientSecret(clientSecret) + .apiAudience(apiAudience) + .apiTokenIssuer(apiTokenIssuer))); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); + apiClient.applyAuthHeader(requestBuilder, configuration); + + assertEquals( + "Bearer " + exchangedToken, + requestBuilder.build().headers().firstValue("Authorization").orElseThrow()); + + // A second call should reuse the cached token and not hit the issuer again. + HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); + apiClient.applyAuthHeader(secondBuilder, configuration); + assertEquals( + "Bearer " + exchangedToken, + secondBuilder.build().headers().firstValue("Authorization").orElseThrow()); + mockHttpClient + .verify() + .post(String.format("https://%s/oauth/token", apiTokenIssuer)) + .called(1); + } + + private static HttpClient.Builder mockBuilderReturning(HttpClient client) { + HttpClient.Builder builder = org.mockito.Mockito.mock(HttpClient.Builder.class); + org.mockito.Mockito.when(builder.build()).thenReturn(client); + org.mockito.Mockito.when(builder.executor(org.mockito.ArgumentMatchers.any())) + .thenReturn(builder); + return builder; + } } diff --git a/src/test/java/dev/openfga/sdk/api/client/ApiExecutorTest.java b/src/test/java/dev/openfga/sdk/api/client/ApiExecutorTest.java index af64a9ed..57b43391 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiExecutorTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiExecutorTest.java @@ -6,7 +6,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import dev.openfga.sdk.api.configuration.ApiToken; import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.api.configuration.ClientCredentials; +import dev.openfga.sdk.api.configuration.Credentials; import dev.openfga.sdk.errors.FgaError; import dev.openfga.sdk.errors.FgaInvalidParameterException; import java.util.HashMap; @@ -382,6 +385,78 @@ public void rawApi_throwsExceptionForNullResponseType() throws Exception { assertThrows(IllegalArgumentException.class, () -> client.apiExecutor().send(request, null)); } + @Test + public void rawApi_appliesApiTokenAuthHeader() throws Exception { + String apiToken = "static-api-token"; + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withHeader("Authorization", equalTo("Bearer " + apiToken)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}"))); + + ClientConfiguration config = new ClientConfiguration() + .apiUrl(fgaApiUrl) + .storeId(DEFAULT_STORE_ID) + .credentials(new Credentials(new ApiToken(apiToken))); + OpenFgaClient client = new OpenFgaClient(config); + + ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.GET, EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .build(); + + ApiResponse response = + client.apiExecutor().send(request, ExperimentalResponse.class).get(); + + assertEquals(200, response.getStatusCode()); + verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withHeader("Authorization", equalTo("Bearer " + apiToken))); + } + + @Test + public void rawApi_appliesClientCredentialsAuthHeader() throws Exception { + String clientId = "some-client-id"; + String clientSecret = "some-client-secret"; + String apiAudience = "some-audience"; + String exchangedToken = "exchanged-access-token"; + + stubFor(post(urlEqualTo("/oauth/token")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken)))); + stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withHeader("Authorization", equalTo("Bearer " + exchangedToken)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}"))); + + ClientConfiguration config = new ClientConfiguration() + .apiUrl(fgaApiUrl) + .storeId(DEFAULT_STORE_ID) + .credentials(new Credentials(new ClientCredentials() + .clientId(clientId) + .clientSecret(clientSecret) + .apiAudience(apiAudience) + .apiTokenIssuer(fgaApiUrl))); + OpenFgaClient client = new OpenFgaClient(config); + + ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.GET, EXPERIMENTAL_ENDPOINT) + .pathParam("store_id", DEFAULT_STORE_ID) + .build(); + + ApiResponse response = + client.apiExecutor().send(request, ExperimentalResponse.class).get(); + + assertEquals(200, response.getStatusCode()); + verify(postRequestedFor(urlEqualTo("/oauth/token")) + .withRequestBody(containing("client_id=" + clientId)) + .withRequestBody(containing("grant_type=client_credentials"))); + verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature")) + .withHeader("Authorization", equalTo("Bearer " + exchangedToken))); + } + @Test public void twoParamConstructor_shouldCreateWithOwnTelemetry() throws Exception { // Verifies the backward-compatible 2-param constructor works diff --git a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java index 138db6fc..c2791f03 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -72,6 +72,7 @@ public void beforeEachTest() throws Exception { var mockHttpClientBuilder = mock(HttpClient.Builder.class); when(mockHttpClientBuilder.executor(any())).thenReturn(mockHttpClientBuilder); + when(mockHttpClientBuilder.connectTimeout(any())).thenReturn(mockHttpClientBuilder); when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); clientConfiguration = new ClientConfiguration() @@ -83,12 +84,7 @@ public void beforeEachTest() throws Exception { .maxRetries(FgaConstants.DEFAULT_MAX_RETRY) .minimumRetryDelay(FgaConstants.DEFAULT_MIN_WAIT_IN_MS); - var mockApiClient = mock(ApiClient.class); - when(mockApiClient.getHttpClient()).thenReturn(mockHttpClient); - when(mockApiClient.getObjectMapper()).thenReturn(new ObjectMapper()); - when(mockApiClient.getHttpClientBuilder()).thenReturn(mockHttpClientBuilder); - - fga = new OpenFgaClient(clientConfiguration, mockApiClient); + fga = new OpenFgaClient(clientConfiguration, new ApiClient(mockHttpClientBuilder, new ObjectMapper())); } /* ****************** diff --git a/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java b/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java index 30b3e970..c073b20d 100644 --- a/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java @@ -6,7 +6,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfga.sdk.api.configuration.ApiToken; import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.api.configuration.Configuration; import dev.openfga.sdk.api.configuration.Credentials; import dev.openfga.sdk.api.model.ListObjectsRequest; import dev.openfga.sdk.api.model.StreamResult; @@ -355,6 +357,38 @@ public void stream_storeIdRequired() throws Exception { // Chaining & CompletableFuture semantics // ----------------------------------------------------------------------- + @Test + public void stream_appliesApiTokenAuthHeader() throws Exception { + // Regression guard for openfga/java-sdk#330: the streaming path must attach + // Authorization: Bearer when the configuration uses API_TOKEN credentials. + String apiToken = "stream-api-token"; + ClientConfiguration authConfig = new ClientConfiguration() + .storeId(DEFAULT_STORE_ID) + .authorizationModelId(DEFAULT_AUTH_MODEL_ID) + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ApiToken(apiToken))) + .readTimeout(Duration.ofMillis(250)); + doCallRealMethod() + .when(mockApiClient) + .applyAuthHeader(any(HttpRequest.Builder.class), any(Configuration.class)); + OpenFgaClient authFga = new OpenFgaClient(authConfig, mockApiClient); + + Stream lines = Stream.of("{\"result\":{\"object\":\"document:1\"}}"); + HttpResponse> mockResponse = mockStreamResponse(200, lines); + when(mockHttpClient.>sendAsync(any(), any())) + .thenReturn(CompletableFuture.completedFuture(mockResponse)); + + authFga.streamingApiExecutor(StreamedListObjectsResponse.class).stream( + buildStreamedListObjectsRequest(), obj -> {}) + .get(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mockHttpClient, times(1)).sendAsync(captor.capture(), any()); + assertEquals( + "Bearer " + apiToken, + captor.getValue().headers().firstValue("Authorization").orElse(null)); + } + @Test public void stream_supportsChaining() throws Exception { Stream lines = From f9f2e4141649d636163afd3a905b2f60e94017ae Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Wed, 22 Apr 2026 10:54:37 -0600 Subject: [PATCH 02/11] fix: #330 use setHeader on request builder to address duplicate Authorization header --- src/main/java/dev/openfga/sdk/api/client/ApiClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a742d52e..6bb36fab 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -384,7 +384,7 @@ public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration co throw new IllegalStateException("Unknown credentials method: " + method); } - requestBuilder.header("Authorization", "Bearer " + accessToken); + requestBuilder.setHeader("Authorization", "Bearer " + accessToken); } private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException { From 026f2699ca526005a088320c7afa7c3a2a8d429d Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Wed, 22 Apr 2026 11:59:05 -0600 Subject: [PATCH 03/11] test: #330 improve coverage on ApiClient changeset --- .../openfga/sdk/api/client/ApiClientTest.java | 247 ++++++++++++------ 1 file changed, 171 insertions(+), 76 deletions(-) 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 31576a7f..7987a87b 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.pgssoft.httpclient.HttpClientMock; import dev.openfga.sdk.api.configuration.ApiToken; @@ -12,9 +13,15 @@ import dev.openfga.sdk.api.configuration.Configuration; import dev.openfga.sdk.api.configuration.Credentials; import dev.openfga.sdk.constants.FgaConstants; +import dev.openfga.sdk.errors.ApiException; +import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; +import java.util.List; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; class ApiClientTest { @@ -48,84 +55,172 @@ public void customHttpClientWithHttp2() { assertEquals(apiClient.getHttpClient().version(), HttpClient.Version.HTTP_2); } - @Test - public void applyAuthHeader_none_skipsHeader() throws Exception { - Configuration configuration = - new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(new Credentials()); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); - - new ApiClient().applyAuthHeader(requestBuilder, configuration); - - assertFalse(requestBuilder.build().headers().firstValue("Authorization").isPresent()); - } - - @Test - public void applyAuthHeader_apiToken_setsBearerHeader() throws Exception { - String token = "static-api-token"; - Configuration configuration = - new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(new Credentials(new ApiToken(token))); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); - - new ApiClient().applyAuthHeader(requestBuilder, configuration); - - assertEquals( - "Bearer " + token, - requestBuilder.build().headers().firstValue("Authorization").orElseThrow()); - } - - @Test - public void applyAuthHeader_clientCredentials_exchangesAndSetsBearerHeader() throws Exception { - String clientId = "some-client-id"; - String clientSecret = "some-client-secret"; - String apiAudience = "some-audience"; - String apiTokenIssuer = "oauth2.server"; - String exchangedToken = "exchanged-access-token"; - - HttpClientMock mockHttpClient = new HttpClientMock(); - mockHttpClient - .onPost(String.format("https://%s/oauth/token", apiTokenIssuer)) - .withBody(allOf( - containsString("client_id=" + clientId), - containsString("client_secret=" + clientSecret), - containsString("audience=" + apiAudience), - containsString("grant_type=client_credentials"))) - .doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken)); - - HttpClient.Builder mockBuilder = mockBuilderReturning(mockHttpClient); - ApiClient apiClient = new ApiClient(mockBuilder); - - Configuration configuration = new Configuration() - .apiUrl(FgaConstants.TEST_API_URL) - .credentials(new Credentials(new ClientCredentials() - .clientId(clientId) - .clientSecret(clientSecret) - .apiAudience(apiAudience) - .apiTokenIssuer(apiTokenIssuer))); - - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); - apiClient.applyAuthHeader(requestBuilder, configuration); - - assertEquals( - "Bearer " + exchangedToken, - requestBuilder.build().headers().firstValue("Authorization").orElseThrow()); - - // A second call should reuse the cached token and not hit the issuer again. - HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example")); - apiClient.applyAuthHeader(secondBuilder, configuration); - assertEquals( - "Bearer " + exchangedToken, - secondBuilder.build().headers().firstValue("Authorization").orElseThrow()); - mockHttpClient - .verify() - .post(String.format("https://%s/oauth/token", apiTokenIssuer)) - .called(1); + @Nested + class ApplyAuthHeader { + + @Test + void none_skipsHeader() throws Exception { + Configuration configuration = + new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(new Credentials()); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + + new ApiClient().applyAuthHeader(requestBuilder, configuration); + + assertFalse( + requestBuilder.build().headers().firstValue("Authorization").isPresent()); + } + + @Test + void nullCredentials_skipsHeader() throws Exception { + Configuration configuration = Mockito.mock(Configuration.class); + Mockito.when(configuration.getCredentials()).thenReturn(null); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + + new ApiClient().applyAuthHeader(requestBuilder, configuration); + + assertFalse( + requestBuilder.build().headers().firstValue("Authorization").isPresent()); + } + + @Test + void nullMethod_skipsHeader() throws Exception { + Credentials credentials = new Credentials(); + credentials.setCredentialsMethod(null); + Configuration configuration = + new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(credentials); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + + new ApiClient().applyAuthHeader(requestBuilder, configuration); + + assertFalse( + requestBuilder.build().headers().firstValue("Authorization").isPresent()); + } + + @Test + void apiToken_setsAuthHeader() throws Exception { + String token = "static-api-token"; + Configuration configuration = new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ApiToken(token))); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + + new ApiClient().applyAuthHeader(requestBuilder, configuration); + + assertEquals( + "Bearer " + token, + requestBuilder.build().headers().firstValue("Authorization").orElseThrow()); + } + + /* + * Regression test for #330: applying auth a second time must replace, not append, + * the Authorization header so retried requests don't ship with duplicates. + */ + @Test + void apiToken_replaceExistingAuthHeader() throws Exception { + String firstToken = "first-token"; + String secondToken = "second-token"; + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + + ApiClient apiClient = new ApiClient(); + apiClient.applyAuthHeader( + requestBuilder, + new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ApiToken(firstToken)))); + + apiClient.applyAuthHeader( + requestBuilder, + new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ApiToken(secondToken)))); + + List authHeaders = requestBuilder.build().headers().allValues("Authorization"); + assertEquals(1, authHeaders.size()); + assertEquals("Bearer " + secondToken, authHeaders.get(0)); + } + + @Test + void clientCredentials_failureAsApiException() { + HttpClientMock mockHttpClient = new HttpClientMock(); + mockHttpClient + .onPost(String.format("%s/oauth/token", FgaConstants.TEST_ISSUER_URL)) + .doReturnStatus(401); + + HttpClient.Builder mockBuilder = mockHttpClientBuilder(mockHttpClient); + ApiClient apiClient = new ApiClient(mockBuilder); + + Configuration configuration = new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .maxRetries(0) + .credentials(new Credentials(new ClientCredentials() + .clientId("cid") + .clientSecret("secret") + .apiAudience("aud") + .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL))); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + + assertThrows(ApiException.class, () -> apiClient.applyAuthHeader(requestBuilder, configuration)); + assertFalse( + requestBuilder.build().headers().firstValue("Authorization").isPresent()); + } + + @Test + void clientCredentials_setsAuthHeader() throws Exception { + String clientId = "some-client-id"; + String clientSecret = "some-client-secret"; + String apiAudience = "some-audience"; + String exchangedToken = "exchanged-access-token"; + + HttpClientMock mockHttpClient = new HttpClientMock(); + mockHttpClient + .onPost(String.format("%s/oauth/token", FgaConstants.TEST_ISSUER_URL)) + .withBody(allOf( + containsString("client_id=" + clientId), + containsString("client_secret=" + clientSecret), + containsString("audience=" + apiAudience), + containsString("grant_type=client_credentials"))) + .doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken)); + + HttpClient.Builder mockBuilder = mockHttpClientBuilder(mockHttpClient); + ApiClient apiClient = new ApiClient(mockBuilder); + + Configuration configuration = new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ClientCredentials() + .clientId(clientId) + .clientSecret(clientSecret) + .apiAudience(apiAudience) + .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL))); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + apiClient.applyAuthHeader(requestBuilder, configuration); + + assertEquals( + "Bearer " + exchangedToken, + requestBuilder.build().headers().firstValue("Authorization").orElseThrow()); + + // A second call should reuse the cached token and not hit the issuer again. + HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + apiClient.applyAuthHeader(secondBuilder, configuration); + assertEquals( + "Bearer " + exchangedToken, + secondBuilder.build().headers().firstValue("Authorization").orElseThrow()); + + mockHttpClient + .verify() + .post(String.format("%s/oauth/token", FgaConstants.TEST_ISSUER_URL)) + .called(1); + } } - private static HttpClient.Builder mockBuilderReturning(HttpClient client) { - HttpClient.Builder builder = org.mockito.Mockito.mock(HttpClient.Builder.class); - org.mockito.Mockito.when(builder.build()).thenReturn(client); - org.mockito.Mockito.when(builder.executor(org.mockito.ArgumentMatchers.any())) - .thenReturn(builder); + private static HttpClient.Builder mockHttpClientBuilder(HttpClient client) { + HttpClient.Builder builder = Mockito.mock(HttpClient.Builder.class); + Mockito.when(builder.build()).thenReturn(client); + Mockito.when(builder.executor(ArgumentMatchers.any())).thenReturn(builder); return builder; } } From 06b048a1151e53de48037d5e0ed2b2377e4e8082 Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Wed, 22 Apr 2026 13:28:16 -0600 Subject: [PATCH 04/11] fix: #330 defensively pivot to concurrent map of OAuth2Client to protect multi-tenant credentials --- .../dev/openfga/sdk/api/client/ApiClient.java | 47 ++++++++++++++++-- .../openfga/sdk/api/client/ApiClientTest.java | 48 +++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) 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 6bb36fab..3f14ec6b 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import dev.openfga.sdk.api.auth.OAuth2Client; +import dev.openfga.sdk.api.configuration.ClientCredentials; import dev.openfga.sdk.api.configuration.Configuration; import dev.openfga.sdk.api.configuration.Credentials; import dev.openfga.sdk.api.configuration.CredentialsMethod; @@ -21,8 +22,10 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.openapitools.jackson.nullable.JsonNullableModule; @@ -47,7 +50,7 @@ public class ApiClient { private Consumer interceptor; private Consumer> responseInterceptor; private Consumer> asyncResponseInterceptor; - private final AtomicReference oAuth2Client = new AtomicReference<>(); + private final ConcurrentMap oAuth2Clients = new ConcurrentHashMap<>(); /** * Create an instance of ApiClient. @@ -388,11 +391,47 @@ public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration co } private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException { - OAuth2Client existing = oAuth2Client.get(); + ClientCredentials cc = configuration.getCredentials().getClientCredentials(); + CredentialsCacheKey key = new CredentialsCacheKey(cc); + OAuth2Client existing = oAuth2Clients.get(key); if (existing != null) { return existing; } OAuth2Client created = new OAuth2Client(configuration, this); - return oAuth2Client.compareAndSet(null, created) ? created : oAuth2Client.get(); + OAuth2Client prior = oAuth2Clients.putIfAbsent(key, created); + return prior != null ? prior : created; + } + + private static final class CredentialsCacheKey { + private final String clientId; + private final String clientSecret; + private final String apiTokenIssuer; + private final String apiAudience; + private final String scopes; + + CredentialsCacheKey(ClientCredentials cc) { + this.clientId = cc.getClientId(); + this.clientSecret = cc.getClientSecret(); + this.apiTokenIssuer = cc.getApiTokenIssuer(); + this.apiAudience = cc.getApiAudience(); + this.scopes = cc.getScopes(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CredentialsCacheKey)) return false; + CredentialsCacheKey that = (CredentialsCacheKey) o; + return Objects.equals(clientId, that.clientId) + && Objects.equals(clientSecret, that.clientSecret) + && Objects.equals(apiTokenIssuer, that.apiTokenIssuer) + && Objects.equals(apiAudience, that.apiAudience) + && Objects.equals(scopes, that.scopes); + } + + @Override + public int hashCode() { + return Objects.hash(clientId, clientSecret, apiTokenIssuer, apiAudience, scopes); + } } } 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 7987a87b..a28bd6f2 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -215,6 +215,54 @@ void clientCredentials_setsAuthHeader() throws Exception { .post(String.format("%s/oauth/token", FgaConstants.TEST_ISSUER_URL)) .called(1); } + + @Test + void clientCredentials_differentCredentials_exchangeSeparateTokens() throws Exception { + String tokenA = "token-for-tenant-a"; + String tokenB = "token-for-tenant-b"; + String issuerA = FgaConstants.TEST_ISSUER_URL; + String issuerB = "https://issuer-b.example.com"; + + HttpClientMock mockHttpClient = new HttpClientMock(); + mockHttpClient + .onPost(issuerA + "/oauth/token") + .withBody(containsString("client_id=client-a")) + .doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", tokenA)); + mockHttpClient + .onPost(issuerB + "/oauth/token") + .withBody(containsString("client_id=client-b")) + .doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", tokenB)); + + ApiClient apiClient = new ApiClient(mockHttpClientBuilder(mockHttpClient)); + + Configuration configA = new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ClientCredentials() + .clientId("client-a") + .clientSecret("secret-a") + .apiAudience("audience-a") + .apiTokenIssuer(issuerA))); + + Configuration configB = new Configuration() + .apiUrl(FgaConstants.TEST_API_URL) + .credentials(new Credentials(new ClientCredentials() + .clientId("client-b") + .clientSecret("secret-b") + .apiAudience("audience-b") + .apiTokenIssuer(issuerB))); + + HttpRequest.Builder requestA = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + apiClient.applyAuthHeader(requestA, configA); + assertEquals("Bearer " + tokenA, requestA.build().headers().firstValue("Authorization").orElseThrow()); + + HttpRequest.Builder requestB = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); + apiClient.applyAuthHeader(requestB, configB); + assertEquals("Bearer " + tokenB, requestB.build().headers().firstValue("Authorization").orElseThrow()); + + // Each issuer is hit exactly once — no cross-contamination. + mockHttpClient.verify().post(issuerA + "/oauth/token").called(1); + mockHttpClient.verify().post(issuerB + "/oauth/token").called(1); + } } private static HttpClient.Builder mockHttpClientBuilder(HttpClient client) { From 5a7754e30f4f1e77a9e6351c3afc7e1d569a7038 Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Wed, 22 Apr 2026 13:29:14 -0600 Subject: [PATCH 05/11] fix: #330 do not keep raw client secret value in cached keys on heap --- .../dev/openfga/sdk/api/client/ApiClient.java | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 3f14ec6b..73bc20c7 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -21,7 +21,10 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.util.Arrays; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -404,26 +407,35 @@ private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaI private static final class CredentialsCacheKey { private final String clientId; - private final String clientSecret; + private final byte[] clientSecretHash; private final String apiTokenIssuer; private final String apiAudience; private final String scopes; CredentialsCacheKey(ClientCredentials cc) { this.clientId = cc.getClientId(); - this.clientSecret = cc.getClientSecret(); + this.clientSecretHash = sha256(cc.getClientSecret()); this.apiTokenIssuer = cc.getApiTokenIssuer(); this.apiAudience = cc.getApiAudience(); this.scopes = cc.getScopes(); } + private static byte[] sha256(String value) { + try { + return MessageDigest.getInstance("SHA-256") + .digest(value == null ? new byte[0] : value.getBytes(UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof CredentialsCacheKey)) return false; CredentialsCacheKey that = (CredentialsCacheKey) o; return Objects.equals(clientId, that.clientId) - && Objects.equals(clientSecret, that.clientSecret) + && Arrays.equals(clientSecretHash, that.clientSecretHash) && Objects.equals(apiTokenIssuer, that.apiTokenIssuer) && Objects.equals(apiAudience, that.apiAudience) && Objects.equals(scopes, that.scopes); @@ -431,7 +443,9 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(clientId, clientSecret, apiTokenIssuer, apiAudience, scopes); + int result = Objects.hash(clientId, apiTokenIssuer, apiAudience, scopes); + result = 31 * result + Arrays.hashCode(clientSecretHash); + return result; } } } From bb21de72db4e121df66f43b037338964aa030f61 Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Wed, 22 Apr 2026 15:22:09 -0600 Subject: [PATCH 06/11] fix: #330 address check-then-act race for access token using single-flight pattern in OAuth2Client --- .../openfga/sdk/api/auth/OAuth2Client.java | 54 ++++++++++++++----- .../openfga/sdk/api/auth/TokenSnapshot.java | 39 ++++++++++++++ .../openfga/sdk/api/auth/AccessTokenTest.java | 8 ++- .../sdk/api/auth/OAuth2ClientTest.java | 42 +++++++++++++++ 4 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java 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 21f17c5a..b5dd5929 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -11,13 +11,15 @@ import java.time.Instant; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.CompletableFuture; public class OAuth2Client { private static final String DEFAULT_API_TOKEN_ISSUER_PATH = "/oauth/token"; private final ApiClient apiClient; - private final AccessToken token = new AccessToken(); + private final AtomicReference snapshot = new AtomicReference<>(TokenSnapshot.EMPTY); + private final AtomicReference> inFlight = new AtomicReference<>(); private final CredentialsFlowRequest authRequest; private final Configuration config; private final Telemetry telemetry; @@ -45,26 +47,54 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga } /** - * Gets an access token, handling exchange when necessary. The access token is naively cached in memory until it - * expires. + * Gets an access token, handling exchange when necessary. The token is cached as an immutable + * snapshot until it expires. Concurrent calls are deduplicated: only one exchange is in flight + * at a time; other callers join the same future rather than issuing redundant requests. * * @return An access token in a {@link CompletableFuture} */ public CompletableFuture getAccessToken() throws FgaInvalidParameterException, ApiException { - if (!token.isValid()) { - return exchangeToken().thenCompose(response -> { - token.setToken(response.getAccessToken()); - token.setExpiresAt(Instant.now().plusSeconds(response.getExpiresInSeconds())); - - Map attributesMap = new HashMap<>(); + TokenSnapshot current = snapshot.get(); + if (current.isValid()) { + return CompletableFuture.completedFuture(current.token()); + } - telemetry.metrics().credentialsRequest(1L, attributesMap); + CompletableFuture promise = new CompletableFuture<>(); + if (!inFlight.compareAndSet(null, promise)) { + // Another thread won the race — join its exchange rather than starting a new one. + CompletableFuture existing = inFlight.get(); + return existing != null ? existing : getAccessToken(); + } - return CompletableFuture.completedFuture(token.getToken()); + // This thread owns the exchange. Start it, wiring completion back to `promise`. + try { + exchangeToken().whenComplete((response, ex) -> { + if (ex != null) { + inFlight.set(null); + promise.completeExceptionally(ex); + } else { + String token = response.getAccessToken(); + // Write snapshot before clearing the gate so any new caller that arrives + // after inFlight becomes null immediately sees a valid token. + snapshot.set( + new TokenSnapshot( + token, + Instant.now().plusSeconds(response.getExpiresInSeconds()))); + + telemetry.metrics().credentialsRequest(1L, new HashMap<>()); + + // Clear before completing + inFlight.set(null); + promise.complete(token); + } }); + } catch (Exception e) { + inFlight.set(null); + promise.completeExceptionally(e); + throw e; } - return CompletableFuture.completedFuture(token.getToken()); + return promise; } /** diff --git a/src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java b/src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java new file mode 100644 index 00000000..77248640 --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java @@ -0,0 +1,39 @@ +package dev.openfga.sdk.api.auth; + +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; + +/** + * Immutable snapshot of an access token and its expiry time. The snapshot is valid if the token is non-empty + * and the current time is before the expiry time minus a buffer to ensure that callers receive a valid token + * even if there is some clock skew or delay between retrieval and use. + */ +record TokenSnapshot(String token, Instant expiresAt) { + private static final int EXPIRY_BUFFER_SECS = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; + private static final int EXPIRY_JITTER_SECS = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; + + static final TokenSnapshot EMPTY = new TokenSnapshot(null, null); + + TokenSnapshot { + expiresAt = expiresAt != null ? expiresAt.truncatedTo(ChronoUnit.SECONDS) : null; + } + + boolean isValid() { + if (isNullOrWhitespace(token)) { + return false; + } + if (expiresAt == null) { + return true; + } + Instant expiresWithLeeway = expiresAt + .minusSeconds(EXPIRY_BUFFER_SECS) + .minusSeconds(ThreadLocalRandom.current().nextInt(EXPIRY_JITTER_SECS)) + .truncatedTo(ChronoUnit.SECONDS); + + return Instant.now().truncatedTo(ChronoUnit.SECONDS).isBefore(expiresWithLeeway); + } +} 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 6ce192d8..85262068 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java @@ -38,10 +38,8 @@ private static Stream expTimeAndResults() { @MethodSource("expTimeAndResults") @ParameterizedTest(name = "{0}") - public void testTokenValid(String name, Instant exp, boolean valid) { - AccessToken accessToken = new AccessToken(); - accessToken.setToken("token"); - accessToken.setExpiresAt(exp); - assertEquals(valid, accessToken.isValid()); + void testTokenValid(String name, Instant exp, boolean valid) { + TokenSnapshot snapshot = new TokenSnapshot("token", exp); + assertEquals(valid, snapshot.isValid()); } } diff --git a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java index 45c112ed..cd72b4a5 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java @@ -18,6 +18,11 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -166,6 +171,43 @@ public void exchangeOAuth2TokenWithRetriesFailure(WireMockRuntimeInfo wm) throws verify(3, postRequestedFor(urlEqualTo("/oauth/token"))); } + @Test + void exchangeOAuth2Token_concurrentRequests_singleExchange(WireMockRuntimeInfo wm) throws Exception { + // Stub with a delay so concurrent threads pile up before the first exchange completes. + stubFor(post(urlEqualTo("/oauth/token")) + .willReturn(ok(String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", ACCESS_TOKEN)) + .withFixedDelay(100))); + + OAuth2Client client = newOAuth2Client(wm.getHttpBaseUrl(), false); + + int threadCount = 5; + CountDownLatch startGate = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + List tokens = Collections.synchronizedList(new ArrayList<>()); + List failures = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startGate.await(); + tokens.add(client.getAccessToken().get()); + } catch (Exception e) { + failures.add(e); + } finally { + done.countDown(); + } + }).start(); + } + + startGate.countDown(); + assertTrue(done.await(3, TimeUnit.SECONDS), "threads did not complete in time"); + + assertEquals(List.of(), failures, "no thread should have thrown"); + assertEquals(threadCount, tokens.size(), "all threads should have received a token"); + assertTrue(tokens.stream().allMatch(ACCESS_TOKEN::equals), "all threads should have received the same token"); + verify(1, postRequestedFor(urlEqualTo("/oauth/token"))); + } + @Test public void apiTokenIssuer_invalidScheme() { // When From e13bebc41fcbfee94d50e22d962bca9bb51e0543 Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Wed, 22 Apr 2026 22:25:11 -0600 Subject: [PATCH 07/11] chore: lint and formatting compliance --- .../openfga/sdk/api/auth/OAuth2Client.java | 9 ++------- .../dev/openfga/sdk/api/client/ApiClient.java | 3 +-- .../sdk/api/auth/OAuth2ClientTest.java | 19 ++++++++++--------- .../openfga/sdk/api/client/ApiClientTest.java | 8 ++++++-- 4 files changed, 19 insertions(+), 20 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 b5dd5929..ccefd2a8 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -4,15 +4,13 @@ import dev.openfga.sdk.api.configuration.*; import dev.openfga.sdk.errors.ApiException; import dev.openfga.sdk.errors.FgaInvalidParameterException; -import dev.openfga.sdk.telemetry.Attribute; import dev.openfga.sdk.telemetry.Telemetry; import java.net.URI; import java.net.http.HttpRequest; import java.time.Instant; import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; public class OAuth2Client { private static final String DEFAULT_API_TOKEN_ISSUER_PATH = "/oauth/token"; @@ -76,10 +74,7 @@ public CompletableFuture getAccessToken() throws FgaInvalidParameterExce String token = response.getAccessToken(); // Write snapshot before clearing the gate so any new caller that arrives // after inFlight becomes null immediately sees a valid token. - snapshot.set( - new TokenSnapshot( - token, - Instant.now().plusSeconds(response.getExpiresInSeconds()))); + snapshot.set(new TokenSnapshot(token, Instant.now().plusSeconds(response.getExpiresInSeconds()))); telemetry.metrics().credentialsRequest(1L, new HashMap<>()); 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 73bc20c7..3ed41852 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -422,8 +422,7 @@ private static final class CredentialsCacheKey { private static byte[] sha256(String value) { try { - return MessageDigest.getInstance("SHA-256") - .digest(value == null ? new byte[0] : value.getBytes(UTF_8)); + return MessageDigest.getInstance("SHA-256").digest(value == null ? new byte[0] : value.getBytes(UTF_8)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 not available", e); } diff --git a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java index cd72b4a5..b6ff7df0 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java @@ -188,15 +188,16 @@ void exchangeOAuth2Token_concurrentRequests_singleExchange(WireMockRuntimeInfo w for (int i = 0; i < threadCount; i++) { new Thread(() -> { - try { - startGate.await(); - tokens.add(client.getAccessToken().get()); - } catch (Exception e) { - failures.add(e); - } finally { - done.countDown(); - } - }).start(); + try { + startGate.await(); + tokens.add(client.getAccessToken().get()); + } catch (Exception e) { + failures.add(e); + } finally { + done.countDown(); + } + }) + .start(); } startGate.countDown(); 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 a28bd6f2..98d248a6 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -253,11 +253,15 @@ void clientCredentials_differentCredentials_exchangeSeparateTokens() throws Exce HttpRequest.Builder requestA = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); apiClient.applyAuthHeader(requestA, configA); - assertEquals("Bearer " + tokenA, requestA.build().headers().firstValue("Authorization").orElseThrow()); + assertEquals( + "Bearer " + tokenA, + requestA.build().headers().firstValue("Authorization").orElseThrow()); HttpRequest.Builder requestB = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL)); apiClient.applyAuthHeader(requestB, configB); - assertEquals("Bearer " + tokenB, requestB.build().headers().firstValue("Authorization").orElseThrow()); + assertEquals( + "Bearer " + tokenB, + requestB.build().headers().firstValue("Authorization").orElseThrow()); // Each issuer is hit exactly once — no cross-contamination. mockHttpClient.verify().post(issuerA + "/oauth/token").called(1); From dfab5a5f286c57d0bd0bec21b9ce66b53d9f5d7d Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Thu, 23 Apr 2026 10:13:01 -0600 Subject: [PATCH 08/11] fix: #330 address reviewer feedback for managing ApiClient#applyAuthHeader exception propogation --- src/main/java/dev/openfga/sdk/api/client/ApiClient.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 3ed41852..82a2d7ee 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ApiClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/ApiClient.java @@ -383,7 +383,11 @@ public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration co Thread.currentThread().interrupt(); throw new ApiException(e); } catch (ExecutionException e) { - throw new ApiException(e); + Throwable cause = e.getCause(); + if (cause instanceof ApiException) { + throw (ApiException) cause; + } + throw new ApiException(cause != null ? cause : e); } break; default: From 6298cb6af0f7f62c92dbe8631bcd54921e449e27 Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Fri, 24 Apr 2026 11:28:03 -0600 Subject: [PATCH 09/11] fix: #330 consolidate existing AccessToken rather than creating a forked TokenSnapshot Also improve stream API executor tests per reviewer comments. --- .../dev/openfga/sdk/api/auth/AccessToken.java | 37 +++++++----------- .../openfga/sdk/api/auth/OAuth2Client.java | 6 +-- .../openfga/sdk/api/auth/TokenSnapshot.java | 39 ------------------- .../openfga/sdk/api/auth/AccessTokenTest.java | 2 +- .../api/client/StreamingApiExecutorTest.java | 11 +++--- 5 files changed, 23 insertions(+), 72 deletions(-) delete mode 100644 src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java 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 503b940d..3732af0a 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/AccessToken.java +++ b/src/main/java/dev/openfga/sdk/api/auth/AccessToken.java @@ -5,19 +5,25 @@ import dev.openfga.sdk.constants.FgaConstants; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Random; - -class AccessToken { +import java.util.concurrent.ThreadLocalRandom; + +/** + * Immutable snapshot of an access token and its expiry time. The snapshot is valid if the token is non-empty + * and the current time is before the expiry time minus a buffer to ensure that callers receive a valid token + * 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; - private Instant expiresAt; + static final AccessToken EMPTY = new AccessToken(null, null); - private final Random random = new Random(); - private String token; + AccessToken { + expiresAt = expiresAt != null ? expiresAt.truncatedTo(ChronoUnit.SECONDS) : null; + } - public boolean isValid() { + boolean isValid() { if (isNullOrWhitespace(token)) { return false; } @@ -31,24 +37,9 @@ public boolean isValid() { // to account for multiple calls to `isValid` at the same time and prevent multiple refresh calls Instant expiresWithLeeway = expiresAt .minusSeconds(TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC) - .minusSeconds(random.nextInt(TOKEN_EXPIRY_JITTER_IN_SEC)) + .minusSeconds(ThreadLocalRandom.current().nextInt(TOKEN_EXPIRY_JITTER_IN_SEC)) .truncatedTo(ChronoUnit.SECONDS); return Instant.now().truncatedTo(ChronoUnit.SECONDS).isBefore(expiresWithLeeway); } - - public String getToken() { - return token; - } - - public void setExpiresAt(Instant expiresAt) { - if (expiresAt != null) { - // Truncate to seconds to zero out the milliseconds to keep comparison simpler - this.expiresAt = expiresAt.truncatedTo(ChronoUnit.SECONDS); - } - } - - public void setToken(String token) { - this.token = token; - } } 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 ccefd2a8..52301797 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -16,7 +16,7 @@ public class OAuth2Client { private static final String DEFAULT_API_TOKEN_ISSUER_PATH = "/oauth/token"; private final ApiClient apiClient; - private final AtomicReference snapshot = new AtomicReference<>(TokenSnapshot.EMPTY); + private final AtomicReference snapshot = new AtomicReference<>(AccessToken.EMPTY); private final AtomicReference> inFlight = new AtomicReference<>(); private final CredentialsFlowRequest authRequest; private final Configuration config; @@ -52,7 +52,7 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga * @return An access token in a {@link CompletableFuture} */ public CompletableFuture getAccessToken() throws FgaInvalidParameterException, ApiException { - TokenSnapshot current = snapshot.get(); + AccessToken current = snapshot.get(); if (current.isValid()) { return CompletableFuture.completedFuture(current.token()); } @@ -74,7 +74,7 @@ public CompletableFuture getAccessToken() throws FgaInvalidParameterExce String token = response.getAccessToken(); // Write snapshot before clearing the gate so any new caller that arrives // after inFlight becomes null immediately sees a valid token. - snapshot.set(new TokenSnapshot(token, Instant.now().plusSeconds(response.getExpiresInSeconds()))); + snapshot.set(new AccessToken(token, Instant.now().plusSeconds(response.getExpiresInSeconds()))); telemetry.metrics().credentialsRequest(1L, new HashMap<>()); diff --git a/src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java b/src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java deleted file mode 100644 index 77248640..00000000 --- a/src/main/java/dev/openfga/sdk/api/auth/TokenSnapshot.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.openfga.sdk.api.auth; - -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; - -/** - * Immutable snapshot of an access token and its expiry time. The snapshot is valid if the token is non-empty - * and the current time is before the expiry time minus a buffer to ensure that callers receive a valid token - * even if there is some clock skew or delay between retrieval and use. - */ -record TokenSnapshot(String token, Instant expiresAt) { - private static final int EXPIRY_BUFFER_SECS = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC; - private static final int EXPIRY_JITTER_SECS = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC; - - static final TokenSnapshot EMPTY = new TokenSnapshot(null, null); - - TokenSnapshot { - expiresAt = expiresAt != null ? expiresAt.truncatedTo(ChronoUnit.SECONDS) : null; - } - - boolean isValid() { - if (isNullOrWhitespace(token)) { - return false; - } - if (expiresAt == null) { - return true; - } - Instant expiresWithLeeway = expiresAt - .minusSeconds(EXPIRY_BUFFER_SECS) - .minusSeconds(ThreadLocalRandom.current().nextInt(EXPIRY_JITTER_SECS)) - .truncatedTo(ChronoUnit.SECONDS); - - return Instant.now().truncatedTo(ChronoUnit.SECONDS).isBefore(expiresWithLeeway); - } -} 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 85262068..2629133b 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java @@ -39,7 +39,7 @@ private static Stream expTimeAndResults() { @MethodSource("expTimeAndResults") @ParameterizedTest(name = "{0}") void testTokenValid(String name, Instant exp, boolean valid) { - TokenSnapshot snapshot = new TokenSnapshot("token", exp); + AccessToken snapshot = new AccessToken("token", exp); assertEquals(valid, snapshot.isValid()); } } diff --git a/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java b/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java index c073b20d..ab650350 100644 --- a/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfga.sdk.api.configuration.ApiToken; import dev.openfga.sdk.api.configuration.ClientConfiguration; -import dev.openfga.sdk.api.configuration.Configuration; import dev.openfga.sdk.api.configuration.Credentials; import dev.openfga.sdk.api.model.ListObjectsRequest; import dev.openfga.sdk.api.model.StreamResult; @@ -41,13 +40,15 @@ public class StreamingApiExecutorTest { private OpenFgaClient fga; private HttpClient mockHttpClient; + private HttpClient.Builder mockHttpClientBuilder; private ApiClient mockApiClient; @BeforeEach public void beforeEachTest() throws Exception { mockHttpClient = mock(HttpClient.class); - var mockHttpClientBuilder = mock(HttpClient.Builder.class); + mockHttpClientBuilder = mock(HttpClient.Builder.class); when(mockHttpClientBuilder.executor(any())).thenReturn(mockHttpClientBuilder); + when(mockHttpClientBuilder.connectTimeout(any())).thenReturn(mockHttpClientBuilder); when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); ClientConfiguration clientConfiguration = new ClientConfiguration() @@ -368,10 +369,8 @@ public void stream_appliesApiTokenAuthHeader() throws Exception { .apiUrl(FgaConstants.TEST_API_URL) .credentials(new Credentials(new ApiToken(apiToken))) .readTimeout(Duration.ofMillis(250)); - doCallRealMethod() - .when(mockApiClient) - .applyAuthHeader(any(HttpRequest.Builder.class), any(Configuration.class)); - OpenFgaClient authFga = new OpenFgaClient(authConfig, mockApiClient); + ApiClient realApiClient = new ApiClient(mockHttpClientBuilder); + OpenFgaClient authFga = new OpenFgaClient(authConfig, realApiClient); Stream lines = Stream.of("{\"result\":{\"object\":\"document:1\"}}"); HttpResponse> mockResponse = mockStreamResponse(200, lines); From f879fde206b932009798d8bf28f5ea1d8a521e0f Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Fri, 24 Apr 2026 11:30:13 -0600 Subject: [PATCH 10/11] fix: #330 address reviewer input to reorder telemetry call for stability during OAuth2 exchange handling --- src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java | 3 +-- 1 file changed, 1 insertion(+), 2 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 52301797..d5b9911b 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -76,11 +76,10 @@ public CompletableFuture getAccessToken() throws FgaInvalidParameterExce // after inFlight becomes null immediately sees a valid token. snapshot.set(new AccessToken(token, Instant.now().plusSeconds(response.getExpiresInSeconds()))); - telemetry.metrics().credentialsRequest(1L, new HashMap<>()); - // Clear before completing inFlight.set(null); promise.complete(token); + telemetry.metrics().credentialsRequest(1L, new HashMap<>()); } }); } catch (Exception e) { From 93de0d7e554ddf99cd607c9a73262d3e31115476 Mon Sep 17 00:00:00 2001 From: "Port, Charlton" Date: Tue, 28 Apr 2026 12:57:35 -0600 Subject: [PATCH 11/11] fix: #330 address possible redundant token exchange with refactor to strict mutex; includes regression test --- .../openfga/sdk/api/auth/OAuth2Client.java | 70 ++++++---- .../sdk/api/auth/OAuth2ClientTest.java | 129 ++++++++++++++++++ 2 files changed, 169 insertions(+), 30 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 d5b9911b..166f3514 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -52,43 +52,53 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga * @return An access token in a {@link CompletableFuture} */ public CompletableFuture getAccessToken() throws FgaInvalidParameterException, ApiException { + // Fast path (lock-free): return cached token if still valid. AccessToken current = snapshot.get(); if (current.isValid()) { return CompletableFuture.completedFuture(current.token()); } - CompletableFuture promise = new CompletableFuture<>(); - if (!inFlight.compareAndSet(null, promise)) { - // Another thread won the race — join its exchange rather than starting a new one. - CompletableFuture existing = inFlight.get(); - return existing != null ? existing : getAccessToken(); - } + // 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()) { + return CompletableFuture.completedFuture(rechecked.token()); + } - // This thread owns the exchange. Start it, wiring completion back to `promise`. - try { - exchangeToken().whenComplete((response, ex) -> { - if (ex != null) { - inFlight.set(null); - promise.completeExceptionally(ex); - } else { - String token = response.getAccessToken(); - // Write snapshot before clearing the gate so any new caller that arrives - // after inFlight becomes null immediately sees a valid token. - snapshot.set(new AccessToken(token, Instant.now().plusSeconds(response.getExpiresInSeconds()))); - - // Clear before completing - inFlight.set(null); - promise.complete(token); - telemetry.metrics().credentialsRequest(1L, new HashMap<>()); - } - }); - } catch (Exception e) { - inFlight.set(null); - promise.completeExceptionally(e); - throw e; + // Join an existing in-flight exchange. + CompletableFuture existing = inFlight.get(); + if (existing != null) { + return existing; + } + + // Start a new exchange and publish the future so other callers join it. + CompletableFuture promise = new CompletableFuture<>(); + inFlight.set(promise); + + try { + exchangeToken().whenComplete((response, ex) -> { + if (ex != null) { + inFlight.set(null); + promise.completeExceptionally(ex); + } else { + String token = response.getAccessToken(); + // Write snapshot before clearing the gate so any new caller that arrives + // after inFlight becomes null immediately sees a valid token. + snapshot.set(new AccessToken(token, Instant.now().plusSeconds(response.getExpiresInSeconds()))); + inFlight.set(null); + promise.complete(token); + telemetry.metrics().credentialsRequest(1L, new HashMap<>()); + } + }); + } catch (Exception e) { + inFlight.set(null); + promise.completeExceptionally(e); + throw e; + } + + return promise; } - - return promise; } /** diff --git a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java index b6ff7df0..3fb72273 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java @@ -209,6 +209,135 @@ void exchangeOAuth2Token_concurrentRequests_singleExchange(WireMockRuntimeInfo w verify(1, postRequestedFor(urlEqualTo("/oauth/token"))); } + /** + * Regression: after a successful exchange, a second wave of concurrent callers must see the + * cached snapshot and NOT trigger a redundant exchange. + * This covers a race where thread A reads an invalid snapshot, thread B completes the exchange, + * and thread A then enters the slow path synchronized block - but still returns the cached + * token without issuing another request. + */ + @Test + void getAccessToken_cachedTokenHit(WireMockRuntimeInfo wm) throws Exception { + stubFor(post(urlEqualTo("/oauth/token")) + .willReturn(ok(String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", ACCESS_TOKEN)) + .withFixedDelay(100))); + + OAuth2Client client = newOAuth2Client(wm.getHttpBaseUrl(), false); + + // Wave 1 — triggers the only exchange. + int wave1Count = 3; + CountDownLatch wave1Start = new CountDownLatch(1); + CountDownLatch wave1Done = new CountDownLatch(wave1Count); + List wave1Tokens = Collections.synchronizedList(new ArrayList<>()); + List failures = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < wave1Count; i++) { + new Thread(() -> { + try { + wave1Start.await(); + wave1Tokens.add(client.getAccessToken().get()); + } catch (Exception e) { + failures.add(e); + } finally { + wave1Done.countDown(); + } + }) + .start(); + } + wave1Start.countDown(); + assertTrue(wave1Done.await(2, TimeUnit.SECONDS), "wave 1 did not complete in time"); + assertTrue(failures.isEmpty(), "wave 1 should not have failures"); + + // Wave 2 — arrives after the exchange has completed; must use the cached token. + int wave2Count = 5; + CountDownLatch wave2Start = new CountDownLatch(1); + CountDownLatch wave2Done = new CountDownLatch(wave2Count); + List wave2Tokens = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < wave2Count; i++) { + new Thread(() -> { + try { + wave2Start.await(); + wave2Tokens.add(client.getAccessToken().get()); + } catch (Exception e) { + failures.add(e); + } finally { + wave2Done.countDown(); + } + }) + .start(); + } + wave2Start.countDown(); + assertTrue(wave2Done.await(2, TimeUnit.SECONDS), "wave 2 did not complete in time"); + + assertEquals(List.of(), failures, "no thread should have thrown"); + assertEquals(wave1Count, wave1Tokens.size()); + assertEquals(wave2Count, wave2Tokens.size()); + assertTrue(wave1Tokens.stream().allMatch(ACCESS_TOKEN::equals)); + assertTrue(wave2Tokens.stream().allMatch(ACCESS_TOKEN::equals)); + + // Only one exchange ever happened across both waves. + verify(1, postRequestedFor(urlEqualTo("/oauth/token"))); + } + + /** + * Regression: a late wave of callers that arrives while the exchange is still in-flight + * must join the existing future rather than starting a second exchange. + */ + @Test + void getAccessToken_joinInFlightExchange(WireMockRuntimeInfo wm) throws Exception { + stubFor(post(urlEqualTo("/oauth/token")) + .willReturn(ok(String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", ACCESS_TOKEN)) + .withFixedDelay(300))); + + OAuth2Client client = newOAuth2Client(wm.getHttpBaseUrl(), false); + + List allTokens = Collections.synchronizedList(new ArrayList<>()); + List failures = Collections.synchronizedList(new ArrayList<>()); + + // Wave 1 — triggers the exchange (300 ms delay). + int wave1Count = 2; + CountDownLatch wave1Start = new CountDownLatch(1); + CountDownLatch allDone = new CountDownLatch(wave1Count + 3); + + for (int i = 0; i < wave1Count; i++) { + new Thread(() -> { + try { + wave1Start.await(); + allTokens.add(client.getAccessToken().get()); + } catch (Exception e) { + failures.add(e); + } finally { + allDone.countDown(); + } + }) + .start(); + } + wave1Start.countDown(); + + // Wave 2 — arrives 50 ms later while exchange is still in-flight. + Thread.sleep(50); + int wave2Count = 3; + for (int i = 0; i < wave2Count; i++) { + new Thread(() -> { + try { + allTokens.add(client.getAccessToken().get()); + } catch (Exception e) { + failures.add(e); + } finally { + allDone.countDown(); + } + }) + .start(); + } + + assertTrue(allDone.await(5, TimeUnit.SECONDS), "threads did not complete in time"); + assertEquals(List.of(), failures, "no thread should have thrown"); + assertEquals(wave1Count + wave2Count, allTokens.size()); + assertTrue(allTokens.stream().allMatch(ACCESS_TOKEN::equals)); + verify(1, postRequestedFor(urlEqualTo("/oauth/token"))); + } + @Test public void apiTokenIssuer_invalidScheme() { // When