Skip to content

Commit 11ab8e4

Browse files
committed
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.
1 parent 2565cc8 commit 11ab8e4

8 files changed

Lines changed: 280 additions & 44 deletions

File tree

src/main/java/dev/openfga/sdk/api/BaseStreamingApi.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ protected HttpRequest buildHttpRequest(String method, String path, Object body,
168168
byte[] bodyBytes = objectMapper.writeValueAsBytes(body);
169169
HttpRequest.Builder requestBuilder = ApiClient.requestBuilder(method, path, bodyBytes, configuration);
170170

171+
apiClient.applyAuthHeader(requestBuilder, configuration);
172+
171173
// Apply request interceptors if any
172174
var interceptor = apiClient.getRequestInterceptor();
173175
if (interceptor != null) {

src/main/java/dev/openfga/sdk/api/OpenFgaApi.java

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import static dev.openfga.sdk.util.StringUtil.isNullOrWhitespace;
1616
import static dev.openfga.sdk.util.Validation.assertParamExists;
1717

18-
import dev.openfga.sdk.api.auth.*;
1918
import dev.openfga.sdk.api.client.*;
2019
import dev.openfga.sdk.api.configuration.*;
2120
import dev.openfga.sdk.api.model.BatchCheckRequest;
@@ -69,7 +68,6 @@ public class OpenFgaApi {
6968
private final Configuration configuration;
7069

7170
private final ApiClient apiClient;
72-
private final OAuth2Client oAuth2Client;
7371
private final Telemetry telemetry;
7472

7573
public OpenFgaApi(Configuration configuration) throws FgaInvalidParameterException {
@@ -89,12 +87,6 @@ public OpenFgaApi(Configuration configuration, ApiClient apiClient, Telemetry te
8987
this.configuration = configuration;
9088
this.telemetry = telemetry;
9189

92-
if (configuration.getCredentials().getCredentialsMethod() == CredentialsMethod.CLIENT_CREDENTIALS) {
93-
this.oAuth2Client = new OAuth2Client(configuration, apiClient);
94-
} else {
95-
this.oAuth2Client = null;
96-
}
97-
9890
var defaultHeaders = configuration.getDefaultHeaders();
9991
if (defaultHeaders != null) {
10092
apiClient.addRequestInterceptor(httpRequest -> defaultHeaders.forEach(httpRequest::setHeader));
@@ -1294,10 +1286,7 @@ private HttpRequest buildHttpRequestWithPublisher(
12941286
httpRequest.header("Content-Type", "application/json");
12951287
httpRequest.header("Accept", "application/json");
12961288

1297-
if (configuration.getCredentials().getCredentialsMethod() != CredentialsMethod.NONE) {
1298-
String accessToken = getAccessToken(configuration);
1299-
httpRequest.header("Authorization", "Bearer " + accessToken);
1300-
}
1289+
apiClient.applyAuthHeader(httpRequest, configuration);
13011290

13021291
if (configuration.getUserAgent() != null) {
13031292
httpRequest.header("User-Agent", configuration.getUserAgent());
@@ -1337,29 +1326,4 @@ private String pathWithParams(String basePath, Object... params) {
13371326
}
13381327
return path.toString();
13391328
}
1340-
1341-
/**
1342-
* Get an access token. Expects that configuration is valid (meaning it can
1343-
* pass {@link Configuration#assertValid()}) and expects that if the
1344-
* CredentialsMethod is CLIENT_CREDENTIALS that a valid {@link OAuth2Client}
1345-
* has been initialized. Otherwise, it will throw an IllegalStateException.
1346-
* @throws IllegalStateException when the configuration is invalid
1347-
*/
1348-
private String getAccessToken(Configuration configuration) throws ApiException {
1349-
CredentialsMethod credentialsMethod = configuration.getCredentials().getCredentialsMethod();
1350-
1351-
if (credentialsMethod == CredentialsMethod.API_TOKEN) {
1352-
return configuration.getCredentials().getApiToken().getToken();
1353-
}
1354-
1355-
if (credentialsMethod == CredentialsMethod.CLIENT_CREDENTIALS) {
1356-
try {
1357-
return oAuth2Client.getAccessToken().get();
1358-
} catch (Exception e) {
1359-
throw new ApiException(e);
1360-
}
1361-
}
1362-
1363-
throw new IllegalStateException("Configuration is invalid.");
1364-
}
13651329
}

src/main/java/dev/openfga/sdk/api/client/ApiClient.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import com.fasterxml.jackson.databind.ObjectMapper;
88
import com.fasterxml.jackson.databind.SerializationFeature;
99
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
10+
import dev.openfga.sdk.api.auth.OAuth2Client;
1011
import dev.openfga.sdk.api.configuration.Configuration;
12+
import dev.openfga.sdk.api.configuration.Credentials;
13+
import dev.openfga.sdk.api.configuration.CredentialsMethod;
14+
import dev.openfga.sdk.errors.ApiException;
1115
import dev.openfga.sdk.errors.FgaInvalidParameterException;
1216
import dev.openfga.sdk.util.StringUtil;
1317
import java.io.InputStream;
@@ -17,6 +21,8 @@
1721
import java.net.http.HttpRequest;
1822
import java.net.http.HttpResponse;
1923
import java.time.Duration;
24+
import java.util.concurrent.ExecutionException;
25+
import java.util.concurrent.atomic.AtomicReference;
2026
import java.util.function.Consumer;
2127
import org.openapitools.jackson.nullable.JsonNullableModule;
2228

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

4552
/**
4653
* Create an instance of ApiClient.
@@ -324,4 +331,68 @@ public ApiClient setAsyncResponseInterceptor(Consumer<HttpResponse<String>> inte
324331
public Consumer<HttpResponse<String>> getAsyncResponseInterceptor() {
325332
return asyncResponseInterceptor;
326333
}
334+
335+
/**
336+
* Applies the {@code Authorization: Bearer <token>} header to the request builder based on the
337+
* supplied configuration's {@link Credentials}. This is the single entry point for attaching
338+
* auth to outbound requests across the SDK — every request builder should delegate here.
339+
*
340+
* <ul>
341+
* <li>{@link CredentialsMethod#NONE}: no header is applied.</li>
342+
* <li>{@link CredentialsMethod#API_TOKEN}: the static API token from the configuration is used.</li>
343+
* <li>{@link CredentialsMethod#CLIENT_CREDENTIALS}: an {@link OAuth2Client} performs the
344+
* client-credentials exchange and caches the token on this {@code ApiClient} until expiry.
345+
* The client is lazily created from {@code configuration} on first use.</li>
346+
* </ul>
347+
*
348+
* @param requestBuilder the request builder to mutate.
349+
* @param configuration the configuration that supplies credentials.
350+
* @throws ApiException if CLIENT_CREDENTIALS token exchange fails.
351+
* @throws FgaInvalidParameterException if the configuration is invalid when lazily creating
352+
* an {@link OAuth2Client}.
353+
*/
354+
public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration configuration)
355+
throws ApiException, FgaInvalidParameterException {
356+
357+
Credentials credentials = configuration.getCredentials();
358+
if (credentials == null) {
359+
return;
360+
}
361+
362+
CredentialsMethod method = credentials.getCredentialsMethod();
363+
if (method == null || method == CredentialsMethod.NONE) {
364+
return;
365+
}
366+
367+
String accessToken;
368+
switch (method) {
369+
case API_TOKEN:
370+
accessToken = credentials.getApiToken().getToken();
371+
break;
372+
case CLIENT_CREDENTIALS:
373+
try {
374+
accessToken =
375+
ensureOAuth2Client(configuration).getAccessToken().get();
376+
} catch (InterruptedException e) {
377+
Thread.currentThread().interrupt();
378+
throw new ApiException(e);
379+
} catch (ExecutionException e) {
380+
throw new ApiException(e);
381+
}
382+
break;
383+
default:
384+
throw new IllegalStateException("Unknown credentials method: " + method);
385+
}
386+
387+
requestBuilder.header("Authorization", "Bearer " + accessToken);
388+
}
389+
390+
private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException {
391+
OAuth2Client existing = oAuth2Client.get();
392+
if (existing != null) {
393+
return existing;
394+
}
395+
OAuth2Client created = new OAuth2Client(configuration, this);
396+
return oAuth2Client.compareAndSet(null, created) ? created : oAuth2Client.get();
397+
}
327398
}

src/main/java/dev/openfga/sdk/api/client/ApiExecutorRequestBuilder.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.fasterxml.jackson.core.JsonProcessingException;
44
import dev.openfga.sdk.api.configuration.ClientConfiguration;
55
import dev.openfga.sdk.api.configuration.Configuration;
6+
import dev.openfga.sdk.errors.ApiException;
67
import dev.openfga.sdk.errors.FgaInvalidParameterException;
78
import dev.openfga.sdk.util.StringUtil;
89
import java.net.http.HttpRequest;
@@ -192,7 +193,7 @@ String buildPath(Configuration configuration) {
192193
* Package-private — used by {@link ApiExecutor} and {@link StreamingApiExecutor}.
193194
*/
194195
HttpRequest buildHttpRequest(Configuration configuration, ApiClient apiClient)
195-
throws FgaInvalidParameterException, JsonProcessingException {
196+
throws ApiException, FgaInvalidParameterException, JsonProcessingException {
196197
String resolvedPath = buildPath(configuration);
197198

198199
HttpRequest.Builder httpRequestBuilder;
@@ -207,6 +208,8 @@ HttpRequest buildHttpRequest(Configuration configuration, ApiClient apiClient)
207208

208209
headers.forEach(httpRequestBuilder::header);
209210

211+
apiClient.applyAuthHeader(httpRequestBuilder, configuration);
212+
210213
if (apiClient.getRequestInterceptor() != null) {
211214
apiClient.getRequestInterceptor().accept(httpRequestBuilder);
212215
}

src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
package dev.openfga.sdk.api.client;
22

3+
import static org.hamcrest.Matchers.allOf;
4+
import static org.hamcrest.Matchers.containsString;
35
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
import static org.junit.jupiter.api.Assertions.assertFalse;
47
import static org.junit.jupiter.api.Assertions.assertNotEquals;
58

9+
import com.pgssoft.httpclient.HttpClientMock;
10+
import dev.openfga.sdk.api.configuration.ApiToken;
11+
import dev.openfga.sdk.api.configuration.ClientCredentials;
12+
import dev.openfga.sdk.api.configuration.Configuration;
13+
import dev.openfga.sdk.api.configuration.Credentials;
14+
import dev.openfga.sdk.constants.FgaConstants;
615
import java.net.http.HttpClient;
16+
import java.net.http.HttpRequest;
717
import org.junit.jupiter.api.Test;
818

919
class ApiClientTest {
@@ -37,4 +47,85 @@ public void customHttpClientWithHttp2() {
3747
;
3848
assertEquals(apiClient.getHttpClient().version(), HttpClient.Version.HTTP_2);
3949
}
50+
51+
@Test
52+
public void applyAuthHeader_none_skipsHeader() throws Exception {
53+
Configuration configuration =
54+
new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(new Credentials());
55+
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example"));
56+
57+
new ApiClient().applyAuthHeader(requestBuilder, configuration);
58+
59+
assertFalse(requestBuilder.build().headers().firstValue("Authorization").isPresent());
60+
}
61+
62+
@Test
63+
public void applyAuthHeader_apiToken_setsBearerHeader() throws Exception {
64+
String token = "static-api-token";
65+
Configuration configuration =
66+
new Configuration().apiUrl(FgaConstants.TEST_API_URL).credentials(new Credentials(new ApiToken(token)));
67+
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example"));
68+
69+
new ApiClient().applyAuthHeader(requestBuilder, configuration);
70+
71+
assertEquals(
72+
"Bearer " + token,
73+
requestBuilder.build().headers().firstValue("Authorization").orElseThrow());
74+
}
75+
76+
@Test
77+
public void applyAuthHeader_clientCredentials_exchangesAndSetsBearerHeader() throws Exception {
78+
String clientId = "some-client-id";
79+
String clientSecret = "some-client-secret";
80+
String apiAudience = "some-audience";
81+
String apiTokenIssuer = "oauth2.server";
82+
String exchangedToken = "exchanged-access-token";
83+
84+
HttpClientMock mockHttpClient = new HttpClientMock();
85+
mockHttpClient
86+
.onPost(String.format("https://%s/oauth/token", apiTokenIssuer))
87+
.withBody(allOf(
88+
containsString("client_id=" + clientId),
89+
containsString("client_secret=" + clientSecret),
90+
containsString("audience=" + apiAudience),
91+
containsString("grant_type=client_credentials")))
92+
.doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken));
93+
94+
HttpClient.Builder mockBuilder = mockBuilderReturning(mockHttpClient);
95+
ApiClient apiClient = new ApiClient(mockBuilder);
96+
97+
Configuration configuration = new Configuration()
98+
.apiUrl(FgaConstants.TEST_API_URL)
99+
.credentials(new Credentials(new ClientCredentials()
100+
.clientId(clientId)
101+
.clientSecret(clientSecret)
102+
.apiAudience(apiAudience)
103+
.apiTokenIssuer(apiTokenIssuer)));
104+
105+
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example"));
106+
apiClient.applyAuthHeader(requestBuilder, configuration);
107+
108+
assertEquals(
109+
"Bearer " + exchangedToken,
110+
requestBuilder.build().headers().firstValue("Authorization").orElseThrow());
111+
112+
// A second call should reuse the cached token and not hit the issuer again.
113+
HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(java.net.URI.create("http://example"));
114+
apiClient.applyAuthHeader(secondBuilder, configuration);
115+
assertEquals(
116+
"Bearer " + exchangedToken,
117+
secondBuilder.build().headers().firstValue("Authorization").orElseThrow());
118+
mockHttpClient
119+
.verify()
120+
.post(String.format("https://%s/oauth/token", apiTokenIssuer))
121+
.called(1);
122+
}
123+
124+
private static HttpClient.Builder mockBuilderReturning(HttpClient client) {
125+
HttpClient.Builder builder = org.mockito.Mockito.mock(HttpClient.Builder.class);
126+
org.mockito.Mockito.when(builder.build()).thenReturn(client);
127+
org.mockito.Mockito.when(builder.executor(org.mockito.ArgumentMatchers.any()))
128+
.thenReturn(builder);
129+
return builder;
130+
}
40131
}

src/test/java/dev/openfga/sdk/api/client/ApiExecutorTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import com.fasterxml.jackson.annotation.JsonProperty;
77
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
88
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
9+
import dev.openfga.sdk.api.configuration.ApiToken;
910
import dev.openfga.sdk.api.configuration.ClientConfiguration;
11+
import dev.openfga.sdk.api.configuration.ClientCredentials;
12+
import dev.openfga.sdk.api.configuration.Credentials;
1013
import dev.openfga.sdk.errors.FgaError;
1114
import dev.openfga.sdk.errors.FgaInvalidParameterException;
1215
import java.util.HashMap;
@@ -382,6 +385,78 @@ public void rawApi_throwsExceptionForNullResponseType() throws Exception {
382385
assertThrows(IllegalArgumentException.class, () -> client.apiExecutor().send(request, null));
383386
}
384387

388+
@Test
389+
public void rawApi_appliesApiTokenAuthHeader() throws Exception {
390+
String apiToken = "static-api-token";
391+
stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
392+
.withHeader("Authorization", equalTo("Bearer " + apiToken))
393+
.willReturn(aResponse()
394+
.withStatus(200)
395+
.withHeader("Content-Type", "application/json")
396+
.withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}")));
397+
398+
ClientConfiguration config = new ClientConfiguration()
399+
.apiUrl(fgaApiUrl)
400+
.storeId(DEFAULT_STORE_ID)
401+
.credentials(new Credentials(new ApiToken(apiToken)));
402+
OpenFgaClient client = new OpenFgaClient(config);
403+
404+
ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.GET, EXPERIMENTAL_ENDPOINT)
405+
.pathParam("store_id", DEFAULT_STORE_ID)
406+
.build();
407+
408+
ApiResponse<ExperimentalResponse> response =
409+
client.apiExecutor().send(request, ExperimentalResponse.class).get();
410+
411+
assertEquals(200, response.getStatusCode());
412+
verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
413+
.withHeader("Authorization", equalTo("Bearer " + apiToken)));
414+
}
415+
416+
@Test
417+
public void rawApi_appliesClientCredentialsAuthHeader() throws Exception {
418+
String clientId = "some-client-id";
419+
String clientSecret = "some-client-secret";
420+
String apiAudience = "some-audience";
421+
String exchangedToken = "exchanged-access-token";
422+
423+
stubFor(post(urlEqualTo("/oauth/token"))
424+
.willReturn(aResponse()
425+
.withStatus(200)
426+
.withHeader("Content-Type", "application/json")
427+
.withBody(String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken))));
428+
stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
429+
.withHeader("Authorization", equalTo("Bearer " + exchangedToken))
430+
.willReturn(aResponse()
431+
.withStatus(200)
432+
.withHeader("Content-Type", "application/json")
433+
.withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}")));
434+
435+
ClientConfiguration config = new ClientConfiguration()
436+
.apiUrl(fgaApiUrl)
437+
.storeId(DEFAULT_STORE_ID)
438+
.credentials(new Credentials(new ClientCredentials()
439+
.clientId(clientId)
440+
.clientSecret(clientSecret)
441+
.apiAudience(apiAudience)
442+
.apiTokenIssuer(fgaApiUrl)));
443+
OpenFgaClient client = new OpenFgaClient(config);
444+
445+
ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.GET, EXPERIMENTAL_ENDPOINT)
446+
.pathParam("store_id", DEFAULT_STORE_ID)
447+
.build();
448+
449+
ApiResponse<ExperimentalResponse> response =
450+
client.apiExecutor().send(request, ExperimentalResponse.class).get();
451+
452+
assertEquals(200, response.getStatusCode());
453+
verify(postRequestedFor(urlEqualTo("/oauth/token"))
454+
.withRequestBody(containing("client_id=" + clientId))
455+
.withRequestBody(containing("grant_type=client_credentials")));
456+
verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
457+
.withHeader("Authorization", equalTo("Bearer " + exchangedToken)));
458+
}
459+
385460
@Test
386461
public void twoParamConstructor_shouldCreateWithOwnTelemetry() throws Exception {
387462
// Verifies the backward-compatible 2-param constructor works

0 commit comments

Comments
 (0)