Repository: gravitee-io/graviteeio-access-management
Pull request #322 (2018-09-11 12:53:54)

feat(oauth2): Add PKCE support
Closes gravitee-io/graviteeio-access-management#224


Patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/granter/code/AuthorizationCodeTokenGranter.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/granter/code/AuthorizationCodeTokenGranter.java
index 96e15bead0..cd5d9d17f3 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/granter/code/AuthorizationCodeTokenGranter.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/granter/code/AuthorizationCodeTokenGranter.java
@@ -19,13 +19,17 @@ import io.gravitee.am.gateway.handler.oauth2.code.AuthorizationCodeService;
 import io.gravitee.am.gateway.handler.oauth2.exception.InvalidGrantException;
 import io.gravitee.am.gateway.handler.oauth2.exception.InvalidRequestException;
 import io.gravitee.am.gateway.handler.oauth2.granter.AbstractTokenGranter;
+import io.gravitee.am.gateway.handler.oauth2.pkce.PKCEUtils;
 import io.gravitee.am.gateway.handler.oauth2.request.OAuth2Request;
 import io.gravitee.am.gateway.handler.oauth2.request.TokenRequest;
 import io.gravitee.am.gateway.handler.oauth2.token.TokenService;
 import io.gravitee.am.gateway.handler.oauth2.utils.OAuth2Constants;
 import io.gravitee.am.model.Client;
+import io.gravitee.am.repository.oauth2.model.AuthorizationCode;
 import io.gravitee.common.util.MultiValueMap;
 import io.reactivex.Single;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Implementation of the Authorization Code Grant Flow
@@ -37,6 +41,8 @@ import io.reactivex.Single;
  */
 public class AuthorizationCodeTokenGranter extends AbstractTokenGranter {
 
+    private final Logger logger = LoggerFactory.getLogger(AuthorizationCodeTokenGranter.class);
+
     private final static String GRANT_TYPE = "authorization_code";
 
     private AuthorizationCodeService authorizationCodeService;
@@ -59,7 +65,6 @@ public class AuthorizationCodeTokenGranter extends AbstractTokenGranter {
     protected Single<OAuth2Request> createOAuth2Request(TokenRequest tokenRequest, Client client) {
         MultiValueMap<String, String> parameters = tokenRequest.getRequestParameters();
         String code = parameters.getFirst(OAuth2Constants.CODE);
-        String redirectUri = parameters.getFirst(OAuth2Constants.REDIRECT_URI);
 
         if (code == null || code.isEmpty()) {
             throw new InvalidRequestException("An authorization code must be supplied.");
@@ -67,18 +72,8 @@ public class AuthorizationCodeTokenGranter extends AbstractTokenGranter {
 
         return authorizationCodeService.remove(code, client)
                 .flatMapSingle(authorizationCode -> {
-                    // This might be null, if the authorization was done without the redirect_uri parameter
-                    // https://tools.ietf.org/html/rfc6749#section-4.1.3 (4.1.3. Access Token Request); if provided
-                    // their values MUST be identical
-                    String redirectUriApprovalParameter = authorizationCode.getRedirectUri();
-                    if (redirectUriApprovalParameter != null) {
-                        if (redirectUri == null) {
-                            throw new InvalidGrantException("Redirect URI is missing");
-                        }
-                        if (!redirectUriApprovalParameter.equals(redirectUri)) {
-                            throw new InvalidGrantException("Redirect URI mismatch.");
-                        }
-                    }
+                    checkRedirectUris(tokenRequest, authorizationCode);
+                    checkPCE(tokenRequest, authorizationCode);
 
                     return super.createOAuth2Request(tokenRequest, client)
                             .map(oAuth2Request -> {
@@ -91,4 +86,64 @@ public class AuthorizationCodeTokenGranter extends AbstractTokenGranter {
                             });
                 });
         }
+
+    private void checkRedirectUris(TokenRequest tokenRequest, AuthorizationCode authorizationCode) {
+        String redirectUri = tokenRequest.getRequestParameters().getFirst(OAuth2Constants.REDIRECT_URI);
+
+        // This might be null, if the authorization was done without the redirect_uri parameter
+        // https://tools.ietf.org/html/rfc6749#section-4.1.3 (4.1.3. Access Token Request); if provided
+        // their values MUST be identical
+        String redirectUriApprovalParameter = authorizationCode.getRedirectUri();
+        if (redirectUriApprovalParameter != null) {
+            if (redirectUri == null) {
+                throw new InvalidGrantException("Redirect URI is missing");
+            }
+            if (!redirectUriApprovalParameter.equals(redirectUri)) {
+                throw new InvalidGrantException("Redirect URI mismatch.");
+            }
+        }
+    }
+
+    /**
+     * // https://tools.ietf.org/html/rfc7636#section-4.6
+     * @param tokenRequest
+     * @param authorizationCode
+     */
+    private void checkPCE(TokenRequest tokenRequest, AuthorizationCode authorizationCode) {
+        String codeVerifier = tokenRequest.getRequestParameters().getFirst(OAuth2Constants.CODE_VERIFIER);
+        MultiValueMap<String, String> parameters = authorizationCode.getRequestParameters();
+
+        String codeChallenge = parameters.getFirst(OAuth2Constants.CODE_CHALLENGE);
+        String codeChallengeMethod = parameters.getFirst(OAuth2Constants.CODE_CHALLENGE_METHOD);
+
+        if (codeChallenge != null && codeVerifier == null) {
+            logger.debug("PKCE code_verifier parameter is missing, even if a code_challenge was initially defined");
+            throw new InvalidGrantException("Missing parameter: code_verifier");
+        }
+
+        if (codeChallenge != null) {
+            // Check that code challenge is valid
+            if (!PKCEUtils.validCodeVerifier(codeVerifier)) {
+                logger.debug("PKCE code_verifier is not valid");
+                throw new InvalidGrantException("Invalid parameter: code_verifier");
+            }
+
+            // By default, assume a plain code_challenge_method
+            String encodedCodeVerifier = codeVerifier;
+
+            // Otherwise, generate is using s256
+            if (OAuth2Constants.PKCE_METHOD_S256.equalsIgnoreCase(codeChallengeMethod)) {
+                try {
+                    encodedCodeVerifier = PKCEUtils.getS256CodeChallenge(codeVerifier);
+                } catch (Exception ex) {
+                    logger.error("Not able to generate the codeChallenge from the given code verifier according to S256 algorithm");
+                    throw new InvalidGrantException("Not supported algorithm");
+                }
+            }
+
+            if (! codeChallenge.equals(encodedCodeVerifier)) {
+                throw new InvalidGrantException("Invalid code_verifier");
+            }
+        }
+    }
 }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/pkce/PKCEUtils.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/pkce/PKCEUtils.java
new file mode 100644
index 0000000000..5ed29d1d29
--- /dev/null
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/pkce/PKCEUtils.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.gateway.handler.oauth2.pkce;
+
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.regex.Pattern;
+
+/**
+ * @author David BRASSELY (david.brassely at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+public final class PKCEUtils {
+
+    // https://tools.ietf.org/html/rfc7636#section-4.1
+    public static final int PKCE_CODE_VERIFIER_MIN_LENGTH = 43;
+    public static final int PKCE_CODE_VERIFIER_MAX_LENGTH = 128;
+
+    // https://tools.ietf.org/html/rfc7636#section-4.2
+    private static final int PKCE_CODE_CHALLENGE_MIN_LENGTH = 43;
+    private static final int PKCE_CODE_CHALLENGE_MAX_LENGTH = 128;
+
+    private static final Pattern VALID_CODE_VERIFIER_PATTERN = Pattern.compile("^[A-Za-z0-9\\-\\._~]+$");
+    private static final Pattern CODE_CHALLENGE_PATTERN = Pattern.compile("^[A-Za-z0-9\\-\\._~]+$");
+
+    /**
+     * Check that code challenge is valid
+     * <a href="https://tools.ietf.org/html/rfc7636#section-4"></a>
+     *
+     * @param codeChallenge Code challenge to validate
+     * @return
+     */
+    public static boolean validCodeChallenge(String codeChallenge) {
+        return !(codeChallenge.length() < PKCE_CODE_CHALLENGE_MIN_LENGTH
+                || codeChallenge.length() > PKCE_CODE_CHALLENGE_MAX_LENGTH
+                || !CODE_CHALLENGE_PATTERN.matcher(codeChallenge).matches());
+    }
+
+    /**
+     * Check that code verifier is valid
+     * <a href="https://tools.ietf.org/html/rfc7636#section-4.2"></a>
+     *
+     * @param codeVerifier Code verifier to validate
+     * @return
+     */
+    public static boolean validCodeVerifier(String codeVerifier) {
+        return !(codeVerifier.length() < PKCE_CODE_VERIFIER_MIN_LENGTH
+                || codeVerifier.length() > PKCE_CODE_VERIFIER_MAX_LENGTH
+                || !VALID_CODE_VERIFIER_PATTERN.matcher(codeVerifier).matches());
+    }
+
+    /**
+     * This method generate an S256 code challenge from the given code verifier.
+     * <a href="https://tools.ietf.org/html/rfc7636#section-4.6"></a>
+     *
+     * @param codeVerifier
+     * @return
+     * @throws Exception
+     */
+    public static String getS256CodeChallenge(String codeVerifier) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("SHA-256");
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(md.digest(codeVerifier.getBytes()));
+    }
+}
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/utils/OAuth2Constants.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/utils/OAuth2Constants.java
index d87a1a8dfd..4bc6491655 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/utils/OAuth2Constants.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/oauth2/utils/OAuth2Constants.java
@@ -49,4 +49,19 @@ public interface OAuth2Constants {
     String USER_OAUTH_APPROVAL = "user_oauth_approval";
 
     String SCOPE_PREFIX = "scope.";
+
+    /**
+     * Next constants are defined for PKCE support.
+     * See <a href="https://tools.ietf.org/html/rfc7636#section-6.1">PKCE</a>
+     */
+    String CODE_CHALLENGE = "code_challenge";
+    String CODE_CHALLENGE_METHOD = "code_challenge_method";
+    String CODE_VERIFIER = "code_verifier";
+
+    /**
+     * Next constants are defined for PKCE support.
+     * See <a href="https://tools.ietf.org/html/rfc7636#section-6.2.2">PKCE</a>
+     */
+    String PKCE_METHOD_PLAIN = "plain";
+    String PKCE_METHOD_S256 = "S256";
 }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oauth2/endpoint/authorization/AuthorizationRequestParseHandler.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oauth2/endpoint/authorization/AuthorizationRequestParseHandler.java
index b1114bebc7..c20d2e42ff 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oauth2/endpoint/authorization/AuthorizationRequestParseHandler.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oauth2/endpoint/authorization/AuthorizationRequestParseHandler.java
@@ -18,6 +18,7 @@ package io.gravitee.am.gateway.handler.vertx.handler.oauth2.endpoint.authorizati
 import io.gravitee.am.gateway.handler.oauth2.exception.InvalidRequestException;
 import io.gravitee.am.gateway.handler.oauth2.exception.LoginRequiredException;
 import io.gravitee.am.gateway.handler.oauth2.exception.UnsupportedResponseTypeException;
+import io.gravitee.am.gateway.handler.oauth2.pkce.PKCEUtils;
 import io.gravitee.am.gateway.handler.oauth2.request.AuthorizationRequest;
 import io.gravitee.am.gateway.handler.oauth2.utils.OAuth2Constants;
 import io.gravitee.am.gateway.handler.oauth2.utils.OIDCParameters;
@@ -61,10 +62,13 @@ public class AuthorizationRequestParseHandler implements Handler<RoutingContext>
         // proceed prompt parameter
         parsePromptParameter(context);
 
+        // proceed prompt parameter
+        parsePKCEParameter(context);
+
         context.next();
     }
 
-    protected final void parsePromptParameter(RoutingContext context) {
+    private void parsePromptParameter(RoutingContext context) {
         String prompt = context.request().getParam(OIDCParameters.PROMPT);
 
         if (prompt != null) {
@@ -80,6 +84,38 @@ public class AuthorizationRequestParseHandler implements Handler<RoutingContext>
         }
     }
 
+    private void parsePKCEParameter(RoutingContext context) {
+        String codeChallenge = context.request().getParam(OAuth2Constants.CODE_CHALLENGE);
+        String codeChallengeMethod = context.request().getParam(OAuth2Constants.CODE_CHALLENGE_METHOD);
+
+        if (codeChallenge == null && codeChallengeMethod != null) {
+            throw new InvalidRequestException("Missing parameter: code_challenge");
+        }
+
+        if (codeChallenge == null) {
+            // No code challenge provided by client
+            return;
+        }
+
+        if (codeChallengeMethod != null) {
+            // https://tools.ietf.org/html/rfc7636#section-4.2
+            // It must be plain or S256
+            if (!OAuth2Constants.PKCE_METHOD_S256.equalsIgnoreCase(codeChallengeMethod) &&
+                    !OAuth2Constants.PKCE_METHOD_PLAIN.equalsIgnoreCase(codeChallengeMethod)) {
+                throw new InvalidRequestException("Invalid parameter: code_challenge_method");
+            }
+        } else {
+            // https://tools.ietf.org/html/rfc7636#section-4.3
+            // Default code challenge is plain
+            context.request().params().set(OAuth2Constants.CODE_CHALLENGE_METHOD, OAuth2Constants.PKCE_METHOD_PLAIN);
+        }
+
+        // Check that code challenge is valid
+        if (!PKCEUtils.validCodeChallenge(codeChallenge)) {
+            throw new InvalidRequestException("Invalid parameter: code_challenge");
+        }
+    }
+
     public static AuthorizationRequestParseHandler create() {
         return new AuthorizationRequestParseHandler();
     }


Test patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/AuthorizationEndpointHandlerTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/AuthorizationEndpointHandlerTest.java
index e3ab1b2ebb..f4e760803a 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/AuthorizationEndpointHandlerTest.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/AuthorizationEndpointHandlerTest.java
@@ -322,4 +322,151 @@ public class AuthorizationEndpointHandlerTest  extends RxWebTestBase {
                 HttpStatusCode.FOUND_302, "Found", null);
     }
 
+    @Test
+    public void shouldNotInvokeAuthorizationEndpoint_noUser_code_challenge_method_without_code_challenge() throws Exception {
+        final Client client = new Client();
+        client.setId("client-id");
+        client.setClientId("client-id");
+        client.setRedirectUris(Collections.singletonList("http://localhost:9999/callback"));
+
+        when(domain.getPath()).thenReturn("test");
+        when(clientService.findByClientId("client-id")).thenReturn(Maybe.just(client));
+
+        testRequest(
+                HttpMethod.GET,
+                "/oauth/authorize?response_type=code&client_id=client-id&redirect_uri=http://localhost:9999/callback&code_challenge_method=plain",
+                null,
+                resp -> {
+                    String location = resp.headers().get("location");
+                    assertNotNull(location);
+                    assertEquals("http://localhost:9999/callback?error=invalid_request&error_description=Missing+parameter%253A+code_challenge", location);
+                },
+                HttpStatusCode.FOUND_302, "Found", null);
+    }
+
+    @Test
+    public void shouldNotInvokeAuthorizationEndpoint_noUser_invalid_code_challenge_method() throws Exception {
+        final Client client = new Client();
+        client.setId("client-id");
+        client.setClientId("client-id");
+        client.setRedirectUris(Collections.singletonList("http://localhost:9999/callback"));
+
+        when(domain.getPath()).thenReturn("test");
+        when(clientService.findByClientId("client-id")).thenReturn(Maybe.just(client));
+
+        testRequest(
+                HttpMethod.GET,
+                "/oauth/authorize?response_type=code&client_id=client-id&redirect_uri=http://localhost:9999/callback&code_challenge_method=invalid&code_challenge=challenge",
+                null,
+                resp -> {
+                    String location = resp.headers().get("location");
+                    assertNotNull(location);
+                    assertEquals("http://localhost:9999/callback?error=invalid_request&error_description=Invalid+parameter%253A+code_challenge_method", location);
+                },
+                HttpStatusCode.FOUND_302, "Found", null);
+    }
+
+    @Test
+    public void shouldNotInvokeAuthorizationEndpoint_noUser_invalid_code_challenge() throws Exception {
+        final Client client = new Client();
+        client.setId("client-id");
+        client.setClientId("client-id");
+        client.setRedirectUris(Collections.singletonList("http://localhost:9999/callback"));
+
+        when(domain.getPath()).thenReturn("test");
+        when(clientService.findByClientId("client-id")).thenReturn(Maybe.just(client));
+
+        testRequest(
+                HttpMethod.GET,
+                "/oauth/authorize?response_type=code&client_id=client-id&redirect_uri=http://localhost:9999/callback&code_challenge_method=plain&code_challenge=challenge",
+                null,
+                resp -> {
+                    String location = resp.headers().get("location");
+                    assertNotNull(location);
+                    assertEquals("http://localhost:9999/callback?error=invalid_request&error_description=Invalid+parameter%253A+code_challenge", location);
+                },
+                HttpStatusCode.FOUND_302, "Found", null);
+    }
+
+    @Test
+    public void shouldNotInvokeAuthorizationEndpoint_noUser_code_challenge_valid_plain() throws Exception {
+        final Client client = new Client();
+        client.setId("client-id");
+        client.setClientId("client-id");
+        client.setScopes(Collections.singletonList("read"));
+        client.setRedirectUris(Collections.singletonList("http://localhost:9999/callback"));
+
+        AuthorizationRequest authorizationRequest = new AuthorizationRequest();
+        authorizationRequest.setApproved(true);
+        authorizationRequest.setResponseType(OAuth2Constants.CODE);
+        authorizationRequest.setRedirectUri("http://localhost:9999/callback");
+
+        AuthorizationCode code = new AuthorizationCode();
+        code.setCode("test-code");
+
+        when(domain.getPath()).thenReturn("test");
+        when(clientService.findByClientId("client-id")).thenReturn(Maybe.just(client));
+        when(approvalService.checkApproval(any(), any(), any())).thenReturn(Single.just(authorizationRequest));
+        when(authorizationCodeService.create(any(), any())).thenReturn(Single.just(code));
+
+        router.route().order(-1).handler(new Handler<RoutingContext>() {
+            @Override
+            public void handle(RoutingContext routingContext) {
+                routingContext.setUser(new User(new io.gravitee.am.gateway.handler.vertx.auth.user.User(new io.gravitee.am.model.User())));
+                routingContext.next();
+            }
+        });
+
+        testRequest(
+                HttpMethod.GET,
+                "/oauth/authorize?response_type=code&client_id=client-id&redirect_uri=http://localhost:9999/callback&code_challenge_method=plain&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+                null,
+                resp -> {
+                    String location = resp.headers().get("location");
+                    assertNotNull(location);
+                    assertEquals("http://localhost:9999/callback?code=test-code", location);
+                },
+                HttpStatusCode.FOUND_302, "Found", null);
+    }
+
+    @Test
+    public void shouldNotInvokeAuthorizationEndpoint_noUser_code_challenge_valid_s256() throws Exception {
+        final Client client = new Client();
+        client.setId("client-id");
+        client.setClientId("client-id");
+        client.setScopes(Collections.singletonList("read"));
+        client.setRedirectUris(Collections.singletonList("http://localhost:9999/callback"));
+
+        AuthorizationRequest authorizationRequest = new AuthorizationRequest();
+        authorizationRequest.setApproved(true);
+        authorizationRequest.setResponseType(OAuth2Constants.CODE);
+        authorizationRequest.setRedirectUri("http://localhost:9999/callback");
+
+        AuthorizationCode code = new AuthorizationCode();
+        code.setCode("test-code");
+
+        when(domain.getPath()).thenReturn("test");
+        when(clientService.findByClientId("client-id")).thenReturn(Maybe.just(client));
+        when(approvalService.checkApproval(any(), any(), any())).thenReturn(Single.just(authorizationRequest));
+        when(authorizationCodeService.create(any(), any())).thenReturn(Single.just(code));
+
+        router.route().order(-1).handler(new Handler<RoutingContext>() {
+            @Override
+            public void handle(RoutingContext routingContext) {
+                routingContext.setUser(new User(new io.gravitee.am.gateway.handler.vertx.auth.user.User(new io.gravitee.am.model.User())));
+                routingContext.next();
+            }
+        });
+
+        testRequest(
+                HttpMethod.GET,
+                "/oauth/authorize?response_type=code&client_id=client-id&redirect_uri=http://localhost:9999/callback&code_challenge_method=S256&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+                null,
+                resp -> {
+                    String location = resp.headers().get("location");
+                    assertNotNull(location);
+                    assertEquals("http://localhost:9999/callback?code=test-code", location);
+                },
+                HttpStatusCode.FOUND_302, "Found", null);
+    }
 }