Skip to content

Commit edff083

Browse files
Fix jjwt sub parsing (#872)
* Fix jjwt sub parsing * Improve JwtHelper test coverage above 0.90 Add tests covering createSignedJwt, the unrecognized PEM header branch, isSkdEnabled fall-through paths, the default constructor, and both Deserializer overloads. JwtHelper instruction coverage goes from 90% to 100% and branch coverage from 71% to 92%.
1 parent 7a0be9d commit edff083

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/jwt/JwtHelper.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import io.jsonwebtoken.Claims;
1010
import io.jsonwebtoken.JwtException;
1111
import io.jsonwebtoken.Jwts;
12+
import io.jsonwebtoken.io.DeserializationException;
13+
import io.jsonwebtoken.io.Deserializer;
1214
import org.apiguardian.api.API;
1315
import org.bouncycastle.asn1.pkcs.RSAPrivateKey;
1416
import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters;
@@ -19,6 +21,7 @@
1921

2022
import java.io.ByteArrayInputStream;
2123
import java.io.IOException;
24+
import java.io.Reader;
2225
import java.io.StringReader;
2326
import java.nio.charset.StandardCharsets;
2427
import java.security.GeneralSecurityException;
@@ -32,6 +35,7 @@
3235
import java.security.spec.PKCS8EncodedKeySpec;
3336
import java.util.Base64;
3437
import java.util.Date;
38+
import java.util.Map;
3539

3640
/**
3741
* JWT helper class, used to :
@@ -119,6 +123,7 @@ public static UserClaim validateJwt(String jwt, String certificate) throws AuthI
119123
try {
120124
final Claims claims = Jwts.parser()
121125
.verifyWith(x509Certificate.getPublicKey())
126+
.json(normalizeSubjectDeserializer())
122127
.build()
123128
.parseSignedClaims(jwt)
124129
.getPayload();
@@ -170,6 +175,39 @@ private static String dropBearer(String jwt) {
170175
return jwt.replace("Bearer ", "");
171176
}
172177

178+
@SuppressWarnings("unchecked")
179+
private static Deserializer<Map<String, ?>> normalizeSubjectDeserializer() {
180+
// jjwt 0.12+ strictly requires 'sub' to be a String (RFC 7519), but the pod may send it
181+
// as a numeric user ID (Long). Coerce it to String before jjwt validates registered claims.
182+
return new Deserializer<Map<String, ?>>() {
183+
@Override
184+
public Map<String, ?> deserialize(byte[] bytes) throws DeserializationException {
185+
try {
186+
return normalize(mapper.readValue(bytes, Map.class));
187+
} catch (IOException e) {
188+
throw new DeserializationException("Unable to deserialize JWT claims", e);
189+
}
190+
}
191+
192+
@Override
193+
public Map<String, ?> deserialize(Reader reader) throws DeserializationException {
194+
try {
195+
return normalize(mapper.readValue(reader, Map.class));
196+
} catch (IOException e) {
197+
throw new DeserializationException("Unable to deserialize JWT claims", e);
198+
}
199+
}
200+
201+
private Map<String, Object> normalize(Map<String, Object> map) {
202+
Object sub = map.get("sub");
203+
if (sub instanceof Number) {
204+
map.put("sub", sub.toString());
205+
}
206+
return map;
207+
}
208+
};
209+
}
210+
173211
private static PrivateKey parsePKCS1PrivateKey(String pemPrivateKey) throws GeneralSecurityException {
174212
try (final PemReader pemReader = new PemReader(new StringReader(pemPrivateKey))) {
175213
final PemObject privateKeyObject = pemReader.readPemObject();

symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/JwtHelperTest.java

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import com.migcomponents.migbase64.Base64;
1616
import io.jsonwebtoken.Jwts;
1717
import io.jsonwebtoken.SignatureAlgorithm;
18+
import io.jsonwebtoken.io.DeserializationException;
19+
import io.jsonwebtoken.io.Deserializer;
1820
import lombok.SneakyThrows;
1921
import lombok.extern.slf4j.Slf4j;
2022
import org.bouncycastle.asn1.ASN1Encodable;
@@ -24,16 +26,24 @@
2426
import org.bouncycastle.util.io.pem.PemWriter;
2527
import org.junit.jupiter.api.Test;
2628

29+
import java.io.ByteArrayInputStream;
2730
import java.io.FileInputStream;
31+
import java.io.IOException;
32+
import java.io.InputStreamReader;
33+
import java.io.Reader;
2834
import java.io.StringWriter;
35+
import java.lang.reflect.Method;
36+
import java.nio.charset.StandardCharsets;
2937
import java.security.GeneralSecurityException;
3038
import java.security.Key;
3139
import java.security.KeyPair;
3240
import java.security.KeyPairGenerator;
3341
import java.security.KeyStore;
3442
import java.security.PrivateKey;
43+
import java.security.Signature;
3544
import java.security.cert.Certificate;
3645
import java.util.Date;
46+
import java.util.Map;
3747

3848
/**
3949
* Test class for the {@link JwtHelper}.
@@ -141,6 +151,153 @@ public void testCheckSkdEnabled() {
141151
assertFalse(JwtHelper.isSkdEnabled("invalid.jwt.value"));
142152
}
143153

154+
/**
155+
* The Symphony pod sets 'sub' as a numeric userId (Long) which violates RFC 7519 but is the real
156+
* payload shape. jjwt 0.12+ rejects this unless we normalize it before claim validation.
157+
*/
158+
@Test
159+
@SneakyThrows
160+
public void testValidateJwtWithNumericSubject() {
161+
final KeyStore keyStore = getKeyStoreFromFile();
162+
final Certificate certificate = keyStore.getCertificate(CERT_ALIAS);
163+
final String certificatePem = java.util.Base64.getEncoder().encodeToString(certificate.getEncoded());
164+
final PrivateKey privateKey = (PrivateKey) keyStore.getKey(CERT_ALIAS, CERT_PASSWORD.toCharArray());
165+
166+
// Build header + payload manually so 'sub' is serialised as a JSON number (Long),
167+
// exactly as the Symphony pod does — jjwt builder would coerce it to String.
168+
String header = java.util.Base64.getUrlEncoder().withoutPadding()
169+
.encodeToString("{\"alg\":\"RS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));
170+
171+
long expiration = System.currentTimeMillis() / 1000 + 3600;
172+
String payloadJson = "{"
173+
+ "\"iss\":\"Symphony Communication Services LLC.\","
174+
+ "\"sub\":699220675788807,"
175+
+ "\"aud\":\"ai-agent-studio-local\","
176+
+ "\"user\":{"
177+
+ "\"id\":699220675788807,"
178+
+ "\"emailAddress\":\"test.user@symphony.com\","
179+
+ "\"firstName\":\"Test\","
180+
+ "\"lastName\":\"User\","
181+
+ "\"displayName\":\"Test User\","
182+
+ "\"company\":\"Test Company\","
183+
+ "\"companyId\":\"10175\","
184+
+ "\"username\":\"test.user@symphony.com\","
185+
+ "\"avatarUrl\":\"../avatars/static/150/default.png\","
186+
+ "\"avatarSmallUrl\":\"../avatars/static/50/default.png\""
187+
+ "},"
188+
+ "\"exp\":" + expiration
189+
+ "}";
190+
191+
String payload = java.util.Base64.getUrlEncoder().withoutPadding()
192+
.encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
193+
194+
String signingInput = header + "." + payload;
195+
Signature signer = Signature.getInstance("SHA256withRSA");
196+
signer.initSign(privateKey);
197+
signer.update(signingInput.getBytes(StandardCharsets.UTF_8));
198+
String signature = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(signer.sign());
199+
200+
String jwt = signingInput + "." + signature;
201+
202+
UserClaim userClaim = JwtHelper.validateJwt(jwt, certificatePem);
203+
204+
assertNotNull(userClaim);
205+
assertEquals(699220675788807L, userClaim.getId());
206+
assertEquals("test.user@symphony.com", userClaim.getEmailAddress());
207+
assertEquals("Test", userClaim.getFirstName());
208+
assertEquals("User", userClaim.getLastName());
209+
assertEquals("Test User", userClaim.getDisplayName());
210+
assertEquals("Test Company", userClaim.getCompany());
211+
assertEquals("10175", userClaim.getCompanyId());
212+
assertEquals("test.user@symphony.com", userClaim.getUsername());
213+
assertEquals("../avatars/static/150/default.png", userClaim.getAvatarUrl());
214+
assertEquals("../avatars/static/50/default.png", userClaim.getAvatarSmallUrl());
215+
}
216+
217+
@Test
218+
@SneakyThrows
219+
public void testCreateSignedJwt() {
220+
final KeyStore keyStore = getKeyStoreFromFile();
221+
final PrivateKey privateKey = (PrivateKey) keyStore.getKey(CERT_ALIAS, CERT_PASSWORD.toCharArray());
222+
223+
String jwt = JwtHelper.createSignedJwt("alice", JwtHelper.JWT_EXPIRATION_MILLIS, privateKey);
224+
225+
assertNotNull(jwt);
226+
// jjwt compact form must have exactly three dot-separated segments
227+
assertEquals(3, jwt.split("\\.").length);
228+
}
229+
230+
@Test
231+
void loadUnknownFormatPrivateKey() {
232+
String unknownFormat = "-----BEGIN SOMETHING ELSE-----\nabcdef\n-----END SOMETHING ELSE-----";
233+
GeneralSecurityException ex = assertThrows(
234+
GeneralSecurityException.class, () -> JwtHelper.parseRsaPrivateKey(unknownFormat));
235+
assertTrue(ex.getMessage().contains("Header not recognized"));
236+
}
237+
238+
@Test
239+
public void testIsSkdEnabledMissingClaim() {
240+
// JWT parses successfully but has no canUseSimplifiedKeyDelivery claim → final return false
241+
assertFalse(JwtHelper.isSkdEnabled(JWT));
242+
}
243+
244+
@Test
245+
public void testIsSkdEnabledNonBooleanClaim() {
246+
// canUseSimplifiedKeyDelivery present but as a string → falls through to final return false
247+
// payload: {"sub":"123","canUseSimplifiedKeyDelivery":"yes"}
248+
String jwtWithStringSkd =
249+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
250+
+ "eyJzdWIiOiIxMjMiLCJjYW5Vc2VTaW1wbGlmaWVkS2V5RGVsaXZlcnkiOiJ5ZXMifQ."
251+
+ "signature";
252+
assertFalse(JwtHelper.isSkdEnabled(jwtWithStringSkd));
253+
}
254+
255+
@Test
256+
public void testIsSkdEnabledTriggersCatch() {
257+
// Too few segments → extractDecodedClaims throws → catch returns false
258+
assertFalse(JwtHelper.isSkdEnabled("only.two"));
259+
}
260+
261+
@Test
262+
public void testJwtHelperConstructor() {
263+
// Cover the implicit default constructor of the utility class
264+
assertNotNull(new JwtHelper());
265+
}
266+
267+
@Test
268+
@SneakyThrows
269+
public void testNormalizeSubjectDeserializerByteArray() {
270+
// The byte[] overload is unreachable through jjwt's public API (Reader is used),
271+
// so exercise it directly via reflection to cover both the success and the
272+
// IOException catch path.
273+
Method method = JwtHelper.class.getDeclaredMethod("normalizeSubjectDeserializer");
274+
method.setAccessible(true);
275+
@SuppressWarnings("unchecked")
276+
Deserializer<Map<String, ?>> deserializer = (Deserializer<Map<String, ?>>) method.invoke(null);
277+
278+
Map<String, ?> result = deserializer.deserialize(
279+
"{\"sub\":12345,\"user\":{\"id\":42}}".getBytes(StandardCharsets.UTF_8));
280+
assertEquals("12345", result.get("sub"));
281+
282+
assertThrows(DeserializationException.class,
283+
() -> deserializer.deserialize("not-json".getBytes(StandardCharsets.UTF_8)));
284+
}
285+
286+
@Test
287+
@SneakyThrows
288+
public void testNormalizeSubjectDeserializerReaderIOException() {
289+
// Exercise the IOException catch in the Reader overload by feeding malformed JSON
290+
Method method = JwtHelper.class.getDeclaredMethod("normalizeSubjectDeserializer");
291+
method.setAccessible(true);
292+
@SuppressWarnings("unchecked")
293+
Deserializer<Map<String, ?>> deserializer = (Deserializer<Map<String, ?>>) method.invoke(null);
294+
295+
try (Reader reader = new InputStreamReader(
296+
new ByteArrayInputStream("not-json".getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)) {
297+
assertThrows(DeserializationException.class, () -> deserializer.deserialize(reader));
298+
}
299+
}
300+
144301
@SneakyThrows
145302
private static String generatePkcs8RsaPrivateKey() {
146303
final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");

0 commit comments

Comments
 (0)