diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JwtHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JwtHelper.java index 3c2f20f2..79c4e074 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JwtHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JwtHelper.java @@ -4,7 +4,10 @@ package com.microsoft.aad.msal4j; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; import java.security.Signature; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; @@ -22,62 +25,183 @@ static ClientAssertion buildJwt(String clientId, final ClientCertificate credent ParameterValidationUtils.validateNotNull("credential", clientId); try { - final long time = System.currentTimeMillis(); - - // Build header - Map header = new HashMap<>(); - header.put("alg", "RS256"); - header.put("typ", "JWT"); - - if (sendX5c) { - List certs = new ArrayList<>(credential.getEncodedPublicKeyCertificateChain()); - header.put("x5c", certs); - } - - //SHA-256 is preferred, however certain flows still require SHA-1 due to what is supported server-side. If SHA-256 - // is not supported or the IClientCredential.publicCertificateHash256() method is not implemented, the library will default to SHA-1. - String hash256 = credential.publicCertificateHash256(); - if (useSha1 || hash256 == null) { - header.put("x5t", credential.publicCertificateHash()); - } else { - header.put("x5t#S256", hash256); + // First try with PS256 (preferred) + return generatePS256Jwt(clientId, credential, jwtAudience, sendX5c, useSha1); + } catch (InvalidKeyException e) { + // If the key isn't compatible with PSS, fall back to RS256. + // This is for backwards compatibility, as the Signature instance created with SHA256withRSA + // accepted key types that weren't RSAPrivateKey but the RSASSA-PSS signature does not. + try { + return generateRs256Jwt(clientId, credential, jwtAudience, sendX5c, useSha1); + } catch (Exception fallbackException) { + throw new MsalClientException(fallbackException); } + } catch (Exception e) { + throw new MsalClientException(e); + } + } - // Build payload - Map payload = new HashMap<>(); - payload.put("aud", jwtAudience); - payload.put("iss", clientId); - payload.put("jti", UUID.randomUUID().toString()); - payload.put("nbf", time / 1000); - payload.put("exp", time / 1000 + Constants.AAD_JWT_TOKEN_LIFETIME_SECONDS); - payload.put("sub", clientId); + /** + * Generates a JWT signed using the PS256 algorithm (RSASSA-PSS with SHA-256). + * + * @param clientId The client ID to use as the issuer and subject + * @param credential The certificate credential used for signing + * @param jwtAudience The audience claim for the JWT + * @param sendX5c Whether to include the x5c header with certificate chain + * @param useSha1 Whether to use SHA-1 hash for thumbprint instead of SHA-256 + * @return A ClientAssertion containing the signed JWT + * @throws Exception If JWT creation or signing fails + */ + private static ClientAssertion generatePS256Jwt(String clientId, ClientCertificate credential, + String jwtAudience, boolean sendX5c, + boolean useSha1) throws Exception { + // Build header with PS256 algorithm + Map header = createHeader(credential, sendX5c, useSha1, "PS256"); + + // Build payload + Map payload = createPayload(clientId, jwtAudience, System.currentTimeMillis()); + + // Encode header and payload + String jsonHeader = JsonHelper.writeJsonMap(header); + String jsonPayload = JsonHelper.writeJsonMap(payload); + String encodedHeader = base64UrlEncode(jsonHeader.getBytes(StandardCharsets.UTF_8)); + String encodedPayload = base64UrlEncode(jsonPayload.getBytes(StandardCharsets.UTF_8)); + String dataToSign = encodedHeader + "." + encodedPayload; + + // Sign with PS256 + byte[] signatureBytes = signWithPS256(credential, dataToSign); + String encodedSignature = base64UrlEncode(signatureBytes); + + // Build the JWT + String jwt = dataToSign + "." + encodedSignature; + return new ClientAssertion(jwt); + } - // Concatenate header and payload - String jsonHeader = JsonHelper.writeJsonMap(header); - String jsonPayload = JsonHelper.writeJsonMap(payload); + /** + * Generates a JWT signed using the RS256 algorithm (RSASSA-PKCS1-v1_5 with SHA-256). + * This is used as a fallback when PS256 is not supported by the private key. + * + * @param clientId The client ID to use as the issuer and subject + * @param credential The certificate credential used for signing + * @param jwtAudience The audience claim for the JWT + * @param sendX5c Whether to include the x5c header with certificate chain + * @param useSha1 Whether to use SHA-1 hash for thumbprint instead of SHA-256 + * @return A ClientAssertion containing the signed JWT + * @throws Exception If JWT creation or signing fails + */ + private static ClientAssertion generateRs256Jwt(String clientId, ClientCertificate credential, + String jwtAudience, boolean sendX5c, + boolean useSha1) throws Exception { + // Build header with RS256 algorithm + Map header = createHeader(credential, sendX5c, useSha1, "RS256"); + + // Build payload + Map payload = createPayload(clientId, jwtAudience, System.currentTimeMillis()); + + // Encode header and payload + String jsonHeader = JsonHelper.writeJsonMap(header); + String jsonPayload = JsonHelper.writeJsonMap(payload); + String encodedHeader = base64UrlEncode(jsonHeader.getBytes(StandardCharsets.UTF_8)); + String encodedPayload = base64UrlEncode(jsonPayload.getBytes(StandardCharsets.UTF_8)); + String dataToSign = encodedHeader + "." + encodedPayload; + + // Sign with RS256 + byte[] signatureBytes = signWithRS256(credential, dataToSign); + String encodedSignature = base64UrlEncode(signatureBytes); + + // Build the JWT + String jwt = dataToSign + "." + encodedSignature; + return new ClientAssertion(jwt); + } - String encodedHeader = base64UrlEncode(jsonHeader.getBytes(StandardCharsets.UTF_8)); - String encodedPayload = base64UrlEncode(jsonPayload.getBytes(StandardCharsets.UTF_8)); + /** + * Creates the JWT header with the specified algorithm and certificate information. + * + * @param credential The certificate credential containing thumbprint and chain + * @param sendX5c Whether to include the x5c header with certificate chain + * @param useSha1 Whether to use SHA-1 hash for thumbprint instead of SHA-256 + * @param algorithm The signing algorithm to specify in the header (PS256 or RS256) + * @return A map containing the JWT header claims + * @throws Exception If certificate operations fail + */ + private static Map createHeader(ClientCertificate credential, boolean sendX5c, + boolean useSha1, String algorithm) throws Exception { + Map header = new HashMap<>(); + header.put("alg", algorithm); + header.put("typ", "JWT"); + + if (sendX5c) { + List certs = new ArrayList<>(credential.getEncodedPublicKeyCertificateChain()); + header.put("x5c", certs); + } - // Create signature - String dataToSign = encodedHeader + "." + encodedPayload; + // SHA-256 is preferred, however certain flows still require SHA-1 + String hash256 = credential.publicCertificateHash256(); + if (useSha1 || hash256 == null) { + header.put("x5t", credential.publicCertificateHash()); + } else { + header.put("x5t#S256", hash256); + } - Signature sig = Signature.getInstance("SHA256withRSA"); - sig.initSign(credential.privateKey()); - sig.update(dataToSign.getBytes(StandardCharsets.UTF_8)); - byte[] signatureBytes = sig.sign(); + return header; + } - String encodedSignature = base64UrlEncode(signatureBytes); + /** + * Creates the JWT payload with standard claims. + * + * @param clientId The client ID to use as the issuer and subject + * @param audience The audience claim for the JWT + * @param time The current time in milliseconds + * @return A map containing the JWT payload claims + */ + private static Map createPayload(String clientId, String audience, long time) { + Map payload = new HashMap<>(); + payload.put("aud", audience); + payload.put("iss", clientId); + payload.put("jti", UUID.randomUUID().toString()); + payload.put("nbf", time / 1000); + payload.put("exp", time / 1000 + Constants.AAD_JWT_TOKEN_LIFETIME_SECONDS); + payload.put("sub", clientId); + return payload; + } - // Build the JWT - String jwt = dataToSign + "." + encodedSignature; + /** + * Signs data using the PS256 algorithm (RSASSA-PSS with SHA-256). + * + * @param credential The certificate credential containing the private key + * @param dataToSign The data to sign + * @return The signature bytes + * @throws Exception If signing fails + */ + private static byte[] signWithPS256(ClientCertificate credential, String dataToSign) throws Exception { + Signature sig = Signature.getInstance("RSASSA-PSS"); + sig.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)); + sig.initSign(credential.privateKey()); + sig.update(dataToSign.getBytes(StandardCharsets.UTF_8)); + return sig.sign(); + } - return new ClientAssertion(jwt); - } catch (final Exception e) { - throw new MsalClientException(e); - } + /** + * Signs data using the RS256 algorithm (RSASSA-PKCS1-v1_5 with SHA-256). + * + * @param credential The certificate credential containing the private key + * @param dataToSign The data to sign + * @return The signature bytes + * @throws Exception If signing fails + */ + private static byte[] signWithRS256(ClientCertificate credential, String dataToSign) throws Exception { + Signature sig = Signature.getInstance("SHA256withRSA"); + sig.initSign(credential.privateKey()); + sig.update(dataToSign.getBytes(StandardCharsets.UTF_8)); + return sig.sign(); } + /** + * Encodes bytes using Base64URL encoding without padding. + * + * @param data The data to encode + * @return The Base64URL encoded string + */ private static String base64UrlEncode(byte[] data) { return Base64.getUrlEncoder().withoutPadding().encodeToString(data); } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java index 08ca2e39..c2688426 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java @@ -3,11 +3,14 @@ package com.microsoft.aad.msal4j; +import com.nimbusds.jwt.SignedJWT; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; import java.security.cert.CertificateEncodingException; +import java.security.interfaces.RSAPrivateKey; import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -115,7 +118,7 @@ void JwtHelper_buildJwt_ValidSha1AndSha256Assertions() throws MsalClientExceptio // Decode and verify headers String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0])); - assertTrue(headerJson.contains("\"alg\":\"RS256\""), "Header should specify RS256 algorithm"); + assertTrue(headerJson.contains("\"alg\":\"PS256\""), "Header should specify RS256 algorithm"); assertTrue(headerJson.contains("\"typ\":\"JWT\""), "Header should specify JWT type"); assertTrue(headerJson.contains("\"x5t#S256\":\"certificateHash256\""), "Header should contain x5t#S256"); assertTrue(headerJson.contains("\"x5c\":[\"cert1\",\"cert2\"]"), "Header should contain x5c"); @@ -187,4 +190,99 @@ void JsonHelper_createIdTokenFromEncodedTokenString_InvalidJsonInToken() { assertEquals(AuthenticationErrorCode.INVALID_JSON, exception.errorCode()); } + + @Test + void JwtHelper_buildJwt_UsesPSS256WhenSupported() throws Exception { + // Create a certificate mock with an RSAPrivateKey that supports PSS + RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) TestHelper.getPrivateKey(); + + ClientCertificate clientCertificateMock = mock(ClientCertificate.class); + when(clientCertificateMock.privateKey()).thenReturn(rsaPrivateKey); + when(clientCertificateMock.publicCertificateHash()).thenReturn("certificateHash"); + when(clientCertificateMock.publicCertificateHash256()).thenReturn("certificateHash256"); + when(clientCertificateMock.getEncodedPublicKeyCertificateChain()).thenReturn(Arrays.asList("cert1", "cert2")); + + String clientId = "clientId"; + String audience = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + + // Create the JWT + ClientAssertion clientAssertion = JwtHelper.buildJwt(clientId, clientCertificateMock, audience, true, false); + + assertNotNull(clientAssertion); + String jwt = clientAssertion.assertion(); + String[] jwtParts = jwt.split("\\."); + assertEquals(3, jwtParts.length, "JWT should have three parts"); + + // Decode and verify header uses PS256 + String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0])); + assertTrue(headerJson.contains("\"alg\":\"PS256\""), "Header should specify PS256 algorithm"); + + // Parse the JWT to verify the algorithm is PS256 + SignedJWT signedJWT = SignedJWT.parse(jwt); + assertEquals("PS256", signedJWT.getHeader().getAlgorithm().getName(), "JWT should use PS256 algorithm"); + } + + @Test + void JwtHelper_buildJwt_FallsBackToRS256WhenPSSNotSupported() throws Exception { + // When loaded from the Windows-MY keystore the PrivateKey will be a sun.security.mscapi.CPrivateKey, + // which for some reason works with the library's older RS256 signature but not the newer PSS signature. + PrivateKey nonRsaCompatibleKey = TestHelper.getPrivateKeyFromKeystore(); + + // This key should cause the PSS code to fail with an InvalidKeyException + ClientCertificate clientCertificateMock = mock(ClientCertificate.class); + when(clientCertificateMock.privateKey()).thenReturn(nonRsaCompatibleKey); + when(clientCertificateMock.publicCertificateHash()).thenReturn("certificateHash"); + when(clientCertificateMock.publicCertificateHash256()).thenReturn("certificateHash256"); + when(clientCertificateMock.getEncodedPublicKeyCertificateChain()).thenReturn(Arrays.asList("cert1", "cert2")); + + String clientId = "clientId"; + String audience = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + + // Create the JWT - this should fallback to RS256 + ClientAssertion clientAssertion = JwtHelper.buildJwt(clientId, clientCertificateMock, audience, true, false); + + assertNotNull(clientAssertion); + String jwt = clientAssertion.assertion(); + String[] jwtParts = jwt.split("\\."); + assertEquals(3, jwtParts.length, "JWT should have three parts"); + + // Decode and verify header uses RS256 as fallback + String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0])); + assertTrue(headerJson.contains("\"alg\":\"RS256\""), "Header should specify RS256 algorithm as fallback"); + } + + @Test + void JwtHelper_buildJwt_UsesCorrectSignatureAlgorithmsBasedOnKeyType() throws Exception { + // Use real keys for both RSA and non-RSA tests + RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) TestHelper.getPrivateKey(); + PrivateKey nonRsaPrivateKey = TestHelper.privateKeyFromKeystore; + + ClientCertificate rsaCertMock = mock(ClientCertificate.class); + when(rsaCertMock.privateKey()).thenReturn(rsaPrivateKey); + when(rsaCertMock.publicCertificateHash256()).thenReturn("certHash256"); + when(rsaCertMock.getEncodedPublicKeyCertificateChain()).thenReturn(Arrays.asList("cert1", "cert2")); + + ClientCertificate nonRsaCertMock = mock(ClientCertificate.class); + when(nonRsaCertMock.privateKey()).thenReturn(nonRsaPrivateKey); + when(nonRsaCertMock.publicCertificateHash256()).thenReturn("certHash256"); + when(nonRsaCertMock.getEncodedPublicKeyCertificateChain()).thenReturn(Arrays.asList("cert1", "cert2")); + + String clientId = "clientId"; + String audience = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + + // Test RSA key -> should use PS256 + ClientAssertion rsaAssertion = JwtHelper.buildJwt(clientId, rsaCertMock, audience, true, false); + String rsaJwt = rsaAssertion.assertion(); + String rsaHeader = new String(Base64.getUrlDecoder().decode(rsaJwt.split("\\.")[0])); + assertTrue(rsaHeader.contains("\"alg\":\"PS256\""), "RSA key should produce PS256 algorithm"); + + // Test non-RSA key -> should fallback to RS256 + ClientAssertion nonRsaAssertion = JwtHelper.buildJwt(clientId, nonRsaCertMock, audience, true, false); + String nonRsaJwt = nonRsaAssertion.assertion(); + String nonRsaHeader = new String(Base64.getUrlDecoder().decode(nonRsaJwt.split("\\.")[0])); + assertTrue(nonRsaHeader.contains("\"alg\":\"RS256\""), "Non-RSA key should fallback to RS256 algorithm"); + + // Verify we're actually using different keys for the different tests + assertNotEquals(rsaJwt, nonRsaJwt, "The two assertions should be different"); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java index 6a01e86b..97255147 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java @@ -6,17 +6,30 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.math.BigInteger; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.security.*; import java.security.cert.X509Certificate; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; import java.util.Base64; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import sun.security.x509.AlgorithmId; +import sun.security.x509.CertificateAlgorithmId; +import sun.security.x509.CertificateSerialNumber; +import sun.security.x509.CertificateValidity; +import sun.security.x509.CertificateVersion; +import sun.security.x509.CertificateX509Key; +import sun.security.x509.X500Name; +import sun.security.x509.X509CertImpl; +import sun.security.x509.X509CertInfo; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.when; @@ -74,14 +87,19 @@ class TestHelper { "\"tid\": \"%s\"," + "\"ver\": \"2.0\"}"; - static X509Certificate x509Cert = getX509Cert(); - static PrivateKey privateKey = getPrivateKey(); - - public static String CERTIFICATE_ALIAS = "LabAuth.MSIDLab.com"; + private static final String CERTIFICATE_ALIAS = "LabAuth.MSIDLab.com"; private static final String WIN_KEYSTORE = "Windows-MY"; private static final String KEYSTORE_PROVIDER = "SunMSCAPI"; private static final String MAC_KEYSTORE = "KeychainStore"; + // Certificate and key storage - programmatically generated + static X509Certificate x509Cert = getX509Cert(); + static PrivateKey privateKey = getPrivateKey(); + + // Certificate and key storage - from system keystore + static X509Certificate x509CertFromKeystore = getX509CertFromKeystore(); + static PrivateKey privateKeyFromKeystore = getPrivateKeyFromKeystore(); + static String readResource(Class classInstance, String resource) { try { return new String(Files.readAllBytes(Paths.get(classInstance.getResource(resource).toURI()))); @@ -106,7 +124,7 @@ static String generateToken() { keyGen.initialize(2048); KeyPair keyPair = keyGen.generateKeyPair(); - String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"kid\":\"kid\"}"; + String header = "{\"alg\":\"PS256\",\"typ\":\"JWT\",\"kid\":\"kid\"}"; String encodedHeader = Base64.getUrlEncoder().withoutPadding() .encodeToString(header.getBytes(StandardCharsets.UTF_8)); @@ -115,7 +133,9 @@ static String generateToken() { .encodeToString(payload.getBytes(StandardCharsets.UTF_8)); String dataToSign = encodedHeader + "." + encodedPayload; - Signature signature = Signature.getInstance("SHA256withRSA"); + Signature signature = Signature.getInstance("RSASSA-PSS"); + signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)); + signature.initSign(keyPair.getPrivate()); signature.update(dataToSign.getBytes(StandardCharsets.UTF_8)); byte[] signatureBytes = signature.sign(); @@ -123,7 +143,7 @@ static String generateToken() { .encodeToString(signatureBytes); return encodedHeader + "." + encodedPayload + "." + encodedSignature; - } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException | InvalidAlgorithmParameterException e) { throw new RuntimeException("Error generating token: " + e.getMessage(), e); } } @@ -201,7 +221,60 @@ static String createIdToken(HashMap idTokenValues) { return String.format("someheader.%s.somesignature", encodedTokenValues); } - static void setPrivateKeyAndCert() { + /** + * Lazily generates an RSA key pair and certificate for testing. + * This produces a standard RSAPrivateKey that works with the PS256 algorithm. + */ + private static void setPrivateKeyAndCertFromKeyPair() { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); // 2048-bit key size + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + privateKey = keyPair.getPrivate(); + + x509Cert = generateCertificate(keyPair); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to generate key pair: " + e.getMessage(), e); + } + } + + /** + * Generate a self-signed X.509 certificate from a key pair + * @param keyPair The KeyPair to use for the certificate + * @return A self-signed X509Certificate + */ + private static X509Certificate generateCertificate(KeyPair keyPair) { + try { + X500Name issuerName = new X500Name("CN=TestIssuer"); + + Date startDate = new Date(); + // Certificate valid for 1 year + Date endDate = new Date(System.currentTimeMillis() + 365 * 24 * 60 * 60 * 1000L); + + X509CertInfo info = new X509CertInfo(); + info.set(X509CertInfo.VERSION, new CertificateVersion(2)); + info.set(X509CertInfo.SERIAL_NUMBER, new CertificateSerialNumber(new BigInteger(64, new SecureRandom()))); + info.set(X509CertInfo.ALGORITHM_ID, new CertificateAlgorithmId(AlgorithmId.get("SHA256withRSA"))); + info.set(X509CertInfo.SUBJECT, issuerName); + info.set(X509CertInfo.ISSUER, issuerName); + info.set(X509CertInfo.VALIDITY, new CertificateValidity(startDate, endDate)); + info.set(X509CertInfo.KEY, new CertificateX509Key(keyPair.getPublic())); + + X509CertImpl certificate = new X509CertImpl(info); + certificate.sign(keyPair.getPrivate(), "SHA256withRSA"); + + return certificate; + } catch (Exception e) { + throw new RuntimeException("Failed to generate certificate: " + e.getMessage(), e); + } + } + + /** + * Loads a key and certificate from the system keystore. + * This produces a special key type (typically CPrivateKey on Windows) that works with RS256 + * but not with PS256, making it useful for testing the fallback mechanism. + */ + private static void setPrivateKeyAndCertFromKeystore() { try { String os = System.getProperty("os.name"); KeyStore keystore; @@ -212,27 +285,68 @@ static void setPrivateKeyAndCert() { } keystore.load(null, null); - privateKey = (PrivateKey) keystore.getKey(CERTIFICATE_ALIAS, null); - x509Cert = (X509Certificate) keystore.getCertificate( + privateKeyFromKeystore = (PrivateKey) keystore.getKey(CERTIFICATE_ALIAS, null); + x509CertFromKeystore = (X509Certificate) keystore.getCertificate( CERTIFICATE_ALIAS); } catch (Exception e) { - throw new RuntimeException("Error getting certificate from keystore: " + e.getMessage()); + throw new RuntimeException("Error getting certificate from keystore: " + e.getMessage(), e); } } + /** + * Get a programmatically generated X509Certificate suitable for unit tests. + * This certificate works with both PS256 and RS256 algorithms. + * + * @return A self-signed X509Certificate + */ static X509Certificate getX509Cert() { if (x509Cert == null) { - setPrivateKeyAndCert(); + setPrivateKeyAndCertFromKeyPair(); } return x509Cert; } + /** + * Get a programmatically generated RSA private key suitable for unit tests. + * This key works with both PS256 and RS256 algorithms. + * + * @return An RSA private key + */ static PrivateKey getPrivateKey() { if (privateKey == null) { - setPrivateKeyAndCert(); + setPrivateKeyAndCertFromKeyPair(); } return privateKey; } + + /** + * Get a certificate from the system keystore. + * This is primarily used for end-to-end tests that need to connect to real services. + * + * @return An X509Certificate from the system keystore + */ + static X509Certificate getX509CertFromKeystore() { + if (x509CertFromKeystore == null) { + setPrivateKeyAndCertFromKeystore(); + } + + return x509CertFromKeystore; + } + + /** + * Get a private key from the system keystore. + * On Windows, this typically returns a CPrivateKey that works with RS256 + * but fails with PS256, making it useful for testing the algorithm fallback. + * + * @return A PrivateKey from the system keystore + */ + static PrivateKey getPrivateKeyFromKeystore() { + if (privateKeyFromKeystore == null) { + setPrivateKeyAndCertFromKeystore(); + } + + return privateKeyFromKeystore; + } }