KEYCLOAK-11251 ES256 or PS256 support for Client Authentication by Signed JWT (#6414)
This commit is contained in:
parent
6bf1e8a9a7
commit
1905260eac
6 changed files with 310 additions and 4 deletions
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue