From 13f2f645b5b9c11b507ce7762e78669a8b69c393 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Wed, 24 Apr 2024 11:15:24 -0500 Subject: [PATCH 1/3] [SDK-4763] - RIch Authorization Request (RAR) --- .../java/com/auth0/client/auth/AuthAPI.java | 51 ++++++ .../com/auth0/client/auth/AuthAPITest.java | 145 +++++++++++++++++- 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/auth0/client/auth/AuthAPI.java b/src/main/java/com/auth0/client/auth/AuthAPI.java index db75aaa2..9265f1f9 100644 --- a/src/main/java/com/auth0/client/auth/AuthAPI.java +++ b/src/main/java/com/auth0/client/auth/AuthAPI.java @@ -1,12 +1,14 @@ package com.auth0.client.auth; import com.auth0.client.mgmt.ManagementAPI; +import com.auth0.json.ObjectMapperProvider; import com.auth0.json.auth.*; import com.auth0.net.*; import com.auth0.net.client.Auth0HttpClient; import com.auth0.net.client.DefaultHttpClient; import com.auth0.net.client.HttpMethod; import com.auth0.utils.Asserts; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; @@ -267,6 +269,22 @@ public String authorizeUrlWithJAR(String request) { * @return a request to execute. */ public Request pushedAuthorizationRequest(String redirectUri, String responseType, Map params) { + return pushedAuthorizationRequest(redirectUri, responseType, params, null); + } + + /** + * Builds a request to make a Pushed Authorization Request (PAR) to receive a {@code request_uri} to send to the {@code /authorize} endpoint. + * @param redirectUri the URL to redirect to after authorization has been granted by the user. Your Auth0 application + * must have this URL as one of its Allowed Callback URLs. Must be a valid non-encoded URL. + * @param responseType the response type to set. Must not be null. + * @param params an optional map of key/value pairs representing any additional parameters to send on the request. + * @param authorizationDetails A list of maps representing the value of the (optional) {code authorization_details} parameter. The list will be serialized to JSON and sent on the request. + * @see #pushedAuthorizationRequest(String, String, Map, List) + * @see RFC 9126 + * @see RFC 9396 + * @return a request to execute. + */ + public Request pushedAuthorizationRequest(String redirectUri, String responseType, Map params, List> authorizationDetails) { Asserts.assertValidUrl(redirectUri, "redirect uri"); Asserts.assertNotNull(responseType, "response type"); @@ -286,18 +304,43 @@ public Request pushedAuthorizationRequest(String re if (params != null) { params.forEach(request::addParameter); } + try { + if (Objects.nonNull(authorizationDetails)) { + String authDetailsJson = ObjectMapperProvider.getMapper().writeValueAsString(authorizationDetails); + request.addParameter("authorization_details", authDetailsJson); + } + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("'authorizationDetails' must be a list that can be serialized to JSON", e); + } return request; } /** * Builds a request to make a Pushed Authorization Request (PAR) with JWT-Secured Authorization Requests (JAR), to receive a {@code request_uri} to send to the {@code /authorize} endpoint. * @param request The signed JWT containing the authorization parameters as claims. + * @see #pushedAuthorizationRequestWithJAR(String, List) * @see Authorization Code Flow with PAR and JAR * @see RFC 9101 * @see RFC 9126 * @return a request to execute. */ public Request pushedAuthorizationRequestWithJAR(String request) { + return pushedAuthorizationRequestWithJAR(request, null); + } + + /** + * Builds a request to make a Pushed Authorization Request (PAR) with JWT-Secured Authorization Requests (JAR), to receive a {@code request_uri} to send to the {@code /authorize} endpoint. + * @param request The signed JWT containing the authorization parameters as claims. + * @param authorizationDetails A list of maps representing the value of the (optional) {code authorization_details} parameter. The list will be serialized to JSON and sent on the request. + * @see #pushedAuthorizationRequestWithJAR(String) + * @see Authorization Code Flow with PAR and JAR + * @see Authorization Code Flow with Rich Authorization Requests (RAR) + * @see RFC 9101 + * @see RFC 9126 + * @see RFC 9396 + * @return a request to execute. + */ + public Request pushedAuthorizationRequestWithJAR(String request, List> authorizationDetails) { Asserts.assertNotNull(request, "request"); String url = baseUrl @@ -313,6 +356,14 @@ public Request pushedAuthorizationRequestWithJAR(St req.addParameter("client_secret", clientSecret); } + try { + if (Objects.nonNull(authorizationDetails)) { + String authDetailsJson = ObjectMapperProvider.getMapper().writeValueAsString(authorizationDetails); + req.addParameter("authorization_details", authDetailsJson); + } + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("'authorizationDetails' must be a list that can be serialized to JSON", e); + } return req; } diff --git a/src/test/java/com/auth0/client/auth/AuthAPITest.java b/src/test/java/com/auth0/client/auth/AuthAPITest.java index 2a23a27a..776f356b 100644 --- a/src/test/java/com/auth0/client/auth/AuthAPITest.java +++ b/src/test/java/com/auth0/client/auth/AuthAPITest.java @@ -2,6 +2,7 @@ import com.auth0.client.MockServer; import com.auth0.exception.APIException; +import com.auth0.json.ObjectMapperProvider; import com.auth0.json.auth.*; import com.auth0.net.BaseRequest; import com.auth0.net.Request; @@ -11,6 +12,7 @@ import com.auth0.net.client.Auth0HttpRequest; import com.auth0.net.client.Auth0HttpResponse; import com.auth0.net.client.HttpMethod; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.mockwebserver.RecordedRequest; @@ -19,11 +21,11 @@ import org.junit.jupiter.api.Test; import java.io.FileReader; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import static com.auth0.AssertsUtil.verifyThrows; import static com.auth0.client.MockServer.*; @@ -35,6 +37,8 @@ import static org.hamcrest.Matchers.*; import static org.hamcrest.collection.IsMapContaining.hasEntry; import static org.hamcrest.collection.IsMapContaining.hasKey; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class AuthAPITest { @@ -1745,6 +1749,68 @@ public void shouldCreatePushedAuthorizationRequestWithAdditionalParams() throws assertThat(response.getExpiresIn(), notNullValue()); } + @Test + @SuppressWarnings("unchecked") + public void shouldCreatePushedAuthorizationRequestWithAuthDetails() throws Exception { + Map authorizationDetails = new HashMap<>(); + authorizationDetails.put("type", "account information"); + authorizationDetails.put("locations", Collections.singletonList("https://example.com/customers")); + authorizationDetails.put("actions", Arrays.asList("read", "write")); + List> authDetailsList = Collections.singletonList(authorizationDetails); + + Request request = api.pushedAuthorizationRequest("https://domain.com/callback", "code", null, authDetailsList); + assertThat(request, is(notNullValue())); + + server.jsonResponse(PUSHED_AUTHORIZATION_RESPONSE, 200); + PushedAuthorizationResponse response = request.execute().getBody(); + RecordedRequest recordedRequest = server.takeRequest(); + + assertThat(recordedRequest, hasMethodAndPath(HttpMethod.POST, "/oauth/par")); + assertThat(recordedRequest, hasHeader("Content-Type", "application/x-www-form-urlencoded")); + + String body = readFromRequest(recordedRequest); + assertThat(body, containsString("client_id=" + CLIENT_ID)); + assertThat(body, containsString("redirect_uri=" + "https%3A%2F%2Fdomain.com%2Fcallback")); + assertThat(body, containsString("response_type=" + "code")); + assertThat(body, containsString("client_secret=" + CLIENT_SECRET)); + + String authDetailsParam = getQueryMap(body).get("authorization_details"); + String decodedAuthDetails = URLDecoder.decode(authDetailsParam, StandardCharsets.UTF_8.name()); + TypeReference>> typeReference = new TypeReference>>() { + }; + List> deserialized = ObjectMapperProvider.getMapper().readValue(decodedAuthDetails, typeReference); + assertThat(deserialized, notNullValue()); + assertThat(deserialized, hasSize(1)); + assertThat(deserialized.get(0).get("type"), is("account information")); + + List locations = (List) deserialized.get(0).get("locations"); + List actions = (List) deserialized.get(0).get("actions"); + + assertThat(locations, hasSize(1)); + assertThat(locations.get(0), is("https://example.com/customers")); + assertThat(actions, hasSize(2)); + assertThat(actions, contains("read", "write")); + + assertThat(response, is(notNullValue())); + assertThat(response.getRequestURI(), not(emptyOrNullString())); + assertThat(response.getExpiresIn(), notNullValue()); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldThrowWhenCreatePushedAuthorizationRequestWithInvalidAuthDetails() { + // force Jackson to throw error on serialization + // see https://stackoverflow.com/questions/26716020/how-to-get-a-jsonprocessingexception-using-jackson + List mockList = mock(List.class); + when(mockList.toString()).thenReturn(mockList.getClass().getName()); + + IllegalArgumentException e = verifyThrows(IllegalArgumentException.class, + () -> api.pushedAuthorizationRequest("https://domain.com/callback", "code", null, mockList)); + + assertThat(e.getMessage(), is("'authorizationDetails' must be a list that can be serialized to JSON")); + assertThat(e.getCause(), instanceOf(JsonProcessingException.class)); + } + @Test public void shouldCreatePushedAuthorizationRequestWithoutSecret() throws Exception { AuthAPI api = AuthAPI.newBuilder(server.getBaseUrl(), CLIENT_ID).build(); @@ -1842,6 +1908,77 @@ public void shouldCreatePushedAuthorizationJarRequestWithoutSecret() throws Exce assertThat(response.getExpiresIn(), notNullValue()); } + @Test + @SuppressWarnings("unchecked") + public void shouldCreatePushedAuthorizationJarRequestWithoutAuthDetails() throws Exception { + String requestJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiIxMjM0NTYiLCJyZWRpcmVjdF91cmkiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJyZXNwb25zZV90eXBlIjoiY29kZSIsIm5vbmNlIjoiMTIzNCIsInN0YXRlIjoiNzhkeXVma2poZGYifQ.UQDz8hBIabaqatY75BvqGyiPoOqNYJQIsimUKg4_VrU"; + Map authorizationDetails = new HashMap<>(); + authorizationDetails.put("type", "account information"); + authorizationDetails.put("locations", Collections.singletonList("https://example.com/customers")); + authorizationDetails.put("actions", Arrays.asList("read", "write")); + List> authDetailsList = Collections.singletonList(authorizationDetails); + + Request request = api.pushedAuthorizationRequestWithJAR(requestJwt, authDetailsList); + assertThat(request, is(notNullValue())); + + server.jsonResponse(PUSHED_AUTHORIZATION_RESPONSE, 200); + PushedAuthorizationResponse response = request.execute().getBody(); + RecordedRequest recordedRequest = server.takeRequest(); + + assertThat(recordedRequest, hasMethodAndPath(HttpMethod.POST, "/oauth/par")); + assertThat(recordedRequest, hasHeader("Content-Type", "application/x-www-form-urlencoded")); + + String body = readFromRequest(recordedRequest); + assertThat(body, containsString("client_id=" + CLIENT_ID)); + assertThat(body, containsString("request=" + requestJwt)); + assertThat(body, containsString("client_secret=" + CLIENT_SECRET)); + + String authDetailsParam = getQueryMap(body).get("authorization_details"); + String decodedAuthDetails = URLDecoder.decode(authDetailsParam, StandardCharsets.UTF_8.name()); + TypeReference>> typeReference = new TypeReference>>() { + }; + List> deserialized = ObjectMapperProvider.getMapper().readValue(decodedAuthDetails, typeReference); + assertThat(deserialized, notNullValue()); + assertThat(deserialized, hasSize(1)); + assertThat(deserialized.get(0).get("type"), is("account information")); + + List locations = (List) deserialized.get(0).get("locations"); + List actions = (List) deserialized.get(0).get("actions"); + + assertThat(locations, hasSize(1)); + assertThat(locations.get(0), is("https://example.com/customers")); + assertThat(actions, hasSize(2)); + assertThat(actions, contains("read", "write")); + + assertThat(response, is(notNullValue())); + assertThat(response.getRequestURI(), not(emptyOrNullString())); + assertThat(response.getExpiresIn(), notNullValue()); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldThrowWhenCreatePushedAuthorizationJarRequestWithInvalidAuthDetails() { + String requestJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiIxMjM0NTYiLCJyZWRpcmVjdF91cmkiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJyZXNwb25zZV90eXBlIjoiY29kZSIsIm5vbmNlIjoiMTIzNCIsInN0YXRlIjoiNzhkeXVma2poZGYifQ.UQDz8hBIabaqatY75BvqGyiPoOqNYJQIsimUKg4_VrU"; + // force Jackson to throw error on serialization + // see https://stackoverflow.com/questions/26716020/how-to-get-a-jsonprocessingexception-using-jackson + List mockList = mock(List.class); + when(mockList.toString()).thenReturn(mockList.getClass().getName()); + + IllegalArgumentException e = verifyThrows(IllegalArgumentException.class, + () -> api.pushedAuthorizationRequestWithJAR(requestJwt, mockList)); + + assertThat(e.getMessage(), is("'authorizationDetails' must be a list that can be serialized to JSON")); + assertThat(e.getCause(), instanceOf(JsonProcessingException.class)); + } + + private Map getQueryMap(String input) { + String[] params = input.split("&"); + + return Arrays.stream(params) + .map(param -> param.split("=")) + .collect(Collectors.toMap(p -> p[0], p -> p[1])); + } + static class TestAssertionSigner implements ClientAssertionSigner { private final String token; From a9d193b26c33bc425ab7714b98b660876de66316 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Thu, 25 Apr 2024 14:48:23 -0500 Subject: [PATCH 2/3] javadoc improvements --- src/main/java/com/auth0/client/auth/AuthAPI.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/auth0/client/auth/AuthAPI.java b/src/main/java/com/auth0/client/auth/AuthAPI.java index 9265f1f9..773a1fba 100644 --- a/src/main/java/com/auth0/client/auth/AuthAPI.java +++ b/src/main/java/com/auth0/client/auth/AuthAPI.java @@ -278,10 +278,11 @@ public Request pushedAuthorizationRequest(String re * must have this URL as one of its Allowed Callback URLs. Must be a valid non-encoded URL. * @param responseType the response type to set. Must not be null. * @param params an optional map of key/value pairs representing any additional parameters to send on the request. - * @param authorizationDetails A list of maps representing the value of the (optional) {code authorization_details} parameter. The list will be serialized to JSON and sent on the request. + * @param authorizationDetails A list of maps representing the value of the (optional) {@code authorization_details} parameter, used to perform Rich Authorization Requests. The list will be serialized to JSON and sent on the request. * @see #pushedAuthorizationRequest(String, String, Map, List) * @see RFC 9126 * @see RFC 9396 + * @see Authorization Code Flow with Rich Authorization Requests (RAR) * @return a request to execute. */ public Request pushedAuthorizationRequest(String redirectUri, String responseType, Map params, List> authorizationDetails) { @@ -331,7 +332,7 @@ public Request pushedAuthorizationRequestWithJAR(St /** * Builds a request to make a Pushed Authorization Request (PAR) with JWT-Secured Authorization Requests (JAR), to receive a {@code request_uri} to send to the {@code /authorize} endpoint. * @param request The signed JWT containing the authorization parameters as claims. - * @param authorizationDetails A list of maps representing the value of the (optional) {code authorization_details} parameter. The list will be serialized to JSON and sent on the request. + * @param authorizationDetails A list of maps representing the value of the (optional) {@code authorization_details} parameter, used to perform Rich Authorization Requests. The list will be serialized to JSON and sent on the request. * @see #pushedAuthorizationRequestWithJAR(String) * @see Authorization Code Flow with PAR and JAR * @see Authorization Code Flow with Rich Authorization Requests (RAR) From 07a39af0089d71042afb9e568c1b3a070fb8aa48 Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Thu, 25 Apr 2024 17:33:14 -0500 Subject: [PATCH 3/3] nit cleanup - use statically imported mapper method, suppress warnings --- src/main/java/com/auth0/client/auth/AuthAPI.java | 6 ++++-- src/test/java/com/auth0/client/auth/AuthAPITest.java | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/auth0/client/auth/AuthAPI.java b/src/main/java/com/auth0/client/auth/AuthAPI.java index 773a1fba..a9f7a9e9 100644 --- a/src/main/java/com/auth0/client/auth/AuthAPI.java +++ b/src/main/java/com/auth0/client/auth/AuthAPI.java @@ -19,6 +19,8 @@ import java.util.Map; import java.util.Objects; +import static com.auth0.json.ObjectMapperProvider.getMapper; + /** * Class that provides an implementation of of the Authentication and Authorization API methods defined by the * Auth0 Authentication API. @@ -307,7 +309,7 @@ public Request pushedAuthorizationRequest(String re } try { if (Objects.nonNull(authorizationDetails)) { - String authDetailsJson = ObjectMapperProvider.getMapper().writeValueAsString(authorizationDetails); + String authDetailsJson = getMapper().writeValueAsString(authorizationDetails); request.addParameter("authorization_details", authDetailsJson); } } catch (JsonProcessingException e) { @@ -359,7 +361,7 @@ public Request pushedAuthorizationRequestWithJAR(St try { if (Objects.nonNull(authorizationDetails)) { - String authDetailsJson = ObjectMapperProvider.getMapper().writeValueAsString(authorizationDetails); + String authDetailsJson = getMapper().writeValueAsString(authorizationDetails); req.addParameter("authorization_details", authDetailsJson); } } catch (JsonProcessingException e) { diff --git a/src/test/java/com/auth0/client/auth/AuthAPITest.java b/src/test/java/com/auth0/client/auth/AuthAPITest.java index 776f356b..6ae92f09 100644 --- a/src/test/java/com/auth0/client/auth/AuthAPITest.java +++ b/src/test/java/com/auth0/client/auth/AuthAPITest.java @@ -260,6 +260,7 @@ public void shouldCreateUserInfoRequest() throws Exception { assertThat(response.getValues(), hasEntry("created_at", "2016-12-05T11:16:59.640Z")); assertThat(response.getValues(), hasEntry("sub", "auth0|58454...")); assertThat(response.getValues(), hasKey("identities")); + @SuppressWarnings("unchecked") List> identities = (List>) response.getValues().get("identities"); assertThat(identities, hasSize(1)); assertThat(identities.get(0), hasEntry("user_id", "58454...")); @@ -501,6 +502,7 @@ public void shouldCreateSignUpRequestWithCustomParameters() throws Exception { assertThat(body, hasEntry("connection", "db-connection")); assertThat(body, hasEntry("client_id", CLIENT_ID)); assertThat(body, hasKey("user_metadata")); + @SuppressWarnings("unchecked") Map metadata = (Map) body.get("user_metadata"); assertThat(metadata, hasEntry("age", "25")); assertThat(metadata, hasEntry("address", "123, fake street")); @@ -1008,6 +1010,7 @@ public void shouldCreateStartEmailPasswordlessFlowRequestWithCustomParams() thro assertThat(body, hasEntry("client_secret", CLIENT_SECRET)); assertThat(body, hasEntry("email", "user@domain.com")); assertThat(body, hasKey("authParams")); + @SuppressWarnings("unchecked") Map authParamsSent = (Map) body.get("authParams"); assertThat(authParamsSent, hasEntry("scope", authParams.get("scope"))); assertThat(authParamsSent, hasEntry("state", authParams.get("state"))); @@ -1797,11 +1800,11 @@ public void shouldCreatePushedAuthorizationRequestWithAuthDetails() throws Excep } @Test - @SuppressWarnings("unchecked") public void shouldThrowWhenCreatePushedAuthorizationRequestWithInvalidAuthDetails() { // force Jackson to throw error on serialization // see https://stackoverflow.com/questions/26716020/how-to-get-a-jsonprocessingexception-using-jackson - List mockList = mock(List.class); + @SuppressWarnings("unchecked") + List> mockList = mock(List.class); when(mockList.toString()).thenReturn(mockList.getClass().getName()); IllegalArgumentException e = verifyThrows(IllegalArgumentException.class,