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) { public void setAuthorization(Authorization authorization) {
this.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; 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 // OIDC Session Management
private List<String> post_logout_redirect_uris; private List<String> post_logout_redirect_uris;
@ -433,4 +437,13 @@ public class OIDCClientRepresentation {
this.registration_access_token = registrationAccessToken; 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"; 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 ClientModel clientModel;
private final ClientRepresentation clientRep; private final ClientRepresentation clientRep;
@ -121,6 +125,18 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(EXCLUDE_SESSION_STATE_FROM_AUTH_RESPONSE, val); 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) { private String getAttribute(String attrKey) {
if (clientModel != null) { if (clientModel != null) {
return clientModel.getAttribute(attrKey); 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 // KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
config.setCodeChallengeMethodsSupported(DEFAULT_CODE_CHALLENGE_METHODS_SUPPORTED); 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; return config;
} }

View file

@ -18,6 +18,7 @@
package org.keycloak.protocol.oidc; package org.keycloak.protocol.oidc;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.cluster.ClusterProvider; import org.keycloak.cluster.ClusterProvider;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.OAuth2Constants; 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.ClientSessionCode;
import org.keycloak.services.managers.UserSessionCrossDCManager; import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import org.keycloak.common.util.Time; 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, public RefreshResult refreshAccessToken(KeycloakSession session, UriInfo uriInfo, ClientConnection connection, RealmModel realm, ClientModel authorizedClient,
String encodedRefreshToken, EventBuilder event, HttpHeaders headers) throws OAuthErrorException { String encodedRefreshToken, EventBuilder event, HttpHeaders headers, HttpRequest request) throws OAuthErrorException {
RefreshToken refreshToken = verifyRefreshToken(session, realm, encodedRefreshToken); RefreshToken refreshToken = verifyRefreshToken(session, realm, authorizedClient, request, encodedRefreshToken, true);
event.user(refreshToken.getSubject()).session(refreshToken.getSessionState()) event.user(refreshToken.getSubject()).session(refreshToken.getSessionState())
.detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()) .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId())
@ -265,6 +267,15 @@ public class TokenManager {
.accessToken(validation.newToken) .accessToken(validation.newToken)
.generateRefreshToken(); .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); String scopeParam = validation.clientSession.getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) { if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken(); responseBuilder.generateIDToken();
@ -307,6 +318,10 @@ public class TokenManager {
} }
public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, String encodedRefreshToken, boolean checkExpiration) throws OAuthErrorException { 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 { try {
RefreshToken refreshToken = toRefreshToken(session, realm, encodedRefreshToken); 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; return refreshToken;
} catch (JWSInputException e) { } catch (JWSInputException e) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e); throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);

View file

@ -43,6 +43,7 @@ import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
@ -180,8 +181,11 @@ public class LogoutEndpoint {
event.error(Errors.INVALID_TOKEN); event.error(Errors.INVALID_TOKEN);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST); throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST);
} }
RefreshToken token = null;
try { 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()); boolean offline = TokenUtil.TOKEN_TYPE_OFFLINE.equals(token.getType());
@ -197,9 +201,16 @@ public class LogoutEndpoint {
logout(userSessionModel, offline); logout(userSessionModel, offline);
} }
} catch (OAuthErrorException e) { } catch (OAuthErrorException e) {
event.error(Errors.INVALID_TOKEN); // KEYCLOAK-6771 Certificate Bound Token
throw new ErrorResponseException(e.getError(), e.getDescription(), Response.Status.BAD_REQUEST); 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(); 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.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver; import org.keycloak.models.utils.AuthenticationFlowResolver;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; 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.IdentityBrokerService;
import org.keycloak.services.resources.admin.AdminAuth; import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.permissions.AdminPermissions; import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import org.keycloak.utils.ProfileHelper; import org.keycloak.utils.ProfileHelper;
@ -361,7 +362,7 @@ public class TokenEndpoint {
if (codeChallenge != null) { if (codeChallenge != null) {
// based on whether code_challenge has been stored at corresponding authorization code request previously // 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)) { if (!isValidPkceCodeVerifier(codeVerifier)) {
logger.infof("PKCE invalid code verifier"); logger.infof("PKCE invalid code verifier");
event.error(Errors.INVALID_CODE_VERIFIER); event.error(Errors.INVALID_CODE_VERIFIER);
@ -371,8 +372,8 @@ public class TokenEndpoint {
logger.debugf("PKCE supporting Client, codeVerifier = %s", codeVerifier); logger.debugf("PKCE supporting Client, codeVerifier = %s", codeVerifier);
String codeVerifierEncoded = codeVerifier; String codeVerifierEncoded = codeVerifier;
try { try {
// https://tools.ietf.org/html/rfc7636#section-4.2 // https://tools.ietf.org/html/rfc7636#section-4.2
// plain or S256 // plain or S256
if (codeChallengeMethod != null && codeChallengeMethod.equals(OAuth2Constants.PKCE_METHOD_S256)) { if (codeChallengeMethod != null && codeChallengeMethod.equals(OAuth2Constants.PKCE_METHOD_S256)) {
logger.debugf("PKCE codeChallengeMethod = %s", codeChallengeMethod); logger.debugf("PKCE codeChallengeMethod = %s", codeChallengeMethod);
codeVerifierEncoded = generateS256CodeChallenge(codeVerifier); codeVerifierEncoded = generateS256CodeChallenge(codeVerifier);
@ -404,6 +405,19 @@ public class TokenEndpoint {
.accessToken(token) .accessToken(token)
.generateRefreshToken(); .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); String scopeParam = clientSession.getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) { if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken(); responseBuilder.generateIDToken();
@ -424,7 +438,8 @@ public class TokenEndpoint {
AccessTokenResponse res; AccessTokenResponse res;
try { 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(); res = result.getResponse();
if (!result.isOfflineToken()) { if (!result.isOfflineToken()) {
@ -436,8 +451,14 @@ public class TokenEndpoint {
} catch (OAuthErrorException e) { } catch (OAuthErrorException e) {
logger.trace(e.getMessage(), e); logger.trace(e.getMessage(), e);
event.error(Errors.INVALID_TOKEN); // KEYCLOAK-6771 Certificate Bound Token
throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.BAD_REQUEST); 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(); 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.AuthenticationManager;
import org.keycloak.services.managers.UserSessionCrossDCManager; import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.utils.MediaType; import org.keycloak.utils.MediaType;
import javax.ws.rs.GET; 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); 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) event.user(userModel)
.detail(Details.USERNAME, userModel.getUsername()); .detail(Details.USERNAME, userModel.getUsername());

View file

@ -107,6 +107,11 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("code_challenge_methods_supported") @JsonProperty("code_challenge_methods_supported")
private List<String> codeChallengeMethodsSupported; 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>(); protected Map<String, Object> otherClaims = new HashMap<String, Object>();
public String getIssuer() { public String getIssuer() {
@ -305,10 +310,21 @@ public class OIDCConfigurationRepresentation {
public List<String> getCodeChallengeMethodsSupported() { public List<String> getCodeChallengeMethodsSupported() {
return codeChallengeMethodsSupported; return codeChallengeMethodsSupported;
} }
public void setCodeChallengeMethodsSupported(List<String> codeChallengeMethodsSupported) { public void setCodeChallengeMethodsSupported(List<String> codeChallengeMethodsSupported) {
this.codeChallengeMethodsSupported = 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 @JsonAnyGetter
public Map<String, Object> getOtherClaims() { public Map<String, Object> getOtherClaims() {
return otherClaims; 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.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper; import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; 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.OIDCResponseType;
import org.keycloak.protocol.oidc.utils.PairwiseSubMapperUtils; import org.keycloak.protocol.oidc.utils.PairwiseSubMapperUtils;
import org.keycloak.protocol.oidc.utils.SubjectType; 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.services.util.CertificateInfoHelper;
import org.keycloak.util.JWKSUtils; import org.keycloak.util.JWKSUtils;
import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.security.PublicKey; import java.security.PublicKey;
import java.util.ArrayList; import java.util.ArrayList;
@ -115,6 +113,14 @@ public class DescriptionConverter {
configWrapper.setRequestObjectSignatureAlg(algorithm); 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; return client;
} }
@ -188,6 +194,13 @@ public class DescriptionConverter {
if (config.isUseJwksUrl()) { if (config.isUseJwksUrl()) {
response.setJwksUri(config.getJwksUrl()); 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); List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client);
SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE; 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

@ -370,6 +370,17 @@ To run the X.509 client certificate authentication tests:
-Dbrowser=phantomjs \ -Dbrowser=phantomjs \
"-Dtest=*.x509.*" "-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
Cluster tests use 2 backend servers (Keycloak on Wildfly/EAP) and 1 frontend loadbalancer server node. Invalidation tests don't use loadbalancer. 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>client.key</include>
<include>intermediate-ca.crl</include> <include>intermediate-ca.crl</include>
<include>empty.crl</include> <include>empty.crl</include>
<!-- KEYCLOAK-6771 Certificate Bound Token -->
<include>other_client.jks</include>
</includes> </includes>
</resource> </resource>
<resource> <resource>

View file

@ -43,6 +43,8 @@
<exclude.crossdc>**/crossdc/**/*Test.java</exclude.crossdc> <exclude.crossdc>**/crossdc/**/*Test.java</exclude.crossdc>
<!-- exclude x509 tests by default, enabled by 'ssl' profile --> <!-- exclude x509 tests by default, enabled by 'ssl' profile -->
<exclude.x509>**/x509/*Test.java</exclude.x509> <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 tests. They can be added by -Dtest=org.keycloak.testsuite.adapter.undertow.**.*Test -->
<exclude.undertow.adapter>**/adapter/undertow/**/*Test.java</exclude.undertow.adapter> <exclude.undertow.adapter>**/adapter/undertow/**/*Test.java</exclude.undertow.adapter>
</properties> </properties>
@ -160,6 +162,7 @@
<exclude>${exclude.crossdc}</exclude> <exclude>${exclude.crossdc}</exclude>
<exclude>${exclude.undertow.adapter}</exclude> <exclude>${exclude.undertow.adapter}</exclude>
<exclude>${exclude.x509}</exclude> <exclude>${exclude.x509}</exclude>
<exclude>${exclude.HoK}</exclude>
</excludes> </excludes>
</configuration> </configuration>
</plugin> </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) { public AccessTokenResponse doAccessTokenRequest(String code, String password) {
try (CloseableHttpClient client = newCloseableHttpClient()) { try (CloseableHttpClient client = newCloseableHttpClient()) {
HttpPost post = new HttpPost(getAccessTokenUrl()); return doAccessTokenRequest(code, password, client);
} catch (IOException ioe) {
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) {
throw new RuntimeException(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) { public String introspectAccessTokenWithClientCredential(String clientId, String clientSecret, String tokenToIntrospect) {
return introspectTokenWithClientCredential(clientId, clientSecret, "access_token", tokenToIntrospect); return introspectTokenWithClientCredential(clientId, clientSecret, "access_token", tokenToIntrospect);
} }
@ -336,41 +383,6 @@ public class OAuthClient {
return introspectTokenWithClientCredential(clientId, clientSecret, "refresh_token", tokenToIntrospect); 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 { public AccessTokenResponse doGrantAccessTokenRequest(String clientSecret, String username, String password) throws Exception {
return doGrantAccessTokenRequest(realm, username, password, null, clientId, clientSecret); return doGrantAccessTokenRequest(realm, username, password, null, clientId, clientSecret);
} }
@ -532,79 +544,92 @@ public class OAuthClient {
} }
} }
// KEYCLOAK-6771 Certificate Bound Token
public CloseableHttpResponse doLogout(String refreshToken, String clientSecret) throws IOException { public CloseableHttpResponse doLogout(String refreshToken, String clientSecret) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getLogoutUrl().build()); return doLogout(refreshToken, clientSecret, client);
} catch (IOException ex) {
List<NameValuePair> parameters = new LinkedList<>(); throw new RuntimeException(ex);
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 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) { public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getRefreshTokenUrl()); return doRefreshTokenRequest(refreshToken, password, client);
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);
}
} catch (IOException ex) { } catch (IOException ex) {
throw new RuntimeException(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) { public void closeClient(CloseableHttpClient client) {
try { try {
client.close(); client.close();
@ -870,16 +895,16 @@ public class OAuthClient {
// https://tools.ietf.org/html/rfc7636#section-4 // https://tools.ietf.org/html/rfc7636#section-4
public OAuthClient codeVerifier(String codeVerifier) { public OAuthClient codeVerifier(String codeVerifier) {
this.codeVerifier = codeVerifier; this.codeVerifier = codeVerifier;
return this; return this;
} }
public OAuthClient codeChallenge(String codeChallenge) { public OAuthClient codeChallenge(String codeChallenge) {
this.codeChallenge = codeChallenge; this.codeChallenge = codeChallenge;
return this; return this;
} }
public OAuthClient codeChallengeMethod(String codeChallengeMethod) { public OAuthClient codeChallengeMethod(String codeChallengeMethod) {
this.codeChallengeMethod = codeChallengeMethod; this.codeChallengeMethod = codeChallengeMethod;
return this; return this;
} }
public OAuthClient origin(String origin) { public OAuthClient origin(String origin) {
this.origin = 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()); 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) { private ClientRepresentation getKeycloakClient(String clientId) {
return ApiUtil.findClientByClientId(adminClient.realms().realm(REALM_NAME), clientId).toRepresentation(); 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 // KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
// PKCE support // PKCE support
Assert.assertNames(oidcConfig.getCodeChallengeMethodsSupported(), OAuth2Constants.PKCE_METHOD_PLAIN, OAuth2Constants.PKCE_METHOD_S256); 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 { } finally {
client.close(); client.close();
} }

View file

@ -131,6 +131,10 @@
<client.key.file>${auth.server.config.dir}/client.key</client.key.file> <client.key.file>${auth.server.config.dir}/client.key</client.key.file>
<client.key.passphrase>secret</client.key.passphrase> <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> <auth.server.ocsp.responder.enabled>false</auth.server.ocsp.responder.enabled>
<keycloak.x509cert.lookup.provider>default</keycloak.x509cert.lookup.provider> <keycloak.x509cert.lookup.provider>default</keycloak.x509cert.lookup.provider>
</properties> </properties>
@ -304,6 +308,10 @@
<client.key.file>${client.key.file}</client.key.file> <client.key.file>${client.key.file}</client.key.file>
<client.key.passphrase>${client.key.passphrase}</client.key.passphrase> <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> <auth.server.ocsp.responder.enabled>${auth.server.ocsp.responder.enabled}</auth.server.ocsp.responder.enabled>
<!--cache server properties--> <!--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-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.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. 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-key=Consumer Key
bitbucket-consumer-secret=Consumer Secret bitbucket-consumer-secret=Consumer Secret
bitbucket.key.tooltip=Bitbucket OAuth Consumer Key bitbucket.key.tooltip=Bitbucket OAuth Consumer Key
bitbucket.secret.tooltip=Bitbucket OAuth Consumer Secret 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'. bitbucket.default-scopes.tooltip=Scopes to ask for on login. If you do not specify anything, scope defaults to 'email'.
# User federation # User federation
sync-ldap-roles-to-keycloak=Sync LDAP Roles To Keycloak sync-ldap-roles-to-keycloak=Sync LDAP Roles To Keycloak
sync-keycloak-roles-to-ldap=Sync Keycloak Roles To LDAP sync-keycloak-roles-to-ldap=Sync Keycloak Roles To LDAP
sync-ldap-groups-to-keycloak=Sync LDAP Groups To Keycloak sync-ldap-groups-to-keycloak=Sync LDAP Groups To Keycloak
sync-keycloak-groups-to-ldap=Sync Keycloak Groups To LDAP sync-keycloak-groups-to-ldap=Sync Keycloak Groups To LDAP
realms=Realms realms=Realms
realm=Realm realm=Realm
identity-provider-mappers=Identity Provider Mappers identity-provider-mappers=Identity Provider Mappers
create-identity-provider-mapper=Create Identity Provider Mapper create-identity-provider-mapper=Create Identity Provider Mapper
add-identity-provider-mapper=Add 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} 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 expires=Expires
expiration=Expiration expiration=Expiration
expiration.tooltip=Specifies how long the token should be valid 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.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 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 no-initial-access-available=No Initial Access Tokens available
client-reg-policies=Client Registration Policies client-reg-policies=Client Registration Policies
client-reg-policy.name.tooltip=Display Name of the policy client-reg-policy.name.tooltip=Display Name of the policy
anonymous-policies=Anonymous Access Policies 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'. 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 user-object-classes=User Object Classes
ldap-user-object-classes.placeholder=LDAP User Object Classes (div. by comma) ldap-user-object-classes.placeholder=LDAP User Object Classes (div. by comma)
ldap-connection-url=LDAP connection URL ldap-connection-url=LDAP connection URL
ldap-users-dn=LDAP Users DN ldap-users-dn=LDAP Users DN
ldap-bind-dn=LDAP Bind DN ldap-bind-dn=LDAP Bind DN
ldap-bind-credentials=LDAP Bind Credentials ldap-bind-credentials=LDAP Bind Credentials
ldap-filter=LDAP Filter 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. 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 connection-url=Connection URL
ldap.connection-url.tooltip=Connection URL to your LDAP server ldap.connection-url.tooltip=Connection URL to your LDAP server
test-connection=Test connection 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=Identity Provider Username
identity-provider-username.tooltip=Username on the Identity Provider side identity-provider-username.tooltip=Username on the Identity Provider side
pagination=Pagination pagination=Pagination
browser-flow=Browser Flow browser-flow=Browser Flow
browser-flow.tooltip=Select the flow you want to use for browser authentication. browser-flow.tooltip=Select the flow you want to use for browser authentication.
registration-flow=Registration Flow 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. groups.default-groups.tooltip=Set of groups that new users will automatically join.
cut=Cut cut=Cut
paste=Paste paste=Paste
create-group=Create group create-group=Create group
create-authenticator-execution=Create Authenticator Execution create-authenticator-execution=Create Authenticator Execution
create-form-action-execution=Create Form Action Execution create-form-action-execution=Create Form Action Execution
@ -1137,7 +1127,6 @@ saved-types=Saved Types
clear-admin-events=Clear admin events clear-admin-events=Clear admin events
clear-changes=Clear changes clear-changes=Clear changes
error=Error error=Error
# Authz # Authz
# Authz Common # Authz Common
authz-authorization=Authorization authz-authorization=Authorization
@ -1175,22 +1164,17 @@ authz-show-details=Show Details
authz-hide-details=Hide Details authz-hide-details=Hide Details
authz-associated-permissions=Associated Permissions authz-associated-permissions=Associated Permissions
authz-no-permission-associated=No permissions associated authz-no-permission-associated=No permissions associated
# Authz Settings # Authz Settings
authz-import-config.tooltip=Import a JSON file containing authorization settings for this resource server. 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=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.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-enforcing=Enforcing
authz-policy-enforcement-mode-permissive=Permissive authz-policy-enforcement-mode-permissive=Permissive
authz-policy-enforcement-mode-disabled=Disabled authz-policy-enforcement-mode-disabled=Disabled
authz-remote-resource-management=Remote Resource Management 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-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=Export Settings
authz-export-settings.tooltip=Export and download all authorization settings for this resource server. authz-export-settings.tooltip=Export and download all authorization settings for this resource server.
# Authz Resource List # Authz Resource List
authz-no-resources-available=No resources available. authz-no-resources-available=No resources available.
authz-no-scopes-assigned=No scopes assigned. 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-permission-assigned=No permission assigned.
authz-no-policy-assigned=No policy assigned. authz-no-policy-assigned=No policy assigned.
authz-create-permission=Create Permission authz-create-permission=Create Permission
# Authz Resource Detail # Authz Resource Detail
authz-add-resource=Add Resource 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. 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 Scope List
authz-add-scope=Add Scope authz-add-scope=Add Scope
authz-no-scopes-available=No scopes available. authz-no-scopes-available=No scopes available.
# Authz Scope Detail # 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-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 Policy List
authz-all-types=All types authz-all-types=All types
authz-create-policy=Create Policy authz-create-policy=Create Policy
authz-no-policies-available=No policies available. authz-no-policies-available=No policies available.
# Authz Policy Detail # Authz Policy Detail
authz-policy-name.tooltip=The name of this policy. authz-policy-name.tooltip=The name of this policy.
authz-policy-description.tooltip=A description for 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-policy-decision-strategy-consensus=Consensus
authz-select-a-policy=Select existing policy authz-select-a-policy=Select existing policy
authz-no-policies-assigned=No policies assigned. authz-no-policies-assigned=No policies assigned.
# Authz Role Policy Detail # Authz Role Policy Detail
authz-add-role-policy=Add Role Policy authz-add-role-policy=Add Role Policy
authz-no-roles-assigned=No roles assigned. authz-no-roles-assigned=No roles assigned.
authz-policy-role-realm-roles.tooltip=Specifies the *realm* roles allowed by this policy. 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-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-policy-role-client-roles.tooltip=Specifies the client roles allowed by this policy.
# Authz User Policy Detail # Authz User Policy Detail
authz-add-user-policy=Add User Policy authz-add-user-policy=Add User Policy
authz-no-users-assigned=No users assigned. authz-no-users-assigned=No users assigned.
authz-policy-user-users.tooltip=Specifies which user(s) are allowed by this policy. authz-policy-user-users.tooltip=Specifies which user(s) are allowed by this policy.
# Authz Client Policy Detail # Authz Client Policy Detail
authz-add-client-policy=Add Client Policy authz-add-client-policy=Add Client Policy
authz-no-clients-assigned=No clients assigned. authz-no-clients-assigned=No clients assigned.
authz-policy-client-clients.tooltip=Specifies which client(s) are allowed by this policy. authz-policy-client-clients.tooltip=Specifies which client(s) are allowed by this policy.
# Authz Time Policy Detail # Authz Time Policy Detail
authz-add-time-policy=Add Time Policy 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. 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-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=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-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 Drools Policy Detail
authz-add-drools-policy=Add Rules Policy authz-add-drools-policy=Add Rules Policy
authz-policy-drools-maven-artifact-resolve=Resolve 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-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=Update Period
authz-policy-drools-update-period.tooltip=Specifies an interval for scanning for artifact updates. authz-policy-drools-update-period.tooltip=Specifies an interval for scanning for artifact updates.
# Authz JS Policy Detail # Authz JS Policy Detail
authz-add-js-policy=Add JavaScript Policy authz-add-js-policy=Add JavaScript Policy
authz-policy-js-code=Code authz-policy-js-code=Code
authz-policy-js-code.tooltip=The JavaScript code providing the conditions for this policy. authz-policy-js-code.tooltip=The JavaScript code providing the conditions for this policy.
# Authz Aggregated Policy Detail # Authz Aggregated Policy Detail
authz-aggregated=Aggregated authz-aggregated=Aggregated
authz-add-aggregated-policy=Add Aggregated Policy authz-add-aggregated-policy=Add Aggregated Policy
# Authz Group Policy Detail # Authz Group Policy Detail
authz-add-group-policy=Add Group Policy authz-add-group-policy=Add Group Policy
authz-no-groups-assigned=No groups assigned. 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. 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-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 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.disableAuthorizationTab = !client.authorizationServicesEnabled;
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled; $scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
$scope.disableCredentialsTab = client.publicClient; $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(client.origin) {
if ($scope.access.viewRealm) { if ($scope.access.viewRealm) {
@ -1068,6 +1071,16 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
$scope.excludeSessionStateFromAuthResponse = false; $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) { 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.protocol = $scope.protocol;
$scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm; $scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm;
$scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat; $scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat;

View file

@ -435,6 +435,18 @@
</div> </div>
</fieldset> </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> <fieldset>
<legend collapsed><span class="text">{{:: 'client-flow-bindings' | translate}}</span> <kc-tooltip>{{:: 'client-flow-bindings.tooltip' | translate}}</kc-tooltip></legend> <legend collapsed><span class="text">{{:: 'client-flow-bindings' | translate}}</span> <kc-tooltip>{{:: 'client-flow-bindings.tooltip' | translate}}</kc-tooltip></legend>
<div class="form-group"> <div class="form-group">