From 94784182df42ceba367b39ddc047180ce0d00ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Kn=C3=BCppel?= Date: Mon, 29 Jul 2024 16:30:54 +0200 Subject: [PATCH] Implement DPoP for all grantTypes (#29967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #30179 fixes #30181 Signed-off-by: Pascal Knüppel Signed-off-by: Pascal Knüppel --- .../java/org/keycloak/OAuth2Constants.java | 2 + .../representations/RefreshToken.java | 20 +- .../protocol/oidc/grants/OAuth2GrantType.java | 6 +- .../protocol/ProtocolMapperUtils.java | 25 +- .../protocol/docker/DockerAuthV2Protocol.java | 1 + .../keycloak/protocol/oidc/TokenManager.java | 33 ++- .../oidc/endpoints/TokenEndpoint.java | 17 +- .../grants/AuthorizationCodeGrantType.java | 4 - .../oidc/grants/OAuth2GrantTypeBase.java | 42 ---- .../oidc/grants/RefreshTokenGrantType.java | 3 - .../services/managers/AppAuthManager.java | 4 +- .../org/keycloak/services/util/DPoPUtil.java | 151 +++++++++++- .../keycloak/testsuite/util/OAuthClient.java | 25 +- .../KcOidcBrokerTransientSessionsTest.java | 6 +- .../client/OAuth2_1PublicClientTest.java | 18 +- .../keycloak/testsuite/oauth/DPoPTest.java | 219 ++++++++++++------ 16 files changed, 405 insertions(+), 171 deletions(-) diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 5d469e2959..b03dd25554 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -152,6 +152,8 @@ public interface OAuth2Constants { String ISSUER = "iss"; String AUTHENTICATOR_METHOD_REFERENCE = "amr"; + + String CNF = "cnf"; } diff --git a/core/src/main/java/org/keycloak/representations/RefreshToken.java b/core/src/main/java/org/keycloak/representations/RefreshToken.java index 2f6b5a1b75..d449450807 100755 --- a/core/src/main/java/org/keycloak/representations/RefreshToken.java +++ b/core/src/main/java/org/keycloak/representations/RefreshToken.java @@ -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; diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantType.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantType.java index 945e8f190d..21fbf0d162 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantType.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantType.java @@ -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 clientAuthAttributes, - MultivaluedMap formParams, EventBuilder event, Cors cors, Object tokenManager, DPoP dPoP) { + MultivaluedMap 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 formParams) { diff --git a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java index 8196177a25..e2e09fe77e 100755 --- a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java +++ b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java @@ -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,16 +131,20 @@ public class ProtocolMapperUtils { public static Stream> getSortedProtocolMappers(KeycloakSession session, ClientSessionContext ctx, Predicate> filter) { KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); - return ctx.getProtocolMappersStream() - .>map(mapperModel -> { - ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapperModel.getProtocolMapper()); - if (mapper == null) { - return null; - } - return new AbstractMap.SimpleEntry<>(mapperModel, mapper); - }) - .filter(Objects::nonNull) - .filter(filter) + + Stream> protocolMapperStream = // + ctx.getProtocolMappersStream() + .>map(mapperModel -> { + ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapperModel.getProtocolMapper()); + if (mapper == null) { + return null; + } + return new AbstractMap.SimpleEntry<>(mapperModel, mapper); + }) + .filter(Objects::nonNull) + .filter(filter); + + return Stream.concat(protocolMapperStream, DPoPUtil.getTransientProtocolMapper()) .sorted(Comparator.comparing(ProtocolMapperUtils::compare)); } diff --git a/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java b/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java index 0ddcc5fa60..0c1e8a558d 100644 --- a/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java +++ b/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java @@ -113,6 +113,7 @@ public class DockerAuthV2Protocol implements LoginProtocol { AtomicReference 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(); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index b6184db89f..f5b0d80ce9 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -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
+ * 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.
+ *
+ * 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 { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 5c8268e592..ced6b06d94 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -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 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); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java index 949cbee541..119e7e3933 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java @@ -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; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java index 19b2e9a9ef..da7c3e0905 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java @@ -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); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/RefreshTokenGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/RefreshTokenGrantType.java index 608da84814..8f48f2535c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/RefreshTokenGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/RefreshTokenGrantType.java @@ -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)); diff --git a/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java b/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java index 2e992200f3..af7fc98751 100755 --- a/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java @@ -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; } diff --git a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java index 7c27a37c03..d3955e34ab 100644 --- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java +++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java @@ -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.
+ *
+ * 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> 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 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 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 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); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 7e3c9cec60..73b7e7f613 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -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 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 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) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTransientSessionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTransientSessionsTest.java index 2159ba2157..4500546b70 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTransientSessionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTransientSessionsTest.java @@ -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())); @@ -713,7 +713,7 @@ public final class KcOidcBrokerTransientSessionsTest extends AbstractAdvancedBro userAttrMapper.setName(USER_ATTRIBUTE_NAME); userAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); userAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID); - + Map userAttrMapperConfig = userAttrMapper.getConfig(); userAttrMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, USER_ATTRIBUTE_NAME); userAttrMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, USER_ATTRIBUTE_NAME); @@ -728,7 +728,7 @@ public final class KcOidcBrokerTransientSessionsTest extends AbstractAdvancedBro client.setProtocolMappers(mappers); return clients; - } + } @Override public List createConsumerClients() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OAuth2_1PublicClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OAuth2_1PublicClientTest.java index 43c7eacfd1..b6700c8bb3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OAuth2_1PublicClientTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OAuth2_1PublicClientTest.java @@ -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() { @@ -157,7 +155,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest { public void testOAuth2_1ProofKeyForCodeExchange() throws Exception { String clientId = generateSuffixedName(CLIENT_NAME); String cId = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> - setupValidClientExceptForRedirectUri(clientRep, OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep)) + setupValidClientExceptForRedirectUri(clientRep, OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep)) ); assertEquals(OAuth2Constants.PKCE_METHOD_S256, OIDCAdvancedConfigWrapper.fromClientRepresentation(getClientByAdmin(cId)).getPkceCodeChallengeMethod()); @@ -212,7 +210,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest { // registration (auto-config) - success String clientId = generateSuffixedName(CLIENT_NAME); String cId = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> - setupValidClientExceptForRedirectUri(clientRep, OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep)) + setupValidClientExceptForRedirectUri(clientRep, OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep)) ); assertTrue(OIDCAdvancedConfigWrapper.fromClientRepresentation(getClientByAdmin(cId)).isUseDPoP()); @@ -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(); @@ -271,7 +269,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest { updatePolicies(json); } - private void setupValidClientExceptForRedirectUri(ClientRepresentation clientRep, OIDCAdvancedConfigWrapper clientConfig ) { + private void setupValidClientExceptForRedirectUri(ClientRepresentation clientRep, OIDCAdvancedConfigWrapper clientConfig) { clientRep.setPublicClient(Boolean.TRUE); clientRep.setRedirectUris(Collections.singletonList(validRedirectUri)); clientRep.setImplicitFlowEnabled(false); @@ -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); @@ -303,4 +301,4 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest { private String generateNonce() { return SecretGenerator.getInstance().randomString(16); } -} \ No newline at end of file +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java index 0e8f7cce66..35374281c5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java @@ -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,12 +152,13 @@ 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()); - jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv()); - jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX()); - jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY()); + jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK) jwkEc).getCrv()); + jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK) jwkEc).getX()); + jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK) jwkEc).getY()); String jkt = JWKSUtils.computeThumbprint(jwkEc); assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint()); RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); @@ -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,11 +200,12 @@ 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()); - jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus()); - jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent()); + 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()); RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); @@ -235,10 +234,11 @@ 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()); - jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent()); + jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus()); + jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent()); jkt = JWKSUtils.computeThumbprint(jwkRsa); accessToken = oauth.verifyToken(response.getAccessToken()); assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint()); @@ -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()); @@ -340,7 +347,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { @Test public void testDPoPProofWithoutJwk() throws Exception { - JWSHeader jwsHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), (JWK)null); + JWSHeader jwsHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), (JWK) null); testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsHeader, ecKeyPair.getPrivate()), "No JWK in DPoP header"); } @@ -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")); @@ -603,9 +611,9 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { String encodedRefreshToken = response.getRefreshToken(); String encodedIdToken = response.getIdToken(); AccessToken accessToken = oauth.verifyToken(encodedAccessToken); - jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv()); - jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX()); - jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY()); + jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK) jwkEc).getCrv()); + jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK) jwkEc).getX()); + jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK) jwkEc).getY()); String jkt = JWKSUtils.computeThumbprint(jwkEc); assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint()); RefreshToken refreshToken = oauth.parseRefreshToken(encodedRefreshToken); @@ -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()); @@ -639,9 +647,9 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { encodedAccessToken = response.getAccessToken(); encodedRefreshToken = response.getRefreshToken(); accessToken = oauth.verifyToken(encodedAccessToken); - jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv()); - jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX()); - jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY()); + jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK) jwkEc).getCrv()); + jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK) jwkEc).getX()); + jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK) jwkEc).getY()); jkt = JWKSUtils.computeThumbprint(jwkEc); assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint()); refreshToken = oauth.parseRefreshToken(encodedRefreshToken); @@ -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,23 +748,24 @@ 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()); - jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus()); - jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent()); + 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()); 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 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 op) throws ClientPolicyException { ClientRepresentation clientRep = new ClientRepresentation(); clientRep.setClientId(clientName); @@ -763,7 +840,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { private void updatePolicies(String json) throws ClientPolicyException { try { - ClientPoliciesRepresentation clientPolicies = json==null ? null : JsonSerialization.readValue(json, ClientPoliciesRepresentation.class); + ClientPoliciesRepresentation clientPolicies = json == null ? null : JsonSerialization.readValue(json, ClientPoliciesRepresentation.class); adminClient.realm(REALM_NAME).clientPoliciesPoliciesResource().updatePolicies(clientPolicies); } catch (BadRequestException e) { throw new ClientPolicyException("update policies failed", e.getResponse().getStatusInfo().toString());