From fe5fe4c9684a0d1afe0af436f46714c181e33a3a Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Fri, 3 Feb 2017 12:02:54 +0900 Subject: [PATCH] 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