keycloak-aplcache
Changes
adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/KeycloakDeploymentBuilder.java 5(+5 -0)
adapters/oidc/servlet-oauth-client/src/main/java/org/keycloak/servlet/ServletOAuthClient.java 64(+63 -1)
services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java 15(+15 -0)
services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java 9(+9 -0)
services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderConfig.java 4(+2 -2)
services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderFactory.java 2(+1 -1)
testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java 4(+2 -2)
Details
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 8664800..ba7bc5d 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 65e9456..2fd9276 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 7ec546c..f5bfad0 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<NameValuePair> 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<NameValuePair> formparams = new ArrayList<NameValuePair>();
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 9e4fa0a..67c9f08 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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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/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/core/src/main/java/org/keycloak/representations/adapters/config/AdapterConfig.java b/core/src/main/java/org/keycloak/representations/adapters/config/AdapterConfig.java
index 0a107bb..f063962 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 559df05..9f07093 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
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 e17743e..e82421f 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,7 +75,16 @@ 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";
+
+
String NOT_LOGGED_IN = "not_logged_in";
String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider";
String ILLEGAL_ORIGIN = "illegal_origin";
+
}
diff --git a/server-spi-private/src/main/java/org/keycloak/migration/migrators/MigrateTo3_0_0.java b/server-spi-private/src/main/java/org/keycloak/migration/migrators/MigrateTo3_0_0.java
index 6d2f651..4daf755 100644
--- a/server-spi-private/src/main/java/org/keycloak/migration/migrators/MigrateTo3_0_0.java
+++ b/server-spi-private/src/main/java/org/keycloak/migration/migrators/MigrateTo3_0_0.java
@@ -20,13 +20,18 @@ package org.keycloak.migration.migrators;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.ClientModel;
+import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
+import org.keycloak.representations.oidc.OIDCClientRepresentation;
+
+import java.util.Objects;
import static org.keycloak.models.AccountRoles.MANAGE_ACCOUNT;
import static org.keycloak.models.AccountRoles.MANAGE_ACCOUNT_LINKS;
import static org.keycloak.models.Constants.ACCOUNT_MANAGEMENT_CLIENT_ID;
+import static org.keycloak.models.Constants.defaultClients;
/**
* @author <a href="mailto:bburke@redhat.com">Bill Burke</a>
@@ -38,6 +43,12 @@ public class MigrateTo3_0_0 implements Migration {
@Override
public void migrate(KeycloakSession session) {
for (RealmModel realm : session.realms().getRealms()) {
+
+ realm.getClients().stream()
+ .filter(clientModel -> defaultClients.contains(clientModel.getId()))
+ .filter(clientModel -> Objects.isNull(clientModel.getProtocol()))
+ .forEach(clientModel -> clientModel.setProtocol("openid-connect"));
+
ClientModel client = realm.getClientByClientId(ACCOUNT_MANAGEMENT_CLIENT_ID);
if (client == null) continue;
RoleModel linkRole = client.getRole(MANAGE_ACCOUNT_LINKS);
diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java
index 68c83b2..260ac1d 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java
@@ -19,6 +19,9 @@ package org.keycloak.models;
import org.keycloak.OAuth2Constants;
+import java.util.Arrays;
+import java.util.Collection;
+
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
@@ -31,6 +34,8 @@ public interface Constants {
String BROKER_SERVICE_CLIENT_ID = "broker";
String REALM_MANAGEMENT_CLIENT_ID = "realm-management";
+ Collection<String> defaultClients = Arrays.asList(ACCOUNT_MANAGEMENT_CLIENT_ID, ADMIN_CLI_CLIENT_ID, BROKER_SERVICE_CLIENT_ID, REALM_MANAGEMENT_CLIENT_ID, ADMIN_CONSOLE_CLIENT_ID);
+
String INSTALLED_APP_URN = "urn:ietf:wg:oauth:2.0:oob";
String INSTALLED_APP_URL = "http://localhost";
String READ_TOKEN_ROLE = "read-token";
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;
diff --git a/services/src/main/java/org/keycloak/services/managers/RealmManager.java b/services/src/main/java/org/keycloak/services/managers/RealmManager.java
index 0921c60..b28cf2f 100755
--- a/services/src/main/java/org/keycloak/services/managers/RealmManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/RealmManager.java
@@ -148,6 +148,7 @@ public class RealmManager {
adminConsole.setPublicClient(true);
adminConsole.addRedirectUri(baseUrl + "/*");
adminConsole.setFullScopeAllowed(false);
+ adminConsole.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
RoleModel adminRole;
if (realm.getName().equals(Config.getAdminRealm())) {
@@ -182,6 +183,7 @@ public class RealmManager {
adminCli.setFullScopeAllowed(false);
adminCli.setStandardFlowEnabled(false);
adminCli.setDirectAccessGrantsEnabled(true);
+ adminCli.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
RoleModel adminRole;
if (realm.getName().equals(Config.getAdminRealm())) {
@@ -348,6 +350,7 @@ public class RealmManager {
adminRole.setScopeParamRequired(false);
realmAdminClient.setBearerOnly(true);
realmAdminClient.setFullScopeAllowed(false);
+ realmAdminClient.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
for (String r : AdminRoles.ALL_REALM_ROLES) {
addAndSetAdminRole(r, realmAdminClient, adminRole);
@@ -389,6 +392,7 @@ public class RealmManager {
String redirectUri = base + "/*";
client.addRedirectUri(redirectUri);
client.setBaseUrl(base);
+ client.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
for (String role : AccountRoles.ALL) {
client.addDefaultRole(role);
@@ -415,6 +419,7 @@ public class RealmManager {
client.setEnabled(true);
client.setName("${client_" + Constants.BROKER_SERVICE_CLIENT_ID + "}");
client.setFullScopeAllowed(false);
+ client.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
for (String role : Constants.BROKER_SERVICE_ROLES) {
RoleModel roleModel = client.addRole(role);
diff --git a/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java b/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java
index 8bc4bd5..bc83af1 100644
--- a/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java
+++ b/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java
@@ -16,7 +16,7 @@ import java.util.Optional;
/**
* Identity provider for Openshift V3. Check <a href="https://docs.openshift.com/enterprise/3.0/architecture/additional_concepts/authentication.html">official documentation</a> for more details.
*/
-public class OpenshiftV3IdentityProvider extends AbstractOAuth2IdentityProvider<OpenshifV3IdentityProviderConfig> implements SocialIdentityProvider<OpenshifV3IdentityProviderConfig> {
+public class OpenshiftV3IdentityProvider extends AbstractOAuth2IdentityProvider<OpenshiftV3IdentityProviderConfig> implements SocialIdentityProvider<OpenshiftV3IdentityProviderConfig> {
public static final String BASE_URL = "https://api.preview.openshift.com";
private static final String AUTH_RESOURCE = "/oauth/authorize";
@@ -24,7 +24,7 @@ public class OpenshiftV3IdentityProvider extends AbstractOAuth2IdentityProvider<
private static final String PROFILE_RESOURCE = "/oapi/v1/users/~";
private static final String DEFAULT_SCOPE = "user:info";
- public OpenshiftV3IdentityProvider(KeycloakSession session, OpenshifV3IdentityProviderConfig config) {
+ public OpenshiftV3IdentityProvider(KeycloakSession session, OpenshiftV3IdentityProviderConfig config) {
super(session, config);
final String baseUrl = Optional.ofNullable(config.getBaseUrl()).orElse(BASE_URL);
config.setAuthorizationUrl(baseUrl + AUTH_RESOURCE);
diff --git a/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderFactory.java b/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderFactory.java
index b370530..d9708a1 100644
--- a/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderFactory.java
+++ b/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderFactory.java
@@ -16,7 +16,7 @@ public class OpenshiftV3IdentityProviderFactory extends AbstractIdentityProvider
@Override
public OpenshiftV3IdentityProvider create(KeycloakSession keycloakSession, IdentityProviderModel identityProviderModel) {
- return new OpenshiftV3IdentityProvider(keycloakSession, new OpenshifV3IdentityProviderConfig(identityProviderModel));
+ return new OpenshiftV3IdentityProvider(keycloakSession, new OpenshiftV3IdentityProviderConfig(identityProviderModel));
}
@Override
diff --git a/services/src/test/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderTest.java b/services/src/test/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderTest.java
index e39f157..8a6ac67 100644
--- a/services/src/test/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderTest.java
+++ b/services/src/test/java/org/keycloak/social/openshift/OpenshiftV3IdentityProviderTest.java
@@ -8,7 +8,7 @@ public class OpenshiftV3IdentityProviderTest {
@Test
public void shouldConstructProviderUrls() throws Exception {
- final OpenshifV3IdentityProviderConfig config = new OpenshifV3IdentityProviderConfig(new IdentityProviderModel());
+ final OpenshiftV3IdentityProviderConfig config = new OpenshiftV3IdentityProviderConfig(new IdentityProviderModel());
config.setBaseUrl("http://openshift.io:8443");
final OpenshiftV3IdentityProvider openshiftV3IdentityProvider = new OpenshiftV3IdentityProvider(null, config);
@@ -17,7 +17,7 @@ public class OpenshiftV3IdentityProviderTest {
@Test
public void shouldConstructProviderUrlsForBaseUrlWithTrailingSlash() throws Exception {
- final OpenshifV3IdentityProviderConfig config = new OpenshifV3IdentityProviderConfig(new IdentityProviderModel());
+ final OpenshiftV3IdentityProviderConfig config = new OpenshiftV3IdentityProviderConfig(new IdentityProviderModel());
config.setBaseUrl("http://openshift.io:8443/");
final OpenshiftV3IdentityProvider openshiftV3IdentityProvider = new OpenshiftV3IdentityProvider(null, config);
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
index 1fb0a63..8d70f07 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
@@ -37,7 +37,7 @@ import org.keycloak.social.google.GoogleIdentityProvider;
import org.keycloak.social.google.GoogleIdentityProviderFactory;
import org.keycloak.social.linkedin.LinkedInIdentityProvider;
import org.keycloak.social.linkedin.LinkedInIdentityProviderFactory;
-import org.keycloak.social.openshift.OpenshifV3IdentityProviderConfig;
+import org.keycloak.social.openshift.OpenshiftV3IdentityProviderConfig;
import org.keycloak.social.openshift.OpenshiftV3IdentityProvider;
import org.keycloak.social.openshift.OpenshiftV3IdentityProviderFactory;
import org.keycloak.social.stackoverflow.StackOverflowIdentityProviderConfig;
@@ -290,7 +290,7 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
private void assertOpenshiftIdentityProviderConfig(IdentityProviderModel identityProvider) {
OpenshiftV3IdentityProvider osoIdentityProvider = new OpenshiftV3IdentityProviderFactory().create(session, identityProvider);
- OpenshifV3IdentityProviderConfig config = osoIdentityProvider.getConfig();
+ OpenshiftV3IdentityProviderConfig config = osoIdentityProvider.getConfig();
assertEquals("model-openshift-v3", config.getAlias());
assertEquals(OpenshiftV3IdentityProviderFactory.PROVIDER_ID, config.getProviderId());
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 e47deac..682e745 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
@@ -113,6 +113,11 @@ public class OAuthClient {
private Map<String, PublicKey> 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));
@@ -166,6 +171,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) {
@@ -251,6 +260,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");
@@ -615,6 +629,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();
}
@@ -730,6 +751,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 0000000..a72aa3a
--- /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 <a href="takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
+ */
+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<RealmRepresentation> 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);
+ }
+}