KEYCLOAK-6771 Holder of Key mechanism

OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access
Tokens
This commit is contained in:
Takashi Norimatsu 2018-06-05 09:30:09 +09:00 committed by Marek Posolda
parent f8919f8baa
commit c586c63533
24 changed files with 1397 additions and 198 deletions

View file

@ -244,4 +244,30 @@ public class AccessToken extends IDToken {
public void setAuthorization(Authorization authorization) {
this.authorization = authorization;
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3.1
public static class CertConf {
@JsonProperty("x5t#S256")
protected String certThumbprint;
public String getCertThumbprint() {
return certThumbprint;
}
public void setCertThumbprint(String certThumbprint) {
this.certThumbprint = certThumbprint;
}
}
@JsonProperty("cnf")
protected CertConf certConf;
public CertConf getCertConf() {
return certConf;
}
public void setCertConf(CertConf certConf) {
this.certConf = certConf;
}
}

View file

@ -96,6 +96,10 @@ public class OIDCClientRepresentation {
private List<String> request_uris;
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
private Boolean tls_client_certificate_bound_access_tokens;
// OIDC Session Management
private List<String> post_logout_redirect_uris;
@ -433,4 +437,13 @@ public class OIDCClientRepresentation {
this.registration_access_token = registrationAccessToken;
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
public Boolean getTlsClientCertificateBoundAccessTokens() {
return tls_client_certificate_bound_access_tokens;
}
public void setTlsClientCertificateBoundAccessTokens(Boolean tls_client_certificate_bound_access_tokens) {
this.tls_client_certificate_bound_access_tokens = tls_client_certificate_bound_access_tokens;
}
}

View file

@ -43,6 +43,10 @@ public class OIDCAdvancedConfigWrapper {
private static final String EXCLUDE_SESSION_STATE_FROM_AUTH_RESPONSE = "exclude.session.state.from.auth.response";
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
private static final String USE_MTLS_HOK_TOKEN = "tls.client.certificate.bound.access.tokens";
private final ClientModel clientModel;
private final ClientRepresentation clientRep;
@ -121,6 +125,18 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(EXCLUDE_SESSION_STATE_FROM_AUTH_RESPONSE, val);
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
public boolean isUseMtlsHokToken() {
String useUtlsHokToken = getAttribute(USE_MTLS_HOK_TOKEN);
return Boolean.parseBoolean(useUtlsHokToken);
}
public void setUseMtlsHoKToken(boolean useUtlsHokToken) {
String val = String.valueOf(useUtlsHokToken);
setAttribute(USE_MTLS_HOK_TOKEN, val);
}
private String getAttribute(String attrKey) {
if (clientModel != null) {
return clientModel.getAttribute(attrKey);

View file

@ -119,6 +119,10 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
config.setCodeChallengeMethodsSupported(DEFAULT_CODE_CHALLENGE_METHODS_SUPPORTED);
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2
config.setTlsClientCertificateBoundAccessTokens(true);
return config;
}

View file

@ -18,6 +18,7 @@
package org.keycloak.protocol.oidc;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.common.ClientConnection;
import org.keycloak.OAuth2Constants;
@ -61,6 +62,7 @@ import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil;
import org.keycloak.common.util.Time;
@ -238,8 +240,8 @@ public class TokenManager {
public RefreshResult refreshAccessToken(KeycloakSession session, UriInfo uriInfo, ClientConnection connection, RealmModel realm, ClientModel authorizedClient,
String encodedRefreshToken, EventBuilder event, HttpHeaders headers) throws OAuthErrorException {
RefreshToken refreshToken = verifyRefreshToken(session, realm, encodedRefreshToken);
String encodedRefreshToken, EventBuilder event, HttpHeaders headers, HttpRequest request) throws OAuthErrorException {
RefreshToken refreshToken = verifyRefreshToken(session, realm, authorizedClient, request, encodedRefreshToken, true);
event.user(refreshToken.getSubject()).session(refreshToken.getSessionState())
.detail(Details.REFRESH_TOKEN_ID, refreshToken.getId())
@ -265,6 +267,15 @@ public class TokenManager {
.accessToken(validation.newToken)
.generateRefreshToken();
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3.1
// bind refreshed access and refresh token with Client Certificate
AccessToken.CertConf certConf = refreshToken.getCertConf();
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
responseBuilder.getRefreshToken().setCertConf(certConf);
}
String scopeParam = validation.clientSession.getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken();
@ -307,6 +318,10 @@ public class TokenManager {
}
public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, String encodedRefreshToken, boolean checkExpiration) throws OAuthErrorException {
return verifyRefreshToken(session, realm, null, null, encodedRefreshToken, true);
}
public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, ClientModel client, HttpRequest request, String encodedRefreshToken, boolean checkExpiration) throws OAuthErrorException {
try {
RefreshToken refreshToken = toRefreshToken(session, realm, encodedRefreshToken);
@ -324,6 +339,13 @@ public class TokenManager {
}
}
// KEYCLOAK-6771 Certificate Bound Token
if (client != null && OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(refreshToken, request)) {
throw new OAuthErrorException(OAuthErrorException.UNAUTHORIZED_CLIENT, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC);
}
}
return refreshToken;
} catch (JWSInputException e) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
@ -936,7 +958,7 @@ public class TokenManager {
returnedScopes.add(s);
}
}
}
}
}
}

View file

@ -43,6 +43,7 @@ import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes;
@ -180,8 +181,11 @@ public class LogoutEndpoint {
event.error(Errors.INVALID_TOKEN);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST);
}
RefreshToken token = null;
try {
RefreshToken token = tokenManager.verifyRefreshToken(session, realm, refreshToken, false);
// KEYCLOAK-6771 Certificate Bound Token
token = tokenManager.verifyRefreshToken(session, realm, client, request, refreshToken, false);
boolean offline = TokenUtil.TOKEN_TYPE_OFFLINE.equals(token.getType());
@ -197,9 +201,16 @@ public class LogoutEndpoint {
logout(userSessionModel, offline);
}
} catch (OAuthErrorException e) {
event.error(Errors.INVALID_TOKEN);
throw new ErrorResponseException(e.getError(), e.getDescription(), Response.Status.BAD_REQUEST);
// KEYCLOAK-6771 Certificate Bound Token
if (MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC.equals(e.getDescription())) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorResponseException(e.getError(), e.getDescription(), Response.Status.UNAUTHORIZED);
} else {
event.error(Errors.INVALID_TOKEN);
throw new ErrorResponseException(e.getError(), e.getDescription(), Response.Status.BAD_REQUEST);
}
}
return Cors.add(request, Response.noContent()).auth().allowedOrigins(uriInfo, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build();
}

View file

@ -58,6 +58,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver;
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;
@ -79,10 +80,10 @@ import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import org.keycloak.utils.ProfileHelper;
@ -361,7 +362,7 @@ public class TokenEndpoint {
if (codeChallenge != null) {
// based on whether code_challenge has been stored at corresponding authorization code request previously
// decide whether this client(RP) supports PKCE
// decide whether this client(RP) supports PKCE
if (!isValidPkceCodeVerifier(codeVerifier)) {
logger.infof("PKCE invalid code verifier");
event.error(Errors.INVALID_CODE_VERIFIER);
@ -371,8 +372,8 @@ public class TokenEndpoint {
logger.debugf("PKCE supporting Client, codeVerifier = %s", codeVerifier);
String codeVerifierEncoded = codeVerifier;
try {
// https://tools.ietf.org/html/rfc7636#section-4.2
// plain or S256
// 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);
@ -404,6 +405,19 @@ public class TokenEndpoint {
.accessToken(token)
.generateRefreshToken();
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request);
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
responseBuilder.getRefreshToken().setCertConf(certConf);
} else {
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
}
}
String scopeParam = clientSession.getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken();
@ -424,7 +438,8 @@ public class TokenEndpoint {
AccessTokenResponse res;
try {
TokenManager.RefreshResult result = tokenManager.refreshAccessToken(session, uriInfo, clientConnection, realm, client, refreshToken, event, headers);
// KEYCLOAK-6771 Certificate Bound Token
TokenManager.RefreshResult result = tokenManager.refreshAccessToken(session, uriInfo, clientConnection, realm, client, refreshToken, event, headers, request);
res = result.getResponse();
if (!result.isOfflineToken()) {
@ -436,8 +451,14 @@ public class TokenEndpoint {
} catch (OAuthErrorException e) {
logger.trace(e.getMessage(), e);
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.BAD_REQUEST);
// KEYCLOAK-6771 Certificate Bound Token
if (MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC.equals(e.getDescription())) {
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.UNAUTHORIZED);
} else {
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.BAD_REQUEST);
}
}
event.success();

View file

@ -44,6 +44,7 @@ import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.utils.MediaType;
import javax.ws.rs.GET;
@ -161,6 +162,15 @@ public class UserInfoEndpoint {
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "User not found", Response.Status.BAD_REQUEST);
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if (OIDCAdvancedConfigWrapper.fromClientModel(clientModel).isUseMtlsHokToken()) {
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(token, request)) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorResponseException(OAuthErrorException.UNAUTHORIZED_CLIENT, "Client certificate missing, or its thumbprint and one in the refresh token did NOT match", Response.Status.UNAUTHORIZED);
}
}
event.user(userModel)
.detail(Details.USERNAME, userModel.getUsername());

View file

@ -107,6 +107,11 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("code_challenge_methods_supported")
private List<String> codeChallengeMethodsSupported;
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2
@JsonProperty("tls_client_certificate_bound_access_tokens")
private Boolean tlsClientCertificateBoundAccessTokens;
protected Map<String, Object> otherClaims = new HashMap<String, Object>();
public String getIssuer() {
@ -305,10 +310,21 @@ public class OIDCConfigurationRepresentation {
public List<String> getCodeChallengeMethodsSupported() {
return codeChallengeMethodsSupported;
}
public void setCodeChallengeMethodsSupported(List<String> codeChallengeMethodsSupported) {
this.codeChallengeMethodsSupported = codeChallengeMethodsSupported;
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2
public Boolean getTlsClientCertificateBoundAccessTokens() {
return tlsClientCertificateBoundAccessTokens;
}
public void setTlsClientCertificateBoundAccessTokens(Boolean tlsClientCertificateBoundAccessTokens) {
this.tlsClientCertificateBoundAccessTokens = tlsClientCertificateBoundAccessTokens;
}
@JsonAnyGetter
public Map<String, Object> getOtherClaims() {
return otherClaims;

View file

@ -32,7 +32,6 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.oidc.utils.JWKSHttpUtils;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.protocol.oidc.utils.PairwiseSubMapperUtils;
import org.keycloak.protocol.oidc.utils.SubjectType;
@ -44,7 +43,6 @@ import org.keycloak.services.clientregistration.ClientRegistrationException;
import org.keycloak.services.util.CertificateInfoHelper;
import org.keycloak.util.JWKSUtils;
import java.io.IOException;
import java.net.URI;
import java.security.PublicKey;
import java.util.ArrayList;
@ -115,6 +113,14 @@ public class DescriptionConverter {
configWrapper.setRequestObjectSignatureAlg(algorithm);
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
Boolean tlsClientCertificateBoundAccessTokens = clientOIDC.getTlsClientCertificateBoundAccessTokens();
if (tlsClientCertificateBoundAccessTokens != null) {
if (tlsClientCertificateBoundAccessTokens.booleanValue()) configWrapper.setUseMtlsHoKToken(true);
else configWrapper.setUseMtlsHoKToken(false);
}
return client;
}
@ -188,6 +194,13 @@ public class DescriptionConverter {
if (config.isUseJwksUrl()) {
response.setJwksUri(config.getJwksUrl());
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
if (config.isUseMtlsHokToken()) {
response.setTlsClientCertificateBoundAccessTokens(Boolean.TRUE);
} else {
response.setTlsClientCertificateBoundAccessTokens(Boolean.FALSE);
}
List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client);
SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE;

View file

@ -0,0 +1,142 @@
package org.keycloak.services.util;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.util.Base64Url;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
public class MtlsHoKTokenUtil {
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3.1
// retrieve client certificate exchanged in TLS handshake
// https://docs.oracle.com/javaee/6/api/javax/servlet/ServletRequest.html#getAttribute(java.lang.String)
private static final String JAVAX_SERVLET_REQUEST_X509_CERTIFICATE = "javax.servlet.request.X509Certificate";
protected static final Logger logger = Logger.getLogger(MtlsHoKTokenUtil.class);
private static final String DIGEST_ALG = "SHA-256";
public static final String CERT_VERIFY_ERROR_DESC = "Client certificate missing, or its thumbprint and one in the refresh token did NOT match";
public static AccessToken.CertConf bindTokenWithClientCertificate(HttpRequest request) {
X509Certificate[] certs = (X509Certificate[]) request.getAttribute(JAVAX_SERVLET_REQUEST_X509_CERTIFICATE);
if (certs == null || certs.length < 1) {
logger.warnf("no client certificate available.");
return null;
}
String DERX509Base64UrlEncoded = null;
try {
// On Certificate Chain, first entry is considered to be client certificate.
DERX509Base64UrlEncoded = getCertificateThumbprintInSHA256DERX509Base64UrlEncoded(certs[0]);
if (logger.isTraceEnabled()) dumpCertInfo(certs);
} catch (NoSuchAlgorithmException | CertificateEncodingException e) {
// give up issuing MTLS HoK Token
logger.warnf("give up issuing hok token. %s", e);
return null;
}
AccessToken.CertConf certConf = new AccessToken.CertConf();
certConf.setCertThumbprint(DERX509Base64UrlEncoded);
return certConf;
}
public static boolean verifyTokenBindingWithClientCertificate(AccessToken token, HttpRequest request) {
X509Certificate[] certs = (X509Certificate[]) request.getAttribute(JAVAX_SERVLET_REQUEST_X509_CERTIFICATE);
if (token == null) {
logger.warnf("token is null");
return false;
}
// Bearer Token, not MTLS HoK Token
if (token.getCertConf() == null) {
logger.warnf("bearer token received instead of hok token.");
return false;
}
// HoK Token, but no Client Certificate available
if (certs == null || certs.length < 1) {
logger.warnf("missing client certificate.");
return false;
}
String DERX509Base64UrlEncoded = null;
String x5ts256 = token.getCertConf().getCertThumbprint();
logger.tracef("hok token cnf-x5t#s256 = %s", x5ts256);
try {
// On Certificate Chain, first entry is considered to be client certificate.
DERX509Base64UrlEncoded = getCertificateThumbprintInSHA256DERX509Base64UrlEncoded(certs[0]);
if (logger.isTraceEnabled()) dumpCertInfo(certs);
} catch (NoSuchAlgorithmException | CertificateEncodingException e) {
logger.warnf("client certificate exception. %s", e);
return false;
}
if (!MessageDigest.isEqual(x5ts256.getBytes(), DERX509Base64UrlEncoded.getBytes())) {
logger.warnf("certificate's thumbprint and one in the refresh token did not match.");
return false;
}
return true;
}
public static boolean verifyTokenBindingWithClientCertificate(String refreshToken, HttpRequest request) {
JWSInput jws = null;
RefreshToken rt = null;
try {
jws = new JWSInput(refreshToken);
rt = jws.readJsonContent(RefreshToken.class);
} catch (JWSInputException e) {
logger.warnf("refresh token JWS Input Exception. %s", e);
return false;
}
return verifyTokenBindingWithClientCertificate(rt, request);
}
private static String getCertificateThumbprintInSHA256DERX509Base64UrlEncoded (X509Certificate cert) throws NoSuchAlgorithmException, CertificateEncodingException {
// need to calculate over DER encoding of the X.509 certificate
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3.1
// in order to do that, call getEncoded()
// https://docs.oracle.com/javase/8/docs/api/java/security/cert/Certificate.html#getEncoded--
byte[] DERX509Hash = cert.getEncoded();
MessageDigest md = MessageDigest.getInstance(DIGEST_ALG);
md.update(DERX509Hash);
String DERX509Base64UrlEncoded = Base64Url.encode(md.digest());
return DERX509Base64UrlEncoded;
}
private static void dumpCertInfo(X509Certificate[] certs) throws CertificateEncodingException {
logger.tracef(":: Try Holder of Key Token");
logger.tracef(":: # of x509 Client Certificate in Certificate Chain = %d", certs.length);
for (int i = 0; i < certs.length; i++) {
logger.tracef(":: certs[%d] Raw Bytes Counts of first x509 Client Certificate in Certificate Chain = %d", i, certs[i].toString().length());
logger.tracef(":: certs[%d] Raw Bytes String of first x509 Client Certificate in Certificate Chain = %s", i, certs[i].toString());
logger.tracef(":: certs[%d] DER Dump Bytes of first x509 Client Certificate in Certificate Chain = %d", i, certs[i].getEncoded().length);
String DERX509Base64UrlEncoded = null;
try {
DERX509Base64UrlEncoded = getCertificateThumbprintInSHA256DERX509Base64UrlEncoded(certs[i]);
} catch (Exception e) {}
logger.tracef(":: certs[%d] Base64URL Encoded SHA-256 Hash of DER formatted first x509 Client Certificate in Certificate Chain = %s", i, DERX509Base64UrlEncoded);
logger.tracef(":: certs[%d] DER Dump Bytes of first x509 Client Certificate TBScertificate in Certificate Chain = %d", i, certs[i].getTBSCertificate().length);
logger.tracef(":: certs[%d] Signature Algorithm of first x509 Client Certificate in Certificate Chain = %s", i, certs[i].getSigAlgName());
logger.tracef(":: certs[%d] Certfication Type of first x509 Client Certificate in Certificate Chain = %s", i, certs[i].getType());
logger.tracef(":: certs[%d] Issuer DN of first x509 Client Certificate in Certificate Chain = %s", i, certs[i].getIssuerDN().getName());
logger.tracef(":: certs[%d] Subject DN of first x509 Client Certificate in Certificate Chain = %s", i, certs[i].getSubjectDN().getName());
}
}
}

View file

@ -369,7 +369,18 @@ To run the X.509 client certificate authentication tests:
-Dauth.server.ssl.required \
-Dbrowser=phantomjs \
"-Dtest=*.x509.*"
## Run Mutual TLS Client Certificate Bound Access Tokens tests
To run the Mutual TLS Client Certificate Bound Access Tokens tests:
mvn -f testsuite/integration-arquillian/pom.xml \
clean install \
-Pauth-server-wildfly \
-Dauth.server.ssl.required \
-Dbrowser=phantomjs \
-Dtest=org.keycloak.testsuite.hok.HoKTest
## Cluster tests
Cluster tests use 2 backend servers (Keycloak on Wildfly/EAP) and 1 frontend loadbalancer server node. Invalidation tests don't use loadbalancer.

View file

@ -243,6 +243,8 @@
<include>client.key</include>
<include>intermediate-ca.crl</include>
<include>empty.crl</include>
<!-- KEYCLOAK-6771 Certificate Bound Token -->
<include>other_client.jks</include>
</includes>
</resource>
<resource>

View file

@ -43,6 +43,8 @@
<exclude.crossdc>**/crossdc/**/*Test.java</exclude.crossdc>
<!-- exclude x509 tests by default, enabled by 'ssl' profile -->
<exclude.x509>**/x509/*Test.java</exclude.x509>
<!-- KEYCLOAK-6771 exclude Mutual TLS Holder of Key Token x509 tests by default, enabled by 'ssl' profile -->
<exclude.HoK>**/hok/**/*Test.java</exclude.HoK>
<!-- exclude undertow adapter tests. They can be added by -Dtest=org.keycloak.testsuite.adapter.undertow.**.*Test -->
<exclude.undertow.adapter>**/adapter/undertow/**/*Test.java</exclude.undertow.adapter>
</properties>
@ -160,6 +162,7 @@
<exclude>${exclude.crossdc}</exclude>
<exclude>${exclude.undertow.adapter}</exclude>
<exclude>${exclude.x509}</exclude>
<exclude>${exclude.HoK}</exclude>
</excludes>
</configuration>
</plugin>

View file

@ -0,0 +1,138 @@
package org.keycloak.testsuite.util;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.KeystoreUtil;
public class HoKTokenUtils {
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
public static final String DEFAULT_KEYSTOREPATH = System.getProperty("client.certificate.keystore");
public static final String DEFAULT_KEYSTOREPASSWORD = System.getProperty("client.certificate.keystore.passphrase");
public static final String DEFAULT_TRUSTSTOREPATH = System.getProperty("client.truststore");
public static final String DEFAULT_TRUSTSTOREPASSWORD = System.getProperty("client.truststore.passphrase");
public static final String OTHER_KEYSTOREPATH = System.getProperty("hok.client.certificate.keystore");
public static final String OTHER_KEYSTOREPASSWORD = System.getProperty("hok.client.certificate.keystore.passphrase");
public static CloseableHttpClient newCloseableHttpClientWithDefaultKeyStoreAndTrustStore() {
return newCloseableHttpClient(DEFAULT_KEYSTOREPATH, DEFAULT_KEYSTOREPASSWORD, DEFAULT_TRUSTSTOREPATH, DEFAULT_TRUSTSTOREPASSWORD);
}
public static CloseableHttpClient newCloseableHttpClientWithOtherKeyStoreAndTrustStore() {
return newCloseableHttpClient(OTHER_KEYSTOREPATH, OTHER_KEYSTOREPASSWORD, DEFAULT_TRUSTSTOREPATH, DEFAULT_TRUSTSTOREPASSWORD);
}
public static CloseableHttpClient newCloseableHttpClientWithoutKeyStoreAndTrustStore() {
return newCloseableHttpClient(null, null, null, null);
}
public static CloseableHttpClient newCloseableHttpClient(String keyStorePath, String keyStorePassword, String trustStorePath, String trustStorePassword) {
KeyStore keystore = null;
// Load the keystore file
if (keyStorePath != null) {
try {
keystore = KeystoreUtil.loadKeyStore(keyStorePath, keyStorePassword);
} catch (Exception e) {
e.printStackTrace();
}
}
// load the trustore
KeyStore truststore = null;
if (trustStorePath != null) {
try {
truststore = KeystoreUtil.loadKeyStore(trustStorePath, trustStorePassword);
} catch(Exception e) {
e.printStackTrace();
}
}
if (keystore != null || truststore != null)
return (CloseableHttpClient) new org.keycloak.adapters.HttpClientBuilder()
.keyStore(keystore, keyStorePassword)
.trustStore(truststore)
.hostnameVerification(org.keycloak.adapters.HttpClientBuilder.HostnameVerificationPolicy.ANY)
.build();
return HttpClientBuilder.create().build();
}
public static String getThumbprintFromDefaultClientCert() throws KeyStoreException, CertificateEncodingException {
return getThumbprintFromClientCert(DEFAULT_KEYSTOREPATH, DEFAULT_KEYSTOREPASSWORD);
}
public static String getThumbprintFromOtherClientCert() throws KeyStoreException, CertificateEncodingException {
return getThumbprintFromClientCert(OTHER_KEYSTOREPATH, OTHER_KEYSTOREPASSWORD);
}
public static String getThumbprintFromClientCert(String keyStorePath, String keyStorePassword) throws KeyStoreException, CertificateEncodingException {
KeyStore keystore = null;
// load the keystore containing the client certificate - keystore type is probably jks or pkcs12
try {
keystore = KeystoreUtil.loadKeyStore(keyStorePath, keyStorePassword);
} catch (Exception e) {
e.printStackTrace();
}
Enumeration<String> es = keystore.aliases();
String alias = null;
while(es.hasMoreElements()) {
alias = es.nextElement();
}
X509Certificate cert = (X509Certificate) keystore.getCertificate(alias);
String digestAlg = "SHA-256";
byte[] DERX509Hash = cert.getEncoded();
String DERX509Base64UrlEncoded = null;
try {
MessageDigest md = MessageDigest.getInstance(digestAlg);
md.update(DERX509Hash);
DERX509Base64UrlEncoded = Base64Url.encode(md.digest());
} catch (Exception e) {
throw new RuntimeException(e);
}
return DERX509Base64UrlEncoded;
}
public static Response executeUserInfoRequestInGetMethod(String accessToken, boolean isKeystoreUsed, String keystorePath, String keystorePassward) {
ClientBuilder clientBuilder = ClientBuilder.newBuilder();
KeyStore keystore = null;
// Load the keystore file
if(isKeystoreUsed) {
try {
if (keystorePath != null) {
keystore = KeystoreUtil.loadKeyStore(keystorePath, keystorePassward);
clientBuilder.keyStore(keystore, keystorePassward);
} else {
keystore = KeystoreUtil.loadKeyStore(DEFAULT_KEYSTOREPATH, DEFAULT_KEYSTOREPASSWORD);
clientBuilder.keyStore(keystore, DEFAULT_KEYSTOREPASSWORD);
}
} catch (Exception e) {
e.printStackTrace();
}
}
Client client = clientBuilder.build();
WebTarget userInfoTarget = null;
try {
userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(client);
} finally {
client.close();
}
return userInfoTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).get();
}
}

View file

@ -279,55 +279,102 @@ public class OAuthClient {
}
}
// KEYCLOAK-6771 Certificate Bound Token
public AccessTokenResponse doAccessTokenRequest(String code, String password) {
try (CloseableHttpClient client = newCloseableHttpClient()) {
HttpPost post = new HttpPost(getAccessTokenUrl());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
if (origin != null) {
post.addHeader("Origin", origin);
}
if (code != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
}
if (redirectUri != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, redirectUri));
}
if (clientId != null && password != null) {
String authorization = BasicAuthHelper.createHeader(clientId, password);
post.setHeader("Authorization", authorization);
} else if (clientId != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId));
}
if (clientSessionState != null) {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_STATE, clientSessionState));
}
if (clientSessionHost != null) {
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 = new UrlEncodedFormEntity(parameters, Charsets.UTF_8);
post.setEntity(formEntity);
try {
return new AccessTokenResponse(client.execute(post));
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve access token", e);
}
} catch (IOException ioe) {
return doAccessTokenRequest(code, password, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
// KEYCLOAK-6771 Certificate Bound Token
public AccessTokenResponse doAccessTokenRequest(String code, String password, CloseableHttpClient client) {
HttpPost post = new HttpPost(getAccessTokenUrl());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
if (origin != null) {
post.addHeader("Origin", origin);
}
if (code != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
}
if (redirectUri != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, redirectUri));
}
if (clientId != null && password != null) {
String authorization = BasicAuthHelper.createHeader(clientId, password);
post.setHeader("Authorization", authorization);
} else if (clientId != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId));
}
if (clientSessionState != null) {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_STATE, clientSessionState));
}
if (clientSessionHost != null) {
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 = new UrlEncodedFormEntity(parameters, Charsets.UTF_8);
post.setEntity(formEntity);
try {
return new AccessTokenResponse(client.execute(post));
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve access token", e);
}
}
// KEYCLOAK-6771 Certificate Bound Token
public String introspectTokenWithClientCredential(String clientId, String clientSecret, String tokenType, String tokenToIntrospect) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
return introspectTokenWithClientCredential(clientId, clientSecret, tokenType, tokenToIntrospect, client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
// KEYCLOAK-6771 Certificate Bound Token
public String introspectTokenWithClientCredential(String clientId, String clientSecret, String tokenType, String tokenToIntrospect, CloseableHttpClient client) {
HttpPost post = new HttpPost(getTokenIntrospectionUrl());
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair("token", tokenToIntrospect));
parameters.add(new BasicNameValuePair("token_type_hint", tokenType));
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
try (CloseableHttpResponse response = client.execute(post)) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
response.getEntity().writeTo(out);
return new String(out.toByteArray());
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve access token", e);
}
}
public String introspectAccessTokenWithClientCredential(String clientId, String clientSecret, String tokenToIntrospect) {
return introspectTokenWithClientCredential(clientId, clientSecret, "access_token", tokenToIntrospect);
}
@ -336,41 +383,6 @@ public class OAuthClient {
return introspectTokenWithClientCredential(clientId, clientSecret, "refresh_token", tokenToIntrospect);
}
public String introspectTokenWithClientCredential(String clientId, String clientSecret, String tokenType, String tokenToIntrospect) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getTokenIntrospectionUrl());
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair("token", tokenToIntrospect));
parameters.add(new BasicNameValuePair("token_type_hint", tokenType));
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
try (CloseableHttpResponse response = client.execute(post)) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
response.getEntity().writeTo(out);
return new String(out.toByteArray());
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve access token", e);
}
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
public AccessTokenResponse doGrantAccessTokenRequest(String clientSecret, String username, String password) throws Exception {
return doGrantAccessTokenRequest(realm, username, password, null, clientId, clientSecret);
}
@ -532,79 +544,92 @@ public class OAuthClient {
}
}
public CloseableHttpResponse doLogout(String refreshToken, String clientSecret) throws IOException {
// KEYCLOAK-6771 Certificate Bound Token
public CloseableHttpResponse doLogout(String refreshToken, String clientSecret) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getLogoutUrl().build());
List<NameValuePair> parameters = new LinkedList<>();
if (refreshToken != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, refreshToken));
}
if (clientId != null && clientSecret != null) {
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
} else if (clientId != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId));
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
return client.execute(post);
}
}
public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getRefreshTokenUrl());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.REFRESH_TOKEN));
if (origin != null) {
post.addHeader("Origin", origin);
}
if (refreshToken != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, refreshToken));
}
if (clientId != null && password != null) {
String authorization = BasicAuthHelper.createHeader(clientId, password);
post.setHeader("Authorization", authorization);
} else if (clientId != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId));
}
if (clientSessionState != null) {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_STATE, clientSessionState));
}
if (clientSessionHost != null) {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, clientSessionHost));
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
try {
return new AccessTokenResponse(client.execute(post));
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve access token", e);
}
return doLogout(refreshToken, clientSecret, client);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
// KEYCLOAK-6771 Certificate Bound Token
public CloseableHttpResponse doLogout(String refreshToken, String clientSecret, CloseableHttpClient client) throws IOException {
HttpPost post = new HttpPost(getLogoutUrl().build());
List<NameValuePair> parameters = new LinkedList<>();
if (refreshToken != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, refreshToken));
}
if (clientId != null && clientSecret != null) {
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
} else if (clientId != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId));
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
return client.execute(post);
}
// KEYCLOAK-6771 Certificate Bound Token
public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
return doRefreshTokenRequest(refreshToken, password, client);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
// KEYCLOAK-6771 Certificate Bound Token
public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password, CloseableHttpClient client) {
HttpPost post = new HttpPost(getRefreshTokenUrl());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.REFRESH_TOKEN));
if (origin != null) {
post.addHeader("Origin", origin);
}
if (refreshToken != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, refreshToken));
}
if (clientId != null && password != null) {
String authorization = BasicAuthHelper.createHeader(clientId, password);
post.setHeader("Authorization", authorization);
} else if (clientId != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId));
}
if (clientSessionState != null) {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_STATE, clientSessionState));
}
if (clientSessionHost != null) {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, clientSessionHost));
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
try {
return new AccessTokenResponse(client.execute(post));
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve access token", e);
}
}
public void closeClient(CloseableHttpClient client) {
try {
client.close();
@ -870,16 +895,16 @@ public class OAuthClient {
// https://tools.ietf.org/html/rfc7636#section-4
public OAuthClient codeVerifier(String codeVerifier) {
this.codeVerifier = codeVerifier;
return this;
this.codeVerifier = codeVerifier;
return this;
}
public OAuthClient codeChallenge(String codeChallenge) {
this.codeChallenge = codeChallenge;
return this;
this.codeChallenge = codeChallenge;
return this;
}
public OAuthClient codeChallengeMethod(String codeChallengeMethod) {
this.codeChallengeMethod = codeChallengeMethod;
return this;
this.codeChallengeMethod = codeChallengeMethod;
return this;
}
public OAuthClient origin(String origin) {
this.origin = origin;
@ -1098,5 +1123,4 @@ public class OAuthClient {
}
}
}

View file

@ -222,6 +222,46 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
Assert.assertFalse(kcClientRep.isPublicClient());
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
@Test
public void testMtlsHoKTokenEnabled() throws Exception {
// create (no specification)
OIDCClientRepresentation clientRep = createRep();
OIDCClientRepresentation response = reg.oidc().create(clientRep);
Assert.assertEquals(Boolean.FALSE, response.getTlsClientCertificateBoundAccessTokens());
Assert.assertNotNull(response.getClientSecret());
// Test Keycloak representation
ClientRepresentation kcClient = getClient(response.getClientId());
OIDCAdvancedConfigWrapper config = OIDCAdvancedConfigWrapper.fromClientRepresentation(kcClient);
assertTrue(!config.isUseMtlsHokToken());
// update (true)
reg.auth(Auth.token(response));
response.setTlsClientCertificateBoundAccessTokens(Boolean.TRUE);
OIDCClientRepresentation updated = reg.oidc().update(response);
assertTrue(updated.getTlsClientCertificateBoundAccessTokens().booleanValue());
// Test Keycloak representation
kcClient = getClient(updated.getClientId());
config = OIDCAdvancedConfigWrapper.fromClientRepresentation(kcClient);
assertTrue(config.isUseMtlsHokToken());
// update (false)
reg.auth(Auth.token(updated));
updated.setTlsClientCertificateBoundAccessTokens(Boolean.FALSE);
OIDCClientRepresentation reUpdated = reg.oidc().update(updated);
assertTrue(!reUpdated.getTlsClientCertificateBoundAccessTokens().booleanValue());
// Test Keycloak representation
kcClient = getClient(reUpdated.getClientId());
config = OIDCAdvancedConfigWrapper.fromClientRepresentation(kcClient);
assertTrue(!config.isUseMtlsHokToken());
}
private ClientRepresentation getKeycloakClient(String clientId) {
return ApiUtil.findClientByClientId(adminClient.realms().realm(REALM_NAME), clientId).toRepresentation();
}

View file

@ -0,0 +1,663 @@
package org.keycloak.testsuite.hok;
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.assertThat;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername;
import java.io.IOException;
import java.net.URI;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.impl.client.CloseableHttpClient;
import org.hamcrest.Matchers;
import org.jboss.arquillian.drone.api.annotation.Drone;
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.common.util.KeystoreUtil;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.jose.jws.crypto.HashProvider;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
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.CredentialRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.drone.Different;
import org.keycloak.testsuite.util.ClientManager;
import org.keycloak.testsuite.util.HoKTokenUtils;
import org.keycloak.testsuite.util.KeycloakModelUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import org.openqa.selenium.WebDriver;
public class HoKTest extends AbstractTestRealmKeycloakTest {
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
@Drone
@Different
protected WebDriver driver2;
private static final List<String> CLIENT_LIST = Arrays.asList("test-app", "named-test-app");
public static class HoKAssertEvents extends AssertEvents {
public HoKAssertEvents(AbstractKeycloakTest ctx) {
super(ctx);
}
private final String defaultRedirectUri = "https://localhost:8543/auth/realms/master/app/auth";
@Override
public ExpectedEvent expectLogin() {
return expect(EventType.LOGIN)
.detail(Details.CODE_ID, isCodeId())
//.detail(Details.USERNAME, DEFAULT_USERNAME)
//.detail(Details.AUTH_METHOD, OIDCLoginProtocol.LOGIN_PROTOCOL)
//.detail(Details.AUTH_TYPE, AuthorizationEndpoint.CODE_AUTH_TYPE)
.detail(Details.REDIRECT_URI, defaultRedirectUri)
.detail(Details.CONSENT, Details.CONSENT_VALUE_NO_CONSENT_REQUIRED)
.session(isUUID());
}
}
@Rule
public HoKAssertEvents events = new HoKAssertEvents(this);
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
// override due to effects caused by enabling TLS
for (String clientId : CLIENT_LIST) addRedirectUrlForTls(testRealm, clientId);
// for token introspection
configTestRealmForTokenIntrospection(testRealm);
}
private void addRedirectUrlForTls(RealmRepresentation testRealm, String clientId) {
for (ClientRepresentation client : testRealm.getClients()) {
if (client.getClientId().equals(clientId)) {
URI baseUri = URI.create(client.getRedirectUris().get(0));
URI redir = URI.create("https://localhost:" + System.getProperty("app.server.https.port", "8543") + baseUri.getRawPath());
client.getRedirectUris().add(redir.toString());
break;
}
}
}
private void configTestRealmForTokenIntrospection(RealmRepresentation testRealm) {
ClientRepresentation confApp = KeycloakModelUtils.createClient(testRealm, "confidential-cli");
confApp.setSecret("secret1");
confApp.setServiceAccountsEnabled(Boolean.TRUE);
ClientRepresentation pubApp = KeycloakModelUtils.createClient(testRealm, "public-cli");
pubApp.setPublicClient(Boolean.TRUE);
UserRepresentation user = new UserRepresentation();
user.setUsername("no-permissions");
CredentialRepresentation credential = new CredentialRepresentation();
credential.setType("password");
credential.setValue("password");
List<CredentialRepresentation> creds = new ArrayList<>();
creds.add(credential);
user.setCredentials(creds);
user.setEnabled(Boolean.TRUE);
List<String> realmRoles = new ArrayList<>();
realmRoles.add("user");
user.setRealmRoles(realmRoles);
testRealm.getUsers().add(user);
}
// enable HoK Token as default
@Before
public void enableHoKToken() {
// Enable MTLS HoK Token
for (String clientId : CLIENT_LIST) enableHoKToken(clientId);
}
private void enableHoKToken(String clientId) {
// Enable MTLS HoK Token
ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm("test"), clientId);
ClientRepresentation clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setUseMtlsHoKToken(true);
clientResource.update(clientRep);
}
// Authorization Code Flow
// Bind HoK Token
@Test
public void accessTokenRequestWithClientCertificate() throws Exception {
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);
AccessTokenResponse response;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
response = oauth.doAccessTokenRequest(code, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Success Pattern
expectSuccessfulResponseFromTokenEndpoint(sessionId, codeId, response);
verifyHoKTokenDefaultCertThumbPrint(response);
}
@Test
public void accessTokenRequestWithoutClientCertificate() throws Exception {
oauth.doLogin("test-user@localhost", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
AccessTokenResponse response;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithoutKeyStoreAndTrustStore()) {
response = oauth.doAccessTokenRequest(code, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Error Pattern
assertEquals(400, response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Client Certification missing for MTLS HoK Token Binding", response.getErrorDescription());
}
private void expectSuccessfulResponseFromTokenEndpoint(String sessionId, String codeId, AccessTokenResponse response) throws Exception {
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());
}
// verify HoK Token - Token Refresh
@Test
public void refreshTokenRequestByHoKRefreshTokenByOtherClient() throws Exception {
// first client user login
oauth.doLogin("test-user@localhost", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
AccessTokenResponse tokenResponse = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
tokenResponse = oauth.doAccessTokenRequest(code, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
verifyHoKTokenDefaultCertThumbPrint(tokenResponse);
String refreshTokenString = tokenResponse.getRefreshToken();
// second client user login
OAuthClient oauth2 = new OAuthClient();
oauth2.init(adminClient, driver2);
oauth2.doLogin("john-doh@localhost", "password");
String code2 = oauth2.getCurrentQuery().get(OAuth2Constants.CODE);
AccessTokenResponse tokenResponse2 = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithOtherKeyStoreAndTrustStore()) {
tokenResponse2 = oauth2.doAccessTokenRequest(code2, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
verifyHoKTokenOtherCertThumbPrint(tokenResponse2);
// token refresh by second client by first client's refresh token
AccessTokenResponse response = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithOtherKeyStoreAndTrustStore()) {
response = oauth2.doRefreshTokenRequest(refreshTokenString, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Error Pattern
assertEquals(401, response.getStatusCode());
assertEquals(OAuthErrorException.UNAUTHORIZED_CLIENT, response.getError());
assertEquals("Client certificate missing, or its thumbprint and one in the refresh token did NOT match", response.getErrorDescription());
}
@Test
public void refreshTokenRequestByHoKRefreshTokenWithClientCertificate() throws Exception {
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);
AccessTokenResponse tokenResponse = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
tokenResponse = oauth.doAccessTokenRequest(code, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
verifyHoKTokenDefaultCertThumbPrint(tokenResponse);
AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
String refreshTokenString = tokenResponse.getRefreshToken();
RefreshToken refreshToken = oauth.verifyRefreshToken(refreshTokenString);
EventRepresentation tokenEvent = events.expectCodeToToken(codeId, sessionId).assertEvent();
Assert.assertNotNull(refreshTokenString);
assertEquals("bearer", tokenResponse.getTokenType());
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);
AccessTokenResponse response = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
response = oauth.doRefreshTokenRequest(refreshTokenString, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Success Pattern
expectSuccessfulResponseFromTokenEndpoint(response, sessionId, token, refreshToken, tokenEvent);
verifyHoKTokenDefaultCertThumbPrint(response);
}
@Test
public void refreshTokenRequestByRefreshTokenWithoutClientCertificate() throws Exception {
oauth.doLogin("test-user@localhost", "password");
EventRepresentation loginEvent = events.expectLogin().assertEvent();
String sessionId = loginEvent.getSessionId();
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
AccessTokenResponse tokenResponse = null;
tokenResponse = oauth.doAccessTokenRequest(code, "password");
verifyHoKTokenDefaultCertThumbPrint(tokenResponse);
AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
String refreshTokenString = tokenResponse.getRefreshToken();
RefreshToken refreshToken = oauth.verifyRefreshToken(refreshTokenString);
Assert.assertNotNull(refreshTokenString);
assertEquals("bearer", tokenResponse.getTokenType());
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);
AccessTokenResponse response = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithoutKeyStoreAndTrustStore()) {
response = oauth.doRefreshTokenRequest(refreshTokenString, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Error Pattern
assertEquals(401, response.getStatusCode());
assertEquals(OAuthErrorException.UNAUTHORIZED_CLIENT, response.getError());
assertEquals("Client certificate missing, or its thumbprint and one in the refresh token did NOT match", response.getErrorDescription());
}
private void expectSuccessfulResponseFromTokenEndpoint(AccessTokenResponse response, String sessionId, AccessToken token, RefreshToken refreshToken, EventRepresentation tokenEvent) {
expectSuccessfulResponseFromTokenEndpoint(oauth, "test-user@localhost", response, sessionId, token, refreshToken, tokenEvent);
}
private void expectSuccessfulResponseFromTokenEndpoint(OAuthClient oauth, String username, AccessTokenResponse response, String sessionId, AccessToken token, RefreshToken refreshToken, EventRepresentation tokenEvent) {
AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken());
RefreshToken refreshedRefreshToken = oauth.verifyRefreshToken(response.getRefreshToken());
if (refreshedToken.getCertConf() != null) {
log.warnf("refreshed access token's cnf-x5t#256 = %s", refreshedToken.getCertConf().getCertThumbprint());
log.warnf("refreshed refresh token's cnf-x5t#256 = %s", refreshedRefreshToken.getCertConf().getCertThumbprint());
}
assertEquals(200, response.getStatusCode());
assertEquals(sessionId, refreshedToken.getSessionState());
assertEquals(sessionId, refreshedRefreshToken.getSessionState());
Assert.assertThat(response.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", response.getTokenType());
assertEquals(findUserByUsername(adminClient.realm("test"), username).getId(), refreshedToken.getSubject());
Assert.assertNotEquals(username, 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(tokenEvent.getDetails().get(Details.REFRESH_TOKEN_ID), sessionId).user(AssertEvents.isUUID()).assertEvent();
Assert.assertNotEquals(tokenEvent.getDetails().get(Details.TOKEN_ID), refreshEvent.getDetails().get(Details.TOKEN_ID));
Assert.assertNotEquals(tokenEvent.getDetails().get(Details.REFRESH_TOKEN_ID), refreshEvent.getDetails().get(Details.UPDATED_REFRESH_TOKEN_ID));
setTimeOffset(0);
}
// verify HoK Token - Get UserInfo
@Test
public void getUserInfoByHoKAccessTokenWithClientCertificate() throws Exception {
// get an access token
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);
AccessTokenResponse tokenResponse = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
tokenResponse = oauth.doAccessTokenRequest(code, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
verifyHoKTokenDefaultCertThumbPrint(tokenResponse);
events.expectCodeToToken(codeId, sessionId).assertEvent();
// execute the access token to get UserInfo with token binded client certificate in mutual authentication TLS
ClientBuilder clientBuilder = ClientBuilder.newBuilder();
KeyStore keystore = null;
keystore = KeystoreUtil.loadKeyStore(HoKTokenUtils.DEFAULT_KEYSTOREPATH, HoKTokenUtils.DEFAULT_KEYSTOREPASSWORD);
clientBuilder.keyStore(keystore, HoKTokenUtils.DEFAULT_KEYSTOREPASSWORD);
Client client = clientBuilder.build();
WebTarget userInfoTarget = null;
Response response = null;
try {
userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(client);
response = userInfoTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + tokenResponse.getAccessToken()).get();
testSuccessfulUserInfoResponse(response);
} finally {
response.close();
client.close();
}
}
@Test
public void getUserInfoByHoKAccessTokenWithoutClientCertificate() throws Exception {
// get an access token
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);
AccessTokenResponse tokenResponse = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
tokenResponse = oauth.doAccessTokenRequest(code, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
verifyHoKTokenDefaultCertThumbPrint(tokenResponse);
events.expectCodeToToken(codeId, sessionId).assertEvent();
// execute the access token to get UserInfo without token binded client certificate in mutual authentication TLS
ClientBuilder clientBuilder = ClientBuilder.newBuilder();
Client client = clientBuilder.build();
WebTarget userInfoTarget = null;
Response response = null;
try {
userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(client);
response = userInfoTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + tokenResponse.getAccessToken()).get();
assertEquals(401, response.getStatus());
} finally {
response.close();
client.close();
}
}
private void testSuccessfulUserInfoResponse(Response response) {
events.expect(EventType.USER_INFO_REQUEST)
.session(Matchers.notNullValue(String.class))
.detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN)
.detail(Details.USERNAME, "test-user@localhost")
.detail(Details.SIGNATURE_REQUIRED, "false")
.assertEvent();
UserInfoClientUtil.testSuccessfulUserInfoResponse(response, "test-user@localhost", "test-user@localhost");
}
// verify HoK Token - Back Channel Logout
@Test
public void postLogoutByHoKRefreshTokenWithClientCertificate() throws Exception {
String refreshTokenString = execPreProcessPostLogout();
CloseableHttpResponse response = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
response = oauth.doLogout(refreshTokenString, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Success Pattern
assertThat(response, org.keycloak.testsuite.util.Matchers.statusCodeIsHC(Status.NO_CONTENT));
assertNotNull(testingClient.testApp().getAdminLogoutAction());
}
@Test
public void postLogoutByHoKRefreshTokenWithoutClientCertificate() throws Exception {
String refreshTokenString = execPreProcessPostLogout();
CloseableHttpResponse response = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithoutKeyStoreAndTrustStore()) {
response = oauth.doLogout(refreshTokenString, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Error Pattern
assertEquals(401, response.getStatusLine().getStatusCode());
}
private String execPreProcessPostLogout() throws Exception {
oauth.doLogin("test-user@localhost", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.clientSessionState("client-session");
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
verifyHoKTokenDefaultCertThumbPrint(tokenResponse);
return tokenResponse.getRefreshToken();
}
// Hybrid Code Flow : response_type = code id_token
// Bind HoK Token
@Test
public void accessTokenRequestWithClientCertificateInHybridFlowWithCodeIDToken() throws Exception {
String nonce = "ckw938gnspa93dj";
ClientManager.realm(adminClient.realm("test")).clientId("test-app").standardFlow(true).implicitFlow(true);
oauth.clientId("test-app");
oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN);
oauth.nonce(nonce);
oauth.doLogin("test-user@localhost", "password");
EventRepresentation loginEvent = events.expectLogin().assertEvent();
OAuthClient.AuthorizationEndpointResponse authzResponse = new OAuthClient.AuthorizationEndpointResponse(oauth, true);
Assert.assertNotNull(authzResponse.getSessionState());
List<IDToken> idTokens = testAuthzResponseAndRetrieveIDTokens(authzResponse, loginEvent);
for (IDToken idToken : idTokens) {
Assert.assertEquals(nonce, idToken.getNonce());
Assert.assertEquals(authzResponse.getSessionState(), idToken.getSessionState());
}
}
protected List<IDToken> testAuthzResponseAndRetrieveIDTokens(OAuthClient.AuthorizationEndpointResponse authzResponse, EventRepresentation loginEvent) {
Assert.assertEquals(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN, loginEvent.getDetails().get(Details.RESPONSE_TYPE));
// IDToken from the authorization response
Assert.assertNull(authzResponse.getAccessToken());
String idTokenStr = authzResponse.getIdToken();
IDToken idToken = oauth.verifyIDToken(idTokenStr);
// Validate "c_hash"
Assert.assertNull(idToken.getAccessTokenHash());
Assert.assertNotNull(idToken.getCodeHash());
Assert.assertEquals(idToken.getCodeHash(), HashProvider.oidcHash(Algorithm.RS256, authzResponse.getCode()));
// IDToken exchanged for the code
IDToken idToken2 = sendTokenRequestAndGetIDToken(loginEvent);
return Arrays.asList(idToken, idToken2);
}
@Test
public void testIntrospectHoKAccessToken() throws Exception {
// get an access token with client certificate in mutual authenticate TLS
// mimic Client
oauth.doLogin("test-user@localhost", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
EventRepresentation loginEvent = events.expectLogin().assertEvent();
AccessTokenResponse accessTokenResponse = null;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore()) {
accessTokenResponse = oauth.doAccessTokenRequest(code, "password", client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
// Do token introspection
// mimic Resource Server
String tokenResponse;
try (CloseableHttpClient client = HoKTokenUtils.newCloseableHttpClientWithoutKeyStoreAndTrustStore()) {
tokenResponse = oauth.introspectTokenWithClientCredential("confidential-cli", "secret1", "access_token", accessTokenResponse.getAccessToken(), client);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
TokenMetadataRepresentation rep = JsonSerialization.readValue(tokenResponse, TokenMetadataRepresentation.class);
JWSInput jws = new JWSInput(accessTokenResponse.getAccessToken());
AccessToken at = jws.readJsonContent(AccessToken.class);
jws = new JWSInput(accessTokenResponse.getRefreshToken());
RefreshToken rt = jws.readJsonContent(RefreshToken.class);
String certThumprintFromAccessToken = at.getCertConf().getCertThumbprint();
String certThumprintFromRefreshToken = rt.getCertConf().getCertThumbprint();
String certThumprintFromTokenIntrospection = rep.getCertConf().getCertThumbprint();
String certThumprintFromBoundClientCertificate = HoKTokenUtils.getThumbprintFromDefaultClientCert();
assertTrue(rep.isActive());
assertEquals("test-user@localhost", rep.getUserName());
assertEquals("test-app", rep.getClientId());
assertEquals(loginEvent.getUserId(), rep.getSubject());
assertEquals(certThumprintFromTokenIntrospection, certThumprintFromBoundClientCertificate);
assertEquals(certThumprintFromBoundClientCertificate, certThumprintFromAccessToken);
assertEquals(certThumprintFromAccessToken, certThumprintFromRefreshToken);
}
private void verifyHoKTokenDefaultCertThumbPrint(AccessTokenResponse response) throws Exception {
verifyHoKTokenCertThumbPrint(response, HoKTokenUtils.getThumbprintFromDefaultClientCert());
}
private void verifyHoKTokenOtherCertThumbPrint(AccessTokenResponse response) throws Exception {
verifyHoKTokenCertThumbPrint(response, HoKTokenUtils.getThumbprintFromOtherClientCert());
}
private void verifyHoKTokenCertThumbPrint(AccessTokenResponse response, String certThumbPrint) {
JWSInput jws = null;
AccessToken at = null;
try {
jws = new JWSInput(response.getAccessToken());
at = jws.readJsonContent(AccessToken.class);
} catch (JWSInputException e) {
Assert.fail(e.toString());
}
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), at.getCertConf().getCertThumbprint().getBytes()));
RefreshToken rt = null;
try {
jws = new JWSInput(response.getRefreshToken());
rt = jws.readJsonContent(RefreshToken.class);
} catch (JWSInputException e) {
Assert.fail(e.toString());
}
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), rt.getCertConf().getCertThumbprint().getBytes()));
}
}

View file

@ -123,6 +123,11 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
// PKCE support
Assert.assertNames(oidcConfig.getCodeChallengeMethodsSupported(), OAuth2Constants.PKCE_METHOD_PLAIN, OAuth2Constants.PKCE_METHOD_S256);
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2
Assert.assertTrue(oidcConfig.getTlsClientCertificateBoundAccessTokens());
} finally {
client.close();
}

View file

@ -131,6 +131,10 @@
<client.key.file>${auth.server.config.dir}/client.key</client.key.file>
<client.key.passphrase>secret</client.key.passphrase>
<!-- KEYCLOAK-6771 Certificate Bound Token -->
<hok.client.certificate.keystore>${auth.server.config.dir}/other_client.jks</hok.client.certificate.keystore>
<hok.client.certificate.keystore.passphrase>secret</hok.client.certificate.keystore.passphrase>
<auth.server.ocsp.responder.enabled>false</auth.server.ocsp.responder.enabled>
<keycloak.x509cert.lookup.provider>default</keycloak.x509cert.lookup.provider>
</properties>
@ -304,6 +308,10 @@
<client.key.file>${client.key.file}</client.key.file>
<client.key.passphrase>${client.key.passphrase}</client.key.passphrase>
<!-- KEYCLOAK-6771 Certificate Bound Token -->
<hok.client.certificate.keystore>${hok.client.certificate.keystore}</hok.client.certificate.keystore>
<hok.client.certificate.keystore.passphrase>${hok.client.certificate.keystore.passphrase}</hok.client.certificate.keystore.passphrase>
<auth.server.ocsp.responder.enabled>${auth.server.ocsp.responder.enabled}</auth.server.ocsp.responder.enabled>
<!--cache server properties-->

View file

@ -608,27 +608,22 @@ gitlab-application-secret=Application Secret
gitlab.application-id.tooltip=Application Id for the application you created in your GitLab Applications account menu
gitlab.application-secret.tooltip=Secret for the application that you created in your GitLab Applications account menu
gitlab.default-scopes.tooltip=Scopes to ask for on login. Will always ask for openid. Additionally adds api if you do not specify anything.
bitbucket-consumer-key=Consumer Key
bitbucket-consumer-secret=Consumer Secret
bitbucket.key.tooltip=Bitbucket OAuth Consumer Key
bitbucket.secret.tooltip=Bitbucket OAuth Consumer Secret
bitbucket.default-scopes.tooltip=Scopes to ask for on login. If you do not specify anything, scope defaults to 'email'.
# User federation
sync-ldap-roles-to-keycloak=Sync LDAP Roles To Keycloak
sync-keycloak-roles-to-ldap=Sync Keycloak Roles To LDAP
sync-ldap-groups-to-keycloak=Sync LDAP Groups To Keycloak
sync-keycloak-groups-to-ldap=Sync Keycloak Groups To LDAP
realms=Realms
realm=Realm
identity-provider-mappers=Identity Provider Mappers
create-identity-provider-mapper=Create Identity Provider Mapper
add-identity-provider-mapper=Add Identity Provider Mapper
client.description.tooltip=Specifies description of the client. For example 'My Client for TimeSheets'. Supports keys for localized values as well. For example\: ${my_client_description}
expires=Expires
expiration=Expiration
expiration.tooltip=Specifies how long the token should be valid
@ -645,7 +640,6 @@ continue=Continue
initial-access-token.confirm.title=Copy Initial Access Token
initial-access-token.confirm.text=Please copy and paste the initial access token before confirming as it can't be retrieved later
no-initial-access-available=No Initial Access Tokens available
client-reg-policies=Client Registration Policies
client-reg-policy.name.tooltip=Display Name of the policy
anonymous-policies=Anonymous Access Policies
@ -812,14 +806,12 @@ ldap-attribute-name-for-uuid=LDAP attribute name for UUID
uuid-ldap-attribute.tooltip=Name of LDAP attribute, which is used as unique object identifier (UUID) for objects in LDAP. For many LDAP server vendors it's 'entryUUID' however some are different. For example for Active directory it should be 'objectGUID'. If your LDAP server really doesn't support the notion of UUID, you can use any other attribute, which is supposed to be unique among LDAP users in tree. For example 'uid' or 'entryDN'.
user-object-classes=User Object Classes
ldap-user-object-classes.placeholder=LDAP User Object Classes (div. by comma)
ldap-connection-url=LDAP connection URL
ldap-users-dn=LDAP Users DN
ldap-bind-dn=LDAP Bind DN
ldap-bind-credentials=LDAP Bind Credentials
ldap-filter=LDAP Filter
ldap.user-object-classes.tooltip=All values of LDAP objectClass attribute for users in LDAP divided by comma. For example: 'inetOrgPerson, organizationalPerson' . Newly created Keycloak users will be written to LDAP with all those object classes and existing LDAP user records are found just if they contain all those object classes.
connection-url=Connection URL
ldap.connection-url.tooltip=Connection URL to your LDAP server
test-connection=Test connection
@ -893,7 +885,6 @@ identity-provider-user-id.tooltip=Unique ID of the user on the Identity Provider
identity-provider-username=Identity Provider Username
identity-provider-username.tooltip=Username on the Identity Provider side
pagination=Pagination
browser-flow=Browser Flow
browser-flow.tooltip=Select the flow you want to use for browser authentication.
registration-flow=Registration Flow
@ -976,7 +967,6 @@ default-groups=Default Groups
groups.default-groups.tooltip=Set of groups that new users will automatically join.
cut=Cut
paste=Paste
create-group=Create group
create-authenticator-execution=Create Authenticator Execution
create-form-action-execution=Create Form Action Execution
@ -1137,7 +1127,6 @@ saved-types=Saved Types
clear-admin-events=Clear admin events
clear-changes=Clear changes
error=Error
# Authz
# Authz Common
authz-authorization=Authorization
@ -1175,22 +1164,17 @@ authz-show-details=Show Details
authz-hide-details=Hide Details
authz-associated-permissions=Associated Permissions
authz-no-permission-associated=No permissions associated
# Authz Settings
authz-import-config.tooltip=Import a JSON file containing authorization settings for this resource server.
authz-policy-enforcement-mode=Policy Enforcement Mode
authz-policy-enforcement-mode.tooltip=The policy enforcement mode dictates how policies are enforced when evaluating authorization requests. 'Enforcing' means requests are denied by default even when there is no policy associated with a given resource. 'Permissive' means requests are allowed even when there is no policy associated with a given resource. 'Disabled' completely disables the evaluation of policies and allows access to any resource.
authz-policy-enforcement-mode-enforcing=Enforcing
authz-policy-enforcement-mode-permissive=Permissive
authz-policy-enforcement-mode-disabled=Disabled
authz-remote-resource-management=Remote Resource Management
authz-remote-resource-management.tooltip=Should resources be managed remotely by the resource server? If false, resources can be managed only from this admin console.
authz-export-settings=Export Settings
authz-export-settings.tooltip=Export and download all authorization settings for this resource server.
# Authz Resource List
authz-no-resources-available=No resources available.
authz-no-scopes-assigned=No scopes assigned.
@ -1199,7 +1183,6 @@ authz-no-uri-defined=No URI defined.
authz-no-permission-assigned=No permission assigned.
authz-no-policy-assigned=No policy assigned.
authz-create-permission=Create Permission
# Authz Resource Detail
authz-add-resource=Add Resource
authz-resource-name.tooltip=A unique name for this resource. The name can be used to uniquely identify a resource, useful when querying for a specific resource.
@ -1215,15 +1198,12 @@ authz-resource-user-managed-access-enabled.tooltip=If enabled this access to thi
# Authz Scope List
authz-add-scope=Add Scope
authz-no-scopes-available=No scopes available.
# Authz Scope Detail
authz-scope-name.tooltip=A unique name for this scope. The name can be used to uniquely identify a scope, useful when querying for a specific scope.
# Authz Policy List
authz-all-types=All types
authz-create-policy=Create Policy
authz-no-policies-available=No policies available.
# Authz Policy Detail
authz-policy-name.tooltip=The name of this policy.
authz-policy-description.tooltip=A description for this policy.
@ -1240,24 +1220,20 @@ authz-policy-decision-strategy-unanimous=Unanimous
authz-policy-decision-strategy-consensus=Consensus
authz-select-a-policy=Select existing policy
authz-no-policies-assigned=No policies assigned.
# Authz Role Policy Detail
authz-add-role-policy=Add Role Policy
authz-no-roles-assigned=No roles assigned.
authz-policy-role-realm-roles.tooltip=Specifies the *realm* roles allowed by this policy.
authz-policy-role-clients.tooltip=Selects a client in order to filter the client roles that can be applied to this policy.
authz-policy-role-client-roles.tooltip=Specifies the client roles allowed by this policy.
# Authz User Policy Detail
authz-add-user-policy=Add User Policy
authz-no-users-assigned=No users assigned.
authz-policy-user-users.tooltip=Specifies which user(s) are allowed by this policy.
# Authz Client Policy Detail
authz-add-client-policy=Add Client Policy
authz-no-clients-assigned=No clients assigned.
authz-policy-client-clients.tooltip=Specifies which client(s) are allowed by this policy.
# Authz Time Policy Detail
authz-add-time-policy=Add Time Policy
authz-policy-time-not-before.tooltip=Defines the time before which the policy MUST NOT be granted. Only granted if current date/time is after or equal to this value.
@ -1273,7 +1249,6 @@ authz-policy-time-hour=Hour
authz-policy-time-hour.tooltip=Defines the hour which the policy MUST be granted. You can also provide a range by filling the second field. In this case, permission is granted only if current hour is between or equal to the two values you provided.
authz-policy-time-minute=Minute
authz-policy-time-minute.tooltip=Defines the minute which the policy MUST be granted. You can also provide a range by filling the second field. In this case, permission is granted only if current minute is between or equal to the two values you provided.
# Authz Drools Policy Detail
authz-add-drools-policy=Add Rules Policy
authz-policy-drools-maven-artifact-resolve=Resolve
@ -1285,17 +1260,13 @@ authz-policy-drools-session=Session
authz-policy-drools-session.tooltip=The session used by this policy. The session provides all the rules to evaluate when processing the policy.
authz-policy-drools-update-period=Update Period
authz-policy-drools-update-period.tooltip=Specifies an interval for scanning for artifact updates.
# Authz JS Policy Detail
authz-add-js-policy=Add JavaScript Policy
authz-policy-js-code=Code
authz-policy-js-code.tooltip=The JavaScript code providing the conditions for this policy.
# Authz Aggregated Policy Detail
authz-aggregated=Aggregated
authz-add-aggregated-policy=Add Aggregated Policy
# Authz Group Policy Detail
authz-add-group-policy=Add Group Policy
authz-no-groups-assigned=No groups assigned.
@ -1453,3 +1424,10 @@ map-roles-authz-users-scope-description=Policies that decide if admin can map ro
user-impersonated-authz-users-scope-description=Policies that decide which users can be impersonated. These policies are applied to the user being impersonated.
manage-membership-authz-group-scope-description=Policies that decide if admin can add or remove users from this group
manage-members-authz-group-scope-description=Policies that decide if an admin can manage the members of this group
# KEYCLOAK-6771 Certificate Bound Token
# https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
advanced-client-settings=Advanced Settings
advanced-client-settings.tooltip=Expand this section to configure advanced settings of this client
tls-client-certificate-bound-access-tokens=OAuth 2.0 Mutual TLS Certificate Bound Access Tokens Enabled
tls-client-certificate-bound-access-tokens.tooltip=TThis enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens.

View file

@ -920,6 +920,9 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
$scope.disableAuthorizationTab = !client.authorizationServicesEnabled;
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
$scope.disableCredentialsTab = client.publicClient;
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
$scope.tlsClientCertificateBoundAccessTokens = false;
if(client.origin) {
if ($scope.access.viewRealm) {
@ -1068,6 +1071,16 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
$scope.excludeSessionStateFromAuthResponse = false;
}
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if ($scope.client.attributes["tls.client.certificate.bound.access.tokens"]) {
if ($scope.client.attributes["tls.client.certificate.bound.access.tokens"] == "true") {
$scope.tlsClientCertificateBoundAccessTokens = true;
} else {
$scope.tlsClientCertificateBoundAccessTokens = false;
}
}
}
if (!$scope.create) {
@ -1309,6 +1322,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if ($scope.tlsClientCertificateBoundAccessTokens == true) {
$scope.clientEdit.attributes["tls.client.certificate.bound.access.tokens"] = "true";
} else {
$scope.clientEdit.attributes["tls.client.certificate.bound.access.tokens"] = "false";
}
$scope.clientEdit.protocol = $scope.protocol;
$scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm;
$scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat;

View file

@ -435,6 +435,18 @@
</div>
</fieldset>
<!-- KEYCLOAK-6771 Certificate Bound Token https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 -->
<fieldset data-ng-show="protocol == 'openid-connect'">
<legend collapsed><span class="text">{{:: 'advanced-client-settings' | translate}}</span> <kc-tooltip>{{:: 'advanced-client-settings.tooltip' | translate}}</kc-tooltip></legend>
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label>
<div class="col-sm-6">
<input ng-model="tlsClientCertificateBoundAccessTokens" ng-click="switchChange()" name="tlsClientCertificateBoundAccessTokens" id="tlsClientCertificateBoundAccessTokens" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'tls-client-certificate-bound-access-tokens.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset>
<fieldset>
<legend collapsed><span class="text">{{:: 'client-flow-bindings' | translate}}</span> <kc-tooltip>{{:: 'client-flow-bindings.tooltip' | translate}}</kc-tooltip></legend>
<div class="form-group">