Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/dev/openfga/sdk/api/BaseStreamingApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Comment thread
SoulPancake marked this conversation as resolved.
// Apply request interceptors if any
var interceptor = apiClient.getRequestInterceptor();
if (interceptor != null) {
Expand Down
38 changes: 1 addition & 37 deletions src/main/java/dev/openfga/sdk/api/OpenFgaApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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));
Expand Down Expand Up @@ -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);

Comment thread
SoulPancake marked this conversation as resolved.
if (configuration.getUserAgent() != null) {
httpRequest.header("User-Agent", configuration.getUserAgent());
Expand Down Expand Up @@ -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.");
}
}
71 changes: 71 additions & 0 deletions src/main/java/dev/openfga/sdk/api/client/ApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -41,6 +47,7 @@ public class ApiClient {
private Consumer<HttpRequest.Builder> interceptor;
private Consumer<HttpResponse<InputStream>> responseInterceptor;
private Consumer<HttpResponse<String>> asyncResponseInterceptor;
private final AtomicReference<OAuth2Client> oAuth2Client = new AtomicReference<>();

/**
* Create an instance of ApiClient.
Expand Down Expand Up @@ -324,4 +331,68 @@ public ApiClient setAsyncResponseInterceptor(Consumer<HttpResponse<String>> inte
public Consumer<HttpResponse<String>> getAsyncResponseInterceptor() {
return asyncResponseInterceptor;
}

/**
* Applies the {@code Authorization: Bearer <token>} 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.
*
* <ul>
* <li>{@link CredentialsMethod#NONE}: no header is applied.</li>
* <li>{@link CredentialsMethod#API_TOKEN}: the static API token from the configuration is used.</li>
* <li>{@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.</li>
* </ul>
*
* @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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiException(e);
} catch (ExecutionException e) {
throw new ApiException(e);
Comment thread
cportcvent marked this conversation as resolved.
Outdated
}
break;
default:
throw new IllegalStateException("Unknown credentials method: " + method);
}
Comment thread
cportcvent marked this conversation as resolved.

requestBuilder.header("Authorization", "Bearer " + accessToken);
Comment thread
cportcvent marked this conversation as resolved.
Outdated
}

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();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cportcvent marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -207,6 +208,8 @@ HttpRequest buildHttpRequest(Configuration configuration, ApiClient apiClient)

headers.forEach(httpRequestBuilder::header);

apiClient.applyAuthHeader(httpRequestBuilder, configuration);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (apiClient.getRequestInterceptor() != null) {
apiClient.getRequestInterceptor().accept(httpRequestBuilder);
Comment thread
cportcvent marked this conversation as resolved.
}
Expand Down
91 changes: 91 additions & 0 deletions src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
}
75 changes: 75 additions & 0 deletions src/test/java/dev/openfga/sdk/api/client/ApiExecutorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ExperimentalResponse> 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<ExperimentalResponse> 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
Expand Down
Loading
Loading