Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ private void triggerBackgroundRegistration(KeycloakSessionFactory factory, Strin
try {
RealmModel realm = bgSession.realms().getRealmByName(realmName);
if (realm != null) {
bgSession.getContext().setRealm(realm);
ensureRealmRegistered(bgSession, realm);
}
bgSession.getTransactionManager().commit();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
import java.security.interfaces.RSAPublicKey;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.AsymmetricSignatureSignerContext;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jws.JWSBuilder;
Expand All @@ -36,15 +35,38 @@ public CryptoIdentityService(KeycloakSession session) {
this.session = session;
}

/**
* Resolves the active signing key for the given realm using a consistent fallback chain:
* default algorithm → ES256 → RS256.
*
* <p>This is the single source of truth for key resolution and is used by both
* {@link #getActiveKey} and {@link #getRealmKeyData} to ensure the JWT bearer token
* is always signed with the same key that was registered as the issuer key.
*/
static KeyWrapper resolveActiveSigningKey(RealmModel realm, KeyManager keyManager) {
String defaultAlg = realm.getDefaultSignatureAlgorithm();
String algorithm = (defaultAlg == null || defaultAlg.isBlank()) ? Algorithm.ES256 : defaultAlg;

KeyWrapper activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, algorithm);
if (activeKey == null || activeKey.getPublicKey() == null) {
activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, Algorithm.ES256);
}
if (activeKey == null || activeKey.getPublicKey() == null) {
activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, Algorithm.RS256);
}
return activeKey;
}

Comment thread
Awambeng marked this conversation as resolved.
/**
* Retrieve the active signing key for the given realm.
*
* @throws IllegalStateException if no active signing key is found
*/
public KeyWrapper getActiveKey(RealmModel realm) {
KeyWrapper activeKey = session.keys().getActiveKey(realm, KeyUse.SIG, "RS256");
KeyWrapper activeKey = resolveActiveSigningKey(realm, session.keys());
if (activeKey == null) {
throw new IllegalStateException("No active signing key found for realm: " + realm.getName());
}

return activeKey;
}

Expand All @@ -53,6 +75,12 @@ public KeyWrapper getActiveKey(RealmModel realm) {
*/
public String getJwtToken(StatusListConfig realmConfig) {
KeyWrapper keyWrapper = getActiveKey(realmConfig.getRealm());
String algorithm = keyWrapper.getAlgorithm() != null ? keyWrapper.getAlgorithm() : Algorithm.ES256;

SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, algorithm);
if (signatureProvider == null) {
throw new IllegalStateException("No SignatureProvider found for algorithm: " + algorithm);
}

// Payload
Map<String, Object> payload = new HashMap<>();
Expand All @@ -61,26 +89,19 @@ public String getJwtToken(StatusListConfig realmConfig) {
payload.put("exp", Time.currentTime() + DEFAULT_AUTH_TOKEN_LIFETIME);

// Build and sign JWT
return new JWSBuilder().jsonContent(payload).sign(new AsymmetricSignatureSignerContext(keyWrapper));
return new JWSBuilder().jsonContent(payload).sign(signatureProvider.signer(keyWrapper));
}

/**
* Gets the realm's active signing key and converts it to JWK. Supports RSA and EC. accessible by
* CredentialRevocationResourceProviderFactory.
* Gets the realm's active signing key and converts it to JWK. Supports RSA and EC.
* Accessible by CredentialRevocationResourceProviderFactory.
*
* <p>Uses {@link #resolveActiveSigningKey} to guarantee that the registered JWK
* always matches the key used to sign the JWT bearer token.
*/
public static KeyData getRealmKeyData(KeycloakSession session, RealmModel realm) throws StatusListException {
try {
KeyManager keyManager = session.keys();

String algorithm =
Optional.ofNullable(realm.getDefaultSignatureAlgorithm()).orElse(Algorithm.ES256);

KeyWrapper activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, algorithm);

if (activeKey == null || activeKey.getPublicKey() == null) {
activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, Algorithm.RS256);
algorithm = Algorithm.RS256;
}
KeyWrapper activeKey = resolveActiveSigningKey(realm, session.keys());

if (activeKey == null) {
throw new StatusListException("No active signing key found for realm: " + realm.getName());
Expand All @@ -91,7 +112,7 @@ public static KeyData getRealmKeyData(KeycloakSession session, RealmModel realm)
}

PublicKey pubKey = (PublicKey) activeKey.getPublicKey();
String finalAlg = activeKey.getAlgorithm() != null ? activeKey.getAlgorithm() : algorithm;
String finalAlg = activeKey.getAlgorithm();

JWKBuilder builder = JWKBuilder.create().kid(activeKey.getKid()).algorithm(finalAlg);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.adorsys.keycloakstatuslist.helpers;

import java.math.BigInteger;
import java.security.AlgorithmParameters;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECParameterSpec;
import java.security.spec.ECPrivateKeySpec;
import java.util.Map;
import java.util.Objects;
import org.keycloak.common.util.Base64Url;
import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.util.JWKSUtils;

public class ECTestUtils {

// Maps JWK crv names to Java ECGenParameterSpec names
private static final Map<String, String> CRV_TO_EC_SPEC = Map.of(
"P-256", "secp256r1",
"P-384", "secp384r1",
"P-521", "secp521r1");

public static KeyWrapper getEcKeyWrapper(JWK jwk) throws Exception {
if (!KeyType.EC.equals(jwk.getKeyType())) {
throw new IllegalArgumentException("Only EC keys are supported");
}

KeyWrapper keyWrapper = JWKSUtils.getKeyWrapper(jwk);
Objects.requireNonNull(keyWrapper);
keyWrapper.setPrivateKey(getEcPrivateKey(jwk));

return keyWrapper;
}

private static PrivateKey getEcPrivateKey(JWK jwk) throws Exception {
String dEncoded = (String) jwk.getOtherClaims().get("d");
if (dEncoded == null) {
throw new IllegalArgumentException("Missing 'd' claim in EC JWK — cannot reconstruct private key");
}
byte[] dBytes = Base64Url.decode(dEncoded);
BigInteger d = new BigInteger(1, dBytes);

// Read curve from JWK 'crv' field
String crv = (String) jwk.getOtherClaims().get("crv");
if (crv == null) {
throw new IllegalArgumentException("Missing 'crv' claim in EC JWK");
}
String ecSpecName = CRV_TO_EC_SPEC.get(crv);
if (ecSpecName == null) {
throw new IllegalArgumentException("Unsupported EC curve: " + crv);
}

AlgorithmParameters params = AlgorithmParameters.getInstance("EC");
params.init(new ECGenParameterSpec(ecSpecName));
ECParameterSpec ecParameters = params.getParameterSpec(ECParameterSpec.class);
Comment thread
Awambeng marked this conversation as resolved.

ECPrivateKeySpec privateKeySpec = new ECPrivateKeySpec(d, ecParameters);
KeyFactory keyFactory = KeyFactory.getInstance("EC");
return keyFactory.generatePrivate(privateKeySpec);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.adorsys.keycloakstatuslist.helpers;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;
Expand All @@ -22,6 +23,8 @@
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeyManager;
Expand Down Expand Up @@ -82,6 +85,12 @@ public class MockKeycloakTest {
@Mock
protected CloseableHttpResponse httpResponse;

@Mock
protected SignatureProvider signatureProvider;

@Mock
protected SignatureSignerContext signerContext;

private MockedStatic<CustomHttpClient> mocked;

static KeyWrapper getActiveRsaKey() {
Expand All @@ -93,6 +102,15 @@ static KeyWrapper getActiveRsaKey() {
}
}

static KeyWrapper getActiveEcKey() {
try {
JWK jwk = testJwkResource("/keycloak-active-key-ec.json");
return ECTestUtils.getEcKeyWrapper(jwk);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

Comment thread
Awambeng marked this conversation as resolved.
Outdated
@SuppressWarnings("SameParameterValue")
protected static JWK testJwkResource(String filename) {
try (InputStream stream = MockKeycloakTest.class.getResourceAsStream(filename)) {
Expand Down Expand Up @@ -126,6 +144,13 @@ protected void rootSetup() {
.when(keyManager.getActiveKey(any(), any(), eq(Algorithm.RS256)))
.thenReturn(getActiveRsaKey());

lenient()
.when(session.getProvider(eq(SignatureProvider.class), anyString()))
.thenReturn(signatureProvider);
lenient().when(signatureProvider.signer(any())).thenReturn(signerContext);
lenient().when(signerContext.getKid()).thenReturn("test-kid");
lenient().when(signerContext.getAlgorithm()).thenReturn(Algorithm.RS256);

lenient().when(session.getKeycloakSessionFactory()).thenReturn(sessionFactory);
lenient().when(sessionFactory.create()).thenReturn(session);
lenient().when(session.getTransactionManager()).thenReturn(transactionManager);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.adorsys.keycloakstatuslist.config.StatusListConfig;
Expand All @@ -24,6 +23,7 @@
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.util.JsonSerialization;
import org.mockito.Mockito;

class CryptoIdentityServiceTest extends MockKeycloakTest {

Expand All @@ -42,8 +42,52 @@ void getActiveKeyShouldReturnCurrentSigningKey() {
assertNotNull(key.getPublicKey());
}

@Test
void getActiveKeyShouldPreferEs256OverRs256() throws Exception {
KeyPairGenerator ecGen = KeyPairGenerator.getInstance("EC");
ecGen.initialize(256);
KeyPair ecPair = ecGen.generateKeyPair();

KeyWrapper esKey = new KeyWrapper();
esKey.setKid("es-kid");
esKey.setAlgorithm(Algorithm.ES256);
esKey.setPublicKey(ecPair.getPublic()); // must be non-null for the shared resolver

KeyWrapper rsaKey = new KeyWrapper();
rsaKey.setKid("rsa-kid");
rsaKey.setAlgorithm(Algorithm.RS256);

Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
.thenReturn(esKey);
Mockito.lenient()
.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
.thenReturn(rsaKey);

KeyWrapper result = service.getActiveKey(realm);
assertEquals("es-kid", result.getKid());
assertEquals(Algorithm.ES256, result.getAlgorithm());
}

@Test
void getActiveKeyShouldFallbackToRs256WhenEs256Missing() {
KeyWrapper rsaKey = new KeyWrapper();
rsaKey.setKid("rsa-kid-fallback");
rsaKey.setAlgorithm(Algorithm.RS256);

Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
.thenReturn(null);
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
.thenReturn(rsaKey);

KeyWrapper result = service.getActiveKey(realm);
assertEquals("rsa-kid-fallback", result.getKid());
assertEquals(Algorithm.RS256, result.getAlgorithm());
}

@Test
void getActiveKeyShouldThrowWhenNoActiveSigningKey() {
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
.thenReturn(null);
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
.thenReturn(null);

Expand All @@ -53,7 +97,7 @@ void getActiveKeyShouldThrowWhenNoActiveSigningKey() {

@Test
void getJwtTokenShouldContainExpectedIssuerClaim() throws Exception {
when(realm.getAttribute(StatusListConfig.STATUS_LIST_TOKEN_ISSUER_PREFIX))
Mockito.when(realm.getAttribute(StatusListConfig.STATUS_LIST_TOKEN_ISSUER_PREFIX))
.thenReturn("issuer-prefix");
StatusListConfig config = new StatusListConfig(realm);

Expand All @@ -72,8 +116,10 @@ void getJwtTokenShouldContainExpectedIssuerClaim() throws Exception {
@Test
void getRealmKeyDataShouldFallbackToRs256WhenDefaultAlgMissing() throws Exception {
when(realm.getDefaultSignatureAlgorithm()).thenReturn(null);
// ES256 check fails
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
.thenReturn(null);
// Fallback to RS256
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
.thenReturn(RSATestUtils.getRsaKeyWrapper(testJwkResource("/keycloak-active-key-rsa.json")));

Expand All @@ -95,8 +141,8 @@ void getRealmKeyDataShouldSupportEcPublicKey() throws Exception {
ecKey.setAlgorithm(Algorithm.ES256);
ecKey.setPublicKey(ecPair.getPublic());

when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.ES256);
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.ES256);
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
.thenReturn(ecKey);

CryptoIdentityService.KeyData keyData = CryptoIdentityService.getRealmKeyData(session, realm);
Expand All @@ -109,8 +155,8 @@ void getRealmKeyDataShouldSupportEcPublicKey() throws Exception {

@Test
void getRealmKeyDataShouldThrowWhenNoActiveKeyFound() {
when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
.thenReturn(null);

StatusListException ex =
Expand All @@ -125,8 +171,8 @@ void getRealmKeyDataShouldThrowWhenPublicKeyMissing() {
keyWithoutPublicKey.setAlgorithm(Algorithm.RS256);
keyWithoutPublicKey.setPublicKey(null);

when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
.thenReturn(keyWithoutPublicKey);

StatusListException ex =
Expand All @@ -136,15 +182,15 @@ void getRealmKeyDataShouldThrowWhenPublicKeyMissing() {

@Test
void getRealmKeyDataShouldThrowForUnsupportedPublicKeyType() {
PublicKey unsupportedKey = mock(PublicKey.class);
PublicKey unsupportedKey = Mockito.mock(PublicKey.class);

KeyWrapper unsupported = new KeyWrapper();
unsupported.setKid("unsupported-kid");
unsupported.setAlgorithm(Algorithm.RS256);
unsupported.setPublicKey(unsupportedKey);

when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
.thenReturn(unsupported);

StatusListException ex =
Expand Down
10 changes: 10 additions & 0 deletions src/test/resources/keycloak-active-key-ec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"kty": "EC",
"kid": "test-ec-kid-valid",
"alg": "ES256",
"crv": "P-256",
"x": "e_yTAUIim6aEaCpg5P9ELpmV20Gv_KdYT6iPGCwab0c",
"y": "8JpCrzHirm2za1ClVKAXUxwEiVozUBq9bhhfjReQFcc",
"d": "bKiaoeHNPu6k-xzNree04SFZCzIDXuC10felpHBY6IU",
"use": "sig"
}