DPoP verification support for admin/account REST API endpoints. Java admin-client DPoP support

closes #33942

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda 2025-08-20 20:33:10 +02:00 committed by Marek Posolda
parent 9afe5fb8a9
commit 624d236ced
19 changed files with 434 additions and 106 deletions

View File

@ -17,6 +17,10 @@
package org.keycloak;
import org.keycloak.jose.jws.Algorithm;
import static org.keycloak.jose.jws.Algorithm.PS256;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@ -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";
}

View File

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -43,7 +44,7 @@ public class JWSBuilder {
protected String kid;
protected String x5t;
protected JWK jwk;
protected List<X509Certificate> x5c;
protected List<String> x5c;
protected String contentType;
protected byte[] contentBytes;
@ -68,7 +69,15 @@ public class JWSBuilder {
}
public JWSBuilder x5c(List<X509Certificate> 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("]");
}

View File

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc9449">OAuth 2.0 Demonstrating Proof of Possession (DPoP) specification</a>
*
*/
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());
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
/**

View File

@ -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() {

View File

@ -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;
}
}

View File

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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;
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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<DP
case BACKCHANNEL_TOKEN_REQUEST:
// Codes for processing these requests verifies DPoP.
// If this verification is done twice, DPoPReplayCheck fails. Therefore, the executor only checks existence of DPoP Proof
if (request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) == null) {
if (request.getHttpHeaders().getHeaderString(OAuth2Constants.DPOP_HTTP_HEADER) == null) {
throw new ClientPolicyException(OAuthErrorException.INVALID_DPOP_PROOF, "DPoP proof is missing");
}
break;

View File

@ -21,12 +21,14 @@ import jakarta.ws.rs.NotAuthorizedException;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.util.TokenUtil;
import java.util.List;
@ -133,6 +135,7 @@ public class AppAuthManager extends AuthenticationManager {
private UriInfo uriInfo;
private ClientConnection connection;
private HttpHeaders headers;
private HttpRequest request;
private String tokenString;
private String audience;
@ -165,6 +168,11 @@ public class AppAuthManager extends AuthenticationManager {
return this;
}
public BearerTokenAuthenticator setRequest(HttpRequest request) {
this.request = request;
return this;
}
public BearerTokenAuthenticator setTokenString(String tokenString) {
this.tokenString = tokenString;
return this;
@ -181,10 +189,12 @@ public class AppAuthManager extends AuthenticationManager {
if (uriInfo == null) uriInfo = ctx.getUri();
if (connection == null) connection = ctx.getConnection();
if (headers == null) headers = ctx.getRequestHeaders();
if (request == null) request = ctx.getHttpRequest();
if (tokenString == null) tokenString = extractAuthorizationHeaderToken(headers);
// audience can be null
return verifyIdentityToken(session, realm, uriInfo, connection, true, true, audience, false, tokenString, headers);
return verifyIdentityToken(session, realm, uriInfo, connection, true, true, audience, false, tokenString, headers,
verifier -> DPoPUtil.withDPoPVerifier(verifier, realm, new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).accessToken(tokenString)));
}
}

View File

@ -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<? super AccessToken>... additionalChecks) {
String checkAudience, boolean isCookie, String tokenString, HttpHeaders headers, Consumer<TokenVerifier<AccessToken>> verifierConsumer) {
try {
TokenVerifier<AccessToken> 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);

View File

@ -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

View File

@ -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)

View File

@ -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();

View File

@ -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);

View File

@ -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);
}
}
}