parent
9d0960d405
commit
258711ef4f
3 changed files with 197 additions and 0 deletions
|
@ -22,6 +22,7 @@ import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.TokenCategory;
|
import org.keycloak.TokenCategory;
|
||||||
import org.keycloak.TokenVerifier;
|
import org.keycloak.TokenVerifier;
|
||||||
import org.keycloak.common.ClientConnection;
|
import org.keycloak.common.ClientConnection;
|
||||||
|
import org.keycloak.common.Profile;
|
||||||
import org.keycloak.common.VerificationException;
|
import org.keycloak.common.VerificationException;
|
||||||
import org.keycloak.common.constants.ServiceAccountConstants;
|
import org.keycloak.common.constants.ServiceAccountConstants;
|
||||||
import org.keycloak.crypto.ContentEncryptionProvider;
|
import org.keycloak.crypto.ContentEncryptionProvider;
|
||||||
|
@ -53,6 +54,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.oidc.TokenManager;
|
import org.keycloak.protocol.oidc.TokenManager;
|
||||||
import org.keycloak.protocol.oidc.TokenManager.NotBeforeCheck;
|
import org.keycloak.protocol.oidc.TokenManager.NotBeforeCheck;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
|
import org.keycloak.representations.dpop.DPoP;
|
||||||
import org.keycloak.services.Urls;
|
import org.keycloak.services.Urls;
|
||||||
import org.keycloak.services.clientpolicy.ClientPolicyException;
|
import org.keycloak.services.clientpolicy.ClientPolicyException;
|
||||||
import org.keycloak.services.clientpolicy.context.UserInfoRequestContext;
|
import org.keycloak.services.clientpolicy.context.UserInfoRequestContext;
|
||||||
|
@ -61,6 +63,7 @@ import org.keycloak.services.managers.AuthenticationManager;
|
||||||
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
||||||
import org.keycloak.services.managers.UserSessionManager;
|
import org.keycloak.services.managers.UserSessionManager;
|
||||||
import org.keycloak.services.resources.Cors;
|
import org.keycloak.services.resources.Cors;
|
||||||
|
import org.keycloak.services.util.DPoPUtil;
|
||||||
import org.keycloak.services.util.DefaultClientSessionContext;
|
import org.keycloak.services.util.DefaultClientSessionContext;
|
||||||
import org.keycloak.services.util.MtlsHoKTokenUtil;
|
import org.keycloak.services.util.MtlsHoKTokenUtil;
|
||||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
|
@ -251,6 +254,18 @@ public class UserInfoEndpoint {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Profile.isFeatureEnabled(Profile.Feature.DPOP)) {
|
||||||
|
if (OIDCAdvancedConfigWrapper.fromClientModel(clientModel).isUseDPoP() || DPoPUtil.DPOP_TOKEN_TYPE.equals(token.getType())) {
|
||||||
|
try {
|
||||||
|
DPoP dPoP = new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).validate();
|
||||||
|
DPoPUtil.validateBinding(token, dPoP);
|
||||||
|
} catch (VerificationException ex) {
|
||||||
|
event.detail("detail", ex.getMessage()).error(Errors.NOT_ALLOWED);
|
||||||
|
throw error.invalidToken("DPoP proof and token binding verification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Existence of authenticatedClientSession for our client already handled before
|
// Existence of authenticatedClientSession for our client already handled before
|
||||||
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());
|
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());
|
||||||
|
|
||||||
|
|
|
@ -1139,6 +1139,19 @@ public class OAuthClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UserInfoResponse doUserInfoRequestByGet(String accessToken) throws Exception {
|
||||||
|
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
|
||||||
|
HttpGet get = new HttpGet(getUserInfoUrl());
|
||||||
|
get.setHeader("Authorization", "Bearer " + accessToken);
|
||||||
|
if (dpopProof != null) {
|
||||||
|
get.addHeader("DPoP", dpopProof);
|
||||||
|
}
|
||||||
|
return new UserInfoResponse(client.execute(get));
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new RuntimeException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ParResponse doPushedAuthorizationRequest(String clientId, String clientSecret) throws IOException {
|
public ParResponse doPushedAuthorizationRequest(String clientId, String clientSecret) throws IOException {
|
||||||
return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{}, null);
|
return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{}, null);
|
||||||
}
|
}
|
||||||
|
@ -2298,4 +2311,42 @@ public class OAuthClient {
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class UserInfoResponse {
|
||||||
|
private int statusCode;
|
||||||
|
|
||||||
|
private UserInfo userInfo;
|
||||||
|
|
||||||
|
private Map<String, String> headers;
|
||||||
|
|
||||||
|
public UserInfoResponse(CloseableHttpResponse response) throws Exception {
|
||||||
|
try {
|
||||||
|
statusCode = response.getStatusLine().getStatusCode();
|
||||||
|
|
||||||
|
headers = new HashMap<>();
|
||||||
|
|
||||||
|
for (Header h : response.getAllHeaders()) {
|
||||||
|
headers.put(h.getName(), h.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode == 200) {
|
||||||
|
userInfo = JsonSerialization.readValue(response.getEntity().getContent(), UserInfo.class);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
response.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserInfo getUserInfo() {
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatusCode() {
|
||||||
|
return statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getHeaders() {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,6 +64,7 @@ import org.keycloak.jose.jws.JWSHeader;
|
||||||
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.RefreshToken;
|
import org.keycloak.representations.RefreshToken;
|
||||||
|
import org.keycloak.representations.UserInfo;
|
||||||
import org.keycloak.representations.dpop.DPoP;
|
import org.keycloak.representations.dpop.DPoP;
|
||||||
import org.keycloak.representations.idm.ClientRepresentation;
|
import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
import org.keycloak.representations.idm.RealmRepresentation;
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
@ -75,6 +76,7 @@ import org.keycloak.testsuite.AssertEvents;
|
||||||
import org.keycloak.testsuite.admin.ApiUtil;
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||||
import org.keycloak.testsuite.util.OAuthClient;
|
import org.keycloak.testsuite.util.OAuthClient;
|
||||||
|
import org.keycloak.testsuite.util.OAuthClient.UserInfoResponse;
|
||||||
import org.keycloak.testsuite.util.ServerURLs;
|
import org.keycloak.testsuite.util.ServerURLs;
|
||||||
import org.keycloak.util.JWKSUtils;
|
import org.keycloak.util.JWKSUtils;
|
||||||
import org.keycloak.util.JsonSerialization;
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
@ -160,6 +162,12 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
||||||
refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
|
refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
|
||||||
assertEquals(jkt, refreshToken.getConfirmation().getKeyThumbprint());
|
assertEquals(jkt, refreshToken.getConfirmation().getKeyThumbprint());
|
||||||
|
|
||||||
|
// userinfo access
|
||||||
|
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
|
||||||
|
oauth.dpopProof(dpopProofEcEncoded);
|
||||||
|
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||||
|
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
|
||||||
|
|
||||||
oauth.idTokenHint(response.getIdToken()).openLogout();
|
oauth.idTokenHint(response.getIdToken()).openLogout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -389,6 +397,129 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
||||||
assertEquals("DPoP proof is missing", response.getErrorDescription());
|
assertEquals("DPoP proof is missing", response.getErrorDescription());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDPoPProofOnUserInfoByConfidentialClient() throws Exception {
|
||||||
|
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||||
|
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||||
|
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
|
||||||
|
|
||||||
|
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDPoPDisabledOnUserInfo() throws Exception {
|
||||||
|
|
||||||
|
changeDPoPBound(TEST_CONFIDENTIAL_CLIENT_ID, false);
|
||||||
|
try {
|
||||||
|
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||||
|
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||||
|
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
|
||||||
|
|
||||||
|
// delete DPoP proof
|
||||||
|
oauth.dpopProof(null);
|
||||||
|
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||||
|
assertEquals(401, userInfoResponse.getStatusCode());
|
||||||
|
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||||
|
|
||||||
|
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||||
|
} finally {
|
||||||
|
changeDPoPBound(TEST_CONFIDENTIAL_CLIENT_ID, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testWithoutDPoPProofOnUserInfo() throws Exception {
|
||||||
|
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||||
|
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||||
|
|
||||||
|
oauth.dpopProof(null);
|
||||||
|
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||||
|
assertEquals(401, userInfoResponse.getStatusCode());
|
||||||
|
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||||
|
|
||||||
|
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInvalidDPoPProofOnUserInfo() throws Exception {
|
||||||
|
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||||
|
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||||
|
|
||||||
|
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
|
||||||
|
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||||
|
// invalid "htu" claim
|
||||||
|
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||||
|
oauth.dpopProof(dpopProofRsaEncoded);
|
||||||
|
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||||
|
assertEquals(401, userInfoResponse.getStatusCode());
|
||||||
|
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||||
|
|
||||||
|
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testMultipleUseDPoPProofOnUserInfo() throws Exception {
|
||||||
|
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||||
|
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||||
|
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
|
||||||
|
|
||||||
|
// use the same DPoP proof
|
||||||
|
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||||
|
assertEquals(401, userInfoResponse.getStatusCode());
|
||||||
|
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||||
|
|
||||||
|
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDifferentKeyDPoPProofOnUserInfo() throws Exception {
|
||||||
|
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||||
|
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||||
|
|
||||||
|
// use different key
|
||||||
|
rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||||
|
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
|
||||||
|
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||||
|
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||||
|
oauth.dpopProof(dpopProofRsaEncoded);
|
||||||
|
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||||
|
assertEquals(401, userInfoResponse.getStatusCode());
|
||||||
|
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||||
|
|
||||||
|
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OAuthClient.AccessTokenResponse getDPoPBindAccessToken(KeyPair rsaKeyPair) throws Exception {
|
||||||
|
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
|
||||||
|
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
|
||||||
|
|
||||||
|
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
|
||||||
|
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||||
|
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||||
|
|
||||||
|
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||||
|
oauth.dpopProof(dpopProofRsaEncoded);
|
||||||
|
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||||
|
|
||||||
|
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||||
|
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||||
|
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus());
|
||||||
|
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent());
|
||||||
|
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
|
||||||
|
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doSuccessfulUserInfoGet(String accessToken, KeyPair rsaKeyPair) throws Exception {
|
||||||
|
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
|
||||||
|
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||||
|
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||||
|
oauth.dpopProof(dpopProofRsaEncoded);
|
||||||
|
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(accessToken);
|
||||||
|
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
|
||||||
|
}
|
||||||
|
|
||||||
private void testDPoPProofFailure(String dpopProofEncoded, String errorDescription) throws Exception {
|
private void testDPoPProofFailure(String dpopProofEncoded, String errorDescription) throws Exception {
|
||||||
oauth.dpopProof(dpopProofEncoded);
|
oauth.dpopProof(dpopProofEncoded);
|
||||||
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
|
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
|
||||||
|
|
Loading…
Reference in a new issue