|
15 | 15 | import com.migcomponents.migbase64.Base64; |
16 | 16 | import io.jsonwebtoken.Jwts; |
17 | 17 | import io.jsonwebtoken.SignatureAlgorithm; |
| 18 | +import io.jsonwebtoken.io.DeserializationException; |
| 19 | +import io.jsonwebtoken.io.Deserializer; |
18 | 20 | import lombok.SneakyThrows; |
19 | 21 | import lombok.extern.slf4j.Slf4j; |
20 | 22 | import org.bouncycastle.asn1.ASN1Encodable; |
|
24 | 26 | import org.bouncycastle.util.io.pem.PemWriter; |
25 | 27 | import org.junit.jupiter.api.Test; |
26 | 28 |
|
| 29 | +import java.io.ByteArrayInputStream; |
27 | 30 | import java.io.FileInputStream; |
| 31 | +import java.io.IOException; |
| 32 | +import java.io.InputStreamReader; |
| 33 | +import java.io.Reader; |
28 | 34 | import java.io.StringWriter; |
| 35 | +import java.lang.reflect.Method; |
| 36 | +import java.nio.charset.StandardCharsets; |
29 | 37 | import java.security.GeneralSecurityException; |
30 | 38 | import java.security.Key; |
31 | 39 | import java.security.KeyPair; |
32 | 40 | import java.security.KeyPairGenerator; |
33 | 41 | import java.security.KeyStore; |
34 | 42 | import java.security.PrivateKey; |
| 43 | +import java.security.Signature; |
35 | 44 | import java.security.cert.Certificate; |
36 | 45 | import java.util.Date; |
| 46 | +import java.util.Map; |
37 | 47 |
|
38 | 48 | /** |
39 | 49 | * Test class for the {@link JwtHelper}. |
@@ -141,6 +151,153 @@ public void testCheckSkdEnabled() { |
141 | 151 | assertFalse(JwtHelper.isSkdEnabled("invalid.jwt.value")); |
142 | 152 | } |
143 | 153 |
|
| 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 | + |
144 | 301 | @SneakyThrows |
145 | 302 | private static String generatePkcs8RsaPrivateKey() { |
146 | 303 | final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); |
|
0 commit comments