From 1905260eacc75350994c79f861bbb59d8c312614 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Fri, 25 Oct 2019 00:58:54 +0900 Subject: [PATCH] KEYCLOAK-11251 ES256 or PS256 support for Client Authentication by Signed JWT (#6414) --- .../client/JWTClientAuthenticator.java | 4 +- .../protocol/oidc/OIDCWellKnownProvider.java | 2 +- ...stingOIDCEndpointsApplicationResource.java | 14 + .../TestOIDCEndpointsApplicationResource.java | 4 + .../oauth/ClientAuthSignedJWTTest.java | 288 ++++++++++++++++++ .../oidc/OIDCWellKnownProviderTest.java | 2 +- 6 files changed, 310 insertions(+), 4 deletions(-) diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java index 4a7d89cd49..1691657ef8 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java @@ -37,7 +37,6 @@ import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.common.util.Time; import org.keycloak.jose.jws.JWSInput; -import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.keys.loader.PublicKeyStorageManager; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.ClientModel; @@ -131,7 +130,8 @@ public class JWTClientAuthenticator extends AbstractClientAuthenticator { boolean signatureValid; try { - signatureValid = RSAProvider.verify(jws, clientPublicKey); + JsonWebToken jwt = context.getSession().tokens().decodeClientJWT(clientAssertion, client, JsonWebToken.class); + signatureValid = jwt == null ? false : true; } catch (RuntimeException e) { Throwable cause = e.getCause() != null ? e.getCause() : e; throw new RuntimeException("Signature on JWT token failed validation", cause); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index f124a31b20..b5eed4745a 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -102,7 +102,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider { config.setGrantTypesSupported(DEFAULT_GRANT_TYPES_SUPPORTED); config.setTokenEndpointAuthMethodsSupported(getClientAuthMethodsSupported()); - config.setTokenEndpointAuthSigningAlgValuesSupported(DEFAULT_CLIENT_AUTH_SIGNING_ALG_VALUES_SUPPORTED); + config.setTokenEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false)); config.setClaimsSupported(DEFAULT_CLAIMS_SUPPORTED); config.setClaimTypesSupported(DEFAULT_CLAIM_TYPES_SUPPORTED); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java index 2923b44dc6..de9605cc4f 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java @@ -20,6 +20,7 @@ package org.keycloak.testsuite.rest.resource; import org.jboss.resteasy.annotations.cache.NoCache; import javax.ws.rs.BadRequestException; import org.keycloak.OAuth2Constants; +import org.keycloak.common.util.Base64; import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.PemUtils; import org.keycloak.crypto.Algorithm; @@ -144,6 +145,19 @@ public class TestingOIDCEndpointsApplicationResource { return res; } + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/get-keys-as-base64") + public Map getKeysAsBase64() { + // It seems that PemUtils.decodePrivateKey, decodePublicKey can only treat RSA type keys, not EC type keys. Therefore, these are not used. + String privateKeyPem = Base64.encodeBytes(clientData.getSigningKeyPair().getPrivate().getEncoded()); + String publicKeyPem = Base64.encodeBytes(clientData.getSigningKeyPair().getPublic().getEncoded()); + + Map res = new HashMap<>(); + res.put(PRIVATE_KEY, privateKeyPem); + res.put(PUBLIC_KEY, publicKeyPem); + return res; + } @GET @Produces(MediaType.APPLICATION_JSON) diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java index 19fd5d4e8f..abca35c1a9 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java @@ -42,6 +42,10 @@ public interface TestOIDCEndpointsApplicationResource { @Path("/get-keys-as-pem") Map getKeysAsPem(); + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/get-keys-as-base64") + Map getKeysAsBase64(); @GET @Produces(MediaType.APPLICATION_JSON) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java index 24e7e94e9b..fb55c7dc19 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java @@ -45,9 +45,17 @@ import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.util.*; import org.keycloak.constants.ServiceUrlConstants; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.AsymmetricSignatureSignerContext; +import org.keycloak.crypto.KeyType; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.events.Details; import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.KeyStoreConfig; @@ -62,6 +70,9 @@ import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.auth.page.AuthRealm; +import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls; +import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource; +import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource; import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.OAuthClient; @@ -75,11 +86,17 @@ import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.nio.file.Files; +import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -263,6 +280,54 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest { .assertEvent(); } + @Test + public void testCodeToTokenRequestSuccessES256() throws Exception { + testCodeToTokenRequestSuccess(Algorithm.ES256); + } + + @Test + public void testCodeToTokenRequestSuccessRS256() throws Exception { + testCodeToTokenRequestSuccess(Algorithm.RS256); + } + + @Test + public void testCodeToTokenRequestSuccessPS256() throws Exception { + testCodeToTokenRequestSuccess(Algorithm.PS256); + } + + private void testCodeToTokenRequestSuccess(String algorithm) throws Exception { + ClientRepresentation clientRepresentation = app2; + ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId()); + clientRepresentation = clientResource.toRepresentation(); + try { + // setup Jwks + KeyPair keyPair = setupJwks(algorithm, clientRepresentation, clientResource); + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + + // test + oauth.clientId("client2"); + oauth.doLogin("test-user@localhost", "password"); + EventRepresentation loginEvent = events.expectLogin() + .client("client2") + .assertEvent(); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = doAccessTokenRequest(code, createSignedRequestToken("client2", getRealmInfoUrl(), privateKey, publicKey, algorithm)); + + assertEquals(200, response.getStatusCode()); + oauth.verifyToken(response.getAccessToken()); + oauth.parseRefreshToken(response.getRefreshToken()); + events.expectCodeToToken(loginEvent.getDetails().get(Details.CODE_ID), loginEvent.getSessionId()) + .client("client2") + .detail(Details.CLIENT_AUTH_METHOD, JWTClientAuthenticator.PROVIDER_ID) + .assertEvent(); + } finally { + // Revert jwks_url settings + revertJwksSettings(clientRepresentation, clientResource); + } + } + @Test public void testDirectGrantRequestSuccess() throws Exception { oauth.clientId("client2"); @@ -286,6 +351,57 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest { .assertEvent(); } + @Test + public void testDirectGrantRequestSuccessES256() throws Exception { + testDirectGrantRequestSuccess(Algorithm.ES256); + } + + @Test + public void testDirectGrantRequestSuccessRS256() throws Exception { + testDirectGrantRequestSuccess(Algorithm.RS256); + } + + @Test + public void testDirectGrantRequestSuccessPS256() throws Exception { + testDirectGrantRequestSuccess(Algorithm.PS256); + } + + private void testDirectGrantRequestSuccess(String algorithm) throws Exception { + ClientRepresentation clientRepresentation = app2; + ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId()); + clientRepresentation = clientResource.toRepresentation(); + try { + // setup Jwks + KeyPair keyPair = setupJwks(algorithm, clientRepresentation, clientResource); + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + + // test + oauth.clientId("client2"); + OAuthClient.AccessTokenResponse response = doGrantAccessTokenRequest("test-user@localhost", "password", createSignedRequestToken("client2", getRealmInfoUrl(), privateKey, publicKey, algorithm)); + + assertEquals(200, response.getStatusCode()); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + + events.expectLogin() + .client("client2") + .session(accessToken.getSessionState()) + .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) + .detail(Details.TOKEN_ID, accessToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()) + .detail(Details.USERNAME, "test-user@localhost") + .detail(Details.CLIENT_AUTH_METHOD, JWTClientAuthenticator.PROVIDER_ID) + .removeDetail(Details.CODE_ID) + .removeDetail(Details.REDIRECT_URI) + .removeDetail(Details.CONSENT) + .assertEvent(); + } finally { + // Revert jwks_url settings + revertJwksSettings(clientRepresentation, clientResource); + } + } + @Test public void testClientWithGeneratedKeysJKS() throws Exception { testClientWithGeneratedKeys("JKS"); @@ -749,6 +865,80 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest { assertEquals("Certificates don't match", pem, certNew); } + @Test + public void testCodeToTokenRequestFailureRS256() throws Exception { + testCodeToTokenRequestFailure(Algorithm.RS256); + } + + private void testCodeToTokenRequestFailure(String algorithm) throws Exception { + ClientRepresentation clientRepresentation = app2; + ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId()); + clientRepresentation = clientResource.toRepresentation(); + try { + // setup Jwks + KeyPair keyPair = setupJwks(algorithm, clientRepresentation, clientResource); + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + + // test + oauth.clientId("client2"); + oauth.doLogin("test-user@localhost", "password"); + EventRepresentation loginEvent = events.expectLogin() + .client("client2") + .assertEvent(); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = doAccessTokenRequest(code, getClient2SignedJWT()); + + assertEquals(400, response.getStatusCode()); + assertEquals("unauthorized_client", response.getError()); + + events.expect(EventType.CODE_TO_TOKEN_ERROR) + .client("client2") + .session((String) null) + .clearDetails() + .error("client_credentials_setup_required") + .user((String) null) + .assertEvent(); + } finally { + // Revert jwks_url settings + revertJwksSettings(clientRepresentation, clientResource); + } + } + + @Test + public void testDirectGrantRequestFailureES256() throws Exception { + testDirectGrantRequestFailure(Algorithm.ES256); + } + + private void testDirectGrantRequestFailure(String algorithm) throws Exception { + ClientRepresentation clientRepresentation = app2; + ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId()); + clientRepresentation = clientResource.toRepresentation(); + try { + // setup Jwks + setupJwks(algorithm, clientRepresentation, clientResource); + + // test + oauth.clientId("client2"); + OAuthClient.AccessTokenResponse response = doGrantAccessTokenRequest("test-user@localhost", "password", getClient2SignedJWT()); + + assertEquals(400, response.getStatusCode()); + assertEquals("unauthorized_client", response.getError()); + + events.expect(EventType.LOGIN_ERROR) + .client("client2") + .session((String) null) + .clearDetails() + .error("client_credentials_setup_required") + .user((String) null) + .assertEvent(); + } finally { + // Revert jwks_url settings + revertJwksSettings(clientRepresentation, clientResource); + } + } + // HELPER METHODS private OAuthClient.AccessTokenResponse doAccessTokenRequest(String code, String signedJwt) throws Exception { @@ -912,4 +1102,102 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest { keyStore.load(is, storePassword.toCharArray()); return keyStore; } + + private KeyPair setupJwks(String algorithm, ClientRepresentation clientRepresentation, ClientResource clientResource) throws Exception { + // generate and register client keypair + TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints(); + oidcClientEndpointsResource.generateKeys(algorithm); + Map generatedKeys = oidcClientEndpointsResource.getKeysAsBase64(); + KeyPair keyPair = getKeyPairFromGeneratedBase64(generatedKeys, algorithm); + + // use and set jwks_url + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRepresentation).setUseJwksUrl(true); + String jwksUrl = TestApplicationResourceUrls.clientJwksUri(); + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRepresentation).setJwksUrl(jwksUrl); + clientResource.update(clientRepresentation); + + // set time offset, so that new keys are downloaded + setTimeOffset(20); + + return keyPair; + } + + private void revertJwksSettings(ClientRepresentation clientRepresentation, ClientResource clientResource) { + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRepresentation).setUseJwksUrl(false); + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRepresentation).setJwksUrl(null); + clientResource.update(clientRepresentation); + } + + private KeyPair getKeyPairFromGeneratedBase64(Map generatedKeys, String algorithm) throws Exception { + // It seems that PemUtils.decodePrivateKey, decodePublicKey can only treat RSA type keys, not EC type keys. Therefore, these are not used. + String privateKeyBase64 = generatedKeys.get(TestingOIDCEndpointsApplicationResource.PRIVATE_KEY); + String publicKeyBase64 = generatedKeys.get(TestingOIDCEndpointsApplicationResource.PUBLIC_KEY); + PrivateKey privateKey = decodePrivateKey(Base64.decode(privateKeyBase64), algorithm); + PublicKey publicKey = decodePublicKey(Base64.decode(publicKeyBase64), algorithm); + return new KeyPair(publicKey, privateKey); + } + + private static PrivateKey decodePrivateKey(byte[] der, String algorithm) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException { + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + String keyAlg = getKeyAlgorithmFromJwaAlgorithm(algorithm); + KeyFactory kf = KeyFactory.getInstance(keyAlg, "BC"); + return kf.generatePrivate(spec); + } + + private static PublicKey decodePublicKey(byte[] der, String algorithm) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException { + X509EncodedKeySpec spec = new X509EncodedKeySpec(der); + String keyAlg = getKeyAlgorithmFromJwaAlgorithm(algorithm); + KeyFactory kf = KeyFactory.getInstance(keyAlg, "BC"); + return kf.generatePublic(spec); + } + + private String createSignedRequestToken(String clientId, String realmInfoUrl, PrivateKey privateKey, PublicKey publicKey, String algorithm) { + JsonWebToken jwt = createRequestToken(clientId, realmInfoUrl); + String kid = KeyUtils.createKeyId(publicKey); + KeyWrapper keyWrapper = new KeyWrapper(); + keyWrapper.setAlgorithm(algorithm); + keyWrapper.setKid(kid); + keyWrapper.setPrivateKey(privateKey); + SignatureSignerContext signer = new AsymmetricSignatureSignerContext(keyWrapper); + String ret = new JWSBuilder().kid(kid).jsonContent(jwt).sign(signer); + return ret; + + } + + private JsonWebToken createRequestToken(String clientId, String realmInfoUrl) { + JsonWebToken reqToken = new JsonWebToken(); + reqToken.id(AdapterUtils.generateId()); + reqToken.issuer(clientId); + reqToken.subject(clientId); + reqToken.audience(realmInfoUrl); + + int now = Time.currentTime(); + reqToken.issuedAt(now); + reqToken.expiration(now + 10); + reqToken.notBefore(now); + + return reqToken; + } + + private static String getKeyAlgorithmFromJwaAlgorithm(String jwaAlgorithm) { + String keyAlg = null; + switch (jwaAlgorithm) { + case Algorithm.RS256: + case Algorithm.RS384: + case Algorithm.RS512: + case Algorithm.PS256: + case Algorithm.PS384: + case Algorithm.PS512: + keyAlg = KeyType.RSA; + break; + case Algorithm.ES256: + case Algorithm.ES384: + case Algorithm.ES512: + keyAlg = KeyType.EC; + break; + default : + throw new RuntimeException("Unsupported signature algorithm"); + } + return keyAlg; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java index bd3926aec4..82a2600d4a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java @@ -137,7 +137,7 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest { // Client authentication Assert.assertNames(oidcConfig.getTokenEndpointAuthMethodsSupported(), "client_secret_basic", "client_secret_post", "private_key_jwt", "client_secret_jwt", "tls_client_auth"); - Assert.assertNames(oidcConfig.getTokenEndpointAuthSigningAlgValuesSupported(), Algorithm.RS256); + Assert.assertNames(oidcConfig.getTokenEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256, Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256, Algorithm.ES384, Algorithm.ES512); // Claims assertContains(oidcConfig.getClaimsSupported(), IDToken.NAME, IDToken.EMAIL, IDToken.PREFERRED_USERNAME, IDToken.FAMILY_NAME, IDToken.ACR);