KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients - RFC

7636 - Server Side Implementation
This commit is contained in:
Takashi Norimatsu 2017-02-03 10:38:54 +09:00
parent 68a171f36c
commit 88bfa563df
8 changed files with 236 additions and 0 deletions

View file

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

View file

@ -75,4 +75,11 @@ public interface Errors {
String PASSWORD_CONFIRM_ERROR = "password_confirm_error";
String PASSWORD_MISSING = "password_missing";
String PASSWORD_REJECTED = "password_rejected";
// https://tools.ietf.org/html/rfc7636
String CODE_VERIFIER_MISSING = "code_verifier_missing";
String INVALID_CODE_VERIFIER = "invalid_code_verifier";
String PKCE_VERIFICATION_FAILED = "pkce_verification_failed";
String INVALID_CODE_CHALLENGE_METHOD = "invalid_code_challenge_method";
}

View file

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

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 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;

View file

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

View file

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

View file

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

View file

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