KEYCLOAK-2604 Proof Key for Code Exchange by OAuth Public Clients - RFC
7636 - Server Side Implementation
This commit is contained in:
parent
68a171f36c
commit
88bfa563df
8 changed files with 236 additions and 0 deletions
|
@ -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";
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue