From 61ad4bb43ba5f577a3fe139788be93cfb2879683 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Fri, 26 Jan 2024 08:22:05 -0600 Subject: [PATCH] fix(java-sdk): send the credentials flow request as form-urlencoded --- .../client-OpenFgaClientTest.java.mustache | 10 +- ...creds-CredentialsFlowRequest.java.mustache | 94 ++++----------- .../template/creds-OAuth2Client.java.mustache | 17 +-- .../creds-OAuth2ClientTest.java.mustache | 107 ++++++++++++------ .../libraries/native/ApiClient.mustache | 15 +++ 5 files changed, 123 insertions(+), 120 deletions(-) diff --git a/config/clients/java/template/client-OpenFgaClientTest.java.mustache b/config/clients/java/template/client-OpenFgaClientTest.java.mustache index a860bd0d..71567a7a 100644 --- a/config/clients/java/template/client-OpenFgaClientTest.java.mustache +++ b/config/clients/java/template/client-OpenFgaClientTest.java.mustache @@ -3,6 +3,7 @@ package {{clientPackage}}; import static org.hamcrest.Matchers.*; +import static org.hamcrest.core.StringContains.containsString; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -128,14 +129,15 @@ public class OpenFgaClientTest { .apiAudience(apiAudience))); fga.setConfiguration(clientConfiguration); - String expectedOAuth2Body = String.format( - "{\"client_id\":\"%s\",\"client_secret\":\"%s\",\"audience\":\"%s\",\"grant_type\":\"client_credentials\"}", - clientId, clientSecret, apiAudience); String expectedBody = String.format("{\"name\":\"%s\"}", DEFAULT_STORE_NAME); String requestBody = String.format("{\"id\":\"%s\",\"name\":\"%s\"}", DEFAULT_STORE_ID, DEFAULT_STORE_NAME); mockHttpClient .onPost(String.format("https://%s/oauth/token", apiTokenIssuer)) - .withBody(is(expectedOAuth2Body)) + .withBody(allOf( + containsString(String.format("client_id=%s", clientId)), + containsString(String.format("client_secret=%s", clientSecret)), + containsString(String.format("audience=%s", apiAudience)), + containsString(String.format("grant_type=%s", "client_credentials")))) .doReturn(200, String.format("{\"access_token\":\"%s\"}", apiToken)); mockHttpClient .onPost("https://localhost/stores") diff --git a/config/clients/java/template/creds-CredentialsFlowRequest.java.mustache b/config/clients/java/template/creds-CredentialsFlowRequest.java.mustache index 06219828..83780c52 100644 --- a/config/clients/java/template/creds-CredentialsFlowRequest.java.mustache +++ b/config/clients/java/template/creds-CredentialsFlowRequest.java.mustache @@ -1,91 +1,45 @@ {{>licenseInfo}} package {{authPackage}}; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; /** * A credentials flow request. It contains a Client ID and Secret that can be exchanged for an access token. *

* {@see "https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow"} */ -@JsonPropertyOrder({ - CredentialsFlowRequest.JSON_PROPERTY_CLIENT_ID, - CredentialsFlowRequest.JSON_PROPERTY_CLIENT_SECRET, - CredentialsFlowRequest.JSON_PROPERTY_AUDIENCE, - CredentialsFlowRequest.JSON_PROPERTY_SCOPE, - CredentialsFlowRequest.JSON_PROPERTY_GRANT_TYPE -}) class CredentialsFlowRequest { - public static final String JSON_PROPERTY_CLIENT_ID = "client_id"; - private String clientId; + public static final String CLIENT_ID_PARAM_NAME = "client_id"; + public static final String CLIENT_SECRET_PARAM_NAME = "client_secret"; + public static final String AUDIENCE_PARAM_NAME = "audience"; + public static final String SCOPE_PARAM_NAME = "scope"; + public static final String GRANT_TYPE_PARAM_NAME = "grant_type"; - public static final String JSON_PROPERTY_CLIENT_SECRET = "client_secret"; - private String clientSecret; + private final Map parameters = new HashMap<>(); - public static final String JSON_PROPERTY_AUDIENCE = "audience"; - private String audience; - - public static final String JSON_PROPERTY_SCOPE = "scope"; - private String scope; - - public static final String JSON_PROPERTY_GRANT_TYPE = "grant_type"; - private String grantType; - - @JsonCreator - public CredentialsFlowRequest() {} - - @JsonProperty(JSON_PROPERTY_CLIENT_ID) - public String getClientId() { - return clientId; - } - - @JsonProperty(JSON_PROPERTY_CLIENT_ID) - public void setClientId(String clientId) { - this.clientId = clientId; - } - - @JsonProperty(JSON_PROPERTY_CLIENT_SECRET) - public String getClientSecret() { - return clientSecret; - } - - @JsonProperty(JSON_PROPERTY_CLIENT_SECRET) - public void setClientSecret(String clientSecret) { - this.clientSecret = clientSecret; - } - - @JsonProperty(JSON_PROPERTY_AUDIENCE) - @JsonInclude(JsonInclude.Include.NON_EMPTY) - public String getAudience() { - return audience; + public CredentialsFlowRequest(String clientId, String clientSecret) { + this.parameters.put(CLIENT_ID_PARAM_NAME, clientId); + this.parameters.put(CLIENT_SECRET_PARAM_NAME, clientSecret); + this.parameters.put(GRANT_TYPE_PARAM_NAME, "client_credentials"); } - @JsonProperty(JSON_PROPERTY_AUDIENCE) - public void setAudience(String audience) { - this.audience = audience; - } - - @JsonProperty(JSON_PROPERTY_SCOPE) - @JsonInclude(JsonInclude.Include.NON_EMPTY) - public String getScope() { - return scope; - } - - @JsonProperty(JSON_PROPERTY_SCOPE) public void setScope(String scope) { - this.scope = scope; + this.parameters.put(SCOPE_PARAM_NAME, scope); } - @JsonProperty(JSON_PROPERTY_GRANT_TYPE) - public String getGrantType() { - return grantType; + public void setAudience(String audience) { + this.parameters.put(AUDIENCE_PARAM_NAME, audience); } - @JsonProperty(JSON_PROPERTY_GRANT_TYPE) - public void setGrantType(String grantType) { - this.grantType = grantType; + public String buildFormRequestBody() { + return parameters.entrySet() + .stream() + .filter(e -> e.getValue() != null) + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); } } diff --git a/config/clients/java/template/creds-OAuth2Client.java.mustache b/config/clients/java/template/creds-OAuth2Client.java.mustache index f05f962e..f0629d6d 100644 --- a/config/clients/java/template/creds-OAuth2Client.java.mustache +++ b/config/clients/java/template/creds-OAuth2Client.java.mustache @@ -1,12 +1,11 @@ {{>licenseInfo}} package {{authPackage}}; -import com.fasterxml.jackson.databind.ObjectMapper; import {{invokerPackage}}.*; import {{configPackage}}.*; import {{errorsPackage}}.ApiException; import {{errorsPackage}}.FgaInvalidParameterException; -import java.io.IOException; + import java.net.URI; import java.net.http.HttpRequest; import java.time.Instant; @@ -30,12 +29,9 @@ public class OAuth2Client { this.apiClient = apiClient; this.apiTokenIssuer = buildApiTokenIssuer(clientCredentials.getApiTokenIssuer()); - this.authRequest = new CredentialsFlowRequest(); - this.authRequest.setClientId(clientCredentials.getClientId()); - this.authRequest.setClientSecret(clientCredentials.getClientSecret()); + this.authRequest = new CredentialsFlowRequest(clientCredentials.getClientId(), clientCredentials.getClientSecret()); this.authRequest.setAudience(clientCredentials.getApiAudience()); this.authRequest.setScope(clientCredentials.getScopes()); - this.authRequest.setGrantType("client_credentials"); } /** @@ -62,22 +58,15 @@ public class OAuth2Client { */ private CompletableFuture exchangeToken() throws ApiException, FgaInvalidParameterException { - try { - byte[] body = apiClient.getObjectMapper().writeValueAsBytes(authRequest); Configuration config = new Configuration().apiUrl(apiTokenIssuer); - HttpRequest.Builder requestBuilder = ApiClient.requestBuilder("POST", "", body, config) - .header("Content-Type", "application/x-www-form-urlencoded"); - + HttpRequest.Builder requestBuilder = ApiClient.formRequestBuilder("POST", "", this.authRequest.buildFormRequestBody(), config); HttpRequest request = requestBuilder.build(); return new HttpRequestAttempt<>(request, "exchangeToken", CredentialsFlowResponse.class, apiClient, config) .attemptHttpRequest() .thenApply(ApiResponse::getData); - } catch (IOException e) { - throw new ApiException(e); - } } private static String buildApiTokenIssuer(String issuer) throws FgaInvalidParameterException { diff --git a/config/clients/java/template/creds-OAuth2ClientTest.java.mustache b/config/clients/java/template/creds-OAuth2ClientTest.java.mustache index 4217c156..3f2af6ca 100644 --- a/config/clients/java/template/creds-OAuth2ClientTest.java.mustache +++ b/config/clients/java/template/creds-OAuth2ClientTest.java.mustache @@ -1,22 +1,26 @@ +{{>licenseInfo}} package {{authPackage}}; -import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - import com.fasterxml.jackson.databind.ObjectMapper; import com.pgssoft.httpclient.HttpClientMock; import {{clientPackage}}.ApiClient; import {{configPackage}}.*; import {{errorsPackage}}.FgaInvalidParameterException; -import java.util.stream.Stream; - import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.core.StringContains.containsString; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + class OAuth2ClientTest { private static final String CLIENT_ID = "client"; private static final String CLIENT_SECRET = "secret"; @@ -42,16 +46,27 @@ class OAuth2ClientTest { "https://issuer.fga.example:8080/some_endpoint")); } + private String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + @ParameterizedTest @MethodSource("apiTokenIssuers") public void exchangeAuth0Token(String apiTokenIssuer, String tokenEndpointUrl) throws Exception { // Given OAuth2Client auth0 = newAuth0Client(apiTokenIssuer); - String expectedPostBody = String.format( - "{\"client_id\":\"%s\",\"client_secret\":\"%s\",\"audience\":\"%s\",\"grant_type\":\"%s\"}", - CLIENT_ID, CLIENT_SECRET, AUDIENCE, GRANT_TYPE); + String responseBody = String.format("{\"access_token\":\"%s\"}", ACCESS_TOKEN); - mockHttpClient.onPost(tokenEndpointUrl).withBody(is(expectedPostBody)).doReturn(200, responseBody); + mockHttpClient + .onPost(tokenEndpointUrl) + .withBody( + allOf( + containsString(String.format("client_id=%s", CLIENT_ID)), + containsString(String.format("client_secret=%s", CLIENT_SECRET)), + containsString(String.format("audience=%s", AUDIENCE)), + containsString(String.format("grant_type=%s", GRANT_TYPE))) + ) + .doReturn(200, responseBody); // When String result = auth0.getAccessToken().get(); @@ -60,7 +75,14 @@ class OAuth2ClientTest { mockHttpClient .verify() .post(tokenEndpointUrl) - .withBody(is(expectedPostBody)) + .withBody( + allOf( + containsString(String.format("client_id=%s", CLIENT_ID)), + containsString(String.format("client_secret=%s", CLIENT_SECRET)), + containsString(String.format("audience=%s", AUDIENCE)), + containsString(String.format("grant_type=%s", GRANT_TYPE))) + ) + .withHeader("Content-Type", "application/x-www-form-urlencoded") .called(); assertEquals(ACCESS_TOKEN, result); } @@ -69,21 +91,34 @@ class OAuth2ClientTest { @MethodSource("apiTokenIssuers") public void exchangeOAuth2Token(String apiTokenIssuer, String tokenEndpointUrl) throws Exception { // Given - OAuth2Client oAuth2 = newOAuth2Client(apiTokenIssuer); - String expectedPostBody = String.format( - "{\"client_id\":\"%s\",\"client_secret\":\"%s\",\"scope\":\"%s\",\"grant_type\":\"%s\"}", - CLIENT_ID, CLIENT_SECRET, SCOPES, GRANT_TYPE); + OAuth2Client auth0 = newOAuth2Client(apiTokenIssuer); + String responseBody = String.format("{\"access_token\":\"%s\"}", ACCESS_TOKEN); - mockHttpClient.onPost(tokenEndpointUrl).withBody(is(expectedPostBody)).doReturn(200, responseBody); + mockHttpClient + .onPost(tokenEndpointUrl) + .withBody( + allOf( + containsString(String.format("client_id=%s", CLIENT_ID)), + containsString(String.format("client_secret=%s", CLIENT_SECRET)), + containsString(String.format("scope=%s", urlEncode(SCOPES))), + containsString(String.format("grant_type=%s", GRANT_TYPE))) + ) + .doReturn(200, responseBody); // When - String result = oAuth2.getAccessToken().get(); + String result = auth0.getAccessToken().get(); // Then mockHttpClient .verify() .post(tokenEndpointUrl) - .withBody(is(expectedPostBody)) + .withBody( + allOf( + containsString(String.format("client_id=%s", CLIENT_ID)), + containsString(String.format("client_secret=%s", CLIENT_SECRET)), + containsString(String.format("scope=%s", urlEncode(SCOPES))), + containsString(String.format("grant_type=%s", GRANT_TYPE))) + ) .withHeader("Content-Type", "application/x-www-form-urlencoded") .called(); assertEquals(ACCESS_TOKEN, result); @@ -92,7 +127,8 @@ class OAuth2ClientTest { @Test public void apiTokenIssuer_invalidScheme() { // When - var exception = assertThrows(FgaInvalidParameterException.class, () -> newAuth0Client("ftp://issuer.fga.example")); + var exception = + assertThrows(FgaInvalidParameterException.class, () -> newAuth0Client("ftp://issuer.fga.example")); // Then assertEquals("Required parameter scheme was invalid when calling apiTokenIssuer.", exception.getMessage()); @@ -113,27 +149,34 @@ class OAuth2ClientTest { var exception = assertThrows(FgaInvalidParameterException.class, () -> newAuth0Client(invalidApiTokenIssuer)); // Then - assertEquals("Required parameter apiTokenIssuer was invalid when calling ClientCredentials.", exception.getMessage()); + assertEquals( + "Required parameter apiTokenIssuer was invalid when calling ClientCredentials.", + exception.getMessage()); assertInstanceOf(IllegalArgumentException.class, exception.getCause()); } private OAuth2Client newAuth0Client(String apiTokenIssuer) throws FgaInvalidParameterException { - return newClientCredentialsClient(apiTokenIssuer, new Credentials(new ClientCredentials() - .clientId(CLIENT_ID) - .clientSecret(CLIENT_SECRET) - .apiAudience(AUDIENCE) - .apiTokenIssuer(apiTokenIssuer))); + return newClientCredentialsClient( + apiTokenIssuer, + new Credentials(new ClientCredentials() + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .apiAudience(AUDIENCE) + .apiTokenIssuer(apiTokenIssuer))); } private OAuth2Client newOAuth2Client(String apiTokenIssuer) throws FgaInvalidParameterException { - return newClientCredentialsClient(apiTokenIssuer, new Credentials(new ClientCredentials() - .clientId(CLIENT_ID) - .clientSecret(CLIENT_SECRET) - .scopes(SCOPES) - .apiTokenIssuer(apiTokenIssuer))); + return newClientCredentialsClient( + apiTokenIssuer, + new Credentials(new ClientCredentials() + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .scopes(SCOPES) + .apiTokenIssuer(apiTokenIssuer))); } - private OAuth2Client newClientCredentialsClient(String apiTokenIssuer, Credentials credentials) throws FgaInvalidParameterException { + private OAuth2Client newClientCredentialsClient(String apiTokenIssuer, Credentials credentials) + throws FgaInvalidParameterException { System.setProperty("HttpRequestAttempt.debug-logging", "enable"); mockHttpClient = new HttpClientMock(); diff --git a/config/clients/java/template/libraries/native/ApiClient.mustache b/config/clients/java/template/libraries/native/ApiClient.mustache index 7a05e734..85812ad7 100644 --- a/config/clients/java/template/libraries/native/ApiClient.mustache +++ b/config/clients/java/template/libraries/native/ApiClient.mustache @@ -105,6 +105,21 @@ public class ApiClient { return builder; } + /** + * Creates a {@link HttpRequest.Builder} for a {@code x-www-form-urlencoded} request. + * @param method the HTTP method to be make. + * @param path the URL path. + * @param body the request body. It must be URL-encoded. + * @param configuration the client configuration. + * @return a configured builder. + * @throws FgaInvalidParameterException + */ + public static HttpRequest.Builder formRequestBuilder(String method, String path, String body, Configuration configuration) throws FgaInvalidParameterException { + HttpRequest.Builder builder = requestBuilder(method, path, HttpRequest.BodyPublishers.ofString(body), configuration); + builder.header("content-type", "application/x-www-form-urlencoded"); + return builder; + } + private static HttpRequest.Builder requestBuilder( String method, String path, HttpRequest.BodyPublisher bodyPublisher, Configuration configuration) throws FgaInvalidParameterException {