diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7b10fed..13991d821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. ## 4.1.0 +- Support additional JWT signature algorithms in `JwtSignatureValidator`. In addition to the previously supported `RS256`, tokens signed with the following RSA-based algorithms (RFC 7518 §3.3 / §3.5) can now be validated: + - `RS384`, `RS512` (RSASSA-PKCS1-v1_5 with SHA-384 / SHA-512) + - `PS256`, `PS384`, `PS512` (RSASSA-PSS with SHA-256 / SHA-384 / SHA-512). The corresponding `PSSParameterSpec` is set automatically before signature verification. + - Selection is driven by the JWT header `alg` value. Unknown values continue to be rejected with the existing "is not supported" error. - Update dependencies: - Spring Boot: 4.0.6 → 4.1.0 - Spring Framework: 7.0.7 → 7.0.8 diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java index 4c6bf31f2..9c3073115 100644 --- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java @@ -5,21 +5,50 @@ */ package com.sap.cloud.security.token.validation.validators; +import jakarta.annotation.Nullable; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; + /** - * This is represented by "kty" (Key Type) Parameter. https://www.rfc-editor.org/rfc/rfc7518.html#section-6.1 + * JWT signature algorithms supported during token validation, as specified in + * RFC 7518 §3.1. + *
+ * Each entry carries: + *
+ * SPDX-License-Identifier: Apache-2.0 + */ +package com.sap.cloud.security.token.validation.validators; + +import com.sap.cloud.security.config.OAuth2ServiceConfiguration; +import com.sap.cloud.security.token.Token; +import com.sap.cloud.security.token.validation.ValidationResult; +import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mockito; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.AlgorithmParameterSpec; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link JwtSignatureValidator#validateSignature(Token, PublicKey, JwtSignatureAlgorithm)} + * accepts every algorithm declared in {@link JwtSignatureAlgorithm}. The test builds a real JWT-shaped + * input (header.payload.signature), signs it with a freshly generated key pair using the JCA algorithm + * the enum advertises, and feeds the result through {@code validateSignature}. + */ +class JwtSignatureAlgorithmTest { + + @Test + void enum_values_areTheExpectedSet() { + assertThat(JwtSignatureAlgorithm.values()) + .extracting(JwtSignatureAlgorithm::value) + .containsExactly("RS256", "RS384", "RS512", "PS256", "PS384", "PS512"); + } + + @Test + void fromValue_unknown_returnsNull() { + assertThat(JwtSignatureAlgorithm.fromValue("HS256")).isNull(); + } + + @Test + void fromType_rsa_returnsRs256() { + assertThat(JwtSignatureAlgorithm.fromType("RSA")).isEqualTo(JwtSignatureAlgorithm.RS256); + } + + @Test + void fromType_unknown_returnsNull() { + assertThat(JwtSignatureAlgorithm.fromType("EC")).isNull(); + assertThat(JwtSignatureAlgorithm.fromType("oct")).isNull(); + } + + @ParameterizedTest + @EnumSource(JwtSignatureAlgorithm.class) + void validateSignature_acceptsTokenSignedWithAlgorithm(JwtSignatureAlgorithm algorithm) throws Exception { + KeyPair keyPair = generateKeyPair(algorithm); + String signedToken = createSignedToken(algorithm, keyPair); + + Token token = Mockito.mock(Token.class); + Mockito.when(token.getTokenValue()).thenReturn(signedToken); + + ValidationResult result = new TestValidator(keyPair.getPublic()) + .validateSignature(token, keyPair.getPublic(), algorithm); + + assertThat(result.isValid()) + .as("validation should accept a token signed with %s", algorithm.value()) + .isTrue(); + } + + @ParameterizedTest + @EnumSource(JwtSignatureAlgorithm.class) + void validateSignature_rejectsTamperedToken(JwtSignatureAlgorithm algorithm) throws Exception { + KeyPair keyPair = generateKeyPair(algorithm); + String signedToken = createSignedToken(algorithm, keyPair); + // Flip a byte in the payload section to invalidate the signature + String[] parts = signedToken.split("\\."); + String tamperedPayload = parts[1].substring(0, parts[1].length() - 1) + + (parts[1].charAt(parts[1].length() - 1) == 'A' ? 'B' : 'A'); + String tampered = parts[0] + "." + tamperedPayload + "." + parts[2]; + + Token token = Mockito.mock(Token.class); + Mockito.when(token.getTokenValue()).thenReturn(tampered); + + ValidationResult result = new TestValidator(keyPair.getPublic()) + .validateSignature(token, keyPair.getPublic(), algorithm); + + assertThat(result.isValid()) + .as("tampered token must be rejected for %s", algorithm.value()) + .isFalse(); + } + + private static KeyPair generateKeyPair(JwtSignatureAlgorithm algorithm) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm.type()); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + private static String createSignedToken(JwtSignatureAlgorithm algorithm, KeyPair keyPair) throws Exception { + String header = base64Url(("{\"alg\":\"" + algorithm.value() + "\",\"typ\":\"JWT\"}") + .getBytes(StandardCharsets.UTF_8)); + String payload = base64Url("{\"sub\":\"test\"}".getBytes(StandardCharsets.UTF_8)); + String signingInput = header + "." + payload; + + Signature signature = Signature.getInstance(algorithm.javaSignature()); + AlgorithmParameterSpec parameterSpec = algorithm.parameterSpec(); + if (parameterSpec != null) { + signature.setParameter(parameterSpec); + } + signature.initSign(keyPair.getPrivate()); + signature.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signatureBytes = signature.sign(); + + return signingInput + "." + base64Url(signatureBytes); + } + + private static String base64Url(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Concrete {@link JwtSignatureValidator} subclass that bypasses the JWKS lookup so we can + * exercise {@code validateSignature} in isolation. + */ + private static final class TestValidator extends JwtSignatureValidator { + private final PublicKey publicKey; + + TestValidator(PublicKey publicKey) { + super(Mockito.mock(OAuth2ServiceConfiguration.class), + Mockito.mock(OAuth2TokenKeyServiceWithCache.class), + Mockito.mock(OidcConfigurationServiceWithCache.class)); + this.publicKey = publicKey; + } + + @Override + protected PublicKey getPublicKey(Token token, JwtSignatureAlgorithm algorithm) throws OAuth2ServiceException { + return publicKey; + } + } +} \ No newline at end of file