diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java index 406d75cb74..48ee7493f1 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java @@ -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()); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index d17c0ab26d..606ea6ec05 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -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 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 getHeaders() { + return headers; + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java index 4c86e1a41b..49ba52483b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/DPoPTest.java @@ -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);