Merge pull request #3831 from Hitachi/master
KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients
This commit is contained in:
commit
3ce0c57e17
16 changed files with 978 additions and 2 deletions
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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";
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -5,5 +5,6 @@
|
|||
"ssl-required" : "external",
|
||||
"credentials" : {
|
||||
"secret": "password"
|
||||
}
|
||||
},
|
||||
"enable-pkce" : true
|
||||
}
|
|
@ -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";
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue