Skip to content

Commit b961487

Browse files
committed
fix: client creds bug across streamed list objects/apiexecutor/streamed executor
1 parent 2565cc8 commit b961487

8 files changed

Lines changed: 521 additions & 3 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,21 @@ 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+
// Attach authorization header if credentials are configured
172+
String accessToken = apiClient.getAccessToken(configuration);
173+
if (accessToken != null) {
174+
requestBuilder.header("Authorization", "Bearer " + accessToken);
175+
}
176+
171177
// Apply request interceptors if any
172178
var interceptor = apiClient.getRequestInterceptor();
173179
if (interceptor != null) {
174180
interceptor.accept(requestBuilder);
175181
}
176182

177183
return requestBuilder.build();
184+
} catch (ApiException e) {
185+
throw e;
178186
} catch (Exception e) {
179187
throw new ApiException(e);
180188
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ public OpenFgaApi(Configuration configuration, ApiClient apiClient, Telemetry te
9494
} else {
9595
this.oAuth2Client = null;
9696
}
97+
apiClient.setOAuth2Client(this.oAuth2Client);
9798

9899
var defaultHeaders = configuration.getDefaultHeaders();
99100
if (defaultHeaders != null) {

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
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.errors.ApiException;
1113
import dev.openfga.sdk.errors.FgaInvalidParameterException;
1214
import dev.openfga.sdk.util.StringUtil;
1315
import java.io.InputStream;
@@ -41,6 +43,7 @@ public class ApiClient {
4143
private Consumer<HttpRequest.Builder> interceptor;
4244
private Consumer<HttpResponse<InputStream>> responseInterceptor;
4345
private Consumer<HttpResponse<String>> asyncResponseInterceptor;
46+
private OAuth2Client oAuth2Client;
4447

4548
/**
4649
* Create an instance of ApiClient.
@@ -324,4 +327,59 @@ public ApiClient setAsyncResponseInterceptor(Consumer<HttpResponse<String>> inte
324327
public Consumer<HttpResponse<String>> getAsyncResponseInterceptor() {
325328
return asyncResponseInterceptor;
326329
}
330+
331+
/**
332+
* Set the OAuth2Client used for client credentials authentication.
333+
* This is typically called by {@link dev.openfga.sdk.api.OpenFgaApi} during initialization.
334+
*
335+
* @param oAuth2Client The OAuth2 client, or null if not using client credentials.
336+
*/
337+
public void setOAuth2Client(OAuth2Client oAuth2Client) {
338+
this.oAuth2Client = oAuth2Client;
339+
}
340+
341+
/**
342+
* Get the OAuth2Client used for client credentials authentication.
343+
*
344+
* @return The OAuth2 client, or null if not configured.
345+
*/
346+
public OAuth2Client getOAuth2Client() {
347+
return oAuth2Client;
348+
}
349+
350+
/**
351+
* Resolve the access token for the given configuration's credentials.
352+
* Returns the bearer token string, or null if credentials method is NONE.
353+
*
354+
* @param configuration The configuration containing credentials
355+
* @return The access token string, or null
356+
* @throws ApiException if token acquisition fails
357+
*/
358+
public String getAccessToken(Configuration configuration) throws ApiException {
359+
dev.openfga.sdk.api.configuration.CredentialsMethod credentialsMethod = configuration.getCredentials().getCredentialsMethod();
360+
361+
if (credentialsMethod == dev.openfga.sdk.api.configuration.CredentialsMethod.NONE) {
362+
return null;
363+
}
364+
365+
if (credentialsMethod == dev.openfga.sdk.api.configuration.CredentialsMethod.API_TOKEN) {
366+
return configuration.getCredentials().getApiToken().getToken();
367+
}
368+
369+
if (credentialsMethod == dev.openfga.sdk.api.configuration.CredentialsMethod.CLIENT_CREDENTIALS) {
370+
if (oAuth2Client == null) {
371+
throw new IllegalStateException(
372+
"OAuth2Client is not initialized but credentials method is CLIENT_CREDENTIALS.");
373+
}
374+
try {
375+
return oAuth2Client.getAccessToken().get();
376+
} catch (ApiException e) {
377+
throw e;
378+
} catch (Exception e) {
379+
throw new ApiException(e);
380+
}
381+
}
382+
383+
throw new IllegalStateException("Configuration is invalid.");
384+
}
327385
}

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

Lines changed: 8 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 FgaInvalidParameterException, JsonProcessingException, ApiException {
196197
String resolvedPath = buildPath(configuration);
197198

198199
HttpRequest.Builder httpRequestBuilder;
@@ -205,6 +206,12 @@ HttpRequest buildHttpRequest(Configuration configuration, ApiClient apiClient)
205206
httpRequestBuilder = ApiClient.requestBuilder(method.name(), resolvedPath, configuration);
206207
}
207208

209+
// Attach authorization header if credentials are configured
210+
String accessToken = apiClient.getAccessToken(configuration);
211+
if (accessToken != null) {
212+
httpRequestBuilder.header("Authorization", "Bearer " + accessToken);
213+
}
214+
208215
headers.forEach(httpRequestBuilder::header);
209216

210217
if (apiClient.getRequestInterceptor() != null) {

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

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

3-
import static org.junit.jupiter.api.Assertions.assertEquals;
4-
import static org.junit.jupiter.api.Assertions.assertNotEquals;
3+
import static org.junit.jupiter.api.Assertions.*;
4+
import static org.mockito.Mockito.*;
55

6+
import dev.openfga.sdk.api.auth.OAuth2Client;
7+
import dev.openfga.sdk.api.configuration.*;
8+
import dev.openfga.sdk.errors.ApiException;
69
import java.net.http.HttpClient;
10+
import java.util.concurrent.CompletableFuture;
711
import org.junit.jupiter.api.Test;
812

913
class ApiClientTest {
@@ -37,4 +41,78 @@ public void customHttpClientWithHttp2() {
3741
;
3842
assertEquals(apiClient.getHttpClient().version(), HttpClient.Version.HTTP_2);
3943
}
44+
45+
@Test
46+
public void getAccessToken_withNone_returnsNull() throws Exception {
47+
ApiClient apiClient = new ApiClient();
48+
Configuration config = new Configuration().apiUrl("https://test.example");
49+
// Default Credentials() has method NONE
50+
assertNull(apiClient.getAccessToken(config));
51+
}
52+
53+
@Test
54+
public void getAccessToken_withApiToken_returnsToken() throws Exception {
55+
ApiClient apiClient = new ApiClient();
56+
Configuration config = new Configuration()
57+
.apiUrl("https://test.example")
58+
.credentials(new Credentials(new ApiToken("my-static-token")));
59+
assertEquals("my-static-token", apiClient.getAccessToken(config));
60+
}
61+
62+
@Test
63+
public void getAccessToken_withClientCredentials_returnsTokenFromOAuth2Client() throws Exception {
64+
ApiClient apiClient = new ApiClient();
65+
OAuth2Client mockOAuth2 = mock(OAuth2Client.class);
66+
when(mockOAuth2.getAccessToken()).thenReturn(CompletableFuture.completedFuture("oauth2-token-abc"));
67+
apiClient.setOAuth2Client(mockOAuth2);
68+
69+
ClientCredentials clientCreds = new ClientCredentials()
70+
.clientId("id")
71+
.clientSecret("secret")
72+
.apiTokenIssuer("issuer.example")
73+
.apiAudience("audience");
74+
Configuration config = new Configuration()
75+
.apiUrl("https://test.example")
76+
.credentials(new Credentials(clientCreds));
77+
78+
assertEquals("oauth2-token-abc", apiClient.getAccessToken(config));
79+
verify(mockOAuth2, times(1)).getAccessToken();
80+
}
81+
82+
@Test
83+
public void getAccessToken_withClientCredentials_noOAuth2Client_throwsIllegalState() {
84+
ApiClient apiClient = new ApiClient();
85+
// No setOAuth2Client called
86+
87+
ClientCredentials clientCreds = new ClientCredentials()
88+
.clientId("id")
89+
.clientSecret("secret")
90+
.apiTokenIssuer("issuer.example")
91+
.apiAudience("audience");
92+
Configuration config = new Configuration()
93+
.apiUrl("https://test.example")
94+
.credentials(new Credentials(clientCreds));
95+
96+
assertThrows(IllegalStateException.class, () -> apiClient.getAccessToken(config));
97+
}
98+
99+
@Test
100+
public void getAccessToken_withClientCredentials_oAuth2Fails_throwsApiException() throws Exception {
101+
ApiClient apiClient = new ApiClient();
102+
OAuth2Client mockOAuth2 = mock(OAuth2Client.class);
103+
when(mockOAuth2.getAccessToken())
104+
.thenReturn(CompletableFuture.failedFuture(new RuntimeException("token exchange failed")));
105+
apiClient.setOAuth2Client(mockOAuth2);
106+
107+
ClientCredentials clientCreds = new ClientCredentials()
108+
.clientId("id")
109+
.clientSecret("secret")
110+
.apiTokenIssuer("issuer.example")
111+
.apiAudience("audience");
112+
Configuration config = new Configuration()
113+
.apiUrl("https://test.example")
114+
.credentials(new Credentials(clientCreds));
115+
116+
assertThrows(ApiException.class, () -> apiClient.getAccessToken(config));
117+
}
40118
}

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

Lines changed: 114 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;
@@ -395,4 +398,115 @@ public void threeParamConstructor_shouldRejectNullTelemetry() {
395398
ClientConfiguration config = new ClientConfiguration().apiUrl(fgaApiUrl).storeId(DEFAULT_STORE_ID);
396399
assertThrows(IllegalArgumentException.class, () -> new ApiExecutor(new ApiClient(), config, null));
397400
}
401+
402+
@Test
403+
public void rawApi_withApiToken_attachesAuthorizationHeader() throws Exception {
404+
// Setup mock server — verify the Authorization header arrives
405+
String apiToken = "test-api-token-for-executor";
406+
stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
407+
.willReturn(aResponse()
408+
.withStatus(200)
409+
.withHeader("Content-Type", "application/json")
410+
.withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}")));
411+
412+
// Create client with API_TOKEN credentials
413+
ClientConfiguration config = new ClientConfiguration()
414+
.apiUrl(fgaApiUrl)
415+
.storeId(DEFAULT_STORE_ID)
416+
.credentials(new Credentials(new ApiToken(apiToken)));
417+
OpenFgaClient client = new OpenFgaClient(config);
418+
419+
ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.GET, EXPERIMENTAL_ENDPOINT)
420+
.pathParam("store_id", DEFAULT_STORE_ID)
421+
.build();
422+
423+
ApiResponse<ExperimentalResponse> response =
424+
client.apiExecutor().send(request, ExperimentalResponse.class).get();
425+
426+
// Verify response succeeded
427+
assertNotNull(response);
428+
assertEquals(200, response.getStatusCode());
429+
430+
// Verify the Authorization header was sent
431+
verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
432+
.withHeader("Authorization", equalTo("Bearer " + apiToken)));
433+
}
434+
435+
@Test
436+
public void rawApi_withNoCredentials_noAuthorizationHeader() throws Exception {
437+
// Setup mock server
438+
stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
439+
.willReturn(aResponse()
440+
.withStatus(200)
441+
.withHeader("Content-Type", "application/json")
442+
.withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}")));
443+
444+
// Create client with no credentials
445+
OpenFgaClient client = createClient();
446+
447+
ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.GET, EXPERIMENTAL_ENDPOINT)
448+
.pathParam("store_id", DEFAULT_STORE_ID)
449+
.build();
450+
451+
ApiResponse<ExperimentalResponse> response =
452+
client.apiExecutor().send(request, ExperimentalResponse.class).get();
453+
454+
// Verify response succeeded
455+
assertNotNull(response);
456+
assertEquals(200, response.getStatusCode());
457+
458+
// Verify no Authorization header was sent
459+
verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
460+
.withoutHeader("Authorization"));
461+
}
462+
463+
@Test
464+
public void rawApi_withClientCredentials_attachesAuthorizationHeader() throws Exception {
465+
// Setup WireMock to serve as both the OAuth2 token endpoint and the API
466+
String generatedToken = "wiremock-oauth2-token-abc";
467+
468+
// Token endpoint
469+
stubFor(post(urlEqualTo("/oauth/token"))
470+
.willReturn(aResponse()
471+
.withStatus(200)
472+
.withHeader("Content-Type", "application/json")
473+
.withBody(String.format(
474+
"{\"access_token\":\"%s\",\"expires_in\":3600}", generatedToken))));
475+
476+
// API endpoint
477+
stubFor(get(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
478+
.willReturn(aResponse()
479+
.withStatus(200)
480+
.withHeader("Content-Type", "application/json")
481+
.withBody("{\"success\":true,\"count\":0,\"message\":\"OK\"}")));
482+
483+
// Create client with CLIENT_CREDENTIALS — point both apiUrl and apiTokenIssuer at WireMock
484+
ClientConfiguration config = new ClientConfiguration()
485+
.apiUrl(fgaApiUrl)
486+
.storeId(DEFAULT_STORE_ID)
487+
.credentials(new Credentials(new ClientCredentials()
488+
.clientId("test-client-id")
489+
.clientSecret("test-client-secret")
490+
.apiTokenIssuer(fgaApiUrl)
491+
.apiAudience("test-audience")));
492+
OpenFgaClient client = new OpenFgaClient(config);
493+
494+
ApiExecutorRequestBuilder request = ApiExecutorRequestBuilder.builder(HttpMethod.GET, EXPERIMENTAL_ENDPOINT)
495+
.pathParam("store_id", DEFAULT_STORE_ID)
496+
.build();
497+
498+
ApiResponse<ExperimentalResponse> response =
499+
client.apiExecutor().send(request, ExperimentalResponse.class).get();
500+
501+
// Verify response succeeded
502+
assertNotNull(response);
503+
assertEquals(200, response.getStatusCode());
504+
505+
// Verify token was requested
506+
verify(postRequestedFor(urlEqualTo("/oauth/token")));
507+
508+
// Verify the Authorization header was sent with the OAuth2 token
509+
verify(getRequestedFor(urlEqualTo("/stores/" + DEFAULT_STORE_ID + "/experimental-feature"))
510+
.withHeader("Authorization", equalTo("Bearer " + generatedToken)));
511+
}
398512
}

0 commit comments

Comments
 (0)