Skip to content

Commit 6781300

Browse files
authored
Merge pull request #74 from ADORSYS-GIS/fix/oid4vp-signatures-and-test-stability
Refine OID4VP JWS Signature Handling and Repair Unit Test Suite
2 parents ffead41 + 517830c commit 6781300

5 files changed

Lines changed: 182 additions & 30 deletions

File tree

src/main/java/com/adorsys/keycloakstatuslist/resource/CustomOIDCLoginProtocolFactory.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ private void triggerBackgroundRegistration(KeycloakSessionFactory factory, Strin
8787
try {
8888
RealmModel realm = bgSession.realms().getRealmByName(realmName);
8989
if (realm != null) {
90+
bgSession.getContext().setRealm(realm);
9091
ensureRealmRegistered(bgSession, realm);
9192
}
9293
bgSession.getTransactionManager().commit();

src/main/java/com/adorsys/keycloakstatuslist/service/CryptoIdentityService.java

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@
77
import java.security.interfaces.RSAPublicKey;
88
import java.util.HashMap;
99
import java.util.Map;
10-
import java.util.Optional;
1110
import org.jboss.logging.Logger;
1211
import org.keycloak.common.util.Time;
1312
import org.keycloak.crypto.Algorithm;
14-
import org.keycloak.crypto.AsymmetricSignatureSignerContext;
1513
import org.keycloak.crypto.KeyUse;
1614
import org.keycloak.crypto.KeyWrapper;
15+
import org.keycloak.crypto.SignatureProvider;
1716
import org.keycloak.jose.jwk.JWK;
1817
import org.keycloak.jose.jwk.JWKBuilder;
1918
import org.keycloak.jose.jws.JWSBuilder;
@@ -36,15 +35,42 @@ public CryptoIdentityService(KeycloakSession session) {
3635
this.session = session;
3736
}
3837

38+
/**
39+
* Resolves the active signing key for the given realm using a consistent fallback chain:
40+
* default algorithm → ES256 → RS256.
41+
*
42+
* <p>This is the single source of truth for key resolution and is used by both
43+
* {@link #getActiveKey} and {@link #getRealmKeyData} to ensure the JWT bearer token
44+
* is always signed with the same key that was registered as the issuer key.
45+
*/
46+
static KeyWrapper resolveActiveSigningKey(RealmModel realm, KeyManager keyManager) {
47+
String defaultAlg = realm.getDefaultSignatureAlgorithm();
48+
String algorithm = (defaultAlg == null || defaultAlg.isBlank()) ? Algorithm.ES256 : defaultAlg;
49+
50+
KeyWrapper activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, algorithm);
51+
52+
if (activeKey == null || activeKey.getPublicKey() == null) {
53+
if (!Algorithm.ES256.equals(algorithm)) {
54+
activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, Algorithm.ES256);
55+
}
56+
}
57+
58+
if (activeKey == null || activeKey.getPublicKey() == null) {
59+
activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, Algorithm.RS256);
60+
}
61+
return activeKey;
62+
}
63+
3964
/**
4065
* Retrieve the active signing key for the given realm.
66+
*
67+
* @throws IllegalStateException if no active signing key is found
4168
*/
4269
public KeyWrapper getActiveKey(RealmModel realm) {
43-
KeyWrapper activeKey = session.keys().getActiveKey(realm, KeyUse.SIG, "RS256");
70+
KeyWrapper activeKey = resolveActiveSigningKey(realm, session.keys());
4471
if (activeKey == null) {
4572
throw new IllegalStateException("No active signing key found for realm: " + realm.getName());
4673
}
47-
4874
return activeKey;
4975
}
5076

@@ -53,6 +79,12 @@ public KeyWrapper getActiveKey(RealmModel realm) {
5379
*/
5480
public String getJwtToken(StatusListConfig realmConfig) {
5581
KeyWrapper keyWrapper = getActiveKey(realmConfig.getRealm());
82+
String algorithm = keyWrapper.getAlgorithm() != null ? keyWrapper.getAlgorithm() : Algorithm.ES256;
83+
84+
SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, algorithm);
85+
if (signatureProvider == null) {
86+
throw new IllegalStateException("No SignatureProvider found for algorithm: " + algorithm);
87+
}
5688

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

6395
// Build and sign JWT
64-
return new JWSBuilder().jsonContent(payload).sign(new AsymmetricSignatureSignerContext(keyWrapper));
96+
return new JWSBuilder().jsonContent(payload).sign(signatureProvider.signer(keyWrapper));
6597
}
6698

6799
/**
68-
* Gets the realm's active signing key and converts it to JWK. Supports RSA and EC. accessible by
69-
* CredentialRevocationResourceProviderFactory.
100+
* Gets the realm's active signing key and converts it to JWK. Supports RSA and EC.
101+
* Accessible by CredentialRevocationResourceProviderFactory.
102+
*
103+
* <p>Uses {@link #resolveActiveSigningKey} to guarantee that the registered JWK
104+
* always matches the key used to sign the JWT bearer token.
70105
*/
71106
public static KeyData getRealmKeyData(KeycloakSession session, RealmModel realm) throws StatusListException {
72107
try {
73-
KeyManager keyManager = session.keys();
74-
75-
String algorithm =
76-
Optional.ofNullable(realm.getDefaultSignatureAlgorithm()).orElse(Algorithm.ES256);
77-
78-
KeyWrapper activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, algorithm);
79-
80-
if (activeKey == null || activeKey.getPublicKey() == null) {
81-
activeKey = keyManager.getActiveKey(realm, KeyUse.SIG, Algorithm.RS256);
82-
algorithm = Algorithm.RS256;
83-
}
108+
KeyWrapper activeKey = resolveActiveSigningKey(realm, session.keys());
84109

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

93118
PublicKey pubKey = (PublicKey) activeKey.getPublicKey();
94-
String finalAlg = activeKey.getAlgorithm() != null ? activeKey.getAlgorithm() : algorithm;
119+
String finalAlg = activeKey.getAlgorithm();
95120

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.adorsys.keycloakstatuslist.helpers;
2+
3+
import java.math.BigInteger;
4+
import java.security.AlgorithmParameters;
5+
import java.security.KeyFactory;
6+
import java.security.PrivateKey;
7+
import java.security.spec.ECGenParameterSpec;
8+
import java.security.spec.ECParameterSpec;
9+
import java.security.spec.ECPrivateKeySpec;
10+
import java.util.Map;
11+
import java.util.Objects;
12+
import org.keycloak.common.util.Base64Url;
13+
import org.keycloak.crypto.KeyType;
14+
import org.keycloak.crypto.KeyWrapper;
15+
import org.keycloak.jose.jwk.JWK;
16+
import org.keycloak.util.JWKSUtils;
17+
18+
public class ECTestUtils {
19+
20+
// Maps JWK crv names to Java ECGenParameterSpec names
21+
private static final Map<String, String> CRV_TO_EC_SPEC = Map.of(
22+
"P-256", "secp256r1",
23+
"P-384", "secp384r1",
24+
"P-521", "secp521r1");
25+
26+
public static KeyWrapper getEcKeyWrapper(JWK jwk) throws Exception {
27+
if (!KeyType.EC.equals(jwk.getKeyType())) {
28+
throw new IllegalArgumentException("Only EC keys are supported");
29+
}
30+
31+
KeyWrapper keyWrapper = JWKSUtils.getKeyWrapper(jwk);
32+
Objects.requireNonNull(keyWrapper);
33+
keyWrapper.setPrivateKey(getEcPrivateKey(jwk));
34+
35+
return keyWrapper;
36+
}
37+
38+
private static PrivateKey getEcPrivateKey(JWK jwk) throws Exception {
39+
String dEncoded = (String) jwk.getOtherClaims().get("d");
40+
if (dEncoded == null) {
41+
throw new IllegalArgumentException("Missing 'd' claim in EC JWK — cannot reconstruct private key");
42+
}
43+
byte[] dBytes = Base64Url.decode(dEncoded);
44+
BigInteger d = new BigInteger(1, dBytes);
45+
46+
// Read curve from JWK 'crv' field
47+
String crv = (String) jwk.getOtherClaims().get("crv");
48+
if (crv == null) {
49+
throw new IllegalArgumentException("Missing 'crv' claim in EC JWK");
50+
}
51+
String ecSpecName = CRV_TO_EC_SPEC.get(crv);
52+
if (ecSpecName == null) {
53+
throw new IllegalArgumentException("Unsupported EC curve: " + crv);
54+
}
55+
56+
AlgorithmParameters params = AlgorithmParameters.getInstance("EC");
57+
params.init(new ECGenParameterSpec(ecSpecName));
58+
ECParameterSpec ecParameters = params.getParameterSpec(ECParameterSpec.class);
59+
60+
ECPrivateKeySpec privateKeySpec = new ECPrivateKeySpec(d, ecParameters);
61+
KeyFactory keyFactory = KeyFactory.getInstance("EC");
62+
return keyFactory.generatePrivate(privateKeySpec);
63+
}
64+
}

src/test/java/com/adorsys/keycloakstatuslist/helpers/MockKeycloakTest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.adorsys.keycloakstatuslist.helpers;
22

33
import static org.mockito.ArgumentMatchers.any;
4+
import static org.mockito.ArgumentMatchers.anyString;
45
import static org.mockito.ArgumentMatchers.eq;
56
import static org.mockito.Mockito.lenient;
67
import static org.mockito.Mockito.mockStatic;
@@ -22,6 +23,8 @@
2223
import org.keycloak.connections.jpa.JpaConnectionProvider;
2324
import org.keycloak.crypto.Algorithm;
2425
import org.keycloak.crypto.KeyWrapper;
26+
import org.keycloak.crypto.SignatureProvider;
27+
import org.keycloak.crypto.SignatureSignerContext;
2528
import org.keycloak.jose.jwk.JWK;
2629
import org.keycloak.models.ClientModel;
2730
import org.keycloak.models.KeyManager;
@@ -82,6 +85,12 @@ public class MockKeycloakTest {
8285
@Mock
8386
protected CloseableHttpResponse httpResponse;
8487

88+
@Mock
89+
protected SignatureProvider signatureProvider;
90+
91+
@Mock
92+
protected SignatureSignerContext signerContext;
93+
8594
private MockedStatic<CustomHttpClient> mocked;
8695

8796
static KeyWrapper getActiveRsaKey() {
@@ -126,6 +135,13 @@ protected void rootSetup() {
126135
.when(keyManager.getActiveKey(any(), any(), eq(Algorithm.RS256)))
127136
.thenReturn(getActiveRsaKey());
128137

138+
lenient()
139+
.when(session.getProvider(eq(SignatureProvider.class), anyString()))
140+
.thenReturn(signatureProvider);
141+
lenient().when(signatureProvider.signer(any())).thenReturn(signerContext);
142+
lenient().when(signerContext.getKid()).thenReturn("test-kid");
143+
lenient().when(signerContext.getAlgorithm()).thenReturn(Algorithm.RS256);
144+
129145
lenient().when(session.getKeycloakSessionFactory()).thenReturn(sessionFactory);
130146
lenient().when(sessionFactory.create()).thenReturn(session);
131147
lenient().when(session.getTransactionManager()).thenReturn(transactionManager);

src/test/java/com/adorsys/keycloakstatuslist/service/CryptoIdentityServiceTest.java

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import static org.junit.jupiter.api.Assertions.assertThrows;
66
import static org.junit.jupiter.api.Assertions.assertTrue;
77
import static org.mockito.ArgumentMatchers.eq;
8-
import static org.mockito.Mockito.mock;
98
import static org.mockito.Mockito.when;
109

1110
import com.adorsys.keycloakstatuslist.config.StatusListConfig;
@@ -24,6 +23,7 @@
2423
import org.keycloak.crypto.KeyWrapper;
2524
import org.keycloak.jose.jws.JWSInput;
2625
import org.keycloak.util.JsonSerialization;
26+
import org.mockito.Mockito;
2727

2828
class CryptoIdentityServiceTest extends MockKeycloakTest {
2929

@@ -42,8 +42,52 @@ void getActiveKeyShouldReturnCurrentSigningKey() {
4242
assertNotNull(key.getPublicKey());
4343
}
4444

45+
@Test
46+
void getActiveKeyShouldPreferEs256OverRs256() throws Exception {
47+
KeyPairGenerator ecGen = KeyPairGenerator.getInstance("EC");
48+
ecGen.initialize(256);
49+
KeyPair ecPair = ecGen.generateKeyPair();
50+
51+
KeyWrapper esKey = new KeyWrapper();
52+
esKey.setKid("es-kid");
53+
esKey.setAlgorithm(Algorithm.ES256);
54+
esKey.setPublicKey(ecPair.getPublic()); // must be non-null for the shared resolver
55+
56+
KeyWrapper rsaKey = new KeyWrapper();
57+
rsaKey.setKid("rsa-kid");
58+
rsaKey.setAlgorithm(Algorithm.RS256);
59+
60+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
61+
.thenReturn(esKey);
62+
Mockito.lenient()
63+
.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
64+
.thenReturn(rsaKey);
65+
66+
KeyWrapper result = service.getActiveKey(realm);
67+
assertEquals("es-kid", result.getKid());
68+
assertEquals(Algorithm.ES256, result.getAlgorithm());
69+
}
70+
71+
@Test
72+
void getActiveKeyShouldFallbackToRs256WhenEs256Missing() {
73+
KeyWrapper rsaKey = new KeyWrapper();
74+
rsaKey.setKid("rsa-kid-fallback");
75+
rsaKey.setAlgorithm(Algorithm.RS256);
76+
77+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
78+
.thenReturn(null);
79+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
80+
.thenReturn(rsaKey);
81+
82+
KeyWrapper result = service.getActiveKey(realm);
83+
assertEquals("rsa-kid-fallback", result.getKid());
84+
assertEquals(Algorithm.RS256, result.getAlgorithm());
85+
}
86+
4587
@Test
4688
void getActiveKeyShouldThrowWhenNoActiveSigningKey() {
89+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
90+
.thenReturn(null);
4791
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
4892
.thenReturn(null);
4993

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

5498
@Test
5599
void getJwtTokenShouldContainExpectedIssuerClaim() throws Exception {
56-
when(realm.getAttribute(StatusListConfig.STATUS_LIST_TOKEN_ISSUER_PREFIX))
100+
Mockito.when(realm.getAttribute(StatusListConfig.STATUS_LIST_TOKEN_ISSUER_PREFIX))
57101
.thenReturn("issuer-prefix");
58102
StatusListConfig config = new StatusListConfig(realm);
59103

@@ -72,8 +116,10 @@ void getJwtTokenShouldContainExpectedIssuerClaim() throws Exception {
72116
@Test
73117
void getRealmKeyDataShouldFallbackToRs256WhenDefaultAlgMissing() throws Exception {
74118
when(realm.getDefaultSignatureAlgorithm()).thenReturn(null);
119+
// ES256 check fails
75120
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
76121
.thenReturn(null);
122+
// Fallback to RS256
77123
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
78124
.thenReturn(RSATestUtils.getRsaKeyWrapper(testJwkResource("/keycloak-active-key-rsa.json")));
79125

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

98-
when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.ES256);
99-
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
144+
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.ES256);
145+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.ES256)))
100146
.thenReturn(ecKey);
101147

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

110156
@Test
111157
void getRealmKeyDataShouldThrowWhenNoActiveKeyFound() {
112-
when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
113-
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
158+
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
159+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
114160
.thenReturn(null);
115161

116162
StatusListException ex =
@@ -125,8 +171,8 @@ void getRealmKeyDataShouldThrowWhenPublicKeyMissing() {
125171
keyWithoutPublicKey.setAlgorithm(Algorithm.RS256);
126172
keyWithoutPublicKey.setPublicKey(null);
127173

128-
when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
129-
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
174+
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
175+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
130176
.thenReturn(keyWithoutPublicKey);
131177

132178
StatusListException ex =
@@ -136,15 +182,15 @@ void getRealmKeyDataShouldThrowWhenPublicKeyMissing() {
136182

137183
@Test
138184
void getRealmKeyDataShouldThrowForUnsupportedPublicKeyType() {
139-
PublicKey unsupportedKey = mock(PublicKey.class);
185+
PublicKey unsupportedKey = Mockito.mock(PublicKey.class);
140186

141187
KeyWrapper unsupported = new KeyWrapper();
142188
unsupported.setKid("unsupported-kid");
143189
unsupported.setAlgorithm(Algorithm.RS256);
144190
unsupported.setPublicKey(unsupportedKey);
145191

146-
when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
147-
when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
192+
Mockito.when(realm.getDefaultSignatureAlgorithm()).thenReturn(Algorithm.RS256);
193+
Mockito.when(keyManager.getActiveKey(eq(realm), eq(KeyUse.SIG), eq(Algorithm.RS256)))
148194
.thenReturn(unsupported);
149195

150196
StatusListException ex =

0 commit comments

Comments
 (0)