Merge pull request #3831 from Hitachi/master

KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients
This commit is contained in:
Bill Burke 2017-04-06 15:36:08 -04:00 committed by GitHub
commit 3ce0c57e17
16 changed files with 978 additions and 2 deletions

View file

@ -85,6 +85,9 @@ public class KeycloakDeployment {
protected int publicKeyCacheTtl; protected int publicKeyCacheTtl;
private PolicyEnforcer policyEnforcer; private PolicyEnforcer policyEnforcer;
// https://tools.ietf.org/html/rfc7636
protected boolean pkce = false;
public KeycloakDeployment() { public KeycloakDeployment() {
} }
@ -414,4 +417,14 @@ public class KeycloakDeployment {
public PolicyEnforcer getPolicyEnforcer() { public PolicyEnforcer getPolicyEnforcer() {
return policyEnforcer; return policyEnforcer;
} }
// https://tools.ietf.org/html/rfc7636
public boolean isPkce() {
return pkce;
}
public void setPkce(boolean pkce) {
this.pkce = pkce;
}
} }

View file

@ -98,6 +98,11 @@ public class KeycloakDeploymentBuilder {
deployment.setCorsAllowedMethods(adapterConfig.getCorsAllowedMethods()); deployment.setCorsAllowedMethods(adapterConfig.getCorsAllowedMethods());
} }
// https://tools.ietf.org/html/rfc7636
if (adapterConfig.isPkce()) {
deployment.setPkce(true);
}
deployment.setBearerOnly(adapterConfig.isBearerOnly()); deployment.setBearerOnly(adapterConfig.isBearerOnly());
deployment.setAutodetectBearerOnly(adapterConfig.isAutodetectBearerOnly()); deployment.setAutodetectBearerOnly(adapterConfig.isAutodetectBearerOnly());
deployment.setEnableBasicAuth(adapterConfig.isEnableBasicAuth()); deployment.setEnableBasicAuth(adapterConfig.isEnableBasicAuth());

View file

@ -33,6 +33,8 @@ import org.keycloak.constants.AdapterConstants;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.jboss.logging.Logger;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@ -46,6 +48,8 @@ import java.util.List;
*/ */
public class ServerRequest { public class ServerRequest {
private static Logger logger = Logger.getLogger(ServerRequest.class);
public static class HttpFailure extends Exception { public static class HttpFailure extends Exception {
private int status; private int status;
private String error; 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 { public static AccessTokenResponse invokeRefresh(KeycloakDeployment deployment, String refreshToken) throws IOException, HttpFailure {
List<NameValuePair> formparams = new ArrayList<NameValuePair>(); List<NameValuePair> formparams = new ArrayList<NameValuePair>();
formparams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.REFRESH_TOKEN)); formparams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.REFRESH_TOKEN));

View file

@ -41,12 +41,60 @@ import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.util.List; 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> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $ * @version $Revision: 1 $
*/ */
public class ServletOAuthClient extends KeycloakDeploymentDelegateOAuthClient { 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 * closes client
*/ */
@ -57,7 +105,15 @@ public class ServletOAuthClient extends KeycloakDeploymentDelegateOAuthClient {
private AccessTokenResponse resolveBearerToken(HttpServletRequest request, String redirectUri, String code) throws IOException, ServerRequest.HttpFailure { private AccessTokenResponse resolveBearerToken(HttpServletRequest request, String redirectUri, String code) throws IOException, ServerRequest.HttpFailure {
// Don't send sessionId in oauth clients for now // Don't send sessionId in oauth clients for now
KeycloakDeployment resolvedDeployment = resolveDeployment(getDeployment(), request); 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 authUrl = resolvedDeployment.getAuthUrl().clone().build().toString();
String scopeParam = TokenUtil.attachOIDCScope(scope); String scopeParam = TokenUtil.attachOIDCScope(scope);
// https://tools.ietf.org/html/rfc7636#section-4
if (resolvedDeployment.isPkce()) {
setCodeVerifier();
setCodeChallenge();
}
KeycloakUriBuilder uriBuilder = KeycloakUriBuilder.fromUri(authUrl) KeycloakUriBuilder uriBuilder = KeycloakUriBuilder.fromUri(authUrl)
.queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE) .queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE)
.queryParam(OAuth2Constants.CLIENT_ID, getClientId()) .queryParam(OAuth2Constants.CLIENT_ID, getClientId())

View file

@ -83,6 +83,14 @@ public interface OAuth2Constants {
String JWT = "JWT"; 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";
} }

View file

@ -78,6 +78,9 @@ public class AdapterConfig extends BaseAdapterConfig implements AdapterHttpClien
protected int publicKeyCacheTtl = 86400; // 1 day protected int publicKeyCacheTtl = 86400; // 1 day
@JsonProperty("policy-enforcer") @JsonProperty("policy-enforcer")
protected PolicyEnforcerConfig policyEnforcerConfig; 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}. * 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) { public void setPublicKeyCacheTtl(int publicKeyCacheTtl) {
this.publicKeyCacheTtl = publicKeyCacheTtl; this.publicKeyCacheTtl = publicKeyCacheTtl;
} }
// https://tools.ietf.org/html/rfc7636
public boolean isPkce() {
return pkce;
}
public void setPkce(boolean pkce) {
this.pkce = pkce;
}
} }

View file

@ -5,5 +5,6 @@
"ssl-required" : "external", "ssl-required" : "external",
"credentials" : { "credentials" : {
"secret": "password" "secret": "password"
} },
"enable-pkce" : true
} }

View file

@ -75,7 +75,16 @@ public interface Errors {
String PASSWORD_CONFIRM_ERROR = "password_confirm_error"; String PASSWORD_CONFIRM_ERROR = "password_confirm_error";
String PASSWORD_MISSING = "password_missing"; String PASSWORD_MISSING = "password_missing";
String PASSWORD_REJECTED = "password_rejected"; 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 NOT_LOGGED_IN = "not_logged_in";
String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider"; String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider";
String ILLEGAL_ORIGIN = "illegal_origin"; String ILLEGAL_ORIGIN = "illegal_origin";
} }

View file

@ -27,6 +27,7 @@ import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel; import org.keycloak.models.RoleModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.OAuth2Constants;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.HashSet; import java.util.HashSet;
@ -233,6 +234,19 @@ public class ClientSessionCode {
sb.append('.'); sb.append('.');
sb.append(clientSession.getId()); 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(); String code = sb.toString();
clientSession.setNote(ACTIVE_CODE, code); clientSession.setNote(ACTIVE_CODE, code);

View file

@ -85,6 +85,22 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String CLIENT_SECRET_JWT = "client_secret_jwt"; public static final String CLIENT_SECRET_JWT = "client_secret_jwt";
public static final String PRIVATE_KEY_JWT = "private_key_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); private static final Logger logger = Logger.getLogger(OIDCLoginProtocol.class);
protected KeycloakSession session; protected KeycloakSession session;

View file

@ -50,6 +50,9 @@ import javax.ws.rs.GET;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response; 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> * @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_"; 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 { private enum Action {
REGISTER, CODE, FORGOT_CREDENTIALS REGISTER, CODE, FORGOT_CREDENTIALS
} }
@ -113,6 +119,12 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
return errorResponse; return errorResponse;
} }
// https://tools.ietf.org/html/rfc7636#section-4
errorResponse = checkPKCEParams();
if (errorResponse != null) {
return errorResponse;
}
createClientSession(); createClientSession();
// So back button doesn't work // So back button doesn't work
CacheControlUtil.noBackButtonCacheControlHeader(); CacheControlUtil.noBackButtonCacheControlHeader();
@ -258,6 +270,65 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
return null; 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) { private Response redirectErrorToClient(OIDCResponseMode responseMode, String error, String errorDescription) {
OIDCRedirectUriBuilder errorResponseBuilder = OIDCRedirectUriBuilder.fromUri(redirectUri, responseMode) OIDCRedirectUriBuilder errorResponseBuilder = OIDCRedirectUriBuilder.fromUri(redirectUri, responseMode)
.addParam(OAuth2Constants.ERROR, error); .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.getIdpHint() != null) clientSession.setNote(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
if (request.getResponseMode() != null) clientSession.setNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM, request.getResponseMode()); 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) { if (request.getAdditionalReqParams() != null) {
for (String paramName : request.getAdditionalReqParams().keySet()) { for (String paramName : request.getAdditionalReqParams().keySet()) {
clientSession.setNote(CLIENT_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName)); clientSession.setNote(CLIENT_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));

View file

@ -25,6 +25,7 @@ import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Base64Url;
import org.keycloak.constants.AdapterConstants; import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
@ -63,6 +64,9 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import java.util.List; import java.util.List;
import java.util.Map; 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> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -78,6 +82,9 @@ public class TokenEndpoint {
AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS 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 @Context
private KeycloakSession session; private KeycloakSession session;
@ -266,6 +273,60 @@ public class TokenEndpoint {
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "Session not active", Response.Status.BAD_REQUEST); 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); updateClientSession(clientSession);
updateUserSessionFromClientAuth(userSession); 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(); 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;
}
} }

View file

@ -38,6 +38,10 @@ public class AuthorizationEndpointRequest {
String idpHint; String idpHint;
Map<String, String> additionalReqParams = new HashMap<>(); Map<String, String> additionalReqParams = new HashMap<>();
// https://tools.ietf.org/html/rfc7636#section-6.1
String codeChallenge;
String codeChallengeMethod;
public String getClientId() { public String getClientId() {
return clientId; return clientId;
} }
@ -85,4 +89,15 @@ public class AuthorizationEndpointRequest {
public Map<String, String> getAdditionalReqParams() { public Map<String, String> getAdditionalReqParams() {
return additionalReqParams; 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;
}
} }

View file

@ -61,6 +61,11 @@ abstract class AuthzEndpointRequestParser {
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.UI_LOCALES_PARAM); KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.UI_LOCALES_PARAM);
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.REQUEST_PARAM); KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.REQUEST_PARAM);
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.REQUEST_URI_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.nonce = replaceIfNotNull(request.nonce, getParameter(OIDCLoginProtocol.NONCE_PARAM));
request.maxAge = replaceIfNotNull(request.maxAge, getIntParameter(OIDCLoginProtocol.MAX_AGE_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); extractAdditionalReqParams(request.additionalReqParams);
} }

View file

@ -113,6 +113,11 @@ public class OAuthClient {
private Map<String, PublicKey> publicKeys = new HashMap<>(); 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 { public class LogoutUrlBuilder {
private final UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl)); private final UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl));
@ -166,6 +171,10 @@ public class OAuthClient {
nonce = null; nonce = null;
request = null; request = null;
requestUri = null; requestUri = null;
// https://tools.ietf.org/html/rfc7636#section-4
codeVerifier = null;
codeChallenge = null;
codeChallengeMethod = null;
} }
public AuthorizationEndpointResponse doLogin(String username, String password) { public AuthorizationEndpointResponse doLogin(String username, String password) {
@ -251,6 +260,11 @@ public class OAuthClient {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, clientSessionHost)); 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; UrlEncodedFormEntity formEntity = null;
try { try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
@ -615,6 +629,13 @@ public class OAuthClient {
if (requestUri != null) { if (requestUri != null) {
b.queryParam(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri); 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(); return b.build(realm).toString();
} }
@ -730,6 +751,20 @@ public class OAuthClient {
return realm; 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 { public static class AuthorizationEndpointResponse {
private boolean isRedirected; private boolean isRedirected;

View file

@ -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);
}
}