diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/KeyInfo.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/KeyInfo.java index a371dcdf0d3..1dd28e49f61 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/KeyInfo.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/KeyInfo.java @@ -158,7 +158,7 @@ public Map getJwkMap() { result.put(JWKParameterNames.RSA_MODULUS, n); result.put(JWKParameterNames.RSA_EXPONENT, e); } else if (type == EC) { - result.putAll(jwk.toJSONObject()); + result.putAll(jwk.toPublicJWK().toJSONObject()); } return result; } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java index 4258aec00c2..d51a369a39d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; +import static org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKey.KeyType.EC; import static org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKey.KeyType.RSA; /** @@ -79,7 +80,7 @@ public ResponseEntity getKeys(Principal principal, */ public VerificationKeyResponse getKey(Principal principal) { KeyInfo key = keyInfoService.getActiveKey(); - if (!includeSymmetricalKeys(principal) && !RSA.name().equals(key.type())) { + if (!includeSymmetricalKeys(principal) && !isAsymmetricKey(key.type())) { throw new AccessDeniedException("You need to authenticate to see a shared key"); } return getVerificationKeyResponse(key); @@ -110,7 +111,7 @@ public VerificationKeysListResponse getKeys(Principal principal) { boolean includeSymmetric = includeSymmetricalKeys(principal); Map keys = keyInfoService.getKeys(); List keyResponses = keys.values().stream() - .filter(k -> includeSymmetric || RSA.name().equals(k.type())) + .filter(k -> includeSymmetric || isAsymmetricKey(k.type())) .map(TokenKeyEndpoint::getVerificationKeyResponse) .toList(); return new VerificationKeysListResponse(keyResponses); @@ -133,4 +134,7 @@ protected boolean includeSymmetricalKeys(Principal principal) { return false; } + private boolean isAsymmetricKey(String keyType) { + return RSA.name().equals(keyType) || EC.name().equals(keyType); + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/KeyInfoBuilderTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/KeyInfoBuilderTest.java index d59753a5b20..51e7ed9d230 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/KeyInfoBuilderTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/KeyInfoBuilderTest.java @@ -59,6 +59,11 @@ void whenProvidingECKey_ShouldBuildJwkMap() { KeyInfo keyInfo = KeyInfoBuilder.build("key-id", SAMPLE_EC_KEY_PAIR, "https://localhost"); assertThat(keyInfo.type()).isEqualTo("EC"); assertThat(keyInfo.algorithm()).isEqualTo("ES256"); - assertThat(keyInfo.getJwkMap()).hasSize(8); + // Should have size: 7 + // Base parameters: 4 (alg, use, kid, kty) + // EC public parameters: 3 (crv, x, y) + assertThat(keyInfo.getJwkMap()) + .hasSize(7) + .doesNotContainKey("d"); } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenKeyEndpointTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenKeyEndpointTests.java index a369e6c6a95..3110ab45cfd 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenKeyEndpointTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenKeyEndpointTests.java @@ -72,6 +72,17 @@ class TokenKeyEndpointTests { SkbkWTex/hl+l0wdNErz/yBxP8esbPukOUqks/if -----END RSA PRIVATE KEY-----"""; + private static final String SAMPLE_EC_KEY_PAIR = """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 + OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r + 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G + -----END PRIVATE KEY----- + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 + q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== + -----END PUBLIC KEY-----"""; + @BeforeEach void setUp() { validUaaResource = new UsernamePasswordAuthenticationToken("client_id", null, Collections.singleton(new SimpleGrantedAuthority("uaa.resource"))); @@ -266,6 +277,51 @@ private IdentityZone createAndSetTestZoneWithKeys(Map keys, Stri return zone; } + @Test + void ecKeyIsReturned() { + configureKeysForDefaultZone(Collections.singletonMap("ecKey1", SAMPLE_EC_KEY_PAIR)); + + // EC keys should be available to unauthenticated users (like RSA keys) + VerificationKeyResponse response = tokenKeyEndpoint.getKey(null); + + assertThat(response.getType()).isEqualTo("EC"); + assertThat(response.getAlgorithm()).isEqualTo("ES256"); + assertThat(response.getId()).isEqualTo("ecKey1"); + + // Verify the EC Key + Map keyProperties = response.getKeyProperties(); + assertThat(keyProperties).containsKeys("crv", "x", "y") + .doesNotContainKey("d"); + } + + @Test + void listResponseContainsECKeysWhenUnauthenticated() { + Map keys = new HashMap<>(); + keys.put("rsaKey", SIGNING_KEY_1); + keys.put("ecKey", SAMPLE_EC_KEY_PAIR); + keys.put("symmetricKey", "someSymmetricSecret"); + + configureKeysForDefaultZone(keys); + + // Unauthenticated users should get RSA and EC keys, but not symmetric keys + VerificationKeysListResponse response = tokenKeyEndpoint.getKeys(null); + + Map keysMap = response.getKeys().stream() + .collect(new MapCollector<>(VerificationKeyResponse::getId, k -> k)); + + assertThat(keysMap).hasSize(2) + // RSA + EC, no symmetric + .containsKeys("rsaKey", "ecKey") + .doesNotContainKey("symmetricKey"); + + // Verify EC key + VerificationKeyResponse ecKeyResponse = keysMap.get("ecKey"); + assertThat(ecKeyResponse.getType()).isEqualTo("EC"); + assertThat(ecKeyResponse.getKeyProperties()) + .containsKeys("crv", "x", "y") + .doesNotContainKey("d"); + } + private void configureKeysForDefaultZone(Map keys) { IdentityZoneProvisioning provisioning = mock(IdentityZoneProvisioning.class); IdentityZoneHolder.setProvisioning(provisioning); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java index aa6afdcbf20..bef554a8c91 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java @@ -29,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -74,6 +75,19 @@ class TokenKeyEndpointMockMvcTests { FYEQjpphGyQmtsqsOndL9zBvfQCp5oT4hukBc3yIR6GVXDi0UURVjKtlYMMD4O+f qwIDAQAB -----END PUBLIC KEY-----"""; + /** + * EC key pair - contains both public and private components + */ + private static final String EC_KEY_PAIR = """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 + OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r + 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G + -----END PRIVATE KEY----- + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 + q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== + -----END PUBLIC KEY-----"""; private UaaClientDetails defaultClient; private IdentityZone testZone; @@ -365,4 +379,69 @@ private void isUrlSafeBase64(String base64) { java.util.Base64.Decoder decoder = java.util.Base64.getUrlDecoder(); assertThat(encoder.encodeToString(decoder.decode(base64))).isEqualTo(base64); } + + @Test + void checkTokenKeys_withECPair() throws Exception { + // Set up zone with EC signing key + setSigningKeyAndDefaultClient(EC_KEY_PAIR); + + MvcResult result = mockMvc + .perform( + get("/token_keys") + .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", getBasicAuth(defaultClient)) + ) + .andDo(print()) // Print the full response for debugging + .andExpect(status().isOk()) + .andReturn(); + + // Parse the response + Map response = JsonUtils.readValue(result.getResponse().getContentAsString(), Map.class); + assertThat(response).containsKey("keys"); + + List> keys = (List>) response.get("keys"); + assertThat(keys).hasSize(1); + + Map ecKey = keys.getFirst(); + + // Verify EC key + assertThat(ecKey).containsEntry("kty", "EC") + .containsEntry("alg", "ES256") + .containsKeys("crv", "x", "y") + .doesNotContainKey("d") + // Verify expected size - should be 7 parameters + // Base parameters: 4 (alg, use, kid, kty) + // EC public parameters: 3 (crv, x, y) + .hasSize(7) + // Verify the key can be used for verification + .containsEntry("use", "sig"); + } + + @Test + void checkTokenKey_withECPair() throws Exception { + setSigningKeyAndDefaultClient(EC_KEY_PAIR); + + MvcResult result = mockMvc + .perform( + get("/token_key") + .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", getBasicAuth(defaultClient)) + ) + .andDo(print()) // Print the full response for debugging + .andExpect(status().isOk()) + .andReturn(); + + Map keyResponse = JsonUtils.readValue( + result.getResponse().getContentAsString(), + Map.class + ); + + // Verify EC Key + assertThat(keyResponse).containsEntry("kty", "EC") + .containsKeys("crv", "x", "y") + .doesNotContainKey("d") + .hasSize(7); + } }