From 624d236ced5577b1bf731a1d14cd3a7ad8de86c5 Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 20 Aug 2025 20:33:10 +0200 Subject: [PATCH] DPoP verification support for admin/account REST API endpoints. Java admin-client DPoP support closes #33942 Signed-off-by: mposolda --- .../java/org/keycloak/OAuth2Constants.java | 10 ++ .../org/keycloak/jose/jws/JWSBuilder.java | 30 +++-- .../java/org/keycloak/util/DPoPGenerator.java | 116 ++++++++++++++++++ .../org/keycloak/admin/client/Config.java | 9 ++ .../org/keycloak/admin/client/Keycloak.java | 15 ++- .../admin/client/KeycloakBuilder.java | 14 ++- .../client/resource/BearerAuthFilter.java | 11 +- .../admin/client/resource/DPoPAuthFilter.java | 66 ++++++++++ .../admin/client/token/TokenManager.java | 23 +++- .../StandardTokenExchangeProvider.java | 3 +- .../V1TokenExchangeProvider.java | 3 +- .../executor/DPoPBindEnforcerExecutor.java | 6 +- .../services/managers/AppAuthManager.java | 12 +- .../managers/AuthenticationManager.java | 10 +- .../org/keycloak/services/util/DPoPUtil.java | 14 +-- .../testsuite/util/AdminClientUtil.java | 10 +- .../testsuite/admin/AdminClientTest.java | 2 +- .../keycloak/testsuite/oauth/DPoPTest.java | 114 ++++++++++++++++- .../testsuite/util/ClientPoliciesUtil.java | 72 +++-------- 19 files changed, 434 insertions(+), 106 deletions(-) create mode 100644 core/src/main/java/org/keycloak/util/DPoPGenerator.java create mode 100644 integration/admin-client/src/main/java/org/keycloak/admin/client/resource/DPoPAuthFilter.java diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 389aab1aae9..4476e27c79f 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -17,6 +17,10 @@ package org.keycloak; +import org.keycloak.jose.jws.Algorithm; + +import static org.keycloak.jose.jws.Algorithm.PS256; + /** * @author Stian Thorgersen */ @@ -155,6 +159,12 @@ public interface OAuth2Constants { String AUTHENTICATOR_METHOD_REFERENCE = "amr"; String CNF = "cnf"; + + // DPoP - https://datatracker.ietf.org/doc/html/rfc9449 + String DPOP_HTTP_HEADER = "DPoP"; + Algorithm DPOP_DEFAULT_ALGORITHM = PS256; + String DPOP_JWT_HEADER_TYPE = "dpop+jwt"; + } diff --git a/core/src/main/java/org/keycloak/jose/jws/JWSBuilder.java b/core/src/main/java/org/keycloak/jose/jws/JWSBuilder.java index bf13bc76162..30c4e410b06 100755 --- a/core/src/main/java/org/keycloak/jose/jws/JWSBuilder.java +++ b/core/src/main/java/org/keycloak/jose/jws/JWSBuilder.java @@ -33,6 +33,7 @@ import java.security.PrivateKey; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.List; +import java.util.stream.Collectors; /** * @author Bill Burke @@ -43,7 +44,7 @@ public class JWSBuilder { protected String kid; protected String x5t; protected JWK jwk; - protected List x5c; + protected List x5c; protected String contentType; protected byte[] contentBytes; @@ -68,7 +69,15 @@ public class JWSBuilder { } public JWSBuilder x5c(List x5c) { - this.x5c = x5c; + this.x5c = x5c.stream() + .map(x509Certificate -> { + try { + return Base64.encodeBytes(x509Certificate.getEncoded()); + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList()); return this; } @@ -77,6 +86,15 @@ public class JWSBuilder { return this; } + public JWSBuilder header(JWSHeader header) { + this.type = header.getType(); + this.kid = header.getKeyId(); + this.jwk = header.getKey(); + this.x5c = header.getX5c(); + this.contentType = header.getContentType(); + return this; + } + public EncodingBuilder content(byte[] bytes) { this.contentBytes = bytes; return new EncodingBuilder(); @@ -108,15 +126,11 @@ public class JWSBuilder { if (x5c != null && !x5c.isEmpty()) { builder.append(",\"x5c\" : ["); for (int i = 0; i < x5c.size(); i++) { - X509Certificate certificate = x5c.get(i); + String certificate = x5c.get(i); if (i > 0) { builder.append(","); } - try { - builder.append("\"").append(Base64.encodeBytes(certificate.getEncoded())).append("\""); - } catch (CertificateEncodingException e) { - throw new RuntimeException(e); - } + builder.append("\"").append(certificate).append("\""); } builder.append("]"); } diff --git a/core/src/main/java/org/keycloak/util/DPoPGenerator.java b/core/src/main/java/org/keycloak/util/DPoPGenerator.java new file mode 100644 index 00000000000..d31c98b354b --- /dev/null +++ b/core/src/main/java/org/keycloak/util/DPoPGenerator.java @@ -0,0 +1,116 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.util; + +import java.security.Key; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.interfaces.RSAPublicKey; + +import org.keycloak.OAuth2Constants; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.SecretGenerator; +import org.keycloak.common.util.Time; +import org.keycloak.crypto.AsymmetricSignatureSignerContext; +import org.keycloak.crypto.ECDSASignatureSignerContext; +import org.keycloak.crypto.KeyType; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureException; +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.RSAPublicJWK; +import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.crypto.HashUtils; +import org.keycloak.representations.dpop.DPoP; + +import static org.keycloak.OAuth2Constants.DPOP_DEFAULT_ALGORITHM; +import static org.keycloak.OAuth2Constants.DPOP_JWT_HEADER_TYPE; +import static org.keycloak.jose.jwk.JWKUtil.toIntegerBytes; + +/** + * Utility for generating signed DPoP proofs + * + * @author Marek Posolda + * @see OAuth 2.0 Demonstrating Proof of Possession (DPoP) specification + * + */ +public class DPoPGenerator { + + // TODO: Similar for EC and EdDSA + public static String generateRsaSignedDPoPProof(KeyPair rsaKeyPair, String httpMethod, String endpointURL, String accessToken) { + JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic()); + JWSHeader jwsRsaHeader = new JWSHeader(DPOP_DEFAULT_ALGORITHM, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa); + return generateSignedDPoPProof(SecretGenerator.getInstance().generateSecureID(), httpMethod, endpointURL, (long) Time.currentTime(), + jwsRsaHeader, rsaKeyPair.getPrivate(), accessToken); + } + + + public static JWK createRsaJwk(Key publicKey) { + RSAPublicKey rsaKey = (RSAPublicKey) publicKey; + + RSAPublicJWK k = new RSAPublicJWK(); + k.setKeyType(KeyType.RSA); + k.setModulus(Base64Url.encode(toIntegerBytes(rsaKey.getModulus()))); + k.setPublicExponent(Base64Url.encode(toIntegerBytes(rsaKey.getPublicExponent()))); + + return k; + } + + + public static String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, JWSHeader jwsHeader, PrivateKey privateKey, String accessToken) { + try { + DPoP dpop = new DPoP(); + dpop.id(jti); + dpop.setHttpMethod(htm); + dpop.setHttpUri(htu); + dpop.iat(iat); + if (accessToken != null) { + dpop.setAccessTokenHash(HashUtils.accessTokenHash(OAuth2Constants.DPOP_DEFAULT_ALGORITHM.toString(), accessToken, true)); + } + + KeyWrapper keyWrapper = new KeyWrapper(); + keyWrapper.setKid(jwsHeader.getKeyId()); + keyWrapper.setAlgorithm(jwsHeader.getAlgorithm().toString()); + keyWrapper.setPrivateKey(privateKey); + keyWrapper.setType(privateKey.getAlgorithm()); + keyWrapper.setUse(KeyUse.SIG); + SignatureSignerContext sigCtx = createSignatureSignerContext(keyWrapper); + + return new JWSBuilder() + .header(jwsHeader) + .jsonContent(dpop) + .sign(sigCtx); + } catch (SignatureException e) { + throw new RuntimeException(e); + } + } + + private static SignatureSignerContext createSignatureSignerContext(KeyWrapper keyWrapper) { + switch (keyWrapper.getType()) { + case KeyType.RSA: + return new AsymmetricSignatureSignerContext(keyWrapper); + case KeyType.EC: + return new ECDSASignatureSignerContext(keyWrapper); + // TODO: EdDSA? + default: + throw new IllegalArgumentException("No signer provider for key algorithm type " + keyWrapper.getType()); + } + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java index 846487c96bb..85f52b775b3 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java @@ -33,6 +33,7 @@ public class Config { private String clientSecret; private String grantType; private String scope; + private boolean useDPoP = false; public Config(String serverUrl, String realm, String username, String password, String clientId, String clientSecret) { this(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, null); @@ -125,4 +126,12 @@ public class Config { " (only " + PASSWORD + " and " + CLIENT_CREDENTIALS + " are supported)"); } } + + public boolean isUseDPoP() { + return useDPoP; + } + + public void setUseDPoP(boolean useDPoP) { + this.useDPoP = useDPoP; + } } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java index 32b1f3d74a3..e8ee622b657 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java @@ -16,8 +16,10 @@ */ package org.keycloak.admin.client; +import jakarta.ws.rs.client.ClientRequestFilter; import jakarta.ws.rs.client.WebTarget; import org.keycloak.admin.client.resource.BearerAuthFilter; +import org.keycloak.admin.client.resource.DPoPAuthFilter; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmsResource; import org.keycloak.admin.client.resource.ServerInfoResource; @@ -84,8 +86,9 @@ public class Keycloak implements AutoCloseable { private final Client client; private boolean closed = false; - Keycloak(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, String grantType, Client resteasyClient, String authtoken, String scope) { + Keycloak(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, String grantType, Client resteasyClient, String authtoken, String scope, boolean useDPoP) { config = new Config(serverUrl, realm, username, password, clientId, clientSecret, grantType, scope); + config.setUseDPoP(useDPoP); client = resteasyClient != null ? resteasyClient : newRestEasyClient(null, null, false); authToken = authtoken; tokenManager = authtoken == null ? new TokenManager(config, client) : null; @@ -98,7 +101,11 @@ public class Keycloak implements AutoCloseable { return CLIENT_PROVIDER.newRestEasyClient(customJacksonProvider, sslContext, disableTrustManager); } - private BearerAuthFilter newAuthFilter() { + private ClientRequestFilter newAuthFilter() { + if (config.isUseDPoP()) { + if (authToken != null) throw new IllegalArgumentException("Not supported to require DPoP when token is provisioned"); + return new DPoPAuthFilter(tokenManager, false); + } return authToken != null ? new BearerAuthFilter(authToken) : new BearerAuthFilter(tokenManager); } @@ -120,14 +127,14 @@ public class Keycloak implements AutoCloseable { * @return Java admin client instance */ public static Keycloak getInstance(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, SSLContext sslContext, Object customJacksonProvider, boolean disableTrustManager, String authToken, String scope) { - return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, newRestEasyClient(customJacksonProvider, sslContext, disableTrustManager), authToken, scope); + return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, newRestEasyClient(customJacksonProvider, sslContext, disableTrustManager), authToken, scope, false); } /** * See {@link #getInstance(String, String, String, String, String, String, SSLContext, Object, boolean, String, String)} for the details about the parameters and their default values */ public static Keycloak getInstance(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, SSLContext sslContext, Object customJacksonProvider, boolean disableTrustManager, String authToken) { - return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, newRestEasyClient(customJacksonProvider, sslContext, disableTrustManager), authToken, null); + return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, newRestEasyClient(customJacksonProvider, sslContext, disableTrustManager), authToken, null, false); } /** diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java index cfb9f003404..036aa01d2a9 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java @@ -17,7 +17,6 @@ package org.keycloak.admin.client; -import static org.keycloak.OAuth2Constants.CLIENT_CREDENTIALS; import static org.keycloak.OAuth2Constants.PASSWORD; import jakarta.ws.rs.client.Client; @@ -66,6 +65,7 @@ public class KeycloakBuilder { private Client resteasyClient; private String authorization; private String scope; + private boolean useDPoP = false; public KeycloakBuilder serverUrl(String serverUrl) { this.serverUrl = serverUrl; @@ -124,6 +124,16 @@ public class KeycloakBuilder { return this; } + /** + * @param useDPoP If true, then admin-client will add DPoP proofs to the token-requests and to the admin REST API requests. DPoP feature must be + * enabled on Keycloak server side to work properly. It is false by default. + * @return admin client builder + */ + public KeycloakBuilder useDPoP(boolean useDPoP) { + this.useDPoP = useDPoP; + return this; + } + /** * Builds a new Keycloak client from this builder. */ @@ -154,7 +164,7 @@ public class KeycloakBuilder { throw new IllegalStateException("clientId required"); } - return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, grantType, resteasyClient, authorization, scope); + return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, grantType, resteasyClient, authorization, scope, useDPoP); } private KeycloakBuilder() { diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java index 6d9527e4fb6..5eca4e109a9 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java @@ -34,7 +34,7 @@ public class BearerAuthFilter implements ClientRequestFilter, ClientResponseFilt public static final String AUTH_HEADER_PREFIX = "Bearer "; private final String tokenString; - private final TokenManager tokenManager; + protected final TokenManager tokenManager; public BearerAuthFilter(String tokenString) { this.tokenString = tokenString; @@ -66,12 +66,17 @@ public class BearerAuthFilter implements ClientRequestFilter, ClientResponseFilt for (Object authHeader : authHeaders) { if (authHeader instanceof String) { String headerValue = (String) authHeader; - if (headerValue.startsWith(AUTH_HEADER_PREFIX)) { - String token = headerValue.substring( AUTH_HEADER_PREFIX.length() ); + String authHeaderPrefix = getAuthHeaderPrefix(); + if (headerValue.startsWith(authHeaderPrefix)) { + String token = headerValue.substring( authHeaderPrefix.length() ); tokenManager.invalidate( token ); } } } } } + + protected String getAuthHeaderPrefix() { + return AUTH_HEADER_PREFIX; + } } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/DPoPAuthFilter.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/DPoPAuthFilter.java new file mode 100644 index 00000000000..badc679112a --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/DPoPAuthFilter.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.admin.client.resource; + +import java.io.IOException; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.core.HttpHeaders; +import org.keycloak.admin.client.token.TokenManager; +import org.keycloak.util.DPoPGenerator; + +import static org.keycloak.OAuth2Constants.DPOP_HTTP_HEADER; + +/** + * @author Marek Posolda + */ +public class DPoPAuthFilter extends BearerAuthFilter { + + private final boolean tokenRequest; + + public DPoPAuthFilter(TokenManager tokenManager, boolean tokenRequest) { + super(tokenManager); + this.tokenRequest = tokenRequest; + } + + @Override + public void filter(ClientRequestContext requestContext) throws IOException { + String requestUri = requestContext.getUri().toString(); + if (tokenRequest) { + if (requestUri.endsWith("/token")) { + // Request for obtain new accessToken or refresh-token request + String dpop = DPoPGenerator.generateRsaSignedDPoPProof(tokenManager.getDpopKeyPair(), requestContext.getMethod(), requestUri, null); + requestContext.getHeaders().add(DPOP_HTTP_HEADER, dpop); + } + } else { + // Regular request to admin REST API + String accessToken = tokenManager.getAccessTokenString(); + String dpop = DPoPGenerator.generateRsaSignedDPoPProof(tokenManager.getDpopKeyPair(), requestContext.getMethod(), requestUri, accessToken); + requestContext.getHeaders().add(DPOP_HTTP_HEADER, dpop); + + String authHeader = DPOP_HTTP_HEADER + " " + accessToken; + requestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, authHeader); + } + } + + + @Override + protected String getAuthHeaderPrefix() { + return DPOP_HTTP_HEADER; + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java index f18087f0751..3da81b050b7 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java @@ -17,12 +17,14 @@ package org.keycloak.admin.client.token; +import java.security.KeyPair; + import jakarta.ws.rs.client.WebTarget; -import org.jboss.resteasy.client.jaxrs.ResteasyClient; -import org.jboss.resteasy.client.jaxrs.ResteasyWebTarget; import org.keycloak.admin.client.Config; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.BasicAuthFilter; +import org.keycloak.admin.client.resource.DPoPAuthFilter; +import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.Time; import org.keycloak.representations.AccessTokenResponse; @@ -30,7 +32,6 @@ import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.client.Client; import jakarta.ws.rs.core.Form; -import static org.keycloak.OAuth2Constants.CLIENT_CREDENTIALS; import static org.keycloak.OAuth2Constants.CLIENT_ID; import static org.keycloak.OAuth2Constants.GRANT_TYPE; import static org.keycloak.OAuth2Constants.PASSWORD; @@ -51,6 +52,7 @@ public class TokenManager { private final Config config; private final TokenService tokenService; private final String accessTokenGrantType; + private final KeyPair dpopKeyPair; public TokenManager(Config config, Client client) { this.config = config; @@ -58,6 +60,14 @@ public class TokenManager { if (!config.isPublicClient()) { target.register(new BasicAuthFilter(config.getClientId(), config.getClientSecret())); } + + if (this.config.isUseDPoP()) { + this.dpopKeyPair = KeyUtils.generateRsaKeyPair(2048); + target.register(new DPoPAuthFilter(this, true)); + } else { + this.dpopKeyPair = null; + } + this.tokenService = Keycloak.getClientProvider().targetProxy(target, TokenService.class); this.accessTokenGrantType = config.getGrantType(); } @@ -161,4 +171,11 @@ public class TokenManager { expirationTime = -1; } } + + /** + * @return dpopKeyPair if it was generated or null if DPoP is not being requested by the configuration + */ + public KeyPair getDpopKeyPair() { + return dpopKeyPair; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java index e3b3dc996b1..98ea277c4a4 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java @@ -122,7 +122,8 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider event.detail(Details.REQUESTED_TOKEN_TYPE, context.getParams().getRequestedTokenType()); - AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, context.getHeaders()); + AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, + false, subjectToken, context.getHeaders(), verifier -> {}); if (authResult == null) { event.detail(Details.REASON, "subject_token validation failure"); event.error(Errors.INVALID_TOKEN); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java index 2a5cf9d1dbe..9d1f9218ed2 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java @@ -115,7 +115,8 @@ public class V1TokenExchangeProvider extends AbstractTokenExchangeProvider { } - AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, context.getHeaders()); + AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, + false, subjectToken, context.getHeaders(), verifier -> {}); if (authResult == null) { event.detail(Details.REASON, "subject_token validation failure"); event.error(Errors.INVALID_TOKEN); diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java index a3a4db5f788..c72b5c52250 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/DPoPBindEnforcerExecutor.java @@ -26,18 +26,14 @@ import org.keycloak.common.Profile.Feature; import org.keycloak.http.HttpRequest; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; -import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.representations.AccessToken; -import org.keycloak.representations.RefreshToken; import org.keycloak.representations.dpop.DPoP; import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.services.clientpolicy.ClientPolicyContext; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.context.ClientCRUDContext; -import org.keycloak.services.clientpolicy.context.TokenRefreshContext; import org.keycloak.services.clientpolicy.context.TokenRevokeContext; -import org.keycloak.services.clientpolicy.context.UserInfoRequestContext; import org.keycloak.services.util.DPoPUtil; import com.fasterxml.jackson.annotation.JsonProperty; @@ -105,7 +101,7 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider DPoPUtil.withDPoPVerifier(verifier, realm, new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).accessToken(tokenString))); } } diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 9c31b052ff8..d05241d52f7 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -886,7 +886,8 @@ public class AuthenticationManager { return null; } - AuthResult authResult = verifyIdentityToken(session, realm, session.getContext().getUri(), session.getContext().getConnection(), checkActive, false, null, true, tokenString, session.getContext().getRequestHeaders(), VALIDATE_IDENTITY_COOKIE); + AuthResult authResult = verifyIdentityToken(session, realm, session.getContext().getUri(), session.getContext().getConnection(), checkActive, false, null, true, tokenString, + session.getContext().getRequestHeaders(), verifier -> verifier.withChecks(VALIDATE_IDENTITY_COOKIE)); if (authResult == null || authResult.getSession() == null) { expireIdentityCookie(session); return null; @@ -1481,14 +1482,15 @@ public class AuthenticationManager { } public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType, - String checkAudience, boolean isCookie, String tokenString, HttpHeaders headers, Predicate... additionalChecks) { + String checkAudience, boolean isCookie, String tokenString, HttpHeaders headers, Consumer> verifierConsumer) { try { TokenVerifier verifier = TokenVerifier.create(tokenString, AccessToken.class) .withDefaultChecks() .realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())) .checkActive(checkActive) - .checkTokenType(checkTokenType) - .withChecks(additionalChecks); + .checkTokenType(checkTokenType); + + verifierConsumer.accept(verifier); if (checkAudience != null) { verifier.audience(checkAudience); 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 47466210aea..f42c3bd2115 100644 --- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java +++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java @@ -24,10 +24,6 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.function.BooleanSupplier; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -84,6 +80,8 @@ import org.keycloak.services.cors.Cors; import org.keycloak.util.JWKSUtils; import org.keycloak.util.TokenUtil; +import static org.keycloak.OAuth2Constants.DPOP_JWT_HEADER_TYPE; +import static org.keycloak.OAuth2Constants.DPOP_HTTP_HEADER; import static org.keycloak.utils.StringUtil.isNotBlank; /** @@ -103,10 +101,6 @@ public class DPoPUtil { DISABLED } - public static final String DPOP_HTTP_HEADER = "DPoP"; - private static final String DPOP_JWT_HEADER_TYPE = "dpop+jwt"; - public static final String DPOP_ATH_ALG = "RS256"; - private static URI normalize(URI uri) { return UriBuilder.fromUri(uri).replaceQuery("").build(); } @@ -151,7 +145,7 @@ public class DPoPUtil { HttpRequest request = keycloakSession.getContext().getHttpRequest(); final boolean isClientRequiresDpop = clientConfig != null && clientConfig.isUseDPoP(); - final boolean isDpopHeaderPresent = request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) != null; + final boolean isDpopHeaderPresent = request.getHttpHeaders().getHeaderString(DPOP_HTTP_HEADER) != null; if (!isClientRequiresDpop && !isDpopHeaderPresent) { return; @@ -445,7 +439,7 @@ public class DPoPUtil { private final String hash; public DPoPAccessTokenHashCheck(String tokenString) { - hash = HashUtils.accessTokenHash(DPOP_ATH_ALG, tokenString, true); + hash = HashUtils.accessTokenHash(OAuth2Constants.DPOP_DEFAULT_ALGORITHM.toString(), tokenString, true); } @Override diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java index 6e91d8ce1e9..ef85716df21 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java @@ -59,18 +59,18 @@ public class AdminClientUtil { public static Keycloak createAdminClient(boolean ignoreUnknownProperties, String authServerContextRoot) throws Exception { return createAdminClient(ignoreUnknownProperties, authServerContextRoot, MASTER, ADMIN, ADMIN, - Constants.ADMIN_CLI_CLIENT_ID, null, null); + Constants.ADMIN_CLI_CLIENT_ID, null, null, false); } public static Keycloak createAdminClient(boolean ignoreUnknownProperties, String realmName, String username, String password, String clientId, String clientSecret) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException { return createAdminClient(ignoreUnknownProperties, getAuthServerContextRoot(), realmName, username, password, - clientId, clientSecret, null); + clientId, clientSecret, null, false); } public static Keycloak createAdminClient(boolean ignoreUnknownProperties, String authServerContextRoot, String realmName, - String username, String password, String clientId, String clientSecret, String scope) + String username, String password, String clientId, String clientSecret, String scope, boolean useDPoP) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException { ResteasyClient resteasyClient = createResteasyClient(ignoreUnknownProperties, null); @@ -83,7 +83,9 @@ public class AdminClientUtil { .clientId(clientId) .clientSecret(clientSecret) .resteasyClient(resteasyClient) - .scope(scope).build(); + .scope(scope) + .useDPoP(useDPoP) + .build(); } public static Keycloak createAdminClientWithClientCredentials(String realmName, String clientId, String clientSecret, String scope) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java index 2c5c486df7f..24c5700e543 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java @@ -220,7 +220,7 @@ public class AdminClientTest extends AbstractKeycloakTest { @Test public void adminAuthUserDisabled() throws Exception { try (Keycloak adminClient = AdminClientUtil.createAdminClient(false, realmName, "test-user@localhost", "password", Constants.ADMIN_CLI_CLIENT_ID, null); - Keycloak adminClientOffline = AdminClientUtil.createAdminClient(false, ServerURLs.getAuthServerContextRoot(), realmName, "test-user@localhost", "password", Constants.ADMIN_CLI_CLIENT_ID, null, OAuth2Constants.OFFLINE_ACCESS); + Keycloak adminClientOffline = AdminClientUtil.createAdminClient(false, ServerURLs.getAuthServerContextRoot(), realmName, "test-user@localhost", "password", Constants.ADMIN_CLI_CLIENT_ID, null, OAuth2Constants.OFFLINE_ACCESS, false); ) { // Check possible to load the realm RealmRepresentation realm = adminClient.realm(realmName).toRepresentation(); 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 11dd594b77c..e8a46383a87 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 @@ -19,15 +19,19 @@ package org.keycloak.testsuite.oauth; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.ProcessingException; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; +import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.client.registration.Auth; import org.keycloak.client.registration.ClientRegistration; @@ -41,6 +45,9 @@ import org.keycloak.jose.jwk.ECPublicJWK; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.RSAPublicJWK; import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.Constants; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.AccessToken; @@ -62,10 +69,13 @@ 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.broker.util.SimpleHttpDefault; +import org.keycloak.testsuite.util.AdminClientUtil; 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.UserBuilder; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.IntrospectionResponse; import org.keycloak.testsuite.util.oauth.UserInfoResponse; @@ -95,6 +105,9 @@ 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.OAuth2Constants.DPOP_HTTP_HEADER; +import static org.keycloak.OAuth2Constants.DPOP_JWT_HEADER_TYPE; +import static org.keycloak.services.util.DPoPUtil.DPOP_TOKEN_TYPE; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createDPoPBindEnforcerExecutorConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createEcJwk; @@ -111,7 +124,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { private static final String TEST_PUBLIC_CLIENT_ID = "test-public-client"; 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; @@ -151,6 +164,13 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + UserBuilder testAdmin = UserBuilder.create() + .id(KeycloakModelUtils.generateId()) + .username("test-admin@localhost") + .password("password") + .role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN) + .addRoles(OAuth2Constants.OFFLINE_ACCESS); + testRealm.getUsers().add(testAdmin.build()); } @Test @@ -759,6 +779,98 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest { oauth.doLogout(response.getRefreshToken()); } + @Test + public void testDPoPAdminRequestSuccess() throws Exception { + modifyClient(TEST_CONFIDENTIAL_CLIENT_ID, (clientRepresentation, configWrapper) -> { + clientRepresentation.setDirectAccessGrantsEnabled(true); + configWrapper.setUseDPoP(true); + }); + + try (Keycloak adminClientDPoP = AdminClientUtil.createAdminClient(false, ServerURLs.getAuthServerContextRoot(), REALM_NAME, + "test-admin@localhost", "password", TEST_CONFIDENTIAL_CLIENT_ID, TEST_CONFIDENTIAL_CLIENT_SECRET, null, true); + ) { + RealmRepresentation realm = adminClientDPoP.realm(REALM_NAME).toRepresentation(); + Assert.assertEquals(REALM_NAME, realm.getRealm()); + + // To enforce token refresh by admin client in the next request + setTimeOffset(700); + + realm = adminClientDPoP.realm(REALM_NAME).toRepresentation(); + Assert.assertEquals(REALM_NAME, realm.getRealm()); + } + } + + @Test + public void testDPoPAdminRequestFailure() throws Exception { + modifyClient(TEST_CONFIDENTIAL_CLIENT_ID, (clientRepresentation, configWrapper) -> { + clientRepresentation.setDirectAccessGrantsEnabled(true); + configWrapper.setUseDPoP(true); + }); + + try (Keycloak adminClientDPoP = AdminClientUtil.createAdminClient(false, ServerURLs.getAuthServerContextRoot(), REALM_NAME, + "test-admin@localhost", "password", TEST_CONFIDENTIAL_CLIENT_ID, TEST_CONFIDENTIAL_CLIENT_SECRET, null, false); + ) { + adminClientDPoP.realm(REALM_NAME).toRepresentation(); + Assert.fail("Expected exception when calling adminClient without DPoP for the client, which requires DPoP"); + } catch (ProcessingException pe) { + Assert.assertTrue(pe.getCause() instanceof BadRequestException); + } + } + + @Test + public void testDPoPAccountRequestSuccess() throws Exception { + KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048); + AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair); + + try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + // Valid DPoP proof for the access-token + 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, getAccountRootUrl(), (long) Time.currentTime(), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate(), response.getAccessToken()); + + int status = SimpleHttpDefault.doGet(getAccountRootUrl(), httpClient).header("Accept", "application/json") + .header("Authorization", DPOP_TOKEN_TYPE + " " + response.getAccessToken()) + .header(DPOP_HTTP_HEADER, dpopProofRsaEncoded) + .asStatus(); + assertEquals(200, status); + } + + oauth.doLogout(response.getRefreshToken()); + } + + @Test + public void testDPoPAccountRequestFailures() throws Exception { + KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048); + AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair); + + try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + // Request with DPoP accessToken and with "Authorization: Bearer" header should fail + int status = SimpleHttpDefault.doGet(getAccountRootUrl(), httpClient).header("Accept", "application/json") + .auth(response.getAccessToken()) + .asStatus(); + assertEquals(401, status); + + // Request with DPoP accessToken and with "Authorization: DPoP" header should fail + status = SimpleHttpDefault.doGet(getAccountRootUrl(), httpClient).header("Accept", "application/json") + .header("Authorization", DPOP_TOKEN_TYPE + " " + response.getAccessToken()) + .asStatus(); + assertEquals(401, status); + + // Invalid DPoP proof for the access-token (Request URL is userInfo instead of getAccountRootUrl() + 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, oauth.getEndpoints().getUserInfo(), (long) Time.currentTime(), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate(), response.getAccessToken()); + + status = SimpleHttpDefault.doGet(getAccountRootUrl(), httpClient).header("Accept", "application/json") + .header("Authorization", DPOP_TOKEN_TYPE + " " + response.getAccessToken()) + .header(DPOP_HTTP_HEADER, dpopProofRsaEncoded) + .asStatus(); + assertEquals(401, status); + } + + oauth.doLogout(response.getRefreshToken()); + } + private AccessTokenResponse getDPoPBindAccessToken(KeyPair rsaKeyPair) throws Exception { oauth.client(TEST_CONFIDENTIAL_CLIENT_ID, TEST_CONFIDENTIAL_CLIENT_SECRET); oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java index 8701a836c3e..362ed8392e0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java @@ -23,21 +23,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.MultivaluedMap; -import org.keycloak.crypto.AsymmetricSignatureSignerContext; -import org.keycloak.crypto.ECDSASignatureSignerContext; import org.keycloak.crypto.KeyType; -import org.keycloak.crypto.KeyUse; -import org.keycloak.crypto.KeyWrapper; -import org.keycloak.crypto.SignatureException; -import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.jose.jwk.ECPublicJWK; import org.keycloak.jose.jwk.JWK; -import org.keycloak.jose.jwk.RSAPublicJWK; +import org.keycloak.jose.jws.Algorithm; import org.keycloak.jose.jws.JWSHeader; -import org.keycloak.jose.jws.crypto.HashUtils; import org.keycloak.models.utils.MapperTypeSerializer; import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutor; -import org.keycloak.representations.dpop.DPoP; import org.keycloak.representations.idm.ClientPoliciesRepresentation; import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation; import org.keycloak.representations.idm.ClientPolicyConditionRepresentation; @@ -70,9 +62,9 @@ import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutor; import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutor; import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmExecutor; import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmForSignedJwtExecutor; -import org.keycloak.services.util.DPoPUtil; import org.keycloak.testsuite.services.clientpolicy.condition.TestRaiseExceptionCondition; import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutor; +import org.keycloak.util.DPoPGenerator; import org.keycloak.util.JsonSerialization; import java.io.IOException; @@ -84,7 +76,6 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.SecureRandom; import java.security.interfaces.ECPublicKey; -import java.security.interfaces.RSAPublicKey; import java.security.spec.ECGenParameterSpec; import java.util.ArrayList; import java.util.List; @@ -454,14 +445,7 @@ public final class ClientPoliciesUtil { // DPoP public static JWK createRsaJwk(Key publicKey) { - RSAPublicKey rsaKey = (RSAPublicKey) publicKey; - - RSAPublicJWK k = new RSAPublicJWK(); - k.setKeyType(KeyType.RSA); - k.setModulus(Base64Url.encode(toIntegerBytes(rsaKey.getModulus()))); - k.setPublicExponent(Base64Url.encode(toIntegerBytes(rsaKey.getPublicExponent()))); - - return k; + return DPoPGenerator.createRsaJwk(publicKey); } public static JWK createEcJwk(Key publicKey) { @@ -487,45 +471,17 @@ public final class ClientPoliciesUtil { } public static String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, String algorithm, JWSHeader jwsHeader, PrivateKey privateKey, String accessToken) throws IOException { - - String dpopProofHeaderEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader)); - - DPoP dpop = new DPoP(); - dpop.id(jti); - dpop.setHttpMethod(htm); - dpop.setHttpUri(htu); - dpop.iat(iat); - if (accessToken != null) { - dpop.setAccessTokenHash(HashUtils.accessTokenHash(DPoPUtil.DPOP_ATH_ALG, accessToken, true)); - } - - String dpopProofPayloadEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(dpop)); - - try { - KeyWrapper keyWrapper = new KeyWrapper(); - keyWrapper.setKid(jwsHeader.getKeyId()); - keyWrapper.setAlgorithm(algorithm); - keyWrapper.setPrivateKey(privateKey); - keyWrapper.setType(privateKey.getAlgorithm()); - keyWrapper.setUse(KeyUse.SIG); - SignatureSignerContext sigCtx = createSignatureSignerContext(keyWrapper); - - String data = dpopProofHeaderEncoded + "." + dpopProofPayloadEncoded; - byte[] signatureByteArray = sigCtx.sign(data.getBytes()); - return data + "." + Base64Url.encode(signatureByteArray); - } catch (SignatureException e) { - throw new RuntimeException(e); - } - } - - private static SignatureSignerContext createSignatureSignerContext(KeyWrapper keyWrapper) { - switch (keyWrapper.getType()) { - case KeyType.RSA: - return new AsymmetricSignatureSignerContext(keyWrapper); - case KeyType.EC: - return new ECDSASignatureSignerContext(keyWrapper); - default: - throw new IllegalArgumentException("No signer provider for key algorithm type " + keyWrapper.getType()); + if (algorithm.equals(jwsHeader.getAlgorithm().toString())) { + return DPoPGenerator.generateSignedDPoPProof(jti, htm, htu, iat, jwsHeader, privateKey, accessToken); + } else { + // Ability to test failure scenarios when different algorithms are used for the JWSHeader and for the actual key + Algorithm origJwsAlg = jwsHeader.getAlgorithm(); + JWSHeader updatedHeader = new JWSHeader(Algorithm.valueOf(algorithm), jwsHeader.getType(), jwsHeader.getKeyId(), jwsHeader.getKey()); + String dpop = DPoPGenerator.generateSignedDPoPProof(jti, htm, htu, iat, updatedHeader, privateKey, accessToken); + String dpopOrigHeader = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader)); + // Replace header with the original algorithm + String updatedAlgorithmHeader = dpop.substring(0, dpop.indexOf('.')); + return dpop.replace(updatedAlgorithmHeader, dpopOrigHeader); } } }