keycloak-aplcache

KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients

2/2/2017 11:38:54 PM

Details

diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java
index cf52423..234b632 100644
--- a/core/src/main/java/org/keycloak/OAuth2Constants.java
+++ b/core/src/main/java/org/keycloak/OAuth2Constants.java
@@ -83,6 +83,14 @@ public interface OAuth2Constants {
 
     String JWT = "JWT";
 
+    // https://tools.ietf.org/html/rfc7636#section-6.1
+    String CODE_VERIFIER = "code_verifier";
+    String CODE_CHALLENGE = "code_challenge";
+    String CODE_CHALLENGE_METHOD = "code_challenge_method";
+
+    // https://tools.ietf.org/html/rfc7636#section-6.2.2
+    String PKCE_METHOD_PLAIN = "plain";
+    String PKCE_METHOD_S256 = "S256";
 
 }
 
diff --git a/server-spi-private/src/main/java/org/keycloak/events/Errors.java b/server-spi-private/src/main/java/org/keycloak/events/Errors.java
index ea2b887..8177abf 100755
--- a/server-spi-private/src/main/java/org/keycloak/events/Errors.java
+++ b/server-spi-private/src/main/java/org/keycloak/events/Errors.java
@@ -75,4 +75,11 @@ public interface Errors {
     String PASSWORD_CONFIRM_ERROR = "password_confirm_error";
     String PASSWORD_MISSING = "password_missing";
     String PASSWORD_REJECTED = "password_rejected";
+
+    // https://tools.ietf.org/html/rfc7636
+    String CODE_VERIFIER_MISSING = "code_verifier_missing";
+    String INVALID_CODE_VERIFIER = "invalid_code_verifier";
+    String PKCE_VERIFICATION_FAILED = "pkce_verification_failed";
+    String INVALID_CODE_CHALLENGE_METHOD = "invalid_code_challenge_method";
+
 }
diff --git a/server-spi-private/src/main/java/org/keycloak/services/managers/ClientSessionCode.java b/server-spi-private/src/main/java/org/keycloak/services/managers/ClientSessionCode.java
index ef11479..f98f8fe 100755
--- a/server-spi-private/src/main/java/org/keycloak/services/managers/ClientSessionCode.java
+++ b/server-spi-private/src/main/java/org/keycloak/services/managers/ClientSessionCode.java
@@ -27,6 +27,7 @@ import org.keycloak.models.ProtocolMapperModel;
 import org.keycloak.models.RealmModel;
 import org.keycloak.models.RoleModel;
 import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.OAuth2Constants;
 
 import java.security.MessageDigest;
 import java.util.HashSet;
@@ -233,6 +234,19 @@ public class ClientSessionCode {
             sb.append('.');
             sb.append(clientSession.getId());
 
+            // https://tools.ietf.org/html/rfc7636#section-4
+            String codeChallenge = clientSession.getNote(OAuth2Constants.CODE_CHALLENGE);
+            String codeChallengeMethod = clientSession.getNote(OAuth2Constants.CODE_CHALLENGE_METHOD);
+            if (codeChallenge != null) {
+                logger.debugf("PKCE received codeChallenge = %s", codeChallenge);
+                if (codeChallengeMethod == null) {
+                    logger.debug("PKCE not received codeChallengeMethod, treating plain");
+                    codeChallengeMethod = OAuth2Constants.PKCE_METHOD_PLAIN;
+                } else {
+                    logger.debugf("PKCE received codeChallengeMethod = %s", codeChallengeMethod);
+                }
+            }
+
             String code = sb.toString();
 
             clientSession.setNote(ACTIVE_CODE, code);
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
index 467f7d1..1588321 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
@@ -50,6 +50,9 @@ import javax.ws.rs.GET;
 import javax.ws.rs.core.MultivaluedMap;
 import javax.ws.rs.core.Response;
 
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 /**
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
  */
@@ -67,6 +70,9 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
      */
     public static final String CLIENT_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX = "client_request_param_";
 
+    // https://tools.ietf.org/html/rfc7636#section-4.2
+    private static final Pattern VALID_CODE_CHALLENGE_PATTERN = Pattern.compile("^[0-9a-zA-Z\\-\\.~_]+$");
+
     private enum Action {
         REGISTER, CODE, FORGOT_CREDENTIALS
     }
@@ -113,6 +119,12 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
             return errorResponse;
         }
 
+        // https://tools.ietf.org/html/rfc7636#section-4
+        errorResponse = checkPKCEParams();
+        if (errorResponse != null) {
+            return errorResponse;
+        }
+
         createClientSession();
         // So back button doesn't work
         CacheControlUtil.noBackButtonCacheControlHeader();
@@ -258,6 +270,65 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
         return null;
     }
 
+    // https://tools.ietf.org/html/rfc7636#section-4
+    private Response checkPKCEParams() {
+        String codeChallenge = request.getCodeChallenge();
+        String codeChallengeMethod = request.getCodeChallengeMethod();
+        
+        // PKCE not adopted to OAuth2 Implicit Grant and OIDC Implicit Flow,
+        // adopted to OAuth2 Authorization Code Grant and OIDC Authorization Code Flow, Hybrid Flow
+        // Namely, flows using authorization code.
+        if (parsedResponseType.isImplicitFlow()) return null;
+        
+        if (codeChallenge == null && codeChallengeMethod != null) {
+            logger.info("PKCE supporting Client without code challenge");
+            event.error(Errors.INVALID_REQUEST);
+            return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Missing parameter: code_challenge");
+        }
+        
+        // based on code_challenge value decide whether this client(RP) supports PKCE
+        if (codeChallenge == null) {
+            logger.debug("PKCE non-supporting Client");
+            return null;
+        }
+        
+        if (codeChallengeMethod != null) {
+        	// https://tools.ietf.org/html/rfc7636#section-4.2
+        	// plain or S256
+            if (!codeChallengeMethod.equals(OIDCLoginProtocol.PKCE_METHOD_S256) && !codeChallengeMethod.equals(OIDCLoginProtocol.PKCE_METHOD_PLAIN)) {
+                logger.infof("PKCE supporting Client with invalid code challenge method not specified in PKCE, codeChallengeMethod = %s", codeChallengeMethod);
+                event.error(Errors.INVALID_REQUEST);
+                return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge_method");
+            }
+        } else {
+        	// https://tools.ietf.org/html/rfc7636#section-4.3
+        	// default code_challenge_method is plane
+        	codeChallengeMethod = OIDCLoginProtocol.PKCE_METHOD_PLAIN;
+        }
+        
+        if (!isValidPkceCodeChallenge(codeChallenge)) {
+            logger.infof("PKCE supporting Client with invalid code challenge specified in PKCE, codeChallenge = %s", codeChallenge);
+            event.error(Errors.INVALID_REQUEST);
+            return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge");
+        }
+        
+        return null;
+    }
+
+    // https://tools.ietf.org/html/rfc7636#section-4
+    private boolean isValidPkceCodeChallenge(String codeChallenge) {
+        if (codeChallenge.length() < OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MIN_LENGTH) {
+           logger.debugf("PKCE codeChallenge length under lower limit , codeChallenge = %s", codeChallenge);
+           return false;
+       }
+       if (codeChallenge.length() > OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MAX_LENGTH) {
+           logger.debugf("PKCE codeChallenge length over upper limit , codeChallenge = %s", codeChallenge);
+           return false;
+       }
+       Matcher m = VALID_CODE_CHALLENGE_PATTERN.matcher(codeChallenge);
+       return m.matches() ? true : false;
+    }
+
     private Response redirectErrorToClient(OIDCResponseMode responseMode, String error, String errorDescription) {
         OIDCRedirectUriBuilder errorResponseBuilder = OIDCRedirectUriBuilder.fromUri(redirectUri, responseMode)
                 .addParam(OAuth2Constants.ERROR, error);
@@ -303,6 +374,14 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
         if (request.getIdpHint() != null) clientSession.setNote(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
         if (request.getResponseMode() != null) clientSession.setNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM, request.getResponseMode());
 
+        // https://tools.ietf.org/html/rfc7636#section-4
+        if (request.getCodeChallenge() != null) clientSession.setNote(OIDCLoginProtocol.CODE_CHALLENGE_PARAM, request.getCodeChallenge());
+        if (request.getCodeChallengeMethod() != null) {
+        	clientSession.setNote(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM, request.getCodeChallengeMethod());
+        } else {
+        	clientSession.setNote(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM, OIDCLoginProtocol.PKCE_METHOD_PLAIN);
+        }
+
         if (request.getAdditionalReqParams() != null) {
             for (String paramName : request.getAdditionalReqParams().keySet()) {
                 clientSession.setNote(CLIENT_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java
index 998a58c..a0f874b 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java
@@ -38,6 +38,10 @@ public class AuthorizationEndpointRequest {
     String idpHint;
     Map<String, String> additionalReqParams = new HashMap<>();
 
+    // https://tools.ietf.org/html/rfc7636#section-6.1
+    String codeChallenge;
+    String codeChallengeMethod;
+
     public String getClientId() {
         return clientId;
     }
@@ -85,4 +89,15 @@ public class AuthorizationEndpointRequest {
     public Map<String, String> getAdditionalReqParams() {
         return additionalReqParams;
     }
+
+    // https://tools.ietf.org/html/rfc7636#section-6.1
+    public String getCodeChallenge() {
+        return codeChallenge;
+    }
+
+    // https://tools.ietf.org/html/rfc7636#section-6.1
+    public String getCodeChallengeMethod() {
+        return codeChallengeMethod;
+    }
+
 }
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java
index ea1c35e..346b1a6 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java
@@ -61,6 +61,11 @@ abstract class AuthzEndpointRequestParser {
         KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.UI_LOCALES_PARAM);
         KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.REQUEST_PARAM);
         KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.REQUEST_URI_PARAM);
+
+        // https://tools.ietf.org/html/rfc7636#section-6.1
+        KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.CODE_CHALLENGE_PARAM);
+        KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM);
+
     }
 
 
@@ -83,6 +88,10 @@ abstract class AuthzEndpointRequestParser {
         request.nonce = replaceIfNotNull(request.nonce, getParameter(OIDCLoginProtocol.NONCE_PARAM));
         request.maxAge = replaceIfNotNull(request.maxAge, getIntParameter(OIDCLoginProtocol.MAX_AGE_PARAM));
 
+        // https://tools.ietf.org/html/rfc7636#section-6.1
+        request.codeChallenge = replaceIfNotNull(request.codeChallenge, getParameter(OIDCLoginProtocol.CODE_CHALLENGE_PARAM));
+        request.codeChallengeMethod = replaceIfNotNull(request.codeChallengeMethod, getParameter(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM));
+
         extractAdditionalReqParams(request.additionalReqParams);
     }
 
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 03be086..8fa4341 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
@@ -25,6 +25,7 @@ import org.keycloak.OAuthErrorException;
 import org.keycloak.authentication.AuthenticationProcessor;
 import org.keycloak.common.ClientConnection;
 import org.keycloak.common.constants.ServiceAccountConstants;
+import org.keycloak.common.util.Base64Url;
 import org.keycloak.constants.AdapterConstants;
 import org.keycloak.events.Details;
 import org.keycloak.events.Errors;
@@ -63,6 +64,9 @@ import javax.ws.rs.core.Response;
 import javax.ws.rs.core.UriInfo;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.security.MessageDigest;
 
 /**
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@@ -78,6 +82,9 @@ public class TokenEndpoint {
         AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS
     }
 
+    // https://tools.ietf.org/html/rfc7636#section-4.2
+    private static final Pattern VALID_CODE_VERIFIER_PATTERN = Pattern.compile("^[0-9a-zA-Z\\-\\.~_]+$");
+
     @Context
     private KeycloakSession session;
 
@@ -266,6 +273,60 @@ public class TokenEndpoint {
             throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "Session not active", Response.Status.BAD_REQUEST);
         }
 
+        // https://tools.ietf.org/html/rfc7636#section-4.6
+        String codeVerifier = formParams.getFirst(OAuth2Constants.CODE_VERIFIER);
+        String codeChallenge = clientSession.getNote(OIDCLoginProtocol.CODE_CHALLENGE_PARAM);
+        String codeChallengeMethod = clientSession.getNote(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM);
+        String authUserId = user.getId();
+        String authUsername = user.getUsername();
+        if (authUserId == null) {
+            authUserId = "unknown";
+        }
+        if (authUsername == null) {
+            authUsername = "unknown";
+        }
+        if (codeChallenge != null && codeVerifier == null) {
+            logger.warnf("PKCE code verifier not specified, authUserId = %s, authUsername = %s", authUserId, authUsername);
+            event.error(Errors.CODE_VERIFIER_MISSING);
+            throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "PKCE code verifier not specified", Response.Status.BAD_REQUEST);
+        }
+
+        if (codeChallenge != null) {
+            // based on whether code_challenge has been stored at corresponding authorization code request previously
+        	// decide whether this client(RP) supports PKCE
+            if (!isValidPkceCodeVerifier(codeVerifier)) {
+                logger.infof("PKCE invalid code verifier");
+                event.error(Errors.INVALID_CODE_VERIFIER);
+                throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "PKCE invalid code verifier", Response.Status.BAD_REQUEST);
+            }
+            
+            logger.debugf("PKCE supporting Client, codeVerifier = %s", codeVerifier);
+            String codeVerifierEncoded = codeVerifier;
+            try {
+            	// https://tools.ietf.org/html/rfc7636#section-4.2
+            	// plain or S256
+                if (codeChallengeMethod != null && codeChallengeMethod.equals(OAuth2Constants.PKCE_METHOD_S256)) {
+                    logger.debugf("PKCE codeChallengeMethod = %s", codeChallengeMethod);                    
+                    codeVerifierEncoded = generateS256CodeChallenge(codeVerifier);
+                } else {
+                    logger.debug("PKCE codeChallengeMethod is plain");
+                    codeVerifierEncoded = codeVerifier;
+                }
+            } catch (Exception nae) {
+                logger.infof("PKCE code verification failed, not supported algorithm specified");
+                event.error(Errors.PKCE_VERIFICATION_FAILED);
+                throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "PKCE code verification failed, not supported algorithm specified", Response.Status.BAD_REQUEST);
+            }
+            if (!codeChallenge.equals(codeVerifierEncoded)) {
+                logger.warnf("PKCE verification failed. authUserId = %s, authUsername = %s", authUserId, authUsername);
+                event.error(Errors.PKCE_VERIFICATION_FAILED);
+                throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "PKCE verification failed", Response.Status.BAD_REQUEST);
+            } else {
+                logger.debugf("PKCE verification success. codeVerifierEncoded = %s, codeChallenge = %s", codeVerifierEncoded, codeChallenge);
+            }
+        }
+
+
         updateClientSession(clientSession);
         updateUserSessionFromClientAuth(userSession);
 
@@ -474,4 +535,31 @@ public class TokenEndpoint {
         return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(uriInfo, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build();
     }
 
+    // https://tools.ietf.org/html/rfc7636#section-4.1
+    private boolean isValidPkceCodeVerifier(String codeVerifier) {
+        if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) {
+            logger.infof(" Error: PKCE codeVerifier length under lower limit , codeVerifier = %s", codeVerifier);
+            return false;
+        }
+        if (codeVerifier.length() > OIDCLoginProtocol.PKCE_CODE_VERIFIER_MAX_LENGTH) {
+            logger.infof(" Error: PKCE codeVerifier length over upper limit , codeVerifier = %s", codeVerifier);
+            return false;
+        }
+        Matcher m = VALID_CODE_VERIFIER_PATTERN.matcher(codeVerifier);
+        return m.matches() ? true : false;
+    }
+
+    // https://tools.ietf.org/html/rfc7636#section-4.6
+    private String generateS256CodeChallenge(String codeVerifier) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("SHA-256");
+        md.update(codeVerifier.getBytes());
+        StringBuilder sb = new StringBuilder();
+        for (byte b : md.digest()) {
+            String hex = String.format("%02x", b);
+            sb.append(hex);
+        }
+        String codeVerifierEncoded = Base64Url.encode(sb.toString().getBytes());
+        return codeVerifierEncoded;
+    }
+ 
 }
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
index 7e41411..4c0691a 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
@@ -85,6 +85,22 @@ public class OIDCLoginProtocol implements LoginProtocol {
     public static final String CLIENT_SECRET_JWT = "client_secret_jwt";
     public static final String PRIVATE_KEY_JWT = "private_key_jwt";
 
+    // https://tools.ietf.org/html/rfc7636#section-4.3
+    public static final String CODE_CHALLENGE_PARAM = "code_challenge";
+    public static final String CODE_CHALLENGE_METHOD_PARAM = "code_challenge_method";
+
+    // https://tools.ietf.org/html/rfc7636#section-4.2
+    public static final int PKCE_CODE_CHALLENGE_MIN_LENGTH = 43;
+    public static final int PKCE_CODE_CHALLENGE_MAX_LENGTH = 128;
+
+    // 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-6.2.2
+    public static final String PKCE_METHOD_PLAIN = "plain";
+    public static final String PKCE_METHOD_S256 = "S256";
+
     private static final Logger logger = Logger.getLogger(OIDCLoginProtocol.class);
 
     protected KeycloakSession session;