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/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 21f17c5a..d5b9911b 100644 --- a/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java +++ b/src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java @@ -4,20 +4,20 @@ 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.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; 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<>(AccessToken.EMPTY); + private final AtomicReference> inFlight = new AtomicReference<>(); private final CredentialsFlowRequest authRequest; private final Configuration config; private final Telemetry telemetry; @@ -45,26 +45,50 @@ 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<>(); + AccessToken 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 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; } - return CompletableFuture.completedFuture(token.getToken()); + return promise; } /** 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..82a2d7ee 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,12 @@ 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.ClientCredentials; 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; @@ -16,7 +21,14 @@ 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; +import java.util.concurrent.ExecutionException; import java.util.function.Consumer; import org.openapitools.jackson.nullable.JsonNullableModule; @@ -41,6 +53,7 @@ public class ApiClient { private Consumer interceptor; private Consumer> responseInterceptor; private Consumer> asyncResponseInterceptor; + private final ConcurrentMap oAuth2Clients = new ConcurrentHashMap<>(); /** * Create an instance of ApiClient. @@ -324,4 +337,118 @@ 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) { + Throwable cause = e.getCause(); + if (cause instanceof ApiException) { + throw (ApiException) cause; + } + throw new ApiException(cause != null ? cause : e); + } + break; + default: + throw new IllegalStateException("Unknown credentials method: " + method); + } + + requestBuilder.setHeader("Authorization", "Bearer " + accessToken); + } + + private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException { + 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); + OAuth2Client prior = oAuth2Clients.putIfAbsent(key, created); + return prior != null ? prior : created; + } + + private static final class CredentialsCacheKey { + private final String clientId; + private final byte[] clientSecretHash; + private final String apiTokenIssuer; + private final String apiAudience; + private final String scopes; + + CredentialsCacheKey(ClientCredentials cc) { + this.clientId = cc.getClientId(); + 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) + && Arrays.equals(clientSecretHash, that.clientSecretHash) + && Objects.equals(apiTokenIssuer, that.apiTokenIssuer) + && Objects.equals(apiAudience, that.apiAudience) + && Objects.equals(scopes, that.scopes); + } + + @Override + public int hashCode() { + int result = Objects.hash(clientId, apiTokenIssuer, apiAudience, scopes); + result = 31 * result + Arrays.hashCode(clientSecretHash); + return result; + } + } } 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/auth/AccessTokenTest.java b/src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java index 6ce192d8..2629133b 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) { + AccessToken snapshot = new AccessToken("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..b6ff7df0 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,44 @@ 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 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..98d248a6 100644 --- a/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java @@ -1,10 +1,27 @@ 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 static org.junit.jupiter.api.Assertions.assertThrows; +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 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 { @@ -37,4 +54,225 @@ public void customHttpClientWithHttp2() { ; assertEquals(apiClient.getHttpClient().version(), HttpClient.Version.HTTP_2); } + + @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); + } + + @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) { + HttpClient.Builder builder = Mockito.mock(HttpClient.Builder.class); + Mockito.when(builder.build()).thenReturn(client); + Mockito.when(builder.executor(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..ab650350 100644 --- a/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/StreamingApiExecutorTest.java @@ -6,6 +6,7 @@ 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.Credentials; import dev.openfga.sdk.api.model.ListObjectsRequest; @@ -39,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() @@ -355,6 +358,36 @@ 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)); + ApiClient realApiClient = new ApiClient(mockHttpClientBuilder); + OpenFgaClient authFga = new OpenFgaClient(authConfig, realApiClient); + + 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 =