Implement DPoP for all grantTypes (#29967)

fixes #30179
fixes #30181


Signed-off-by: Pascal Knüppel <captain.p.goldfish@gmx.de>
Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
This commit is contained in:
Pascal Knüppel 2024-07-29 16:30:54 +02:00 committed by GitHub
parent 17c01c9380
commit 94784182df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 405 additions and 171 deletions

View file

@ -152,6 +152,8 @@ public interface OAuth2Constants {
String ISSUER = "iss";
String AUTHENTICATOR_METHOD_REFERENCE = "amr";
String CNF = "cnf";
}

View file

@ -36,7 +36,6 @@ public class RefreshToken extends AccessToken {
/**
* Deep copies issuer, subject, issuedFor, sessionState from AccessToken.
*
* @param token
*/
public RefreshToken(AccessToken token) {
this();
@ -49,6 +48,25 @@ public class RefreshToken extends AccessToken {
this.scope = token.scope;
}
/**
* Deep copies issuer, subject, issuedFor, sessionState from AccessToken.
*
* @param token
* @param confirmation optional confirmation parameter that might be processed during authentication but should not
* always be included in the response
*/
public RefreshToken(AccessToken token, Confirmation confirmation) {
this();
this.issuer = token.issuer;
this.subject = token.subject;
this.issuedFor = token.issuedFor;
this.sessionId = token.sessionId;
this.nonce = token.nonce;
this.audience = new String[] { token.issuer };
this.scope = token.scope;
this.confirmation = confirmation;
}
@Override
public TokenCategory getCategory() {
return TokenCategory.INTERNAL;

View file

@ -33,7 +33,6 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.Provider;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.services.cors.Cors;
/**
@ -72,10 +71,9 @@ public interface OAuth2GrantType extends Provider {
protected EventBuilder event;
protected Cors cors;
protected Object tokenManager;
protected DPoP dPoP;
public Context(KeycloakSession session, Object clientConfig, Map<String, String> clientAuthAttributes,
MultivaluedMap<String, String> formParams, EventBuilder event, Cors cors, Object tokenManager, DPoP dPoP) {
MultivaluedMap<String, String> formParams, EventBuilder event, Cors cors, Object tokenManager) {
this.session = session;
this.realm = session.getContext().getRealm();
this.client = session.getContext().getClient();
@ -89,7 +87,6 @@ public interface OAuth2GrantType extends Provider {
this.event = event;
this.cors = cors;
this.tokenManager = tokenManager;
this.dPoP = dPoP;
}
public Context(Context context) {
@ -106,7 +103,6 @@ public interface OAuth2GrantType extends Provider {
this.event = context.event;
this.cors = context.cors;
this.tokenManager = context.tokenManager;
this.dPoP = context.dPoP;
}
public void setFormParams(MultivaluedHashMap<String, String> formParams) {

View file

@ -24,6 +24,7 @@ import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.services.util.DPoPUtil;
import java.lang.reflect.Method;
import java.util.AbstractMap;
@ -130,7 +131,9 @@ public class ProtocolMapperUtils {
public static Stream<Entry<ProtocolMapperModel, ProtocolMapper>> getSortedProtocolMappers(KeycloakSession session, ClientSessionContext ctx, Predicate<Entry<ProtocolMapperModel, ProtocolMapper>> filter) {
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
return ctx.getProtocolMappersStream()
Stream<Entry<ProtocolMapperModel, ProtocolMapper>> protocolMapperStream = //
ctx.getProtocolMappersStream()
.<Entry<ProtocolMapperModel, ProtocolMapper>>map(mapperModel -> {
ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapperModel.getProtocolMapper());
if (mapper == null) {
@ -139,7 +142,9 @@ public class ProtocolMapperUtils {
return new AbstractMap.SimpleEntry<>(mapperModel, mapper);
})
.filter(Objects::nonNull)
.filter(filter)
.filter(filter);
return Stream.concat(protocolMapperStream, DPoPUtil.getTransientProtocolMapper())
.sorted(Comparator.comparing(ProtocolMapperUtils::compare));
}

View file

@ -113,6 +113,7 @@ public class DockerAuthV2Protocol implements LoginProtocol {
AtomicReference<DockerResponseToken> finalResponseToken = new AtomicReference<>(responseToken);
ProtocolMapperUtils.getSortedProtocolMappers(session, clientSessionCtx, mapper ->
mapper.getValue() instanceof DockerAuthV2AttributeMapper && ((DockerAuthV2AttributeMapper) mapper.getValue()).appliesTo(finalResponseToken.get()))
.filter(mapper -> mapper instanceof DockerAuthV2AttributeMapper)
.forEach(mapper -> finalResponseToken.set(((DockerAuthV2AttributeMapper) mapper.getValue())
.transformDockerResponseToken(finalResponseToken.get(), mapper.getKey(), session, userSession, clientSession)));
responseToken = finalResponseToken.get();

View file

@ -46,7 +46,6 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.ImpersonationSessionNote;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
@ -959,7 +958,7 @@ public class TokenManager {
ClientSessionContext clientSessionCtx, UriInfo uriInfo) {
AccessToken token = new AccessToken();
token.id(KeycloakModelUtils.generateId());
token.type(TokenUtil.TOKEN_TYPE_BEARER);
token.type(formatTokenType(client, token));
if (UserSessionModel.SessionPersistenceState.TRANSIENT.equals(session.getPersistenceState())) {
token.subject(user.getId());
}
@ -1061,7 +1060,7 @@ public class TokenManager {
this.session = session;
this.userSession = userSession;
this.clientSessionCtx = clientSessionCtx;
this.responseTokenType = formatTokenType(client);
this.responseTokenType = formatTokenType(client, null);
}
public AccessToken getAccessToken() {
@ -1078,6 +1077,7 @@ public class TokenManager {
public AccessTokenResponseBuilder accessToken(AccessToken accessToken) {
this.accessToken = accessToken;
this.responseTokenType = formatTokenType(client, accessToken);
return this;
}
public AccessTokenResponseBuilder refreshToken(RefreshToken refreshToken) {
@ -1098,6 +1098,7 @@ public class TokenManager {
public AccessTokenResponseBuilder generateAccessToken() {
UserModel user = userSession.getUser();
accessToken = createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
responseTokenType = formatTokenType(client, accessToken);
return this;
}
@ -1136,10 +1137,11 @@ public class TokenManager {
}
private void generateRefreshToken(boolean offlineTokenRequested) {
refreshToken = new RefreshToken(accessToken);
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
final AccessToken.Confirmation confirmation = getConfirmation(clientSession, accessToken);
refreshToken = new RefreshToken(accessToken, confirmation);
refreshToken.id(KeycloakModelUtils.generateId());
refreshToken.issuedNow();
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
clientSession.setTimestamp(refreshToken.getIat().intValue());
UserSessionModel userSession = clientSession.getUserSession();
userSession.setLastSessionRefresh(refreshToken.getIat().intValue());
@ -1160,6 +1162,19 @@ public class TokenManager {
}
}
/**
* RFC9449 chapter 5<br/>
* Refresh tokens issued to confidential clients are not bound to the DPoP proof public key because
* they are already sender-constrained with a different existing mechanism.<br/>
* <br/>
* Based on the definition above the confirmation is only returned for public-clients.
*/
private AccessToken.Confirmation getConfirmation(AuthenticatedClientSessionModel clientSession,
AccessToken accessToken) {
final boolean isPublicClient = clientSession.getClient().isPublicClient();
return isPublicClient ? accessToken.getConfirmation() : null;
}
private Long getExpiration(boolean offline) {
long expiration = SessionExpirationUtils.calculateClientSessionIdleTimestamp(
offline, userSession.isRememberMe(),
@ -1314,11 +1329,13 @@ public class TokenManager {
}
private String formatTokenType(ClientModel client) {
private String formatTokenType(ClientModel client, AccessToken accessToken) {
final String tokenType = Optional.ofNullable(accessToken).map(AccessToken::getType)
.orElse(TokenUtil.TOKEN_TYPE_BEARER);
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseLowerCaseInTokenResponse()) {
return TokenUtil.TOKEN_TYPE_BEARER.toLowerCase();
return tokenType.toLowerCase();
}
return TokenUtil.TOKEN_TYPE_BEARER;
return tokenType;
}
public static class NotBeforeCheck implements TokenVerifier.Predicate<JsonWebToken> {

View file

@ -48,7 +48,6 @@ import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlClient;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.saml.common.constants.JBossSAMLConstants;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.common.exceptions.ConfigurationException;
@ -56,6 +55,7 @@ import org.keycloak.saml.common.exceptions.ProcessingException;
import org.keycloak.saml.common.util.DocumentUtil;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.util.DPoPUtil;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
@ -73,7 +73,6 @@ public class TokenEndpoint {
private ClientModel client;
private Map<String, String> clientAuthAttributes;
private OIDCAdvancedConfigWrapper clientConfig;
private DPoP dPoP;
private final KeycloakSession session;
@ -136,7 +135,19 @@ public class TokenEndpoint {
checkParameterDuplicated();
}
OAuth2GrantType.Context context = new OAuth2GrantType.Context(session, clientConfig, clientAuthAttributes, formParams, event, cors, tokenManager, dPoP);
/*
* To request an access token that is bound to a public key using DPoP, the client MUST provide a valid DPoP
* proof JWT in a DPoP header when making an access token request to the authorization server's token endpoint.
* This is applicable for all access token requests regardless of grant type (e.g., the common
* authorization_code and refresh_token grant types and extension grants such as the JWT
* authorization grant [RFC7523])
*/
DPoPUtil.retrieveDPoPHeaderIfPresent(session, clientConfig, event, cors).ifPresent(dPoP -> {
session.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP);
});
OAuth2GrantType.Context context = new OAuth2GrantType.Context(session, clientConfig, clientAuthAttributes,
formParams, event, cors, tokenManager);
return grant.process(context);
}

View file

@ -26,7 +26,6 @@ import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.Profile;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@ -34,7 +33,6 @@ import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -63,8 +61,6 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
public Response process(Context context) {
setContext(context);
checkAndRetrieveDPoPProof(Profile.isFeatureEnabled(Profile.Feature.DPOP));
String code = formParams.getFirst(OAuth2Constants.CODE);
if (code == null) {
String errorMessage = "Missing parameter: " + OAuth2Constants.CODE;

View file

@ -24,12 +24,7 @@ import jakarta.ws.rs.core.Response;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jboss.logging.Logger;
@ -37,7 +32,6 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@ -46,7 +40,6 @@ import org.keycloak.http.HttpRequest;
import org.keycloak.http.HttpResponse;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -58,14 +51,12 @@ import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.rar.AuthorizationRequestContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.util.AuthorizationContextUtil;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.util.TokenUtil;
@ -90,7 +81,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
protected EventBuilder event;
protected Cors cors;
protected TokenManager tokenManager;
protected DPoP dPoP;
protected HttpRequest request;
protected HttpResponse response;
protected HttpHeaders headers;
@ -110,7 +100,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
this.event = context.event;
this.cors = context.cors;
this.tokenManager = (TokenManager) context.tokenManager;
this.dPoP = context.dPoP;
}
protected Response createTokenResponse(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
@ -125,7 +114,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
}
checkAndBindMtlsHoKToken(responseBuilder, useRefreshToken);
checkAndBindDPoPToken(responseBuilder, useRefreshToken && client.isPublicClient(), Profile.isFeatureEnabled(Profile.Feature.DPOP));
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
@ -182,21 +170,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
}
}
protected void checkAndBindDPoPToken(TokenManager.AccessTokenResponseBuilder responseBuilder, boolean useRefreshToken, boolean isDPoPSupported) {
if (!isDPoPSupported) return;
if (clientConfig.isUseDPoP() || dPoP != null) {
DPoPUtil.bindToken(responseBuilder.getAccessToken(), dPoP);
responseBuilder.getAccessToken().type(DPoPUtil.DPOP_TOKEN_TYPE);
responseBuilder.responseTokenType(DPoPUtil.DPOP_TOKEN_TYPE);
// Bind refresh tokens for public clients, See "Section 5. DPoP Access Token Request" from DPoP specification
if (useRefreshToken) {
DPoPUtil.bindToken(responseBuilder.getRefreshToken(), dPoP);
}
}
}
protected void updateClientSession(AuthenticatedClientSessionModel clientSession) {
if(clientSession == null) {
@ -227,21 +200,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
}
}
protected void checkAndRetrieveDPoPProof(boolean isDPoPSupported) {
if (!isDPoPSupported) return;
if (clientConfig.isUseDPoP() || request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) != null) {
try {
dPoP = new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).validate();
session.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP);
} catch (VerificationException ex) {
event.detail(Details.REASON, ex.getMessage());
event.error(Errors.INVALID_DPOP_PROOF);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_DPOP_PROOF, ex.getMessage(), Response.Status.BAD_REQUEST);
}
}
}
protected String getRequestedScopes() {
String scope = formParams.getFirst(OAuth2Constants.SCOPE);

View file

@ -52,8 +52,6 @@ public class RefreshTokenGrantType extends OAuth2GrantTypeBase {
public Response process(Context context) {
setContext(context);
checkAndRetrieveDPoPProof(Profile.isFeatureEnabled(Profile.Feature.DPOP));
String refreshToken = formParams.getFirst(OAuth2Constants.REFRESH_TOKEN);
if (refreshToken == null) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST);
@ -76,7 +74,6 @@ public class RefreshTokenGrantType extends OAuth2GrantTypeBase {
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.refreshAccessToken(session, session.getContext().getUri(), clientConnection, realm, client, refreshToken, event, headers, request, scopeParameter);
checkAndBindMtlsHoKToken(responseBuilder, clientConfig.isUseRefreshToken());
checkAndBindDPoPToken(responseBuilder, clientConfig.isUseRefreshToken() && (client.isPublicClient() || client.isBearerOnly()), Profile.isFeatureEnabled(Profile.Feature.DPOP));
session.clientPolicy().triggerOnEvent(new TokenRefreshResponseContext(formParams, responseBuilder));

View file

@ -26,6 +26,8 @@ import org.keycloak.models.RealmModel;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.util.TokenUtil;
import java.util.regex.Pattern;
/**
@ -65,7 +67,7 @@ public class AppAuthManager extends AuthenticationManager {
}
String bearerPart = split[0];
if (!bearerPart.equalsIgnoreCase(BEARER)){
if (!bearerPart.equalsIgnoreCase(BEARER) && !bearerPart.equalsIgnoreCase(TokenUtil.TOKEN_TYPE_DPOP)){
return null;
}

View file

@ -19,33 +19,61 @@ package org.keycloak.services.util;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.codec.binary.Hex;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.TokenVerifier;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.exceptions.TokenVerificationException;
import org.keycloak.http.HttpRequest;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.ProtocolMapperUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenResponseMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.TokenIntrospectionTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.util.JWKSUtils;
import static org.keycloak.utils.StringUtil.isNotBlank;
@ -60,8 +88,6 @@ public class DPoPUtil {
public static final String DPOP_TOKEN_TYPE = "DPoP";
public static final String DPOP_SCHEME = "DPoP";
public final static String DPOP_SESSION_ATTRIBUTE = "dpop";
public final static String DPOP_PARAM = "dpop";
public final static String DPOP_THUMBPRINT_NOTE = "dpop.thumbprint";
public static enum Mode {
ENABLED,
@ -89,6 +115,62 @@ public class DPoPUtil {
return UriBuilder.fromUri(uri).replaceQuery("").build();
}
/**
* creates a protocol mapper that cannot be modified by administration users and that is used to bind AccessTokens
* to specific DPoP keys. <br />
* <br />
* NOTE: The binding was solved with a protocol mapper to have generic solution for DPoP on all implemented
* grantTypes, even custom-implemented grantTypes.
*/
public static Stream<Map.Entry<ProtocolMapperModel, ProtocolMapper>> getTransientProtocolMapper() {
final String PROVIDER_ID = DPOP_SCHEME.toLowerCase(Locale.ROOT) + "-protocol-mapper";
ProtocolMapperModel protocolMapperModel = new ProtocolMapperModel();
protocolMapperModel.setId(DPOP_SCHEME);
protocolMapperModel.setName(DPOP_SCHEME);
protocolMapperModel.setProtocolMapper(PROVIDER_ID);
protocolMapperModel.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> config = new HashMap<>();
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "false");
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "false");
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "false");
protocolMapperModel.setConfig(config);
ProtocolMapper dpopProtocolMapper = new DpopProtocolMapper(PROVIDER_ID);
return Stream.of(Map.entry(protocolMapperModel, dpopProtocolMapper));
}
/**
* checks the current request if a DPoP HTTP Header is present and returns it if it is present.
*/
public static Optional<DPoP> retrieveDPoPHeaderIfPresent(KeycloakSession keycloakSession,
OIDCAdvancedConfigWrapper clientConfig,
EventBuilder event,
Cors cors) {
boolean isDPoPSupported = Profile.isFeatureEnabled(Profile.Feature.DPOP);
if (!isDPoPSupported) {
return Optional.empty();
}
HttpRequest request = keycloakSession.getContext().getHttpRequest();
final boolean isClientRequiresDpop = clientConfig.isUseDPoP();
final boolean isDpopHeaderPresent = request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) != null;
if (!isClientRequiresDpop && !isDpopHeaderPresent) {
return Optional.empty();
}
try {
DPoP dPoP = new DPoPUtil.Validator(keycloakSession).request(request).uriInfo(keycloakSession.getContext().getUri()).validate();
keycloakSession.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP);
return Optional.of(dPoP);
} catch (VerificationException ex) {
event.detail(Details.REASON, ex.getMessage());
event.error(Errors.INVALID_DPOP_PROOF);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_DPOP_PROOF, ex.getMessage(), Response.Status.BAD_REQUEST);
}
}
private static DPoP validateDPoP(KeycloakSession session, URI uri, String method, String token, String accessToken, int lifetime, int clockSkew) throws VerificationException {
if (token == null || token.trim().equals("")) {
@ -150,7 +232,7 @@ public class DPoPUtil {
} catch (DPoPVerificationException ex) {
throw ex;
} catch (VerificationException ex) {
throw new VerificationException("DPoP verification failure", ex);
throw new VerificationException("DPoP verification failure: " + ex.getMessage(), ex);
}
}
@ -387,4 +469,67 @@ public class DPoPUtil {
}
/**
* a custom protocol mapper that is not meant for configuration in the Admin-UI. This mapper is created on the
* fly for TokenRequests to bind the created generated AccessTokens to the key of the DPoP HTTP Header.
*/
private static final class DpopProtocolMapper extends AbstractOIDCProtocolMapper
implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper, TokenIntrospectionTokenMapper,
OIDCAccessTokenResponseMapper {
private final String providerId;
public DpopProtocolMapper(String providerId) {
this.providerId = providerId;
}
@Override
public String getDisplayCategory() {
return TOKEN_MAPPER_CATEGORY;
}
@Override
public String getDisplayType() {
return DPOP_SCHEME;
}
@Override
public String getHelpText() {
return "not needed";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of(new ProviderConfigProperty("multivalued", ProtocolMapperUtils.MULTIVALUED, "",
ProviderConfigProperty.BOOLEAN_TYPE, false));
}
@Override
public String getId() {
return providerId;
}
@Override
public AccessToken transformAccessToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
boolean isDPoPSupported = Profile.isFeatureEnabled(Profile.Feature.DPOP);
if (!isDPoPSupported) {
return super.transformAccessToken(token, mappingModel, session, userSession, clientSessionCtx);
}
DPoP dPoP = session.getAttribute(DPOP_SESSION_ATTRIBUTE, DPoP.class);
if (dPoP == null) {
return super.transformAccessToken(token, mappingModel, session, userSession, clientSessionCtx);
}
AccessToken.Confirmation confirmation = (AccessToken.Confirmation) token.getOtherClaims()
.get(OAuth2Constants.CNF);
if (confirmation == null) {
confirmation = new AccessToken.Confirmation();
token.setConfirmation(confirmation);
}
confirmation.setKeyThumbprint(dPoP.getThumbprint());
// make sure that the token-type is set to DPoP. This will be resolved if the AccessTokenResponse is built.
token.type(DPOP_TOKEN_TYPE);
return super.transformAccessToken(token, mappingModel, session, userSession, clientSessionCtx);
}
}
}

View file

@ -83,6 +83,8 @@ import org.keycloak.utils.MediaType;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
@ -116,6 +118,9 @@ import static org.keycloak.testsuite.util.WaitUtils.waitUntilElement;
* @author Stan Silvert ssilvert@redhat.com (C) 2016 Red Hat Inc.
*/
public class OAuthClient {
private static final Logger logger = LoggerFactory.getLogger(OAuthClient.class);
public static String SERVER_ROOT;
public static String AUTH_SERVER_ROOT;
public static String APP_ROOT;
@ -384,7 +389,7 @@ public class OAuthClient {
}
driver.findElement(By.name("login")).click();
} catch (Throwable t) {
System.err.println(src);
logger.error("Unexpected page was loaded\n{}", src);
throw t;
}
}
@ -499,7 +504,7 @@ public class OAuthClient {
}
if (dpopProof != null) {
post.addHeader("DPoP", dpopProof);
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
}
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
@ -592,6 +597,9 @@ public class OAuthClient {
post.addHeader(header.getKey(), header.getValue());
}
}
if (dpopProof != null) {
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
}
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
@ -748,6 +756,9 @@ public class OAuthClient {
String authorization = BasicAuthHelper.RFC6749.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
}
if (dpopProof != null) {
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
}
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
@ -979,7 +990,7 @@ public class OAuthClient {
}
if (dpopProof != null) {
post.addHeader("DPoP", dpopProof);
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
}
UrlEncodedFormEntity formEntity;
@ -1033,7 +1044,7 @@ public class OAuthClient {
}
if (dpopProof != null) {
post.addHeader("DPoP", dpopProof);
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
}
UrlEncodedFormEntity formEntity;
@ -1154,12 +1165,12 @@ public class OAuthClient {
}
}
public UserInfoResponse doUserInfoRequestByGet(String accessToken) throws Exception {
public UserInfoResponse doUserInfoRequestByGet(OAuthClient.AccessTokenResponse accessTokenResponse) throws Exception {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpGet get = new HttpGet(getUserInfoUrl());
get.setHeader("Authorization", "Bearer " + accessToken);
get.setHeader("Authorization", accessTokenResponse.getTokenType() + " " + accessTokenResponse.getAccessToken());
if (dpopProof != null) {
get.addHeader("DPoP", dpopProof);
get.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
}
return new UserInfoResponse(client.execute(get));
} catch (IOException ex) {

View file

@ -551,7 +551,7 @@ public final class KcOidcBrokerTransientSessionsTest extends AbstractAdvancedBro
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, CONSUMER_BROKER_APP_SECRET);
// Check that userInfo can be invoked
var userInfoResponse = oauth.doUserInfoRequestByGet(tokenResponse.getAccessToken());
var userInfoResponse = oauth.doUserInfoRequestByGet(tokenResponse);
assertThat(userInfoResponse.getUserInfo().getSub(), is(lwUserId));
assertThat(userInfoResponse.getUserInfo().getPreferredUsername(), is(bc.getUserLogin()));
assertThat(userInfoResponse.getUserInfo().getEmail(), is(bc.getUserEmail()));

View file

@ -26,7 +26,6 @@ import org.junit.Before;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.common.Profile;
import org.keycloak.common.util.SecretGenerator;
@ -41,13 +40,12 @@ import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls;
import org.keycloak.testsuite.util.ClientPoliciesUtil;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.MutualTLSUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.util.JsonSerialization;
@ -76,7 +74,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest {
private JWK jwkEc;
private String validRedirectUri;;
private String validRedirectUri;
@Before
public void setupValidateRedirectUri() {
@ -243,7 +241,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest {
// userinfo request with DPoP Proof - success
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET, oauth.getUserInfoUrl(), (long) Time.currentTime(), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
oauth.idTokenHint(response.getIdToken()).openLogout();
@ -279,7 +277,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest {
clientConfig.setRequestUris(Collections.singletonList(TestApplicationResourceUrls.clientRequestUri()));
clientConfig.setPkceCodeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256);
clientConfig.setUseDPoP(true);
};
}
private void testProhibitedImplicitOrHybridFlow(boolean isOpenid, String responseType, String nonce) {
oauth.openid(isOpenid);

View file

@ -17,32 +17,10 @@
package org.keycloak.testsuite.oauth;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createDPoPBindEnforcerExecutorConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createEcJwk;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createRsaJwk;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateEcdsaKey;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateSignedDPoPProof;
import java.io.IOException;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.junit.Before;
import org.junit.Rule;
@ -83,22 +61,44 @@ import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.OAuthClient.UserInfoResponse;
import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.OAuthClient.UserInfoResponse;
import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.HttpMethod;
import java.io.IOException;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createDPoPBindEnforcerExecutorConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createEcJwk;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createRsaJwk;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateEcdsaKey;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateSignedDPoPProof;
@EnableFeature(value = Profile.Feature.DPOP, skipRestart = true)
public class DPoPTest extends AbstractTestRealmKeycloakTest {
@ -110,19 +110,16 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
private static final String TEST_USER_NAME = "test-user@localhost";
private static final String TEST_USER_PASSWORD = "password";
private static final String DPOP_JWT_HEADER_TYPE = "dpop+jwt";
@Rule
public AssertEvents events = new AssertEvents(this);
private KeyPair ecKeyPair;
private KeyPair rsaKeyPair;
private JWK jwkRsa;
private JWK jwkEc;
private JWSHeader jwsRsaHeader;
private JWSHeader jwsEcHeader;
private ClientRegistration reg;
@Rule
public AssertEvents events = new AssertEvents(this);
@Before
public void beforeDPoPTest() throws Exception {
ecKeyPair = generateEcdsaKey("secp256r1");
@ -155,6 +152,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
@ -171,6 +169,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
oauth.dpopProof(dpopProofEcEncoded);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
accessToken = oauth.verifyToken(response.getAccessToken());
@ -181,9 +180,8 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
// userinfo access
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
oauth.idTokenHint(response.getIdToken()).openLogout();
}
@ -202,6 +200,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofRsaEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
@ -235,6 +234,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
oauth.dpopProof(dpopProofRsaEncoded);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
@ -263,6 +263,8 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
// token-type must be "Bearer" because no DPoP is present within the token-request
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType());
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
@ -272,6 +274,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
// token refresh
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType());
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
accessToken = oauth.verifyToken(response.getAccessToken());
@ -296,9 +299,11 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
// token refresh
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
assertNull(response.getTokenType());
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
assertEquals("DPoP proof has already been used", response.getErrorDescription());
@ -317,10 +322,12 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofRsaEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_USER_PASSWORD);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
// token refresh
oauth.dpopProof(null);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
assertNull(response.getTokenType());
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
assertEquals("DPoP proof is missing", response.getErrorDescription());
@ -358,7 +365,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
@Test
public void testDPoPProofInvalidSignature() throws Exception {
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsEcHeader, rsaKeyPair.getPrivate()), "DPoP verification failure");
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsEcHeader, rsaKeyPair.getPrivate()), "DPoP verification failure: org.keycloak.exceptions.TokenSignatureInvalidException: Invalid token signature");
}
@Test
@ -376,6 +383,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_USER_PASSWORD);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
testDPoPProofFailure(dpopProofEcEncoded, "DPoP proof has already been used");
@ -417,7 +425,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
public void testDPoPProofOnUserInfoByConfidentialClient() throws Exception {
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
doSuccessfulUserInfoGet(response, rsaKeyPair);
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
}
@ -429,11 +437,11 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
try {
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
doSuccessfulUserInfoGet(response, rsaKeyPair);
// delete DPoP proof
oauth.dpopProof(null);
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
@ -449,7 +457,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
oauth.dpopProof(null);
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
@ -466,7 +474,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
// invalid "htu" claim
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
oauth.dpopProof(dpopProofRsaEncoded);
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
@ -477,10 +485,10 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
public void testMultipleUseDPoPProofOnUserInfo() throws Exception {
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
doSuccessfulUserInfoGet(response, rsaKeyPair);
// use the same DPoP proof
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
@ -498,7 +506,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
oauth.dpopProof(dpopProofRsaEncoded);
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(401, userInfoResponse.getStatusCode());
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
@ -613,14 +621,14 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
// userinfo request without a DPoP proof - fail
oauth.dpopProof(null);
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(encodedAccessToken);
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(401, userInfoResponse.getStatusCode());
// userinfo request with a valid DPoP proof - success
jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc);
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
userInfoResponse = oauth.doUserInfoRequestByGet(encodedAccessToken);
userInfoResponse = oauth.doUserInfoRequestByGet(response);
assertEquals(200, userInfoResponse.getStatusCode());
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
@ -669,6 +677,66 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
oauth.idTokenHint(encodedIdToken).openLogout();
}
@Test
public void testDPoPProofWithClientCredentialsGrant() throws Exception {
modifyClient(TEST_CONFIDENTIAL_CLIENT_ID, (clientRepresentation, configWrapper) -> {
clientRepresentation.setServiceAccountsEnabled(true);
configWrapper.setUseDPoP(true);
});
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
oauth.dpopProof(dpopProofRsaEncoded);
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest(TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent());
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
}
@Test
public void testDPoPProofWithResourceOwnerPasswordCredentialsGrant() throws Exception {
modifyClient(TEST_CONFIDENTIAL_CLIENT_ID, (clientRepresentation, configWrapper) -> {
clientRepresentation.setDirectAccessGrantsEnabled(true);
configWrapper.setUseDPoP(true);
});
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
oauth.dpopProof(dpopProofRsaEncoded);
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest(TEST_CONFIDENTIAL_CLIENT_SECRET,
TEST_USER_NAME,
TEST_USER_PASSWORD);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent());
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
}
private OAuthClient.AccessTokenResponse getDPoPBindAccessToken(KeyPair rsaKeyPair) throws Exception {
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
@ -680,6 +748,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofRsaEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
@ -691,12 +760,12 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
return response;
}
private void doSuccessfulUserInfoGet(String accessToken, KeyPair rsaKeyPair) throws Exception {
private void doSuccessfulUserInfoGet(OAuthClient.AccessTokenResponse accessTokenResponse, KeyPair rsaKeyPair) throws Exception {
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
oauth.dpopProof(dpopProofRsaEncoded);
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(accessToken);
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(accessTokenResponse);
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
}
@ -720,6 +789,14 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
clientResource.update(clientRep);
}
private void modifyClient(String clientId, BiConsumer<ClientRepresentation, OIDCAdvancedConfigWrapper> modify) {
ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), clientId);
ClientRepresentation clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper configWrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
modify.accept(clientRep, configWrapper);
clientResource.update(clientRep);
}
private String createClientByAdmin(String clientName, Consumer<ClientRepresentation> op) throws ClientPolicyException {
ClientRepresentation clientRep = new ClientRepresentation();
clientRep.setClientId(clientName);