KEYCLOAK-13923 Support PKCE for OIDC based Identity Providers (#7381)

* KEYCLOAK-13923 - Support PKCE for Identity Provider

We now support usage of PKCE for OIDC based Identity Providers.

* KEYCLOAK-13923 Warn if PKCE information cannot be found code-to-token request in OIDCIdentityProvider

* KEYCLOAK-13923 Pull up PKCE handling from OIDC to OAuth IdentityProvider infrastructure

* KEYCLOAK-13923 Adding test for PKCE support for OAuth Identity providers

* KEYCLOAK-13923 Use URI from KeycloakContext instead of HttpRequest

Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>

Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Thomas Darimont 2021-01-05 14:59:59 +01:00 committed by GitHub
parent d4a36d0d9c
commit 1a7600e356
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 213 additions and 21 deletions

View file

@ -19,6 +19,7 @@ package org.keycloak.broker.oidc;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.broker.provider.AbstractIdentityProvider;
@ -28,6 +29,7 @@ import org.keycloak.broker.provider.ExchangeExternalToken;
import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.util.IdentityBrokerState;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Time;
@ -51,11 +53,13 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.vault.VaultStringSecret;
@ -103,6 +107,9 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
public static final String OAUTH2_PARAMETER_CLIENT_SECRET = "client_secret";
public static final String OAUTH2_PARAMETER_GRANT_TYPE = "grant_type";
private static final String BROKER_CODE_CHALLENGE_PARAM = "BROKER_CODE_CHALLENGE";
private static final String BROKER_CODE_CHALLENGE_METHOD_PARAM = "BROKER_CODE_CHALLENGE_METHOD";
public AbstractOAuth2IdentityProvider(KeycloakSession session, C config) {
super(session, config);
@ -349,6 +356,18 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
uriBuilder.queryParam(forwardParameter, parameter);
}
}
if (getConfig().isPkceEnabled()) {
String codeVerifier = PkceUtils.generateCodeVerifier();
String codeChallengeMethod = getConfig().getPkceMethod();
request.getAuthenticationSession().setClientNote(BROKER_CODE_CHALLENGE_PARAM, codeVerifier);
request.getAuthenticationSession().setClientNote(BROKER_CODE_CHALLENGE_METHOD_PARAM, codeChallengeMethod);
String codeChallenge = PkceUtils.encodeCodeChallenge(codeVerifier, codeChallengeMethod);
uriBuilder.queryParam(OAuth2Constants.CODE_CHALLENGE, codeChallenge);
uriBuilder.queryParam(OAuth2Constants.CODE_CHALLENGE_METHOD, codeChallengeMethod);
}
return uriBuilder;
}
@ -384,6 +403,7 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
}
public SimpleHttp authenticateTokenRequest(final SimpleHttp tokenRequest) {
if (getConfig().isJWTAuthentication()) {
String jws = new JWSBuilder().type(OAuth2Constants.JWT).jsonContent(generateToken()).sign(getSignatureContext());
return tokenRequest
@ -442,6 +462,9 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
@Context
protected HttpHeaders headers;
@Context
protected HttpRequest httpRequest;
public Endpoint(AuthenticationCallback callback, RealmModel realm, EventBuilder event) {
this.callback = callback;
this.realm = realm;
@ -507,6 +530,45 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
.param(OAUTH2_PARAMETER_REDIRECT_URI, Urls.identityProviderAuthnResponse(context.getUri().getBaseUri(),
getConfig().getAlias(), context.getRealm().getName()).toString())
.param(OAUTH2_PARAMETER_GRANT_TYPE, OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE);
if (getConfig().isPkceEnabled()) {
// reconstruct the original code verifier that was used to generate the code challenge from the HttpRequest.
String stateParam = session.getContext().getUri().getQueryParameters().getFirst(OAuth2Constants.STATE);
if (stateParam == null) {
logger.warn("Cannot lookup PKCE code_verifier: state param is missing.");
return tokenRequest;
}
RealmModel realm = context.getRealm();
IdentityBrokerState idpBrokerState = IdentityBrokerState.encoded(stateParam);
ClientModel client = realm.getClientByClientId(idpBrokerState.getClientId());
AuthenticationSessionModel authSession = ClientSessionCode.getClientSession(
idpBrokerState.getEncoded(),
idpBrokerState.getTabId(),
session,
realm,
client,
event,
AuthenticationSessionModel.class);
if (authSession == null) {
logger.warnf("Cannot lookup PKCE code_verifier: authSession not found. state=%s", stateParam);
return tokenRequest;
}
String brokerCodeChallenge = authSession.getClientNote(BROKER_CODE_CHALLENGE_PARAM);
if (brokerCodeChallenge == null) {
logger.warnf("Cannot lookup PKCE code_verifier: brokerCodeChallenge not found. state=%s", stateParam);
return tokenRequest;
}
tokenRequest.param(OAuth2Constants.CODE_VERIFIER, brokerCodeChallenge);
tokenRequest.param(OAuth2Constants.CODE_CHALLENGE_METHOD, getConfig().getPkceMethod());
}
return authenticateTokenRequest(tokenRequest);
}

View file

@ -41,7 +41,6 @@ import org.keycloak.util.JsonSerialization;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.io.IOException;
@ -140,9 +139,6 @@ public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider {
.param(AdapterConstants.CLIENT_SESSION_STATE, "n/a"); // hack to get backchannel logout to work
}
}
@Override

View file

@ -18,17 +18,23 @@ package org.keycloak.broker.oidc;
import static org.keycloak.common.util.UriUtils.checkUrl;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import java.util.Arrays;
/**
* @author Pedro Igor
*/
public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
public static final String PKCE_ENABLED = "pkceEnabled";
public static final String PKCE_METHOD = "pkceMethod";
public OAuth2IdentityProviderConfig(IdentityProviderModel model) {
super(model);
}
@ -125,6 +131,22 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
getConfig().put("forwardParameters", forwardParameters);
}
public boolean isPkceEnabled() {
return Boolean.parseBoolean(getConfig().getOrDefault(PKCE_ENABLED, "false"));
}
public void setPkceEnabled(boolean enabled) {
getConfig().put(PKCE_ENABLED, String.valueOf(enabled));
}
public String getPkceMethod() {
return getConfig().get(PKCE_METHOD);
}
public String setPkceMethod(String method) {
return getConfig().put(PKCE_METHOD, method);
}
@Override
public void validate(RealmModel realm) {
SslRequired sslRequired = realm.getSslRequired();
@ -132,5 +154,13 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
checkUrl(sslRequired, getAuthorizationUrl(), "authorization_url");
checkUrl(sslRequired, getTokenUrl(), "token_url");
checkUrl(sslRequired, getUserInfoUrl(), "userinfo_url");
if (isPkceEnabled()) {
String pkceMethod = getPkceMethod();
if (!Arrays.asList(OAuth2Constants.PKCE_METHOD_PLAIN, OAuth2Constants.PKCE_METHOD_S256).contains(pkceMethod)) {
throw new IllegalArgumentException("PKCE Method not supported: " + pkceMethod);
}
}
}
}

View file

@ -25,6 +25,7 @@ import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.ExchangeExternalToken;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.util.IdentityBrokerState;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time;
@ -44,12 +45,14 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.services.resources.RealmsResource;
@ -68,7 +71,6 @@ import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
import java.security.PublicKey;
import java.util.UUID;
/**
* @author Pedro Igor
@ -317,6 +319,11 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
super(callback, realm, event);
}
@Override
public SimpleHttp generateTokenRequest(String authorizationCode) {
SimpleHttp simpleHttp = super.generateTokenRequest(authorizationCode);
return simpleHttp;
}
@GET
@Path("logout_response")
@ -760,7 +767,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
authenticationSession.setClientNote(BROKER_NONCE_PARAM, nonce);
uriBuilder.queryParam(OIDCLoginProtocol.NONCE_PARAM, nonce);
return uriBuilder;
}

View file

@ -18,11 +18,13 @@ package org.keycloak.broker.oidc;
import static org.keycloak.common.util.UriUtils.checkUrl;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import java.util.Arrays;
/**
* @author Pedro Igor
*/
@ -33,7 +35,6 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
public static final String USE_JWKS_URL = "useJwksUrl";
public static final String VALIDATE_SIGNATURE = "validateSignature";
public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel);
}
@ -132,7 +133,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
}
}
@Override
@Override
public void validate(RealmModel realm) {
super.validate(realm);
SslRequired sslRequired = realm.getSslRequired();

View file

@ -66,6 +66,7 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlClient;
import org.keycloak.protocol.saml.SamlProtocol;
@ -121,7 +122,6 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.xml.namespace.QName;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.List;
import java.util.Map;
@ -510,7 +510,7 @@ public class TokenEndpoint {
// plain or S256
if (codeChallengeMethod != null && codeChallengeMethod.equals(OAuth2Constants.PKCE_METHOD_S256)) {
logger.debugf("PKCE codeChallengeMethod = %s", codeChallengeMethod);
codeVerifierEncoded = generateS256CodeChallenge(codeVerifier);
codeVerifierEncoded = PkceUtils.generateS256CodeChallenge(codeVerifier);
} else {
logger.debug("PKCE codeChallengeMethod is plain");
codeVerifierEncoded = codeVerifier;
@ -1356,16 +1356,8 @@ public class TokenEndpoint {
return m.matches();
}
// 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("ISO_8859_1"));
byte[] digestBytes = md.digest();
String codeVerifierEncoded = Base64Url.encode(digestBytes);
return codeVerifierEncoded;
}
private static class TokenExchangeSamlProtocol extends SamlProtocol {
final SamlClient samlClient;
TokenExchangeSamlProtocol(SamlClient samlClient) {

View file

@ -0,0 +1,54 @@
package org.keycloak.protocol.oidc.utils;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Base64Url;
import org.keycloak.models.utils.KeycloakModelUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public class PkceUtils {
public static String generateCodeVerifier() {
return Base64Url.encode(KeycloakModelUtils.generateSecret(64));
}
public static String encodeCodeChallenge(String codeVerifier, String codeChallengeMethod) {
try {
switch (codeChallengeMethod) {
case OAuth2Constants.PKCE_METHOD_S256:
return generateS256CodeChallenge(codeVerifier);
case OAuth2Constants.PKCE_METHOD_PLAIN:
// fall-trhough
default:
return codeVerifier;
}
} catch(Exception ex) {
return null;
}
}
// https://tools.ietf.org/html/rfc7636#section-4.6
public static String generateS256CodeChallenge(String codeVerifier) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(codeVerifier.getBytes(StandardCharsets.ISO_8859_1));
byte[] digestBytes = md.digest();
return Base64Url.encode(digestBytes);
}
public static boolean validateCodeChallenge(String verifier, String codeChallenge, String codeChallengeMethod) {
try {
switch (codeChallengeMethod) {
case OAuth2Constants.PKCE_METHOD_PLAIN:
return verifier.equals(codeChallenge);
case OAuth2Constants.PKCE_METHOD_S256:
return generateS256CodeChallenge(verifier).equals(codeChallenge);
default:
return false;
}
} catch(Exception ex) {
return false;
}
}
}

View file

@ -0,0 +1,23 @@
package org.keycloak.testsuite.broker;
import org.keycloak.OAuth2Constants;
import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
public class KcOidcBrokerPkceTest extends AbstractBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfiguration() {
@Override public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation provider = super.setUpIdentityProvider(syncMode);
provider.getConfig().put(OAuth2IdentityProviderConfig.PKCE_ENABLED, "true");
provider.getConfig().put(OAuth2IdentityProviderConfig.PKCE_METHOD, OAuth2Constants.PKCE_METHOD_S256);
return provider;
}
};
}
}

View file

@ -448,6 +448,12 @@ use-jwks-url=Use JWKS URL
use-jwks-url.tooltip=If the switch is on, client public keys will be downloaded from given JWKS URL. This allows great flexibility because new keys will be always re-downloaded again when client generates new keypair. If the switch is off, public key (or certificate) from the Keycloak DB is used, so when client keypair changes, you always need to import new key (or certificate) to the Keycloak DB as well.
jwks-url=JWKS URL
jwks-url.tooltip=URL where client keys in JWK format are stored. See JWK specification for more details. If you use Keycloak client adapter with "jwt" credential, you can use URL of your app with '/k_jwks' suffix. For example 'http://www.myhost.com/myapp/k_jwks' .
pkce-enabled=Use PKCE
pkce-enabled.tooltip=Use PKCE (Proof of Key-code exchange) for IdP Brokering
pkce-method=PKCE Method
pkce-method.tooltip=PKCE Method to use
pkce.plain.option=Plain
pkce.s256.option=S256
archive-format=Archive Format
archive-format.tooltip=Java keystore or PKCS12 archive format.
key-alias=Key Alias

View file

@ -274,7 +274,7 @@
</div>
<div class="form-group clearfix" data-ng-hide="identityProvider.config.useJwksUrl == 'true'">
<label class="col-md-2 control-label" for="publicKeySignatureVerifierKey">{{:: 'validating-public-key' | translate}}</label>
<label class="col-md-2 control-label" for="publicKeySignatureVerifier">{{:: 'validating-public-key' | translate}}</label>
<div class="col-md-6">
<textarea class="form-control" id="publicKeySignatureVerifier" ng-model="identityProvider.config.publicKeySignatureVerifier"></textarea>
</div>
@ -291,6 +291,27 @@
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="pkceEnabled">{{:: 'pkce-enabled' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.config.pkceEnabled" id="pkceEnabled" onoffswitchvalue on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
<kc-tooltip>{{:: 'identity-provider.pkce-enabled.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix" data-ng-hide="identityProvider.config.pkceEnabled == 'false'">
<label class="col-md-2 control-label" for="pkceMethod">{{:: 'pkce-method' | translate}}</label>
<div class="col-md-6">
<div>
<select class="form-control" id="pkceMethod" ng-model="identityProvider.config.pkceMethod">
<option value="plain">{{:: 'pkce.plain.option' | translate}}</option>
<option value="S256" selected>{{:: 'pkce.s256.option' | translate}}</option>
</select>
</div>
</div>
<kc-tooltip>{{:: 'pkce-method.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="allowedClockSkew">{{:: 'allowed-clock-skew' | translate}}</label>
<div class="col-md-6">