mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
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:
parent
9afe5fb8a9
commit
624d236ced
@ -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";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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("]");
|
||||
}
|
||||
|
||||
116
core/src/main/java/org/keycloak/util/DPoPGenerator.java
Normal file
116
core/src/main/java/org/keycloak/util/DPoPGenerator.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user