From 88bfa563df56dc031aa7b2f5a56d772f10914c38 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Fri, 3 Feb 2017 10:38:54 +0900 Subject: [PATCH 1/4] KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients - RFC 7636 - Server Side Implementation --- .../java/org/keycloak/OAuth2Constants.java | 8 ++ .../main/java/org/keycloak/events/Errors.java | 7 ++ .../services/managers/ClientSessionCode.java | 14 +++ .../protocol/oidc/OIDCLoginProtocol.java | 16 ++++ .../oidc/endpoints/AuthorizationEndpoint.java | 79 +++++++++++++++++ .../oidc/endpoints/TokenEndpoint.java | 88 +++++++++++++++++++ .../request/AuthorizationEndpointRequest.java | 15 ++++ .../request/AuthzEndpointRequestParser.java | 9 ++ 8 files changed, 236 insertions(+) diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index cf52423efe..234b632b04 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 ea2b8877e4..8177abf875 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 ef11479e5e..f98f8fe837 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/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 7e4141195e..4c0691a974 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; 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 467f7d1c58..1588321f31 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 Stian Thorgersen */ @@ -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/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 03be086ecd..8fa4341344 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 Stian Thorgersen @@ -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/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java index 998a58ca45..a0f874b8de 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 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 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 ea1c35ed0c..346b1a6625 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); } From fe5fe4c9684a0d1afe0af436f46714c181e33a3a Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Fri, 3 Feb 2017 12:02:54 +0900 Subject: [PATCH 2/4] KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients - RFC 7636 - Client Side Implementation --- .../keycloak/adapters/KeycloakDeployment.java | 13 ++++ .../adapters/KeycloakDeploymentBuilder.java | 5 ++ .../org/keycloak/adapters/ServerRequest.java | 60 +++++++++++++++++ .../keycloak/servlet/ServletOAuthClient.java | 64 ++++++++++++++++++- .../adapters/config/AdapterConfig.java | 13 ++++ .../src/main/webapp/WEB-INF/keycloak.json | 3 +- 6 files changed, 156 insertions(+), 2 deletions(-) diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeployment.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeployment.java index 8664800ec8..ba7bc5d759 100755 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeployment.java +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeployment.java @@ -85,6 +85,9 @@ public class KeycloakDeployment { protected int publicKeyCacheTtl; private PolicyEnforcer policyEnforcer; + // https://tools.ietf.org/html/rfc7636 + protected boolean pkce = false; + public KeycloakDeployment() { } @@ -414,4 +417,14 @@ public class KeycloakDeployment { public PolicyEnforcer getPolicyEnforcer() { return policyEnforcer; } + + // https://tools.ietf.org/html/rfc7636 + public boolean isPkce() { + return pkce; + } + + public void setPkce(boolean pkce) { + this.pkce = pkce; + } + } diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeploymentBuilder.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeploymentBuilder.java index 65e945601e..2fd92760c6 100755 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeploymentBuilder.java +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeploymentBuilder.java @@ -98,6 +98,11 @@ public class KeycloakDeploymentBuilder { deployment.setCorsAllowedMethods(adapterConfig.getCorsAllowedMethods()); } + // https://tools.ietf.org/html/rfc7636 + if (adapterConfig.isPkce()) { + deployment.setPkce(true); + } + deployment.setBearerOnly(adapterConfig.isBearerOnly()); deployment.setAutodetectBearerOnly(adapterConfig.isAutodetectBearerOnly()); deployment.setEnableBasicAuth(adapterConfig.isEnableBasicAuth()); diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/ServerRequest.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/ServerRequest.java index 7ec546c710..f5bfad0db2 100755 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/ServerRequest.java +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/ServerRequest.java @@ -33,6 +33,8 @@ import org.keycloak.constants.AdapterConstants; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.util.JsonSerialization; +import org.jboss.logging.Logger; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -46,6 +48,8 @@ import java.util.List; */ public class ServerRequest { + private static Logger logger = Logger.getLogger(ServerRequest.class); + public static class HttpFailure extends Exception { private int status; private String error; @@ -136,6 +140,62 @@ public class ServerRequest { } } + // https://tools.ietf.org/html/rfc7636#section-4 + public static AccessTokenResponse invokeAccessCodeToToken(KeycloakDeployment deployment, String code, String redirectUri, String sessionId, String codeVerifier) throws IOException, HttpFailure { + List formparams = new ArrayList<>(); + redirectUri = stripOauthParametersFromRedirect(redirectUri); + formparams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, "authorization_code")); + formparams.add(new BasicNameValuePair(OAuth2Constants.CODE, code)); + formparams.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, redirectUri)); + if (sessionId != null) { + formparams.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_STATE, sessionId)); + formparams.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, HostUtils.getHostName())); + } + // https://tools.ietf.org/html/rfc7636#section-4 + if (codeVerifier != null) { + logger.debugf("add to POST parameters of Token Request, codeVerifier = %s", codeVerifier); + formparams.add(new BasicNameValuePair(OAuth2Constants.CODE_VERIFIER, codeVerifier)); + } else { + logger.debug("add to POST parameters of Token Request without codeVerifier"); + } + + HttpPost post = new HttpPost(deployment.getTokenUrl()); + ClientCredentialsProviderUtils.setClientCredentials(deployment, post, formparams); + + UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8"); + post.setEntity(form); + HttpResponse response = deployment.getClient().execute(post); + int status = response.getStatusLine().getStatusCode(); + HttpEntity entity = response.getEntity(); + if (status != 200) { + error(status, entity); + } + if (entity == null) { + throw new HttpFailure(status, null); + } + InputStream is = entity.getContent(); + try { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + int c; + while ((c = is.read()) != -1) { + os.write(c); + } + byte[] bytes = os.toByteArray(); + String json = new String(bytes); + try { + return JsonSerialization.readValue(json, AccessTokenResponse.class); + } catch (IOException e) { + throw new IOException(json, e); + } + } finally { + try { + is.close(); + } catch (IOException ignored) { + + } + } + } + public static AccessTokenResponse invokeRefresh(KeycloakDeployment deployment, String refreshToken) throws IOException, HttpFailure { List formparams = new ArrayList(); formparams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.REFRESH_TOKEN)); diff --git a/adapters/oidc/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java b/adapters/oidc/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java index 9e4fa0ad8f..67c9f08cba 100755 --- a/adapters/oidc/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java +++ b/adapters/oidc/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java @@ -41,12 +41,60 @@ import java.io.InputStream; import java.net.URI; import java.util.List; +import org.jboss.logging.Logger; +import org.keycloak.common.util.Base64Url; +import java.security.MessageDigest; +import java.security.SecureRandom; + /** * @author Bill Burke * @version $Revision: 1 $ */ public class ServletOAuthClient extends KeycloakDeploymentDelegateOAuthClient { + // https://tools.ietf.org/html/rfc7636#section-4 + private String codeVerifier; + private String codeChallenge; + private String codeChallengeMethod = OAuth2Constants.PKCE_METHOD_S256; + private static Logger logger = Logger.getLogger(ServletOAuthClient.class); + + public static String generateSecret() { + return generateSecret(32); + } + + public static String generateSecret(int bytes) { + byte[] buf = new byte[bytes]; + new SecureRandom().nextBytes(buf); + return Base64Url.encode(buf); + } + + private void setCodeVerifier() { + codeVerifier = generateSecret(); + logger.debugf("Generated codeVerifier = %s", codeVerifier); + return; + } + + private void setCodeChallenge() { + try { + if (codeChallengeMethod.equals(OAuth2Constants.PKCE_METHOD_S256)) { + 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); + } + codeChallenge = Base64Url.encode(sb.toString().getBytes()); + } else { + codeChallenge = Base64Url.encode(codeVerifier.getBytes()); + } + logger.debugf("Encode codeChallenge = %s, codeChallengeMethod = %s", codeChallenge, codeChallengeMethod); + } catch (Exception e) { + logger.info("PKCE client side unknown hash algorithm"); + codeChallenge = Base64Url.encode(codeVerifier.getBytes()); + } + } + /** * closes client */ @@ -57,7 +105,15 @@ public class ServletOAuthClient extends KeycloakDeploymentDelegateOAuthClient { private AccessTokenResponse resolveBearerToken(HttpServletRequest request, String redirectUri, String code) throws IOException, ServerRequest.HttpFailure { // Don't send sessionId in oauth clients for now KeycloakDeployment resolvedDeployment = resolveDeployment(getDeployment(), request); - return ServerRequest.invokeAccessCodeToToken(resolvedDeployment, code, redirectUri, null); + + // https://tools.ietf.org/html/rfc7636#section-4 + if (codeVerifier != null) { + logger.debugf("Before sending Token Request, codeVerifier = %s", codeVerifier); + return ServerRequest.invokeAccessCodeToToken(resolvedDeployment, code, redirectUri, null, codeVerifier); + } else { + logger.debug("Before sending Token Request without codeVerifier"); + return ServerRequest.invokeAccessCodeToToken(resolvedDeployment, code, redirectUri, null); + } } /** @@ -94,6 +150,12 @@ public class ServletOAuthClient extends KeycloakDeploymentDelegateOAuthClient { String authUrl = resolvedDeployment.getAuthUrl().clone().build().toString(); String scopeParam = TokenUtil.attachOIDCScope(scope); + // https://tools.ietf.org/html/rfc7636#section-4 + if (resolvedDeployment.isPkce()) { + setCodeVerifier(); + setCodeChallenge(); + } + KeycloakUriBuilder uriBuilder = KeycloakUriBuilder.fromUri(authUrl) .queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE) .queryParam(OAuth2Constants.CLIENT_ID, getClientId()) diff --git a/core/src/main/java/org/keycloak/representations/adapters/config/AdapterConfig.java b/core/src/main/java/org/keycloak/representations/adapters/config/AdapterConfig.java index 0a107bb4b4..f063962a55 100755 --- a/core/src/main/java/org/keycloak/representations/adapters/config/AdapterConfig.java +++ b/core/src/main/java/org/keycloak/representations/adapters/config/AdapterConfig.java @@ -78,6 +78,9 @@ public class AdapterConfig extends BaseAdapterConfig implements AdapterHttpClien protected int publicKeyCacheTtl = 86400; // 1 day @JsonProperty("policy-enforcer") protected PolicyEnforcerConfig policyEnforcerConfig; + // https://tools.ietf.org/html/rfc7636 + @JsonProperty("enable-pkce") + protected boolean pkce = false; /** * The Proxy url to use for requests to the auth-server, configurable via the adapter config property {@code proxy-url}. @@ -244,4 +247,14 @@ public class AdapterConfig extends BaseAdapterConfig implements AdapterHttpClien public void setPublicKeyCacheTtl(int publicKeyCacheTtl) { this.publicKeyCacheTtl = publicKeyCacheTtl; } + + // https://tools.ietf.org/html/rfc7636 + public boolean isPkce() { + return pkce; + } + + public void setPkce(boolean pkce) { + this.pkce = pkce; + } + } diff --git a/examples/demo-template/third-party/src/main/webapp/WEB-INF/keycloak.json b/examples/demo-template/third-party/src/main/webapp/WEB-INF/keycloak.json index 559df05f0a..9f07093c6f 100755 --- a/examples/demo-template/third-party/src/main/webapp/WEB-INF/keycloak.json +++ b/examples/demo-template/third-party/src/main/webapp/WEB-INF/keycloak.json @@ -5,5 +5,6 @@ "ssl-required" : "external", "credentials" : { "secret": "password" - } + }, + "enable-pkce" : true } \ No newline at end of file From 6bab704bbaecfb2d6da490d7d4e4f73dc5cec702 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Fri, 3 Feb 2017 14:41:36 +0900 Subject: [PATCH 3/4] KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients - RFC 7636 - Arquillian Test Cases --- .../keycloak/testsuite/util/OAuthClient.java | 35 ++ .../OAuthProofKeyForCodeExchangeTest.java | 549 ++++++++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index d7509eddd5..001e30e684 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -111,6 +111,11 @@ public class OAuthClient { private Map publicKeys = new HashMap<>(); + // https://tools.ietf.org/html/rfc7636#section-4 + private String codeVerifier; + private String codeChallenge; + private String codeChallengeMethod; + public class LogoutUrlBuilder { private final UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl)); @@ -164,6 +169,10 @@ public class OAuthClient { nonce = null; request = null; requestUri = null; + // https://tools.ietf.org/html/rfc7636#section-4 + codeVerifier = null; + codeChallenge = null; + codeChallengeMethod = null; } public AuthorizationEndpointResponse doLogin(String username, String password) { @@ -219,6 +228,11 @@ public class OAuthClient { parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, clientSessionHost)); } + // https://tools.ietf.org/html/rfc7636#section-4.5 + if (codeVerifier != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_VERIFIER, codeVerifier)); + } + UrlEncodedFormEntity formEntity = null; try { formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); @@ -581,6 +595,13 @@ public class OAuthClient { if (requestUri != null) { b.queryParam(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri); } + // https://tools.ietf.org/html/rfc7636#section-4.3 + if (codeChallenge != null) { + b.queryParam(OAuth2Constants.CODE_CHALLENGE, codeChallenge); + } + if (codeChallengeMethod != null) { + b.queryParam(OAuth2Constants.CODE_CHALLENGE_METHOD, codeChallengeMethod); + } return b.build(realm).toString(); } @@ -696,6 +717,20 @@ public class OAuthClient { return realm; } + // https://tools.ietf.org/html/rfc7636#section-4 + public OAuthClient codeVerifier(String codeVerifier) { + this.codeVerifier = codeVerifier; + return this; + } + public OAuthClient codeChallenge(String codeChallenge) { + this.codeChallenge = codeChallenge; + return this; + } + public OAuthClient codeChallengeMethod(String codeChallengeMethod) { + this.codeChallengeMethod = codeChallengeMethod; + return this; + } + public static class AuthorizationEndpointResponse { private boolean isRedirected; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java new file mode 100644 index 0000000000..a72aa3a8d0 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuthProofKeyForCodeExchangeTest.java @@ -0,0 +1,549 @@ +package org.keycloak.testsuite.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientTemplateResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.enums.SslRequired; +import org.keycloak.common.util.Base64Url; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.models.Constants; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.mappers.HardcodedClaim; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientTemplateRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.ClientManager; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.RealmManager; +import org.keycloak.testsuite.util.RoleBuilder; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.testsuite.util.UserInfoClientUtil; +import org.keycloak.testsuite.util.UserManager; +import org.keycloak.util.BasicAuthHelper; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; +import java.io.IOException; +import java.net.URI; +import java.security.MessageDigest; +import java.util.LinkedList; +import java.util.List; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.admin.ApiUtil.findClientByClientId; +import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; +import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId; +import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createRoleNameMapper; + +//https://tools.ietf.org/html/rfc7636 + +/** + * @author Takashi Norimatsu + */ +public class OAuthProofKeyForCodeExchangeTest extends AbstractKeycloakTest { + + @Rule + public AssertEvents events = new AssertEvents(this); + + + @Override + public void beforeAbstractKeycloakTest() throws Exception { + super.beforeAbstractKeycloakTest(); + } + + @Before + public void clientConfiguration() { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").directAccessGrant(true); + /* + * Configure the default client ID. Seems like OAuthClient is keeping the state of clientID + * For example: If some test case configure oauth.clientId("sample-public-client"), other tests + * will faile and the clientID will always be "sample-public-client + * @see AccessTokenTest#testAuthorizationNegotiateHeaderIgnored() + */ + oauth.clientId("test-app"); + } + + @Override + public void addTestRealms(List testRealms) { + + RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + + UserBuilder user = UserBuilder.create() + .id(KeycloakModelUtils.generateId()) + .username("no-permissions") + .addRoles("user") + .password("password"); + realm.getUsers().add(user.build()); + + testRealms.add(realm); + + } + + @Test + public void accessTokenRequestWithoutPKCE() throws Exception { + // test case : success : A-1-1 + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + expectSuccessfulResponseFromTokenEndpoint(codeId, sessionId, code); + } + + @Test + public void accessTokenRequestInPKCEValidS256CodeChallengeMethod() throws Exception { + // test case : success : A-1-2 + String codeVerifier = "1234567890123456789012345678901234567890123"; // 43 + String codeChallenge = generateS256CodeChallenge(codeVerifier); + oauth.codeChallenge(codeChallenge); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier(codeVerifier); + + expectSuccessfulResponseFromTokenEndpoint(codeId, sessionId, code); + } + + @Test + public void accessTokenRequestInPKCEUnmatchedCodeVerifierWithS256CodeChallengeMethod() throws Exception { + // test case : failure : A-1-5 + String codeVerifier = "1234567890123456789012345678901234567890123"; + String codeChallenge = codeVerifier; + oauth.codeChallenge(codeChallenge); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier(codeVerifier); + + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_GRANT, response.getError()); + assertEquals("PKCE verification failed", response.getErrorDescription()); + + events.expectCodeToToken(codeId, sessionId).error(Errors.PKCE_VERIFICATION_FAILED).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEValidPlainCodeChallengeMethod() throws Exception { + // test case : success : A-1-3 + oauth.codeChallenge(".234567890-234567890~234567890_234567890123"); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_PLAIN); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier(".234567890-234567890~234567890_234567890123"); + + expectSuccessfulResponseFromTokenEndpoint(codeId, sessionId, code); + } + + @Test + public void accessTokenRequestInPKCEUnmachedCodeVerifierWithPlainCodeChallengeMethod() throws Exception { + // test case : failure : A-1-6 + oauth.codeChallenge("1234567890123456789012345678901234567890123"); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_PLAIN); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier("aZ_-.~1234567890123456789012345678901234567890123Za"); + + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_GRANT, response.getError()); + assertEquals("PKCE verification failed", response.getErrorDescription()); + + events.expectCodeToToken(codeId, sessionId).error(Errors.PKCE_VERIFICATION_FAILED).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEValidDefaultCodeChallengeMethod() throws Exception { + // test case : success : A-1-4 + oauth.codeChallenge("1234567890123456789012345678901234567890123"); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier("1234567890123456789012345678901234567890123"); + + expectSuccessfulResponseFromTokenEndpoint(codeId, sessionId, code); + } + + @Test + public void accessTokenRequestInPKCEWithoutCodeChallengeWithValidCodeChallengeMethod() throws Exception { + // test case : failure : A-1-7 + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_PLAIN); + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + + driver.navigate().to(b.build().toURL()); + + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + + Assert.assertTrue(errorResponse.isRedirected()); + Assert.assertEquals(errorResponse.getError(), OAuthErrorException.INVALID_REQUEST); + Assert.assertEquals(errorResponse.getErrorDescription(), "Missing parameter: code_challenge"); + + events.expectLogin().error(Errors.INVALID_REQUEST).user((String) null).session((String) null).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEInvalidUnderCodeChallengeWithS256CodeChallengeMethod() throws Exception { + // test case : failure : A-1-8 + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + oauth.codeChallenge("ABCDEFGabcdefg1234567ABCDEFGabcdefg1234567"); // 42 + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + + driver.navigate().to(b.build().toURL()); + + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + + Assert.assertTrue(errorResponse.isRedirected()); + Assert.assertEquals(errorResponse.getError(), OAuthErrorException.INVALID_REQUEST); + Assert.assertEquals(errorResponse.getErrorDescription(), "Invalid parameter: code_challenge"); + + events.expectLogin().error(Errors.INVALID_REQUEST).user((String) null).session((String) null).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEInvalidOverCodeChallengeWithPlainCodeChallengeMethod() throws Exception { + // test case : failure : A-1-9 + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_PLAIN); + oauth.codeChallenge("3fRc92kac_keic8c7al-3ncbdoaie.DDeizlck3~3fRc92kac_keic8c7al-3ncbdoaie.DDeizlck3~3fRc92kac_keic8c7al-3ncbdoaie.DDeizlck3~123456789"); // 129 + + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + + driver.navigate().to(b.build().toURL()); + + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + + Assert.assertTrue(errorResponse.isRedirected()); + Assert.assertEquals(errorResponse.getError(), OAuthErrorException.INVALID_REQUEST); + Assert.assertEquals(errorResponse.getErrorDescription(), "Invalid parameter: code_challenge"); + + events.expectLogin().error(Errors.INVALID_REQUEST).user((String) null).session((String) null).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEInvalidUnderCodeVerifierWithS256CodeChallengeMethod() throws Exception { + // test case : success : A-1-10 + String codeVerifier = "ABCDEFGabcdefg1234567ABCDEFGabcdefg1234567"; // 42 + String codeChallenge = generateS256CodeChallenge(codeVerifier); + + oauth.codeChallenge(codeChallenge); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier(codeVerifier); + + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_GRANT, response.getError()); + assertEquals("PKCE invalid code verifier", response.getErrorDescription()); + + events.expectCodeToToken(codeId, sessionId).error(Errors.INVALID_CODE_VERIFIER).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEInvalidOverCodeVerifierWithS256CodeChallengeMethod() throws Exception { + // test case : success : A-1-11 + String codeVerifier = "3fRc92kac_keic8c7al-3ncbdoaie.DDeizlck3~3fRc92kac_keic8c7al-3ncbdoaie.DDeizlck3~3fRc92kac_keic8c7al-3ncbdoaie.DDeizlck3~123456789"; // 129 + String codeChallenge = generateS256CodeChallenge(codeVerifier); + oauth.codeChallenge(codeChallenge); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier(codeVerifier); + + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_GRANT, response.getError()); + assertEquals("PKCE invalid code verifier", response.getErrorDescription()); + + events.expectCodeToToken(codeId, sessionId).error(Errors.INVALID_CODE_VERIFIER).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEWIthoutCodeVerifierWithS256CodeChallengeMethod() throws Exception { + // test case : failure : A-1-12 + String codeVerifier = "1234567890123456789012345678901234567890123"; + String codeChallenge = codeVerifier; + oauth.codeChallenge(codeChallenge); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_GRANT, response.getError()); + assertEquals("PKCE code verifier not specified", response.getErrorDescription()); + + events.expectCodeToToken(codeId, sessionId).error(Errors.CODE_VERIFIER_MISSING).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEInvalidCodeChallengeWithS256CodeChallengeMethod() throws Exception { + // test case : failure : A-1-13 + String codeVerifier = "1234567890123456789=12345678901234567890123"; + String codeChallenge = codeVerifier; + oauth.codeChallenge(codeChallenge); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + + driver.navigate().to(b.build().toURL()); + + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + + Assert.assertTrue(errorResponse.isRedirected()); + Assert.assertEquals(errorResponse.getError(), OAuthErrorException.INVALID_REQUEST); + Assert.assertEquals(errorResponse.getErrorDescription(), "Invalid parameter: code_challenge"); + + events.expectLogin().error(Errors.INVALID_REQUEST).user((String) null).session((String) null).clearDetails().assertEvent(); + } + + @Test + public void accessTokenRequestInPKCEInvalidCodeVerifierWithS256CodeChallengeMethod() throws Exception { + // test case : failure : A-1-14 + String codeVerifier = "123456789.123456789-123456789~1234$6789_123"; + String codeChallenge = generateS256CodeChallenge(codeVerifier); + oauth.codeChallenge(codeChallenge); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.codeVerifier(codeVerifier); + + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(400, response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_GRANT, response.getError()); + assertEquals("PKCE invalid code verifier", response.getErrorDescription()); + + events.expectCodeToToken(codeId, sessionId).error(Errors.INVALID_CODE_VERIFIER).clearDetails().assertEvent(); + } + + 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 codeChallenge = Base64Url.encode(sb.toString().getBytes()); + return codeChallenge; + } + + private void expectSuccessfulResponseFromTokenEndpoint(String codeId, String sessionId, String code) throws Exception { + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(200, response.getStatusCode()); + Assert.assertThat(response.getExpiresIn(), allOf(greaterThanOrEqualTo(250), lessThanOrEqualTo(300))); + Assert.assertThat(response.getRefreshExpiresIn(), allOf(greaterThanOrEqualTo(1750), lessThanOrEqualTo(1800))); + assertEquals("bearer", response.getTokenType()); + + String expectedKid = oauth.doCertsRequest("test").getKeys()[0].getKeyId(); + + JWSHeader header = new JWSInput(response.getAccessToken()).getHeader(); + assertEquals("RS256", header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertEquals(expectedKid, header.getKeyId()); + assertNull(header.getContentType()); + + header = new JWSInput(response.getIdToken()).getHeader(); + assertEquals("RS256", header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertEquals(expectedKid, header.getKeyId()); + assertNull(header.getContentType()); + + header = new JWSInput(response.getRefreshToken()).getHeader(); + assertEquals("RS256", header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertEquals(expectedKid, header.getKeyId()); + assertNull(header.getContentType()); + + AccessToken token = oauth.verifyToken(response.getAccessToken()); + + assertEquals(findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(), token.getSubject()); + Assert.assertNotEquals("test-user@localhost", token.getSubject()); + assertEquals(sessionId, token.getSessionState()); + assertEquals(1, token.getRealmAccess().getRoles().size()); + assertTrue(token.getRealmAccess().isUserInRole("user")); + assertEquals(1, token.getResourceAccess(oauth.getClientId()).getRoles().size()); + assertTrue(token.getResourceAccess(oauth.getClientId()).isUserInRole("customer-user")); + + EventRepresentation event = events.expectCodeToToken(codeId, sessionId).assertEvent(); + + assertEquals(token.getId(), event.getDetails().get(Details.TOKEN_ID)); + assertEquals(oauth.verifyRefreshToken(response.getRefreshToken()).getId(), event.getDetails().get(Details.REFRESH_TOKEN_ID)); + assertEquals(sessionId, token.getSessionState()); + + // make sure PKCE does not affect token refresh on Token Endpoint + + String refreshTokenString = response.getRefreshToken(); + RefreshToken refreshToken = oauth.verifyRefreshToken(refreshTokenString); + + Assert.assertNotNull(refreshTokenString); + Assert.assertThat(token.getExpiration() - getCurrentTime(), allOf(greaterThanOrEqualTo(200), lessThanOrEqualTo(350))); + int actual = refreshToken.getExpiration() - getCurrentTime(); + Assert.assertThat(actual, allOf(greaterThanOrEqualTo(1799), lessThanOrEqualTo(1800))); + assertEquals(sessionId, refreshToken.getSessionState()); + + setTimeOffset(2); + + OAuthClient.AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(refreshTokenString, "password"); + + AccessToken refreshedToken = oauth.verifyToken(refreshResponse.getAccessToken()); + RefreshToken refreshedRefreshToken = oauth.verifyRefreshToken(refreshResponse.getRefreshToken()); + + assertEquals(200, refreshResponse.getStatusCode()); + assertEquals(sessionId, refreshedToken.getSessionState()); + assertEquals(sessionId, refreshedRefreshToken.getSessionState()); + + Assert.assertThat(refreshResponse.getExpiresIn(), allOf(greaterThanOrEqualTo(250), lessThanOrEqualTo(300))); + Assert.assertThat(refreshedToken.getExpiration() - getCurrentTime(), allOf(greaterThanOrEqualTo(250), lessThanOrEqualTo(300))); + + Assert.assertThat(refreshedToken.getExpiration() - token.getExpiration(), allOf(greaterThanOrEqualTo(1), lessThanOrEqualTo(10))); + Assert.assertThat(refreshedRefreshToken.getExpiration() - refreshToken.getExpiration(), allOf(greaterThanOrEqualTo(1), lessThanOrEqualTo(10))); + + Assert.assertNotEquals(token.getId(), refreshedToken.getId()); + Assert.assertNotEquals(refreshToken.getId(), refreshedRefreshToken.getId()); + + assertEquals("bearer", refreshResponse.getTokenType()); + + assertEquals(findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(), refreshedToken.getSubject()); + Assert.assertNotEquals("test-user@localhost", refreshedToken.getSubject()); + + assertEquals(1, refreshedToken.getRealmAccess().getRoles().size()); + Assert.assertTrue(refreshedToken.getRealmAccess().isUserInRole("user")); + + assertEquals(1, refreshedToken.getResourceAccess(oauth.getClientId()).getRoles().size()); + Assert.assertTrue(refreshedToken.getResourceAccess(oauth.getClientId()).isUserInRole("customer-user")); + + EventRepresentation refreshEvent = events.expectRefresh(event.getDetails().get(Details.REFRESH_TOKEN_ID), sessionId).assertEvent(); + Assert.assertNotEquals(event.getDetails().get(Details.TOKEN_ID), refreshEvent.getDetails().get(Details.TOKEN_ID)); + Assert.assertNotEquals(event.getDetails().get(Details.REFRESH_TOKEN_ID), refreshEvent.getDetails().get(Details.UPDATED_REFRESH_TOKEN_ID)); + + setTimeOffset(0); + } +} From a183d50ad21b11c5bbf50b4b43b1ef175e63d955 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Wed, 29 Mar 2017 09:53:24 +0900 Subject: [PATCH 4/4] delete erroneous characters inserted by mistake --- .../src/main/java/org/keycloak/events/Errors.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3f23e7e4c5..e82421f079 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 @@ -76,7 +76,7 @@ public interface Errors { String PASSWORD_MISSING = "password_missing"; String PASSWORD_REJECTED = "password_rejected"; -   // https://tools.ietf.org/html/rfc7636 + // 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";