KEYCLOAK-11251 ES256 or PS256 support for Client Authentication by Signed JWT (#6414)

This commit is contained in:
Takashi Norimatsu 2019-10-25 00:58:54 +09:00 committed by Marek Posolda
parent 6bf1e8a9a7
commit 1905260eac
6 changed files with 310 additions and 4 deletions

View file

@ -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);

View file

@ -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);

View file

@ -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<String, String> 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<String, String> res = new HashMap<>();
res.put(PRIVATE_KEY, privateKeyPem);
res.put(PUBLIC_KEY, publicKeyPem);
return res;
}
@GET
@Produces(MediaType.APPLICATION_JSON)

View file

@ -42,6 +42,10 @@ public interface TestOIDCEndpointsApplicationResource {
@Path("/get-keys-as-pem")
Map<String, String> getKeysAsPem();
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/get-keys-as-base64")
Map<String, String> getKeysAsBase64();
@GET
@Produces(MediaType.APPLICATION_JSON)

View file

@ -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<String, String> 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<String, String> 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;
}
}

View file

@ -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);