DPoP verification in UserInfo endpoint

closes #22215
This commit is contained in:
Takashi Norimatsu 2023-08-04 14:12:33 +09:00 committed by Marek Posolda
parent 9d0960d405
commit 258711ef4f
3 changed files with 197 additions and 0 deletions

View file

@ -22,6 +22,7 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.TokenCategory;
import org.keycloak.TokenVerifier;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
import org.keycloak.common.constants.ServiceAccountConstants;
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.NotBeforeCheck;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.services.Urls;
import org.keycloak.services.clientpolicy.ClientPolicyException;
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.UserSessionManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.services.util.MtlsHoKTokenUtil;
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
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());

View file

@ -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 {
return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{}, null);
}
@ -2298,4 +2311,42 @@ public class OAuthClient {
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;
}
}
}

View file

@ -64,6 +64,7 @@ import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.UserInfo;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.representations.idm.ClientRepresentation;
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.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.OAuthClient.UserInfoResponse;
import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.JsonSerialization;
@ -160,6 +162,12 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
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();
}
@ -389,6 +397,129 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
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 {
oauth.dpopProof(dpopProofEncoded);
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);