From fe24edc29292a99abd429b47ab4df676b9d46345 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:27:25 +0200 Subject: [PATCH 001/130] feat(model): add tls_client_auth constant and logic to ClientAuthentication --- .../uaa/constants/ClientAuthentication.java | 29 +++++++++---- .../constants/ClientAuthenticationTest.java | 41 +++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java index c1fe791bc4f..4bd57ac8b19 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java @@ -7,9 +7,8 @@ /** * ClientAuthentication constants are defined in OIDC core and discovery standard, e.g. https://openid.net/specs/openid-connect-registration-1_0.html * OIDC possible values are: client_secret_post, client_secret_basic, client_secret_jwt, private_key_jwt, and none - * UAA knows only: client_secret_post, client_secret_basic, private_key_jwt, and none + * UAA knows only: client_secret_post, client_secret_basic, private_key_jwt, none, and tls_client_auth * - * Planned: tls_client_auth */ public final class ClientAuthentication { @@ -20,8 +19,10 @@ private ClientAuthentication() { public static final String CLIENT_SECRET_POST = "client_secret_post"; public static final String PRIVATE_KEY_JWT = "private_key_jwt"; public static final String NONE = "none"; + public static final String TLS_CLIENT_AUTH = "tls_client_auth"; - public static final List UAA_SUPPORTED_METHODS = List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT); + public static final List UAA_SUPPORTED_METHODS = + List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT, TLS_CLIENT_AUTH); public static boolean secretNeeded(String method) { return method == null || CLIENT_SECRET_POST.equals(method) || CLIENT_SECRET_BASIC.equals(method); @@ -31,17 +32,25 @@ public static boolean isMethodSupported(String method) { return Optional.ofNullable(method).map(UAA_SUPPORTED_METHODS::contains).orElse(true); } + public static boolean isValidMethod(String method, boolean hasSecret, + boolean hasKeyConfiguration, boolean hasCaConfig) { + return isMethodSupported(method) && secretNeeded(method) && hasSecret && !hasKeyConfiguration && !hasCaConfig + || isMethodSupported(method) && PRIVATE_KEY_JWT.equals(method) && !hasSecret && hasKeyConfiguration && !hasCaConfig + || isMethodSupported(method) && TLS_CLIENT_AUTH.equals(method) && !hasSecret && !hasKeyConfiguration && hasCaConfig + || isMethodSupported(method) && (NONE.equals(method) || method == null) && !hasSecret && !hasKeyConfiguration && !hasCaConfig + || (method == null && (!hasSecret || !hasKeyConfiguration) && !hasCaConfig); + } + public static boolean isValidMethod(String method, boolean hasSecret, boolean hasKeyConfiguration) { - return isMethodSupported(method) && secretNeeded(method) && hasSecret && !hasKeyConfiguration || - isMethodSupported(method) && !secretNeeded(method) && !hasSecret || - (method == null && (!hasSecret || !hasKeyConfiguration)); + return isValidMethod(method, hasSecret, hasKeyConfiguration, false); } public static boolean isAuthMethodEqual(String method1, String method2) { return secretNeeded(method1) && secretNeeded(method2) || Objects.equals(method1, method2); } - public static String getCalculatedMethod(String method, boolean hasSecret, boolean hasKeyConfiguration) { + public static String getCalculatedMethod(String method, boolean hasSecret, + boolean hasKeyConfiguration, boolean hasCaConfig) { if (method != null && isMethodSupported(method)) { return method; } else { @@ -49,9 +58,15 @@ public static String getCalculatedMethod(String method, boolean hasSecret, boole return CLIENT_SECRET_BASIC; } else if (hasKeyConfiguration) { return PRIVATE_KEY_JWT; + } else if (hasCaConfig) { + return TLS_CLIENT_AUTH; } else { return NONE; } } } + + public static String getCalculatedMethod(String method, boolean hasSecret, boolean hasKeyConfiguration) { + return getCalculatedMethod(method, hasSecret, hasKeyConfiguration, false); + } } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java index 1f7350d733c..4af67ef5215 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java @@ -7,6 +7,7 @@ import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.CLIENT_SECRET_POST; import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.NONE; import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.PRIVATE_KEY_JWT; +import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.TLS_CLIENT_AUTH; class ClientAuthenticationTest { @@ -78,4 +79,44 @@ void isAuthMethodEqualFalse() { assertThat(ClientAuthentication.isAuthMethodEqual(PRIVATE_KEY_JWT, CLIENT_SECRET_BASIC)).isFalse(); assertThat(ClientAuthentication.isAuthMethodEqual(PRIVATE_KEY_JWT, NONE)).isFalse(); } + + @Test + void tlsClientAuthIsARecognisedMethod() { + assertThat(ClientAuthentication.isMethodSupported(TLS_CLIENT_AUTH)).isTrue(); + } + + @Test + void tlsClientAuthDoesNotRequireASecret() { + assertThat(ClientAuthentication.secretNeeded(TLS_CLIENT_AUTH)).isFalse(); + } + + @Test + void tlsClientAuthIsCalculatedWhenHasCaConfig() { + String method = ClientAuthentication.getCalculatedMethod(null, false, false, true); + assertThat(method).isEqualTo(TLS_CLIENT_AUTH); + } + + @Test + void tlsClientAuthIsValidMethodWhenHasCaConfig() { + assertThat(ClientAuthentication.isValidMethod( + TLS_CLIENT_AUTH, false, false, true)).isTrue(); + } + + @Test + void tlsClientAuthIsInvalidWithoutCaConfig() { + assertThat(ClientAuthentication.isValidMethod( + ClientAuthentication.TLS_CLIENT_AUTH, false, false, false)).isFalse(); + } + + @Test + void tlsClientAuthIsInvalidWhenHasSecret() { + assertThat(ClientAuthentication.isValidMethod( + ClientAuthentication.TLS_CLIENT_AUTH, true, false, true)).isFalse(); + } + + @Test + void tlsClientAuthIsInvalidWhenHasKeyConfig() { + assertThat(ClientAuthentication.isValidMethod( + ClientAuthentication.TLS_CLIENT_AUTH, false, true, true)).isFalse(); + } } From 91f18c9be3a3e1e3ce439ebbd87f10ebd18c1d79 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:37:17 +0200 Subject: [PATCH 002/130] feat(model): add CLIENT_AUTH_TLS_CLIENT_AUTH to TokenConstants --- .../cloudfoundry/identity/uaa/oauth/token/TokenConstants.java | 1 + 1 file changed, 1 insertion(+) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java index 0c1d28df850..7bf976d4fea 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java @@ -80,6 +80,7 @@ public static List getStringValues() { public static final String CLIENT_AUTH_EMPTY = "empty"; public static final String CLIENT_AUTH_SECRET = "secret"; public static final String CLIENT_AUTH_PRIVATE_KEY_JWT = ClientAuthentication.PRIVATE_KEY_JWT; + public static final String CLIENT_AUTH_TLS_CLIENT_AUTH = ClientAuthentication.TLS_CLIENT_AUTH; public static final String ID_TOKEN_HINT_PROMPT = "prompt"; public static final String ID_TOKEN_HINT_PROMPT_NONE = "none"; From 8d21fd4c48cd6de4c42f420513807c8a02b80ff2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:42:43 +0200 Subject: [PATCH 003/130] feat(model): add TlsClientAuthConfiguration model class --- .../client/TlsClientAuthConfiguration.java | 68 +++++++++++++++++++ .../TlsClientAuthConfigurationTest.java | 49 +++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java create mode 100644 model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java new file mode 100644 index 00000000000..7fc3235e700 --- /dev/null +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -0,0 +1,68 @@ +package org.cloudfoundry.identity.uaa.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class TlsClientAuthConfiguration { + + public static final String TLS_CLIENT_AUTH_CA = "tls-client-auth-ca"; + public static final String TLS_CLIENT_AUTH_CLAIM_MAPPINGS = "tls-client-auth-claim-mappings"; + + @JsonProperty(TLS_CLIENT_AUTH_CA) + private String trustedCaPem; + + @JsonProperty(TLS_CLIENT_AUTH_CLAIM_MAPPINGS) + private List claimMappings; + + public TlsClientAuthConfiguration() {} + + public TlsClientAuthConfiguration(String trustedCaPem, List claimMappings) { + this.trustedCaPem = trustedCaPem; + this.claimMappings = claimMappings; + } + + public String getTrustedCaPem() { return trustedCaPem; } + public void setTrustedCaPem(String trustedCaPem) { this.trustedCaPem = trustedCaPem; } + + public List getClaimMappings() { return claimMappings; } + public void setClaimMappings(List claimMappings) { this.claimMappings = claimMappings; } + + public static boolean isConfigured(TlsClientAuthConfiguration config) { + return config != null && config.getTrustedCaPem() != null && !config.getTrustedCaPem().isBlank(); + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ClaimMapping { + + @JsonProperty("field") + private String field; + + @JsonProperty("pattern") + private String pattern; + + @JsonProperty("claim") + private String claim; + + public ClaimMapping() {} + + public ClaimMapping(String field, String pattern, String claim) { + this.field = field; + this.pattern = pattern; + this.claim = claim; + } + + public String getField() { return field; } + public String getPattern() { return pattern; } + public String getClaim() { return claim; } + + public void setField(String field) { this.field = field; } + public void setPattern(String pattern) { this.pattern = pattern; } + public void setClaim(String claim) { this.claim = claim; } + } +} diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java new file mode 100644 index 00000000000..6fb9eeed1fc --- /dev/null +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -0,0 +1,49 @@ +package org.cloudfoundry.identity.uaa.client; + +import tools.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TlsClientAuthConfigurationTest { + + private static final String EXAMPLE_CA = "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"; + + @Test + void roundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid")) + ); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getTrustedCaPem()).isEqualTo(EXAMPLE_CA); + assertThat(deserialized.getClaimMappings()).hasSize(1); + assertThat(deserialized.getClaimMappings().get(0).getClaim()).isEqualTo("app_guid"); + } + + @Test + void nullCaMeansNotConfigured() { + assertThat(TlsClientAuthConfiguration.isConfigured(null)).isFalse(); + assertThat(TlsClientAuthConfiguration.isConfigured(new TlsClientAuthConfiguration(null, null))).isFalse(); + } + + @Test + void nonNullCaMeansConfigured() { + assertThat(TlsClientAuthConfiguration.isConfigured( + new TlsClientAuthConfiguration(EXAMPLE_CA, null))).isTrue(); + } + + @Test + void claimMappingWithoutPatternUsesFieldDirectly() { + TlsClientAuthConfiguration.ClaimMapping mapping = + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "instance_guid"); + assertThat(mapping.getPattern()).isNull(); + assertThat(mapping.getClaim()).isEqualTo("instance_guid"); + } +} From 76498615fe726b343af1473fc4c9d39ff71dd06d Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:52:24 +0200 Subject: [PATCH 004/130] feat(model): add tlsClientAuthConfiguration field to UaaClientDetails Add TlsClientAuthConfiguration field serialized as tls-client-auth-ca in client JSON, following the clientJwtConfig pattern. Includes getter/setter, copy constructor support, equals/hashCode. Fix fragile isPositive() hash code assertion to isNotZero(). --- .../identity/uaa/client/UaaClientDetails.java | 19 ++++++++++++++++++- .../uaa/client/UaaClientDetailsTest.java | 19 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index 92418c12927..bb39a34c602 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java @@ -39,6 +39,7 @@ * * Extended this class with fields * - client_jwt_config (supporting private_key_jwt) + * - tls-client-auth-ca (supporting RFC 8705 mTLS client authentication) */ @JsonInclude(JsonInclude.Include.NON_DEFAULT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -86,6 +87,9 @@ public class UaaClientDetails implements ClientDetails { @JsonProperty("client_jwt_config") private String clientJwtConfig; + @JsonProperty(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + private TlsClientAuthConfiguration tlsClientAuthConfiguration; + public UaaClientDetails() { } @@ -103,6 +107,7 @@ public UaaClientDetails(ClientDetails prototype) { this.setAdditionalInformation(prototype.getAdditionalInformation()); if (prototype instanceof UaaClientDetails uaa) { this.setClientJwtConfig(uaa.getClientJwtConfig()); + this.setTlsClientAuthConfiguration(uaa.getTlsClientAuthConfiguration()); } } @@ -302,6 +307,14 @@ public void setClientJwtConfig(String clientJwtConfig) { this.clientJwtConfig = clientJwtConfig; } + public TlsClientAuthConfiguration getTlsClientAuthConfiguration() { + return tlsClientAuthConfiguration; + } + + public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAuthConfiguration) { + this.tlsClientAuthConfiguration = tlsClientAuthConfiguration; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -344,7 +357,10 @@ public boolean equals(Object obj) { if (!Objects.equals(additionalInformation, other.additionalInformation)) { return false; } - return Objects.equals(clientJwtConfig, other.clientJwtConfig); + if (!Objects.equals(clientJwtConfig, other.clientJwtConfig)) { + return false; + } + return Objects.equals(tlsClientAuthConfiguration, other.tlsClientAuthConfiguration); } @Override @@ -378,6 +394,7 @@ public int hashCode() { result = prime * result + (scope == null ? 0 : scope.hashCode()); result = prime * result + (additionalInformation == null ? 0 : additionalInformation.hashCode()); result = prime * result + (clientJwtConfig == null ? 0 : clientJwtConfig.hashCode()); + result = prime * result + (tlsClientAuthConfiguration == null ? 0 : tlsClientAuthConfiguration.hashCode()); return result; } } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java index f05aab0ac8b..0aaaa803c15 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java @@ -208,6 +208,23 @@ void isSecretRequired() { assertThat(details.isSecretRequired()).isFalse(); } + @Test + void tlsClientAuthConfigRoundTripsViaJson() throws Exception { + UaaClientDetails details = new UaaClientDetails(); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + null + ); + details.setTlsClientAuthConfiguration(config); + + String json = new JsonMapper().writeValueAsString(details); + UaaClientDetails deserialized = new JsonMapper().readValue(json, UaaClientDetails.class); + + assertThat(deserialized.getTlsClientAuthConfiguration()).isNotNull(); + assertThat(deserialized.getTlsClientAuthConfiguration().getTrustedCaPem()) + .isEqualTo(config.getTrustedCaPem()); + } + @Test void autoApprove() { UaaClientDetails details = new UaaClientDetails(); @@ -221,7 +238,7 @@ void testHashCode() { uaaClientDetails.setRegisteredRedirectUri(Set.of("http://localhost:8080/uaa")); uaaClientDetails.setRefreshTokenValiditySeconds(1); uaaClientDetails.setAccessTokenValiditySeconds(1); - assertThat(uaaClientDetails.hashCode()).isPositive(); + assertThat(uaaClientDetails.hashCode()).isNotZero(); } } From 95a01db1941f254a76b4e26447a8d9c7281c8a94 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:57:54 +0200 Subject: [PATCH 005/130] fix(model): add equals/hashCode to TlsClientAuthConfiguration and ClaimMapping --- .../client/TlsClientAuthConfiguration.java | 28 +++++++++++++++++++ .../TlsClientAuthConfigurationTest.java | 21 ++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java index 7fc3235e700..9486f635d8e 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import java.util.Objects; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @@ -32,6 +33,19 @@ public TlsClientAuthConfiguration(String trustedCaPem, List claimM public List getClaimMappings() { return claimMappings; } public void setClaimMappings(List claimMappings) { this.claimMappings = claimMappings; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TlsClientAuthConfiguration that)) return false; + return Objects.equals(trustedCaPem, that.trustedCaPem) && + Objects.equals(claimMappings, that.claimMappings); + } + + @Override + public int hashCode() { + return Objects.hash(trustedCaPem, claimMappings); + } + public static boolean isConfigured(TlsClientAuthConfiguration config) { return config != null && config.getTrustedCaPem() != null && !config.getTrustedCaPem().isBlank(); } @@ -64,5 +78,19 @@ public ClaimMapping(String field, String pattern, String claim) { public void setField(String field) { this.field = field; } public void setPattern(String pattern) { this.pattern = pattern; } public void setClaim(String claim) { this.claim = claim; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ClaimMapping that)) return false; + return Objects.equals(field, that.field) && + Objects.equals(pattern, that.pattern) && + Objects.equals(claim, that.claim); + } + + @Override + public int hashCode() { + return Objects.hash(field, pattern, claim); + } } } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java index 6fb9eeed1fc..c102cc3c26e 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -46,4 +46,25 @@ void claimMappingWithoutPatternUsesFieldDirectly() { assertThat(mapping.getPattern()).isNull(); assertThat(mapping.getClaim()).isEqualTo("instance_guid"); } + + @Test + void equalConfigurations() { + TlsClientAuthConfiguration a = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid")) + ); + TlsClientAuthConfiguration b = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid")) + ); + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + void unequalWhenCaDiffers() { + TlsClientAuthConfiguration a = new TlsClientAuthConfiguration("ca-a", null); + TlsClientAuthConfiguration b = new TlsClientAuthConfiguration("ca-b", null); + assertThat(a).isNotEqualTo(b); + } } From 5f187091248ddec8f190d241a18019b965a8c70c Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:04:12 +0200 Subject: [PATCH 006/130] feat(model): add mtls_endpoint_aliases and tls_client_auth to OpenIdConfiguration --- .../uaa/account/OpenIdConfiguration.java | 9 +++++++- .../uaa/account/OpenIdConfigurationTests.java | 23 ++++++++++++++++++- .../uaa/account/OpenIdConfiguration.json | 3 ++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java index 591525495bc..e78d6e51fb3 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java @@ -1,10 +1,13 @@ package org.cloudfoundry.identity.uaa.account; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import lombok.NoArgsConstructor; import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; +import java.util.Map; + @Data @NoArgsConstructor public class OpenIdConfiguration { @@ -19,7 +22,7 @@ public class OpenIdConfiguration { private String tokenUrl; @JsonProperty("token_endpoint_auth_methods_supported") - private String[] tokenAMR = new String[]{ClientAuthentication.CLIENT_SECRET_BASIC, ClientAuthentication.CLIENT_SECRET_POST, ClientAuthentication.PRIVATE_KEY_JWT}; + private String[] tokenAMR = new String[]{ClientAuthentication.CLIENT_SECRET_BASIC, ClientAuthentication.CLIENT_SECRET_POST, ClientAuthentication.PRIVATE_KEY_JWT, ClientAuthentication.TLS_CLIENT_AUTH}; @JsonProperty("token_endpoint_auth_signing_alg_values_supported") private String[] tokenEndpointAuthSigningValues = new String[]{"RS256", "HS256"}; @@ -67,6 +70,10 @@ public class OpenIdConfiguration { @JsonProperty("code_challenge_methods_supported") private String[] codeChallengeMethodsSupported = new String[]{"S256", "plain"}; + @JsonProperty("mtls_endpoint_aliases") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Map mtlsEndpointAliases; + public OpenIdConfiguration(final String contextPath, final String issuer) { this.issuer = issuer; this.authUrl = contextPath + "/oauth/authorize"; diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java b/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java index 6530d0206ad..2599ac13d64 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java @@ -7,6 +7,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.lang.reflect.Field; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -27,7 +28,7 @@ void defaultClaims() { assertThat(defaultConfig.getIssuer()).isEqualTo("issuer"); assertThat(defaultConfig.getAuthUrl()).isEqualTo("/uaa/oauth/authorize"); assertThat(defaultConfig.getTokenUrl()).isEqualTo("/uaa/oauth/token"); - assertThat(defaultConfig.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); + assertThat(defaultConfig.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"}); assertThat(defaultConfig.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); assertThat(defaultConfig.getUserInfoUrl()).isEqualTo("/uaa/userinfo"); assertThat(defaultConfig.getJwksUri()).isEqualTo("/uaa/token_keys"); @@ -65,4 +66,24 @@ void allNulls() throws Exception { assertThat(json.from("OpenIdConfiguration-nulls.json", this.getClass())) .hasEmptyJsonPathValue("issuer"); } + + @Test + void mtlsEndpointAliasesIsNullByDefault() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com"); + assertThat(conf.getMtlsEndpointAliases()).isNull(); + } + + @Test + void mtlsEndpointAliasesCanBeSet() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com"); + conf.setMtlsEndpointAliases(Map.of("token_endpoint", "https://uaa.example.com/oauth/mtls/token")); + assertThat(conf.getMtlsEndpointAliases()) + .containsEntry("token_endpoint", "https://uaa.example.com/oauth/mtls/token"); + } + + @Test + void tlsClientAuthIsInSupportedAuthMethods() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com"); + assertThat(conf.getTokenAMR()).contains("tls_client_auth"); + } } diff --git a/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json b/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json index 2a102387fae..b49d0e65b7c 100644 --- a/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json +++ b/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json @@ -5,7 +5,8 @@ "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post", - "private_key_jwt" + "private_key_jwt", + "tls_client_auth" ], "token_endpoint_auth_signing_alg_values_supported": [ "RS256", From 95fc065e17db062aa98e67493f5b235a10b3e4da Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:09:32 +0200 Subject: [PATCH 007/130] feat(server): add java-buildpack-client-certificate-mapper-jakarta dependency --- server/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/build.gradle.kts b/server/build.gradle.kts index e52e19f1537..14d3b62d5d2 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(libs.bouncyCastleTlsFips) implementation(libs.bouncyCastleUtilFips) + implementation("org.cloudfoundry:java-buildpack-client-certificate-mapper-jakarta:2.0.1") + implementation(libs.guava) implementation(libs.aspectJWeaver) From f88378032adf8ddbef5f5bff76c3be5c5147fcd7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:18:14 +0200 Subject: [PATCH 008/130] feat(server): register ClientCertificateMapper filter for /oauth/mtls/* --- .../SpringServletXmlFiltersConfiguration.java | 19 ++++++++++++++++++ .../ClientCertificateMapperFilterTest.java | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index b68afd44242..e8f4c25d865 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -231,4 +231,23 @@ public FilterRegistrationBean httpHeaderSecurityFilter bean.setEnabled(false); return bean; } + + @Bean + @SuppressWarnings({"unchecked", "rawtypes"}) + public FilterRegistrationBean clientCertificateMapperFilter() { + try { + Class mapperClass = Class.forName("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); + java.lang.reflect.Constructor ctor = mapperClass.getDeclaredConstructor(); + ctor.setAccessible(true); + jakarta.servlet.Filter mapper = (jakarta.servlet.Filter) ctor.newInstance(); + FilterRegistrationBean bean = new FilterRegistrationBean(mapper); + bean.addUrlPatterns("/oauth/mtls/*"); + bean.setOrder(10); + return bean; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to instantiate ClientCertificateMapper", e); + } catch (Exception e) { + throw new IllegalStateException("Failed to create ClientCertificateMapper filter", e); + } + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java new file mode 100644 index 00000000000..aaccf250717 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java @@ -0,0 +1,20 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.SpringServletXmlFiltersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; + +import static org.assertj.core.api.Assertions.assertThat; + +class ClientCertificateMapperFilterTest { + + @Test + void clientCertificateMapperFilter_registersClientCertificateMapperForMtlsEndpoint() { + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean bean = config.clientCertificateMapperFilter(); + assertThat(bean.getFilter().getClass().getName()) + .isEqualTo("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); + assertThat(bean.getUrlPatterns()).contains("/oauth/mtls/*"); + assertThat(bean.getOrder()).isLessThan(100); + } +} From ca390e33f71129871228704bb2e7c69c400971e4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:27:41 +0200 Subject: [PATCH 009/130] feat(server): add TlsClientAuthentication cert chain validation service --- .../oauth/tls/TlsClientAuthentication.java | 109 ++++++++++++++++++ .../tls/TlsClientAuthenticationTest.java | 41 +++++++ 2 files changed, 150 insertions(+) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java new file mode 100644 index 00000000000..33c047fac55 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -0,0 +1,109 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.http.HttpServletRequest; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.openssl.PEMParser; +import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.StringReader; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +/** + * Validates mTLS client certificates against a configured CA and extracts + * the certificate from the current HTTP request. + * + *

Analogous to {@link org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication} + * but for RFC 8705 mutual-TLS client authentication. + */ +@Component +public class TlsClientAuthentication { + + /** + * Returns the first X.509 certificate from the current request's + * {@code jakarta.servlet.request.X509Certificate} attribute + * (populated by the ClientCertificateMapper filter). + * + * @return the client certificate, or {@code null} if none is present + */ + public X509Certificate getCertificateFromRequest() { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return null; + } + HttpServletRequest request = attrs.getRequest(); + X509Certificate[] certs = (X509Certificate[]) + request.getAttribute("jakarta.servlet.request.X509Certificate"); + return (certs != null && certs.length > 0) ? certs[0] : null; + } + + /** + * Validates {@code clientCert} against the trusted CA PEM configured in {@code config} + * using PKIX path validation. + * + * @param clientCert the certificate presented by the client, may be {@code null} + * @param config the per-client TLS configuration, may be {@code null} + * @return {@code Optional.of(clientCert)} when validation succeeds; + * {@code Optional.empty()} when cert or config is absent + * @throws InvalidClientDetailsException if the CA PEM is malformed or the cert chain is invalid + */ + public Optional validateClientCert( + X509Certificate clientCert, TlsClientAuthConfiguration config) { + + if (clientCert == null || !TlsClientAuthConfiguration.isConfigured(config)) { + return Optional.empty(); + } + + try { + X509Certificate caCert = parsePemCertificate(config.getTrustedCaPem()); + + TrustAnchor anchor = new TrustAnchor(caCert, null); + PKIXParameters params = new PKIXParameters(Set.of(anchor)); + params.setRevocationEnabled(false); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + var certPath = cf.generateCertPath(Collections.singletonList(clientCert)); + + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + validator.validate(certPath, params); + + return Optional.of(clientCert); + + } catch (CertPathValidatorException e) { + throw new InvalidClientDetailsException( + "tls_client_auth: certificate chain validation failed: " + e.getMessage()); + } catch (Exception e) { + throw new InvalidClientDetailsException( + "tls_client_auth: CA configuration error: " + e.getMessage()); + } + } + + private static X509Certificate parsePemCertificate(String pem) throws Exception { + try (PEMParser parser = new PEMParser(new StringReader(pem))) { + Object obj = parser.readObject(); + if (!(obj instanceof X509CertificateHolder holder)) { + throw new IllegalArgumentException( + obj == null + ? "No PEM object found in tls-client-auth-ca" + : "PEM object is not a certificate: " + obj.getClass().getSimpleName()); + } + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java new file mode 100644 index 00000000000..6f1daa74d28 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -0,0 +1,41 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class TlsClientAuthenticationTest { + + private TlsClientAuthentication service; + + @BeforeEach + void setUp() { + service = new TlsClientAuthentication(); + } + + @Test + void nullCertReturnsEmptyOptional() { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("...", null); + assertThat(service.validateClientCert(null, config)).isEmpty(); + } + + @Test + void nullConfigReturnsEmptyOptional() { + X509Certificate cert = mock(X509Certificate.class); + assertThat(service.validateClientCert(cert, null)).isEmpty(); + } + + @Test + void invalidCaThrowsInvalidClientDetailsException() { + X509Certificate cert = mock(X509Certificate.class); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("not-a-cert", null); + assertThatThrownBy(() -> service.validateClientCert(cert, config)) + .hasMessageContaining("tls_client_auth"); + } +} From ee6201f77a81bd7e18ef77301c524b0a048acdc9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:43:52 +0200 Subject: [PATCH 010/130] feat(server): add isTlsClientAuth / validateTlsClientAuth to ClientDetailsAuthenticationProvider --- .../ClientDetailsAuthenticationProvider.java | 42 ++++++++++++++++++- .../beans/OauthEndpointBeanConfiguration.java | 7 +++- ...entDetailsAuthenticationProviderTests.java | 24 +++++++++++ .../UaaClientAuthenticationProviderTest.java | 5 ++- 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index 13b5cfbf5a0..d0eafea0b58 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -13,9 +13,11 @@ *******************************************************************************/ package org.cloudfoundry.identity.uaa.authentication; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClient; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -29,6 +31,7 @@ import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import java.security.cert.X509Certificate; import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -36,6 +39,7 @@ import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_EMPTY; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_NONE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_PRIVATE_KEY_JWT; +import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_TLS_CLIENT_AUTH; import static org.cloudfoundry.identity.uaa.util.UaaStringUtils.getSafeParameterValue; /** @@ -50,11 +54,14 @@ public class ClientDetailsAuthenticationProvider extends DaoAuthenticationProvider { private final JwtClientAuthentication jwtClientAuthentication; + private final TlsClientAuthentication tlsClientAuthentication; - public ClientDetailsAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder encoder, JwtClientAuthentication jwtClientAuthentication) { + public ClientDetailsAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder encoder, + JwtClientAuthentication jwtClientAuthentication, TlsClientAuthentication tlsClientAuthentication) { super(userDetailsService); setPasswordEncoder(encoder); this.jwtClientAuthentication = jwtClientAuthentication; + this.tlsClientAuthentication = tlsClientAuthentication; } @Override @@ -84,6 +91,12 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP error = new BadCredentialsException("Bad client_assertion type"); } break; + } else if (isTlsClientAuthPath(authentication.getDetails())) { + setAuthenticationMethod(authentication, CLIENT_AUTH_TLS_CLIENT_AUTH); + if (!validateTlsClientAuth(uaaClient)) { + error = new BadCredentialsException("tls_client_auth: certificate validation failed"); + } + break; } else { // set internally empty as client_auth_method e.g. cf client setAuthenticationMethod(authentication, CLIENT_AUTH_EMPTY); @@ -165,4 +178,31 @@ private boolean validatePrivateKeyJwt(Object uaaAuthenticationDetails, UaaClient return jwtClientAuthentication.validateClientJwt(getRequestParameters(getUaaAuthenticationDetails(uaaAuthenticationDetails)), uaaClient.getClientJwtConfiguration(), uaaClient.getUsername()); } + + static boolean isTlsClientAuthPath(Object uaaAuthenticationDetails) { + UaaAuthenticationDetails details = getUaaAuthenticationDetails(uaaAuthenticationDetails); + String path = details != null ? details.getRequestPath() : null; + return path != null && path.startsWith("/oauth/mtls"); + } + + private boolean validateTlsClientAuth(UaaClient uaaClient) { + X509Certificate cert = tlsClientAuthentication.getCertificateFromRequest(); + if (cert == null) { + return false; + } + TlsClientAuthConfiguration config = getTlsClientAuthConfiguration(uaaClient); + return tlsClientAuthentication.validateClientCert(cert, config).isPresent(); + } + + private static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaClient) { + Map info = uaaClient.getAdditionalInformation(); + if (info == null) { + return null; + } + Object rawConfig = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (rawConfig instanceof TlsClientAuthConfiguration cfg) { + return cfg; + } + return null; + } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java index 2eb7a881490..ef5396ce058 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java @@ -45,6 +45,7 @@ import org.cloudfoundry.identity.uaa.oauth.UaaTokenServices; import org.cloudfoundry.identity.uaa.oauth.UaaTokenStore; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.openid.IdTokenCreator; import org.cloudfoundry.identity.uaa.oauth.openid.IdTokenGranter; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2RequestFactory; @@ -452,12 +453,14 @@ ClientAuthenticationPublisher clientAuthenticationPublisher() { ClientDetailsAuthenticationProvider clientAuthenticationProvider( @Qualifier("clientDetailsUserService") UserDetailsService clientDetailsUserService, @Qualifier("cachingPasswordEncoder") PasswordEncoder cachingPasswordEncoder, - @Qualifier("jwtClientAuthentication") JwtClientAuthentication jwtClientAuthentication + @Qualifier("jwtClientAuthentication") JwtClientAuthentication jwtClientAuthentication, + TlsClientAuthentication tlsClientAuthentication ) { return new ClientDetailsAuthenticationProvider( clientDetailsUserService, cachingPasswordEncoder, - jwtClientAuthentication + jwtClientAuthentication, + tlsClientAuthentication ); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java new file mode 100644 index 00000000000..59af719d5fc --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -0,0 +1,24 @@ +package org.cloudfoundry.identity.uaa.authentication; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ClientDetailsAuthenticationProviderTests { + + @Test + void tlsClientAuthPathIsDetectedAsTlsClientAuth() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/mtls/token"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isTrue(); + } + + @Test + void regularTokenPathIsNotTlsClientAuth() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/token"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isFalse(); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java index 87f68db57b0..b822e9d9d84 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java @@ -7,6 +7,7 @@ import org.cloudfoundry.identity.uaa.client.UaaClientDetailsUserDetailsService; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.cloudfoundry.identity.uaa.user.UaaUser; import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; @@ -46,6 +47,7 @@ class UaaClientAuthenticationProviderTest { private ClientDetails client; private ClientDetailsAuthenticationProvider authenticationProvider; private JwtClientAuthentication jwtClientAuthentication; + private TlsClientAuthentication tlsClientAuthentication; @Autowired private NamedParameterJdbcTemplate namedJdbcTemplate; @@ -57,12 +59,13 @@ class UaaClientAuthenticationProviderTest { void setUpForClientTests() { IdentityZoneManager mockIdentityZoneManager = mock(IdentityZoneManager.class); jwtClientAuthentication = mock(JwtClientAuthentication.class); + tlsClientAuthentication = mock(TlsClientAuthentication.class); when(mockIdentityZoneManager.getCurrentIdentityZoneId()).thenReturn(IdentityZone.getUaaZoneId()); jdbcClientDetailsService = new MultitenantJdbcClientDetailsService(namedJdbcTemplate, mockIdentityZoneManager, passwordEncoder); UaaClientDetailsUserDetailsService clientDetailsService = new UaaClientDetailsUserDetailsService(jdbcClientDetailsService); client = createClient(); - authenticationProvider = new ClientDetailsAuthenticationProvider(clientDetailsService, passwordEncoder, jwtClientAuthentication); + authenticationProvider = new ClientDetailsAuthenticationProvider(clientDetailsService, passwordEncoder, jwtClientAuthentication, tlsClientAuthentication); } public UaaClientDetails createClient() { From b0c3b206961329aedf3d47c2b6a62d9a25f7b1ae Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:59:34 +0200 Subject: [PATCH 011/130] fix(server): deserialize TlsClientAuthConfiguration from additionalInformation Map --- .../ClientDetailsAuthenticationProvider.java | 12 +++++++-- ...entDetailsAuthenticationProviderTests.java | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index d0eafea0b58..b5ac28b606b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -15,6 +15,7 @@ import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClient; +import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; @@ -194,14 +195,21 @@ private boolean validateTlsClientAuth(UaaClient uaaClient) { return tlsClientAuthentication.validateClientCert(cert, config).isPresent(); } - private static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaClient) { + static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaClient) { Map info = uaaClient.getAdditionalInformation(); if (info == null) { return null; } Object rawConfig = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); if (rawConfig instanceof TlsClientAuthConfiguration cfg) { - return cfg; + return cfg; // in-memory client (tests) + } + if (rawConfig instanceof Map) { + try { + return JsonUtils.convertValue(rawConfig, TlsClientAuthConfiguration.class); + } catch (Exception e) { + return null; + } } return null; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java index 59af719d5fc..2b7c5a22319 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -1,7 +1,12 @@ package org.cloudfoundry.identity.uaa.authentication; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.client.UaaClient; import org.junit.jupiter.api.Test; +import java.util.HashMap; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -21,4 +26,25 @@ void regularTokenPathIsNotTlsClientAuth() { when(details.getRequestPath()).thenReturn("/oauth/token"); assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isFalse(); } + + @Test + void tlsConfigIsDeserializedFromRawMapInAdditionalInfo() { + // Simulate what happens when additionalInformation comes from the DB: + // the JSON is parsed to a LinkedHashMap, not TlsClientAuthConfiguration + Map rawMap = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n" + ); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, rawMap); + + UaaClient mockClient = mock(UaaClient.class); + when(mockClient.getAdditionalInformation()).thenReturn(additionalInfo); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(mockClient); + assertThat(config).isNotNull(); + assertThat(config.getTrustedCaPem()) + .isEqualTo("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"); + } } From 54437184b8906e86dc7794a53cd72cd321191601 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:04:35 +0200 Subject: [PATCH 012/130] feat(server): allow tls_client_auth in ClientCredentialsTokenGranter --- .../provider/client/ClientCredentialsTokenGranter.java | 7 ++++++- .../client/ClientCredentialsTokenGranterTests.java | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java index a8ee326e167..5390dfdec0d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java @@ -12,6 +12,7 @@ import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_PRIVATE_KEY_JWT; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_SECRET; +import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_TLS_CLIENT_AUTH; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; /** @@ -24,7 +25,11 @@ */ public class ClientCredentialsTokenGranter extends AbstractTokenGranter { - private static final List ALLOWED_AUTH_METHODS = List.of(CLIENT_AUTH_SECRET, CLIENT_AUTH_PRIVATE_KEY_JWT); + private static final List ALLOWED_AUTH_METHODS = List.of(CLIENT_AUTH_SECRET, CLIENT_AUTH_PRIVATE_KEY_JWT, CLIENT_AUTH_TLS_CLIENT_AUTH); + + public static boolean isAllowedAuthMethod(String method) { + return ALLOWED_AUTH_METHODS.contains(method); + } public ClientCredentialsTokenGranter(AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java index 1fba1b9eabc..e3976a08918 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java @@ -67,6 +67,12 @@ void grantNoToken() { assertThat(clientCredentialsTokenGranter.grant(TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS, tokenRequest)).isNull(); } + @Test + void tlsClientAuthIsAllowedForClientCredentials() { + assertThat(ClientCredentialsTokenGranter.isAllowedAuthMethod( + TokenConstants.CLIENT_AUTH_TLS_CLIENT_AUTH)).isTrue(); + } + @Test void grantNoSecretFails() { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("username", null, null); From 446a8a341d6e118bec2bb6e6bc428fbef5768b04 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:13:00 +0200 Subject: [PATCH 013/130] feat(server): add MtlsClaimsEnhancer UaaTokenEnhancer for cert-derived JWT claims --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 166 ++++++++++++++++++ .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 100 +++++++++++ 2 files changed, 266 insertions(+) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java new file mode 100644 index 00000000000..e59fec55c05 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -0,0 +1,166 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.oauth.UaaTokenEnhancer; +import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; +import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.security.auth.x500.X500Principal; +import java.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A {@link UaaTokenEnhancer} that enriches access tokens with claims derived from the + * mTLS client certificate presented during the {@code /oauth/mtls/token} flow. + * + *

When a client configured with {@code tls-client-auth} authenticates, this enhancer: + *

    + *
  • Maps certificate subject fields (CN, OU, O) to JWT claims as configured per-client.
  • + *
  • Adds a {@code cnf.x5t#S256} confirmation claim (RFC 8705 §3.1).
  • + *
+ * + *

Spring auto-wires this bean into + * {@link org.cloudfoundry.identity.uaa.oauth.UaaTokenServices#setUaaTokenEnhancers} via + * {@code @Autowired(required = false)}. + */ +@Component +public class MtlsClaimsEnhancer implements UaaTokenEnhancer { + + private static final Logger logger = LoggerFactory.getLogger(MtlsClaimsEnhancer.class); + + private final TlsClientAuthentication tlsClientAuthentication; + private final ClientDetailsService clientDetailsService; + + @Autowired + public MtlsClaimsEnhancer(TlsClientAuthentication tlsClientAuthentication, + ClientDetailsService clientDetailsService) { + this.tlsClientAuthentication = tlsClientAuthentication; + this.clientDetailsService = clientDetailsService; + } + + /** + * Not used — all enrichment is performed in {@link #enhance}. + */ + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + /** + * Returns a map of additional top-level JWT claims derived from the client certificate. + * Returns an empty map when no certificate is present on the request. + */ + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + X509Certificate cert = tlsClientAuthentication.getCertificateFromRequest(); + if (cert == null) { + return new HashMap<>(); + } + + String clientId = authentication.getOAuth2Request().getClientId(); + UaaClientDetails clientDetails; + try { + clientDetails = (UaaClientDetails) clientDetailsService.loadClientByClientId(clientId); + } catch (Exception e) { + logger.warn("MtlsClaimsEnhancer: failed to load client details for '{}': {}", clientId, e.getMessage()); + return new HashMap<>(); + } + + TlsClientAuthConfiguration config = clientDetails.getTlsClientAuthConfiguration(); + if (!TlsClientAuthConfiguration.isConfigured(config)) { + return new HashMap<>(); + } + + Map result = new HashMap<>(); + + // Apply per-client claim mappings from cert subject fields + if (config.getClaimMappings() != null) { + X500Principal subject = cert.getSubjectX500Principal(); + String dn = subject.getName(X500Principal.RFC2253); + String cn = extractRdnValue(dn, "CN="); + List ous = extractOus(dn); + + for (TlsClientAuthConfiguration.ClaimMapping mapping : config.getClaimMappings()) { + String value = switch (mapping.getField()) { + case "subject_cn" -> cn; + case "subject_ou" -> matchFirstOu(ous, mapping.getPattern()); + case "subject_o" -> extractRdnValue(dn, "O="); + default -> null; + }; + if (value != null && !value.isBlank()) { + result.put(mapping.getClaim(), value); + } + } + } + + // Always add cnf.x5t#S256 (RFC 8705 §3.1 confirmation claim) + try { + byte[] derEncoded = cert.getEncoded(); + byte[] sha256 = MessageDigest.getInstance("SHA-256").digest(derEncoded); + String thumbprint = Base64.getUrlEncoder().withoutPadding().encodeToString(sha256); + result.put("cnf", Map.of("x5t#S256", thumbprint)); + } catch (Exception ignored) { + // Silently skip cnf claim if cert encoding fails + } + + return result; + } + + /** + * Extracts the value of a single-valued RDN (e.g. {@code "CN="}) from a RFC 2253 DN string. + * Returns {@code null} if no matching RDN is found. + */ + private String extractRdnValue(String dn, String prefix) { + for (String rdn : dn.split(",")) { + String trimmed = rdn.trim(); + if (trimmed.startsWith(prefix)) { + return trimmed.substring(prefix.length()); + } + } + return null; + } + + /** + * Collects all OU values from a RFC 2253 DN string, in order. + */ + private List extractOus(String dn) { + List ous = new ArrayList<>(); + for (String rdn : dn.split(",")) { + String trimmed = rdn.trim(); + if (trimmed.startsWith("OU=")) { + ous.add(trimmed.substring(3)); + } + } + return ous; + } + + /** + * Returns the first captured group from the first OU that matches {@code patternStr}. + * When {@code patternStr} is null or blank, returns the first OU value verbatim. + */ + private String matchFirstOu(List ous, String patternStr) { + if (patternStr == null || patternStr.isBlank()) { + return ous.isEmpty() ? null : ous.get(0); + } + Pattern pat = Pattern.compile(patternStr); + for (String ou : ous) { + Matcher m = pat.matcher(ou); + if (m.matches() && m.groupCount() >= 1) { + return m.group(1); + } + } + return null; + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java new file mode 100644 index 00000000000..4c4d5b5a8bd --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -0,0 +1,100 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; +import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; +import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Request; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.security.auth.x500.X500Principal; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MtlsClaimsEnhancerTest { + + private TlsClientAuthentication tlsClientAuthentication; + private ClientDetailsService clientDetailsService; + private MtlsClaimsEnhancer enhancer; + + @BeforeEach + void setUp() { + tlsClientAuthentication = mock(TlsClientAuthentication.class); + clientDetailsService = mock(ClientDetailsService.class); + enhancer = new MtlsClaimsEnhancer(tlsClientAuthentication, clientDetailsService); + } + + @Test + void extractsClaimsFromCertOuFields() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getSubjectX500Principal()).thenReturn( + new X500Principal("CN=instance-guid, OU=app:app-guid-123, OU=space:space-guid-456, OU=organization:org-guid-789, O=Cloud Foundry")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "org_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsEntry("app_guid", "app-guid-123"); + assertThat(result).containsEntry("space_guid", "space-guid-456"); + assertThat(result).containsEntry("org_guid", "org-guid-789"); + assertThat(result).containsEntry("cf_instance_guid", "instance-guid"); + } + + @Test + void addsX5tThumbprintWhenCertPresent() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration( + new TlsClientAuthConfiguration("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", null)); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsKey("cnf"); + @SuppressWarnings("unchecked") + Map cnf = (Map) result.get("cnf"); + assertThat(cnf).containsKey("x5t#S256"); + } + + @Test + void returnsEmptyWhenNoCertOnRequest() { + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(null); + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + assertThat(result).doesNotContainKey("app_guid"); + } + + private OAuth2Authentication mockAuthentication(String clientId) { + OAuth2Request request = mock(OAuth2Request.class); + when(request.getClientId()).thenReturn(clientId); + OAuth2Authentication auth = mock(OAuth2Authentication.class); + when(auth.getOAuth2Request()).thenReturn(request); + return auth; + } +} From fa226eab7d30e3b493d48efbe86bbce2737ecc72 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:25:01 +0200 Subject: [PATCH 014/130] feat(server): add mtls_endpoint_aliases to OIDC discovery document --- .../uaa/account/OpenIdConnectEndpoints.java | 8 +++- .../account/OpenIdConnectEndpointsTest.java | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java index cd734dee90d..69b644e897f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java @@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.net.URISyntaxException; +import java.util.Map; import static org.springframework.http.HttpStatus.OK; @@ -18,6 +19,9 @@ public class OpenIdConnectEndpoints { private final String issuer; private final IdentityZoneManager identityZoneManager; + @Value("${uaa.mtls_endpoint_path:/oauth/mtls/token}") + private String mtlsEndpointPath = "/oauth/mtls/token"; + public OpenIdConnectEndpoints( final @Value("${issuer.uri}") String issuer, final IdentityZoneManager identityZoneManager @@ -31,7 +35,9 @@ public OpenIdConnectEndpoints( "/oauth/token/.well-known/openid-configuration" }) public ResponseEntity getOpenIdConfiguration(HttpServletRequest request) throws URISyntaxException { - OpenIdConfiguration conf = new OpenIdConfiguration(getServerContextPath(request), getTokenEndpoint()); + String contextPath = getServerContextPath(request); + OpenIdConfiguration conf = new OpenIdConfiguration(contextPath, getTokenEndpoint()); + conf.setMtlsEndpointAliases(Map.of("token_endpoint", contextPath + mtlsEndpointPath)); return new ResponseEntity<>(conf, OK); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java new file mode 100644 index 00000000000..8cd49cfdfef --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java @@ -0,0 +1,43 @@ +package org.cloudfoundry.identity.uaa.account; + +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OpenIdConnectEndpointsTest { + + private OpenIdConnectEndpoints endpoints; + private IdentityZoneManager mockIdentityZoneManager; + + @BeforeEach + void setUp() { + mockIdentityZoneManager = mock(IdentityZoneManager.class); + when(mockIdentityZoneManager.getCurrentIdentityZone()).thenReturn(IdentityZone.getUaa()); + endpoints = new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager); + } + + @Test + void mtlsEndpointAliasesIsPopulatedInDiscovery() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + ResponseEntity response = endpoints.getOpenIdConfiguration(request); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMtlsEndpointAliases()) + .isNotNull() + .containsKey("token_endpoint"); + assertThat(response.getBody().getMtlsEndpointAliases().get("token_endpoint")) + .endsWith("/oauth/mtls/token"); + } +} From 40515e5fe57af3c322c387079b26f3bde2f535bd Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:46:11 +0200 Subject: [PATCH 015/130] Fix @Value key in OpenIdConnectEndpoints to match ERB-emitted mtls.endpoint The BOSH ERB template emits 'mtls.endpoint' (from the nested mtls.endpoint YAML block) but the @Value annotation was reading 'uaa.mtls_endpoint_path', a key never emitted by the template. Align the annotation to the actual Spring property so operator-configured paths are honoured. --- .../identity/uaa/account/OpenIdConnectEndpoints.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java index 69b644e897f..8ea13f7f65b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java @@ -19,7 +19,7 @@ public class OpenIdConnectEndpoints { private final String issuer; private final IdentityZoneManager identityZoneManager; - @Value("${uaa.mtls_endpoint_path:/oauth/mtls/token}") + @Value("${mtls.endpoint:/oauth/mtls/token}") private String mtlsEndpointPath = "/oauth/mtls/token"; public OpenIdConnectEndpoints( From 396456850ee18d3a3a32eff5628c557c57c7c5ee Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 17:18:36 +0200 Subject: [PATCH 016/130] fix(server): add OAUTH_11 order constant and dedicated mTLS security filter chain Add FilterChainOrder.OAUTH_11 (211) so the mTLS security chain can be ordered before the OAUTH_10 catch-all token endpoint chain. Add mtlsTokenEndpointSecurity @Order(OAUTH_11) that: - matches /oauth/mtls/token - runs client-credentials authentication with tls_client_auth support - disables CSRF (stateless machine-to-machine endpoint) - uses BasicAuthenticationEntryPoint so Spring returns 401, not a redirect Without this chain the /oauth/mtls/token path falls through to the LoginSecurityConfiguration catch-all which rejects requests with CSRF-related 403 errors. --- .../OauthEndpointSecurityConfiguration.java | 34 +++++++++++++++++++ .../identity/uaa/web/FilterChainOrder.java | 1 + 2 files changed, 35 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java index be14d690012..b72711ded4f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java @@ -463,6 +463,40 @@ UaaFilterChain externalOAuthCallbackEndpointSecurity(HttpSecurity http) throws E return new UaaFilterChain(chain, "externalOAuthCallbackEndpointSecurity"); } + /** + * Security filter chain for the mTLS token endpoint ({@code /oauth/mtls/token}). + * + *

The {@code ClientCertificateMapper} servlet filter (registered separately) converts the + * {@code X-Forwarded-Client-Cert} header set by the Gorouter into a + * {@code jakarta.servlet.request.X509Certificate} request attribute before this chain runs. + * CSRF is disabled because this is a stateless machine-to-machine API endpoint. + */ + @Bean + @Order(FilterChainOrder.OAUTH_11) + UaaFilterChain mtlsTokenEndpointSecurity(HttpSecurity http) throws Exception { + SecurityFilterChain chain = http + .securityMatcher("/oauth/mtls/token", "/oauth/mtls/token/**") + .authenticationManager(clientAuthenticationManager) + .authorizeHttpRequests(auth -> { + auth.requestMatchers("/**").access(anyOf().fullyAuthenticated()); + auth.anyRequest().denyAll(); + }) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(getClientParameterAuthenticationFilter(), BasicAuthenticationFilter.class) + .addFilterAt(clientAuthenticationFilter.getFilter(), BasicAuthenticationFilter.class) + .addFilterAfter(tokenEndpointAuthenticationFilter.getFilter(), BasicAuthenticationFilter.class) + .anonymous(AnonymousConfigurer::disable) + .csrf(CsrfConfigurer::disable) + .exceptionHandling(exception -> + exception.authenticationEntryPoint(basicAuthenticationEntryPoint) + .accessDeniedHandler(oauthAccessDeniedHandler) + ) + .securityContext(sc -> sc.requireExplicitSave(false)) + .build(); + + return new UaaFilterChain(chain, "mtlsTokenEndpointSecurity"); + } + @Bean @Order(FilterChainOrder.OAUTH_10) UaaFilterChain oldAuthzEndpointSecurity(HttpSecurity http) throws Exception { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java index 749d68f4f21..2db781875d6 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java @@ -35,6 +35,7 @@ public class FilterChainOrder { public static final int OAUTH_08 = 208; public static final int OAUTH_09 = 209; public static final int OAUTH_10 = 210; + public static final int OAUTH_11 = 211; // scim-endpoints.xml: 300 public static final int SCIM_PASSWORD = 300; From f954da6fa15a74ffd19c576f56ad581c3e9dd194 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 17:18:50 +0200 Subject: [PATCH 017/130] fix(server): handle flat String PEM in getTlsClientAuthConfiguration; expose /oauth/mtls/token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getTlsClientAuthConfiguration previously handled only TlsClientAuthConfiguration (in-memory) and Map (Jackson-deserialized BOSH config stored as nested object). BOSH flat config stores tls-client-auth-ca as a plain PEM string and tls-client-auth-claim-mappings as a JSON array string — add an 'instanceof String pem' branch that parses both. UaaTokenEndpoint @RequestMapping previously covered only /oauth/token. After Gorouter sanitize_set was set, client authentication started succeeding for /oauth/mtls/token but Spring MVC returned 404 because no controller was mapped to that path. Add /oauth/mtls/token to the value array so the same endpoint handles both paths. --- .../ClientDetailsAuthenticationProvider.java | 16 ++++++++++++++++ .../uaa/oauth/token/UaaTokenEndpoint.java | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index b5ac28b606b..c1a550f563a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -32,8 +32,11 @@ import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import tools.jackson.core.type.TypeReference; + import java.security.cert.X509Certificate; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -211,6 +214,19 @@ static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaCli return null; } } + if (rawConfig instanceof String pem) { + try { + List claimMappings = null; + Object rawMappings = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings instanceof String mappingsJson) { + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } + return new TlsClientAuthConfiguration(pem, claimMappings); + } catch (Exception e) { + return null; + } + } return null; } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java index 66653097bc3..7ceb57ba397 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java @@ -31,7 +31,7 @@ import static org.springframework.util.StringUtils.hasText; @Controller -@RequestMapping(value = "/oauth/token") //used simply because TokenEndpoint wont match /oauth/token/alias/saml-entity-id +@RequestMapping(value = {"/oauth/token", "/oauth/mtls/token"}) //used simply because TokenEndpoint wont match /oauth/token/alias/saml-entity-id public class UaaTokenEndpoint extends TokenEndpoint { private final boolean allowQueryString; From 0ed40d48fdb731962c9d11e150fc4cbb6ebc9b1f Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 17:19:07 +0200 Subject: [PATCH 018/130] fix(server): fix MtlsClaimsEnhancer for DB-loaded clients and Diego multi-valued RDNs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented CF identity claims from appearing in tokens issued via /oauth/mtls/token: 1. DB-loaded clients: UaaClientDetails.getTlsClientAuthConfiguration() returns null for clients loaded via JDBC because the tlsClientAuthConfiguration field is only set through JSON deserialization, not through the JDBC row-mapper path (which populates additionalInformation instead). Switch to loadTlsConfig() which mirrors ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration and reads from additionalInformation directly, handling the TlsClientAuthConfiguration/Map/String cases. 2. Multi-valued RDNs in Diego instance-identity certs: Diego encodes all three OU attributes (app:, space:, organization:) as a single multi-valued RDN using '+' as separator (RFC 2253 §2.2). Splitting only on ',' left the entire '+OU=space:..+OU=org:..' string attached to the app GUID. Fix extractOus() and extractRdnValue() to split on '+' within each RDN component. --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index e59fec55c05..f026aa2b22c 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -5,10 +5,12 @@ import org.cloudfoundry.identity.uaa.oauth.UaaTokenEnhancer; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; +import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import tools.jackson.core.type.TypeReference; import javax.security.auth.x500.X500Principal; import java.security.MessageDigest; @@ -78,7 +80,7 @@ public Map enhance(Map claims, OAuth2Authenticat return new HashMap<>(); } - TlsClientAuthConfiguration config = clientDetails.getTlsClientAuthConfiguration(); + TlsClientAuthConfiguration config = loadTlsConfig(clientDetails.getAdditionalInformation()); if (!TlsClientAuthConfiguration.isConfigured(config)) { return new HashMap<>(); } @@ -120,13 +122,17 @@ public Map enhance(Map claims, OAuth2Authenticat /** * Extracts the value of a single-valued RDN (e.g. {@code "CN="}) from a RFC 2253 DN string. + * Handles multi-valued RDNs (attributes joined by {@code +}). * Returns {@code null} if no matching RDN is found. */ private String extractRdnValue(String dn, String prefix) { for (String rdn : dn.split(",")) { - String trimmed = rdn.trim(); - if (trimmed.startsWith(prefix)) { - return trimmed.substring(prefix.length()); + // Multi-valued RDNs use '+' to separate attribute-value pairs within one RDN + for (String attrVal : rdn.split("\\+")) { + String trimmed = attrVal.trim(); + if (trimmed.startsWith(prefix)) { + return trimmed.substring(prefix.length()); + } } } return null; @@ -134,13 +140,17 @@ private String extractRdnValue(String dn, String prefix) { /** * Collects all OU values from a RFC 2253 DN string, in order. + * Handles multi-valued RDNs (attributes joined by {@code +}). */ private List extractOus(String dn) { List ous = new ArrayList<>(); for (String rdn : dn.split(",")) { - String trimmed = rdn.trim(); - if (trimmed.startsWith("OU=")) { - ous.add(trimmed.substring(3)); + // Multi-valued RDNs use '+' to separate attribute-value pairs within one RDN + for (String attrVal : rdn.split("\\+")) { + String trimmed = attrVal.trim(); + if (trimmed.startsWith("OU=")) { + ous.add(trimmed.substring(3)); + } } } return ous; @@ -163,4 +173,40 @@ private String matchFirstOu(List ous, String patternStr) { } return null; } + + /** + * Builds a {@link TlsClientAuthConfiguration} from the client's {@code additionalInformation} map. + * Mirrors {@code ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration} so that + * DB-loaded clients (whose {@code tlsClientAuthConfiguration} field is null) are handled correctly. + */ + private static TlsClientAuthConfiguration loadTlsConfig(Map info) { + if (info == null) { + return null; + } + Object raw = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (raw instanceof TlsClientAuthConfiguration cfg) { + return cfg; // in-memory / test client + } + if (raw instanceof Map) { + try { + return JsonUtils.convertValue(raw, TlsClientAuthConfiguration.class); + } catch (Exception e) { + return null; + } + } + if (raw instanceof String pem) { + try { + List claimMappings = null; + Object rawMappings = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings instanceof String mappingsJson) { + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } + return new TlsClientAuthConfiguration(pem, claimMappings); + } catch (Exception e) { + return null; + } + } + return null; + } } From 08da0cd08d0dfd5a0026489f1fadaa77b2d20c1d Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 6 Jul 2026 09:45:50 +0200 Subject: [PATCH 019/130] fix(review): address PR feedback on mTLS client auth - UaaClientDetails.setTlsClientAuthConfiguration() now syncs to additionalInformation so JDBC-loaded clients see the typed value - MtlsClaimsEnhancer checks the typed getTlsClientAuthConfiguration() field first, falls back to loadTlsConfig(additionalInformation) - ClientDetailsAuthenticationProvider and MtlsClaimsEnhancer now handle rawMappings instanceof List (Jackson parses JSON arrays natively from JDBC; not always a String) - Move java-buildpack-client-certificate-mapper-jakarta to Gradle version catalog (libs.versions.toml) - SpringServletXmlFiltersConfiguration: remove class-level @SuppressWarnings and raw FilterRegistrationBean; add comment explaining why reflection is required (ClientCertificateMapper is package-private) --- gradle/libs.versions.toml | 4 ++++ .../identity/uaa/client/UaaClientDetails.java | 7 +++++++ server/build.gradle.kts | 2 +- .../uaa/SpringServletXmlFiltersConfiguration.java | 15 +++++++++------ .../ClientDetailsAuthenticationProvider.java | 6 ++++++ .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 13 ++++++++++++- 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6efe2c7c54..047ab15374a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ jacoco = "4.0.2" jackson = "3.1.5" jackson2 = "2.21.5" jacocoAgent = "0.8.15" +javaBuildpackClientCertificateMapper = "2.0.1" nimbusJwt = "10.9.1" opensaml = "5.2.3" orgJson = "20260719" @@ -70,6 +71,9 @@ bouncyCastlePkixFips = { module = "org.bouncycastle:bcpkix-fips", version.ref = bouncyCastleTlsFips = { module = "org.bouncycastle:bctls-fips", version.ref = "bouncyCastleTls" } bouncyCastleUtilFips = { module = "org.bouncycastle:bcutil-fips", version.ref = "bouncyCastleUtil" } +# CloudFoundry +javaBuildpackClientCertificateMapper = { module = "org.cloudfoundry:java-buildpack-client-certificate-mapper-jakarta", version.ref = "javaBuildpackClientCertificateMapper" } + # Eclipse JGit eclipseJgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "eclipseJgit" } diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index bb39a34c602..69116588494 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java @@ -313,6 +313,13 @@ public TlsClientAuthConfiguration getTlsClientAuthConfiguration() { public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAuthConfiguration) { this.tlsClientAuthConfiguration = tlsClientAuthConfiguration; + // Keep additionalInformation in sync so JDBC-loaded clients (which only + // persist the additional_information JSON column) see the same value. + if (tlsClientAuthConfiguration != null) { + this.additionalInformation.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfiguration); + } else { + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } } @Override diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 14d3b62d5d2..b511bedc264 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { implementation(libs.bouncyCastleTlsFips) implementation(libs.bouncyCastleUtilFips) - implementation("org.cloudfoundry:java-buildpack-client-certificate-mapper-jakarta:2.0.1") + implementation(libs.javaBuildpackClientCertificateMapper) implementation(libs.guava) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index e8f4c25d865..3ce4164bba5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -233,21 +233,24 @@ public FilterRegistrationBean httpHeaderSecurityFilter } @Bean - @SuppressWarnings({"unchecked", "rawtypes"}) - public FilterRegistrationBean clientCertificateMapperFilter() { + public FilterRegistrationBean clientCertificateMapperFilter() { + // ClientCertificateMapper is a package-private final class in + // org.cloudfoundry.router.jakarta; its constructor is also package-private. + // The library is designed for Spring Boot autoconfiguration or Servlet container + // initializer use — direct instantiation from outside the package requires + // reflection. setAccessible(true) is the only available mechanism. try { Class mapperClass = Class.forName("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); java.lang.reflect.Constructor ctor = mapperClass.getDeclaredConstructor(); ctor.setAccessible(true); - jakarta.servlet.Filter mapper = (jakarta.servlet.Filter) ctor.newInstance(); - FilterRegistrationBean bean = new FilterRegistrationBean(mapper); + @SuppressWarnings("unchecked") + FilterRegistrationBean bean = + new FilterRegistrationBean<>((jakarta.servlet.Filter) ctor.newInstance()); bean.addUrlPatterns("/oauth/mtls/*"); bean.setOrder(10); return bean; } catch (ReflectiveOperationException e) { throw new IllegalStateException("Failed to instantiate ClientCertificateMapper", e); - } catch (Exception e) { - throw new IllegalStateException("Failed to create ClientCertificateMapper filter", e); } } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index c1a550f563a..a9e76296698 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -221,6 +221,12 @@ static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaCli if (rawMappings instanceof String mappingsJson) { claimMappings = JsonUtils.readValue(mappingsJson, new TypeReference>() {}); + } else if (rawMappings instanceof List mappingsList) { + // Jackson may parse a JSON array directly as a List when additionalInformation + // is deserialized from JDBC without a String-encoded wrapper. + String mappingsJson = JsonUtils.writeValueAsString(mappingsList); + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); } return new TlsClientAuthConfiguration(pem, claimMappings); } catch (Exception e) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index f026aa2b22c..1220d3fbff7 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -80,7 +80,12 @@ public Map enhance(Map claims, OAuth2Authenticat return new HashMap<>(); } - TlsClientAuthConfiguration config = loadTlsConfig(clientDetails.getAdditionalInformation()); + // Check the typed field first (set directly on in-memory / admin-API clients); + // fall back to additionalInformation for JDBC-loaded clients. + TlsClientAuthConfiguration config = clientDetails.getTlsClientAuthConfiguration(); + if (config == null) { + config = loadTlsConfig(clientDetails.getAdditionalInformation()); + } if (!TlsClientAuthConfiguration.isConfigured(config)) { return new HashMap<>(); } @@ -201,6 +206,12 @@ private static TlsClientAuthConfiguration loadTlsConfig(Map info if (rawMappings instanceof String mappingsJson) { claimMappings = JsonUtils.readValue(mappingsJson, new TypeReference>() {}); + } else if (rawMappings instanceof List mappingsList) { + // Jackson may parse a JSON array directly as a List when additionalInformation + // is deserialized from JDBC without a String-encoded wrapper. + String mappingsJson = JsonUtils.writeValueAsString(mappingsList); + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); } return new TlsClientAuthConfiguration(pem, claimMappings); } catch (Exception e) { From e038d5dd1df8069bd10ebbcb80a792fda52e1126 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 6 Jul 2026 13:10:56 +0200 Subject: [PATCH 020/130] test: update OIDC discovery tests for mtls_endpoint_aliases and tls_client_auth - Add tls_client_auth to tokenAMR assertion in wellKnownEndpoint tests - Assert mtls_endpoint_aliases.token_endpoint in both MockMvc test variants (SUBDOMAIN and ZONE_PATH zone resolution modes) - Document mtls_endpoint_aliases.token_endpoint in REST Docs snippet --- .../uaa/scim/endpoints/OpenIdConnectEndpointDocs.java | 3 ++- .../scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java | 4 +++- .../OpenIdConnectEndpointsMockMvcZonePathTests.java | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java index e691ee45cd2..58f7dba9bba 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java @@ -35,7 +35,8 @@ void getWellKnownOpenidConf() throws Exception { fieldWithPath("claims_parameter_supported").description("Boolean value specifying whether the OP supports use of the claims parameter."), fieldWithPath("service_documentation").description("URL of a page containing human-readable information that developers might want or need to know when using the OpenID Provider."), fieldWithPath("code_challenge_methods_supported").description("UAA 75.5.0JSON array containing a list of [PKCE](https://tools.ietf.org/html/rfc7636) code challenge methods supported by this authorization endpoint."), - fieldWithPath("ui_locales_supported").description("Languages and scripts supported for the user interface.") + fieldWithPath("ui_locales_supported").description("Languages and scripts supported for the user interface."), + fieldWithPath("mtls_endpoint_aliases.token_endpoint").description("mTLS-specific token endpoint alias for RFC 8705 mutual-TLS client authentication (proxy-terminated via Gorouter XFCC).") ); mockMvc.perform( diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java index f90f8dcb374..27ad32c822e 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java @@ -62,7 +62,7 @@ void wellKnownEndpoint() throws Exception { assertThat(openIdConfiguration.getIssuer()).isEqualTo("http://" + host + ":8080/uaa/oauth/token"); assertThat(openIdConfiguration.getAuthUrl()).isEqualTo("http://" + host + "/oauth/authorize"); assertThat(openIdConfiguration.getTokenUrl()).isEqualTo("http://" + host + "/oauth/token"); - assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); + assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"}); assertThat(openIdConfiguration.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); assertThat(openIdConfiguration.getUserInfoUrl()).isEqualTo("http://" + host + "/userinfo"); assertThat(openIdConfiguration.getScopes()).containsExactly(new String[]{"openid", "profile", "email", "phone", ROLES, USER_ATTRIBUTES}); @@ -74,6 +74,8 @@ void wellKnownEndpoint() throws Exception { assertThat(openIdConfiguration.isClaimsParameterSupported()).isFalse(); assertThat(openIdConfiguration.getServiceDocumentation()).isEqualTo("http://docs.cloudfoundry.org/api/uaa/"); assertThat(openIdConfiguration.getUiLocalesSupported()).containsExactly(new String[]{"en-US"}); + assertThat(openIdConfiguration.getMtlsEndpointAliases()) + .containsEntry("token_endpoint", "http://" + host + "/oauth/mtls/token"); } } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java index bdfe0e3c814..65c246c6147 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java @@ -76,7 +76,7 @@ void wellKnownEndpoint(ZoneResolutionMode mode) throws Exception { assertThat(openIdConfiguration.getIssuer()).isEqualTo("http://" + identityZone.getSubdomain() + ".localhost:8080/uaa/oauth/token"); assertThat(openIdConfiguration.getAuthUrl()).isEqualTo(expectedAuthUrl); assertThat(openIdConfiguration.getTokenUrl()).isEqualTo(expectedTokenUrl); - assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); + assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"}); assertThat(openIdConfiguration.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); assertThat(openIdConfiguration.getUserInfoUrl()).isEqualTo(expectedUserInfoUrl); assertThat(openIdConfiguration.getScopes()).containsExactly(new String[]{"openid", "profile", "email", "phone", ROLES, USER_ATTRIBUTES}); @@ -88,6 +88,11 @@ void wellKnownEndpoint(ZoneResolutionMode mode) throws Exception { assertThat(openIdConfiguration.isClaimsParameterSupported()).isFalse(); assertThat(openIdConfiguration.getServiceDocumentation()).isEqualTo("http://docs.cloudfoundry.org/api/uaa/"); assertThat(openIdConfiguration.getUiLocalesSupported()).containsExactly(new String[]{"en-US"}); + String expectedMtlsTokenUrl = mode == ZoneResolutionMode.ZONE_PATH + ? "http://localhost/z/" + identityZone.getSubdomain() + "/oauth/mtls/token" + : "http://" + host + "/oauth/mtls/token"; + assertThat(openIdConfiguration.getMtlsEndpointAliases()) + .containsEntry("token_endpoint", expectedMtlsTokenUrl); } } From c9be2f037a945582b61dbc5fd0060abfbb4ab14f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 6 Jul 2026 17:17:56 +0200 Subject: [PATCH 021/130] fix(review): fix JsonProperty collision and support full cert chain in mTLS auth Thread 8: Replace @JsonProperty(TLS_CLIENT_AUTH_CA) with @JsonIgnore on tlsClientAuthConfiguration in UaaClientDetails. The field is set only programmatically; JSON wire format for tls-client-auth-ca flows through additionalInformation via @JsonAnyGetter/@JsonAnySetter, avoiding the nested serialisation and flat-PEM deserialization failure. Thread 9: Add getCertificateChainFromRequest() returning the full X509Certificate[] from the request attribute. Add chain-aware overload validateClientCert(X509Certificate[], TlsClientAuthConfiguration) that builds CertPath from Arrays.asList(chain) so intermediate CAs are included in PKIX path validation. ClientDetailsAuthenticationProvider now uses the chain-based methods. Single-cert overload kept for backward compat (delegates to chain overload). --- .../identity/uaa/client/UaaClientDetails.java | 2 +- .../ClientDetailsAuthenticationProvider.java | 6 +-- .../oauth/tls/TlsClientAuthentication.java | 41 ++++++++++++++++--- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index 69116588494..e5292851882 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java @@ -87,7 +87,7 @@ public class UaaClientDetails implements ClientDetails { @JsonProperty("client_jwt_config") private String clientJwtConfig; - @JsonProperty(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + @JsonIgnore private TlsClientAuthConfiguration tlsClientAuthConfiguration; public UaaClientDetails() { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index a9e76296698..f5a32211118 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -190,12 +190,12 @@ static boolean isTlsClientAuthPath(Object uaaAuthenticationDetails) { } private boolean validateTlsClientAuth(UaaClient uaaClient) { - X509Certificate cert = tlsClientAuthentication.getCertificateFromRequest(); - if (cert == null) { + X509Certificate[] chain = tlsClientAuthentication.getCertificateChainFromRequest(); + if (chain == null || chain.length == 0) { return false; } TlsClientAuthConfiguration config = getTlsClientAuthConfiguration(uaaClient); - return tlsClientAuthentication.validateClientCert(cert, config).isPresent(); + return tlsClientAuthentication.validateClientCert(chain, config).isPresent(); } static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaClient) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index 33c047fac55..ee187b9127f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -18,7 +18,7 @@ import java.security.cert.PKIXParameters; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; -import java.util.Collections; +import java.util.Arrays; import java.util.Optional; import java.util.Set; @@ -40,6 +40,19 @@ public class TlsClientAuthentication { * @return the client certificate, or {@code null} if none is present */ public X509Certificate getCertificateFromRequest() { + X509Certificate[] chain = getCertificateChainFromRequest(); + return (chain != null && chain.length > 0) ? chain[0] : null; + } + + /** + * Returns the full X.509 certificate chain from the current request's + * {@code jakarta.servlet.request.X509Certificate} attribute + * (populated by the ClientCertificateMapper filter). + * Index 0 is the end-entity (leaf) certificate. + * + * @return the client certificate chain, or {@code null} if none is present + */ + public X509Certificate[] getCertificateChainFromRequest() { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) { @@ -48,12 +61,14 @@ public X509Certificate getCertificateFromRequest() { HttpServletRequest request = attrs.getRequest(); X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); - return (certs != null && certs.length > 0) ? certs[0] : null; + return (certs != null && certs.length > 0) ? certs : null; } /** * Validates {@code clientCert} against the trusted CA PEM configured in {@code config} * using PKIX path validation. + * For chains with intermediates, prefer + * {@link #validateClientCert(X509Certificate[], TlsClientAuthConfiguration)}. * * @param clientCert the certificate presented by the client, may be {@code null} * @param config the per-client TLS configuration, may be {@code null} @@ -63,8 +78,24 @@ public X509Certificate getCertificateFromRequest() { */ public Optional validateClientCert( X509Certificate clientCert, TlsClientAuthConfiguration config) { + return validateClientCert( + clientCert != null ? new X509Certificate[]{clientCert} : null, config); + } + + /** + * Validates a full certificate chain against the trusted CA PEM configured in {@code config} + * using PKIX path validation. Supports chains that include intermediate CAs. + * + * @param chain the full certificate chain (index 0 = end-entity), may be {@code null} + * @param config the per-client TLS configuration, may be {@code null} + * @return {@code Optional.of(chain[0])} when validation succeeds; + * {@code Optional.empty()} when chain or config is absent + * @throws InvalidClientDetailsException if the CA PEM is malformed or the cert chain is invalid + */ + public Optional validateClientCert( + X509Certificate[] chain, TlsClientAuthConfiguration config) { - if (clientCert == null || !TlsClientAuthConfiguration.isConfigured(config)) { + if (chain == null || chain.length == 0 || !TlsClientAuthConfiguration.isConfigured(config)) { return Optional.empty(); } @@ -76,12 +107,12 @@ public Optional validateClientCert( params.setRevocationEnabled(false); CertificateFactory cf = CertificateFactory.getInstance("X.509"); - var certPath = cf.generateCertPath(Collections.singletonList(clientCert)); + var certPath = cf.generateCertPath(Arrays.asList(chain)); CertPathValidator validator = CertPathValidator.getInstance("PKIX"); validator.validate(certPath, params); - return Optional.of(clientCert); + return Optional.of(chain[0]); } catch (CertPathValidatorException e) { throw new InvalidClientDetailsException( From 901e4f85c68a6ef03c39a6e9dcc97f973fc9b24e Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 08:13:44 +0200 Subject: [PATCH 022/130] test: update tlsClientAuthConfigRoundTripsViaJson for @JsonIgnore design After adding @JsonIgnore to UaaClientDetails.tlsClientAuthConfiguration, JSON round-trips populate additionalInformation (via @JsonAnySetter) rather than the typed getter. Update the test to assert on additionalInformation directly, which is the actual contract authentication providers depend on. --- .../identity/uaa/client/UaaClientDetailsTest.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java index 0aaaa803c15..07d38f6a402 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java @@ -220,9 +220,14 @@ void tlsClientAuthConfigRoundTripsViaJson() throws Exception { String json = new JsonMapper().writeValueAsString(details); UaaClientDetails deserialized = new JsonMapper().readValue(json, UaaClientDetails.class); - assertThat(deserialized.getTlsClientAuthConfiguration()).isNotNull(); - assertThat(deserialized.getTlsClientAuthConfiguration().getTrustedCaPem()) - .isEqualTo(config.getTrustedCaPem()); + // @JsonIgnore on the typed field: after JSON round-trip the config is persisted via + // additionalInformation (@JsonAnySetter), not the typed getter. + // Authentication providers read it from additionalInformation and convert as needed. + Object raw = deserialized.getAdditionalInformation() + .get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + assertThat(raw).isInstanceOf(Map.class); + assertThat(((Map) raw).get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA)) + .isEqualTo(config.getTrustedCaPem()); } @Test From ad24fe6637e602c221d9456ac475b452c92374b3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:21:03 +0200 Subject: [PATCH 023/130] feat: add subTemplate and audTemplates to TlsClientAuthConfiguration --- .../client/TlsClientAuthConfiguration.java | 20 +++++- .../TlsClientAuthConfigurationTest.java | 65 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java index 9486f635d8e..0d8a1f5b102 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -13,6 +13,8 @@ public class TlsClientAuthConfiguration { public static final String TLS_CLIENT_AUTH_CA = "tls-client-auth-ca"; public static final String TLS_CLIENT_AUTH_CLAIM_MAPPINGS = "tls-client-auth-claim-mappings"; + public static final String TLS_CLIENT_AUTH_SUB_TEMPLATE = "tls-client-auth-sub-template"; + public static final String TLS_CLIENT_AUTH_AUD_TEMPLATES = "tls-client-auth-aud-templates"; @JsonProperty(TLS_CLIENT_AUTH_CA) private String trustedCaPem; @@ -20,6 +22,12 @@ public class TlsClientAuthConfiguration { @JsonProperty(TLS_CLIENT_AUTH_CLAIM_MAPPINGS) private List claimMappings; + @JsonProperty(TLS_CLIENT_AUTH_SUB_TEMPLATE) + private String subTemplate; + + @JsonProperty(TLS_CLIENT_AUTH_AUD_TEMPLATES) + private List audTemplates; + public TlsClientAuthConfiguration() {} public TlsClientAuthConfiguration(String trustedCaPem, List claimMappings) { @@ -33,17 +41,25 @@ public TlsClientAuthConfiguration(String trustedCaPem, List claimM public List getClaimMappings() { return claimMappings; } public void setClaimMappings(List claimMappings) { this.claimMappings = claimMappings; } + public String getSubTemplate() { return subTemplate; } + public void setSubTemplate(String subTemplate) { this.subTemplate = subTemplate; } + + public List getAudTemplates() { return audTemplates; } + public void setAudTemplates(List audTemplates) { this.audTemplates = audTemplates; } + @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof TlsClientAuthConfiguration that)) return false; return Objects.equals(trustedCaPem, that.trustedCaPem) && - Objects.equals(claimMappings, that.claimMappings); + Objects.equals(claimMappings, that.claimMappings) && + Objects.equals(subTemplate, that.subTemplate) && + Objects.equals(audTemplates, that.audTemplates); } @Override public int hashCode() { - return Objects.hash(trustedCaPem, claimMappings); + return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates); } public static boolean isConfigured(TlsClientAuthConfiguration config) { diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java index c102cc3c26e..a3579efc277 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -67,4 +67,69 @@ void unequalWhenCaDiffers() { TlsClientAuthConfiguration b = new TlsClientAuthConfiguration("ca-b", null); assertThat(a).isNotEqualTo(b); } + + @Test + void subTemplateRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + ); + config.setSubTemplate("o/{cf.org}/s/{cf.space}/a/{cf.app}"); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getSubTemplate()).isEqualTo("o/{cf.org}/s/{cf.space}/a/{cf.app}"); + } + + @Test + void audTemplatesRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config.setAudTemplates(List.of( + "o/{cf.org}/s/{cf.space}/a/{cf.app}", + "o/{cf.org}/s/{cf.space}", + "o/{cf.org}" + )); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getAudTemplates()).containsExactly( + "o/{cf.org}/s/{cf.space}/a/{cf.app}", + "o/{cf.org}/s/{cf.space}", + "o/{cf.org}" + ); + } + + @Test + void nullSubTemplateAndAudTemplatesOmittedFromJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + // subTemplate and audTemplates left null + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + + assertThat(json).doesNotContain("tls-client-auth-sub-template"); + assertThat(json).doesNotContain("tls-client-auth-aud-templates"); + } + + @Test + void equalityIncludesSubTemplateAndAudTemplates() { + TlsClientAuthConfiguration a = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + a.setSubTemplate("o/{cf.org}"); + a.setAudTemplates(List.of("o/{cf.org}")); + + TlsClientAuthConfiguration b = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + b.setSubTemplate("o/{cf.org}"); + b.setAudTemplates(List.of("o/{cf.org}")); + + TlsClientAuthConfiguration c = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + c.setSubTemplate("different"); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + assertThat(a).isNotEqualTo(c); + } } From 04a3dfd3b784b72e77723dbcae0eb659bf26d337 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:26:23 +0200 Subject: [PATCH 024/130] test: fix audTemplates equality isolation in TlsClientAuthConfigurationTest --- .../identity/uaa/client/TlsClientAuthConfigurationTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java index a3579efc277..98fbceae9e2 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -128,8 +128,13 @@ void equalityIncludesSubTemplateAndAudTemplates() { TlsClientAuthConfiguration c = new TlsClientAuthConfiguration(EXAMPLE_CA, null); c.setSubTemplate("different"); + TlsClientAuthConfiguration d = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + d.setSubTemplate("o/{cf.org}"); // same as a + // d.audTemplates left null // differs from a + assertThat(a).isEqualTo(b); assertThat(a.hashCode()).isEqualTo(b.hashCode()); assertThat(a).isNotEqualTo(c); + assertThat(a).isNotEqualTo(d); } } From 66d8173b3582997e6aca2d644a5298750850d658 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:34:02 +0200 Subject: [PATCH 025/130] refactor: restructure enhance() into phase 1/2 (vars + dot-notation) --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 31 +++++++-- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 69 +++++++++++++++++++ .../tls/TlsClientAuthenticationTest.java | 2 +- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 1220d3fbff7..e62165056aa 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -80,8 +80,6 @@ public Map enhance(Map claims, OAuth2Authenticat return new HashMap<>(); } - // Check the typed field first (set directly on in-memory / admin-API clients); - // fall back to additionalInformation for JDBC-loaded clients. TlsClientAuthConfiguration config = clientDetails.getTlsClientAuthConfiguration(); if (config == null) { config = loadTlsConfig(clientDetails.getAdditionalInformation()); @@ -90,13 +88,12 @@ public Map enhance(Map claims, OAuth2Authenticat return new HashMap<>(); } - Map result = new HashMap<>(); - - // Apply per-client claim mappings from cert subject fields + // PHASE 1 — extract cert subject fields into vars (keyed by claim name) + Map vars = new HashMap<>(); if (config.getClaimMappings() != null) { X500Principal subject = cert.getSubjectX500Principal(); String dn = subject.getName(X500Principal.RFC2253); - String cn = extractRdnValue(dn, "CN="); + String cn = extractRdnValue(dn, "CN="); List ous = extractOus(dn); for (TlsClientAuthConfiguration.ClaimMapping mapping : config.getClaimMappings()) { @@ -107,11 +104,29 @@ public Map enhance(Map claims, OAuth2Authenticat default -> null; }; if (value != null && !value.isBlank()) { - result.put(mapping.getClaim(), value); + vars.put(mapping.getClaim(), value); } } } + // PHASE 2 — build JWT claims: dot-notation → nested object; flat → top-level + Map result = new HashMap<>(); + Map> nestedClaims = new HashMap<>(); + for (Map.Entry entry : vars.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + int dotIdx = key.indexOf('.'); + if (dotIdx > 0 && dotIdx < key.length() - 1) { + String parent = key.substring(0, dotIdx); + String child = key.substring(dotIdx + 1); + nestedClaims.computeIfAbsent(parent, k -> new HashMap<>()).put(child, value); + } else { + result.put(key, value); + } + } + // Nested maps overwrite any flat claim that shares the same parent key + result.putAll(nestedClaims); + // Always add cnf.x5t#S256 (RFC 8705 §3.1 confirmation claim) try { byte[] derEncoded = cert.getEncoded(); @@ -122,6 +137,8 @@ public Map enhance(Map claims, OAuth2Authenticat // Silently skip cnf claim if cert encoding fails } + // PHASE 3 — template rendering (added in next task) + return result; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 4c4d5b5a8bd..60f6a82edc3 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -90,6 +90,75 @@ void returnsEmptyWhenNoCertOnRequest() { assertThat(result).doesNotContainKey("app_guid"); } + @Test + void dotNotationClaimProducesNestedObject() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid, OU=app:app-guid, OU=space:space-guid, OU=organization:org-guid, O=Cloud Foundry")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "cf.app"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "cf.space"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "cf.org"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsKey("cf"); + @SuppressWarnings("unchecked") + Map cf = (Map) result.get("cf"); + assertThat(cf).containsEntry("app", "app-guid"); + assertThat(cf).containsEntry("space", "space-guid"); + assertThat(cf).containsEntry("org", "org-guid"); + assertThat(result).containsEntry("cf_instance_guid", "inst-guid"); + // Dot-notation keys must NOT appear as top-level claims + assertThat(result).doesNotContainKey("cf.app"); + assertThat(result).doesNotContainKey("cf.space"); + assertThat(result).doesNotContainKey("cf.org"); + } + + @Test + void flatClaimsStillWorkAfterRefactor() throws Exception { + // Existing flat-key behaviour must be unchanged + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=instance-guid, OU=app:app-guid-123, OU=space:space-guid-456, OU=organization:org-guid-789, O=Cloud Foundry")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "org_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsEntry("app_guid", "app-guid-123"); + assertThat(result).containsEntry("space_guid", "space-guid-456"); + assertThat(result).containsEntry("org_guid", "org-guid-789"); + assertThat(result).containsEntry("cf_instance_guid","instance-guid"); + assertThat(result).doesNotContainKey("cf"); + } + private OAuth2Authentication mockAuthentication(String clientId) { OAuth2Request request = mock(OAuth2Request.class); when(request.getClientId()).thenReturn(clientId); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 6f1daa74d28..8b40676556c 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -22,7 +22,7 @@ void setUp() { @Test void nullCertReturnsEmptyOptional() { TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("...", null); - assertThat(service.validateClientCert(null, config)).isEmpty(); + assertThat(service.validateClientCert((X509Certificate) null, config)).isEmpty(); } @Test From 14f4ecacb51021998f96032b9485de972bef4c98 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:40:50 +0200 Subject: [PATCH 026/130] fix: document single-level nesting, add conflict test, restore comment --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 4 +++ .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index e62165056aa..7f72d3f1c61 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -80,6 +80,8 @@ public Map enhance(Map claims, OAuth2Authenticat return new HashMap<>(); } + // Check the typed field first (set directly on in-memory / admin-API clients); + // fall back to additionalInformation for JDBC-loaded clients. TlsClientAuthConfiguration config = clientDetails.getTlsClientAuthConfiguration(); if (config == null) { config = loadTlsConfig(clientDetails.getAdditionalInformation()); @@ -115,6 +117,8 @@ public Map enhance(Map claims, OAuth2Authenticat for (Map.Entry entry : vars.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); + // Only a single dot level is supported (spec: UAA-RFC8705-001 configurable-token-shape). + // A key like "cf.app.id" would produce parent="cf", child="app.id" (not deeper nesting). int dotIdx = key.indexOf('.'); if (dotIdx > 0 && dotIdx < key.length() - 1) { String parent = key.substring(0, dotIdx); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 60f6a82edc3..cb953c02f67 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -159,6 +159,35 @@ void flatClaimsStillWorkAfterRefactor() throws Exception { assertThat(result).doesNotContainKey("cf"); } + @Test + void dotNotationOverwritesFlatClaimWithSameParentKey() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid, OU=app:app-guid, O=cf-org")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_o", null, "cf"), // flat "cf" + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "cf.app") // nested "cf.app" + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + // Nested map must overwrite the flat "cf" string value + assertThat(result.get("cf")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map cf = (Map) result.get("cf"); + assertThat(cf).containsEntry("app", "app-guid"); + } + private OAuth2Authentication mockAuthentication(String clientId) { OAuth2Request request = mock(OAuth2Request.class); when(request.getClientId()).thenReturn(clientId); From 16d366ac10ca23269b1b73739b4a25cc3342794c Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:46:48 +0200 Subject: [PATCH 027/130] feat: add phase 3 sub/aud template rendering to MtlsClaimsEnhancer --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 46 +++++- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 152 ++++++++++++++++++ 2 files changed, 197 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 7f72d3f1c61..b01df10023d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -141,7 +141,28 @@ public Map enhance(Map claims, OAuth2Authenticat // Silently skip cnf claim if cert encoding fails } - // PHASE 3 — template rendering (added in next task) + // PHASE 3 — template rendering for sub and aud + Pattern placeholder = Pattern.compile("\\{([^}]+)\\}"); + + if (config.getSubTemplate() != null) { + String rendered = renderTemplate(config.getSubTemplate(), vars, placeholder); + if (rendered != null) { + result.put("sub", rendered); + } + } + + if (config.getAudTemplates() != null && !config.getAudTemplates().isEmpty()) { + List audList = new ArrayList<>(); + for (String tmpl : config.getAudTemplates()) { + String rendered = renderTemplate(tmpl, vars, placeholder); + if (rendered != null) { + audList.add(rendered); + } + } + if (!audList.isEmpty()) { + result.put("aud", audList); + } + } return result; } @@ -200,6 +221,29 @@ private String matchFirstOu(List ous, String patternStr) { return null; } + /** + * Renders a template string by substituting all {@code {varName}} placeholders + * from {@code vars}. Returns {@code null} if any placeholder has no corresponding + * value in {@code vars} (the whole template is then dropped by the caller). + * + *

Variable names may contain dots (e.g. {@code {cf.org}}); dots inside braces + * are treated as part of the name, not as path separators. + */ + private String renderTemplate(String template, Map vars, Pattern placeholder) { + StringBuffer sb = new StringBuffer(); + Matcher m = placeholder.matcher(template); + while (m.find()) { + String varName = m.group(1); + String value = vars.get(varName); + if (value == null) { + return null; // unresolved placeholder → caller should drop this template + } + m.appendReplacement(sb, Matcher.quoteReplacement(value)); + } + m.appendTail(sb); + return sb.toString(); + } + /** * Builds a {@link TlsClientAuthConfiguration} from the client's {@code additionalInformation} map. * Mirrors {@code ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration} so that diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index cb953c02f67..51119365fb5 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -188,6 +188,158 @@ void dotNotationOverwritesFlatClaimWithSameParentKey() throws Exception { assertThat(cf).containsEntry("app", "app-guid"); } + @Test + void subTemplateRenderedAndOverridesDefault() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setSubTemplate("o/{cf.org}/s/{cf.space}/a/{cf.app}"); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("sub", + "o/org-guid/s/space-guid/a/app-guid"); + } + + @Test + void audTemplatesRenderedAndOverrideDefault() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(List.of( + "o/{cf.org}/s/{cf.space}/a/{cf.app}", + "o/{cf.org}/s/{cf.space}", + "o/{cf.org}" + )); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsKey("aud"); + @SuppressWarnings("unchecked") + List aud = (List) result.get("aud"); + assertThat(aud).containsExactly( + "o/org-guid/s/space-guid/a/app-guid", + "o/org-guid/s/space-guid", + "o/org-guid" + ); + } + + @Test + void subOmittedWhenTemplateVarMissing() throws Exception { + // Cert has no OU fields → cf.org will not be in vars + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=only-cn")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + ); + config.setSubTemplate("o/{cf.org}"); // {cf.org} will have no value + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).doesNotContainKey("sub"); + assertThat(result).containsEntry("cf_instance_guid", "only-cn"); + } + + @Test + void audEntryDroppedWhenTemplateVarMissing() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(List.of( + "a/{cf.app}", // will resolve + "x/{missing_var}" // {missing_var} not in vars → dropped + )); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsKey("aud"); + @SuppressWarnings("unchecked") + List aud = (List) result.get("aud"); + assertThat(aud).containsExactly("a/app-guid"); + } + + @Test + void audOmittedWhenAllTemplateEntriesFail() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(List.of("x/{missing}", "y/{also_missing}")); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).doesNotContainKey("aud"); + } + + @Test + void noTemplatesConfiguredLeavesSubAndAudAbsent() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + // Config with no subTemplate/audTemplates (original behaviour) + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(cfMappingsConfig()); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).doesNotContainKey("sub"); + assertThat(result).doesNotContainKey("aud"); + } + + private X509Certificate mockCfCert() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid, OU=app:app-guid, OU=space:space-guid, OU=organization:org-guid, O=Cloud Foundry")); + return cert; + } + + private TlsClientAuthConfiguration cfMappingsConfig() { + return new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "cf.app"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "cf.space"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "cf.org"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + ); + } + private OAuth2Authentication mockAuthentication(String clientId) { OAuth2Request request = mock(OAuth2Request.class); when(request.getClientId()).thenReturn(clientId); From 70a03f659cd560b51975b13b5f5b26637ab1b606 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:53:20 +0200 Subject: [PATCH 028/130] fix: static PLACEHOLDER pattern, StringBuilder, restore comment, rename test --- .../identity/uaa/oauth/tls/MtlsClaimsEnhancer.java | 13 ++++++------- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index b01df10023d..2186163ec9d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -41,6 +41,7 @@ public class MtlsClaimsEnhancer implements UaaTokenEnhancer { private static final Logger logger = LoggerFactory.getLogger(MtlsClaimsEnhancer.class); + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); private final TlsClientAuthentication tlsClientAuthentication; private final ClientDetailsService clientDetailsService; @@ -142,10 +143,8 @@ public Map enhance(Map claims, OAuth2Authenticat } // PHASE 3 — template rendering for sub and aud - Pattern placeholder = Pattern.compile("\\{([^}]+)\\}"); - if (config.getSubTemplate() != null) { - String rendered = renderTemplate(config.getSubTemplate(), vars, placeholder); + String rendered = renderTemplate(config.getSubTemplate(), vars); if (rendered != null) { result.put("sub", rendered); } @@ -154,7 +153,7 @@ public Map enhance(Map claims, OAuth2Authenticat if (config.getAudTemplates() != null && !config.getAudTemplates().isEmpty()) { List audList = new ArrayList<>(); for (String tmpl : config.getAudTemplates()) { - String rendered = renderTemplate(tmpl, vars, placeholder); + String rendered = renderTemplate(tmpl, vars); if (rendered != null) { audList.add(rendered); } @@ -229,9 +228,9 @@ private String matchFirstOu(List ous, String patternStr) { *

Variable names may contain dots (e.g. {@code {cf.org}}); dots inside braces * are treated as part of the name, not as path separators. */ - private String renderTemplate(String template, Map vars, Pattern placeholder) { - StringBuffer sb = new StringBuffer(); - Matcher m = placeholder.matcher(template); + private String renderTemplate(String template, Map vars) { + StringBuilder sb = new StringBuilder(); + Matcher m = PLACEHOLDER.matcher(template); while (m.find()) { String varName = m.group(1); String value = vars.get(varName); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 51119365fb5..a4ba1db1b8d 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -189,7 +189,7 @@ void dotNotationOverwritesFlatClaimWithSameParentKey() throws Exception { } @Test - void subTemplateRenderedAndOverridesDefault() throws Exception { + void subTemplateRendered() throws Exception { X509Certificate cert = mockCfCert(); when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); From f4fcdb09028990db178a3f53e0b4070235f2a4fa Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:59:01 +0200 Subject: [PATCH 029/130] feat: read tls-client-auth-sub-template and aud-templates from flat BOSH String path --- .../ClientDetailsAuthenticationProvider.java | 21 ++++++++++++- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 23 +++++++++++++- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 30 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index f5a32211118..a2497cac165 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -228,7 +228,26 @@ static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaCli claimMappings = JsonUtils.readValue(mappingsJson, new TypeReference>() {}); } - return new TlsClientAuthConfiguration(pem, claimMappings); + String subTemplate = null; + Object rawSubTemplate = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + if (rawSubTemplate instanceof String st && !st.isBlank()) { + subTemplate = st; + } + + List audTemplates = null; + Object rawAudTemplates = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + if (rawAudTemplates instanceof String audJson) { + audTemplates = JsonUtils.readValue(audJson, new TypeReference>() {}); + } else if (rawAudTemplates instanceof List audList) { + audTemplates = JsonUtils.readValue( + JsonUtils.writeValueAsString(audList), + new TypeReference>() {}); + } + + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); + cfg.setSubTemplate(subTemplate); + cfg.setAudTemplates(audTemplates); + return cfg; } catch (Exception e) { return null; } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 2186163ec9d..e68e8fdafac 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -277,7 +277,28 @@ private static TlsClientAuthConfiguration loadTlsConfig(Map info claimMappings = JsonUtils.readValue(mappingsJson, new TypeReference>() {}); } - return new TlsClientAuthConfiguration(pem, claimMappings); + String subTemplate = null; + Object rawSubTemplate = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + if (rawSubTemplate instanceof String st && !st.isBlank()) { + subTemplate = st; + } + + List audTemplates = null; + Object rawAudTemplates = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + if (rawAudTemplates instanceof String audJson) { + audTemplates = JsonUtils.readValue(audJson, new TypeReference>() {}); + } else if (rawAudTemplates instanceof List audList) { + // Jackson may deserialise a JSON array as a List when additionalInformation + // is loaded from JDBC without a String-encoded wrapper. + audTemplates = JsonUtils.readValue( + JsonUtils.writeValueAsString(audList), + new TypeReference>() {}); + } + + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); + cfg.setSubTemplate(subTemplate); + cfg.setAudTemplates(audTemplates); + return cfg; } catch (Exception e) { return null; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index a4ba1db1b8d..2bab7c3fe27 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -320,6 +320,36 @@ void noTemplatesConfiguredLeavesSubAndAudAbsent() throws Exception { assertThat(result).doesNotContainKey("aud"); } + @Test + void stringPathInAdditionalInformationLoadsSubTemplateAndAudTemplates() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + // Do NOT call setTlsClientAuthConfiguration — use String values directly + clientDetails.setAdditionalInformation(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + "[{\"field\":\"subject_ou\",\"pattern\":\"^app:(.+)$\",\"claim\":\"cf.app\"}," + + "{\"field\":\"subject_cn\",\"claim\":\"cf_instance_guid\"}]", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + "app/{cf.app}", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + "[\"app/{cf.app}\"]" + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("sub", "app/app-guid"); + assertThat(result).containsKey("aud"); + @SuppressWarnings("unchecked") + List aud = (List) result.get("aud"); + assertThat(aud).containsExactly("app/app-guid"); + } + private X509Certificate mockCfCert() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); From e94d33848f8cf23d7b59ed2e86c3240dcd054f53 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 14:39:34 +0200 Subject: [PATCH 030/130] fix: apply token enhancer overrides after UAA defaults so sub/aud templates win UaaTokenServices.createJWTAccessToken() was unconditionally setting sub and aud after spreading additionalRootClaims, causing MtlsClaimsEnhancer's rendered sub/aud templates to be silently discarded. Move the additionalRootClaims.putAll() to after all UAA-default claims are set (including sub and aud) so that enhancer claims take precedence. Explicit excluded claims are still removed last so operator exclusions win. Add regression test: WhenTokenEnhancerOverridesSubAndAud verifies that an enhancer-supplied sub and aud survive into the final JWT. --- .../identity/uaa/oauth/UaaTokenServices.java | 11 ++-- .../uaa/oauth/UaaTokenServicesTests.java | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java index 75140ecd5f2..dea81982887 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java @@ -536,10 +536,6 @@ private Map createJWTAccessToken(OAuth2AccessToken token, claims.put(JTI, token.getAdditionalInformation().get(JTI)); claims.putAll(token.getAdditionalInformation()); - if (additionalRootClaims != null) { - claims.putAll(additionalRootClaims); - } - claims.put(SUB, clientId); if (GRANT_TYPE_CLIENT_CREDENTIALS.equals(grantType)) { claims.put(AUTHORITIES, AuthorityUtils.authorityListToSet(clientScopes)); @@ -570,6 +566,13 @@ private Map createJWTAccessToken(OAuth2AccessToken token, claims.put(AUD, UaaStringUtils.getValuesOrDefaultValue(resourceIds, clientId)); + // Apply token enhancer overrides after all UAA-default claims are set. + // This allows enhancers to override sub/aud (e.g. mTLS cert-identity templates). + // Excluded claims are removed after so operator exclusions always win. + if (additionalRootClaims != null) { + claims.putAll(additionalRootClaims); + } + for (String excludedClaim : getExcludedClaims()) { claims.remove(excludedClaim); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java index 0aba9b20527..b16f3b17d1f 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java @@ -825,6 +825,56 @@ public Integer getZoneValiditySeconds() { } } + @Nested + @DisplayName("when token enhancer overrides sub and aud") + class WhenTokenEnhancerOverridesSubAndAud { + + @Test + @DisplayName("enhancer sub and aud claims win over UAA defaults") + void enhancerSubAndAudClaimsWinOverUaaDefaults() { + UaaTokenEnhancer testEnhancer = new UaaTokenEnhancer() { + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + Map result = new HashMap<>(); + result.put("sub", "enhancer-sub"); + result.put("aud", List.of("enhancer-aud")); + return result; + } + }; + + tokenServices.setUaaTokenEnhancers(List.of(testEnhancer)); + + try { + AuthorizationRequest authorizationRequest = constructAuthorizationRequest( + clientId, GRANT_TYPE_CLIENT_CREDENTIALS, CLIENT_SCOPES.split(",")); + OAuth2Authentication authentication = new OAuth2Authentication( + authorizationRequest.createOAuth2Request(), null); + + OAuth2AccessToken accessToken = tokenServices.createAccessToken(authentication); + + Jwt jwt = JwtHelper.decode(accessToken.getValue()); + Map tokenClaims = JsonUtils.readValue(jwt.getClaims(), + new TypeReference>() {}); + assertThat(tokenClaims).containsEntry("sub", "enhancer-sub"); + // JWT RFC 7519 §4.1.3: single-audience MAY be serialized as a plain string + Object aud = tokenClaims.get("aud"); + if (aud instanceof String s) { + assertThat(s).isEqualTo("enhancer-aud"); + } else { + assertThat(aud).asInstanceOf(InstanceOfAssertFactories.list(Object.class)) + .containsExactly("enhancer-aud"); + } + } finally { + tokenServices.setUaaTokenEnhancers(new ArrayList<>()); + } + } + } + private OAuth2Authentication constructUserAuthenticationFromAuthzRequest(AuthorizationRequest authzRequest, String userId, String userOrigin, From 046f03dfb245efc8d0ef1afd33a51111c35eef33 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 18 Aug 2026 09:24:27 +0200 Subject: [PATCH 031/130] fix(review): isValidMethod accepts null method with CA-only config isValidMethod(method, hasSecret, hasKeyConfiguration, hasCaConfig) returned false for method=null + hasCaConfig=true, contradicting getCalculatedMethod which derives tls_client_auth from CA config alone. This could reject valid mTLS-only client configs where the auth method is left unset. Extends the tls_client_auth clause to treat null method the same as an explicit tls_client_auth, mirroring the existing null-method handling for the none/secret/key-config cases. Addresses PR review comment on ClientAuthentication.java:42. --- .../uaa/constants/ClientAuthentication.java | 2 +- .../uaa/constants/ClientAuthenticationTest.java | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java index 4bd57ac8b19..b4f6779a20d 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java @@ -36,7 +36,7 @@ public static boolean isValidMethod(String method, boolean hasSecret, boolean hasKeyConfiguration, boolean hasCaConfig) { return isMethodSupported(method) && secretNeeded(method) && hasSecret && !hasKeyConfiguration && !hasCaConfig || isMethodSupported(method) && PRIVATE_KEY_JWT.equals(method) && !hasSecret && hasKeyConfiguration && !hasCaConfig - || isMethodSupported(method) && TLS_CLIENT_AUTH.equals(method) && !hasSecret && !hasKeyConfiguration && hasCaConfig + || isMethodSupported(method) && (TLS_CLIENT_AUTH.equals(method) || method == null) && !hasSecret && !hasKeyConfiguration && hasCaConfig || isMethodSupported(method) && (NONE.equals(method) || method == null) && !hasSecret && !hasKeyConfiguration && !hasCaConfig || (method == null && (!hasSecret || !hasKeyConfiguration) && !hasCaConfig); } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java index 4af67ef5215..914caf7f791 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java @@ -119,4 +119,21 @@ void tlsClientAuthIsInvalidWhenHasKeyConfig() { assertThat(ClientAuthentication.isValidMethod( ClientAuthentication.TLS_CLIENT_AUTH, false, true, true)).isFalse(); } + + @Test + void tlsClientAuthIsValidMethodWhenHasCaConfigAndMethodUnset() { + // method left null (unset), matching how getCalculatedMethod derives tls_client_auth + // from CA config alone -- see tlsClientAuthIsCalculatedWhenHasCaConfig above. + assertThat(ClientAuthentication.isValidMethod(null, false, false, true)).isTrue(); + } + + @Test + void tlsClientAuthIsInvalidWhenMethodUnsetAndHasSecretAndCaConfig() { + assertThat(ClientAuthentication.isValidMethod(null, true, false, true)).isFalse(); + } + + @Test + void tlsClientAuthIsInvalidWhenMethodUnsetAndHasKeyConfigAndCaConfig() { + assertThat(ClientAuthentication.isValidMethod(null, false, true, true)).isFalse(); + } } From 553992d9df0d6f6d0ad015a06ba353e683c15c6e Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 18 Aug 2026 09:40:06 +0200 Subject: [PATCH 032/130] fix(review): use LdapName for RFC 2253-safe DN parsing in MtlsClaimsEnhancer extractRdnValue and extractOus previously used dn.split(",") to parse the certificate subject DN, which breaks on RFC 2253 backslash-escaped commas inside attribute values (e.g. CN=Smith\, John), mis-extracting CN/O/OU claim values. Replace with javax.naming.ldap.LdapName/Rdn, the JDK's RFC 2253-compliant DN parser. LdapName.getRdns() returns RDNs least-specific-first, so the list is reversed to preserve the original left-to-right (most-specific- first) extraction order. Attribute type matching is now case-insensitive, consistent with LDAP semantics. Added tests reproducing the escaped-comma mis-parse for both CN and OU extraction before the fix, confirming they fail for the expected reason. Addresses PR review comments on MtlsClaimsEnhancer.java:185 and :203. --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 79 ++++++++++++++----- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 55 +++++++++++++ 2 files changed, 115 insertions(+), 19 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index e68e8fdafac..ba080b97a58 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -12,11 +12,17 @@ import org.springframework.stereotype.Component; import tools.jackson.core.type.TypeReference; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; import javax.security.auth.x500.X500Principal; import java.security.MessageDigest; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -96,14 +102,14 @@ public Map enhance(Map claims, OAuth2Authenticat if (config.getClaimMappings() != null) { X500Principal subject = cert.getSubjectX500Principal(); String dn = subject.getName(X500Principal.RFC2253); - String cn = extractRdnValue(dn, "CN="); + String cn = extractRdnValue(dn, "CN"); List ous = extractOus(dn); for (TlsClientAuthConfiguration.ClaimMapping mapping : config.getClaimMappings()) { String value = switch (mapping.getField()) { case "subject_cn" -> cn; case "subject_ou" -> matchFirstOu(ous, mapping.getPattern()); - case "subject_o" -> extractRdnValue(dn, "O="); + case "subject_o" -> extractRdnValue(dn, "O"); default -> null; }; if (value != null && !value.isBlank()) { @@ -167,19 +173,57 @@ public Map enhance(Map claims, OAuth2Authenticat } /** - * Extracts the value of a single-valued RDN (e.g. {@code "CN="}) from a RFC 2253 DN string. - * Handles multi-valued RDNs (attributes joined by {@code +}). - * Returns {@code null} if no matching RDN is found. + * Parses an RFC 2253 DN string into its RDNs, ordered most-specific-first + * (i.e. matching the left-to-right order of the original DN string). + * + *

{@link LdapName#getRdns()} returns RDNs least-specific-first (root/rightmost + * component at index 0), so the list is reversed here. Using {@link LdapName} instead + * of a naive {@code dn.split(",")} correctly handles backslash-escaped commas/quotes + * within attribute values (RFC 2253 §2.4), which a plain string split would mis-parse. + * Returns an empty list if {@code dn} cannot be parsed as a valid DN. + */ + private static List parseRdnsMostSpecificFirst(String dn) { + try { + List rdns = new ArrayList<>(new LdapName(dn).getRdns()); + Collections.reverse(rdns); + return rdns; + } catch (NamingException e) { + return List.of(); + } + } + + /** + * Returns the value of the given attribute {@code type} (e.g. {@code "CN"}) from an RDN, + * including multi-valued RDNs (attributes joined by {@code +}). Attribute type matching + * is case-insensitive, per LDAP semantics. Returns {@code null} if not present. */ - private String extractRdnValue(String dn, String prefix) { - for (String rdn : dn.split(",")) { - // Multi-valued RDNs use '+' to separate attribute-value pairs within one RDN - for (String attrVal : rdn.split("\\+")) { - String trimmed = attrVal.trim(); - if (trimmed.startsWith(prefix)) { - return trimmed.substring(prefix.length()); + private static String rdnAttributeValue(Rdn rdn, String type) { + try { + NamingEnumeration attrs = rdn.toAttributes().getAll(); + while (attrs.hasMore()) { + Attribute attr = attrs.next(); + if (attr.getID().equalsIgnoreCase(type)) { + Object value = attr.get(); + return value == null ? null : value.toString(); } } + } catch (NamingException e) { + // fall through to null + } + return null; + } + + /** + * Extracts the value of a single-valued RDN attribute (e.g. {@code "CN"}) from a RFC 2253 + * DN string. Handles multi-valued RDNs (attributes joined by {@code +}). + * Returns {@code null} if no matching RDN is found. + */ + private String extractRdnValue(String dn, String type) { + for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { + String value = rdnAttributeValue(rdn, type); + if (value != null) { + return value; + } } return null; } @@ -190,13 +234,10 @@ private String extractRdnValue(String dn, String prefix) { */ private List extractOus(String dn) { List ous = new ArrayList<>(); - for (String rdn : dn.split(",")) { - // Multi-valued RDNs use '+' to separate attribute-value pairs within one RDN - for (String attrVal : rdn.split("\\+")) { - String trimmed = attrVal.trim(); - if (trimmed.startsWith("OU=")) { - ous.add(trimmed.substring(3)); - } + for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { + String value = rdnAttributeValue(rdn, "OU"); + if (value != null) { + ous.add(value); } } return ous; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 2bab7c3fe27..97ac7dd7403 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -350,6 +350,61 @@ void stringPathInAdditionalInformationLoadsSubTemplateAndAudTemplates() throws E assertThat(aud).containsExactly("app/app-guid"); } + @Test + void extractsCnValueContainingEscapedComma() throws Exception { + // RFC 2253 escapes literal commas inside an attribute value with a backslash. + // A naive dn.split(",") breaks on the escaped comma and mangles the CN value. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=Smith\\, John,OU=app:app-guid-123,O=Cloud Foundry")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("cf_instance_guid", "Smith, John"); + assertThat(result).containsEntry("app_guid", "app-guid-123"); + } + + @Test + void extractsOuValueContainingEscapedComma() throws Exception { + // Same RFC 2253 escaping issue, but for a multi-valued OU list: an escaped comma + // inside one OU must not be treated as an RDN separator, and subsequent OUs must + // still be collected correctly. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid,OU=team\\, ops,OU=app:app-guid-123,O=Cloud Foundry")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^(team, ops)$", "team"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("team", "team, ops"); + assertThat(result).containsEntry("app_guid", "app-guid-123"); + } + private X509Certificate mockCfCert() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); From 73483c565f12995309e4718fab9928e2bcb15d48 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 18 Aug 2026 10:04:47 +0200 Subject: [PATCH 033/130] test(review): confirm PKIX validation tolerates trust anchor in presented chain Adds coverage for the scenario raised in PR review on TlsClientAuthentication.java:113 -- proxies/clients may forward the full certificate chain including the trust anchor / root CA itself. Using real BouncyCastle-FIPS-signed certificates (not mocks), confirms validateClientCert succeeds whether the chain omits or includes the trust anchor, for both a single-level (leaf + root) and a two-level (leaf + intermediate + root) CA hierarchy. No production code change: the JDK's PKIX CertPathValidator correctly implements RFC 5280 section 6.1, which excludes trailing self-issued certificates from path-length/depth accounting, so no anchor-stripping is required before building the CertPath. Addresses PR review comment on TlsClientAuthentication.java:113. --- .../tls/TlsClientAuthenticationTest.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 8b40676556c..7fc5cfd6ef2 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -1,10 +1,30 @@ package org.cloudfoundry.identity.uaa.oauth.tls; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.io.StringWriter; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Security; import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -17,6 +37,7 @@ class TlsClientAuthenticationTest { @BeforeEach void setUp() { service = new TlsClientAuthentication(); + Security.addProvider(new BouncyCastleFipsProvider()); } @Test @@ -38,4 +59,100 @@ void invalidCaThrowsInvalidClientDetailsException() { assertThatThrownBy(() -> service.validateClientCert(cert, config)) .hasMessageContaining("tls_client_auth"); } + + @Test + void validateClientCertSucceedsWhenChainOmitsTrustAnchor() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert( + new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertSucceedsWhenChainIncludesTrustAnchor() throws Exception { + // Reproduces the reviewer's concern (PR #3972 discussion on TlsClientAuthentication.java:113): + // some proxies/clients forward the full chain including the trust anchor / root CA itself. + // RFC 5280 section 6.1 excludes trailing self-issued certificates from path validation + // accounting, and the JDK's PKIX CertPathValidator correctly implements this, so no + // stripping of the anchor from the presented chain is required. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + // Chain includes the root CA cert itself, at the tail — unlike the "omits" test above. + Optional result = service.validateClientCert( + new X509Certificate[]{leafCert, rootCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertSucceedsWithIntermediateChainIncludingTrustAnchor() throws Exception { + // Same as above but with an intermediate CA between leaf and root, matching a more + // realistic multi-tier CA hierarchy. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair interKp = generateKeyPair(); + X500Name interName = new X500Name("CN=Test Intermediate CA"); + X509Certificate interCert = signCert(interName, rootName, interKp.getPublic(), rootKp.getPrivate(), true, BigInteger.TWO); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), interName, leafKp.getPublic(), interKp.getPrivate(), false, BigInteger.valueOf(3)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert( + new X509Certificate[]{leafCert, interCert, rootCert}, config); + + assertThat(result).contains(leafCert); + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + + private static String toPem(X509Certificate cert) throws Exception { + StringWriter sw = new StringWriter(); + try (PemWriter pemWriter = new PemWriter(sw)) { + pemWriter.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + return sw.toString(); + } } From afc88015bc81dc2d379fdde3b78ff4fbbc8e6166 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 18 Aug 2026 11:13:11 +0200 Subject: [PATCH 034/130] fix(review): run ClientCertificateMapper filter before Spring Security clientCertificateMapperFilter() registered at order=10, which runs *after* Spring Boot's Security filter (registered at the documented default order -100, per org.springframework.boot.security.autoconfigure.web.servlet .SecurityFilterProperties.DEFAULT_FILTER_ORDER -- verified against the Spring Boot 4.1.0 source). That meant the jakarta.servlet.request .X509Certificate request attribute derived from the X-Forwarded-Client-Cert header was not yet populated when ClientDetailsAuthenticationProvider / TlsClientAuthentication authenticated /oauth/mtls/token requests, breaking mTLS client auth. Set order to -200 so this filter runs strictly before Spring Security. Replaced the previous unit test's overly-loose assertion (isLessThan(100), trivially true for the buggy order=10) with behavioural tests that drive a real ClientCertificateMapper filter plus a stand-in Spring Security filter through an actual filter chain, using a real X-Forwarded-Client-Cert header: one proves the fix (attribute visible to the downstream filter), and a control test reproduces the old buggy order to prove the bug was real (attribute is null when Spring Security runs first). Addresses PR review comment on SpringServletXmlFiltersConfiguration.java:250. --- .../SpringServletXmlFiltersConfiguration.java | 8 +- .../ClientCertificateMapperFilterTest.java | 151 +++++++++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index 3ce4164bba5..4d8ee7e71ee 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -247,7 +247,13 @@ public FilterRegistrationBean clientCertificateMapperFil FilterRegistrationBean bean = new FilterRegistrationBean<>((jakarta.servlet.Filter) ctor.newInstance()); bean.addUrlPatterns("/oauth/mtls/*"); - bean.setOrder(10); + // Spring Boot registers its Security filter in the servlet container at order -100 + // (org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties + // .DEFAULT_FILTER_ORDER). This filter must run strictly before that so the + // jakarta.servlet.request.X509Certificate request attribute it derives from the + // XFCC header is already populated when ClientDetailsAuthenticationProvider / + // TlsClientAuthentication authenticate the /oauth/mtls/token request. + bean.setOrder(-200); return bean; } catch (ReflectiveOperationException e) { throw new IllegalStateException("Failed to instantiate ClientCertificateMapper", e); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java index aaccf250717..31c969dceca 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java @@ -1,13 +1,45 @@ package org.cloudfoundry.identity.uaa.oauth.tls; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.cloudfoundry.identity.uaa.SpringServletXmlFiltersConfiguration; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; class ClientCertificateMapperFilterTest { + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + @Test void clientCertificateMapperFilter_registersClientCertificateMapperForMtlsEndpoint() { SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); @@ -15,6 +47,123 @@ void clientCertificateMapperFilter_registersClientCertificateMapperForMtlsEndpoi assertThat(bean.getFilter().getClass().getName()) .isEqualTo("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); assertThat(bean.getUrlPatterns()).contains("/oauth/mtls/*"); - assertThat(bean.getOrder()).isLessThan(100); + } + + @Test + void clientCertificateMapperFilterPopulatesCertAttributeBeforeSpringSecurityRuns() throws Exception { + // Behavioural regression test for the ordering bug reported in PR review + // (SpringServletXmlFiltersConfiguration.java:250): the ClientCertificateMapper filter + // must run *before* Spring Boot's Security filter in servlet-container dispatch order + // (filters run in ascending getOrder() value), so that the + // jakarta.servlet.request.X509Certificate attribute it derives from the + // X-Forwarded-Client-Cert header is already populated when Spring Security's + // authentication logic (ClientDetailsAuthenticationProvider / TlsClientAuthentication) + // reads it for the /oauth/mtls/token request. + // + // Rather than asserting on the raw order integer in isolation, this drives a real + // two-filter chain -- the actual ClientCertificateMapper filter plus a stand-in for + // Spring Boot's registered Security filter at its real documented order (-100, + // org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties + // .DEFAULT_FILTER_ORDER) -- through a real request carrying a real X-Forwarded-Client-Cert + // header, and observes what the "security" filter actually sees. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + AtomicReference certSeenBySecurityFilter = new AtomicReference<>(); + FilterRegistrationBean securityFilterBean = + fakeSpringSecurityFilterBean(certSeenBySecurityFilter, -100); + + MockHttpServletRequest request = requestWithClientCertHeader(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(mapperBean, securityFilterBean), request, response); + + assertThat(certSeenBySecurityFilter.get()) + .as("X509Certificate request attribute must be populated before Spring Security's filter runs") + .isInstanceOf(X509Certificate[].class); + } + + @Test + void securityFilterOrderedFirstWouldNotSeeCertAttribute() throws Exception { + // Companion/control test proving the pre-fix behaviour really was broken: with the + // ClientCertificateMapper filter's order set the way it was before this fix (10, which + // is *after* Spring Security's -100), the cert attribute is not yet populated when + // Spring Security's filter runs. + Filter clientCertificateMapper = + new SpringServletXmlFiltersConfiguration().clientCertificateMapperFilter().getFilter(); + FilterRegistrationBean mapperBeanWithBuggyOrder = new FilterRegistrationBean<>(clientCertificateMapper); + mapperBeanWithBuggyOrder.setOrder(10); // the old, buggy order + + AtomicReference certSeenBySecurityFilter = new AtomicReference<>(); + FilterRegistrationBean securityFilterBean = + fakeSpringSecurityFilterBean(certSeenBySecurityFilter, -100); + + MockHttpServletRequest request = requestWithClientCertHeader(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(mapperBeanWithBuggyOrder, securityFilterBean), request, response); + + assertThat(certSeenBySecurityFilter.get()) + .as("with the old buggy order, Spring Security runs first and must NOT see the cert attribute yet") + .isNull(); + } + + private static FilterRegistrationBean fakeSpringSecurityFilterBean( + AtomicReference certSeenBySecurityFilter, int order) { + Filter fakeSpringSecurityFilter = (request, response, chain) -> { + certSeenBySecurityFilter.set( + ((HttpServletRequest) request).getAttribute("jakarta.servlet.request.X509Certificate")); + chain.doFilter(request, response); + }; + FilterRegistrationBean bean = new FilterRegistrationBean<>(fakeSpringSecurityFilter); + bean.setOrder(order); + return bean; + } + + /** + * Simulates servlet-container filter dispatch: registered filters run in ascending + * {@link FilterRegistrationBean#getOrder()} value, each delegating to the next via a + * standard {@link FilterChain}. + */ + private static void runContainerFilterChain( + List> registrations, + MockHttpServletRequest request, + MockHttpServletResponse response) throws Exception { + List> sorted = new ArrayList<>(registrations); + sorted.sort(Comparator.comparingInt(FilterRegistrationBean::getOrder)); + + FilterChain chain = (req, res) -> { }; + for (int i = sorted.size() - 1; i >= 0; i--) { + Filter filter = sorted.get(i).getFilter(); + FilterChain next = chain; + chain = (req, res) -> filter.doFilter(req, res, next); + } + chain.doFilter(request, response); + } + + private static MockHttpServletRequest requestWithClientCertHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(generateSelfSignedCert().getEncoded())); + return request; + } + + private static X509Certificate generateSelfSignedCert() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + X500Name name = new X500Name("CN=leaf-instance"); + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, BigInteger.ONE, notBefore, notAfter, name, kp.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(kp.getPrivate()); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); } } From 36f1f573e25d8dda94d73a6b3be7db05698fcfa4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 18 Aug 2026 11:48:38 +0200 Subject: [PATCH 035/130] fix(review): require tls_client_auth before deriving mTLS claims MtlsClaimsEnhancer only checked that a certificate header and TLS config were present, not that the client actually authenticated via tls_client_auth. Traced through ClientDetailsAuthenticationProvider .additionalAuthenticationChecks: the validateTlsClientAuth branch only runs when the request has no client_secret; a client configured with both a secret and tls-client-auth-ca could hit /oauth/mtls/token, authenticate with the secret (bypassing cert validation entirely), and still receive identity/cnf claims derived from an unvalidated, potentially harvested certificate. Reuse the existing UaaSecurityContextUtils.getClientAuthenticationMethod helper to require the OAuth2Request's client_auth_method extension is exactly tls_client_auth before deriving any claims. Fails closed when the extension is missing or null. Updated the shared mockAuthentication test helper to represent the legitimate tls_client_auth case (all prior tests still pass, now correctly modelling a validated mTLS authentication). Added tests reproducing the client_secret bypass and the missing-extension case, both failing for the expected reason before the fix. Addresses PR review comment on MtlsClaimsEnhancer.java:76. --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 14 +++- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index ba080b97a58..e165e53800c 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -2,10 +2,12 @@ import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.UaaTokenEnhancer; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.UaaSecurityContextUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -69,7 +71,12 @@ public Map getExternalAttributes(OAuth2Authentication authentica /** * Returns a map of additional top-level JWT claims derived from the client certificate. - * Returns an empty map when no certificate is present on the request. + * Returns an empty map when no certificate is present on the request, or when the client + * did not actually authenticate via {@code tls_client_auth} (e.g. a client with both a + * secret and TLS config configured that authenticated via {@code client_secret_basic} on + * the mTLS alias) — the certificate mapped onto the request in that case was never + * validated by {@link TlsClientAuthentication#validateClientCert}, so it must not be + * trusted as a source of identity claims. */ @Override public Map enhance(Map claims, OAuth2Authentication authentication) { @@ -78,6 +85,11 @@ public Map enhance(Map claims, OAuth2Authenticat return new HashMap<>(); } + if (!ClientAuthentication.TLS_CLIENT_AUTH.equals( + UaaSecurityContextUtils.getClientAuthenticationMethod(authentication))) { + return new HashMap<>(); + } + String clientId = authentication.getOAuth2Request().getClientId(); UaaClientDetails clientDetails; try { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 97ac7dd7403..0d1fbe8fae7 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -2,6 +2,7 @@ import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Request; @@ -9,12 +10,14 @@ import org.junit.jupiter.api.Test; import javax.security.auth.x500.X500Principal; +import java.io.Serializable; import java.security.cert.X509Certificate; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.CLIENT_AUTH_METHOD; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -405,6 +408,59 @@ void extractsOuValueContainingEscapedComma() throws Exception { assertThat(result).containsEntry("app_guid", "app-guid-123"); } + @Test + void enhanceReturnsEmptyWhenClientAuthenticatedViaClientSecretInsteadOfTlsClientAuth() throws Exception { + // Reproduces PR review concern (MtlsClaimsEnhancer.java:76): a client with both a + // client_secret AND tls-client-auth-ca configured could hit /oauth/mtls/token, present a + // harvested/unvalidated certificate (via the mapped X509Certificate attribute), but + // authenticate with the secret instead -- bypassing validateTlsClientAuth entirely. + // The enhancer must not derive identity/cnf claims from a certificate that was never + // actually validated as the proof of authentication. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=instance-guid, OU=app:app-guid-123, O=Cloud Foundry")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthenticationWithMethod("instance-identity", "client_secret_basic"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).doesNotContainKey("cf_instance_guid"); + assertThat(result).doesNotContainKey("cnf"); + assertThat(result).isEmpty(); + } + + @Test + void enhanceReturnsEmptyWhenClientAuthMethodExtensionIsMissing() throws Exception { + // Fail closed: if the client_auth_method extension isn't present at all (e.g. an older + // token-granting path that never set it), claims must not be derived either. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=instance-guid")); + when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthenticationWithMethod("instance-identity", null); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).isEmpty(); + } + private X509Certificate mockCfCert() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); @@ -426,8 +482,19 @@ private TlsClientAuthConfiguration cfMappingsConfig() { } private OAuth2Authentication mockAuthentication(String clientId) { + // Default: represents a client that actually authenticated via tls_client_auth, + // matching what the mTLS token endpoint's ClientDetailsAuthenticationProvider sets + // after validateTlsClientAuth succeeds. + return mockAuthenticationWithMethod(clientId, ClientAuthentication.TLS_CLIENT_AUTH); + } + + private OAuth2Authentication mockAuthenticationWithMethod(String clientId, String clientAuthMethod) { OAuth2Request request = mock(OAuth2Request.class); when(request.getClientId()).thenReturn(clientId); + Map extensions = clientAuthMethod == null + ? Map.of() + : Map.of(CLIENT_AUTH_METHOD, clientAuthMethod); + when(request.getExtensions()).thenReturn(extensions); OAuth2Authentication auth = mock(OAuth2Authentication.class); when(auth.getOAuth2Request()).thenReturn(request); return auth; From 7b6af4ffe5c13c652290185f066bdb7b340f622b Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 10:41:15 +0200 Subject: [PATCH 036/130] feat: configure Tomcat connector to capture client certs without CA validation when mTLS enabled --- gradle/libs.versions.toml | 1 + server/build.gradle.kts | 1 + .../MtlsClientAuthTomcatCustomizer.java | 45 ++++ ...ntAuthTomcatCustomizerIntegrationTest.java | 255 ++++++++++++++++++ .../MtlsClientAuthTomcatCustomizerTest.java | 37 +++ 5 files changed, 339 insertions(+) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 047ab15374a..08f9364dba0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -168,6 +168,7 @@ springBootStarterMail = { module = "org.springframework.boot:spring-boot-starter springBootStarterTest = { module = "org.springframework.boot:spring-boot-starter-test" } springBootStarterTomcatRuntime = { module = "org.springframework.boot:spring-boot-starter-tomcat-runtime" } springBootStarterWeb = { module = "org.springframework.boot:spring-boot-starter-web" } +springBootTomcat = { module = "org.springframework.boot:spring-boot-tomcat" } springBootTransaction = { module = "org.springframework.boot:spring-boot-transaction" } # Spring Data diff --git a/server/build.gradle.kts b/server/build.gradle.kts index b511bedc264..6adca444f0f 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(libs.springBootStarterMail) implementation(libs.springBootSql) implementation(libs.springBootJdbc) + implementation(libs.springBootTomcat) implementation(libs.springBootTransaction) implementation(libs.openSamlApi) implementation(libs.springSecuritySamlServiceProvider) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java new file mode 100644 index 00000000000..58b826a9696 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -0,0 +1,45 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.tomcat.util.net.SSLHostConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.stereotype.Component; + +/** + * When {@code uaa.mtls-enabled} is true, configures the embedded Tomcat connector to request a client + * certificate during the TLS handshake without validating it against any CA at the TLS layer + * ({@code certificateVerification=optionalNoCA}) -- proof of private-key possession still happens as + * part of the handshake itself, but the trust decision (which CA, if any, is acceptable) is deferred + * entirely to per-client application logic in {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication}. + * + *

This is deliberately different from Spring Boot's own {@code server.ssl.client-auth} property, + * which only supports Tomcat's {@code none}/{@code optional}/{@code required} verification levels -- + * not {@code optionalNoCA}. A static, deploy-time CA truststore was considered and rejected: it would + * need to be kept in sync with whatever CA(s) are configured per-client at runtime via the client-admin + * API, which is an operational hazard. + * + *

Runs after Spring Boot's own SSL connector configuration so it can override the already-configured + * {@link SSLHostConfig}(s) on the connector. + */ +@Component +public class MtlsClientAuthTomcatCustomizer implements WebServerFactoryCustomizer { + + private final boolean mtlsEnabled; + + public MtlsClientAuthTomcatCustomizer(@Value("${uaa.mtls-enabled:false}") boolean mtlsEnabled) { + this.mtlsEnabled = mtlsEnabled; + } + + @Override + public void customize(TomcatServletWebServerFactory factory) { + if (!mtlsEnabled) { + return; + } + factory.addConnectorCustomizers(connector -> { + for (SSLHostConfig sslHostConfig : connector.findSslHostConfigs()) { + sslHostConfig.setCertificateVerification("optionalNoCA"); + } + }); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java new file mode 100644 index 00000000000..57d0ff512eb --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java @@ -0,0 +1,255 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.Ssl; +import org.springframework.boot.web.server.WebServer; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509TrustManager; +import java.io.FileOutputStream; +import java.math.BigInteger; +import java.net.Socket; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Empirically verifies the customizer-ordering assumption underlying {@link MtlsClientAuthTomcatCustomizer}: + * that a {@code TomcatConnectorCustomizer} registered via {@code addConnectorCustomizers} runs + * after Spring Boot's own SSL bundle configuration has populated the connector's + * {@code SSLHostConfig}(s) -- so overriding {@code certificateVerification} there actually takes + * effect against a real embedded Tomcat TLS handshake, not just against a manually constructed + * {@code SSLHostConfig} in isolation (see {@link MtlsClientAuthTomcatCustomizerTest}). + * + *

Uses a real {@link SSLSocket} handshake against a real embedded Tomcat connector. Whether the + * server actually sent a {@code CertificateRequest} is detected from the client side: JSSE only + * invokes the client {@link X509ExtendedKeyManager#chooseClientAlias} callback when the server + * requested a certificate during the handshake. + */ +class MtlsClientAuthTomcatCustomizerIntegrationTest { + + private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray(); + + @TempDir + Path tempDir; + + private WebServer webServer; + + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @AfterEach + void tearDown() { + if (webServer != null) { + webServer.stop(); + } + } + + @Test + void requestsAndAcceptsAnUntrustedClientCertificateWhenMtlsEnabled() throws Exception { + int port = startServer(true); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); + + try (SSLSocket socket = clientSocketPresentingArbitraryCert(port, clientCertRequested)) { + socket.startHandshake(); + } + + assertThat(clientCertRequested) + .as("server should have requested a client certificate (certificateVerification=optionalNoCA)") + .isTrue(); + } + + @Test + void doesNotRequestAClientCertificateWhenMtlsDisabled() throws Exception { + int port = startServer(false); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); + + try (SSLSocket socket = clientSocketPresentingArbitraryCert(port, clientCertRequested)) { + socket.startHandshake(); + } + + assertThat(clientCertRequested) + .as("server should not request a client certificate (certificateVerification=none, the Tomcat default)") + .isFalse(); + } + + private int startServer(boolean mtlsEnabled) throws Exception { + KeyPair serverKeyPair = generateKeyPair(); + X500Name serverName = new X500Name("CN=localhost"); + X509Certificate serverCert = signCert(serverName, serverName, serverKeyPair.getPublic(), serverKeyPair.getPrivate(), BigInteger.ONE); + + Path keystorePath = tempDir.resolve("server.p12"); + KeyStore serverKeyStore = KeyStore.getInstance("PKCS12"); + serverKeyStore.load(null, null); + serverKeyStore.setKeyEntry("server", serverKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{serverCert}); + try (FileOutputStream out = new FileOutputStream(keystorePath.toFile())) { + serverKeyStore.store(out, KEYSTORE_PASSWORD); + } + + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + Ssl ssl = new Ssl(); + ssl.setEnabled(true); + ssl.setKeyStore(keystorePath.toString()); + ssl.setKeyStorePassword(new String(KEYSTORE_PASSWORD)); + ssl.setKeyAlias("server"); + ssl.setKeyStoreType("PKCS12"); + factory.setSsl(ssl); + + // This is the exact bean-under-test, invoked exactly as Spring would invoke any + // WebServerFactoryCustomizer -- after Spring Boot's own + // SSL auto-configuration would have already called factory.setSsl(...) above. + new MtlsClientAuthTomcatCustomizer(mtlsEnabled).customize(factory); + + webServer = factory.getWebServer(); + webServer.start(); + return webServer.getPort(); + } + + private SSLSocket clientSocketPresentingArbitraryCert(int port, AtomicBoolean clientCertRequested) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), BigInteger.TWO); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + KeyManager[] trackingKeyManagers = trackClientAliasRequests(keyManagerFactory.getKeyManagers(), clientCertRequested); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackingKeyManagers, new TrustManager[]{trustAnyServerCertificate()}, null); + + return (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + } + + /** + * Wraps each {@link X509ExtendedKeyManager} so we can observe -- from the client side -- whether + * the server ever asked for a client certificate during the handshake. JSSE only calls + * {@code chooseClientAlias}/{@code chooseEngineClientAlias} when the server sent a + * {@code CertificateRequest}, which is exactly the behavior {@link MtlsClientAuthTomcatCustomizer} + * is meant to control. + */ + private KeyManager[] trackClientAliasRequests(KeyManager[] keyManagers, AtomicBoolean clientCertRequested) { + KeyManager[] wrapped = new KeyManager[keyManagers.length]; + for (int i = 0; i < keyManagers.length; i++) { + if (keyManagers[i] instanceof X509ExtendedKeyManager delegate) { + wrapped[i] = new X509ExtendedKeyManager() { + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + clientCertRequested.set(true); + return delegate.chooseClientAlias(keyType, issuers, socket); + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + clientCertRequested.set(true); + return delegate.chooseEngineClientAlias(keyType, issuers, engine); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate.getClientAliases(keyType, issuers); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return delegate.chooseServerAlias(keyType, issuers, socket); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate.getServerAliases(keyType, issuers); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + }; + } else { + wrapped[i] = keyManagers[i]; + } + } + return wrapped; + } + + private TrustManager trustAnyServerCertificate() { + return new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // not used: this is the client-side trust manager for the server's cert + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // the server cert is self-signed and not in any trust store; accept it for this test + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, BigInteger serial) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java new file mode 100644 index 00000000000..d095f9dc64a --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -0,0 +1,37 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.catalina.connector.Connector; +import org.apache.tomcat.util.net.SSLHostConfig; +import org.junit.jupiter.api.Test; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +class MtlsClientAuthTomcatCustomizerTest { + + @Test + void setsOptionalNoCaWhenMtlsEnabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + + customizer.customize(factory); + + Connector connector = new Connector(); + SSLHostConfig sslHostConfig = new SSLHostConfig(); + connector.addSslHostConfig(sslHostConfig); + factory.getConnectorCustomizers().forEach(c -> c.customize(connector)); + + assertThat(sslHostConfig.getCertificateVerification()) + .isEqualTo(SSLHostConfig.CertificateVerification.OPTIONAL_NO_CA); + } + + @Test + void doesNothingWhenMtlsDisabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(false); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + + customizer.customize(factory); + + assertThat(factory.getConnectorCustomizers()).isEmpty(); + } +} From 3e500b2e33e223ac3eb560feea45296c02723f88 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 10:57:39 +0200 Subject: [PATCH 037/130] fix(review): parameterize isCa in signCert instead of hardcoding true Code review follow-up on 7b6af4ffe: signCert always marked certificates as CA certs via BasicConstraints(true), even for the self-signed leaf server and client certs used in this test. Harmless here (optionalNoCA skips CA validation entirely) but a misleading precedent inconsistent with the established convention in TlsClientAuthenticationTest.java, where signCert correctly parameterizes isCa. --- .../MtlsClientAuthTomcatCustomizerIntegrationTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java index 57d0ff512eb..0afaf677e57 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java @@ -107,7 +107,7 @@ void doesNotRequestAClientCertificateWhenMtlsDisabled() throws Exception { private int startServer(boolean mtlsEnabled) throws Exception { KeyPair serverKeyPair = generateKeyPair(); X500Name serverName = new X500Name("CN=localhost"); - X509Certificate serverCert = signCert(serverName, serverName, serverKeyPair.getPublic(), serverKeyPair.getPrivate(), BigInteger.ONE); + X509Certificate serverCert = signCert(serverName, serverName, serverKeyPair.getPublic(), serverKeyPair.getPrivate(), false, BigInteger.ONE); Path keystorePath = tempDir.resolve("server.p12"); KeyStore serverKeyStore = KeyStore.getInstance("PKCS12"); @@ -139,7 +139,7 @@ private int startServer(boolean mtlsEnabled) throws Exception { private SSLSocket clientSocketPresentingArbitraryCert(int port, AtomicBoolean clientCertRequested) throws Exception { KeyPair clientKeyPair = generateKeyPair(); X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); - X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), BigInteger.TWO); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.TWO); KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); clientKeyStore.load(null, null); @@ -238,12 +238,12 @@ private static KeyPair generateKeyPair() throws Exception { } private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, - PrivateKey signerKey, BigInteger serial) throws Exception { + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { Date notBefore = new Date(System.currentTimeMillis() - 60_000); Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( issuer, serial, notBefore, notAfter, subject, subjectKey); - builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) .build(signerKey); From 2855b3a3ba018b3ce322cdeb30046c0c6c8b8f61 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:00:58 +0200 Subject: [PATCH 038/130] feat: add per-client tls-client-auth-trusted-proxy-ca to TlsClientAuthConfiguration --- .../client/TlsClientAuthConfiguration.java | 12 ++++++++-- .../TlsClientAuthConfigurationTest.java | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java index 0d8a1f5b102..8d3e75c0c36 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -15,6 +15,7 @@ public class TlsClientAuthConfiguration { public static final String TLS_CLIENT_AUTH_CLAIM_MAPPINGS = "tls-client-auth-claim-mappings"; public static final String TLS_CLIENT_AUTH_SUB_TEMPLATE = "tls-client-auth-sub-template"; public static final String TLS_CLIENT_AUTH_AUD_TEMPLATES = "tls-client-auth-aud-templates"; + public static final String TLS_CLIENT_AUTH_TRUSTED_PROXY_CA = "tls-client-auth-trusted-proxy-ca"; @JsonProperty(TLS_CLIENT_AUTH_CA) private String trustedCaPem; @@ -28,6 +29,9 @@ public class TlsClientAuthConfiguration { @JsonProperty(TLS_CLIENT_AUTH_AUD_TEMPLATES) private List audTemplates; + @JsonProperty(TLS_CLIENT_AUTH_TRUSTED_PROXY_CA) + private String trustedProxyCaPem; + public TlsClientAuthConfiguration() {} public TlsClientAuthConfiguration(String trustedCaPem, List claimMappings) { @@ -47,6 +51,9 @@ public TlsClientAuthConfiguration(String trustedCaPem, List claimM public List getAudTemplates() { return audTemplates; } public void setAudTemplates(List audTemplates) { this.audTemplates = audTemplates; } + public String getTrustedProxyCaPem() { return trustedProxyCaPem; } + public void setTrustedProxyCaPem(String trustedProxyCaPem) { this.trustedProxyCaPem = trustedProxyCaPem; } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -54,12 +61,13 @@ public boolean equals(Object o) { return Objects.equals(trustedCaPem, that.trustedCaPem) && Objects.equals(claimMappings, that.claimMappings) && Objects.equals(subTemplate, that.subTemplate) && - Objects.equals(audTemplates, that.audTemplates); + Objects.equals(audTemplates, that.audTemplates) && + Objects.equals(trustedProxyCaPem, that.trustedProxyCaPem); } @Override public int hashCode() { - return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates); + return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates, trustedProxyCaPem); } public static boolean isConfigured(TlsClientAuthConfiguration config) { diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java index 98fbceae9e2..8d4887393a4 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -137,4 +137,28 @@ void equalityIncludesSubTemplateAndAudTemplates() { assertThat(a).isNotEqualTo(c); assertThat(a).isNotEqualTo(d); } + + @Test + void trustedProxyCaPemRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config.setTrustedProxyCaPem("-----BEGIN CERTIFICATE-----\nPROXY\n-----END CERTIFICATE-----\n"); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getTrustedProxyCaPem()) + .isEqualTo("-----BEGIN CERTIFICATE-----\nPROXY\n-----END CERTIFICATE-----\n"); + assertThat(json).contains("tls-client-auth-trusted-proxy-ca"); + } + + @Test + void unequalWhenTrustedProxyCaPemDiffers() { + TlsClientAuthConfiguration config1 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config1.setTrustedProxyCaPem("proxy-ca-1"); + TlsClientAuthConfiguration config2 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config2.setTrustedProxyCaPem("proxy-ca-2"); + + assertThat(config1).isNotEqualTo(config2); + } } From 3abec45210f578612fb2b96e7f39e1ea7fb805ab Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:08:31 +0200 Subject: [PATCH 039/130] feat: read tls-client-auth-trusted-proxy-ca in ClientDetailsAuthenticationProvider --- .../ClientDetailsAuthenticationProvider.java | 7 +++++ ...entDetailsAuthenticationProviderTests.java | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index a2497cac165..d01e1013b46 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -244,9 +244,16 @@ static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaCli new TypeReference>() {}); } + String trustedProxyCaPem = null; + Object rawTrustedProxyCa = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + if (rawTrustedProxyCa instanceof String tpc && !tpc.isBlank()) { + trustedProxyCaPem = tpc; + } + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); cfg.setSubTemplate(subTemplate); cfg.setAudTemplates(audTemplates); + cfg.setTrustedProxyCaPem(trustedProxyCaPem); return cfg; } catch (Exception e) { return null; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java index 2b7c5a22319..86031b6561d 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -47,4 +47,33 @@ void tlsConfigIsDeserializedFromRawMapInAdditionalInfo() { assertThat(config.getTrustedCaPem()) .isEqualTo("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"); } + + @Test + void getTlsClientAuthConfigurationReadsTrustedProxyCaFromFlatStringPath() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getTrustedProxyCaPem()).isEqualTo("proxy-ca-pem"); + } + + @Test + void getTlsClientAuthConfigurationTrustedProxyCaNullWhenAbsent() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getTrustedProxyCaPem()).isNull(); + } } From d182fa7e2e76c92a647ff77ae20c44d7662cd594 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:17:00 +0200 Subject: [PATCH 040/130] feat: read tls-client-auth-trusted-proxy-ca in MtlsClaimsEnhancer.loadTlsConfig --- .../identity/uaa/oauth/tls/MtlsClaimsEnhancer.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index e165e53800c..a15edd723b0 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -348,9 +348,16 @@ private static TlsClientAuthConfiguration loadTlsConfig(Map info new TypeReference>() {}); } + String trustedProxyCaPem = null; + Object rawTrustedProxyCa = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + if (rawTrustedProxyCa instanceof String tpc && !tpc.isBlank()) { + trustedProxyCaPem = tpc; + } + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); cfg.setSubTemplate(subTemplate); cfg.setAudTemplates(audTemplates); + cfg.setTrustedProxyCaPem(trustedProxyCaPem); return cfg; } catch (Exception e) { return null; From 90b506bdbe5ea39a81233ee4a6616a3ba0915fee Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:25:21 +0200 Subject: [PATCH 041/130] feat: capture genuine TLS peer certificate before ClientCertificateMapper overwrites it --- .../tls/RawPeerCertificateCaptureFilter.java | 44 +++++++++++++++++++ .../RawPeerCertificateCaptureFilterTest.java | 43 ++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java new file mode 100644 index 00000000000..e799ebc1730 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java @@ -0,0 +1,44 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +import java.io.IOException; + +/** + * Captures the genuine TLS-handshake peer certificate before the downstream {@code ClientCertificateMapper} + * filter has a chance to overwrite it. + * + *

With {@code certificateVerification=optionalNoCA} configured on the servlet container, the container + * populates the standard {@code jakarta.servlet.request.X509Certificate} request attribute with whatever + * certificate the immediate TLS peer actually presented during the handshake (e.g. the Gorouter's + * {@code gorouter_backend_tls} client cert). The {@code ClientCertificateMapper} filter (registered at + * order -200 in {@code SpringServletXmlFiltersConfiguration}) later overwrites that same attribute with a + * certificate it decodes from the {@code X-Forwarded-Client-Cert} header whenever that header is present, + * discarding the genuine handshake value. + * + *

This filter must be registered to run before {@code ClientCertificateMapper} so that the + * genuine peer certificate is preserved in a dedicated attribute + * ({@link #RAW_PEER_CERTIFICATE_ATTRIBUTE}). Later code can then compare "what the immediate TLS peer + * actually presented" against "what the {@code X-Forwarded-Client-Cert} header claims" to confirm the + * header was genuinely set by a trusted proxy (e.g. the Gorouter) rather than a direct caller spoofing it. + * That comparison is expected to be implemented by a future + * {@code TlsClientAuthentication.isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} method. + */ +public class RawPeerCertificateCaptureFilter implements Filter { + + public static final String RAW_PEER_CERTIFICATE_ATTRIBUTE = + "org.cloudfoundry.identity.uaa.oauth.tls.rawPeerCertificate"; + + private static final String X509_CERTIFICATE_ATTRIBUTE = "jakarta.servlet.request.X509Certificate"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + request.setAttribute(RAW_PEER_CERTIFICATE_ATTRIBUTE, request.getAttribute(X509_CERTIFICATE_ATTRIBUTE)); + chain.doFilter(request, response); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java new file mode 100644 index 00000000000..74a3cf39c8e --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java @@ -0,0 +1,43 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RawPeerCertificateCaptureFilterTest { + + @Test + void copiesGenuinePeerCertificateIntoDedicatedAttributeBeforeChainContinues() throws Exception { + X509Certificate[] genuinePeerCert = new X509Certificate[]{mock(X509Certificate.class)}; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute("jakarta.servlet.request.X509Certificate", genuinePeerCert); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + new RawPeerCertificateCaptureFilter().doFilter(request, response, chain); + + assertThat(request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)) + .isEqualTo(genuinePeerCert); + verify(chain).doFilter(request, response); + } + + @Test + void setsNullAttributeWhenNoPeerCertificatePresent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + new RawPeerCertificateCaptureFilter().doFilter(request, response, chain); + + assertThat(request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)) + .isNull(); + verify(chain).doFilter(request, response); + } +} From 84125c1d3fd16212806e7f0fe8467a33c76c2e30 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:35:20 +0200 Subject: [PATCH 042/130] feat: register RawPeerCertificateCaptureFilter before ClientCertificateMapper --- .../SpringServletXmlFiltersConfiguration.java | 13 +++++++++++ ...tificateCaptureFilterRegistrationTest.java | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index 4d8ee7e71ee..0960bea07dc 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -6,6 +6,7 @@ import org.cloudfoundry.identity.uaa.metrics.UaaMetricsFilter; import org.cloudfoundry.identity.uaa.metrics.UaaMetricsManagedBean; import org.cloudfoundry.identity.uaa.oauth.DisableIdTokenResponseTypeFilter; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning; import org.cloudfoundry.identity.uaa.ratelimiting.RateLimitingFilter; import org.cloudfoundry.identity.uaa.scim.DisableInternalUserManagementFilter; @@ -232,6 +233,18 @@ public FilterRegistrationBean httpHeaderSecurityFilter return bean; } + @Bean + public FilterRegistrationBean rawPeerCertificateCaptureFilter() { + FilterRegistrationBean bean = + new FilterRegistrationBean<>(new RawPeerCertificateCaptureFilter()); + bean.addUrlPatterns("/oauth/mtls/*"); + // Must run before clientCertificateMapperFilter() (order -200) so it captures the genuine + // TLS-handshake peer certificate before that filter overwrites the same standard + // jakarta.servlet.request.X509Certificate attribute with the XFCC-header-derived certificate. + bean.setOrder(-300); + return bean; + } + @Bean public FilterRegistrationBean clientCertificateMapperFilter() { // ClientCertificateMapper is a package-private final class in diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java new file mode 100644 index 00000000000..683c57dbaa0 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java @@ -0,0 +1,22 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.SpringServletXmlFiltersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; + +import static org.assertj.core.api.Assertions.assertThat; + +class RawPeerCertificateCaptureFilterRegistrationTest { + + @Test + void rawPeerCertificateCaptureFilterRunsBeforeClientCertificateMapper() { + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + assertThat(captureBean.getFilter()).isInstanceOf(RawPeerCertificateCaptureFilter.class); + assertThat(captureBean.getUrlPatterns()).contains("/oauth/mtls/*"); + assertThat(captureBean.getOrder()).isLessThan(mapperBean.getOrder()); + } +} From e65f611d290b80391bc4845a71e21325649b2fb9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:42:30 +0200 Subject: [PATCH 043/130] fix(review): add behavioral proof that captured attribute survives ClientCertificateMapper Code review follow-up on 84125c1d3: the original test only compared order integers, which would pass even if the two filters' attribute-handling interaction were subtly broken. Adds a real two-filter chain test (mirroring the established pattern in ClientCertificateMapperFilterTest) proving RAW_PEER_CERTIFICATE_ATTRIBUTE retains the genuine peer cert after ClientCertificateMapper overwrites the standard jakarta.servlet.request .X509Certificate attribute with a different, XFCC-derived certificate. --- ...tificateCaptureFilterRegistrationTest.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java index 683c57dbaa0..09e16e8d37e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java @@ -1,13 +1,43 @@ package org.cloudfoundry.identity.uaa.oauth.tls; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.cloudfoundry.identity.uaa.SpringServletXmlFiltersConfiguration; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.Date; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class RawPeerCertificateCaptureFilterRegistrationTest { + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + @Test void rawPeerCertificateCaptureFilterRunsBeforeClientCertificateMapper() { SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); @@ -19,4 +49,83 @@ void rawPeerCertificateCaptureFilterRunsBeforeClientCertificateMapper() { assertThat(captureBean.getUrlPatterns()).contains("/oauth/mtls/*"); assertThat(captureBean.getOrder()).isLessThan(mapperBean.getOrder()); } + + @Test + void capturedAttributeSurvivesClientCertificateMapperOverwritingTheStandardAttribute() throws Exception { + // Behavioural proof, not just an order-integer comparison: simulates a real TLS handshake + // having already populated the standard jakarta.servlet.request.X509Certificate attribute + // with the genuine peer certificate (as Tomcat would when uaa.mtls-enabled configures + // certificateVerification=optionalNoCA), then runs the real two-filter chain -- this filter + // followed by the real ClientCertificateMapper -- with an X-Forwarded-Client-Cert header + // present (a *different* certificate than the genuine peer one). ClientCertificateMapper is + // expected to overwrite the standard attribute with the XFCC-derived certificate, while + // RAW_PEER_CERTIFICATE_ATTRIBUTE must retain the original, genuine peer certificate. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + X509Certificate genuinePeerCert = generateSelfSignedCert("CN=gorouter"); + X509Certificate xfccDerivedCert = generateSelfSignedCert("CN=app-instance"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + // Simulates what Tomcat's TLS handshake would have already set before any filter runs. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{genuinePeerCert}); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(xfccDerivedCert.getEncoded())); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(captureBean, mapperBean), request, response); + + X509Certificate[] capturedRawPeerCert = + (X509Certificate[]) request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + X509Certificate[] finalStandardAttribute = + (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); + + assertThat(capturedRawPeerCert) + .as("RAW_PEER_CERTIFICATE_ATTRIBUTE must retain the genuine TLS peer cert, unaffected by ClientCertificateMapper") + .containsExactly(genuinePeerCert); + assertThat(finalStandardAttribute) + .as("ClientCertificateMapper should still overwrite the standard attribute with the XFCC-derived cert") + .containsExactly(xfccDerivedCert); + } + + /** + * Simulates servlet-container filter dispatch: registered filters run in ascending + * {@link FilterRegistrationBean#getOrder()} value, each delegating to the next via a standard + * {@link FilterChain}. + */ + private static void runContainerFilterChain( + List> registrations, + MockHttpServletRequest request, + MockHttpServletResponse response) throws Exception { + List> sorted = new ArrayList<>(registrations); + sorted.sort(Comparator.comparingInt(FilterRegistrationBean::getOrder)); + + FilterChain chain = (req, res) -> { }; + for (int i = sorted.size() - 1; i >= 0; i--) { + Filter filter = sorted.get(i).getFilter(); + FilterChain next = chain; + chain = (req, res) -> filter.doFilter(req, res, next); + } + chain.doFilter(request, response); + } + + private static X509Certificate generateSelfSignedCert(String subjectDn) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + X500Name name = new X500Name(subjectDn); + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, BigInteger.ONE, notBefore, notAfter, name, kp.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(kp.getPrivate()); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } } From 1a19dbb2bfd4f6f48a0746f9dbe16e104656a6f2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:48:06 +0200 Subject: [PATCH 044/130] feat: validate raw TLS peer certificate against per-client trusted-proxy CA --- .../oauth/tls/TlsClientAuthentication.java | 135 ++++++++++++-- .../tls/TlsClientAuthenticationTest.java | 169 ++++++++++++++++++ 2 files changed, 291 insertions(+), 13 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index ee187b9127f..aa8028cc6dc 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -64,6 +64,103 @@ public X509Certificate[] getCertificateChainFromRequest() { return (certs != null && certs.length > 0) ? certs : null; } + /** + * Returns {@code true} when any certificate derived from the {@code X-Forwarded-Client-Cert} + * header is present on the current request, regardless of whether it is trustworthy. This is a + * cheap, non-trust-deciding presence check intended as an early exit before resolving a client's + * {@link TlsClientAuthConfiguration} (which may require a database lookup) -- it does not grant or + * imply any authorization by itself, since any caller (trusted or not) that sets the header will + * cause the servlet container to populate this attribute. + */ + public boolean hasCertificateFromRequest() { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return false; + } + Object certs = attrs.getRequest().getAttribute("jakarta.servlet.request.X509Certificate"); + return certs instanceof X509Certificate[] arr && arr.length > 0; + } + + /** + * Returns the first X.509 certificate from the current request that is trustworthy for + * {@code clientConfig} -- i.e. only when {@link #isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} + * is {@code true} for this client's configuration. + * + * @return the client certificate, or {@code null} if none is present or not from a trusted proxy + */ + public X509Certificate getCertificateFromRequest(TlsClientAuthConfiguration clientConfig) { + X509Certificate[] chain = getCertificateChainFromRequest(clientConfig); + return (chain != null && chain.length > 0) ? chain[0] : null; + } + + /** + * Returns the full X.509 certificate chain from the current request's + * {@code jakarta.servlet.request.X509Certificate} attribute (populated by the + * {@code ClientCertificateMapper} filter), but only when + * {@link #isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} is {@code true} for + * {@code clientConfig} -- i.e. only when the genuine TLS-handshake peer presented a certificate + * signed by this specific client's {@code tls-client-auth-trusted-proxy-ca}. This prevents a direct + * caller (bypassing the Gorouter) from having a self-supplied {@code X-Forwarded-Client-Cert} + * header trusted. Index 0 is the end-entity (leaf) certificate. + * + * @return the client certificate chain, or {@code null} if none is present or not from a trusted proxy + */ + public X509Certificate[] getCertificateChainFromRequest(TlsClientAuthConfiguration clientConfig) { + if (!isCertificateFromTrustedProxy(clientConfig)) { + return null; + } + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return null; + } + HttpServletRequest request = attrs.getRequest(); + X509Certificate[] certs = (X509Certificate[]) + request.getAttribute("jakarta.servlet.request.X509Certificate"); + return (certs != null && certs.length > 0) ? certs : null; + } + + /** + * Returns {@code true} only when the genuine TLS-handshake peer certificate captured by + * {@link RawPeerCertificateCaptureFilter} (the certificate the immediate TCP peer actually + * presented during the TLS handshake -- distinct from any certificate derived from the + * {@code X-Forwarded-Client-Cert} header) validates against {@code clientConfig}'s + * {@code tls-client-auth-trusted-proxy-ca}. + * + *

Scoped per-client (like {@code tls-client-auth-ca}) rather than a single global CA: the value + * is stored in {@code additionalInformation} and is therefore API-mutable at runtime. The Tomcat + * connector (see {@code MtlsClientAuthTomcatCustomizer}) performs no CA validation at the TLS layer + * at all ({@code certificateVerification=optionalNoCA}), so there is no static allowlist that could + * drift out of sync with this per-client value. + * + * @return {@code false} if {@code clientConfig} is {@code null}, has no + * {@code tls-client-auth-trusted-proxy-ca} configured, or there is no current request or no + * captured peer certificate + */ + public boolean isCertificateFromTrustedProxy(TlsClientAuthConfiguration clientConfig) { + String trustedProxyCaPem = clientConfig != null ? clientConfig.getTrustedProxyCaPem() : null; + if (trustedProxyCaPem == null || trustedProxyCaPem.isBlank()) { + return false; + } + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return false; + } + Object raw = attrs.getRequest() + .getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + if (!(raw instanceof X509Certificate[] peerChain) || peerChain.length == 0) { + return false; + } + try { + X509Certificate caCert = parsePemCertificate(trustedProxyCaPem); + return validateCertPath(peerChain, caCert).isPresent(); + } catch (Exception e) { + return false; + } + } + /** * Validates {@code clientCert} against the trusted CA PEM configured in {@code config} * using PKIX path validation. @@ -101,19 +198,7 @@ public Optional validateClientCert( try { X509Certificate caCert = parsePemCertificate(config.getTrustedCaPem()); - - TrustAnchor anchor = new TrustAnchor(caCert, null); - PKIXParameters params = new PKIXParameters(Set.of(anchor)); - params.setRevocationEnabled(false); - - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - var certPath = cf.generateCertPath(Arrays.asList(chain)); - - CertPathValidator validator = CertPathValidator.getInstance("PKIX"); - validator.validate(certPath, params); - - return Optional.of(chain[0]); - + return validateCertPath(chain, caCert); } catch (CertPathValidatorException e) { throw new InvalidClientDetailsException( "tls_client_auth: certificate chain validation failed: " + e.getMessage()); @@ -123,6 +208,30 @@ public Optional validateClientCert( } } + /** + * Validates {@code chain} against {@code caCert} using PKIX path validation, without requiring any + * per-client {@link TlsClientAuthConfiguration}. Shared by {@link #validateClientCert} and + * {@link #isCertificateFromTrustedProxy}. + * + * @return {@code Optional.of(chain[0])} when validation succeeds + * @throws CertPathValidatorException if the chain does not validate against {@code caCert} + * @throws Exception if {@code caCert} or the PKIX machinery is misconfigured + */ + private static Optional validateCertPath(X509Certificate[] chain, X509Certificate caCert) + throws Exception { + TrustAnchor anchor = new TrustAnchor(caCert, null); + PKIXParameters params = new PKIXParameters(Set.of(anchor)); + params.setRevocationEnabled(false); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + var certPath = cf.generateCertPath(Arrays.asList(chain)); + + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + validator.validate(certPath, params); + + return Optional.of(chain[0]); + } + private static X509Certificate parsePemCertificate(String pem) throws Exception { try (PEMParser parser = new PEMParser(new StringReader(pem))) { Object obj = parser.readObject(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 7fc5cfd6ef2..164b6734d18 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -14,6 +14,9 @@ import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import java.io.StringWriter; import java.math.BigInteger; @@ -126,6 +129,172 @@ void validateClientCertSucceedsWithIntermediateChainIncludingTrustAnchor() throw assertThat(result).contains(leafCert); } + @Test + void isCertificateFromTrustedProxyTrueWhenPeerCertSignedByClientsTrustedProxyCa() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isTrue(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertNotSignedByClientsTrustedProxyCa() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + // An unrelated, self-signed certificate -- e.g. a harvested cert an attacker presents directly. + KeyPair attackerKp = generateKeyPair(); + X500Name attackerName = new X500Name("CN=attacker"); + X509Certificate attackerCert = signCert(attackerName, attackerName, attackerKp.getPublic(), attackerKp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{attackerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenNoPeerCertificatePresent() { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem("some-ca-pem"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenClientHasNoTrustedProxyCaConfigured() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Some CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + // Client has tls-client-auth-ca configured but no tls-client-auth-trusted-proxy-ca. + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenConfigIsNull() { + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(null)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void hasCertificateFromRequestTrueWhenXfccDerivedCertPresent() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute("jakarta.servlet.request.X509Certificate", + new X509Certificate[]{mock(X509Certificate.class)}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.hasCertificateFromRequest()).isTrue(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void hasCertificateFromRequestFalseWhenNoCertPresent() { + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.hasCertificateFromRequest()).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsNullWhenNotFromClientsTrustedProxy() { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + // no trusted-proxy CA configured for this client -> never trusted + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute("jakarta.servlet.request.X509Certificate", + new X509Certificate[]{mock(X509Certificate.class)}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsChainWhenFromClientsTrustedProxy() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + X509Certificate[] xfccDerivedChain = new X509Certificate[]{mock(X509Certificate.class)}; + MockHttpServletRequest request = new MockHttpServletRequest(); + // The genuine TLS peer cert (captured separately) validates against this client's trusted-proxy CA... + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + // ...so the XFCC-header-derived certificate (a completely different value) is trusted too. + request.setAttribute("jakarta.servlet.request.X509Certificate", xfccDerivedChain); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isEqualTo(xfccDerivedChain); + assertThat(service.getCertificateFromRequest(config)).isEqualTo(xfccDerivedChain[0]); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + private static KeyPair generateKeyPair() throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); kpg.initialize(2048); From f61ff3ab9350d6f89530bffe4a5253cde7e00bbd Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 11:58:51 +0200 Subject: [PATCH 045/130] fix(review): log swallowed exceptions in isCertificateFromTrustedProxy Code review follow-up on 1a19dbb2b: a malformed tls-client-auth-trusted-proxy-ca (e.g. an admin typo) was silently caught and treated identically to a genuinely untrusted/spoofed request, with zero diagnostic signal. Adds a WARN log line on the exception path so misconfiguration is distinguishable from an actual bypass attempt in production, while preserving the fail-closed behavior (still returns false either way). --- .../identity/uaa/oauth/tls/TlsClientAuthentication.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index aa8028cc6dc..4aec77ad5d5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -7,6 +7,8 @@ import org.bouncycastle.openssl.PEMParser; import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -32,6 +34,8 @@ @Component public class TlsClientAuthentication { + private static final Logger logger = LoggerFactory.getLogger(TlsClientAuthentication.class); + /** * Returns the first X.509 certificate from the current request's * {@code jakarta.servlet.request.X509Certificate} attribute @@ -157,6 +161,8 @@ public boolean isCertificateFromTrustedProxy(TlsClientAuthConfiguration clientCo X509Certificate caCert = parsePemCertificate(trustedProxyCaPem); return validateCertPath(peerChain, caCert).isPresent(); } catch (Exception e) { + logger.warn("isCertificateFromTrustedProxy: peer certificate did not validate against " + + "tls-client-auth-trusted-proxy-ca: {}", e.getMessage()); return false; } } From e0d50562b9e7128fe0d0c901881ecf58edab79e8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 12:02:58 +0200 Subject: [PATCH 046/130] fix: resolve per-client TlsClientAuthConfiguration before fetching certificate chain --- .../ClientDetailsAuthenticationProvider.java | 6 ++--- ...entDetailsAuthenticationProviderTests.java | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index d01e1013b46..0672be8626e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -189,12 +189,12 @@ static boolean isTlsClientAuthPath(Object uaaAuthenticationDetails) { return path != null && path.startsWith("/oauth/mtls"); } - private boolean validateTlsClientAuth(UaaClient uaaClient) { - X509Certificate[] chain = tlsClientAuthentication.getCertificateChainFromRequest(); + boolean validateTlsClientAuth(UaaClient uaaClient) { + TlsClientAuthConfiguration config = getTlsClientAuthConfiguration(uaaClient); + X509Certificate[] chain = tlsClientAuthentication.getCertificateChainFromRequest(config); if (chain == null || chain.length == 0) { return false; } - TlsClientAuthConfiguration config = getTlsClientAuthConfiguration(uaaClient); return tlsClientAuthentication.validateClientCert(chain, config).isPresent(); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java index 86031b6561d..d3f38c4978f 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -2,13 +2,19 @@ import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClient; +import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; import java.util.HashMap; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class ClientDetailsAuthenticationProviderTests { @@ -48,6 +54,26 @@ void tlsConfigIsDeserializedFromRawMapInAdditionalInfo() { .isEqualTo("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"); } + @Test + void validateTlsClientAuthPassesClientConfigToCertificateChainLookup() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem" + )); + TlsClientAuthentication tlsClientAuthentication = mock(TlsClientAuthentication.class); + ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( + mock(UserDetailsService.class), mock(PasswordEncoder.class), + mock(JwtClientAuthentication.class), tlsClientAuthentication); + + provider.validateTlsClientAuth(uaaClient); + + ArgumentCaptor configCaptor = + ArgumentCaptor.forClass(TlsClientAuthConfiguration.class); + verify(tlsClientAuthentication).getCertificateChainFromRequest(configCaptor.capture()); + assertThat(configCaptor.getValue().getTrustedProxyCaPem()).isEqualTo("proxy-ca-pem"); + } + @Test void getTlsClientAuthConfigurationReadsTrustedProxyCaFromFlatStringPath() { UaaClient uaaClient = mock(UaaClient.class); From 8a34536498ec04d547c2c1f7e07d4949443ba012 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 12:10:42 +0200 Subject: [PATCH 047/130] fix(review): use hasCertificateFromRequest() as a cheap early exit in validateTlsClientAuth Code review follow-up on e0d50562b: resolving the client's TlsClientAuthConfiguration (JSON/claim-mapping parsing) unconditionally, even for requests presenting no certificate/XFCC header at all, is avoidable work -- TlsClientAuthentication.hasCertificateFromRequest() exists specifically for this purpose (its own Javadoc already describes this exact use case) but wasn't being used by any production caller. Adds the short-circuit and a test proving neither the client's additionalInformation nor getCertificateChainFromRequest are touched when there is no certificate present. --- .../ClientDetailsAuthenticationProvider.java | 5 +++++ ...entDetailsAuthenticationProviderTests.java | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index 0672be8626e..3d9bdbb98f7 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -190,6 +190,11 @@ static boolean isTlsClientAuthPath(Object uaaAuthenticationDetails) { } boolean validateTlsClientAuth(UaaClient uaaClient) { + // Cheap presence-only check (no config resolution, no JSON/claim-mapping parsing) before + // doing any work to resolve this client's TlsClientAuthConfiguration. + if (!tlsClientAuthentication.hasCertificateFromRequest()) { + return false; + } TlsClientAuthConfiguration config = getTlsClientAuthConfiguration(uaaClient); X509Certificate[] chain = tlsClientAuthentication.getCertificateChainFromRequest(config); if (chain == null || chain.length == 0) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java index d3f38c4978f..caea8076138 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -13,7 +13,9 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -62,6 +64,7 @@ void validateTlsClientAuthPassesClientConfigToCertificateChainLookup() { TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem" )); TlsClientAuthentication tlsClientAuthentication = mock(TlsClientAuthentication.class); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( mock(UserDetailsService.class), mock(PasswordEncoder.class), mock(JwtClientAuthentication.class), tlsClientAuthentication); @@ -74,6 +77,22 @@ void validateTlsClientAuthPassesClientConfigToCertificateChainLookup() { assertThat(configCaptor.getValue().getTrustedProxyCaPem()).isEqualTo("proxy-ca-pem"); } + @Test + void validateTlsClientAuthShortCircuitsWithoutResolvingConfigWhenNoCertificatePresent() { + UaaClient uaaClient = mock(UaaClient.class); + TlsClientAuthentication tlsClientAuthentication = mock(TlsClientAuthentication.class); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(false); + ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( + mock(UserDetailsService.class), mock(PasswordEncoder.class), + mock(JwtClientAuthentication.class), tlsClientAuthentication); + + boolean result = provider.validateTlsClientAuth(uaaClient); + + assertThat(result).isFalse(); + verify(uaaClient, never()).getAdditionalInformation(); + verify(tlsClientAuthentication, never()).getCertificateChainFromRequest(any()); + } + @Test void getTlsClientAuthConfigurationReadsTrustedProxyCaFromFlatStringPath() { UaaClient uaaClient = mock(UaaClient.class); From 25e6a14d0ebd72b60dfef490b1052f9b030464a8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 13:45:34 +0200 Subject: [PATCH 048/130] fix: resolve per-client TlsClientAuthConfiguration before fetching certificate in MtlsClaimsEnhancer --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 12 ++++- .../oauth/tls/TlsClientAuthentication.java | 32 ------------ .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 51 ++++++++++++------- 3 files changed, 44 insertions(+), 51 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index a15edd723b0..15074a875d1 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -80,8 +80,9 @@ public Map getExternalAttributes(OAuth2Authentication authentica */ @Override public Map enhance(Map claims, OAuth2Authentication authentication) { - X509Certificate cert = tlsClientAuthentication.getCertificateFromRequest(); - if (cert == null) { + // Cheap presence-only check (no trust decision, no database lookup) -- avoids resolving the + // client's TlsClientAuthConfiguration at all when there is clearly nothing to process. + if (!tlsClientAuthentication.hasCertificateFromRequest()) { return new HashMap<>(); } @@ -109,6 +110,13 @@ public Map enhance(Map claims, OAuth2Authenticat return new HashMap<>(); } + // Now do the real, per-client trust decision: only a certificate validated against *this + // client's* tls-client-auth-trusted-proxy-ca is used from here on. + X509Certificate cert = tlsClientAuthentication.getCertificateFromRequest(config); + if (cert == null) { + return new HashMap<>(); + } + // PHASE 1 — extract cert subject fields into vars (keyed by claim name) Map vars = new HashMap<>(); if (config.getClaimMappings() != null) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index 4aec77ad5d5..d009749f8f6 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -36,38 +36,6 @@ public class TlsClientAuthentication { private static final Logger logger = LoggerFactory.getLogger(TlsClientAuthentication.class); - /** - * Returns the first X.509 certificate from the current request's - * {@code jakarta.servlet.request.X509Certificate} attribute - * (populated by the ClientCertificateMapper filter). - * - * @return the client certificate, or {@code null} if none is present - */ - public X509Certificate getCertificateFromRequest() { - X509Certificate[] chain = getCertificateChainFromRequest(); - return (chain != null && chain.length > 0) ? chain[0] : null; - } - - /** - * Returns the full X.509 certificate chain from the current request's - * {@code jakarta.servlet.request.X509Certificate} attribute - * (populated by the ClientCertificateMapper filter). - * Index 0 is the end-entity (leaf) certificate. - * - * @return the client certificate chain, or {@code null} if none is present - */ - public X509Certificate[] getCertificateChainFromRequest() { - ServletRequestAttributes attrs = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attrs == null) { - return null; - } - HttpServletRequest request = attrs.getRequest(); - X509Certificate[] certs = (X509Certificate[]) - request.getAttribute("jakarta.servlet.request.X509Certificate"); - return (certs != null && certs.length > 0) ? certs : null; - } - /** * Returns {@code true} when any certificate derived from the {@code X-Forwarded-Client-Cert} * header is present on the current request, regardless of whether it is trustworthy. This is a diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 0d1fbe8fae7..9a7934c1b56 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.CLIENT_AUTH_METHOD; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -39,7 +40,8 @@ void extractsClaimsFromCertOuFields() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getSubjectX500Principal()).thenReturn( new X500Principal("CN=instance-guid, OU=app:app-guid-123, OU=space:space-guid-456, OU=organization:org-guid-789, O=Cloud Foundry")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -68,7 +70,8 @@ void addsX5tThumbprintWhenCertPresent() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -87,7 +90,7 @@ void addsX5tThumbprintWhenCertPresent() throws Exception { @Test void returnsEmptyWhenNoCertOnRequest() { - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(null); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(false); OAuth2Authentication auth = mockAuthentication("instance-identity"); Map result = enhancer.enhance(new HashMap<>(), auth); assertThat(result).doesNotContainKey("app_guid"); @@ -99,7 +102,8 @@ void dotNotationClaimProducesNestedObject() throws Exception { when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( "CN=inst-guid, OU=app:app-guid, OU=space:space-guid, OU=organization:org-guid, O=Cloud Foundry")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -137,7 +141,8 @@ void flatClaimsStillWorkAfterRefactor() throws Exception { when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( "CN=instance-guid, OU=app:app-guid-123, OU=space:space-guid-456, OU=organization:org-guid-789, O=Cloud Foundry")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -168,7 +173,8 @@ void dotNotationOverwritesFlatClaimWithSameParentKey() throws Exception { when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( "CN=inst-guid, OU=app:app-guid, O=cf-org")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -194,7 +200,8 @@ void dotNotationOverwritesFlatClaimWithSameParentKey() throws Exception { @Test void subTemplateRendered() throws Exception { X509Certificate cert = mockCfCert(); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); TlsClientAuthConfiguration config = cfMappingsConfig(); config.setSubTemplate("o/{cf.org}/s/{cf.space}/a/{cf.app}"); @@ -213,7 +220,8 @@ void subTemplateRendered() throws Exception { @Test void audTemplatesRenderedAndOverrideDefault() throws Exception { X509Certificate cert = mockCfCert(); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); TlsClientAuthConfiguration config = cfMappingsConfig(); config.setAudTemplates(List.of( @@ -245,7 +253,8 @@ void subOmittedWhenTemplateVarMissing() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=only-cn")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", @@ -267,7 +276,8 @@ void subOmittedWhenTemplateVarMissing() throws Exception { @Test void audEntryDroppedWhenTemplateVarMissing() throws Exception { X509Certificate cert = mockCfCert(); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); TlsClientAuthConfiguration config = cfMappingsConfig(); config.setAudTemplates(List.of( @@ -291,7 +301,8 @@ void audEntryDroppedWhenTemplateVarMissing() throws Exception { @Test void audOmittedWhenAllTemplateEntriesFail() throws Exception { X509Certificate cert = mockCfCert(); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); TlsClientAuthConfiguration config = cfMappingsConfig(); config.setAudTemplates(List.of("x/{missing}", "y/{also_missing}")); @@ -309,7 +320,8 @@ void audOmittedWhenAllTemplateEntriesFail() throws Exception { @Test void noTemplatesConfiguredLeavesSubAndAudAbsent() throws Exception { X509Certificate cert = mockCfCert(); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); // Config with no subTemplate/audTemplates (original behaviour) UaaClientDetails clientDetails = new UaaClientDetails(); @@ -326,7 +338,8 @@ void noTemplatesConfiguredLeavesSubAndAudAbsent() throws Exception { @Test void stringPathInAdditionalInformationLoadsSubTemplateAndAudTemplates() throws Exception { X509Certificate cert = mockCfCert(); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -361,7 +374,8 @@ void extractsCnValueContainingEscapedComma() throws Exception { when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( "CN=Smith\\, John,OU=app:app-guid-123,O=Cloud Foundry")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -389,7 +403,8 @@ void extractsOuValueContainingEscapedComma() throws Exception { when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( "CN=inst-guid,OU=team\\, ops,OU=app:app-guid-123,O=Cloud Foundry")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -420,7 +435,8 @@ void enhanceReturnsEmptyWhenClientAuthenticatedViaClientSecretInsteadOfTlsClient when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( "CN=instance-guid, OU=app:app-guid-123, O=Cloud Foundry")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); @@ -445,7 +461,8 @@ void enhanceReturnsEmptyWhenClientAuthMethodExtensionIsMissing() throws Exceptio X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=instance-guid")); - when(tlsClientAuthentication.getCertificateFromRequest()).thenReturn(cert); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); From 1ba3a096dab65afb1430d7d40d629351133f8f72 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 13:50:43 +0200 Subject: [PATCH 049/130] test: add stringPathInAdditionalInformationLoadsTrustedProxyCa for MtlsClaimsEnhancer The plan's Task 5 deferred this test to Task 10 (once the config-gated getCertificateFromRequest(config) overload existed and enhance() was reordered to use it), but it was never actually added when Task 10 was implemented. Adds it now: verifies that when a client's additionalInformation encodes tls-client-auth-trusted-proxy-ca as a flat PEM string (the JDBC/round-tripped shape, not an in-memory typed field), MtlsClaimsEnhancer.loadTlsConfig reads it into the TlsClientAuthConfiguration passed to getCertificateFromRequest(config). Confirmed the test fails for the right reason by temporarily disabling cfg.setTrustedProxyCaPem(...) in production code and re-running just this test before restoring it. --- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 9a7934c1b56..62646931c05 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -8,6 +8,7 @@ import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Request; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import javax.security.auth.x500.X500Principal; import java.io.Serializable; @@ -20,6 +21,7 @@ import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.CLIENT_AUTH_METHOD; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class MtlsClaimsEnhancerTest { @@ -366,6 +368,33 @@ void stringPathInAdditionalInformationLoadsSubTemplateAndAudTemplates() throws E assertThat(aud).containsExactly("app/app-guid"); } + @Test + void stringPathInAdditionalInformationLoadsTrustedProxyCa() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + // Do NOT call setTlsClientAuthConfiguration — use String values directly, mirroring how + // additionalInformation looks once round-tripped through the DB/JDBC. + clientDetails.setAdditionalInformation(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, + "-----BEGIN CERTIFICATE-----\nMIIBproxy\n-----END CERTIFICATE-----\n" + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + ArgumentCaptor configCaptor = + ArgumentCaptor.forClass(TlsClientAuthConfiguration.class); + verify(tlsClientAuthentication).getCertificateFromRequest(configCaptor.capture()); + assertThat(configCaptor.getValue().getTrustedProxyCaPem()) + .isEqualTo("-----BEGIN CERTIFICATE-----\nMIIBproxy\n-----END CERTIFICATE-----\n"); + } + @Test void extractsCnValueContainingEscapedComma() throws Exception { // RFC 2253 escapes literal commas inside an attribute value with a backslash. From 9a3c6fa1b5e6b6a12c045837bd86b12cd74b5fa0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 13:59:47 +0200 Subject: [PATCH 050/130] docs: clarify hasCertificateFromRequest javadoc covers raw TLS peer cert too Code review follow-up on Task 10: the previous wording said the checked attribute holds a certificate 'derived from the X-Forwarded-Client-Cert header', but RawPeerCertificateCaptureFilter documents that the same jakarta.servlet.request.X509Certificate attribute holds the raw TLS-handshake peer certificate until ClientCertificateMapper overwrites it when an XFCC header is present. Clarifies the javadoc to cover both cases so it isn't misleading about what hasCertificateFromRequest() actually observes. --- .../uaa/oauth/tls/TlsClientAuthentication.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index d009749f8f6..c2b9adb90c8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -37,12 +37,14 @@ public class TlsClientAuthentication { private static final Logger logger = LoggerFactory.getLogger(TlsClientAuthentication.class); /** - * Returns {@code true} when any certificate derived from the {@code X-Forwarded-Client-Cert} - * header is present on the current request, regardless of whether it is trustworthy. This is a - * cheap, non-trust-deciding presence check intended as an early exit before resolving a client's - * {@link TlsClientAuthConfiguration} (which may require a database lookup) -- it does not grant or - * imply any authorization by itself, since any caller (trusted or not) that sets the header will - * cause the servlet container to populate this attribute. + * Returns {@code true} when any certificate is present on the current request under the + * standard {@code jakarta.servlet.request.X509Certificate} attribute -- whether that attribute + * holds a certificate derived from the {@code X-Forwarded-Client-Cert} header, or (when no XFCC + * header was sent) the raw TLS-handshake peer certificate -- regardless of whether it is + * trustworthy. This is a cheap, non-trust-deciding presence check intended as an early exit before + * resolving a client's {@link TlsClientAuthConfiguration} (which may require a database lookup) -- + * it does not grant or imply any authorization by itself, since any caller (trusted or not) that + * sets the header will cause the servlet container to populate this attribute. */ public boolean hasCertificateFromRequest() { ServletRequestAttributes attrs = From db82bb551e44ae5e99df8acf089ea3d81d0c1410 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 14:09:53 +0200 Subject: [PATCH 051/130] feat: reject tls-client-auth-ca/tls-client-auth-trusted-proxy-ca when uaa.mtls_enabled is false ClientAdminEndpointsValidator now takes a mtlsEnabled constructor argument and rejects client config containing tls-client-auth-ca or tls-client-auth-trusted-proxy-ca in additionalInformation when mTLS is not enabled at the platform level (uaa.mtls-enabled). Wires the new argument from the uaa.mtls-enabled property in SpringServletXmlBeansConfiguration. This surfaces the misconfiguration immediately at client-config time with an explicit error, instead of silently accepting it and only failing later at token-request time with a generic tls_client_auth certificate validation error indistinguishable from an actual Gorouter-bypass attack. Also updates the other existing 2-arg constructor call site in ClientAdminEndpointsTests to pass mtlsEnabled=false, preserving prior test behavior. --- .../SpringServletXmlBeansConfiguration.java | 5 +- .../client/ClientAdminEndpointsValidator.java | 17 ++++- .../uaa/client/ClientAdminEndpointsTests.java | 2 +- .../ClientAdminEndpointsValidatorTests.java | 62 ++++++++++++++++++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java index e7515dd3232..f4cb03acb89 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java @@ -144,8 +144,9 @@ String loginUrl(@Value("${login.url:http://localhost:8080/uaa}") String loginUrl ClientAdminEndpointsValidator clientDetailsValidator( SecurityContextAccessor securityContextAccessor, @Qualifier("clientDetailsService") QueryableResourceManager clientDetailsService, - @Qualifier("zoneAwareClientSecretPolicyValidator") ClientSecretValidator clientDetailsValidator) { - ClientAdminEndpointsValidator bean = new ClientAdminEndpointsValidator(securityContextAccessor, identityZoneManager); + @Qualifier("zoneAwareClientSecretPolicyValidator") ClientSecretValidator clientDetailsValidator, + @Value("${uaa.mtls-enabled:false}") boolean mtlsEnabled) { + ClientAdminEndpointsValidator bean = new ClientAdminEndpointsValidator(securityContextAccessor, identityZoneManager, mtlsEnabled); bean.setClientDetailsService(clientDetailsService); bean.setClientSecretValidator(clientDetailsValidator); return bean; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 9f66ee9d340..f2a8c9f0dc6 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -82,14 +82,18 @@ public class ClientAdminEndpointsValidator implements InitializingBean, ClientDe private final IdentityZoneManager identityZoneManager; + private final boolean mtlsEnabled; + private final Set reservedClientIds = StringUtils.commaDelimitedListToSet(OriginKeys.UAA); private final Set invalidClientIdsCharacters = Set.of('/', '\\'); public ClientAdminEndpointsValidator(final SecurityContextAccessor securityContextAccessor, - final IdentityZoneManager identityZoneManager) { + final IdentityZoneManager identityZoneManager, + final boolean mtlsEnabled) { this.securityContextAccessor = securityContextAccessor; this.identityZoneManager = identityZoneManager; + this.mtlsEnabled = mtlsEnabled; } /** @@ -123,6 +127,17 @@ public ClientDetails validate(ClientDetails prototype, boolean create, boolean c } client.setAdditionalInformation(prototype.getAdditionalInformation()); + + if (!mtlsEnabled) { + Map additionalInfo = client.getAdditionalInformation(); + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + || additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA)) { + throw new InvalidClientDetailsException( + "tls-client-auth-ca / tls-client-auth-trusted-proxy-ca require uaa.mtls_enabled " + + "to be true on this UAA deployment"); + } + } + String clientId = client.getClientId(); if (create) { if (reservedClientIds.contains(clientId)) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java index e30c76e2c9c..8c236ab24b6 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java @@ -121,7 +121,7 @@ void setUp() { clientRegistrationService = Mockito.mock(MultitenantClientServices.class, withSettings().extraInterfaces(SystemDeletable.class)); mockAuthenticationManager = Mockito.mock(AuthenticationManager.class); ApprovalStore approvalStore = mock(ApprovalStore.class); - clientDetailsValidator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl()); + clientDetailsValidator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl(), false); clientDetailsValidator.setClientDetailsService(clientDetailsService); clientDetailsValidator.setClientSecretValidator( new ZoneAwareClientSecretPolicyValidator(new ClientSecretPolicy(0, 255, 0, 0, 0, 0, 6))); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 3f4288bcf3c..1d4cd03fb7e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -74,7 +74,7 @@ void createClient() { client.setClientSecret("secret"); caller = new UaaClientDetails("caller", "", "", "client_credentials", "clients.write"); SecurityContextAccessor mockSecurityContextAccessor = mock(SecurityContextAccessor.class); - validator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl()); + validator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl(), false); secretValidator = new ZoneAwareClientSecretPolicyValidator(new ClientSecretPolicy(0, 255, 0, 0, 0, 0, 6)); validator.setClientSecretValidator(secretValidator); @@ -314,4 +314,64 @@ void validate_create_invalidJwtCredsList_throws() { assertThatThrownBy(() -> validator.validate(client, true, true)) .isInstanceOf(InvalidClientDetailsException.class); } + + @Test + void rejectsTlsClientAuthCaWhenMtlsDisabled() { + ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), false); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsDisabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls_enabled"); + } + + @Test + void rejectsTlsClientAuthTrustedProxyCaWhenMtlsDisabled() { + ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), false); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsDisabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls_enabled"); + } + + @Test + void allowsTlsClientAuthCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); + client.setAdditionalInformation(additionalInfo); + + ClientDetails validated = mtlsEnabledValidator.validate(client, false, false); + + assertThat(validated.getAdditionalInformation()) + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + } + + @Test + void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { + ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), false); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + client.setClientSecret("secret"); + + ClientDetails validated = mtlsDisabledValidator.validate(client, false, false); + + assertThat(validated.getClientId()).isEqualTo(client.getClientId()); + } } From f14cfcf1287026077f85f3fc6ea7f2ab5ea67c81 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 14:38:03 +0200 Subject: [PATCH 052/130] fix: gate zone-endpoints client creation on uaa.mtls-enabled POST /identity-zones/{zoneId}/clients (ZoneEndpointsClientDetailsValidator, used by IdentityZoneEndpointClientRegistrationService.createClient()) was copying additionalInformation straight through with no mTLS gating, so a zone admin could configure tls-client-auth-ca / tls-client-auth-trusted-proxy-ca on a client via this endpoint even when uaa.mtls-enabled is false. This defeated the purpose of the Task 11 fix applied to the /oauth/clients admin path in ClientAdminEndpointsValidator. - Extract the mTLS-field-gating check from ClientAdminEndpointsValidator.validate(...) into a new public static helper, checkMtlsClientConfigAllowed(...), placed next to the existing checkRequestedGrantTypes(...) helper that ZoneEndpointsClientDetailsValidator already reuses. - Call the shared helper from both ClientAdminEndpointsValidator and ZoneEndpointsClientDetailsValidator so the two client-creation paths enforce the same mTLS gating. - Wire mtlsEnabled into ZoneEndpointsClientDetailsValidator via a new constructor parameter annotated with @Value("${uaa.mtls-enabled:false}") (the class is purely component-scanned via @Component, with no explicit @Bean definition, so Spring resolves @Value directly on the constructor parameter). - Fix the rejection message spelling bug: the property is uaa.mtls-enabled (hyphen), not uaa.mtls_enabled (underscore), matching SpringServletXmlBeansConfiguration.java and MtlsClientAuthTomcatCustomizer.java. Updated the two existing ClientAdminEndpointsValidatorTests assertions that checked for the old underscore spelling. - Add tests to ZoneEndpointsClientDetailsValidatorTests covering: rejection when mtlsEnabled=false, pass-through when mtlsEnabled=true, and no false-positive rejection of ordinary zone clients with no mTLS fields. --- .../client/ClientAdminEndpointsValidator.java | 20 ++++--- .../ZoneEndpointsClientDetailsValidator.java | 8 ++- .../ClientAdminEndpointsValidatorTests.java | 4 +- ...eEndpointsClientDetailsValidatorTests.java | 58 ++++++++++++++++++- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index f2a8c9f0dc6..6014c6324ce 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -128,15 +128,7 @@ public ClientDetails validate(ClientDetails prototype, boolean create, boolean c client.setAdditionalInformation(prototype.getAdditionalInformation()); - if (!mtlsEnabled) { - Map additionalInfo = client.getAdditionalInformation(); - if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) - || additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA)) { - throw new InvalidClientDetailsException( - "tls-client-auth-ca / tls-client-auth-trusted-proxy-ca require uaa.mtls_enabled " - + "to be true on this UAA deployment"); - } - } + checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled); String clientId = client.getClientId(); if (create) { @@ -367,6 +359,16 @@ public static void checkRequestedGrantTypes(Set requestedGrantTypes) { } } + public static void checkMtlsClientConfigAllowed(Map additionalInfo, boolean mtlsEnabled) { + if (!mtlsEnabled + && (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + || additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA))) { + throw new InvalidClientDetailsException( + "tls-client-auth-ca / tls-client-auth-trusted-proxy-ca require uaa.mtls-enabled " + + "to be true on this UAA deployment"); + } + } + @Override public ClientSecretValidator getClientSecretValidator() { return this.clientSecretValidator; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 4a6b9d0dea9..61da0ba772c 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -6,12 +6,14 @@ import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.authority.AuthorityUtils; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.springframework.stereotype.Component; import java.util.Collections; +import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkRequestedGrantTypes; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; @@ -27,10 +29,13 @@ public class ZoneEndpointsClientDetailsValidator implements ClientDetailsValidat private static final String REQUIRED_SCOPE = "zones.write"; private final ClientSecretValidator clientSecretValidator; + private final boolean mtlsEnabled; public ZoneEndpointsClientDetailsValidator( - final ClientSecretValidator clientSecretValidator) { + final ClientSecretValidator clientSecretValidator, + @Value("${uaa.mtls-enabled:false}") final boolean mtlsEnabled) { this.clientSecretValidator = clientSecretValidator; + this.mtlsEnabled = mtlsEnabled; } @Override @@ -47,6 +52,7 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv throw new InvalidClientDetailsException("client_id cannot be blank"); } checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); + checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_USER_TOKEN) || diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 1d4cd03fb7e..3707ba69ef2 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -327,7 +327,7 @@ void rejectsTlsClientAuthCaWhenMtlsDisabled() { assertThatThrownBy(() -> mtlsDisabledValidator.validate(client, false, false)) .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("uaa.mtls_enabled"); + .hasMessageContaining("uaa.mtls-enabled"); } @Test @@ -342,7 +342,7 @@ void rejectsTlsClientAuthTrustedProxyCaWhenMtlsDisabled() { assertThatThrownBy(() -> mtlsDisabledValidator.validate(client, false, false)) .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("uaa.mtls_enabled"); + .hasMessageContaining("uaa.mtls-enabled"); } @Test diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index 8d20909fba5..f6155ddab21 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -3,6 +3,7 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.cloudfoundry.identity.uaa.client.ClientDetailsValidator.Mode; import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.extensions.PollutionPreventionExtension; @@ -13,11 +14,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -36,9 +38,13 @@ class ZoneEndpointsClientDetailsValidatorTests { @Mock private ClientSecretValidator mockClientSecretValidator; - @InjectMocks private ZoneEndpointsClientDetailsValidator zoneEndpointsClientDetailsValidator; + @org.junit.jupiter.api.BeforeEach + void setUp() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, false); + } + @Test void createLimitedClient() { UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "authorization_code,password", "uaa.resource"); @@ -106,4 +112,52 @@ void createAdminAuthorityClientIsInvalid() { ClientDetails clientDetails = new UaaClientDetails("admin-client", null, "openid", GRANT_TYPE_AUTHORIZATION_CODE, "uaa.admin"); assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); } + + @Test + void rejectsTlsClientAuthCaWhenMtlsDisabled() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, false); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("secret"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void allowsTlsClientAuthCaWhenMtlsEnabled() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("secret"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); + clientDetails.setAdditionalInformation(additionalInfo); + + ClientDetails validated = zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); + + assertThat(validated.getAdditionalInformation()) + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem") + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); + } + + @Test + void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, false); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("secret"); + clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + + ClientDetails validated = zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); + + assertThat(validated.getClientId()).isEqualTo(clientDetails.getClientId()); + } } From c1b03264a9fdf665a9f77b454dafc530d457c404 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 15:01:47 +0200 Subject: [PATCH 053/130] fix: gate ClientAdminBootstrap YAML client config on uaa.mtls-enabled ClientAdminBootstrap (server/src/main/java/org/cloudfoundry/identity/uaa/client/ ClientAdminBootstrap.java) reads client definitions directly from uaa.yml's oauth.clients map (BOSH property uaa.clients.) at UAA boot time and calls clientRegistrationService.addClientDetails(...)/updateClientDetails(...) directly, with no ClientDetailsValidator in the loop. This was the third path that bypassed the mTLS gate already enforced on the REST /oauth/clients admin endpoint (ClientAdminEndpointsValidator, commit db82bb551) and the REST POST /identity-zones/{zoneId}/clients endpoint (ZoneEndpointsClientDetailsValidator, commit f14cfcf12): a client's tls-client-auth-ca / tls-client-auth-trusted-proxy-ca could be set via YAML/BOSH properties with no check against uaa.mtls-enabled. This is directly relevant to this RFC's own instance-identity client, which is bootstrapped this exact way via ops-enable-app-identity.yml (uaa.clients.instance-identity.tls-client-auth-ca: ...). - Add a mtlsEnabled field/constructor parameter to ClientAdminBootstrap, annotated with @Value("${uaa.mtls-enabled:false}") (matching the exact property key spelling used in SpringServletXmlBeansConfiguration and MtlsClientAuthTomcatCustomizer). - In addNewClients(), after client.setAdditionalInformation(info), call the existing shared static helper ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed(...), reusing the same gating logic as the other two paths (same package, no import needed). - Update all four existing call sites constructing new ClientAdminBootstrap(...) to pass the new trailing boolean parameter (false, preserving existing behavior for tests unrelated to this feature). - Add tests to ClientAdminBootstrapTests covering: rejection of tls-client-auth-ca and tls-client-auth-trusted-proxy-ca when mtlsEnabled=false, successful bootstrap with the field present in additionalInformation when mtlsEnabled=true, and no false-positive rejection of an ordinary client with no mTLS fields. --- .../ClientAdminBootstrapProdEncoderTest.java | 3 +- .../uaa/client/ClientAdminBootstrap.java | 9 ++- ...ientAdminBootstrapMultipleSecretsTest.java | 2 +- ...inBootstrapMultipleSecretsUpdateTests.java | 3 +- .../uaa/client/ClientAdminBootstrapTests.java | 66 +++++++++++++++++-- 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java b/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java index 5be6a36618b..fdf317eb003 100644 --- a/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java +++ b/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java @@ -85,7 +85,8 @@ void emptySecretHashIsStableAcrossBootstrapRuns() { Collections.emptySet(), Collections.emptySet(), jdbcTemplate, - Collections.emptySet()); + Collections.emptySet(), + false); // First run — simulates UAA startup 1 bootstrap.afterPropertiesSet(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java index 92daa4a23bc..0cadca187d3 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java @@ -65,6 +65,7 @@ public class ClientAdminBootstrap implements private final Set autoApproveClients; private final Set allowPublicClients; private final boolean defaultOverride; + private final boolean mtlsEnabled; /** * @param defaultOverride the default override flag to set. Flag to indicate @@ -80,6 +81,9 @@ public class ClientAdminBootstrap implements * into the client details store. * @param allowPublicClients A set of client ids that are allowed to be used * without client_secret parameter but with PKCE S256 method + * @param mtlsEnabled whether platform-wide RFC 8705 mTLS client auth is enabled; + * gates whether bootstrapped clients may set + * tls-client-auth-ca / tls-client-auth-trusted-proxy-ca */ ClientAdminBootstrap( @Qualifier("nonCachingPasswordEncoder") final PasswordEncoder passwordEncoder, @@ -90,7 +94,8 @@ public class ClientAdminBootstrap implements @Value("#{@applicationProperties.containsKey('oauth.client.autoapprove') ? @config['oauth']['client']['autoapprove'] : 'cf'}") final Collection autoApproveClients, @Value("#{@config['delete']==null ? null : @config['delete']['clients']}") final Collection clientsToDelete, final JdbcTemplate jdbcTemplate, - final Set allowPublicClients) { + final Set allowPublicClients, + @Value("${uaa.mtls-enabled:false}") final boolean mtlsEnabled) { this.passwordEncoder = passwordEncoder; this.clientRegistrationService = clientRegistrationService; this.clientMetadataProvisioning = clientMetadataProvisioning; @@ -100,6 +105,7 @@ public class ClientAdminBootstrap implements this.clientsToDelete = new HashSet<>(ofNullable(clientsToDelete).orElse(Collections.emptySet())); this.jdbcTemplate = jdbcTemplate; this.allowPublicClients = new HashSet<>(ofNullable(allowPublicClients).orElse(Collections.emptySet())); + this.mtlsEnabled = mtlsEnabled; } @Override @@ -214,6 +220,7 @@ private void addNewClients() { } client.setAdditionalInformation(info); + ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled); ClientJwtConfiguration keyConfig = null; if (map.get(JWKS_URI) instanceof String || map.get(JWKS) instanceof String) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java index 6ebd777054b..967200cf375 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java @@ -67,7 +67,7 @@ void setUp() { ClientMetadataProvisioning clientMetadataProvisioning = mock(ClientMetadataProvisioning.class); clientAdminBootstrap = new ClientAdminBootstrap(passwordEncoder, clientRegistrationService, clientMetadataProvisioning, defaultOverride, clients, autoApproveClients, clientsToDelete, null, - allowPublicClients); + allowPublicClients, false); oneSecretClient = new UaaClientDetails(); oneSecretClient.setClientId(clientId); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java index ba0fac5c9c9..1fa7d6e312f 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java @@ -61,7 +61,8 @@ void passwordHashFirstSecretDidNotChangeButSecondIsNullDuringBootstrap() throws Collections.singleton(autoApproveId), Collections.emptySet(), null, - Collections.singleton(allowPublicId)); + Collections.singleton(allowPublicId), + false); /* setup first a client with 2 secrets */ Map map = ClientAdminBootstrapTests.createClientMap("foo"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java index a8ff145577b..f0fea1c2b34 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java @@ -105,7 +105,8 @@ void setUpClientAdminTests() { Collections.singleton(autoApproveId), Collections.emptySet(), null, - Collections.singleton(allowPublicId)); + Collections.singleton(allowPublicId), + false); mockApplicationEventPublisher = mock(ApplicationEventPublisher.class); clientAdminBootstrap.setApplicationEventPublisher(mockApplicationEventPublisher); @@ -125,7 +126,8 @@ void setUp() { Collections.emptySet(), Collections.emptySet(), null, - Collections.emptySet()); + Collections.emptySet(), + false); } @Test @@ -161,7 +163,7 @@ void setUp() { clients, Collections.singleton(clientIdToDelete), Collections.singleton(clientIdToDelete), - null, Collections.singleton(clientIdToDelete)); + null, Collections.singleton(clientIdToDelete), false); clientAdminBootstrap.setApplicationEventPublisher(mockApplicationEventPublisher); } @@ -372,7 +374,7 @@ void setUp() { clients, Collections.singleton(autoApproveId), Collections.emptySet(), - null, Collections.singleton(allowPublicId)); + null, Collections.singleton(allowPublicId), false); when(mockClientMetadataProvisioning.update(any(ClientMetadata.class), anyString())).thenReturn(new ClientMetadata()); } @@ -461,7 +463,7 @@ void setUp() { clients, Collections.singleton(autoApproveId), Collections.emptySet(), - null, Collections.singleton(allowPublicId)); + null, Collections.singleton(allowPublicId), false); } @Test @@ -646,6 +648,60 @@ void clientWithoutGrantTypeFails() { .hasMessageContaining("Client must have at least one authorized-grant-type"); } + @Test + void mtlsClientConfigRejectedWhenMtlsDisabled() { + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(() -> clientAdminBootstrap.afterPropertiesSet()) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void mtlsClientTrustedProxyConfigRejectedWhenMtlsDisabled() { + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "some-trusted-proxy-ca"); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(() -> clientAdminBootstrap.afterPropertiesSet()) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void mtlsClientConfigAllowedWhenMtlsEnabled() { + ClientAdminBootstrap mtlsEnabledBootstrap = new ClientAdminBootstrap( + passwordEncoder, + multitenantJdbcClientDetailsService, + clientMetadataProvisioning, + true, + clients, + Collections.singleton(autoApproveId), + Collections.emptySet(), + null, + Collections.singleton(allowPublicId), + true); + + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + clients.put((String) map.get("id"), map); + + mtlsEnabledBootstrap.afterPropertiesSet(); + + ClientDetails created = multitenantJdbcClientDetailsService.loadClientByClientId("foo"); + assertThat(created.getAdditionalInformation()).containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + } + + @Test + void ordinaryClientBootstrapsSuccessfullyWhenMtlsDisabled() { + Map map = createClientMap("foo"); + ClientDetails created = doSimpleTest(map, clientAdminBootstrap, multitenantJdbcClientDetailsService, clients); + assertThat(created.getAdditionalInformation()).doesNotContainKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + assertThat(created.getAdditionalInformation()).doesNotContainKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + } + static ClientDetails doSimpleTest( final Map map, final ClientAdminBootstrap clientAdminBootstrap, From febb3282f1e20c850c1b5b3e7d8927e944a87a4e Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 15:28:29 +0200 Subject: [PATCH 054/130] fix(review): include clientId in checkMtlsClientConfigAllowed error message Code review follow-up on c1b03264a9f: the shared mTLS-gating helper's error message didn't name which client was rejected, unlike the other pre-existing InvalidClientDetailsException messages in ClientAdminBootstrap (which already include '...ClientID: '). This matters most for the YAML/BOSH-property bootstrap path, where an operator staring at a uaa.yml with dozens of uaa.clients. blocks gets no indication from the error alone which client is misconfigured. Adds a clientId parameter to checkMtlsClientConfigAllowed(...) and updates all three call sites (ClientAdminEndpointsValidator, ZoneEndpointsClientDetailsValidator, ClientAdminBootstrap) to pass the client ID they already have in scope. Existing test assertions only checked message substring 'uaa.mtls-enabled' (not the full message), so no test changes were required; verified by re-running the full client/zone package suites plus the integrationTest compile. --- .../identity/uaa/client/ClientAdminBootstrap.java | 2 +- .../identity/uaa/client/ClientAdminEndpointsValidator.java | 6 +++--- .../uaa/zone/ZoneEndpointsClientDetailsValidator.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java index 0cadca187d3..d6036226428 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java @@ -220,7 +220,7 @@ private void addNewClients() { } client.setAdditionalInformation(info); - ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled); + ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled, clientId); ClientJwtConfiguration keyConfig = null; if (map.get(JWKS_URI) instanceof String || map.get(JWKS) instanceof String) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 6014c6324ce..cd0ed9733db 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -128,7 +128,7 @@ public ClientDetails validate(ClientDetails prototype, boolean create, boolean c client.setAdditionalInformation(prototype.getAdditionalInformation()); - checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled); + checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled, client.getClientId()); String clientId = client.getClientId(); if (create) { @@ -359,13 +359,13 @@ public static void checkRequestedGrantTypes(Set requestedGrantTypes) { } } - public static void checkMtlsClientConfigAllowed(Map additionalInfo, boolean mtlsEnabled) { + public static void checkMtlsClientConfigAllowed(Map additionalInfo, boolean mtlsEnabled, String clientId) { if (!mtlsEnabled && (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) || additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA))) { throw new InvalidClientDetailsException( "tls-client-auth-ca / tls-client-auth-trusted-proxy-ca require uaa.mtls-enabled " - + "to be true on this UAA deployment"); + + "to be true on this UAA deployment. ClientID: " + clientId); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 61da0ba772c..e5d6396306b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -52,7 +52,7 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv throw new InvalidClientDetailsException("client_id cannot be blank"); } checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); - checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled); + checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled, clientDetails.getClientId()); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_USER_TOKEN) || From 949c82975a61d32d1033ec088daaac91863ff7f7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 16:18:13 +0200 Subject: [PATCH 055/130] fix: disable TLSv1.3 on the mTLS connector to work around JSSE PHA limitation Found during Task 14 real-deployment end-to-end verification: with mTLS enabled and a real Gorouter presenting its backend TLS client certificate, the token exchange failed with 'tls_client_auth: certificate validation failed' even for a completely legitimate request through the Gorouter. Root cause: confirmed empirically via openssl s_client against the live deployed UAA instance that a TLSv1.2 handshake receives a CertificateRequest from the connector, but a TLSv1.3 handshake against the exact same connector does not receive one at all. Tomcat's own startup log already documents why: 'The JSSE TLS 1.3 implementation does not support post handshake authentication (PHA) and is therefore incompatible with optional certificate authentication' -- optionalNoCA (like Tomcat's other 'optional' verification modes) can rely on PHA to request a certificate that wasn't sent upfront, and JSSE's TLS 1.3 implementation cannot do that. On at least one real deployed JDK build, this means the server silently never asks for a certificate under TLS 1.3 at all, so RawPeerCertificateCaptureFilter never captures anything and isCertificateFromTrustedProxy always returns false -- completely defeating this feature for any client that (like a real Gorouter backend connection, or any modern TLS client) prefers TLS 1.3. Fix: restrict this connector's enabled protocols to exclude TLSv1.3 (protocols="all,-TLSv1.3"), which is Tomcat's own documented workaround for this exact incompatibility. Added a regression test that asserts a client willing to speak both TLSv1.2 and TLSv1.3 actually negotiates TLSv1.2 against this connector; confirmed RED (negotiated TLSv1.3) before applying the fix, GREEN after. --- .../MtlsClientAuthTomcatCustomizer.java | 15 +++++ ...ntAuthTomcatCustomizerIntegrationTest.java | 57 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index 58b826a9696..a18bf6b73c1 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -19,6 +19,20 @@ * need to be kept in sync with whatever CA(s) are configured per-client at runtime via the client-admin * API, which is an operational hazard. * + *

Also disables TLSv1.3 on this connector ({@code protocols="all,-TLSv1.3"}). This works around a + * real, confirmed limitation of JSSE's TLS 1.3 implementation: requesting a client certificate + * without also requiring/validating it against a CA (i.e. {@code optionalNoCA}, or Tomcat's other + * {@code optional} mode) relies on the server being able to request the certificate again later via + * post-handshake authentication (PHA) if it wasn't sent upfront -- and JSSE's TLS 1.3 implementation + * does not support PHA (Tomcat itself logs this exact incompatibility at startup: + * "The JSSE TLS 1.3 implementation does not support post handshake authentication (PHA) and is + * therefore incompatible with optional certificate authentication"). In practice, on at least one real + * JDK build this means the server silently never sends a {@code CertificateRequest} at all under TLS + * 1.3, so no client certificate -- trusted or not -- is ever captured, silently defeating this entire + * feature. Confirmed empirically against a live deployment via {@code openssl s_client}: {@code -tls1_2} + * receives a {@code CertificateRequest}; {@code -tls1_3} does not. Restricting this connector to + * TLSv1.2 is Tomcat's own documented workaround for this exact incompatibility. + * *

Runs after Spring Boot's own SSL connector configuration so it can override the already-configured * {@link SSLHostConfig}(s) on the connector. */ @@ -39,6 +53,7 @@ public void customize(TomcatServletWebServerFactory factory) { factory.addConnectorCustomizers(connector -> { for (SSLHostConfig sslHostConfig : connector.findSslHostConfigs()) { sslHostConfig.setCertificateVerification("optionalNoCA"); + sslHostConfig.setProtocols("all,-TLSv1.3"); } }); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java index 0afaf677e57..7e70a666c84 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java @@ -90,6 +90,37 @@ void requestsAndAcceptsAnUntrustedClientCertificateWhenMtlsEnabled() throws Exce .isTrue(); } + /** + * Regression test for a real-deployment finding (Task 14 end-to-end verification): Tomcat's own + * startup log emits "The JSSE TLS 1.3 implementation does not support post handshake + * authentication (PHA) and is therefore incompatible with optional certificate authentication" -- + * and on at least one JDK build used in a real BOSH-deployed environment, a TLS 1.3 handshake + * against this connector never sends a {@code CertificateRequest} at all (confirmed empirically + * via {@code openssl s_client -tls1_3} against a live instance, compared against + * {@code -tls1_2} which does send one). Tomcat's own documented workaround for this exact + * incompatibility is to exclude TLSv1.3 from the connector's enabled protocols + * ({@code protocols="all,-TLSv1.3"}) whenever {@code certificateVerification=optionalNoCA} is in + * use. This test asserts the customizer actually negotiates TLSv1.2 (not TLSv1.3) even when the + * client is willing to speak both -- which is what actually prevents the silent-no-CertificateRequest + * failure mode in production, independent of whether any particular local JDK happens to dodge the + * underlying JSSE limitation. + */ + @Test + void negotiatesTlsV12NotTlsV13WhenMtlsEnabled() throws Exception { + int port = startServer(true); + + try (SSLSocket socket = clientSocketOfferingBothTls12And13(port)) { + socket.startHandshake(); + + assertThat(socket.getSession().getProtocol()) + .as("connector must not negotiate TLSv1.3 when optionalNoCA client-auth is in " + + "effect, since JSSE's TLS 1.3 implementation cannot request a client " + + "certificate without post-handshake authentication (PHA), which it does " + + "not support -- see MtlsClientAuthTomcatCustomizer's Javadoc") + .isEqualTo("TLSv1.2"); + } + } + @Test void doesNotRequestAClientCertificateWhenMtlsDisabled() throws Exception { int port = startServer(false); @@ -156,6 +187,32 @@ private SSLSocket clientSocketPresentingArbitraryCert(int port, AtomicBoolean cl return (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); } + /** + * A client socket configured to offer both TLSv1.2 and TLSv1.3, presenting an arbitrary + * untrusted certificate if asked. Used to verify which protocol the connector actually + * negotiates when both are available to the client (see + * {@link #negotiatesTlsV12NotTlsV13WhenMtlsEnabled()}). + */ + private SSLSocket clientSocketOfferingBothTls12And13(int port) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(3)); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), new TrustManager[]{trustAnyServerCertificate()}, null); + + SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + socket.setEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"}); + return socket; + } + /** * Wraps each {@link X509ExtendedKeyManager} so we can observe -- from the client side -- whether * the server ever asked for a client certificate during the handshake. JSSE only calls From 63b1df64853de039fe7af5e98810dd874b3735ac Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 17:01:14 +0200 Subject: [PATCH 056/130] fix: advertise no acceptable-issuer constraint in the mTLS connector's CertificateRequest Found during Task 14 real-deployment end-to-end verification, after the TLSv1.3/PHA fix: a completely legitimate request through the Gorouter still failed with 'tls_client_auth: certificate validation failed'. Packet capture of the actual Gorouter-to-UAA backend TLS handshake showed the Certificate message the Gorouter sent had a zero-length certificate_list -- it received the CertificateRequest but presented no certificate at all. Root cause: even with certificateVerification=optionalNoCA (which disables certificate *validation*), Tomcat/JSSE still populates the CertificateRequest's 'certificate_authorities' field from whatever trust store is configured on the connector -- and absent an explicit one, JSSE falls back to the JVM's default cacerts (confirmed via openssl s_client -tls1_2's "Acceptable client certificate CA names" output: only unrelated public root CAs like Certainly, Cybertrust, QuoVadis -- never service_cf_internal_ca, the CA that signs the Gorouter's own gorouter_backend_tls certificate). Go's crypto/tls client (used by the real Gorouter) correctly implements the TLS spec's client certificate selection rules: it only presents a certificate whose issuer appears in that advertised list, and sends an empty Certificate message otherwise -- silently withholding the Gorouter's backend cert on every single request, completely defeating this feature for any real deployment. An empty KeyStore-based trust store was tried first and rejected: Tomcat's PKIX trust manager path throws InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty for a zero-entry KeyStore -- PKIX fundamentally requires at least one trust anchor to construct PKIXParameters. Fix: add NoAcceptedIssuersTrustManager, a minimal X509TrustManager whose getAcceptedIssuers() returns an empty array and whose checkClientTrusted is a no-op (TLS-layer validation is already disabled via optionalNoCA). Installed via SSLHostConfig#setTrustManagerClassName(...), which bypasses Tomcat's KeyStore/algorithm-based trust manager construction entirely, so the PKIX empty-trust-anchors restriction never applies. An empty accepted-issuers list is valid per the TLS spec and means "any CA is acceptable" on the wire. Also strengthens test coverage: the existing requestsAndAcceptsAnUntrustedClientCertificateWhenMtlsEnabled test only verified that the client's chooseClientAlias callback fired (proving a CertificateRequest was sent), not that an alias was actually chosen and a certificate actually transmitted -- so it did not catch this. Adds chosenClientAliasIsNotNullEvenWhenCertIssuerIsNotInDefaultCaCerts, which checks the KeyManager's actual return value; confirmed RED (chose null) before this fix, GREEN after. --- .../MtlsClientAuthTomcatCustomizer.java | 24 ++++ .../tomcat/NoAcceptedIssuersTrustManager.java | 51 +++++++ ...ntAuthTomcatCustomizerIntegrationTest.java | 126 ++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/NoAcceptedIssuersTrustManager.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index a18bf6b73c1..8f37e676a84 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -33,6 +33,29 @@ * receives a {@code CertificateRequest}; {@code -tls1_3} does not. Restricting this connector to * TLSv1.2 is Tomcat's own documented workaround for this exact incompatibility. * + *

Also installs a custom {@link NoAcceptedIssuersTrustManager} on the connector, via + * {@code SSLHostConfig#setTrustManagerClassName(String)}. Even with {@code optionalNoCA} (which + * disables certificate validation), Tomcat/JSSE still populates the + * {@code CertificateRequest} handshake message's "certificate_authorities" field from whatever trust + * store/trust manager is configured on the connector -- and absent an explicit one, JSSE falls back to + * the JVM's default {@code cacerts} (a large list of public root CAs, e.g. DigiCert, Let's Encrypt, + * etc.), none of which sign any real client's mTLS certificate here. Well-behaved TLS clients -- + * including Go's {@code crypto/tls}, used by the real Gorouter -- select which certificate (if any) to + * present by matching its issuer against that advertised list, and send an empty Certificate + * message if nothing matches, per the TLS spec. Confirmed empirically against a live deployment: + * {@code openssl s_client}'s "Acceptable client certificate CA names" output listed only unrelated + * public root CAs, never {@code service_cf_internal_ca} (the CA that signs the Gorouter's own backend + * mTLS certificate) -- and packet capture confirmed the Gorouter's actual backend connection sent a + * zero-length certificate_list in response, silently defeating this entire feature. An empty trust + * store was tried first but rejected: Tomcat's PKIX-based trust manager path + * ({@code TrustManagerFactory}'s default algorithm on at least one JDK build) throws + * {@code InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty} for an + * empty {@link java.security.KeyStore} -- PKIX fundamentally requires at least one trust anchor. + * {@link NoAcceptedIssuersTrustManager} sidesteps that entirely by bypassing the KeyStore/algorithm + * path via Tomcat's {@code trustManagerClassName} extension point, advertising no acceptable-issuer + * constraint at all -- consistent with this customizer's overall design of deferring the trust + * decision entirely to per-client application logic. + * *

Runs after Spring Boot's own SSL connector configuration so it can override the already-configured * {@link SSLHostConfig}(s) on the connector. */ @@ -54,6 +77,7 @@ public void customize(TomcatServletWebServerFactory factory) { for (SSLHostConfig sslHostConfig : connector.findSslHostConfigs()) { sslHostConfig.setCertificateVerification("optionalNoCA"); sslHostConfig.setProtocols("all,-TLSv1.3"); + sslHostConfig.setTrustManagerClassName(NoAcceptedIssuersTrustManager.class.getName()); } }); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/NoAcceptedIssuersTrustManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/NoAcceptedIssuersTrustManager.java new file mode 100644 index 00000000000..11cd6face6e --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/NoAcceptedIssuersTrustManager.java @@ -0,0 +1,51 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import javax.net.ssl.X509TrustManager; +import java.security.cert.X509Certificate; + +/** + * A {@link X509TrustManager} used by {@link MtlsClientAuthTomcatCustomizer} to make the mTLS + * connector advertise no acceptable-issuer constraint in the TLS {@code CertificateRequest} + * handshake message, while performing no certificate validation at the TLS layer at all -- the trust + * decision is deferred entirely to per-client application logic in + * {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication}. + * + *

Must be a public, top-level class with a public no-arg constructor: Tomcat instantiates trust + * manager classes configured via {@code SSLHostConfig#setTrustManagerClassName(String)} via + * reflection ({@code Class#getConstructor()} / {@code Constructor#newInstance()}), which requires + * exactly that shape. + * + *

An empty accepted-issuers list is not merely "no restriction is enforced" -- it changes what + * Tomcat/JSSE actually puts on the wire. Even with {@code certificateVerification=optionalNoCA} (which + * only disables validation), Tomcat still populates the handshake's "certificate_authorities" field + * from whatever trust store/trust manager is configured, and absent one, JSSE falls back to the JVM's + * default {@code cacerts} (a long list of unrelated public root CAs). Well-behaved TLS clients -- + * including Go's {@code crypto/tls}, used by the real Gorouter -- select which certificate (if any) to + * present by matching its issuer against that advertised list, and send an empty Certificate + * message if nothing matches, per the TLS spec -- silently withholding a legitimate client certificate + * whose CA simply isn't a public root CA. An empty accepted-issuers list here means "any CA is + * acceptable" on the wire, so such clients present whatever certificate they have configured. + * + * @see MtlsClientAuthTomcatCustomizer + */ +public final class NoAcceptedIssuersTrustManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // No-op: TLS-layer client certificate validation is intentionally disabled + // (certificateVerification=optionalNoCA on the connector). Proof of private-key possession + // still happens as part of the handshake itself; the trust decision (which CA, if any, is + // acceptable for a given client) is deferred entirely to per-client application logic. + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // Not used: this trust manager is installed on a server-side connector to evaluate + // certificates presented BY clients, not to validate a server certificate. + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java index 7e70a666c84..bded08a7f6b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java @@ -39,6 +39,7 @@ import java.security.cert.X509Certificate; import java.util.Date; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; @@ -90,6 +91,44 @@ void requestsAndAcceptsAnUntrustedClientCertificateWhenMtlsEnabled() throws Exce .isTrue(); } + /** + * Regression test for a real-deployment finding (Task 14 end-to-end verification): even with + * {@code certificateVerification=optionalNoCA}, Tomcat/JSSE still populates the + * {@code CertificateRequest}'s "certificate_authorities" field from the connector's trust store -- + * which, absent any explicit trust store configuration, falls back to the JVM's default + * {@code cacerts} (a large list of public root CAs). Confirmed empirically against a live + * deployment via {@code openssl s_client -tls1_2}, whose "Acceptable client certificate CA names" + * output listed only unrelated public root CAs (Certainly, Cybertrust, QuoVadis, etc.), never + * {@code service_cf_internal_ca} (the CA that signs the Gorouter's own backend mTLS certificate). + * Go's {@code crypto/tls} client (used by the real Gorouter) correctly implements TLS's client + * certificate selection rules: when none of its available certificates' issuers appear in that + * list, it sends an empty Certificate message rather than presenting a cert the server + * didn't ask for -- confirmed via packet capture showing a zero-length certificate_list. The + * existing {@link #requestsAndAcceptsAnUntrustedClientCertificateWhenMtlsEnabled()} test only + * verifies the client's {@code chooseClientAlias} callback fires (proving a + * {@code CertificateRequest} was sent) -- not that an alias was actually chosen and a certificate + * actually transmitted, so it did not catch this. This test checks the KeyManager's actual return + * value (the alias it chose, or {@code null} if none matched), which is what real TLS clients like + * Go's use to decide whether to present a certificate at all. + */ + @Test + void chosenClientAliasIsNotNullEvenWhenCertIssuerIsNotInDefaultCaCerts() throws Exception { + int port = startServer(true); + AtomicReference chosenAlias = new AtomicReference<>("not-yet-invoked"); + + try (SSLSocket socket = clientSocketTrackingChosenAlias(port, chosenAlias)) { + socket.startHandshake(); + } + + assertThat(chosenAlias) + .as("the KeyManager must actually choose an alias (not null) for a certificate whose " + + "issuer is not in the JVM's default cacerts -- otherwise real TLS clients " + + "(e.g. Go's crypto/tls, used by the Gorouter) will send an empty certificate " + + "message instead of the client cert, silently defeating this entire feature " + + "for any backend TLS connection whose CA isn't a public root CA") + .doesNotHaveValue(null); + } + /** * Regression test for a real-deployment finding (Task 14 end-to-end verification): Tomcat's own * startup log emits "The JSSE TLS 1.3 implementation does not support post handshake @@ -187,6 +226,93 @@ private SSLSocket clientSocketPresentingArbitraryCert(int port, AtomicBoolean cl return (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); } + /** + * A client socket whose only certificate is signed by a throwaway, arbitrary self-signed CA (i.e. + * NOT one of the JVM's default {@code cacerts} public root CAs). Tracks the actual alias the + * KeyManager chooses for {@code chooseClientAlias}/{@code chooseEngineClientAlias} -- {@code null} + * means no matching certificate was found for the server's advertised acceptable-issuer list, so no + * certificate will actually be transmitted (see {@link #chosenClientAliasIsNotNullEvenWhenCertIssuerIsNotInDefaultCaCerts()}). + */ + private SSLSocket clientSocketTrackingChosenAlias(int port, AtomicReference chosenAlias) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(4)); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + KeyManager[] trackingKeyManagers = trackChosenClientAlias(keyManagerFactory.getKeyManagers(), chosenAlias); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackingKeyManagers, new TrustManager[]{trustAnyServerCertificate()}, null); + + SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + socket.setEnabledProtocols(new String[]{"TLSv1.2"}); + return socket; + } + + /** + * Wraps each {@link X509ExtendedKeyManager} so we can observe the actual alias returned by + * {@code chooseClientAlias}/{@code chooseEngineClientAlias} -- {@code null} means the delegate + * KeyManager found no certificate whose issuer matched the server's advertised acceptable-issuer + * list, so nothing will be sent (unlike {@link #trackClientAliasRequests}, which only tracks + * whether the callback was invoked at all). + */ + private KeyManager[] trackChosenClientAlias(KeyManager[] keyManagers, AtomicReference chosenAlias) { + KeyManager[] wrapped = new KeyManager[keyManagers.length]; + for (int i = 0; i < keyManagers.length; i++) { + if (keyManagers[i] instanceof X509ExtendedKeyManager delegate) { + wrapped[i] = new X509ExtendedKeyManager() { + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + String alias = delegate.chooseClientAlias(keyType, issuers, socket); + chosenAlias.set(alias); + return alias; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + String alias = delegate.chooseEngineClientAlias(keyType, issuers, engine); + chosenAlias.set(alias); + return alias; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate.getClientAliases(keyType, issuers); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return delegate.chooseServerAlias(keyType, issuers, socket); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate.getServerAliases(keyType, issuers); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + }; + } else { + wrapped[i] = keyManagers[i]; + } + } + return wrapped; + } + /** * A client socket configured to offer both TLSv1.2 and TLSv1.3, presenting an arbitrary * untrusted certificate if asked. Used to verify which protocol the connector actually From 28d2b82875cb8174f35566626a81a460de0775be Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 19 Aug 2026 17:31:40 +0200 Subject: [PATCH 057/130] docs: update RawPeerCertificateCaptureFilter javadoc to describe the actual implementation Final coherence review follow-up: this class's javadoc still described isCertificateFromTrustedProxy(...) as a 'future' method with a peer-vs-XFCC comparison it doesn't actually perform (it does direct PKIX path validation of the captured raw peer cert against the client's tls-client-auth-trusted-proxy-ca, not a literal comparison of two certificates). The method has existed since 1a19dbb2b; this class was never updated afterward. Replaces the forward-looking description with one that matches current behavior and clarifies that this filter's captured value is used only for the trust check, never as the returned client certificate itself. --- .../tls/RawPeerCertificateCaptureFilter.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java index e799ebc1730..c4a1ca7f101 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java @@ -22,11 +22,16 @@ * *

This filter must be registered to run before {@code ClientCertificateMapper} so that the * genuine peer certificate is preserved in a dedicated attribute - * ({@link #RAW_PEER_CERTIFICATE_ATTRIBUTE}). Later code can then compare "what the immediate TLS peer - * actually presented" against "what the {@code X-Forwarded-Client-Cert} header claims" to confirm the - * header was genuinely set by a trusted proxy (e.g. the Gorouter) rather than a direct caller spoofing it. - * That comparison is expected to be implemented by a future - * {@code TlsClientAuthentication.isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} method. + * ({@link #RAW_PEER_CERTIFICATE_ATTRIBUTE}). This is what allows + * {@link TlsClientAuthentication#isCertificateFromTrustedProxy(org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration)} + * to validate "what the immediate TLS peer actually presented" (this attribute) against a specific + * client's configured {@code tls-client-auth-trusted-proxy-ca}, confirming the + * {@code X-Forwarded-Client-Cert} header was genuinely set by a trusted proxy (e.g. the Gorouter) + * rather than a direct caller spoofing it -- the actual value returned to callers (e.g. via + * {@link TlsClientAuthentication#getCertificateFromRequest(org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration)}) + * still comes from the standard, XFCC-derived {@code jakarta.servlet.request.X509Certificate} + * attribute -- this filter's captured value is used only for the trust check, never as the + * authenticated client certificate itself. */ public class RawPeerCertificateCaptureFilter implements Filter { From aa5117769ee5b98dc8f68d76f5dd6a42f7797b63 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 11:49:07 +0200 Subject: [PATCH 058/130] feat: register FIPS Bouncy Castle JSSE provider for the mTLS connector --- .../MtlsClientAuthTomcatCustomizer.java | 21 +++++++++++++++++++ .../MtlsClientAuthTomcatCustomizerTest.java | 14 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index 8f37e676a84..175d6b8d988 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -1,11 +1,15 @@ package org.cloudfoundry.identity.uaa.web.tomcat; import org.apache.tomcat.util.net.SSLHostConfig; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.stereotype.Component; +import java.security.Security; + /** * When {@code uaa.mtls-enabled} is true, configures the embedded Tomcat connector to request a client * certificate during the TLS handshake without validating it against any CA at the TLS layer @@ -81,4 +85,21 @@ public void customize(TomcatServletWebServerFactory factory) { } }); } + + /** + * Registers the FIPS Bouncy Castle provider ({@code BCFIPS}) and the FIPS Bouncy Castle JSSE + * provider ({@code BCJSSE}) idempotently, if not already present. Registering the low-level + * crypto provider first is required: {@code BouncyCastleJsseProvider} built in FIPS mode binds + * to it, which is what makes {@code SSLContext.getInstance("TLS", "BCJSSE")} usable (and is what + * supplies FIPS-compliant {@code SecureRandom}s for the TLS handshake). + */ + static void ensureJsseProviderRegistered() { + if (Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleFipsProvider()); + } + if (Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleJsseProvider(true, + Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME))); + } + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java index d095f9dc64a..dcd4cf55234 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -2,9 +2,12 @@ import org.apache.catalina.connector.Connector; import org.apache.tomcat.util.net.SSLHostConfig; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.jupiter.api.Test; import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import java.security.Security; + import static org.assertj.core.api.Assertions.assertThat; class MtlsClientAuthTomcatCustomizerTest { @@ -34,4 +37,15 @@ void doesNothingWhenMtlsDisabled() { assertThat(factory.getConnectorCustomizers()).isEmpty(); } + + @Test + void registersTheFipsBouncyCastleJsseProviderIdempotently() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + assertThat(Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isNotNull(); + BouncyCastleJsseProvider registered = + (BouncyCastleJsseProvider) Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + assertThat(registered.isFipsMode()).isTrue(); + } } From 1e0b6980833afbe15aa8d136a74052eabdbe4396 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 11:56:44 +0200 Subject: [PATCH 059/130] feat: add BCJSSESSLContext, a BCJSSE-backed Tomcat SSLContext --- .../uaa/web/tomcat/BCJSSESSLContext.java | 112 ++++++++++++++++++ .../uaa/web/tomcat/BCJSSESSLContextTest.java | 22 ++++ 2 files changed, 134 insertions(+) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContext.java create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContext.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContext.java new file mode 100644 index 00000000000..d206da387cb --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContext.java @@ -0,0 +1,112 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; + +import org.apache.tomcat.util.net.SSLContext; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; + +/** + * A Tomcat {@link SSLContext} backed by the FIPS Bouncy Castle JSSE provider (BCJSSE). Mirrors Tomcat's + * own (package-private) {@code JSSESSLContext} but sources the underlying {@code javax.net.ssl.SSLContext} + * from the registered {@link BouncyCastleJsseProvider} instead of the default (SunJSSE) provider. + * + *

This is what lets the {@code uaa.mtls-enabled} connector both negotiate TLS 1.3 and request an + * optional client certificate: unlike SunJSSE (which sends no {@code CertificateRequest} under TLS 1.3, + * JDK-8206923), BCJSSE sends an in-handshake TLS 1.3 {@code CertificateRequest}. + * + *

Requires {@link MtlsClientAuthTomcatCustomizer#ensureJsseProviderRegistered()} to have run, so that + * the {@code BCJSSE} provider is registered. + */ +public final class BCJSSESSLContext implements SSLContext { + + private final javax.net.ssl.SSLContext context; + private KeyManager[] kms; + private TrustManager[] tms; + + public BCJSSESSLContext(String protocol) throws NoSuchAlgorithmException { + Provider provider = Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + if (provider == null) { + throw new NoSuchAlgorithmException( + "The " + BouncyCastleJsseProvider.PROVIDER_NAME + " provider is not registered; " + + "call MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered() before " + + "building the connector SSLContext"); + } + this.context = javax.net.ssl.SSLContext.getInstance(protocol, provider); + } + + @Override + public void init(KeyManager[] kms, TrustManager[] tms, SecureRandom sr) throws KeyManagementException { + this.kms = kms; + this.tms = tms; + context.init(kms, tms, sr); + } + + @Override + public void destroy() { + // No-op, matching Tomcat's JSSESSLContext. + } + + @Override + public SSLSessionContext getServerSessionContext() { + return context.getServerSessionContext(); + } + + @Override + public SSLEngine createSSLEngine() { + return context.createSSLEngine(); + } + + @Override + public SSLServerSocketFactory getServerSocketFactory() { + return context.getServerSocketFactory(); + } + + @Override + public SSLParameters getSupportedSSLParameters() { + return context.getSupportedSSLParameters(); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + X509Certificate[] result = null; + if (kms != null) { + for (int i = 0; i < kms.length && result == null; i++) { + if (kms[i] instanceof X509KeyManager) { + result = ((X509KeyManager) kms[i]).getCertificateChain(alias); + } + } + } + return result; + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + Set certs = new HashSet<>(); + if (tms != null) { + for (TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + X509Certificate[] accepted = ((X509TrustManager) tm).getAcceptedIssuers(); + certs.addAll(Arrays.asList(accepted)); + } + } + } + return certs.toArray(new X509Certificate[0]); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java new file mode 100644 index 00000000000..01cba7b8f7d --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java @@ -0,0 +1,22 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BCJSSESSLContextTest { + + @BeforeEach + void setUp() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + } + + @Test + void supportsTls12AndTls13FromTheFipsBouncyCastleJsseProvider() throws Exception { + BCJSSESSLContext context = new BCJSSESSLContext("TLS"); + context.init(null, null, null); + + assertThat(context.getSupportedSSLParameters().getProtocols()).contains("TLSv1.3", "TLSv1.2"); + } +} From 15e8c50535032b4944ce4b9ec5e11cb6d88a2e52 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 12:08:08 +0200 Subject: [PATCH 060/130] test: cover the fail-fast path when BCJSSE is not registered --- .../uaa/web/tomcat/BCJSSESSLContextTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java index 01cba7b8f7d..00188139883 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java @@ -1,9 +1,15 @@ package org.cloudfoundry.identity.uaa.web.tomcat; +import java.security.NoSuchAlgorithmException; +import java.security.Security; + +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class BCJSSESSLContextTest { @@ -12,6 +18,11 @@ void setUp() { MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); } + @AfterEach + void tearDown() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + } + @Test void supportsTls12AndTls13FromTheFipsBouncyCastleJsseProvider() throws Exception { BCJSSESSLContext context = new BCJSSESSLContext("TLS"); @@ -19,4 +30,13 @@ void supportsTls12AndTls13FromTheFipsBouncyCastleJsseProvider() throws Exception assertThat(context.getSupportedSSLParameters().getProtocols()).contains("TLSv1.3", "TLSv1.2"); } + + @Test + void throwsWhenTheBcjsseProviderIsNotRegistered() { + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + + assertThatThrownBy(() -> new BCJSSESSLContext("TLS")) + .isInstanceOf(NoSuchAlgorithmException.class) + .hasMessageContaining("ensureJsseProviderRegistered"); + } } From 38febe6978600e0afb42e8d00dcbc34353eda3f6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 12:16:47 +0200 Subject: [PATCH 061/130] feat: serve the mTLS connector's SSLContext from FIPS BCJSSE (TLS 1.3 client auth) --- .../web/tomcat/BCJSSESslImplementation.java | 20 +++++++++++ .../identity/uaa/web/tomcat/BCJSSEUtil.java | 34 +++++++++++++++++++ .../MtlsClientAuthTomcatCustomizer.java | 30 ++++++++-------- .../MtlsClientAuthTomcatCustomizerTest.java | 31 +++++++++++++++++ 4 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESslImplementation.java create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESslImplementation.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESslImplementation.java new file mode 100644 index 00000000000..26addff76b0 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESslImplementation.java @@ -0,0 +1,20 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.tomcat.util.net.SSLHostConfigCertificate; +import org.apache.tomcat.util.net.SSLUtil; +import org.apache.tomcat.util.net.jsse.JSSEImplementation; + +/** + * A Tomcat {@link org.apache.tomcat.util.net.SSLImplementation} that serves the connector's + * {@link SSLUtil} from a {@link BCJSSEUtil}, i.e. backed by the FIPS Bouncy Castle JSSE provider + * (BCJSSE). Referenced from {@link MtlsClientAuthTomcatCustomizer} via the connector's + * {@code sslImplementationName} so that only this connector uses BCJSSE; every other JVM SSLContext + * (LDAP, DB, outbound TLS) stays on the default provider. + */ +public final class BCJSSESslImplementation extends JSSEImplementation { + + @Override + public SSLUtil getSSLUtil(SSLHostConfigCertificate certificate) { + return new BCJSSEUtil(certificate); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java new file mode 100644 index 00000000000..6f317fec99b --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java @@ -0,0 +1,34 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import java.security.NoSuchAlgorithmException; +import java.util.List; + +import org.apache.tomcat.util.net.SSLContext; +import org.apache.tomcat.util.net.SSLHostConfigCertificate; +import org.apache.tomcat.util.net.jsse.JSSEUtil; + +/** + * A {@link JSSEUtil} that builds the connector's {@link SSLContext} from the FIPS Bouncy Castle JSSE + * provider (BCJSSE) via {@link BCJSSESSLContext}, and declares that TLS 1.3 renegotiable + * (post-handshake-requestable) client authentication is available -- which is precisely what JSSE + * cannot do (the reason the connector previously pinned {@code all,-TLSv1.3}). + * + *

Everything else (keystore loading, {@code trustManagerClassName} handling, cipher/protocol + * filtering) is inherited from {@link JSSEUtil}/{@link org.apache.tomcat.util.net.SSLUtilBase}. + */ +public final class BCJSSEUtil extends JSSEUtil { + + public BCJSSEUtil(SSLHostConfigCertificate certificate) { + super(certificate); + } + + @Override + public SSLContext createSSLContextInternal(List negotiableProtocols) throws NoSuchAlgorithmException { + return new BCJSSESSLContext(sslHostConfig.getSslProtocol()); + } + + @Override + protected boolean isTls13RenegAuthAvailable() { + return true; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index 175d6b8d988..862f13a6b2b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -1,5 +1,6 @@ package org.cloudfoundry.identity.uaa.web.tomcat; +import org.apache.coyote.http11.AbstractHttp11Protocol; import org.apache.tomcat.util.net.SSLHostConfig; import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; @@ -23,19 +24,17 @@ * need to be kept in sync with whatever CA(s) are configured per-client at runtime via the client-admin * API, which is an operational hazard. * - *

Also disables TLSv1.3 on this connector ({@code protocols="all,-TLSv1.3"}). This works around a - * real, confirmed limitation of JSSE's TLS 1.3 implementation: requesting a client certificate - * without also requiring/validating it against a CA (i.e. {@code optionalNoCA}, or Tomcat's other - * {@code optional} mode) relies on the server being able to request the certificate again later via - * post-handshake authentication (PHA) if it wasn't sent upfront -- and JSSE's TLS 1.3 implementation - * does not support PHA (Tomcat itself logs this exact incompatibility at startup: - * "The JSSE TLS 1.3 implementation does not support post handshake authentication (PHA) and is - * therefore incompatible with optional certificate authentication"). In practice, on at least one real - * JDK build this means the server silently never sends a {@code CertificateRequest} at all under TLS - * 1.3, so no client certificate -- trusted or not -- is ever captured, silently defeating this entire - * feature. Confirmed empirically against a live deployment via {@code openssl s_client}: {@code -tls1_2} - * receives a {@code CertificateRequest}; {@code -tls1_3} does not. Restricting this connector to - * TLSv1.2 is Tomcat's own documented workaround for this exact incompatibility. + *

Uses the FIPS Bouncy Castle JSSE provider (BCJSSE) for this connector. OpenJDK's JSSE never + * implemented server-side TLS 1.3 client authentication (JDK-8206923): requesting a client certificate + * without requiring/validating it against a CA ({@code certificateVerification=optionalNoCA}, or Tomcat's + * {@code optional} mode) relies on requesting the certificate again via post-handshake authentication + * (PHA), which JSSE's TLS 1.3 implementation does not support -- and on at least one JDK build the server + * silently never sends a {@code CertificateRequest} at all under TLS 1.3, silently defeating this feature. + * BCJSSE implements TLS 1.3 client authentication in-handshake, so the connector can offer TLS 1.3 again + * (reverting this PR's earlier {@code all,-TLSv1.3} pin and restoring the pre-PR {@code TLSv1.2,TLSv1.3} + * protocol set). The BCJSSE provider is registered idempotently, and the connector's protocol handler is + * pointed at {@link BCJSSESslImplementation} via {@code sslImplementationName} so that only this connector + * uses BCJSSE; all other JVM TLS stays on the default provider. * *

Also installs a custom {@link NoAcceptedIssuersTrustManager} on the connector, via * {@code SSLHostConfig#setTrustManagerClassName(String)}. Even with {@code optionalNoCA} (which @@ -77,10 +76,13 @@ public void customize(TomcatServletWebServerFactory factory) { if (!mtlsEnabled) { return; } + ensureJsseProviderRegistered(); factory.addConnectorCustomizers(connector -> { + if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol protocol) { + protocol.setSslImplementationName(BCJSSESslImplementation.class.getName()); + } for (SSLHostConfig sslHostConfig : connector.findSslHostConfigs()) { sslHostConfig.setCertificateVerification("optionalNoCA"); - sslHostConfig.setProtocols("all,-TLSv1.3"); sslHostConfig.setTrustManagerClassName(NoAcceptedIssuersTrustManager.class.getName()); } }); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java index dcd4cf55234..64a3ab43cad 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.web.tomcat; import org.apache.catalina.connector.Connector; +import org.apache.coyote.http11.AbstractHttp11Protocol; import org.apache.tomcat.util.net.SSLHostConfig; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.jupiter.api.Test; @@ -26,6 +27,36 @@ void setsOptionalNoCaWhenMtlsEnabled() { assertThat(sslHostConfig.getCertificateVerification()) .isEqualTo(SSLHostConfig.CertificateVerification.OPTIONAL_NO_CA); + assertThat(sslHostConfig.getTrustManagerClassName()).isEqualTo(NoAcceptedIssuersTrustManager.class.getName()); + } + + @Test + void usesTheBouncyCastleJsseImplementationWhenMtlsEnabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + customizer.customize(factory); + + Connector connector = new Connector(); + factory.getConnectorCustomizers().forEach(c -> c.customize(connector)); + + assertThat(((AbstractHttp11Protocol) connector.getProtocolHandler()).getSslImplementationName()) + .isEqualTo(BCJSSESslImplementation.class.getName()); + } + + @Test + void doesNotExcludeTlsV13FromTheConnectorWhenMtlsEnabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + customizer.customize(factory); + + Connector connector = new Connector(); + SSLHostConfig sslHostConfig = new SSLHostConfig(); + connector.addSslHostConfig(sslHostConfig); + factory.getConnectorCustomizers().forEach(c -> c.customize(connector)); + + assertThat(sslHostConfig.getProtocols()) + .as("TLS 1.3 must not be excluded once the connector is served by BCJSSE") + .doesNotContain("all,-TLSv1.3"); } @Test From f687fa11f78ed5828b4f9e0ceac35d707f99de6b Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 12:34:24 +0200 Subject: [PATCH 062/130] fix: derive BCJSSEUtil's implemented protocols/ciphers from BCJSSE, not SunJSSE BCJSSEUtil previously inherited JSSEUtil's private initialise(), which probes a SunJSSE-backed SSLContext to discover implemented protocols/ciphers -- and that set includes SSLv2Hello/SSLv3, which SunJSSE supports but BCJSSE does not. SSLUtilBase's constructor only strips SSLv2Hello/TLSv1.3 from the configured protocol set when the implemented set doesn't contain them, so inheriting SunJSSE's implemented set meant SSLv2Hello was never stripped, and every handshake against the BCJSSE-backed engine failed with 'protocols cannot be null, or contain unsupported protocols'. Also fail fast (IllegalStateException) instead of silently skipping sslImplementationName wiring when the connector isn't HTTP/1.1-based. --- .../identity/uaa/web/tomcat/BCJSSEUtil.java | 45 ++++++++++++++++++- .../MtlsClientAuthTomcatCustomizer.java | 8 +++- .../uaa/web/tomcat/BCJSSEUtilTest.java | 28 ++++++++++++ .../MtlsClientAuthTomcatCustomizerTest.java | 14 ++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtilTest.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java index 6f317fec99b..128b80253b9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java @@ -1,7 +1,13 @@ package org.cloudfoundry.identity.uaa.web.tomcat; +import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; + +import javax.net.ssl.SSLParameters; import org.apache.tomcat.util.net.SSLContext; import org.apache.tomcat.util.net.SSLHostConfigCertificate; @@ -13,8 +19,17 @@ * (post-handshake-requestable) client authentication is available -- which is precisely what JSSE * cannot do (the reason the connector previously pinned {@code all,-TLSv1.3}). * - *

Everything else (keystore loading, {@code trustManagerClassName} handling, cipher/protocol - * filtering) is inherited from {@link JSSEUtil}/{@link org.apache.tomcat.util.net.SSLUtilBase}. + *

{@code getImplementedProtocols()}/{@code getImplementedCiphers()} are overridden to source from + * BCJSSE's own {@code getSupportedSSLParameters()} rather than {@link JSSEUtil}'s private + * {@code initialise()}, which probes a SunJSSE-backed {@code SSLContext} instead. This matters because + * {@code SSLUtilBase}'s constructor only strips {@code SSLv2Hello}/{@code TLSv1.3} from the connector's + * configured protocol set when the implemented set doesn't contain them -- and SunJSSE's + * implemented set (unlike BCJSSE's) includes {@code SSLv2Hello}, which the BC-backed engine then + * rejects at handshake time ({@code ProvSSLParameters.setProtocols}: "'protocols' cannot be null, or + * contain unsupported protocols"), breaking every TLS handshake on this connector. + * + *

Everything else (keystore loading, {@code trustManagerClassName} handling) is inherited from + * {@link JSSEUtil}/{@link org.apache.tomcat.util.net.SSLUtilBase}. */ public final class BCJSSEUtil extends JSSEUtil { @@ -31,4 +46,30 @@ public SSLContext createSSLContextInternal(List negotiableProtocols) thr protected boolean isTls13RenegAuthAvailable() { return true; } + + @Override + protected Set getImplementedProtocols() { + return new HashSet<>(Arrays.asList(supportedSslParameters().getProtocols())); + } + + @Override + protected Set getImplementedCiphers() { + return new HashSet<>(Arrays.asList(supportedSslParameters().getCipherSuites())); + } + + /** + * Recomputed on every call, deliberately not cached in an instance field: {@code SSLUtilBase}'s + * constructor invokes {@link #getImplementedProtocols()}/{@link #getImplementedCiphers()} via + * dynamic dispatch before this subclass's own field initializers run, so caching here would risk + * a stale/uninitialized value being read on that first, superclass-constructor-driven call. + */ + private SSLParameters supportedSslParameters() { + try { + BCJSSESSLContext context = new BCJSSESSLContext(sslHostConfig.getSslProtocol()); + context.init(null, null, null); + return context.getSupportedSSLParameters(); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException(e); + } + } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index 862f13a6b2b..790512fcacc 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -78,9 +78,13 @@ public void customize(TomcatServletWebServerFactory factory) { } ensureJsseProviderRegistered(); factory.addConnectorCustomizers(connector -> { - if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol protocol) { - protocol.setSslImplementationName(BCJSSESslImplementation.class.getName()); + if (!(connector.getProtocolHandler() instanceof AbstractHttp11Protocol protocol)) { + throw new IllegalStateException( + "uaa.mtls-enabled requires an HTTP/1.1 Tomcat connector (got " + + connector.getProtocolHandler().getClass().getName() + + "); cannot install BCJSSESslImplementation for TLS 1.3 client-cert support"); } + protocol.setSslImplementationName(BCJSSESslImplementation.class.getName()); for (SSLHostConfig sslHostConfig : connector.findSslHostConfigs()) { sslHostConfig.setCertificateVerification("optionalNoCA"); sslHostConfig.setTrustManagerClassName(NoAcceptedIssuersTrustManager.class.getName()); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtilTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtilTest.java new file mode 100644 index 00000000000..0d65ea40d0e --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtilTest.java @@ -0,0 +1,28 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.tomcat.util.net.SSLHostConfig; +import org.apache.tomcat.util.net.SSLHostConfigCertificate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BCJSSEUtilTest { + + @BeforeEach + void setUp() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + } + + @Test + void enabledProtocolsExcludeSslv2HelloAndIncludeTls13() { + SSLHostConfig sslHostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = + new SSLHostConfigCertificate(sslHostConfig, SSLHostConfigCertificate.Type.UNDEFINED); + + BCJSSEUtil util = new BCJSSEUtil(certificate); + + assertThat(util.getEnabledProtocols()).contains("TLSv1.3", "TLSv1.2"); + assertThat(util.getEnabledProtocols()).doesNotContain("SSLv2Hello", "SSLv3"); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java index 64a3ab43cad..ddd7aafa0b7 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -10,6 +10,7 @@ import java.security.Security; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class MtlsClientAuthTomcatCustomizerTest { @@ -59,6 +60,19 @@ void doesNotExcludeTlsV13FromTheConnectorWhenMtlsEnabled() { .doesNotContain("all,-TLSv1.3"); } + @Test + void failsFastWhenTheConnectorIsNotHttp11Based() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + customizer.customize(factory); + + Connector connector = new Connector("AJP/1.3"); + + assertThatThrownBy(() -> factory.getConnectorCustomizers().forEach(c -> c.customize(connector))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("HTTP/1.1"); + } + @Test void doesNothingWhenMtlsDisabled() { MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(false); From b054b809e29b720a011b89a1e9e17a37e8a64812 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 12:41:52 +0200 Subject: [PATCH 063/130] test: verify TLS 1.3 client-auth negotiation on the BCJSSE connector --- ...ntAuthTomcatCustomizerIntegrationTest.java | 100 ++++++++++++++---- 1 file changed, 79 insertions(+), 21 deletions(-) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java index bded08a7f6b..931701723c1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java @@ -7,6 +7,7 @@ import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.AfterEach; @@ -129,35 +130,50 @@ void chosenClientAliasIsNotNullEvenWhenCertIssuerIsNotInDefaultCaCerts() throws .doesNotHaveValue(null); } - /** - * Regression test for a real-deployment finding (Task 14 end-to-end verification): Tomcat's own - * startup log emits "The JSSE TLS 1.3 implementation does not support post handshake - * authentication (PHA) and is therefore incompatible with optional certificate authentication" -- - * and on at least one JDK build used in a real BOSH-deployed environment, a TLS 1.3 handshake - * against this connector never sends a {@code CertificateRequest} at all (confirmed empirically - * via {@code openssl s_client -tls1_3} against a live instance, compared against - * {@code -tls1_2} which does send one). Tomcat's own documented workaround for this exact - * incompatibility is to exclude TLSv1.3 from the connector's enabled protocols - * ({@code protocols="all,-TLSv1.3"}) whenever {@code certificateVerification=optionalNoCA} is in - * use. This test asserts the customizer actually negotiates TLSv1.2 (not TLSv1.3) even when the - * client is willing to speak both -- which is what actually prevents the silent-no-CertificateRequest - * failure mode in production, independent of whether any particular local JDK happens to dodge the - * underlying JSSE limitation. - */ @Test - void negotiatesTlsV12NotTlsV13WhenMtlsEnabled() throws Exception { + void negotiatesTlsV13AndRequestsClientCertificateWhenMtlsEnabled() throws Exception { + int port = startServer(true); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); + + try (SSLSocket socket = clientSocketOfferingBothTls12And13TrackingCertRequest(port, clientCertRequested)) { + socket.startHandshake(); + + assertThat(socket.getSession().getProtocol()) + .as("BCJSSE supports TLS 1.3 client-auth in-handshake, so the connector re-enables " + + "TLS 1.3 (JSSE could not, because it cannot send a CertificateRequest " + + "under TLS 1.3 -- JDK-8206923)") + .isEqualTo("TLSv1.3"); + } + + assertThat(clientCertRequested) + .as("the connector must send a CertificateRequest under TLS 1.3 (BCJSSE does this " + + "in-handshake), so an optional client certificate is captured") + .isTrue(); + } + + @Test + void negotiatesTlsV12AndRequestsClientCertificateWhenMtlsEnabled() throws Exception { int port = startServer(true); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); - try (SSLSocket socket = clientSocketOfferingBothTls12And13(port)) { + try (SSLSocket socket = clientSocketTrackingCertRequestOnTls12(port, clientCertRequested)) { socket.startHandshake(); assertThat(socket.getSession().getProtocol()) - .as("connector must not negotiate TLSv1.3 when optionalNoCA client-auth is in " - + "effect, since JSSE's TLS 1.3 implementation cannot request a client " - + "certificate without post-handshake authentication (PHA), which it does " - + "not support -- see MtlsClientAuthTomcatCustomizer's Javadoc") + .as("TLS 1.2 client-auth continues to work under BCJSSE") .isEqualTo("TLSv1.2"); } + + assertThat(clientCertRequested).isTrue(); + } + + @Test + void connectorServesFromTheFipsBouncyCastleJsseProvider() throws Exception { + startServer(true); + + assertThat(Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isNotNull(); + assertThat(((BouncyCastleJsseProvider) Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isFipsMode()) + .isTrue(); } @Test @@ -339,6 +355,48 @@ private SSLSocket clientSocketOfferingBothTls12And13(int port) throws Exception return socket; } + private SSLSocket clientSocketOfferingBothTls12And13TrackingCertRequest(int port, AtomicBoolean clientCertRequested) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(5)); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackClientAliasRequests(keyManagerFactory.getKeyManagers(), clientCertRequested), + new TrustManager[]{trustAnyServerCertificate()}, null); + + SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + socket.setEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"}); + return socket; + } + + private SSLSocket clientSocketTrackingCertRequestOnTls12(int port, AtomicBoolean clientCertRequested) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(6)); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackClientAliasRequests(keyManagerFactory.getKeyManagers(), clientCertRequested), + new TrustManager[]{trustAnyServerCertificate()}, null); + + SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + socket.setEnabledProtocols(new String[]{"TLSv1.2"}); + return socket; + } + /** * Wraps each {@link X509ExtendedKeyManager} so we can observe -- from the client side -- whether * the server ever asked for a client certificate during the handshake. JSSE only calls From 7496ce8c2e7b5048277a70b0927d26a8e36cacf1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 12:50:59 +0200 Subject: [PATCH 064/130] test: remove clientSocketOfferingBothTls12And13, unused after the TLS 1.3 test rewrite Its only caller was negotiatesTlsV12NotTlsV13WhenMtlsEnabled(), deleted in the previous commit; its javadoc also had a dangling @link to that now-deleted method. --- ...ntAuthTomcatCustomizerIntegrationTest.java | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java index 931701723c1..3f0f70555a7 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java @@ -329,32 +329,6 @@ public PrivateKey getPrivateKey(String alias) { return wrapped; } - /** - * A client socket configured to offer both TLSv1.2 and TLSv1.3, presenting an arbitrary - * untrusted certificate if asked. Used to verify which protocol the connector actually - * negotiates when both are available to the client (see - * {@link #negotiatesTlsV12NotTlsV13WhenMtlsEnabled()}). - */ - private SSLSocket clientSocketOfferingBothTls12And13(int port) throws Exception { - KeyPair clientKeyPair = generateKeyPair(); - X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); - X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(3)); - - KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); - clientKeyStore.load(null, null); - clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); - - KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); - - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(keyManagerFactory.getKeyManagers(), new TrustManager[]{trustAnyServerCertificate()}, null); - - SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); - socket.setEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"}); - return socket; - } - private SSLSocket clientSocketOfferingBothTls12And13TrackingCertRequest(int port, AtomicBoolean clientCertRequested) throws Exception { KeyPair clientKeyPair = generateKeyPair(); X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); From 9becf1c0ddad4f428e3fc1a18fe923247b1f6326 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 13:53:25 +0200 Subject: [PATCH 065/130] fix(review): reject enhancer overrides of protected JWT claims createJWTAccessToken() applied all additionalRootClaims (from every UaaTokenEnhancer, including MtlsClaimsEnhancer, whose claim-name mappings are client-configurable) after every UAA-owned default claim was set, so an enhancer could overwrite iss, exp, scope, client_id, authorities, or any other protected claim -- not just the intended sub/aud. Reuse the existing NON_ADDITIONAL_ROOT_CLAIMS set (protected claim names) to split the merge into two phases: non-reserved enhancer claims are applied early (before UAA's own claims.put(...) calls, so UAA's value always wins for a protected name), and only sub/aud are re-applied late as the two explicitly-supported overrides (e.g. mTLS cert-identity templates). Every other reserved claim name is rejected outright. Added tests reproducing the overwrite for client_id/cid/authorities/ scope/iss/grant_type (failing for the expected reason before the fix) and confirming non-reserved enhancer claims still apply. Addresses PR review comment on UaaTokenServices.java:574. --- .../identity/uaa/oauth/UaaTokenServices.java | 30 +++++- .../uaa/oauth/UaaTokenServicesTests.java | 100 ++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java index dea81982887..fd639ddae55 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java @@ -536,6 +536,23 @@ private Map createJWTAccessToken(OAuth2AccessToken token, claims.put(JTI, token.getAdditionalInformation().get(JTI)); claims.putAll(token.getAdditionalInformation()); + // Apply enhancer-supplied claims that are NOT one of UAA's own protected/reserved claim + // names (NON_ADDITIONAL_ROOT_CLAIMS) now, before any UAA-owned default below is set -- so + // the corresponding claims.put(...) calls below always win over an enhancer's value for + // the same reserved claim name (e.g. scope, client_id, authorities, iss, grant_type). This + // closes a gap where a client-configurable enhancer (e.g. certificate-derived mTLS claim + // mappings) could otherwise overwrite any UAA-owned/protected claim. sub and aud are the + // two explicitly-supported late overrides (e.g. mTLS cert-identity templates rendering + // their own sub/aud) and are re-applied after all defaults below, once it is safe for them + // to win. + if (additionalRootClaims != null) { + additionalRootClaims.forEach((key, value) -> { + if (!NON_ADDITIONAL_ROOT_CLAIMS.contains(key)) { + claims.put(key, value); + } + }); + } + claims.put(SUB, clientId); if (GRANT_TYPE_CLIENT_CREDENTIALS.equals(grantType)) { claims.put(AUTHORITIES, AuthorityUtils.authorityListToSet(clientScopes)); @@ -566,11 +583,16 @@ private Map createJWTAccessToken(OAuth2AccessToken token, claims.put(AUD, UaaStringUtils.getValuesOrDefaultValue(resourceIds, clientId)); - // Apply token enhancer overrides after all UAA-default claims are set. - // This allows enhancers to override sub/aud (e.g. mTLS cert-identity templates). - // Excluded claims are removed after so operator exclusions always win. + // Re-apply only the two explicitly-supported late overrides (e.g. mTLS cert-identity + // templates rendering their own sub/aud). Every other claim name in additionalRootClaims + // was already rejected above (see NON_ADDITIONAL_ROOT_CLAIMS) and must not win here either. if (additionalRootClaims != null) { - claims.putAll(additionalRootClaims); + if (additionalRootClaims.containsKey(SUB)) { + claims.put(SUB, additionalRootClaims.get(SUB)); + } + if (additionalRootClaims.containsKey(AUD)) { + claims.put(AUD, additionalRootClaims.get(AUD)); + } } for (String excludedClaim : getExcludedClaims()) { diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java index b16f3b17d1f..38cc8fd8fd8 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java @@ -875,6 +875,106 @@ public Map enhance(Map claims, OAuth2Authenticat } } + @Nested + @DisplayName("when token enhancer attempts to override protected claims") + class WhenTokenEnhancerAttemptsToOverrideProtectedClaims { + + @Test + @DisplayName("enhancer cannot override client_id, authorities, scope, or iss") + void enhancerCannotOverrideProtectedClaims() { + UaaTokenEnhancer maliciousEnhancer = new UaaTokenEnhancer() { + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + Map result = new HashMap<>(); + result.put("client_id", "some-other-client"); + result.put("cid", "some-other-client"); + result.put("authorities", List.of("uaa.admin")); + result.put("scope", List.of("uaa.admin")); + result.put("iss", "https://attacker.example.com/oauth/token"); + result.put("grant_type", "authorization_code"); + return result; + } + }; + + tokenServices.setUaaTokenEnhancers(List.of(maliciousEnhancer)); + + try { + AuthorizationRequest authorizationRequest = constructAuthorizationRequest( + clientId, GRANT_TYPE_CLIENT_CREDENTIALS, CLIENT_SCOPES.split(",")); + OAuth2Authentication authentication = new OAuth2Authentication( + authorizationRequest.createOAuth2Request(), null); + + OAuth2AccessToken accessToken = tokenServices.createAccessToken(authentication); + + Jwt jwt = JwtHelper.decode(accessToken.getValue()); + Map tokenClaims = JsonUtils.readValue(jwt.getClaims(), + new TypeReference>() {}); + + assertThat(tokenClaims) + .as("client_id must remain the authenticated client, not the enhancer-supplied value") + .containsEntry("client_id", clientId) + .containsEntry("cid", clientId); + assertThat(tokenClaims.get("iss")) + .as("iss must remain UAA's own token endpoint, not the enhancer-supplied value") + .isNotEqualTo("https://attacker.example.com/oauth/token"); + assertThat(tokenClaims.get("grant_type")) + .as("grant_type must remain the actual grant used, not the enhancer-supplied value") + .isEqualTo(GRANT_TYPE_CLIENT_CREDENTIALS); + assertThat(tokenClaims.get("authorities")) + .as("authorities must remain the client's actual granted scopes") + .asInstanceOf(InstanceOfAssertFactories.list(Object.class)) + .doesNotContain("uaa.admin"); + assertThat(tokenClaims.get("scope")) + .as("scope must remain the actually granted scopes, not the enhancer-supplied value") + .asInstanceOf(InstanceOfAssertFactories.list(Object.class)) + .doesNotContain("uaa.admin"); + } finally { + tokenServices.setUaaTokenEnhancers(new ArrayList<>()); + } + } + + @Test + @DisplayName("enhancer-supplied custom (non-reserved) claims still apply") + void enhancerCanStillAddCustomClaims() { + UaaTokenEnhancer testEnhancer = new UaaTokenEnhancer() { + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + return Map.of("cf.app", "app-guid", "cnf", Map.of("x5t#S256", "thumbprint")); + } + }; + + tokenServices.setUaaTokenEnhancers(List.of(testEnhancer)); + + try { + AuthorizationRequest authorizationRequest = constructAuthorizationRequest( + clientId, GRANT_TYPE_CLIENT_CREDENTIALS, CLIENT_SCOPES.split(",")); + OAuth2Authentication authentication = new OAuth2Authentication( + authorizationRequest.createOAuth2Request(), null); + + OAuth2AccessToken accessToken = tokenServices.createAccessToken(authentication); + + Jwt jwt = JwtHelper.decode(accessToken.getValue()); + Map tokenClaims = JsonUtils.readValue(jwt.getClaims(), + new TypeReference>() {}); + + assertThat(tokenClaims).containsEntry("cf.app", "app-guid"); + assertThat(tokenClaims).containsKey("cnf"); + } finally { + tokenServices.setUaaTokenEnhancers(new ArrayList<>()); + } + } + } + private OAuth2Authentication constructUserAuthenticationFromAuthzRequest(AuthorizationRequest authzRequest, String userId, String userOrigin, From 838bfc3cf12448ddfa74c5d37e42e6b37bbea5ad Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 15:03:36 +0200 Subject: [PATCH 066/130] fix(review): run mtls filters for zone-path mTLS requests too RawPeerCertificateCaptureFilter and the ClientCertificateMapper filter were registered via addUrlPatterns("/oauth/mtls/*"), a container-level URL-pattern match evaluated against the request's original, pre-rewrite URI. UAA also advertises /z/{subdomain}/oauth/mtls/token via OIDC discovery (OpenIdConnectEndpoints#getServerContextPath), and ZonePathContextRewritingFilter (which runs first) only rewraps the request for filters *after* it in the same chain -- it cannot retroactively add an already-excluded filter to the chain. So neither filter ever ran for a zone-path-prefixed mTLS request, and authentication saw no XFCC-derived certificate at all. Both filters are now registered on the default (all-requests) pattern, like every other filter in SpringServletXmlFiltersConfiguration, and instead guard internally on the request's *effective* servlet path (i.e. after ZonePathContextRewritingFilter has stripped the /z/{subdomain} prefix), via the new RawPeerCertificateCaptureFilter.isMtlsTokenPath(...). Since ClientCertificateMapper is a third-party, package-private class we can't add that check to directly, it's wrapped in a new MtlsPathGuardedFilter that only delegates to it for a matching effective path. Added a zone-path regression test (both filters still run and populate the expected attributes for a simulated /z/myzone/oauth/mtls/token request) and tests proving both filters remain no-ops for unrelated paths (preserving the original scope). Addresses PR review comment on SpringServletXmlFiltersConfiguration.java:262. --- .../SpringServletXmlFiltersConfiguration.java | 19 ++++- .../uaa/oauth/tls/MtlsPathGuardedFilter.java | 45 +++++++++++ .../tls/RawPeerCertificateCaptureFilter.java | 28 ++++++- .../ClientCertificateMapperFilterTest.java | 27 ++++++- ...tificateCaptureFilterRegistrationTest.java | 81 ++++++++++++++++++- .../RawPeerCertificateCaptureFilterTest.java | 2 + 6 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index 0960bea07dc..049e847551a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -6,6 +6,7 @@ import org.cloudfoundry.identity.uaa.metrics.UaaMetricsFilter; import org.cloudfoundry.identity.uaa.metrics.UaaMetricsManagedBean; import org.cloudfoundry.identity.uaa.oauth.DisableIdTokenResponseTypeFilter; +import org.cloudfoundry.identity.uaa.oauth.tls.MtlsPathGuardedFilter; import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning; import org.cloudfoundry.identity.uaa.ratelimiting.RateLimitingFilter; @@ -237,7 +238,13 @@ public FilterRegistrationBean httpHeaderSecurityFilter public FilterRegistrationBean rawPeerCertificateCaptureFilter() { FilterRegistrationBean bean = new FilterRegistrationBean<>(new RawPeerCertificateCaptureFilter()); - bean.addUrlPatterns("/oauth/mtls/*"); + // No addUrlPatterns(...): registered on the default (all-requests) pattern, like every other + // filter in this class. RawPeerCertificateCaptureFilter internally no-ops unless the request's + // effective (post-ZonePathContextRewritingFilter) servlet path is /oauth/mtls/* -- see + // RawPeerCertificateCaptureFilter.isMtlsTokenPath(...) -- so it still runs for zone-path- + // prefixed mTLS requests (e.g. /z/{subdomain}/oauth/mtls/token). A container URL-pattern + // registration for a literal "/oauth/mtls/*" is matched against the request's original, + // pre-rewrite URI, so it would never include this filter in the chain for such a request. // Must run before clientCertificateMapperFilter() (order -200) so it captures the genuine // TLS-handshake peer certificate before that filter overwrites the same standard // jakarta.servlet.request.X509Certificate attribute with the XFCC-header-derived certificate. @@ -256,10 +263,14 @@ public FilterRegistrationBean clientCertificateMapperFil Class mapperClass = Class.forName("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); java.lang.reflect.Constructor ctor = mapperClass.getDeclaredConstructor(); ctor.setAccessible(true); - @SuppressWarnings("unchecked") + jakarta.servlet.Filter delegate = (jakarta.servlet.Filter) ctor.newInstance(); FilterRegistrationBean bean = - new FilterRegistrationBean<>((jakarta.servlet.Filter) ctor.newInstance()); - bean.addUrlPatterns("/oauth/mtls/*"); + new FilterRegistrationBean<>(new MtlsPathGuardedFilter(delegate)); + // No addUrlPatterns(...): see rawPeerCertificateCaptureFilter() above. + // MtlsPathGuardedFilter internally scopes the delegate ClientCertificateMapper to the + // effective (post-ZonePathContextRewritingFilter) /oauth/mtls/* servlet path, so a literal + // "/oauth/mtls/*" URL-pattern registration -- which would not match zone-path-prefixed + // requests -- is not needed here either. // Spring Boot registers its Security filter in the servlet container at order -100 // (org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties // .DEFAULT_FILTER_ORDER). This filter must run strictly before that so the diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java new file mode 100644 index 00000000000..e56d5decdf3 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java @@ -0,0 +1,45 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; + +/** + * Wraps a delegate {@link Filter} so it only runs for requests whose effective (post + * {@code ZonePathContextRewritingFilter}) servlet path is {@code /oauth/mtls/*} -- see + * {@link RawPeerCertificateCaptureFilter#isMtlsTokenPath(HttpServletRequest)}. + * + *

Used in {@code SpringServletXmlFiltersConfiguration} to scope the third-party, package-private + * {@code ClientCertificateMapper} filter to the mTLS token endpoint without relying on a container + * URL-pattern registration. A URL-pattern registration is matched against the request's original, + * pre-rewrite URI, so it would not include the filter in the chain for a zone-path-prefixed mTLS + * request (e.g. {@code /z/{subdomain}/oauth/mtls/token}), even though downstream code (including this + * guard) sees the same effective path as a direct request. + */ +public class MtlsPathGuardedFilter implements Filter { + + private final Filter delegate; + + public MtlsPathGuardedFilter(Filter delegate) { + this.delegate = delegate; + } + + Filter getDelegate() { + return delegate; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (RawPeerCertificateCaptureFilter.isMtlsTokenPath((HttpServletRequest) request)) { + delegate.doFilter(request, response, chain); + } else { + chain.doFilter(request, response); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java index c4a1ca7f101..25a77bb8f74 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java @@ -5,6 +5,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; @@ -32,6 +33,15 @@ * still comes from the standard, XFCC-derived {@code jakarta.servlet.request.X509Certificate} * attribute -- this filter's captured value is used only for the trust check, never as the * authenticated client certificate itself. + * + *

Registered in {@code SpringServletXmlFiltersConfiguration} on the default (all-requests) URL + * pattern, not a literal {@code /oauth/mtls/*} one: {@link #isMtlsTokenPath(HttpServletRequest)} guards + * this filter's work internally instead, checking the request's effective servlet path (i.e. + * after {@code ZonePathContextRewritingFilter}, which runs first, has rewritten it). A container + * URL-pattern registration is matched against the request's original, pre-rewrite URI, so it would + * never include this filter in the chain for a zone-path-prefixed mTLS request (e.g. + * {@code /z/{subdomain}/oauth/mtls/token}), even though downstream code sees the same effective path as + * a direct request. */ public class RawPeerCertificateCaptureFilter implements Filter { @@ -39,11 +49,27 @@ public class RawPeerCertificateCaptureFilter implements Filter { "org.cloudfoundry.identity.uaa.oauth.tls.rawPeerCertificate"; private static final String X509_CERTIFICATE_ATTRIBUTE = "jakarta.servlet.request.X509Certificate"; + private static final String MTLS_SERVLET_PATH = "/oauth/mtls"; + private static final String MTLS_SERVLET_PATH_PREFIX = MTLS_SERVLET_PATH + "/"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - request.setAttribute(RAW_PEER_CERTIFICATE_ATTRIBUTE, request.getAttribute(X509_CERTIFICATE_ATTRIBUTE)); + if (isMtlsTokenPath((HttpServletRequest) request)) { + request.setAttribute(RAW_PEER_CERTIFICATE_ATTRIBUTE, request.getAttribute(X509_CERTIFICATE_ATTRIBUTE)); + } chain.doFilter(request, response); } + + /** + * Matches the request's effective servlet path -- i.e. after {@code ZonePathContextRewritingFilter} + * (which runs first in the filter chain) has stripped any {@code /z/{subdomain}} prefix -- against + * {@code /oauth/mtls/*}. Also used by {@link MtlsPathGuardedFilter} to scope the (externally + * supplied, package-private) {@code ClientCertificateMapper} filter to the same effective path. + */ + static boolean isMtlsTokenPath(HttpServletRequest request) { + String servletPath = request.getServletPath(); + return servletPath != null + && (servletPath.equals(MTLS_SERVLET_PATH) || servletPath.startsWith(MTLS_SERVLET_PATH_PREFIX)); + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java index 31c969dceca..8ab88f30a36 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java @@ -44,9 +44,31 @@ void setUp() { void clientCertificateMapperFilter_registersClientCertificateMapperForMtlsEndpoint() { SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); FilterRegistrationBean bean = config.clientCertificateMapperFilter(); - assertThat(bean.getFilter().getClass().getName()) + assertThat(bean.getFilter()).isInstanceOf(MtlsPathGuardedFilter.class); + assertThat(((MtlsPathGuardedFilter) bean.getFilter()).getDelegate().getClass().getName()) .isEqualTo("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); - assertThat(bean.getUrlPatterns()).contains("/oauth/mtls/*"); + // No addUrlPatterns(...): registered on the default (all-requests) pattern, guarded internally + // by MtlsPathGuardedFilter -- see RawPeerCertificateCaptureFilterRegistrationTest for why a + // container URL-pattern registration cannot correctly scope this filter to zone-path requests. + assertThat(bean.getUrlPatterns()).isEmpty(); + } + + @Test + void doesNotInvokeTheDelegateForUnrelatedPaths() throws Exception { + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/login"); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(generateSelfSignedCert().getEncoded())); + MockHttpServletResponse response = new MockHttpServletResponse(); + + mapperBean.getFilter().doFilter(request, response, (req, res) -> { }); + + assertThat(request.getAttribute("jakarta.servlet.request.X509Certificate")) + .as("ClientCertificateMapper must not run for a path other than /oauth/mtls/*") + .isNull(); } @Test @@ -143,6 +165,7 @@ private static void runContainerFilterChain( private static MockHttpServletRequest requestWithClientCertHeader() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); request.addHeader("X-Forwarded-Client-Cert", Base64.getEncoder().encodeToString(generateSelfSignedCert().getEncoded())); return request; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java index 09e16e8d37e..4d88080c46c 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java @@ -46,10 +46,54 @@ void rawPeerCertificateCaptureFilterRunsBeforeClientCertificateMapper() { FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); assertThat(captureBean.getFilter()).isInstanceOf(RawPeerCertificateCaptureFilter.class); - assertThat(captureBean.getUrlPatterns()).contains("/oauth/mtls/*"); + // No addUrlPatterns(...): registered on the default (all-requests) pattern -- see + // isMtlsTokenPathAcceptsTheEffectivePostZoneRewriteServletPath() below for why a container + // URL-pattern registration cannot correctly scope this filter to zone-path requests. + assertThat(captureBean.getUrlPatterns()).isEmpty(); assertThat(captureBean.getOrder()).isLessThan(mapperBean.getOrder()); } + @Test + void isMtlsTokenPathAcceptsTheEffectivePostZoneRewriteServletPath() { + // ZonePathContextRewritingFilter (which runs first in the filter chain) wraps the request so + // that request.getServletPath() reflects the effective path *after* the /z/{subdomain} prefix + // is stripped -- e.g. a request whose original URI is /z/myzone/oauth/mtls/token presents + // getServletPath() == "/oauth/mtls/token" to filters running after it, same as a direct + // (non-zone-path) request. A container URL-pattern registration for "/oauth/mtls/*" is matched + // against the *original* request URI before any filter runs, so it would never include this + // filter in the chain for a zone-path request -- checking the effective servlet path instead, + // from inside the filter, works uniformly for both cases. + MockHttpServletRequest direct = new MockHttpServletRequest(); + direct.setServletPath("/oauth/mtls/token"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(direct)).isTrue(); + + MockHttpServletRequest zonePathRewritten = new MockHttpServletRequest(); + zonePathRewritten.setContextPath("/uaa/z/myzone"); + zonePathRewritten.setRequestURI("/uaa/z/myzone/oauth/mtls/token"); + zonePathRewritten.setServletPath("/oauth/mtls/token"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(zonePathRewritten)) + .as("must match the effective (post zone-path-rewrite) servlet path") + .isTrue(); + + MockHttpServletRequest unrelated = new MockHttpServletRequest(); + unrelated.setServletPath("/login"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(unrelated)).isFalse(); + } + + @Test + void doesNotCaptureAnAttributeForUnrelatedPaths() throws Exception { + RawPeerCertificateCaptureFilter filter = new RawPeerCertificateCaptureFilter(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/login"); + request.setAttribute("jakarta.servlet.request.X509Certificate", + new X509Certificate[]{generateSelfSignedCert("CN=some-peer")}); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (req, res) -> { }); + + assertThat(request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)).isNull(); + } + @Test void capturedAttributeSurvivesClientCertificateMapperOverwritingTheStandardAttribute() throws Exception { // Behavioural proof, not just an order-integer comparison: simulates a real TLS handshake @@ -68,6 +112,7 @@ void capturedAttributeSurvivesClientCertificateMapperOverwritingTheStandardAttri X509Certificate xfccDerivedCert = generateSelfSignedCert("CN=app-instance"); MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); // Simulates what Tomcat's TLS handshake would have already set before any filter runs. request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{genuinePeerCert}); request.addHeader("X-Forwarded-Client-Cert", @@ -89,6 +134,40 @@ void capturedAttributeSurvivesClientCertificateMapperOverwritingTheStandardAttri .containsExactly(xfccDerivedCert); } + @Test + void capturesTheGenuinePeerCertificateForAZonePathMtlsRequest() throws Exception { + // Regression test for PR review comment on SpringServletXmlFiltersConfiguration.java:262: + // simulates a request that arrived as /z/myzone/oauth/mtls/token and was already rewritten by + // ZonePathContextRewritingFilter (which runs first) before reaching this filter -- same + // servlet path as a direct request, but a zone-prefixed context path/request URI. Both + // filters must still run: a container URL-pattern registration for "/oauth/mtls/*" would not + // have included them in the chain at all for the original /z/myzone/... request URI. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + X509Certificate genuinePeerCert = generateSelfSignedCert("CN=gorouter"); + X509Certificate xfccDerivedCert = generateSelfSignedCert("CN=app-instance"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContextPath("/uaa/z/myzone"); + request.setRequestURI("/uaa/z/myzone/oauth/mtls/token"); + request.setServletPath("/oauth/mtls/token"); + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{genuinePeerCert}); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(xfccDerivedCert.getEncoded())); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(captureBean, mapperBean), request, response); + + assertThat((X509Certificate[]) request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)) + .as("must still capture the genuine peer cert for a zone-path mtls request") + .containsExactly(genuinePeerCert); + assertThat((X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate")) + .as("ClientCertificateMapper must still run for a zone-path mtls request") + .containsExactly(xfccDerivedCert); + } + /** * Simulates servlet-container filter dispatch: registered filters run in ascending * {@link FilterRegistrationBean#getOrder()} value, each delegating to the next via a standard diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java index 74a3cf39c8e..bddc596296d 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java @@ -17,6 +17,7 @@ class RawPeerCertificateCaptureFilterTest { void copiesGenuinePeerCertificateIntoDedicatedAttributeBeforeChainContinues() throws Exception { X509Certificate[] genuinePeerCert = new X509Certificate[]{mock(X509Certificate.class)}; MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); request.setAttribute("jakarta.servlet.request.X509Certificate", genuinePeerCert); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); @@ -31,6 +32,7 @@ void copiesGenuinePeerCertificateIntoDedicatedAttributeBeforeChainContinues() th @Test void setsNullAttributeWhenNoPeerCertificatePresent() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); From a3f1173479e910badaba88740e76fe784422cacc Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 15:06:56 +0200 Subject: [PATCH 067/130] docs(review): document tls_client_auth and topology-dependent trusted-proxy-ca docs/UAA-Client-Authentication.md still labeled tls_client_auth as a 'Planned Feature' with no mention of any of its properties. Documents token-endpoint-auth-method, tls-client-auth-ca, tls-client-auth-trusted-proxy-ca, tls-client-auth-claim-mappings, tls-client-auth-sub-template, and tls-client-auth-aud-templates, plus a full example. Also clarifies that tls-client-auth-trusted-proxy-ca is required regardless of deployment topology, but which CA to configure differs: the Gorouter's own backend mTLS CA when fronted by a Gorouter (forwarded_client_cert: sanitize_set), or the same CA as tls-client-auth-ca when Application Security Group configuration permits direct app-to-UAA connections (e.g. resolved via BOSH DNS) that bypass the Gorouter -- in that case the client itself is the immediate TLS peer, not a proxy. Addresses PR review comment on TlsClientAuthentication.java:118 (also flagged at line 175). --- docs/UAA-Client-Authentication.md | 97 ++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 38bf74e9e57..a594d36a5b4 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -6,7 +6,7 @@ In [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1) the password or better the process of checking its possession means the authentication process. The secrets can be passed to a server in different ways. It can happen through the HTTP header and/or the body. In the case that an Authorization header is used, -the encoding of the secret needs to be done according to the RFC 6749. UAA fixed this behavior with https://github.com/cloudfoundry/uaa/issues/778. +the encoding of the secret needs to be done according to the RFC 6749. UAA fixed this behavior with . The OIDC standard defines additional authentication mechanisms, see [section 9](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication). The usage of secrets via client_secret_basic and client_secret_post is straightforward to set up and to use, however, if system-to-system communication is in use, this can be a security problem because it will be hard to change secrets in running systems. The use of many secrets is not @@ -17,10 +17,12 @@ standards define token-based authentication mechanisms for OAuth2 clients. They * tls_client_auth [RFC 8705](https://www.rfc-editor.org/rfc/rfc8705) ## New methods + The new methods are based on asymmetric trust relation, so that the keys are divided into a private and a public one. The private key should never leave the original system, but only the public key should be exchanged. ### private_key_jwt (Partly finished) + The standard private_key_jwt is similar to the existing JWT bearer flow, but JWT bearer is for user principle propagation, whereas private_key_jwt is used for client authentication only. The used technics are similar and therefore the trust model is similar. Both usages are specified in the same [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523.txt). The JWT bearer trust is based on parameters tokenKey and/or tokenKeyUrl parameter, part of the @@ -29,7 +31,7 @@ of public keys, and this set can contain many keys because each key has its own a dynamic token key URI. OIDC has defined the parameter jwks_uri for this already. The structure of the keys is defined with [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517). UAA provides its own jwks_uri with endpoint /token_keys. The content of this endpoint is [JWKS](https://datatracker.ietf.org/doc/html/rfc7517#section-5). -The content of the JWT (parameter client_assertion) can be different. The standards define the difference. The [OIDC core standard](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication) +The content of the JWT (parameter client_assertion) can be different. The standards define the difference. The [OIDC core standard](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication) simplifies the structure so that issuer and subject are the client_id of the authenticated OAuth2 client. The key rotation is supported with jwks_uri, which retrieves the JWK. You can only have one JWKS_URI by the client. For the [RFC 7523 from OAuth2 standard](https://www.rfc-editor.org/info/rfc7523) the structure is more complex, but with seperated issuer and subject there can be more than one entry of federated credential. @@ -45,11 +47,87 @@ The new parameter for federated Credentials in UAA clients is (Work in progress * jwt_creds -### tls_client_auth (Planned Feature) -Not yet defined a release date. +### tls_client_auth ([RFC 8705](https://www.rfc-editor.org/rfc/rfc8705)) + +Mutual-TLS client authentication: a client presents an X.509 certificate at the TLS layer +instead of a `client_secret` or a signed JWT assertion. UAA validates the certificate against +a per-client trusted CA and, optionally, derives JWT claims from the certificate's subject +fields (e.g. mapping a Cloud Foundry app instance identity certificate to `app_guid`/ +`space_guid`/`org_guid` claims). + +The client is authenticated on a dedicated endpoint, `/oauth/mtls/token`, rather than the +regular `/oauth/token`. This lets the endpoint be given a servlet-container TLS configuration +that requests a client certificate ("mutual TLS"), without changing behavior for every other +client on `/oauth/token`. + +#### Deployment topology + +UAA itself only ever sees the certificate presented by its *immediate* TLS peer -- whatever +that happens to be depends on how UAA is deployed: + +* **Behind a Gorouter** with `forwarded_client_cert: sanitize_set` (the typical Cloud + Foundry deployment): the Gorouter terminates the client's TLS connection, validates it, and + forwards the client's certificate to UAA in the `X-Forwarded-Client-Cert` header over its own + backend mTLS connection. Here, UAA's immediate TLS peer is the Gorouter itself, not the + original client. +* **Direct connections**, e.g. an app connecting straight to UAA over BOSH DNS + (`uaa.service.cf.internal`) where Application Security Groups permit it, bypassing the + Gorouter entirely: UAA's immediate TLS peer *is* the original client. + +To prevent a caller that can reach UAA directly from simply replaying a certificate it +harvested from an `X-Forwarded-Client-Cert` header (without possessing that certificate's +private key), UAA only trusts the `X-Forwarded-Client-Cert` header when the certificate its +immediate TLS peer *actually presented during the TLS handshake* validates against +`tls-client-auth-trusted-proxy-ca` (see below). This property must therefore be configured +for the client to authenticate at all, and **which CA to configure depends on the topology**: + +* Gorouter-fronted: set it to the CA that signs the Gorouter's own backend mTLS certificate + (e.g. `service_cf_internal_ca` in a typical `cf-deployment`-based CF). +* Direct connections: set it to the same CA as `tls-client-auth-ca` (the client's own leaf + certificate CA), since the client itself is the immediate TLS peer. + +#### Configuration + +Per-client properties (set via the client-admin API, `oauth.clients` bootstrap, or the client +admin UI, alongside the client's other properties such as `authorized-grant-types`): + +| Property | Required | Description | +|----------|----------|--------------| +| `token-endpoint-auth-method: tls_client_auth` | yes | Selects mTLS client authentication for this client. | +| `tls-client-auth-ca` | yes | PEM-encoded CA certificate. The client's own presented (leaf) certificate must chain to this CA. | +| `tls-client-auth-trusted-proxy-ca` | yes | PEM-encoded CA certificate that the entity presenting a certificate at the TLS layer immediately in front of UAA (Gorouter or the client itself -- see "Deployment topology" above) must chain to. Without this, `/oauth/mtls/token` rejects every request for this client. | +| `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`) to JWT claim names, optionally extracting a capture group via `pattern`. | +| `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. | +| `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. | + +Example (Gorouter-fronted; a Cloud Foundry app instance identity certificate mapped to +`cf_instance_guid`/`app_guid`/`space_guid`/`org_guid` claims): + +```yaml +token-endpoint-auth-method: tls_client_auth +tls-client-auth-ca: +tls-client-auth-trusted-proxy-ca: +tls-client-auth-claim-mappings: + - field: subject_cn + claim: cf_instance_guid + - field: subject_ou + pattern: "app:(.+)" + claim: app_guid + - field: subject_ou + pattern: "space:(.+)" + claim: space_guid + - field: subject_ou + pattern: "organization:(.+)" + claim: org_guid +``` + +For the direct-connection topology described above, `tls-client-auth-trusted-proxy-ca` would +instead be set to the same value as `tls-client-auth-ca`. ## Configs + Here is a brief example of the `clients` section: + ```yaml oauth: clients: @@ -78,9 +156,11 @@ oauth: ] } ``` + The example configuration above with jwks_uri enables continuous trust to a running UAA. Here is a brief example of the oauth providers section, where UAA is acting as a client. + ```yaml login: oauth: @@ -99,12 +179,13 @@ login: The option jwtClientAuthentication creates during the proxy flow a client assertion which is based on OIDC private_key_jwt. ### Developer implementation + As a developer, you should use the [UAA documentation](https://docs.cloudfoundry.org/api/uaa/version/77.18.0/index.html#token). There is a description -about the new parameters client_assertion and client_assertion_type. In addition, you can check in the retrieved access_token tokens for the existence -of claim client_auth_method with value private_key_jwt, (client_auth_method=private_key). This claim should guarantee the used method of client -authentication. Tokens without this claim are authenticated with secrets. There might be use-cases where a stronger authentication mechanism is +about the new parameters client_assertion and client_assertion_type. In addition, you can check in the retrieved access_token tokens for the existence +of claim client_auth_method with value private_key_jwt, (client_auth_method=private_key). This claim should guarantee the used method of client +authentication. Tokens without this claim are authenticated with secrets. There might be use-cases where a stronger authentication mechanism is required. ### Production use -The support of private_key_jwt (according to OIDC) for a production system is given with the end of Q4/2024. +The support of private_key_jwt (according to OIDC) for a production system is given with the end of Q4/2024. From 78335c485981e265f286adbec1c9ddcb290f4fbc Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 17:25:37 +0200 Subject: [PATCH 068/130] fix(review): scope tls-client-auth-trusted-proxy-ca to the proxy path only getCertificateChainFromRequest required tls-client-auth-trusted-proxy-ca unconditionally, so a client connecting to UAA directly (no Gorouter, no XFCC in the path) could never authenticate without configuring a CA whose entire purpose (verifying an intermediary) didn't apply to its topology. Conversely, once trusted-proxy-ca WAS configured, a direct connection whose own certificate happened to validate against that same CA was silently accepted too, blurring the trust boundary. Now branches strictly on whether tls-client-auth-trusted-proxy-ca is configured: unconfigured clients always use the genuine TLS-handshake peer certificate and never consult the X-Forwarded-Client-Cert header at all; configured clients require the header to actually be present (not just a coincidentally-matching raw peer cert) in addition to the existing isCertificateFromTrustedProxy check. The two modes are mutually exclusive per client -- an operator needing both direct and proxy-forwarded access for the same workload registers two separate UAA clients. Addresses PR review comment on TlsClientAuthentication.java:118 (also flagged at line 175). --- .../oauth/tls/TlsClientAuthentication.java | 56 ++++++++--- .../tls/TlsClientAuthenticationTest.java | 98 ++++++++++++++++++- 2 files changed, 137 insertions(+), 17 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index c2b9adb90c8..5955704891a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -36,6 +36,8 @@ public class TlsClientAuthentication { private static final Logger logger = LoggerFactory.getLogger(TlsClientAuthentication.class); + private static final String XFCC_HEADER = "X-Forwarded-Client-Cert"; + /** * Returns {@code true} when any certificate is present on the current request under the * standard {@code jakarta.servlet.request.X509Certificate} attribute -- whether that attribute @@ -69,27 +71,57 @@ public X509Certificate getCertificateFromRequest(TlsClientAuthConfiguration clie } /** - * Returns the full X.509 certificate chain from the current request's - * {@code jakarta.servlet.request.X509Certificate} attribute (populated by the - * {@code ClientCertificateMapper} filter), but only when - * {@link #isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} is {@code true} for - * {@code clientConfig} -- i.e. only when the genuine TLS-handshake peer presented a certificate - * signed by this specific client's {@code tls-client-auth-trusted-proxy-ca}. This prevents a direct - * caller (bypassing the Gorouter) from having a self-supplied {@code X-Forwarded-Client-Cert} - * header trusted. Index 0 is the end-entity (leaf) certificate. + * Returns the full X.509 certificate chain the current request should be authenticated with, + * per {@code clientConfig}'s configured trust model: + * + *

    + *
  • If {@code clientConfig} has no {@code tls-client-auth-trusted-proxy-ca} configured, + * this client is direct-connection-only: always returns the genuine TLS-handshake peer + * certificate chain (captured by {@link RawPeerCertificateCaptureFilter}), ignoring any + * {@code X-Forwarded-Client-Cert} header entirely.
  • + *
  • If {@code tls-client-auth-trusted-proxy-ca} is configured, this client is + * proxy-only: requires an {@code X-Forwarded-Client-Cert} header to be present and + * {@link #isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} to be {@code true} + * (the genuine TLS peer -- e.g. the Gorouter -- must validate against the configured + * proxy CA) before returning the header-derived certificate chain from the standard + * {@code jakarta.servlet.request.X509Certificate} attribute.
  • + *
+ * + *

The two modes are mutually exclusive per client: a client cannot accept both a direct + * connection and a proxy-forwarded one. An operator needing both registers two separate UAA + * clients. Index 0 of the returned chain is the end-entity (leaf) certificate. * - * @return the client certificate chain, or {@code null} if none is present or not from a trusted proxy + * @return the client certificate chain, or {@code null} if none is present or the request + * doesn't match this client's configured trust model */ public X509Certificate[] getCertificateChainFromRequest(TlsClientAuthConfiguration clientConfig) { - if (!isCertificateFromTrustedProxy(clientConfig)) { - return null; - } ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) { return null; } HttpServletRequest request = attrs.getRequest(); + boolean trustedProxyConfigured = clientConfig != null + && clientConfig.getTrustedProxyCaPem() != null + && !clientConfig.getTrustedProxyCaPem().isBlank(); + + if (!trustedProxyConfigured) { + // Direct-connection-only client: always use the genuine TLS-handshake peer + // certificate, regardless of any X-Forwarded-Client-Cert header -- this client's + // trust model has no proxy in it at all. + X509Certificate[] rawPeerCerts = (X509Certificate[]) + request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + return (rawPeerCerts != null && rawPeerCerts.length > 0) ? rawPeerCerts : null; + } + + // Proxy-only client: require the XFCC header to actually be present -- a direct + // connection (no header) is always rejected, even if its own certificate would validate + // against tls-client-auth-trusted-proxy-ca -- plus the genuine peer (the proxy) must + // validate against it. + String xfccHeader = request.getHeader(XFCC_HEADER); + if (xfccHeader == null || xfccHeader.isBlank() || !isCertificateFromTrustedProxy(clientConfig)) { + return null; + } X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); return (certs != null && certs.length > 0) ? certs : null; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 164b6734d18..ba0ffb0107b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -251,13 +251,12 @@ void hasCertificateFromRequestFalseWhenNoCertPresent() { } @Test - void getCertificateChainFromRequestReturnsNullWhenNotFromClientsTrustedProxy() { + void getCertificateChainFromRequestReturnsNullWhenNoTrustedProxyCaConfiguredAndNoPeerCertCaptured() { TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); - // no trusted-proxy CA configured for this client -> never trusted + // no trusted-proxy CA configured for this client -> direct-connection-only, but no raw + // peer certificate was ever captured (e.g. uaa.mtls-enabled=false) MockHttpServletRequest request = new MockHttpServletRequest(); - request.setAttribute("jakarta.servlet.request.X509Certificate", - new X509Certificate[]{mock(X509Certificate.class)}); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); try { assertThat(service.getCertificateChainFromRequest(config)).isNull(); @@ -267,6 +266,62 @@ void getCertificateChainFromRequestReturnsNullWhenNotFromClientsTrustedProxy() { } } + @Test + void getCertificateChainFromRequestReturnsRawPeerCertForDirectConnectionWhenNoTrustedProxyCaConfigured() throws Exception { + KeyPair clientKp = generateKeyPair(); + X500Name clientCaName = new X500Name("CN=Instance Identity CA"); + X509Certificate clientCaCert = signCert(clientCaName, clientCaName, clientKp.getPublic(), clientKp.getPrivate(), true, BigInteger.ONE); + KeyPair leafKp = generateKeyPair(); + X509Certificate directPeerCert = signCert( + new X500Name("CN=app-instance"), clientCaName, leafKp.getPublic(), clientKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(clientCaCert), null); + // no trusted-proxy CA configured for this client -> direct-connection-only + + MockHttpServletRequest request = new MockHttpServletRequest(); + // No X-Forwarded-Client-Cert header, no ClientCertificateMapper rewriting: the standard + // attribute would ordinarily equal the raw peer cert here, but this client never reads the + // standard attribute at all. + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{directPeerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).containsExactly(directPeerCert); + assertThat(service.getCertificateFromRequest(config)).isEqualTo(directPeerCert); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestIgnoresXfccWhenNoTrustedProxyCaConfigured() throws Exception { + KeyPair clientKp = generateKeyPair(); + X500Name clientCaName = new X500Name("CN=Instance Identity CA"); + X509Certificate clientCaCert = signCert(clientCaName, clientCaName, clientKp.getPublic(), clientKp.getPrivate(), true, BigInteger.ONE); + KeyPair leafKp = generateKeyPair(); + X509Certificate directPeerCert = signCert( + new X500Name("CN=app-instance"), clientCaName, leafKp.getPublic(), clientKp.getPrivate(), false, BigInteger.TWO); + X509Certificate xfccDerivedCert = mock(X509Certificate.class); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(clientCaCert), null); + // no trusted-proxy CA configured for this client -> direct-connection-only + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{directPeerCert}); + // A different certificate somehow ended up in the standard attribute, and an XFCC header + // is present -- e.g. noise from an unrelated proxy somewhere in the network path. Neither + // should matter for a client with no trusted-proxy CA configured. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{xfccDerivedCert}); + request.addHeader("X-Forwarded-Client-Cert", "irrelevant-base64-value"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).containsExactly(directPeerCert); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + @Test void getCertificateChainFromRequestReturnsChainWhenFromClientsTrustedProxy() throws Exception { KeyPair rootKp = generateKeyPair(); @@ -284,8 +339,10 @@ void getCertificateChainFromRequestReturnsChainWhenFromClientsTrustedProxy() thr // The genuine TLS peer cert (captured separately) validates against this client's trusted-proxy CA... request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, new X509Certificate[]{peerCert}); - // ...so the XFCC-header-derived certificate (a completely different value) is trusted too. + // ...so the XFCC-header-derived certificate (a completely different value) is trusted too, + // given the header that ClientCertificateMapper would have parsed it from is present. request.setAttribute("jakarta.servlet.request.X509Certificate", xfccDerivedChain); + request.addHeader("X-Forwarded-Client-Cert", "irrelevant-base64-value"); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); try { assertThat(service.getCertificateChainFromRequest(config)).isEqualTo(xfccDerivedChain); @@ -295,6 +352,37 @@ void getCertificateChainFromRequestReturnsChainWhenFromClientsTrustedProxy() thr } } + @Test + void getCertificateChainFromRequestReturnsNullWhenTrustedProxyCaConfiguredButXfccHeaderAbsent() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + // A direct peer whose own certificate happens to validate against the SAME CA configured + // as tls-client-auth-trusted-proxy-ca -- e.g. an operator who set them equal, or a + // coincidence. This must NOT be enough on its own: the client is proxy-only. + KeyPair peerKp = generateKeyPair(); + X509Certificate directPeerCert = signCert( + new X500Name("CN=direct-caller"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{directPeerCert}); + // No X-Forwarded-Client-Cert header at all -- ClientCertificateMapper never touched the + // standard attribute, so (if read) it would equal the raw peer cert above. But this client + // is proxy-only and must reject a request with no XFCC header, regardless. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{directPeerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + private static KeyPair generateKeyPair() throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); kpg.initialize(2048); From aa072298598a8da84e822195b43250a4476a5fa8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 17:39:09 +0200 Subject: [PATCH 069/130] test(review): cover blank XFCC header and hasCertificateFromRequest interaction Adds a test proving a proxy-configured client rejects a request when the X-Forwarded-Client-Cert header is present but blank (design doc edge case, previously unexercised). Also adds a regression test proving hasCertificateFromRequest() (which only inspects the standard jakarta.servlet.request.X509Certificate attribute) correctly returns true for a genuine direct connection with no XFCC header, since ClientCertificateMapper never clears that attribute -- it only ever replaces it with a non-empty XFCC-derived value or leaves it untouched. Confirms the two-filter chain (RawPeerCertificateCaptureFilter + ClientCertificateMapper) does not introduce a gap where a direct-only client's genuine certificate would be invisible to the cheap hasCertificateFromRequest() early-exit used by ClientDetailsAuthenticationProvider and MtlsClaimsEnhancer. --- ...tificateCaptureFilterRegistrationTest.java | 48 +++++++++++++++++++ .../tls/TlsClientAuthenticationTest.java | 30 ++++++++++++ 2 files changed, 78 insertions(+) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java index 4d88080c46c..65a3291bd86 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java @@ -134,6 +134,54 @@ void capturedAttributeSurvivesClientCertificateMapperOverwritingTheStandardAttri .containsExactly(xfccDerivedCert); } + @Test + void standardAttributeAndRawAttributeBothHoldTheGenuinePeerCertForADirectConnection() throws Exception { + // Regression test for a PR review concern: TlsClientAuthentication.hasCertificateFromRequest() + // (used as a cheap early exit by ClientDetailsAuthenticationProvider and MtlsClaimsEnhancer + // before calling getCertificateChainFromRequest(config)) only inspects the standard + // jakarta.servlet.request.X509Certificate attribute, never RAW_PEER_CERTIFICATE_ATTRIBUTE. + // The concern: could a direct-only client (no tls-client-auth-trusted-proxy-ca configured) + // hit a case where the standard attribute is empty/null while the genuine raw peer cert is + // still present, causing hasCertificateFromRequest() to incorrectly short-circuit before the + // (correct) direct-connection logic in getCertificateChainFromRequest ever runs? + // + // Per the decompiled ClientCertificateMapper bytecode: when the X-Forwarded-Client-Cert + // header is absent, blank, or fails to parse, the filter's certificate list is empty and it + // never calls setAttribute at all -- it neither clears nor nulls the standard attribute, it + // simply leaves whatever was already there (i.e. Tomcat's TLS-handshake-populated value, the + // same value RawPeerCertificateCaptureFilter had already copied to + // RAW_PEER_CERTIFICATE_ATTRIBUTE moments earlier). So for a direct connection with no XFCC + // header, both attributes end up holding the same genuine peer cert: hasCertificateFromRequest() + // (checking only the standard attribute) correctly returns true, and there is no gap. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + X509Certificate genuinePeerCert = generateSelfSignedCert("CN=app-instance"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); + // Simulates what Tomcat's TLS handshake would have already set before any filter runs. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{genuinePeerCert}); + // No X-Forwarded-Client-Cert header at all -- a direct connection. + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(captureBean, mapperBean), request, response); + + X509Certificate[] capturedRawPeerCert = + (X509Certificate[]) request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + X509Certificate[] finalStandardAttribute = + (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); + + assertThat(capturedRawPeerCert) + .as("RAW_PEER_CERTIFICATE_ATTRIBUTE must hold the genuine TLS peer cert") + .containsExactly(genuinePeerCert); + assertThat(finalStandardAttribute) + .as("ClientCertificateMapper must leave the standard attribute untouched when no XFCC " + + "header is present, so hasCertificateFromRequest() sees the genuine peer cert too") + .containsExactly(genuinePeerCert); + } + @Test void capturesTheGenuinePeerCertificateForAZonePathMtlsRequest() throws Exception { // Regression test for PR review comment on SpringServletXmlFiltersConfiguration.java:262: diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index ba0ffb0107b..95268a38dc2 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -383,6 +383,36 @@ void getCertificateChainFromRequestReturnsNullWhenTrustedProxyCaConfiguredButXfc } } + @Test + void getCertificateChainFromRequestReturnsNullWhenTrustedProxyCaConfiguredButXfccHeaderBlank() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + // X-Forwarded-Client-Cert header is present but blank -- e.g. a proxy or intermediate + // that clears the header without removing it. Per the design doc, this must be treated + // the same as the header being entirely absent: reject the request, even though the + // genuine peer certificate would otherwise validate against tls-client-auth-trusted-proxy-ca. + request.addHeader("X-Forwarded-Client-Cert", ""); + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + private static KeyPair generateKeyPair() throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); kpg.initialize(2048); From 29103f0f414d7dfc81e5b0a468c883629ee779fb Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 20 Aug 2026 17:48:53 +0200 Subject: [PATCH 070/130] docs(review): correct trusted-proxy-ca guidance to strict path separation Commit a3f117347 documented tls-client-auth-trusted-proxy-ca as satisfiable by either topology depending on which CA was configured. The actual implementation (see TlsClientAuthentication.getCertificateChainFromRequest) makes the two topologies mutually exclusive per client: configuring the property at all switches a client to proxy-only (XFCC required, direct connections rejected); leaving it unset makes a client direct-only (XFCC always ignored). Corrects the doc to match, and clarifies that two separate clients are needed to support both patterns for the same workload. --- docs/UAA-Client-Authentication.md | 34 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index a594d36a5b4..5636cd41f4e 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -74,17 +74,23 @@ that happens to be depends on how UAA is deployed: (`uaa.service.cf.internal`) where Application Security Groups permit it, bypassing the Gorouter entirely: UAA's immediate TLS peer *is* the original client. -To prevent a caller that can reach UAA directly from simply replaying a certificate it -harvested from an `X-Forwarded-Client-Cert` header (without possessing that certificate's -private key), UAA only trusts the `X-Forwarded-Client-Cert` header when the certificate its -immediate TLS peer *actually presented during the TLS handshake* validates against -`tls-client-auth-trusted-proxy-ca` (see below). This property must therefore be configured -for the client to authenticate at all, and **which CA to configure depends on the topology**: - -* Gorouter-fronted: set it to the CA that signs the Gorouter's own backend mTLS certificate - (e.g. `service_cf_internal_ca` in a typical `cf-deployment`-based CF). -* Direct connections: set it to the same CA as `tls-client-auth-ca` (the client's own leaf - certificate CA), since the client itself is the immediate TLS peer. +`tls-client-auth-trusted-proxy-ca` determines which of the two topologies a *given client* uses -- +the two are mutually exclusive per client, not two ways of satisfying the same requirement: + +* **Not configured:** the client is direct-connection-only. UAA always authenticates it using the + certificate its immediate TLS peer actually presented during the handshake, and never consults + the `X-Forwarded-Client-Cert` header at all (even if one happens to be present -- e.g. noise + from an unrelated proxy in the network path). +* **Configured:** the client is proxy-only. UAA requires the `X-Forwarded-Client-Cert` header to + actually be present, and the genuine TLS peer that presented it to validate against this CA, + before trusting the header-derived certificate. A direct connection (no header) is always + rejected for this client, even if its own certificate happens to validate against the configured + CA. + +An operator who needs both a Gorouter-fronted access pattern and a direct-connection access +pattern for what is conceptually "the same" workload registers **two separate UAA clients** -- one +with `tls-client-auth-trusted-proxy-ca` set (proxy path) and one without it (direct path) -- rather +than expecting one client to accept either. #### Configuration @@ -95,7 +101,7 @@ admin UI, alongside the client's other properties such as `authorized-grant-type |----------|----------|--------------| | `token-endpoint-auth-method: tls_client_auth` | yes | Selects mTLS client authentication for this client. | | `tls-client-auth-ca` | yes | PEM-encoded CA certificate. The client's own presented (leaf) certificate must chain to this CA. | -| `tls-client-auth-trusted-proxy-ca` | yes | PEM-encoded CA certificate that the entity presenting a certificate at the TLS layer immediately in front of UAA (Gorouter or the client itself -- see "Deployment topology" above) must chain to. Without this, `/oauth/mtls/token` rejects every request for this client. | +| `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | | `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`) to JWT claim names, optionally extracting a capture group via `pattern`. | | `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. | | `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. | @@ -121,8 +127,8 @@ tls-client-auth-claim-mappings: claim: org_guid ``` -For the direct-connection topology described above, `tls-client-auth-trusted-proxy-ca` would -instead be set to the same value as `tls-client-auth-ca`. +For the direct-connection topology described above, omit `tls-client-auth-trusted-proxy-ca` +entirely rather than setting it -- configuring it at all switches this client to proxy-only. ## Configs From 1163fc97ee367919df9ffbb115695799a539eec0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 11:37:52 +0200 Subject: [PATCH 071/130] feat(review): add tls-client-auth-required-claims to TlsClientAuthConfiguration --- .../client/TlsClientAuthConfiguration.java | 13 ++++++++-- .../TlsClientAuthConfigurationTest.java | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java index 8d3e75c0c36..3535384bdf6 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import java.util.Map; import java.util.Objects; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -16,6 +17,7 @@ public class TlsClientAuthConfiguration { public static final String TLS_CLIENT_AUTH_SUB_TEMPLATE = "tls-client-auth-sub-template"; public static final String TLS_CLIENT_AUTH_AUD_TEMPLATES = "tls-client-auth-aud-templates"; public static final String TLS_CLIENT_AUTH_TRUSTED_PROXY_CA = "tls-client-auth-trusted-proxy-ca"; + public static final String TLS_CLIENT_AUTH_REQUIRED_CLAIMS = "tls-client-auth-required-claims"; @JsonProperty(TLS_CLIENT_AUTH_CA) private String trustedCaPem; @@ -32,6 +34,9 @@ public class TlsClientAuthConfiguration { @JsonProperty(TLS_CLIENT_AUTH_TRUSTED_PROXY_CA) private String trustedProxyCaPem; + @JsonProperty(TLS_CLIENT_AUTH_REQUIRED_CLAIMS) + private Map requiredClaims; + public TlsClientAuthConfiguration() {} public TlsClientAuthConfiguration(String trustedCaPem, List claimMappings) { @@ -54,6 +59,9 @@ public TlsClientAuthConfiguration(String trustedCaPem, List claimM public String getTrustedProxyCaPem() { return trustedProxyCaPem; } public void setTrustedProxyCaPem(String trustedProxyCaPem) { this.trustedProxyCaPem = trustedProxyCaPem; } + public Map getRequiredClaims() { return requiredClaims; } + public void setRequiredClaims(Map requiredClaims) { this.requiredClaims = requiredClaims; } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -62,12 +70,13 @@ public boolean equals(Object o) { Objects.equals(claimMappings, that.claimMappings) && Objects.equals(subTemplate, that.subTemplate) && Objects.equals(audTemplates, that.audTemplates) && - Objects.equals(trustedProxyCaPem, that.trustedProxyCaPem); + Objects.equals(trustedProxyCaPem, that.trustedProxyCaPem) && + Objects.equals(requiredClaims, that.requiredClaims); } @Override public int hashCode() { - return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates, trustedProxyCaPem); + return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates, trustedProxyCaPem, requiredClaims); } public static boolean isConfigured(TlsClientAuthConfiguration config) { diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java index 8d4887393a4..3b149a93bf7 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -161,4 +162,27 @@ void unequalWhenTrustedProxyCaPemDiffers() { assertThat(config1).isNotEqualTo(config2); } + + @Test + void requiredClaimsRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config.setRequiredClaims(Map.of("space_guid", "the-expected-space-guid")); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getRequiredClaims()).containsEntry("space_guid", "the-expected-space-guid"); + assertThat(json).contains("tls-client-auth-required-claims"); + } + + @Test + void unequalWhenRequiredClaimsDiffer() { + TlsClientAuthConfiguration config1 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config1.setRequiredClaims(Map.of("space_guid", "space-a")); + TlsClientAuthConfiguration config2 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config2.setRequiredClaims(Map.of("space_guid", "space-b")); + + assertThat(config1).isNotEqualTo(config2); + } } From ca7fc59139088576c206ee3145c56a22b8f0bba5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 11:49:18 +0200 Subject: [PATCH 072/130] refactor(review): relocate claim-mapping extraction to TlsClientAuthentication MtlsClaimsEnhancer's subject CN/OU/O extraction pipeline (RDN parsing via LdapName, pattern matching) moves to TlsClientAuthentication.extractClaimMappingValues, since the upcoming tls-client-auth-required-claims enforcement needs it at authentication time, not just claims-enhancement time. MtlsClaimsEnhancer now delegates to the shared method instead of duplicating the logic -- pure extract-and-relocate, no algorithmic change. MtlsClaimsEnhancerTest switches from a plain mock to a spy of TlsClientAuthentication so the real (now relocated) extraction still runs during these tests, exactly as before -- no test body changes needed beyond setUp(), except one stub in stringPathInAdditionalInformationLoadsTrustedProxyCa() switched from when/thenReturn to doReturn/when to avoid Mockito's real-method-invocation-during-stubbing behavior on spies, which was double-counting an interaction verified by that test. --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 116 +-------------- .../oauth/tls/TlsClientAuthentication.java | 135 ++++++++++++++++++ .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 14 +- .../tls/TlsClientAuthenticationTest.java | 36 +++++ 4 files changed, 184 insertions(+), 117 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 15074a875d1..b04f5e4b9de 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -14,17 +14,10 @@ import org.springframework.stereotype.Component; import tools.jackson.core.type.TypeReference; -import javax.naming.NamingEnumeration; -import javax.naming.NamingException; -import javax.naming.directory.Attribute; -import javax.naming.ldap.LdapName; -import javax.naming.ldap.Rdn; -import javax.security.auth.x500.X500Principal; import java.security.MessageDigest; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Base64; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -118,25 +111,7 @@ public Map enhance(Map claims, OAuth2Authenticat } // PHASE 1 — extract cert subject fields into vars (keyed by claim name) - Map vars = new HashMap<>(); - if (config.getClaimMappings() != null) { - X500Principal subject = cert.getSubjectX500Principal(); - String dn = subject.getName(X500Principal.RFC2253); - String cn = extractRdnValue(dn, "CN"); - List ous = extractOus(dn); - - for (TlsClientAuthConfiguration.ClaimMapping mapping : config.getClaimMappings()) { - String value = switch (mapping.getField()) { - case "subject_cn" -> cn; - case "subject_ou" -> matchFirstOu(ous, mapping.getPattern()); - case "subject_o" -> extractRdnValue(dn, "O"); - default -> null; - }; - if (value != null && !value.isBlank()) { - vars.put(mapping.getClaim(), value); - } - } - } + Map vars = tlsClientAuthentication.extractClaimMappingValues(cert, config); // PHASE 2 — build JWT claims: dot-notation → nested object; flat → top-level Map result = new HashMap<>(); @@ -192,95 +167,6 @@ public Map enhance(Map claims, OAuth2Authenticat return result; } - /** - * Parses an RFC 2253 DN string into its RDNs, ordered most-specific-first - * (i.e. matching the left-to-right order of the original DN string). - * - *

{@link LdapName#getRdns()} returns RDNs least-specific-first (root/rightmost - * component at index 0), so the list is reversed here. Using {@link LdapName} instead - * of a naive {@code dn.split(",")} correctly handles backslash-escaped commas/quotes - * within attribute values (RFC 2253 §2.4), which a plain string split would mis-parse. - * Returns an empty list if {@code dn} cannot be parsed as a valid DN. - */ - private static List parseRdnsMostSpecificFirst(String dn) { - try { - List rdns = new ArrayList<>(new LdapName(dn).getRdns()); - Collections.reverse(rdns); - return rdns; - } catch (NamingException e) { - return List.of(); - } - } - - /** - * Returns the value of the given attribute {@code type} (e.g. {@code "CN"}) from an RDN, - * including multi-valued RDNs (attributes joined by {@code +}). Attribute type matching - * is case-insensitive, per LDAP semantics. Returns {@code null} if not present. - */ - private static String rdnAttributeValue(Rdn rdn, String type) { - try { - NamingEnumeration attrs = rdn.toAttributes().getAll(); - while (attrs.hasMore()) { - Attribute attr = attrs.next(); - if (attr.getID().equalsIgnoreCase(type)) { - Object value = attr.get(); - return value == null ? null : value.toString(); - } - } - } catch (NamingException e) { - // fall through to null - } - return null; - } - - /** - * Extracts the value of a single-valued RDN attribute (e.g. {@code "CN"}) from a RFC 2253 - * DN string. Handles multi-valued RDNs (attributes joined by {@code +}). - * Returns {@code null} if no matching RDN is found. - */ - private String extractRdnValue(String dn, String type) { - for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { - String value = rdnAttributeValue(rdn, type); - if (value != null) { - return value; - } - } - return null; - } - - /** - * Collects all OU values from a RFC 2253 DN string, in order. - * Handles multi-valued RDNs (attributes joined by {@code +}). - */ - private List extractOus(String dn) { - List ous = new ArrayList<>(); - for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { - String value = rdnAttributeValue(rdn, "OU"); - if (value != null) { - ous.add(value); - } - } - return ous; - } - - /** - * Returns the first captured group from the first OU that matches {@code patternStr}. - * When {@code patternStr} is null or blank, returns the first OU value verbatim. - */ - private String matchFirstOu(List ous, String patternStr) { - if (patternStr == null || patternStr.isBlank()) { - return ous.isEmpty() ? null : ous.get(0); - } - Pattern pat = Pattern.compile(patternStr); - for (String ou : ous) { - Matcher m = pat.matcher(ou); - if (m.matches() && m.groupCount() >= 1) { - return m.group(1); - } - } - return null; - } - /** * Renders a template string by substituting all {@code {varName}} placeholders * from {@code vars}. Returns {@code null} if any placeholder has no corresponding diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index 5955704891a..aeffe4760e7 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -13,6 +13,12 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; import java.io.StringReader; import java.security.cert.CertPathValidator; import java.security.cert.CertPathValidatorException; @@ -20,9 +26,16 @@ import java.security.cert.PKIXParameters; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Validates mTLS client certificates against a configured CA and extracts @@ -169,6 +182,39 @@ public boolean isCertificateFromTrustedProxy(TlsClientAuthConfiguration clientCo } } + /** + * Extracts claim-name -> value pairs from {@code cert}'s subject fields per {@code config}'s + * {@code tls-client-auth-claim-mappings}. Shared by {@link MtlsClaimsEnhancer} (to build JWT + * claims) and {@link #certificateSatisfiesRequiredClaims} (to enforce + * {@code tls-client-auth-required-claims} at authentication time, before any claim is built). + * + * @return an empty map if {@code cert} or {@code config} is {@code null}, or if + * {@code config} has no {@code tls-client-auth-claim-mappings} configured + */ + public Map extractClaimMappingValues(X509Certificate cert, TlsClientAuthConfiguration config) { + if (cert == null || config == null || config.getClaimMappings() == null) { + return Map.of(); + } + X500Principal subject = cert.getSubjectX500Principal(); + String dn = subject.getName(X500Principal.RFC2253); + String cn = extractRdnValue(dn, "CN"); + List ous = extractOus(dn); + + Map vars = new HashMap<>(); + for (TlsClientAuthConfiguration.ClaimMapping mapping : config.getClaimMappings()) { + String value = switch (mapping.getField()) { + case "subject_cn" -> cn; + case "subject_ou" -> matchFirstOu(ous, mapping.getPattern()); + case "subject_o" -> extractRdnValue(dn, "O"); + default -> null; + }; + if (value != null && !value.isBlank()) { + vars.put(mapping.getClaim(), value); + } + } + return vars; + } + /** * Validates {@code clientCert} against the trusted CA PEM configured in {@code config} * using PKIX path validation. @@ -254,4 +300,93 @@ private static X509Certificate parsePemCertificate(String pem) throws Exception .getCertificate(holder); } } + + /** + * Parses an RFC 2253 DN string into its RDNs, ordered most-specific-first + * (i.e. matching the left-to-right order of the original DN string). + * + *

{@link LdapName#getRdns()} returns RDNs least-specific-first (root/rightmost + * component at index 0), so the list is reversed here. Using {@link LdapName} instead + * of a naive {@code dn.split(",")} correctly handles backslash-escaped commas/quotes + * within attribute values (RFC 2253 §2.4), which a plain string split would mis-parse. + * Returns an empty list if {@code dn} cannot be parsed as a valid DN. + */ + private static List parseRdnsMostSpecificFirst(String dn) { + try { + List rdns = new ArrayList<>(new LdapName(dn).getRdns()); + Collections.reverse(rdns); + return rdns; + } catch (NamingException e) { + return List.of(); + } + } + + /** + * Returns the value of the given attribute {@code type} (e.g. {@code "CN"}) from an RDN, + * including multi-valued RDNs (attributes joined by {@code +}). Attribute type matching + * is case-insensitive, per LDAP semantics. Returns {@code null} if not present. + */ + private static String rdnAttributeValue(Rdn rdn, String type) { + try { + NamingEnumeration attrs = rdn.toAttributes().getAll(); + while (attrs.hasMore()) { + Attribute attr = attrs.next(); + if (attr.getID().equalsIgnoreCase(type)) { + Object value = attr.get(); + return value == null ? null : value.toString(); + } + } + } catch (NamingException e) { + // fall through to null + } + return null; + } + + /** + * Extracts the value of a single-valued RDN attribute (e.g. {@code "CN"}) from a RFC 2253 + * DN string. Handles multi-valued RDNs (attributes joined by {@code +}). + * Returns {@code null} if no matching RDN is found. + */ + private static String extractRdnValue(String dn, String type) { + for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { + String value = rdnAttributeValue(rdn, type); + if (value != null) { + return value; + } + } + return null; + } + + /** + * Collects all OU values from a RFC 2253 DN string, in order. + * Handles multi-valued RDNs (attributes joined by {@code +}). + */ + private static List extractOus(String dn) { + List ous = new ArrayList<>(); + for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { + String value = rdnAttributeValue(rdn, "OU"); + if (value != null) { + ous.add(value); + } + } + return ous; + } + + /** + * Returns the first captured group from the first OU that matches {@code patternStr}. + * When {@code patternStr} is null or blank, returns the first OU value verbatim. + */ + private static String matchFirstOu(List ous, String patternStr) { + if (patternStr == null || patternStr.isBlank()) { + return ous.isEmpty() ? null : ous.get(0); + } + Pattern pat = Pattern.compile(patternStr); + for (String ou : ous) { + Matcher m = pat.matcher(ou); + if (m.matches() && m.groupCount() >= 1) { + return m.group(1); + } + } + return null; + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 62646931c05..ef4a122f81e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -20,7 +20,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.CLIENT_AUTH_METHOD; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,7 +34,12 @@ class MtlsClaimsEnhancerTest { @BeforeEach void setUp() { - tlsClientAuthentication = mock(TlsClientAuthentication.class); + // A spy (not a plain mock): extractClaimMappingValues now lives on TlsClientAuthentication, + // and this test suite still needs it to actually run (real RDN parsing) to exercise + // MtlsClaimsEnhancer's claim-shape-building logic end-to-end, exactly as before this class + // was relocated. hasCertificateFromRequest()/getCertificateFromRequest(...) remain stubbed + // per-test exactly as before. + tlsClientAuthentication = spy(new TlsClientAuthentication()); clientDetailsService = mock(ClientDetailsService.class); enhancer = new MtlsClaimsEnhancer(tlsClientAuthentication, clientDetailsService); } @@ -372,7 +379,10 @@ void stringPathInAdditionalInformationLoadsSubTemplateAndAudTemplates() throws E void stringPathInAdditionalInformationLoadsTrustedProxyCa() throws Exception { X509Certificate cert = mockCfCert(); when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); - when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + // doReturn/when (not when/thenReturn) — this test verifies the exact invocation count of + // getCertificateFromRequest below; when/thenReturn on a spy invokes the real method once + // during stub setup, which would be double-counted as an extra interaction. + doReturn(cert).when(tlsClientAuthentication).getCertificateFromRequest(any()); UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("instance-identity"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 95268a38dc2..2f4a8d5b441 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -27,6 +27,8 @@ import java.security.Security; import java.security.cert.X509Certificate; import java.util.Date; +import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -55,6 +57,40 @@ void nullConfigReturnsEmptyOptional() { assertThat(service.validateClientCert(cert, null)).isEmpty(); } + @Test + void extractClaimMappingValuesExtractsSubjectCnOuAndO() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=app:app-guid-123,OU=space:space-guid-456,O=Cloud Foundry"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_o", null, "org_name") + )); + + Map vars = service.extractClaimMappingValues(cert, config); + + assertThat(vars).containsEntry("cf_instance_guid", "instance-guid"); + assertThat(vars).containsEntry("app_guid", "app-guid-123"); + assertThat(vars).containsEntry("org_name", "Cloud Foundry"); + } + + @Test + void extractClaimMappingValuesReturnsEmptyMapWhenNoClaimMappingsConfigured() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + + assertThat(service.extractClaimMappingValues(cert, config)).isEmpty(); + } + + @Test + void extractClaimMappingValuesReturnsEmptyMapWhenCertOrConfigIsNull() { + assertThat(service.extractClaimMappingValues(null, new TlsClientAuthConfiguration("ca", null))).isEmpty(); + } + @Test void invalidCaThrowsInvalidClientDetailsException() { X509Certificate cert = mock(X509Certificate.class); From d84a180ccb182e700bde7a7f1ff8c2e4442e744f Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 11:57:57 +0200 Subject: [PATCH 073/130] feat(review): add certificateSatisfiesRequiredClaims to TlsClientAuthentication --- .../oauth/tls/TlsClientAuthentication.java | 24 +++++++++ .../tls/TlsClientAuthenticationTest.java | 51 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index aeffe4760e7..1e646fefabd 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -215,6 +215,30 @@ public Map extractClaimMappingValues(X509Certificate cert, TlsCl return vars; } + /** + * Returns {@code true} when {@code config} has no {@code tls-client-auth-required-claims} + * configured (unrestricted, current behavior), or when every required claim name maps to + * exactly the required value once extracted from {@code cert} via + * {@code tls-client-auth-claim-mappings}. This is what lets a client be scoped to e.g. a + * specific CF space/org/app -- closing the gap where any certificate chaining to a shared CA + * could otherwise authenticate as any client that trusts that CA. + */ + public boolean certificateSatisfiesRequiredClaims(X509Certificate cert, TlsClientAuthConfiguration config) { + if (config == null || config.getRequiredClaims() == null || config.getRequiredClaims().isEmpty()) { + return true; + } + Map vars = extractClaimMappingValues(cert, config); + for (Map.Entry required : config.getRequiredClaims().entrySet()) { + if (!required.getValue().equals(vars.get(required.getKey()))) { + logger.debug("certificateSatisfiesRequiredClaims: required claim '{}' did not match " + + "(expected '{}', extracted '{}')", + required.getKey(), required.getValue(), vars.get(required.getKey())); + return false; + } + } + return true; + } + /** * Validates {@code clientCert} against the trusted CA PEM configured in {@code config} * using PKIX path validation. diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 2f4a8d5b441..b8411b4cb71 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -91,6 +91,57 @@ void extractClaimMappingValuesReturnsEmptyMapWhenCertOrConfigIsNull() { assertThat(service.extractClaimMappingValues(null, new TlsClientAuthConfiguration("ca", null))).isEmpty(); } + @Test + void certificateSatisfiesRequiredClaimsTrueWhenNoConstraintConfigured() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isTrue(); + } + + @Test + void certificateSatisfiesRequiredClaimsTrueWhenExtractedValueMatchesRequiredValue() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=space:space-guid-456"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid") + )); + config.setRequiredClaims(Map.of("space_guid", "space-guid-456")); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isTrue(); + } + + @Test + void certificateSatisfiesRequiredClaimsFalseWhenExtractedValueDiffersFromRequiredValue() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=space:some-other-space"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid") + )); + config.setRequiredClaims(Map.of("space_guid", "space-guid-456")); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isFalse(); + } + + @Test + void certificateSatisfiesRequiredClaimsFalseWhenRequiredClaimNeverExtracted() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + // No claim-mappings configured at all -> "space_guid" is never extracted + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setRequiredClaims(Map.of("space_guid", "space-guid-456")); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isFalse(); + } + @Test void invalidCaThrowsInvalidClientDetailsException() { X509Certificate cert = mock(X509Certificate.class); From c565fe5410a2569fc4e4b47dbf163aa921ab67c7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 12:13:49 +0200 Subject: [PATCH 074/130] fix(review): enforce tls-client-auth-required-claims during tls_client_auth validateTlsClientAuth now additionally requires certificateSatisfiesRequiredClaims to pass, closing the gap where any certificate chaining to a client's configured tls-client-auth-ca could authenticate as that client, even when a different client shares the same CA. getTlsClientAuthConfiguration (and the mirrored MtlsClaimsEnhancer.loadTlsConfig) now also parse tls-client-auth-required-claims from the flat/BOSH-bootstrapped additionalInformation shape. Addresses PR review comment on TlsClientAuthentication.java:150 (also flagged at line 175). --- .../ClientDetailsAuthenticationProvider.java | 15 +- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 12 ++ ...entDetailsAuthenticationProviderTests.java | 143 ++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index 3d9bdbb98f7..c29d7190671 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -200,7 +200,8 @@ boolean validateTlsClientAuth(UaaClient uaaClient) { if (chain == null || chain.length == 0) { return false; } - return tlsClientAuthentication.validateClientCert(chain, config).isPresent(); + return tlsClientAuthentication.validateClientCert(chain, config).isPresent() + && tlsClientAuthentication.certificateSatisfiesRequiredClaims(chain[0], config); } static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaClient) { @@ -255,10 +256,22 @@ static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaCli trustedProxyCaPem = tpc; } + Map requiredClaims = null; + Object rawRequiredClaims = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + if (rawRequiredClaims instanceof String requiredClaimsJson) { + requiredClaims = JsonUtils.readValue(requiredClaimsJson, + new TypeReference>() {}); + } else if (rawRequiredClaims instanceof Map requiredClaimsMap) { + requiredClaims = JsonUtils.readValue( + JsonUtils.writeValueAsString(requiredClaimsMap), + new TypeReference>() {}); + } + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); cfg.setSubTemplate(subTemplate); cfg.setAudTemplates(audTemplates); cfg.setTrustedProxyCaPem(trustedProxyCaPem); + cfg.setRequiredClaims(requiredClaims); return cfg; } catch (Exception e) { return null; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index b04f5e4b9de..1fa736f7e91 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -248,10 +248,22 @@ private static TlsClientAuthConfiguration loadTlsConfig(Map info trustedProxyCaPem = tpc; } + Map requiredClaims = null; + Object rawRequiredClaims = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + if (rawRequiredClaims instanceof String requiredClaimsJson) { + requiredClaims = JsonUtils.readValue(requiredClaimsJson, + new TypeReference>() {}); + } else if (rawRequiredClaims instanceof Map requiredClaimsMap) { + requiredClaims = JsonUtils.readValue( + JsonUtils.writeValueAsString(requiredClaimsMap), + new TypeReference>() {}); + } + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); cfg.setSubTemplate(subTemplate); cfg.setAudTemplates(audTemplates); cfg.setTrustedProxyCaPem(trustedProxyCaPem); + cfg.setRequiredClaims(requiredClaims); return cfg; } catch (Exception e) { return null; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java index caea8076138..30784d0b0bc 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -1,15 +1,38 @@ package org.cloudfoundry.identity.uaa.authentication; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClient; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -21,6 +44,11 @@ class ClientDetailsAuthenticationProviderTests { + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + @Test void tlsClientAuthPathIsDetectedAsTlsClientAuth() { UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); @@ -121,4 +149,119 @@ void getTlsClientAuthConfigurationTrustedProxyCaNullWhenAbsent() { assertThat(config).isNotNull(); assertThat(config.getTrustedProxyCaPem()).isNull(); } + + @Test + void getTlsClientAuthConfigurationReadsRequiredClaimsFromFlatStringPath() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + "{\"space_guid\":\"the-expected-space-guid\"}" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getRequiredClaims()).containsEntry("space_guid", "the-expected-space-guid"); + } + + @Test + void getTlsClientAuthConfigurationRequiredClaimsNullWhenAbsent() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getRequiredClaims()).isNull(); + } + + @Test + void validateTlsClientAuthEnforcesRequiredClaimsAgainstAClientSharingTheSameCa() throws Exception { + // Reproduces the reviewer's impersonation scenario (PR review comment on + // TlsClientAuthentication.java:150, also flagged at line 175): two UAA clients share the + // same tls-client-auth-ca (e.g. Diego's shared instance-identity CA). Without a + // tls-client-auth-required-claims constraint, a certificate for one app could authenticate + // as ANY client trusting that CA. A client that configures tls-client-auth-required-claims + // now rejects a certificate belonging to a different space, while an unconstrained client + // sharing the same CA still accepts it. + KeyPair caKeyPair = generateKeyPair(); + X500Name caName = new X500Name("CN=Shared Diego Instance Identity CA"); + X509Certificate caCert = signCert(caName, caName, caKeyPair.getPublic(), caKeyPair.getPrivate(), true, BigInteger.ONE); + + KeyPair appKeyPair = generateKeyPair(); + X500Name appSubject = new X500Name("CN=app-instance,OU=space:some-other-space-guid"); + X509Certificate appCert = signCert(appSubject, caName, appKeyPair.getPublic(), caKeyPair.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthentication tlsClientAuthentication = new TlsClientAuthentication(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + X509Certificate[] presentedChain = new X509Certificate[]{appCert}; + request.setAttribute("jakarta.servlet.request.X509Certificate", presentedChain); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, presentedChain); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + TlsClientAuthConfiguration unconstrainedConfig = new TlsClientAuthConfiguration(toPem(caCert), null); + + TlsClientAuthConfiguration constrainedConfig = new TlsClientAuthConfiguration(toPem(caCert), List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid") + )); + constrainedConfig.setRequiredClaims(Map.of("space_guid", "the-expected-space-guid")); + + UaaClient unconstrainedClient = mock(UaaClient.class); + when(unconstrainedClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, unconstrainedConfig)); + + UaaClient constrainedClient = mock(UaaClient.class); + when(constrainedClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, constrainedConfig)); + + ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( + mock(UserDetailsService.class), mock(PasswordEncoder.class), + mock(JwtClientAuthentication.class), tlsClientAuthentication); + + assertThat(provider.validateTlsClientAuth(unconstrainedClient)) + .as("the unconstrained client (no tls-client-auth-required-claims) still accepts any cert from the shared CA") + .isTrue(); + assertThat(provider.validateTlsClientAuth(constrainedClient)) + .as("the constrained client rejects a cert whose space_guid doesn't match its required claim") + .isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + + private static String toPem(X509Certificate cert) throws Exception { + java.io.StringWriter sw = new java.io.StringWriter(); + try (org.bouncycastle.util.io.pem.PemWriter pemWriter = new org.bouncycastle.util.io.pem.PemWriter(sw)) { + pemWriter.writeObject(new org.bouncycastle.util.io.pem.PemObject("CERTIFICATE", cert.getEncoded())); + } + return sw.toString(); + } } From 4a772df6652704c69d59f6fb705043de8aebad25 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 12:27:03 +0200 Subject: [PATCH 075/130] docs(review): document tls-client-auth-required-claims Addresses PR review comment on TlsClientAuthentication.java:150 (also flagged at line 175). --- docs/UAA-Client-Authentication.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 5636cd41f4e..f431b809a0f 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -92,6 +92,29 @@ pattern for what is conceptually "the same" workload registers **two separate UA with `tls-client-auth-trusted-proxy-ca` set (proxy path) and one without it (direct path) -- rather than expecting one client to accept either. +#### Scoping a client to a specific org/space/app + +Because Cloud Foundry's Diego instance-identity CA is shared across every app instance in a +foundation, any two clients configured with the same `tls-client-auth-ca` can otherwise +authenticate each other's certificates -- PKIX chain validation alone only proves a certificate +was issued by the configured CA, not that it belongs to *this* client specifically. Configure +`tls-client-auth-required-claims` to close this gap for a client that should only be reachable by +a specific subset of apps: + +```yaml +tls-client-auth-claim-mappings: + - field: subject_ou + pattern: "space:(.+)" + claim: space_guid +tls-client-auth-required-claims: + space_guid: +``` + +An operator who needs both a broadly-scoped client (e.g. the generic `instance-identity` client, +accepting any app in the foundation) and a narrowly-scoped one (e.g. limited to a single space) +registers them as two separate UAA clients, only the latter configuring +`tls-client-auth-required-claims`. + #### Configuration Per-client properties (set via the client-admin API, `oauth.clients` bootstrap, or the client @@ -102,6 +125,7 @@ admin UI, alongside the client's other properties such as `authorized-grant-type | `token-endpoint-auth-method: tls_client_auth` | yes | Selects mTLS client authentication for this client. | | `tls-client-auth-ca` | yes | PEM-encoded CA certificate. The client's own presented (leaf) certificate must chain to this CA. | | `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | +| `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values already produced by `tls-client-auth-claim-mappings`. When configured, authentication fails unless every entry matches exactly -- e.g. `{space_guid: ""}` scopes this client to a single CF space, even if other clients share the same `tls-client-auth-ca`. | | `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`) to JWT claim names, optionally extracting a capture group via `pattern`. | | `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. | | `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. | From 5b63fdd64a91c38f45578a13b061077d0cd389ba Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 13:23:19 +0200 Subject: [PATCH 076/130] fix(review): reject proxy-forwarded requests where ClientCertificateMapper silently failed getCertificateChainFromRequest's proxy branch previously assumed that a nonblank X-Forwarded-Client-Cert header, combined with a genuine peer validating as a trusted proxy, was sufficient proof that ClientCertificateMapper had actually replaced the standard jakarta.servlet.request.X509Certificate attribute with the XFCC-derived certificate. Decompiling ClientCertificateMapper v2.0.1 previously confirmed it does not clear/null that attribute on a certificate parse failure -- it simply leaves the prior value (the proxy's own raw peer certificate, captured separately by RawPeerCertificateCaptureFilter) untouched. If that proxy's own certificate happens to validate against a client's tls-client-auth-ca, the proxy could then authenticate as that OAuth client. Now compares the standard attribute's certificate chain against the raw-peer attribute's chain; if they're equal, ClientCertificateMapper did not actually replace the attribute, and the request is rejected rather than treating the proxy's own certificate as the client's. Addresses PR review comment on TlsClientAuthentication.java:138. --- .../oauth/tls/TlsClientAuthentication.java | 31 ++++++++++++++- .../tls/TlsClientAuthenticationTest.java | 39 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index 1e646fefabd..2d38bcde8bb 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -97,7 +97,15 @@ public X509Certificate getCertificateFromRequest(TlsClientAuthConfiguration clie * {@link #isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} to be {@code true} * (the genuine TLS peer -- e.g. the Gorouter -- must validate against the configured * proxy CA) before returning the header-derived certificate chain from the standard - * {@code jakarta.servlet.request.X509Certificate} attribute. + * {@code jakarta.servlet.request.X509Certificate} attribute. Additionally, the standard + * attribute's certificate chain must differ from the raw peer certificate chain + * captured by {@link RawPeerCertificateCaptureFilter}: the third-party + * {@code ClientCertificateMapper} servlet filter does not clear or null the standard + * attribute when it fails to parse the XFCC header -- it simply never replaces it, + * leaving the genuine raw peer certificate (e.g. the proxy's own certificate) in place. + * If the two attributes are identical, the mapper did not actually run successfully, + * and the request is rejected rather than treating the proxy's own certificate as the + * client's. * * *

The two modes are mutually exclusive per client: a client cannot accept both a direct @@ -137,7 +145,26 @@ public X509Certificate[] getCertificateChainFromRequest(TlsClientAuthConfigurati } X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); - return (certs != null && certs.length > 0) ? certs : null; + if (certs == null || certs.length == 0) { + return null; + } + // Guard against ClientCertificateMapper silently failing to parse the XFCC header: it + // does not clear/null the standard attribute on a parse failure, it simply never replaces + // it, leaving the genuine raw peer certificate (e.g. the proxy's own certificate) in + // place. If the standard attribute is unchanged from the raw peer capture, the mapper did + // not actually run successfully -- treating it as the client's certificate would let the + // proxy authenticate as the client whenever the proxy's own certificate happens to + // validate against this client's tls-client-auth-ca. + X509Certificate[] rawPeerCerts = (X509Certificate[]) + request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + if (Arrays.equals(certs, rawPeerCerts)) { + logger.warn("getCertificateChainFromRequest: X-Forwarded-Client-Cert header present and peer " + + "validated as trusted proxy, but the standard X509Certificate attribute was unchanged " + + "from the raw peer certificate -- ClientCertificateMapper likely failed to parse the " + + "XFCC header; rejecting rather than treating the proxy's own certificate as the client's"); + return null; + } + return certs; } /** diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index b8411b4cb71..402c2b84c18 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -500,6 +500,45 @@ void getCertificateChainFromRequestReturnsNullWhenTrustedProxyCaConfiguredButXfc } } + @Test + void getCertificateChainFromRequestReturnsNullWhenClientCertificateMapperSilentlyFailedToParseXfcc() throws Exception { + // Reproduces the reviewer's concern (PR #3972 discussion on TlsClientAuthentication.java:138): + // a trusted proxy (e.g. the Gorouter) sends a well-formed mTLS connection whose own + // certificate validates against tls-client-auth-trusted-proxy-ca, and a nonblank (but + // malformed) X-Forwarded-Client-Cert header. ClientCertificateMapper fails to parse the + // header and -- per its decompiled behavior -- never calls setAttribute, leaving the + // standard jakarta.servlet.request.X509Certificate attribute equal to the raw peer + // certificate captured by RawPeerCertificateCaptureFilter. This must be rejected, even + // though the XFCC header is present and the peer validates as a trusted proxy: reading + // the standard attribute here would wrongly authenticate the proxy's own certificate as + // the OAuth client. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + // ClientCertificateMapper did NOT replace the standard attribute -- it still holds the + // same certificate as the raw peer capture, because parsing the (malformed) XFCC header + // failed and the mapper filter never called setAttribute. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{peerCert}); + request.addHeader("X-Forwarded-Client-Cert", "malformed-or-corrupted-base64-value"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + private static KeyPair generateKeyPair() throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); kpg.initialize(2048); From 1f39eaf082b33b9d0007c9bb75e63a75ca31c487 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 13:58:47 +0200 Subject: [PATCH 077/130] fix(review): fail closed on client-details lookup failure in MtlsClaimsEnhancer enhance() previously caught any exception from clientDetailsService.loadClientByClientId and silently returned an empty claims map, allowing token issuance to continue without the certificate-derived identity claims and the RFC 8705 cnf.x5t#S256 confirmation claim -- silently downgrading a supposedly certificate-bound mTLS token into an ordinary bearer token on a transient lookup failure. The try/catch is removed so any exception (ClientDetailsService.loadClientByClientId only ever throws the unchecked ClientRegistrationException) now propagates through enhance() and fails the whole token request, rather than silently issuing an incomplete one. Also removes the now-fully-unused logger field/imports (its only call site was the removed catch block). Addresses PR review comment on MtlsClaimsEnhancer.java:94. --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 11 +--------- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 1fa736f7e91..95e85dfee65 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -8,8 +8,6 @@ import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.UaaSecurityContextUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import tools.jackson.core.type.TypeReference; @@ -41,7 +39,6 @@ @Component public class MtlsClaimsEnhancer implements UaaTokenEnhancer { - private static final Logger logger = LoggerFactory.getLogger(MtlsClaimsEnhancer.class); private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); private final TlsClientAuthentication tlsClientAuthentication; @@ -85,13 +82,7 @@ public Map enhance(Map claims, OAuth2Authenticat } String clientId = authentication.getOAuth2Request().getClientId(); - UaaClientDetails clientDetails; - try { - clientDetails = (UaaClientDetails) clientDetailsService.loadClientByClientId(clientId); - } catch (Exception e) { - logger.warn("MtlsClaimsEnhancer: failed to load client details for '{}': {}", clientId, e.getMessage()); - return new HashMap<>(); - } + UaaClientDetails clientDetails = (UaaClientDetails) clientDetailsService.loadClientByClientId(clientId); // Check the typed field first (set directly on in-memory / admin-API clients); // fall back to additionalInformation for JDBC-loaded clients. diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index ef4a122f81e..a2cac11c9c0 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -6,6 +6,7 @@ import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Request; +import org.cloudfoundry.identity.uaa.provider.ClientRegistrationException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -18,6 +19,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.CLIENT_AUTH_METHOD; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; @@ -517,6 +519,25 @@ void enhanceReturnsEmptyWhenClientAuthMethodExtensionIsMissing() throws Exceptio assertThat(result).isEmpty(); } + @Test + void enhancePropagatesExceptionWhenClientDetailsLookupFails() throws Exception { + // PR review concern (MtlsClaimsEnhancer.java:94): a transient failure loading client + // details (e.g. a database error) must fail the whole token request closed, not be + // swallowed into an incomplete/degraded token missing identity + cnf claims. + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + when(clientDetailsService.loadClientByClientId("instance-identity")) + .thenThrow(new ClientRegistrationException("db unavailable")); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + + assertThatThrownBy(() -> enhancer.enhance(new HashMap<>(), auth)) + .isInstanceOf(ClientRegistrationException.class) + .hasMessage("db unavailable"); + } + private X509Certificate mockCfCert() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); From 5dbb65ad43c6fa666ed6b65be97284cd72a17ac5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 14:15:01 +0200 Subject: [PATCH 078/130] fix(review): enforce end-entity certificate constraints for tls_client_auth PKIX path validation alone only proves a certificate chains to the configured tls-client-auth-ca -- it does not enforce that the end-entity (leaf) certificate is actually meant to act as a client authentication credential. Because the connector's trust manager is intentionally permissive at the TLS layer (certificateVerification=optionalNoCA), nothing rejects an unsuitable certificate before this application-level check. A CA certificate, or a leaf whose Key Usage/Extended Key Usage extensions explicitly exclude client authentication, could otherwise validate and authenticate as an OAuth client. validateClientCert now additionally rejects (after PKIX validation succeeds): - a leaf certificate that is itself a CA certificate (BasicConstraints CA=true) - a leaf whose Key Usage extension, if present, does not permit digitalSignature - a leaf whose Extended Key Usage extension, if present, does not include id-kp-clientAuth (1.3.6.1.5.5.7.3.2) or anyExtendedKeyUsage These checks only reject when an extension is present and explicitly excludes client authentication -- an absent extension imposes no restriction, per RFC 5280 semantics, preserving backward compatibility with CAs (e.g. Diego's instance-identity CA) that may not set these extensions at all. Scoped to validateClientCert only (the OAuth-client-authentication decision); validateCertPath itself is unchanged and still shared as-is by isCertificateFromTrustedProxy, which validates a different kind of certificate for a different purpose. Addresses PR review comment on TlsClientAuthentication.java:330. --- .../oauth/tls/TlsClientAuthentication.java | 74 ++++++++++- .../tls/TlsClientAuthenticationTest.java | 117 ++++++++++++++++++ 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index 2d38bcde8bb..c07331eb0b6 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -288,11 +288,28 @@ public Optional validateClientCert( * Validates a full certificate chain against the trusted CA PEM configured in {@code config} * using PKIX path validation. Supports chains that include intermediate CAs. * + *

PKIX path validation alone only proves the presented chain structurally links to the + * configured trust anchor -- it does not enforce that the end-entity (leaf) certificate, + * {@code chain[0]}, is actually meant to be used as a TLS client authentication credential. + * Because the connector accepts this call without any TLS-layer CA validation of its own + * (see {@code MtlsClientAuthTomcatCustomizer}, {@code certificateVerification=optionalNoCA}), + * this method additionally rejects {@code chain[0]} (after PKIX validation succeeds) when it: + *

    + *
  • is itself a CA certificate (a {@code BasicConstraints} extension with {@code CA=true}),
  • + *
  • has a Key Usage extension present that does not permit {@code digitalSignature}, or
  • + *
  • has an Extended Key Usage extension present that does not include + * {@code id-kp-clientAuth} or {@code anyExtendedKeyUsage}.
  • + *
+ * These checks only reject when an extension is present and explicitly excludes client + * authentication -- an absent extension imposes no restriction (RFC 5280), preserving + * backward compatibility with CAs that don't set these extensions at all. + * * @param chain the full certificate chain (index 0 = end-entity), may be {@code null} * @param config the per-client TLS configuration, may be {@code null} * @return {@code Optional.of(chain[0])} when validation succeeds; * {@code Optional.empty()} when chain or config is absent - * @throws InvalidClientDetailsException if the CA PEM is malformed or the cert chain is invalid + * @throws InvalidClientDetailsException if the CA PEM is malformed, the cert chain is invalid, + * or {@code chain[0]} fails an end-entity constraint check */ public Optional validateClientCert( X509Certificate[] chain, TlsClientAuthConfiguration config) { @@ -303,7 +320,11 @@ public Optional validateClientCert( try { X509Certificate caCert = parsePemCertificate(config.getTrustedCaPem()); - return validateCertPath(chain, caCert); + Optional validated = validateCertPath(chain, caCert); + if (validated.isPresent()) { + validateEndEntityConstraints(validated.get()); + } + return validated; } catch (CertPathValidatorException e) { throw new InvalidClientDetailsException( "tls_client_auth: certificate chain validation failed: " + e.getMessage()); @@ -313,6 +334,55 @@ public Optional validateClientCert( } } + /** + * Extended Key Usage OID for TLS client authentication ({@code id-kp-clientAuth}, RFC 5280 §4.2.1.12). + */ + private static final String EKU_CLIENT_AUTH = "1.3.6.1.5.5.7.3.2"; + + /** + * Extended Key Usage OID meaning "any extended key usage is acceptable" (RFC 5280 §4.2.1.12). + */ + private static final String EKU_ANY = "2.5.29.37.0"; + + /** + * Rejects {@code leaf} when it carries an extension that explicitly signals it is unsuitable + * as a TLS client authentication credential. See {@link #validateClientCert(X509Certificate[], + * TlsClientAuthConfiguration)} for the full rationale. Absent extensions are never rejected. + * + * @throws CertPathValidatorException if {@code leaf} is itself a CA certificate, its Key + * Usage extension (if present) does not permit {@code digitalSignature}, or its + * Extended Key Usage extension (if present, or unparsable) does not include + * {@code id-kp-clientAuth} or {@code anyExtendedKeyUsage} + */ + private static void validateEndEntityConstraints(X509Certificate leaf) throws CertPathValidatorException { + if (leaf.getBasicConstraints() != -1) { + throw new CertPathValidatorException( + "presented end-entity certificate is itself a CA certificate"); + } + + boolean[] keyUsage = leaf.getKeyUsage(); + if (keyUsage != null && (keyUsage.length < 1 || !keyUsage[0])) { + throw new CertPathValidatorException( + "presented end-entity certificate's Key Usage extension does not permit digitalSignature"); + } + + List extendedKeyUsage; + try { + extendedKeyUsage = leaf.getExtendedKeyUsage(); + } catch (java.security.cert.CertificateParsingException e) { + throw new CertPathValidatorException( + "presented end-entity certificate has an unparsable Extended Key Usage extension: " + + e.getMessage()); + } + if (extendedKeyUsage != null + && !extendedKeyUsage.contains(EKU_CLIENT_AUTH) + && !extendedKeyUsage.contains(EKU_ANY)) { + throw new CertPathValidatorException( + "presented end-entity certificate's Extended Key Usage extension does not include " + + "clientAuth or anyExtendedKeyUsage"); + } + } + /** * Validates {@code chain} against {@code caCert} using PKIX path validation, without requiring any * per-client {@link TlsClientAuthConfiguration}. Shared by {@link #validateClientCert} and diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 402c2b84c18..c57f8372a77 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -2,7 +2,10 @@ import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; @@ -216,6 +219,100 @@ void validateClientCertSucceedsWithIntermediateChainIncludingTrustAnchor() throw assertThat(result).contains(leafCert); } + @Test + void validateClientCertRejectsLeafThatIsItselfACaCertificate() throws Exception { + // A CA cert (BasicConstraints CA=true) presented as the "leaf" chain[0] genuinely PKIX- + // validates -- a 1-cert chain from the intermediate to the root trust anchor is + // structurally valid -- but must be rejected because the presented end-entity is itself + // a CA certificate, not a genuine client credential. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair interKp = generateKeyPair(); + X500Name interName = new X500Name("CN=Test Intermediate CA"); + X509Certificate intermediateCaCert = signCert( + interName, rootName, interKp.getPublic(), rootKp.getPrivate(), true, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{intermediateCaCert}, config)) + .hasMessageContaining("tls_client_auth"); + } + + @Test + void validateClientCertRejectsLeafWithExtendedKeyUsageExcludingClientAuth() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + null, List.of(KeyPurposeId.id_kp_serverAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{leafCert}, config)) + .hasMessageContaining("tls_client_auth"); + } + + @Test + void validateClientCertAcceptsLeafWithExtendedKeyUsageIncludingClientAuth() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + null, List.of(KeyPurposeId.id_kp_clientAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert(new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertAcceptsLeafWithNoKeyUsageOrExtendedKeyUsageExtensions() throws Exception { + // Backward compatibility: certs that set neither extension at all (e.g. Diego's + // instance-identity CA, whose exact extension profile is not assumed here) must still be + // accepted -- an absent extension imposes no restriction per RFC 5280. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert(new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertRejectsLeafWithKeyUsageExcludingDigitalSignature() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + // Key Usage present but only keyEncipherment -- digitalSignature explicitly excluded. + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + KeyUsage.keyEncipherment, null); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{leafCert}, config)) + .hasMessageContaining("tls_client_auth"); + } + @Test void isCertificateFromTrustedProxyTrueWhenPeerCertSignedByClientsTrustedProxyCa() throws Exception { KeyPair rootKp = generateKeyPair(); @@ -547,11 +644,31 @@ private static KeyPair generateKeyPair() throws Exception { private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + return signCert(subject, issuer, subjectKey, signerKey, isCa, serial, null, null); + } + + /** + * Overload allowing tests to optionally set a Key Usage extension ({@code keyUsageBits}, a + * bitmask built from {@link KeyUsage} constants, or {@code null} to omit the extension + * entirely) and/or an Extended Key Usage extension ({@code ekuPurposes}, or {@code null}/empty + * to omit it entirely). Existing call sites using the 6-arg overload above are unaffected, + * matching production certificates that typically don't set these extensions. + */ + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial, + Integer keyUsageBits, List ekuPurposes) throws Exception { Date notBefore = new Date(System.currentTimeMillis() - 60_000); Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( issuer, serial, notBefore, notAfter, subject, subjectKey); builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + if (keyUsageBits != null) { + builder.addExtension(Extension.keyUsage, true, new KeyUsage(keyUsageBits)); + } + if (ekuPurposes != null && !ekuPurposes.isEmpty()) { + builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(ekuPurposes.toArray(new KeyPurposeId[0]))); + } ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) .build(signerKey); From ed178ddc8029b7d5e152bd8cb93887cde1bc94f3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 14:30:20 +0200 Subject: [PATCH 079/130] test(review): assert specific rejection messages in end-entity constraint tests The three rejection tests added in 5dbb65ad4 asserted only a generic 'tls_client_auth' substring shared by every exception validateClientCert can throw, so each test would still pass even if the wrong specific check fired. Now asserts a message substring specific to the check under test (CA-cert leaf, missing digitalSignature, missing clientAuth/anyExtendedKeyUsage). --- .../tls/TlsClientAuthenticationTest.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index c57f8372a77..f35383efdbe 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -237,7 +237,7 @@ void validateClientCertRejectsLeafThatIsItselfACaCertificate() throws Exception TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{intermediateCaCert}, config)) - .hasMessageContaining("tls_client_auth"); + .hasMessageContaining("is itself a CA certificate"); } @Test @@ -254,7 +254,27 @@ void validateClientCertRejectsLeafWithExtendedKeyUsageExcludingClientAuth() thro TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{leafCert}, config)) - .hasMessageContaining("tls_client_auth"); + .hasMessageContaining("does not include clientAuth or anyExtendedKeyUsage"); + } + + @Test + void validateClientCertAcceptsLeafWithExtendedKeyUsageIncludingClientAuthAndServerAuth() throws Exception { + // Locks in "contains, not equals" semantics: an EKU listing clientAuth alongside an + // unrelated purpose must still be accepted. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + null, List.of(KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert(new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); } @Test @@ -310,7 +330,7 @@ void validateClientCertRejectsLeafWithKeyUsageExcludingDigitalSignature() throws TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{leafCert}, config)) - .hasMessageContaining("tls_client_auth"); + .hasMessageContaining("does not permit digitalSignature"); } @Test From 24dadff698dd5035cab4386d8d72e481f1d14a75 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 15:17:54 +0200 Subject: [PATCH 080/130] fix(review): allow secretless mTLS clients via the zone client API ZoneEndpointsClientDetailsValidator unconditionally required a nonblank client_secret for client_credentials (and other grant types), even when the client was configured with a tls-client-auth-ca -- an alternative credential this feature is specifically meant to support. Now skips the blank-secret rejection when tls-client-auth-ca is present in additionalInformation, while still passing the (possibly blank) secret to clientSecretValidator.validate(...) unconditionally -- a no-op for a blank secret, but still enforces secret policy if an operator supplies one alongside mTLS config. Addresses PR review comment on ZoneEndpointsClientDetailsValidator.java:55. --- .../ZoneEndpointsClientDetailsValidator.java | 5 ++- ...eEndpointsClientDetailsValidatorTests.java | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index e5d6396306b..39543b3fd01 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -3,6 +3,7 @@ import org.apache.commons.lang3.StringUtils; import org.cloudfoundry.identity.uaa.client.ClientDetailsValidator; import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; @@ -53,6 +54,8 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv } checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled, clientDetails.getClientId()); + boolean hasTlsClientAuthCa = clientDetails.getAdditionalInformation() + .containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_USER_TOKEN) || @@ -61,7 +64,7 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_JWT_BEARER) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_TOKEN_EXCHANGE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_PASSWORD)) { - if (StringUtils.isBlank(clientDetails.getClientSecret())) { + if (!hasTlsClientAuthCa && StringUtils.isBlank(clientDetails.getClientSecret())) { throw new InvalidClientDetailsException("client_secret cannot be blank"); } clientSecretValidator.validate(clientDetails.getClientSecret()); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index f6155ddab21..a1b01074aaa 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -22,8 +22,10 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.oauth.client.ClientConstants.ALLOWED_PROVIDERS; +import static org.mockito.Mockito.verify; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_JWT_BEARER; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_REFRESH_TOKEN; @@ -148,6 +150,35 @@ void allowsTlsClientAuthCaWhenMtlsEnabled() { .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); } + @Test + void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaConfigured() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); + } + + @Test + void stillValidatesSuppliedSecretWhenTlsClientAuthCaConfigured() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("supplied-secret"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + clientDetails.setAdditionalInformation(additionalInfo); + + zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); + + verify(mockClientSecretValidator).validate("supplied-secret"); + } + @Test void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, false); From 852b28bbdbdb226defa76ded6862e1671193e959 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 15:18:38 +0200 Subject: [PATCH 081/130] fix(review): validate tls-client-auth-claim-mappings/templates/required-claims at client creation Malformed tls-client-auth-claim-mappings was previously accepted without any validation, deferring failure to actual token-issuance time: a missing field throws NullPointerException in TlsClientAuthentication.extractClaimMappingValues's switch expression (a null selector isn't caught by 'default'), a missing claim reaches key.indexOf('.') in MtlsClaimsEnhancer's PHASE 2 (NullPointerException), and an invalid regex pattern throws PatternSyntaxException in matchFirstOu -- all unhandled, turning legitimate token requests into 500 errors. ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig now validates, at client creation/update time: each claim-mapping's field is one of the three recognized values, each claim name is nonblank, each pattern (if present) compiles as valid regex, every {placeholder} in tls-client-auth-sub-template/ aud-templates references a claim actually produced by some claim-mapping, and every tls-client-auth-required-claims key references a produced claim (an unreferenced required-claim key could never be satisfied, permanently locking out the client). Called from both ClientAdminEndpointsValidator and ZoneEndpointsClientDetailsValidator, alongside their existing checkMtlsClientConfigAllowed calls. Addresses PR review comment on ClientAdminEndpointsValidator.java:365. --- .../client/ClientAdminEndpointsValidator.java | 163 ++++++++++++++++++ .../ZoneEndpointsClientDetailsValidator.java | 2 + .../ClientAdminEndpointsValidatorTests.java | 125 ++++++++++++++ 3 files changed, 290 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index cd0ed9733db..32833f7c815 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -29,14 +29,20 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import tools.jackson.core.type.TypeReference; + import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; @@ -129,6 +135,7 @@ public ClientDetails validate(ClientDetails prototype, boolean create, boolean c client.setAdditionalInformation(prototype.getAdditionalInformation()); checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled, client.getClientId()); + validateTlsClientAuthClaimConfig(client.getAdditionalInformation(), client.getClientId()); String clientId = client.getClientId(); if (create) { @@ -369,6 +376,162 @@ public static void checkMtlsClientConfigAllowed(Map additionalIn } } + /** + * Mirrors {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer}'s private + * {@code PLACEHOLDER} field -- must be kept in sync with it. + */ + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); + + /** + * Validates a client's mTLS claim-related configuration ({@code tls-client-auth-claim-mappings}, + * {@code tls-client-auth-sub-template}, {@code tls-client-auth-aud-templates}, and + * {@code tls-client-auth-required-claims}) at client creation/update time, before it is + * persisted, so malformed configuration is rejected here rather than causing an unhandled + * exception at token-issuance time (see {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#extractClaimMappingValues} + * and {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer#enhance}). + * + *

A no-op when {@code additionalInfo} is {@code null} or has no + * {@code tls-client-auth-claim-mappings} key at all. + */ + public static void validateTlsClientAuthClaimConfig(Map additionalInfo, String clientId) { + if (additionalInfo == null + || !additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS)) { + return; + } + + List claimMappings; + try { + Object rawMappings = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings instanceof String mappingsJson) { + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } else { + claimMappings = JsonUtils.readValue( + JsonUtils.writeValueAsString(rawMappings), + new TypeReference>() {}); + } + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-claim-mappings for client_id=" + clientId + ": " + e.getMessage(), e); + } + + if (claimMappings == null) { + return; + } + + Set declaredClaims = new HashSet<>(); + for (TlsClientAuthConfiguration.ClaimMapping mapping : claimMappings) { + if (mapping == null) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry cannot be null for client_id=" + clientId); + } + String field = mapping.getField(); + if (field == null + || !(field.equals("subject_cn") || field.equals("subject_ou") || field.equals("subject_o"))) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry has invalid field '" + field + + "' for client_id=" + clientId + + ". Must be one of: subject_cn, subject_ou, subject_o"); + } + String claim = mapping.getClaim(); + if (claim == null || claim.isBlank()) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry has a blank claim for client_id=" + clientId); + } + String pattern = mapping.getPattern(); + if (pattern != null && !pattern.isBlank()) { + try { + Pattern.compile(pattern); + } catch (PatternSyntaxException e) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry has an invalid pattern '" + pattern + + "' for client_id=" + clientId + ": " + e.getMessage(), e); + } + } + declaredClaims.add(claim); + } + + Object rawSubTemplate = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + if (rawSubTemplate instanceof String subTemplate && !subTemplate.isBlank()) { + validateTemplatePlaceholders(subTemplate, declaredClaims, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, clientId); + } + + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES)) { + List audTemplates; + try { + Object rawAudTemplates = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + if (rawAudTemplates instanceof String audJson) { + audTemplates = JsonUtils.readValue(audJson, new TypeReference>() {}); + } else { + audTemplates = JsonUtils.readValue( + JsonUtils.writeValueAsString(rawAudTemplates), + new TypeReference>() {}); + } + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-aud-templates for client_id=" + clientId + ": " + e.getMessage(), e); + } + if (audTemplates != null) { + for (String template : audTemplates) { + if (template != null && !template.isBlank()) { + validateTemplatePlaceholders(template, declaredClaims, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, clientId); + } + } + } + } + + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS)) { + Map requiredClaims; + try { + Object rawRequiredClaims = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + if (rawRequiredClaims instanceof String requiredClaimsJson) { + requiredClaims = JsonUtils.readValue(requiredClaimsJson, + new TypeReference>() {}); + } else { + requiredClaims = JsonUtils.readValue( + JsonUtils.writeValueAsString(rawRequiredClaims), + new TypeReference>() {}); + } + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-required-claims for client_id=" + clientId + ": " + e.getMessage(), e); + } + if (requiredClaims != null) { + for (String requiredClaim : requiredClaims.keySet()) { + if (!declaredClaims.contains(requiredClaim)) { + throw new InvalidClientDetailsException( + "tls-client-auth-required-claims references undeclared claim '" + requiredClaim + + "' for client_id=" + clientId + + ". Every required claim must be produced by a tls-client-auth-claim-mappings entry."); + } + } + } + } + } + + /** + * Validates that every {@code {placeholderName}} referenced in {@code template} is present in + * {@code declaredClaims} -- i.e. actually produced by some {@code tls-client-auth-claim-mappings} + * entry -- otherwise the placeholder could never be resolved by + * {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer#renderTemplate}, silently + * dropping the whole template at token-issuance time. + */ + private static void validateTemplatePlaceholders( + String template, Set declaredClaims, String propertyName, String clientId) { + Matcher m = PLACEHOLDER.matcher(template); + while (m.find()) { + String placeholder = m.group(1); + if (!declaredClaims.contains(placeholder)) { + throw new InvalidClientDetailsException( + propertyName + " references undeclared claim placeholder '{" + placeholder + + "}' for client_id=" + clientId + + ". Every placeholder must be produced by a tls-client-auth-claim-mappings entry."); + } + } + } + @Override public ClientSecretValidator getClientSecretValidator() { return this.clientSecretValidator; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 39543b3fd01..5979853ce3a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -16,6 +16,7 @@ import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkRequestedGrantTypes; +import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_JWT_BEARER; @@ -54,6 +55,7 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv } checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled, clientDetails.getClientId()); + validateTlsClientAuthClaimConfig(clientDetails.getAdditionalInformation(), clientDetails.getClientId()); boolean hasTlsClientAuthCa = clientDetails.getAdditionalInformation() .containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 3707ba69ef2..44308b6301e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -374,4 +374,129 @@ void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { assertThat(validated.getClientId()).isEqualTo(client.getClientId()); } + + @Test + void validateTlsClientAuthClaimConfig_noOpWhenNoClaimMappingsKey() { + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(Map.of(), "client-id")); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsValidNativeClaimMappings() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid", "pattern", "^(.+)$")) + ); + + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsValidJsonStringClaimMappings() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + "[{\"field\":\"subject_cn\",\"claim\":\"cf_instance_guid\"}]" + ); + + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsMissingField() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("claim", "cf_instance_guid")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsUnrecognizedField() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_email", "claim", "cf_instance_guid")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsBlankClaim() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", " ")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsInvalidRegexPattern() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "claim", "cf_org", "pattern", "[")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsSubTemplateReferencingUndeclaredClaim() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{cf_undeclared}"); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsAudTemplateReferencingUndeclaredClaim() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + List.of("https://valid.example.com/{cf_undeclared}")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsReferencingUndeclaredClaim() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_undeclared", "some-value")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsFullyValidConfig() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of( + Map.of("field", "subject_cn", "claim", "cf_instance_guid"), + Map.of("field", "subject_ou", "claim", "cf_org", "pattern", "^org:(.+)$") + )); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{cf_instance_guid}"); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + List.of("https://valid.example.com/{cf_org}")); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_org", "myorg")); + + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); + } } From 6f99d7036146995125e7b32ddf578ab5cec49a6a Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 15:47:05 +0200 Subject: [PATCH 082/130] fix(review): reject null/blank tls-client-auth-required-claims values The previous validation only checked that required-claims KEYS reference a declared claim -- it never checked that VALUES are non-null. A null value reaches TlsClientAuthentication.certificateSatisfiesRequiredClaims's required.getValue().equals(...) on EVERY authentication attempt for that client, throwing an unhandled NullPointerException per-request rather than once at client creation/update time -- a worse blast radius than the original bugs this validation exists to prevent. Also strengthens the JSON-string-vs-native-object test to parse identical logical claim-mapping data via both shapes, genuinely proving parsing equivalence rather than exercising each shape with different data. --- .../client/ClientAdminEndpointsValidator.java | 12 +++++-- .../ClientAdminEndpointsValidatorTests.java | 31 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 32833f7c815..28e0465848f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -499,13 +499,19 @@ public static void validateTlsClientAuthClaimConfig(Map addition "Invalid tls-client-auth-required-claims for client_id=" + clientId + ": " + e.getMessage(), e); } if (requiredClaims != null) { - for (String requiredClaim : requiredClaims.keySet()) { - if (!declaredClaims.contains(requiredClaim)) { + for (Map.Entry requiredClaim : requiredClaims.entrySet()) { + if (!declaredClaims.contains(requiredClaim.getKey())) { throw new InvalidClientDetailsException( - "tls-client-auth-required-claims references undeclared claim '" + requiredClaim + "tls-client-auth-required-claims references undeclared claim '" + requiredClaim.getKey() + "' for client_id=" + clientId + ". Every required claim must be produced by a tls-client-auth-claim-mappings entry."); } + String requiredValue = requiredClaim.getValue(); + if (requiredValue == null || requiredValue.isBlank()) { + throw new InvalidClientDetailsException( + "tls-client-auth-required-claims has a null or blank value for required claim '" + + requiredClaim.getKey() + "' for client_id=" + clientId); + } } } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 44308b6301e..2e1715c98d4 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -394,9 +394,13 @@ void validateTlsClientAuthClaimConfig_acceptsValidNativeClaimMappings() { @Test void validateTlsClientAuthClaimConfig_acceptsValidJsonStringClaimMappings() { + // Same logical field/claim/pattern data as + // validateTlsClientAuthClaimConfig_acceptsValidNativeClaimMappings, but supplied as a + // JSON string, to genuinely prove the two parsing shapes (native List/Map vs. JSON + // string) handle identical input equivalently. Map info = Map.of( TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, - "[{\"field\":\"subject_cn\",\"claim\":\"cf_instance_guid\"}]" + "[{\"field\":\"subject_cn\",\"claim\":\"cf_instance_guid\",\"pattern\":\"^(.+)$\"}]" ); assertThatNoException().isThrownBy(() -> @@ -482,6 +486,31 @@ void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsReferencingUndeclared .isInstanceOf(InvalidClientDetailsException.class); } + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsWithNullValue() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "claim", "cf_org"))); + Map requiredClaims = new java.util.HashMap<>(); + requiredClaims.put("cf_org", null); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, requiredClaims); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsWithBlankValue() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "claim", "cf_org"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_org", " ")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + @Test void validateTlsClientAuthClaimConfig_acceptsFullyValidConfig() { Map info = new java.util.HashMap<>(); From aff499db74d166f7a6fc6dbd68b0ddfc1d66a50d Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 16:33:00 +0200 Subject: [PATCH 083/130] fix(review): use possessive quantifier in PLACEHOLDER regex to prevent ReDoS Pattern.compile("\\{([^}]+)\\}") in both ClientAdminEndpointsValidator and MtlsClaimsEnhancer backtracks character-by-character on unmatched braces (e.g. many consecutive '{' with no closing '}'), degrading to roughly O(n^2) across Matcher.find()'s repeated scan attempts -- a polynomial-time denial-of-service vector on operator-controlled tls-client-auth-sub-template/aud-templates values (CodeQL: js/polynomial-redos, flagged on the newly-added ClientAdminEndpointsValidator occurrence). [^}]+ -> [^}]++ (possessive quantifier) eliminates backtracking entirely, making every match attempt provably linear-time, with no change in matching result for any well-formed input. Adds a timing-based test (ClientAdminEndpointsValidatorTests) proving the fixed regex completes quickly on a pathological input of many unmatched '{' characters. --- .../client/ClientAdminEndpointsValidator.java | 2 +- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 2 +- .../ClientAdminEndpointsValidatorTests.java | 23 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 28e0465848f..215931aa30b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -380,7 +380,7 @@ public static void checkMtlsClientConfigAllowed(Map additionalIn * Mirrors {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer}'s private * {@code PLACEHOLDER} field -- must be kept in sync with it. */ - private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]++)\\}"); /** * Validates a client's mTLS claim-related configuration ({@code tls-client-auth-claim-mappings}, diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 95e85dfee65..ee5e54e913f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -39,7 +39,7 @@ @Component public class MtlsClaimsEnhancer implements UaaTokenEnhancer { - private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]++)\\}"); private final TlsClientAuthentication tlsClientAuthentication; private final ClientDetailsService clientDetailsService; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 2e1715c98d4..8e563464eec 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -528,4 +528,27 @@ void validateTlsClientAuthClaimConfig_acceptsFullyValidConfig() { assertThatNoException().isThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); } + + @Test + void validateTlsClientAuthClaimConfig_subTemplatePlaceholderRegexIsNotPolynomial() { + // CodeQL: js/polynomial-redos on the PLACEHOLDER regex (\{([^}]+)\}). A pathological + // sub-template of many consecutive unmatched '{' characters forces Matcher.find() to + // retry a failed match at every position; with the greedy [^}]+ quantifier each failed + // attempt also backtracks character-by-character, degrading to roughly O(n^2) (measured + // locally: ~160ms for n=10000 with [^}]+ vs. ~25ms with the possessive [^}]++ fix). This + // asserts the fixed, possessive-quantifier regex comfortably completes well within a + // generous bound (chosen to avoid flakiness under CI/parallel-test-suite load) that the + // unfixed regex would meaningfully exceed. + String pathologicalSubTemplate = "{".repeat(10_000); + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, pathologicalSubTemplate); + + long start = System.nanoTime(); + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id"); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + assertThat(elapsedMillis).isLessThan(3000); + } } From 333a446af6351f55e6f3f665dcf0ad9dee4ec6ca Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 16:33:21 +0200 Subject: [PATCH 084/130] fix(review): fail token issuance instead of silently dropping cnf on cert-encoding failure enhance() previously caught any exception from cert.getEncoded()/SHA-256 digesting and silently omitted the cnf.x5t#S256 confirmation claim, allowing token issuance to continue -- silently downgrading a certificate-bound (RFC 8705 sec:3.1 sender-constrained) mTLS token into an ordinary, unbound bearer token on what should be a practically-impossible encoding failure. Now rethrows as IllegalStateException, which (per the same unguarded enhancer-loop precedent established for the client-lookup fail-open fix in 1f39eaf08) propagates through enhance() and fails the whole token request instead of silently issuing an unbound one. Also fixes extractsClaimsFromCertOuFields, a pre-existing test that didn't stub cert.getEncoded() and was incidentally relying on the removed catch-all to swallow the resulting NullPointerException from MessageDigest.digest(null). Addresses PR review comment on MtlsClaimsEnhancer.java:135. --- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 13 +++++++-- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index ee5e54e913f..d0f5af3057a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -13,6 +13,8 @@ import tools.jackson.core.type.TypeReference; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Base64; @@ -130,8 +132,15 @@ public Map enhance(Map claims, OAuth2Authenticat byte[] sha256 = MessageDigest.getInstance("SHA-256").digest(derEncoded); String thumbprint = Base64.getUrlEncoder().withoutPadding().encodeToString(sha256); result.put("cnf", Map.of("x5t#S256", thumbprint)); - } catch (Exception ignored) { - // Silently skip cnf claim if cert encoding fails + } catch (CertificateEncodingException | NoSuchAlgorithmException e) { + // Fail closed: an already-validated peer certificate should always be re-encodable, + // and SHA-256 is a guaranteed JCE algorithm, so this is practically impossible. If it + // ever happens, we must not silently issue an unbound bearer token in place of a + // certificate-bound (RFC 8705 §3.1) one -- fail the whole token request instead (same + // fail-closed philosophy as the client-details lookup failure above). + throw new IllegalStateException( + "Failed to compute cnf.x5t#S256 confirmation claim for client_id=" + + clientId + ": " + e.getMessage(), e); } // PHASE 3 — template rendering for sub and aud diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index a2cac11c9c0..eaf4cc89bee 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -13,6 +13,7 @@ import javax.security.auth.x500.X500Principal; import java.io.Serializable; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.HashMap; import java.util.List; @@ -49,6 +50,7 @@ void setUp() { @Test void extractsClaimsFromCertOuFields() throws Exception { X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); when(cert.getSubjectX500Principal()).thenReturn( new X500Principal("CN=instance-guid, OU=app:app-guid-123, OU=space:space-guid-456, OU=organization:org-guid-789, O=Cloud Foundry")); when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); @@ -538,6 +540,31 @@ void enhancePropagatesExceptionWhenClientDetailsLookupFails() throws Exception { .hasMessage("db unavailable"); } + @Test + void enhanceThrowsWhenCertEncodingFailsInsteadOfSilentlyDroppingCnfClaim() throws Exception { + // PR review concern (MtlsClaimsEnhancer.java:135): a failure to DER-encode the cert or + // compute its SHA-256 digest must fail the whole token request closed, not silently + // downgrade a certificate-bound (RFC 8705 sec:3.1) token into an unbound bearer token by + // dropping the cnf claim. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenThrow(new CertificateEncodingException("boom")); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration( + new TlsClientAuthConfiguration("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", null)); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + + assertThatThrownBy(() -> enhancer.enhance(new HashMap<>(), auth)) + .isInstanceOf(IllegalStateException.class) + .hasCauseInstanceOf(CertificateEncodingException.class); + } + private X509Certificate mockCfCert() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); From c4d8aee44e95d5d4796469855d524ce32bd08bdf Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 21 Aug 2026 16:50:24 +0200 Subject: [PATCH 085/130] fix(review): bound template length to actually close the ReDoS gap The possessive-quantifier change in aff499db7 only reduced the constant factor (~3x) for the flagged CodeQL polynomial-regex finding -- it did not change the underlying O(n^2) complexity, since Matcher.find() retries the full match attempt at every character position regardless of quantifier possessive-ness. Independently benchmarked and confirmed: at n=100,000 unmatched '{' characters, the 'fixed' regex still took ~7.5s. The prior timing test provided no real regression protection (it still passed even with the original, unfixed regex reverted back in). The actual fix: bound template length BEFORE it reaches the regex. ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig now rejects any tls-client-auth-sub-template/aud-templates entry exceeding MAX_TEMPLATE_LENGTH at client creation/update time. MtlsClaimsEnhancer's renderTemplate independently applies the same bound at token-issuance time (returning null, i.e. silently dropping the oversized template, consistent with its existing contract for unresolved placeholders) -- covering BOSH-flat-config-bootstrapped clients, which bypass admin-API validation entirely. Replaces the previous, ineffective timing-based test with a deterministic functional test asserting oversized templates are rejected/dropped. --- .../client/ClientAdminEndpointsValidator.java | 28 +++++++++ .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 20 +++++++ .../ClientAdminEndpointsValidatorTests.java | 58 ++++++++++++++----- .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 27 +++++++++ 4 files changed, 120 insertions(+), 13 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 215931aa30b..25516ebbae6 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -382,6 +382,20 @@ public static void checkMtlsClientConfigAllowed(Map additionalIn */ private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]++)\\}"); + /** + * Bounds the length of {@code tls-client-auth-sub-template} / {@code tls-client-auth-aud-templates} + * entries before they are ever passed to {@link #PLACEHOLDER}'s regex. This is the actual fix for + * the CodeQL "polynomial regular expression on uncontrolled data" finding: {@link Matcher#find()} + * retries the full match attempt at every character position in the input, giving O(n^2) worst-case + * cost regardless of the possessive-ness of the {@code [^}]++} quantifier. Bounding input length + * caps the worst case at a small, fixed constant. 256 is generous -- real templates (e.g. + * {@code "o/{cf.org}/s/{cf.space}/a/{cf.app}"}) are a few dozen characters at most. + * + *

Mirrored in {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer#MAX_TEMPLATE_LENGTH} + * for defense-in-depth on the BOSH-flat-config bootstrap path, which bypasses this validator entirely. + */ + static final int MAX_TEMPLATE_LENGTH = 256; + /** * Validates a client's mTLS claim-related configuration ({@code tls-client-auth-claim-mappings}, * {@code tls-client-auth-sub-template}, {@code tls-client-auth-aud-templates}, and @@ -453,6 +467,7 @@ public static void validateTlsClientAuthClaimConfig(Map addition Object rawSubTemplate = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); if (rawSubTemplate instanceof String subTemplate && !subTemplate.isBlank()) { + checkTemplateLength(subTemplate, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, clientId); validateTemplatePlaceholders(subTemplate, declaredClaims, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, clientId); } @@ -475,6 +490,7 @@ public static void validateTlsClientAuthClaimConfig(Map addition if (audTemplates != null) { for (String template : audTemplates) { if (template != null && !template.isBlank()) { + checkTemplateLength(template, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, clientId); validateTemplatePlaceholders(template, declaredClaims, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, clientId); } @@ -517,6 +533,18 @@ public static void validateTlsClientAuthClaimConfig(Map addition } } + /** + * Rejects templates longer than {@link #MAX_TEMPLATE_LENGTH} before they reach the + * {@link #PLACEHOLDER} regex -- see {@link #MAX_TEMPLATE_LENGTH}'s javadoc for why. + */ + private static void checkTemplateLength(String template, String propertyName, String clientId) { + if (template.length() > MAX_TEMPLATE_LENGTH) { + throw new InvalidClientDetailsException( + propertyName + " for client_id=" + clientId + " has length " + template.length() + + ", which exceeds the maximum allowed length of " + MAX_TEMPLATE_LENGTH + "."); + } + } + /** * Validates that every {@code {placeholderName}} referenced in {@code template} is present in * {@code declaredClaims} -- i.e. actually produced by some {@code tls-client-auth-claim-mappings} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index d0f5af3057a..5aa5cbc62ac 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -43,6 +43,17 @@ public class MtlsClaimsEnhancer implements UaaTokenEnhancer { private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]++)\\}"); + /** + * Bounds template length before it reaches {@link #PLACEHOLDER}'s regex -- see + * {@link org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator}'s + * {@code MAX_TEMPLATE_LENGTH} javadoc for the full rationale (same value, duplicated here + * because the two classes are in different packages/modules; kept in sync by convention, + * same as {@code PLACEHOLDER} itself). This guard is defense-in-depth for clients configured + * via the BOSH-flat-config bootstrap path ({@link #loadTlsConfig}), which bypasses + * ClientAdminEndpointsValidator's admin-API-time validation entirely. + */ + static final int MAX_TEMPLATE_LENGTH = 256; + private final TlsClientAuthentication tlsClientAuthentication; private final ClientDetailsService clientDetailsService; @@ -174,8 +185,17 @@ public Map enhance(Map claims, OAuth2Authenticat * *

Variable names may contain dots (e.g. {@code {cf.org}}); dots inside braces * are treated as part of the name, not as path separators. + * + *

Returns {@code null} without attempting to match if {@code template} exceeds + * {@link #MAX_TEMPLATE_LENGTH}, treating an oversized template the same as an unresolvable + * one (silently dropped by the caller) rather than a hard failure -- a hard failure here + * would break every future token request for a client with a pre-existing, already-persisted + * oversized template. */ private String renderTemplate(String template, Map vars) { + if (template.length() > MAX_TEMPLATE_LENGTH) { + return null; + } StringBuilder sb = new StringBuilder(); Matcher m = PLACEHOLDER.matcher(template); while (m.find()) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 8e563464eec..ad720d9394f 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -530,25 +530,57 @@ void validateTlsClientAuthClaimConfig_acceptsFullyValidConfig() { } @Test - void validateTlsClientAuthClaimConfig_subTemplatePlaceholderRegexIsNotPolynomial() { - // CodeQL: js/polynomial-redos on the PLACEHOLDER regex (\{([^}]+)\}). A pathological - // sub-template of many consecutive unmatched '{' characters forces Matcher.find() to - // retry a failed match at every position; with the greedy [^}]+ quantifier each failed - // attempt also backtracks character-by-character, degrading to roughly O(n^2) (measured - // locally: ~160ms for n=10000 with [^}]+ vs. ~25ms with the possessive [^}]++ fix). This - // asserts the fixed, possessive-quantifier regex comfortably completes well within a - // generous bound (chosen to avoid flakiness under CI/parallel-test-suite load) that the - // unfixed regex would meaningfully exceed. - String pathologicalSubTemplate = "{".repeat(10_000); + void validateTlsClientAuthClaimConfig_rejectsSubTemplateExceedingMaxLength() { + // CodeQL: js/polynomial-redos on the PLACEHOLDER regex (\{([^}]+)\}). The possessive + // quantifier fix ([^}]++) only reduces the constant factor -- Matcher.find() still + // retries the full match attempt at every character position, so the real fix is to + // bound the input length before it ever reaches the regex. + String oversizedSubTemplate = "{".repeat(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH + 1); Map info = new java.util.HashMap<>(); info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); - info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, pathologicalSubTemplate); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, oversizedSubTemplate); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client-id") + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE) + .hasMessageContaining(String.valueOf(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH)); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsAudTemplateExceedingMaxLength() { + String oversizedAudTemplate = "{".repeat(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH + 1); + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, List.of(oversizedAudTemplate)); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client-id") + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES) + .hasMessageContaining(String.valueOf(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH)); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsSubTemplateAtExactlyMaxLength() { + // A pathological all-'{' template of exactly MAX_TEMPLATE_LENGTH characters must still + // be processed quickly, confirming the bound (combined with the possessive quantifier) + // makes this genuinely fast rather than merely rejected. + String maxLengthSubTemplate = "{".repeat(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH); + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, maxLengthSubTemplate); long start = System.nanoTime(); - ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id"); + // The all-'{' template never closes a placeholder, so no undeclared-placeholder + // exception is thrown -- validateTemplatePlaceholders() simply finds no matches. + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); long elapsedMillis = (System.nanoTime() - start) / 1_000_000; - assertThat(elapsedMillis).isLessThan(3000); + assertThat(elapsedMillis).isLessThan(100); } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index eaf4cc89bee..8c3aa9911c5 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -565,6 +565,33 @@ void enhanceThrowsWhenCertEncodingFailsInsteadOfSilentlyDroppingCnfClaim() throw .hasCauseInstanceOf(CertificateEncodingException.class); } + @Test + void subOmittedWhenTemplateExceedsMaxLength() throws Exception { + // Defense-in-depth: covers BOSH-flat-config-bootstrapped clients, which bypass + // ClientAdminEndpointsValidator's admin-API-time length check entirely. An oversized + // template must be dropped (not hang or throw), consistent with renderTemplate's + // existing "unresolved placeholder" contract. + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + String oversizedSubTemplate = "{".repeat(MtlsClaimsEnhancer.MAX_TEMPLATE_LENGTH + 1) + "cf.org}"; + config.setSubTemplate(oversizedSubTemplate); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + long start = System.nanoTime(); + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + assertThat(result).doesNotContainKey("sub"); + assertThat(elapsedMillis).isLessThan(100); + } + private X509Certificate mockCfCert() throws Exception { X509Certificate cert = mock(X509Certificate.class); when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); From 1aeffdfd3198a0e6b2fdebea02a380b35b9081b3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 09:10:51 +0200 Subject: [PATCH 086/130] fix(review): apply end-entity certificate constraints to the trusted-proxy leaf isCertificateFromTrustedProxy only performed PKIX path validation on the genuine TLS peer's certificate (e.g. the Gorouter's backend mTLS cert) -- unlike validateClientCert (fixed in an earlier review round), it never applied validateEndEntityConstraints afterward. Because the connector's trust manager accepts any certificate at the TLS layer (certificateVerification=optionalNoCA), a CA certificate or a certificate whose Extended Key Usage excludes client authentication could still be accepted as the trusted XFCC proxy's own credential. Now calls validateEndEntityConstraints on the validated peer leaf, exactly mirroring the check already applied in validateClientCert -- integrates cleanly with the method's existing broad catch-and-log-warn-return-false handling, no new exception wiring needed. Addresses PR review comment on TlsClientAuthentication.java:204. --- .../oauth/tls/TlsClientAuthentication.java | 19 +++- .../tls/TlsClientAuthenticationTest.java | 107 ++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index c07331eb0b6..06da79b7270 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -180,9 +180,18 @@ public X509Certificate[] getCertificateChainFromRequest(TlsClientAuthConfigurati * at all ({@code certificateVerification=optionalNoCA}), so there is no static allowlist that could * drift out of sync with this per-client value. * + *

Like {@link #validateClientCert(X509Certificate[], TlsClientAuthConfiguration)}, PKIX path + * validation alone does not enforce that the validated peer leaf is actually meant to be used as a + * TLS client authentication credential -- since the connector's trust manager accepts any certificate + * at the TLS layer, a CA certificate or a certificate whose Key Usage/Extended Key Usage extensions + * explicitly exclude client authentication could otherwise still be accepted as the trusted proxy's + * own credential. This method therefore also applies {@link #validateEndEntityConstraints} to the + * validated peer leaf, returning {@code false} (via the catch-all below) if it fails. + * * @return {@code false} if {@code clientConfig} is {@code null}, has no - * {@code tls-client-auth-trusted-proxy-ca} configured, or there is no current request or no - * captured peer certificate + * {@code tls-client-auth-trusted-proxy-ca} configured, there is no current request or no + * captured peer certificate, the peer certificate does not validate against the configured + * proxy CA, or the validated peer leaf fails an end-entity constraint check */ public boolean isCertificateFromTrustedProxy(TlsClientAuthConfiguration clientConfig) { String trustedProxyCaPem = clientConfig != null ? clientConfig.getTrustedProxyCaPem() : null; @@ -201,7 +210,11 @@ public boolean isCertificateFromTrustedProxy(TlsClientAuthConfiguration clientCo } try { X509Certificate caCert = parsePemCertificate(trustedProxyCaPem); - return validateCertPath(peerChain, caCert).isPresent(); + Optional validated = validateCertPath(peerChain, caCert); + if (validated.isPresent()) { + validateEndEntityConstraints(validated.get()); + } + return validated.isPresent(); } catch (Exception e) { logger.warn("isCertificateFromTrustedProxy: peer certificate did not validate against " + "tls-client-auth-trusted-proxy-ca: {}", e.getMessage()); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index f35383efdbe..19a5e89ae04 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -419,6 +419,113 @@ void isCertificateFromTrustedProxyFalseWhenClientHasNoTrustedProxyCaConfigured() } } + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertIsItselfACaCertificate() throws Exception { + // Mirrors validateClientCertRejectsLeafThatIsItselfACaCertificate, but for the trusted- + // proxy leaf: PKIX path validation alone would succeed (a 1-cert chain from the + // intermediate to the root trust anchor is structurally valid), but the presented + // end-entity is itself a CA certificate, not a genuine proxy TLS credential. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair interKp = generateKeyPair(); + X500Name interName = new X500Name("CN=Trusted Proxy Intermediate CA"); + X509Certificate intermediateCaCert = signCert( + interName, rootName, interKp.getPublic(), rootKp.getPrivate(), true, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{intermediateCaCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertExtendedKeyUsageExcludesClientAuth() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), + false, BigInteger.TWO, null, List.of(KeyPurposeId.id_kp_serverAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertKeyUsageExcludesDigitalSignature() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + // Key Usage present but only keyEncipherment -- digitalSignature explicitly excluded. + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), + false, BigInteger.TWO, KeyUsage.keyEncipherment, null); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyTrueWhenPeerCertHasClientAuthExtendedKeyUsage() throws Exception { + // Positive control: a genuinely valid, EKU-restricted-to-clientAuth proxy leaf must + // still be accepted -- no regression to the happy path from adding end-entity checks. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), + false, BigInteger.TWO, null, List.of(KeyPurposeId.id_kp_clientAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isTrue(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + @Test void isCertificateFromTrustedProxyFalseWhenConfigIsNull() { MockHttpServletRequest request = new MockHttpServletRequest(); From 98835113183e089af39311507da408a561658206 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 09:11:02 +0200 Subject: [PATCH 087/130] fix(review): fail fast if an existing BCJSSE provider isn't genuinely FIPS ensureJsseProviderRegistered previously treated the mere presence of a provider named BCJSSE as sufficient -- if some other, non-FIPS provider had already been registered under that name (e.g. via JVM-wide java.security configuration), this method silently kept it, silently defeating the connector's promised FIPS guarantee. Now verifies an existing same-named provider is genuinely an instance of BouncyCastleJsseProvider with isFipsMode() true, throwing IllegalStateException otherwise rather than silently proceeding. Addresses PR review comment on MtlsClientAuthTomcatCustomizer.java:109. --- .../MtlsClientAuthTomcatCustomizer.java | 23 +++++++++++- .../MtlsClientAuthTomcatCustomizerTest.java | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index 790512fcacc..8c57ad68a2d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -9,6 +9,7 @@ import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.stereotype.Component; +import java.security.Provider; import java.security.Security; /** @@ -98,14 +99,34 @@ public void customize(TomcatServletWebServerFactory factory) { * crypto provider first is required: {@code BouncyCastleJsseProvider} built in FIPS mode binds * to it, which is what makes {@code SSLContext.getInstance("TLS", "BCJSSE")} usable (and is what * supplies FIPS-compliant {@code SecureRandom}s for the TLS handshake). + * + *

If a provider is already registered under the {@code BCJSSE} name -- e.g. via the JVM's + * {@code java.security} configuration file, or some other library -- its mere presence is not + * sufficient: it must genuinely be a FIPS-mode {@link BouncyCastleJsseProvider}, or this connector's + * promised FIPS guarantee (documented on this class) would be silently defeated. Fails fast with + * {@link IllegalStateException} rather than silently proceeding with a wrong/non-FIPS provider. + * + * @throws IllegalStateException if a provider already registered under the {@code BCJSSE} name is + * not a {@link BouncyCastleJsseProvider}, or is one but not in FIPS mode */ static void ensureJsseProviderRegistered() { if (Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleFipsProvider()); } - if (Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME) == null) { + Provider existingJsseProvider = Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + if (existingJsseProvider == null) { Security.addProvider(new BouncyCastleJsseProvider(true, Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME))); + return; + } + if (!(existingJsseProvider instanceof BouncyCastleJsseProvider bcJsseProvider) + || !bcJsseProvider.isFipsMode()) { + throw new IllegalStateException( + "uaa.mtls-enabled requires the FIPS BouncyCastleJsseProvider registered under the name '" + + BouncyCastleJsseProvider.PROVIDER_NAME + + "', but a different provider is already registered under that name: " + + existingJsseProvider.getClass().getName() + + " -- refusing to silently proceed without the promised FIPS guarantee"); } } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java index ddd7aafa0b7..ba05e3b386a 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import java.security.Provider; import java.security.Security; import static org.assertj.core.api.Assertions.assertThat; @@ -93,4 +94,40 @@ void registersTheFipsBouncyCastleJsseProviderIdempotently() { (BouncyCastleJsseProvider) Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); assertThat(registered.isFipsMode()).isTrue(); } + + @Test + void failsFastWhenAnExistingNonFipsProviderIsAlreadyRegisteredUnderTheBcjsseName() { + // Simulates a non-FIPS provider having already claimed the "BCJSSE" provider name (e.g. + // via JVM-wide java.security configuration) before this method runs -- must not be + // silently trusted as the genuine FIPS BouncyCastleJsseProvider. + // + // Security.addProvider(Provider) is a no-op (returns -1) if a provider with the same + // name is already registered -- and since Security providers are global, JVM-wide state + // with no automatic teardown, another test in this class (or a prior run of this same + // customizer) may have already registered the genuine FIPS provider under this name. + // Remove any existing registration first so the impostor is guaranteed to actually take + // its place, regardless of test execution order. + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + Provider impostor = new Provider(BouncyCastleJsseProvider.PROVIDER_NAME, "1.0", "not actually BCJSSE") { + }; + Security.addProvider(impostor); + try { + assertThatThrownBy(MtlsClientAuthTomcatCustomizer::ensureJsseProviderRegistered) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(BouncyCastleJsseProvider.PROVIDER_NAME) + .hasMessageContaining(impostor.getClass().getName()); + } finally { + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + } + } + + @Test + void succeedsWhenTheGenuineFipsProviderIsAlreadyRegisteredUnderTheBcjsseName() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + // Calling it again with the genuine FIPS provider already registered must not throw. + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + assertThat(Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isNotNull(); + } } From 225e57459cdd36ab161abbc9892faadbec671aea Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 09:11:12 +0200 Subject: [PATCH 088/130] docs(review): document uaa.mtls-enabled in the configuration reference Addresses PR review comment on MtlsClientAuthTomcatCustomizer.java:70. --- docs/UAA-Configuration-Reference.md | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 65aca8f4e6b..73759664bff 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -126,6 +126,7 @@ or `$CLOUDFOUNDRY_CONFIG_PATH/uaa.yml`. | `oauth.client.autoapprove` | `[]`| Clients auto-approved for all scopes| | `oauth.user.authorities` | (see details)| Default authorities for new users| | `clientMaxCount` | `500`| Max clients returned by list endpoint| +| `uaa.mtls-enabled` | `false`| Enables RFC 8705 mutual-TLS client authentication| ### Password Policy @@ -1221,6 +1222,36 @@ client admin API (`/oauth/clients`). --- +### `uaa.mtls-enabled` + +**Default:** `false` +**Source:** `@Value("${uaa.mtls-enabled:false}")` in [`SpringServletXmlBeansConfiguration`](../server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java), [`ClientAdminBootstrap`](../server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java), [`ZoneEndpointsClientDetailsValidator`](../server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java), [`MtlsClientAuthTomcatCustomizer`](../server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java) +**Type:** `boolean` + +Master switch enabling RFC 8705 mutual-TLS client authentication (`tls_client_auth`) +deployment-wide. This is **connector-wide**: it affects every TLS connection to this UAA +instance, not just requests to the mTLS token endpoint (`/oauth/mtls/token`). +[`SpringServletXmlBeansConfiguration`](../server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java) +also uses this value to wire +[`ClientAdminEndpointsValidator`](../server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java)'s +`mtlsEnabled` constructor argument. + +When `true`, the embedded Tomcat connector is reconfigured to request a client certificate +during every TLS handshake (`certificateVerification=optionalNoCA`), without validating it +against any CA at the transport layer -- the trust decision is deferred entirely to per-client +application logic (see [`docs/UAA-Client-Authentication.md`](UAA-Client-Authentication.md) for +the per-client `tls-client-auth-*` properties). Enabling this also switches the connector to the +FIPS BouncyCastle JSSE provider, required for TLS 1.3 client-certificate support (OpenJDK's JSSE +does not implement server-side TLS 1.3 post-handshake client-certificate requests). + +When `false` (the default), no client certificate is requested at the TLS layer at all, and any +client configured with `token-endpoint-auth-method: tls_client_auth` or a `tls-client-auth-ca` +property fails validation at creation/update time. + +[Back to table](#oauth-clients--users) + +--- + ### `password.policy.global.minLength` **Default:** `0` From 0e78d80fe5e850cdf8a6ecfcc6023554b3fb9747 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 09:55:46 +0200 Subject: [PATCH 089/130] fix(review): distinguish wrong-provider-class from wrong-FIPS-mode error messages ensureJsseProviderRegistered's fail-fast check previously produced one generic message ('a different provider is already registered') for both distinct failure modes -- a wrong provider class entirely, and a genuine BouncyCastleJsseProvider that simply isn't built in FIPS mode -- printing the correct class name even in the latter case, which read as contradictory to an operator debugging a startup failure. Now branches into two clearly worded IllegalStateExceptions. Adds a test covering the previously-untested 'right class, non-FIPS mode' branch. --- .../MtlsClientAuthTomcatCustomizer.java | 11 ++++++++-- .../MtlsClientAuthTomcatCustomizerTest.java | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index 8c57ad68a2d..98631889ed8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -119,8 +119,7 @@ static void ensureJsseProviderRegistered() { Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME))); return; } - if (!(existingJsseProvider instanceof BouncyCastleJsseProvider bcJsseProvider) - || !bcJsseProvider.isFipsMode()) { + if (!(existingJsseProvider instanceof BouncyCastleJsseProvider bcJsseProvider)) { throw new IllegalStateException( "uaa.mtls-enabled requires the FIPS BouncyCastleJsseProvider registered under the name '" + BouncyCastleJsseProvider.PROVIDER_NAME @@ -128,5 +127,13 @@ static void ensureJsseProviderRegistered() { + existingJsseProvider.getClass().getName() + " -- refusing to silently proceed without the promised FIPS guarantee"); } + if (!bcJsseProvider.isFipsMode()) { + throw new IllegalStateException( + "uaa.mtls-enabled requires the FIPS BouncyCastleJsseProvider registered under the name '" + + BouncyCastleJsseProvider.PROVIDER_NAME + + "', and a " + BouncyCastleJsseProvider.class.getName() + + " is indeed registered under that name, but it was not constructed in FIPS mode" + + " -- refusing to silently proceed without the promised FIPS guarantee"); + } } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java index ba05e3b386a..a029e0119ab 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -121,6 +121,27 @@ void failsFastWhenAnExistingNonFipsProviderIsAlreadyRegisteredUnderTheBcjsseName } } + @Test + void failsFastWhenTheGenuineProviderIsRegisteredButNotInFipsMode() { + // Simulates the correct BouncyCastleJsseProvider class having already been registered under + // the "BCJSSE" name, but constructed in non-FIPS mode -- a distinct failure mode from an + // entirely different provider class claiming the name (see the "impostor" test above). The + // error message must be specific to this case, not the generic "different provider" message. + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + BouncyCastleJsseProvider nonFipsProvider = new BouncyCastleJsseProvider(false); + Security.addProvider(nonFipsProvider); + try { + assertThatThrownBy(MtlsClientAuthTomcatCustomizer::ensureJsseProviderRegistered) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(BouncyCastleJsseProvider.PROVIDER_NAME) + .hasMessageContaining("not") + .hasMessageContaining("FIPS mode") + .hasMessageNotContaining("a different provider is already registered"); + } finally { + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + } + } + @Test void succeedsWhenTheGenuineFipsProviderIsAlreadyRegisteredUnderTheBcjsseName() { MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); From a7b77e310633bb46ba0772390bbec355cd3d2667 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 13:14:25 +0200 Subject: [PATCH 090/130] fix(review): only advertise tls_client_auth when mTLS is actually enabled OpenIdConfiguration's token_endpoint_auth_methods_supported unconditionally included tls_client_auth, regardless of uaa.mtls-enabled -- a discovery client on a deployment with mTLS disabled (the default) would select an authentication method this server cannot actually perform, since such deployments never request peer certificates at the TLS layer nor allow tls-client-auth-ca to be configured on any client. Adds a new 3-arg constructor overload accepting mtlsEnabled, which filters tls_client_auth out of tokenAMR when false. The existing 2-arg constructor is unchanged in behavior (delegates with mtlsEnabled=true), preserving all existing callers/tests. Addresses PR review comment on OpenIdConfiguration.java:25. --- .../identity/uaa/account/OpenIdConfiguration.java | 10 ++++++++++ .../uaa/account/OpenIdConfigurationTests.java | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java index e78d6e51fb3..01d236c4a61 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; +import java.util.Arrays; import java.util.Map; @Data @@ -75,11 +76,20 @@ public class OpenIdConfiguration { private Map mtlsEndpointAliases; public OpenIdConfiguration(final String contextPath, final String issuer) { + this(contextPath, issuer, true); + } + + public OpenIdConfiguration(final String contextPath, final String issuer, final boolean mtlsEnabled) { this.issuer = issuer; this.authUrl = contextPath + "/oauth/authorize"; this.tokenUrl = contextPath + "/oauth/token"; this.userInfoUrl = contextPath + "/userinfo"; this.jwksUri = contextPath + "/token_keys"; this.logoutEndpoint = contextPath + "/logout.do"; + if (!mtlsEnabled) { + this.tokenAMR = Arrays.stream(this.tokenAMR) + .filter(method -> !ClientAuthentication.TLS_CLIENT_AUTH.equals(method)) + .toArray(String[]::new); + } } } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java b/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java index 2599ac13d64..151bb679181 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java @@ -86,4 +86,19 @@ void tlsClientAuthIsInSupportedAuthMethods() { OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com"); assertThat(conf.getTokenAMR()).contains("tls_client_auth"); } + + @Test + void tlsClientAuthIsExcludedWhenMtlsDisabled() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com", false); + assertThat(conf.getTokenAMR()) + .containsExactlyInAnyOrder("client_secret_basic", "client_secret_post", "private_key_jwt") + .doesNotContain("tls_client_auth"); + } + + @Test + void tlsClientAuthIsIncludedWhenMtlsEnabled() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com", true); + assertThat(conf.getTokenAMR()) + .containsExactlyInAnyOrder("client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"); + } } From bf6149ae88c11924a0c48ff42a504680d9d5bfe2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 13:14:35 +0200 Subject: [PATCH 091/130] fix(review): only advertise the mtls_endpoint_aliases when mTLS is enabled getOpenIdConfiguration unconditionally set mtls_endpoint_aliases pointing at /oauth/mtls/token, even on deployments with uaa.mtls-enabled=false (the default), where that endpoint cannot actually authenticate a client via a certificate -- making the discovery document contradict the deployment's actual capabilities. OpenIdConnectEndpoints now takes a uaa.mtls-enabled-injected constructor argument, passed through to OpenIdConfiguration's new mtlsEnabled-aware constructor, and only sets mtls_endpoint_aliases when true. Addresses PR review comment on OpenIdConnectEndpoints.java:40. --- .../uaa/account/OpenIdConnectEndpoints.java | 11 ++++++++--- .../account/OpenIdConnectEndpointsTest.java | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java index 8ea13f7f65b..ff4b792e115 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java @@ -18,16 +18,19 @@ public class OpenIdConnectEndpoints { private final String issuer; private final IdentityZoneManager identityZoneManager; + private final boolean mtlsEnabled; @Value("${mtls.endpoint:/oauth/mtls/token}") private String mtlsEndpointPath = "/oauth/mtls/token"; public OpenIdConnectEndpoints( final @Value("${issuer.uri}") String issuer, - final IdentityZoneManager identityZoneManager + final IdentityZoneManager identityZoneManager, + final @Value("${uaa.mtls-enabled:false}") boolean mtlsEnabled ) { this.issuer = issuer; this.identityZoneManager = identityZoneManager; + this.mtlsEnabled = mtlsEnabled; } @GetMapping(value = { @@ -36,8 +39,10 @@ public OpenIdConnectEndpoints( }) public ResponseEntity getOpenIdConfiguration(HttpServletRequest request) throws URISyntaxException { String contextPath = getServerContextPath(request); - OpenIdConfiguration conf = new OpenIdConfiguration(contextPath, getTokenEndpoint()); - conf.setMtlsEndpointAliases(Map.of("token_endpoint", contextPath + mtlsEndpointPath)); + OpenIdConfiguration conf = new OpenIdConfiguration(contextPath, getTokenEndpoint(), mtlsEnabled); + if (mtlsEnabled) { + conf.setMtlsEndpointAliases(Map.of("token_endpoint", contextPath + mtlsEndpointPath)); + } return new ResponseEntity<>(conf, OK); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java index 8cd49cfdfef..9bd4c4e8eba 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java @@ -20,7 +20,7 @@ class OpenIdConnectEndpointsTest { void setUp() { mockIdentityZoneManager = mock(IdentityZoneManager.class); when(mockIdentityZoneManager.getCurrentIdentityZone()).thenReturn(IdentityZone.getUaa()); - endpoints = new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager); + endpoints = new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager, true); } @Test @@ -40,4 +40,20 @@ void mtlsEndpointAliasesIsPopulatedInDiscovery() throws Exception { assertThat(response.getBody().getMtlsEndpointAliases().get("token_endpoint")) .endsWith("/oauth/mtls/token"); } + + @Test + void mtlsEndpointAliasesIsAbsentWhenMtlsDisabled() throws Exception { + OpenIdConnectEndpoints mtlsDisabledEndpoints = + new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager, false); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + ResponseEntity response = mtlsDisabledEndpoints.getOpenIdConfiguration(request); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMtlsEndpointAliases()).isNull(); + } } From 3384e00319b81f8fc6e41770adf6328b850501ff Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 13:14:45 +0200 Subject: [PATCH 092/130] fix(review): fail fast if an existing BCFIPS provider isn't genuinely BouncyCastleFipsProvider ensureJsseProviderRegistered already verified an existing same-named BCJSSE provider (an earlier review round), but the BCFIPS crypto-provider registration just above it still only checked for presence under that name, not type -- the same provider-name substitution gap. BouncyCastleFipsProvider has no FIPS/non-FIPS mode distinction (unlike BouncyCastleJsseProvider) -- it IS inherently the FIPS-only crypto provider by construction -- so only an instanceof check is needed here, not a second mode check. Addresses PR review comment on MtlsClientAuthTomcatCustomizer.java:119. --- .../MtlsClientAuthTomcatCustomizer.java | 27 ++++++++---- .../MtlsClientAuthTomcatCustomizerTest.java | 41 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java index 98631889ed8..b0aa8fbf46a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -100,18 +100,31 @@ public void customize(TomcatServletWebServerFactory factory) { * to it, which is what makes {@code SSLContext.getInstance("TLS", "BCJSSE")} usable (and is what * supplies FIPS-compliant {@code SecureRandom}s for the TLS handshake). * - *

If a provider is already registered under the {@code BCJSSE} name -- e.g. via the JVM's - * {@code java.security} configuration file, or some other library -- its mere presence is not - * sufficient: it must genuinely be a FIPS-mode {@link BouncyCastleJsseProvider}, or this connector's + *

If a provider is already registered under the {@code BCFIPS} or {@code BCJSSE} name -- e.g. via + * the JVM's {@code java.security} configuration file, or some other library -- its mere presence is + * not sufficient: it must genuinely be the expected Bouncy Castle provider class, or this connector's * promised FIPS guarantee (documented on this class) would be silently defeated. Fails fast with - * {@link IllegalStateException} rather than silently proceeding with a wrong/non-FIPS provider. + * {@link IllegalStateException} rather than silently proceeding with a wrong/impostor provider. + * {@link BouncyCastleFipsProvider} is a {@code final} class with no FIPS/non-FIPS mode distinction -- + * it is inherently and only a FIPS-approved crypto provider by construction -- so only an + * {@code instanceof} check is needed for it, unlike {@link BouncyCastleJsseProvider}, which also + * needs its FIPS-mode flag verified. * - * @throws IllegalStateException if a provider already registered under the {@code BCJSSE} name is - * not a {@link BouncyCastleJsseProvider}, or is one but not in FIPS mode + * @throws IllegalStateException if a provider already registered under the {@code BCFIPS} name is + * not a {@link BouncyCastleFipsProvider}, or if a provider already registered under the + * {@code BCJSSE} name is not a {@link BouncyCastleJsseProvider}, or is one but not in FIPS mode */ static void ensureJsseProviderRegistered() { - if (Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME) == null) { + Provider existingFipsProvider = Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + if (existingFipsProvider == null) { Security.addProvider(new BouncyCastleFipsProvider()); + } else if (!(existingFipsProvider instanceof BouncyCastleFipsProvider)) { + throw new IllegalStateException( + "uaa.mtls-enabled requires the FIPS BouncyCastleFipsProvider registered under the name '" + + BouncyCastleFipsProvider.PROVIDER_NAME + + "', but a different provider is already registered under that name: " + + existingFipsProvider.getClass().getName() + + " -- refusing to silently proceed without the promised FIPS guarantee"); } Provider existingJsseProvider = Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); if (existingJsseProvider == null) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java index a029e0119ab..e5be196847b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -3,6 +3,7 @@ import org.apache.catalina.connector.Connector; import org.apache.coyote.http11.AbstractHttp11Protocol; import org.apache.tomcat.util.net.SSLHostConfig; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.jupiter.api.Test; import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; @@ -151,4 +152,44 @@ void succeedsWhenTheGenuineFipsProviderIsAlreadyRegisteredUnderTheBcjsseName() { assertThat(Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isNotNull(); } + + @Test + void failsFastWhenAnExistingNonFipsProviderIsAlreadyRegisteredUnderTheBcfipsName() { + // Simulates a non-FIPS provider having already claimed the "BCFIPS" name (e.g. via JVM-wide + // java.security configuration) before this method runs -- must not be silently trusted as + // the genuine BouncyCastleFipsProvider. + // + // Security.addProvider(Provider) is a no-op (returns -1) if a provider with the same name is + // already registered -- and since Security providers are global, JVM-wide state with no + // automatic teardown, another test in this class (or a prior run of this same customizer) may + // have already registered the genuine FIPS provider under this name. Remove any existing + // registration first so the impostor is guaranteed to actually take its place, regardless of + // test execution order. + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + Provider impostor = new Provider(BouncyCastleFipsProvider.PROVIDER_NAME, "1.0", "not actually BCFIPS") { + }; + Security.addProvider(impostor); + try { + assertThatThrownBy(MtlsClientAuthTomcatCustomizer::ensureJsseProviderRegistered) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(BouncyCastleFipsProvider.PROVIDER_NAME) + .hasMessageContaining(impostor.getClass().getName()); + } finally { + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + } + } + + @Test + void succeedsWhenTheGenuineFipsProviderIsAlreadyRegisteredUnderTheBcfipsName() { + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + Security.addProvider(new BouncyCastleFipsProvider()); + try { + // Calling it with the genuine FIPS crypto provider already registered must not throw. + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + assertThat(Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME)).isNotNull(); + } finally { + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + } + } } From 59b76c8ee89d9ec9a178c31c817a037bddaacffc Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 13:27:56 +0200 Subject: [PATCH 093/130] test(review): guard the tls_client_auth / mtls_endpoint_aliases coupling invariant Both discovery gates are driven by the same uaa.mtls-enabled flag today, but nothing previously asserted they stay consistent with each other -- a future edit to one call site without the other would silently reintroduce a contradictory discovery document. Adds an explicit test for both the enabled and disabled cases. --- .../account/OpenIdConnectEndpointsTest.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java index 9bd4c4e8eba..bc23b445b20 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java @@ -1,5 +1,6 @@ package org.cloudfoundry.identity.uaa.account; +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.junit.jupiter.api.BeforeEach; @@ -56,4 +57,40 @@ void mtlsEndpointAliasesIsAbsentWhenMtlsDisabled() throws Exception { assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().getMtlsEndpointAliases()).isNull(); } + + @Test + void mtlsAdvertisementsAreConsistentWhenMtlsEnabled() throws Exception { + // Guards against the two mTLS discovery gates (tokenAMR's tls_client_auth entry and + // mtls_endpoint_aliases) ever drifting apart: whenever mTLS is enabled, a discovery client + // must see BOTH tls_client_auth advertised AND the mtls_endpoint_aliases pointing at it -- + // never just one without the other, which would itself be a contradictory discovery document. + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + OpenIdConfiguration conf = endpoints.getOpenIdConfiguration(request).getBody(); + + assertThat(conf).isNotNull(); + assertThat(conf.getTokenAMR()).contains(ClientAuthentication.TLS_CLIENT_AUTH); + assertThat(conf.getMtlsEndpointAliases()).isNotNull().containsKey("token_endpoint"); + } + + @Test + void mtlsAdvertisementsAreConsistentWhenMtlsDisabled() throws Exception { + OpenIdConnectEndpoints mtlsDisabledEndpoints = + new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager, false); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + OpenIdConfiguration conf = mtlsDisabledEndpoints.getOpenIdConfiguration(request).getBody(); + + assertThat(conf).isNotNull(); + assertThat(conf.getTokenAMR()).doesNotContain(ClientAuthentication.TLS_CLIENT_AUTH); + assertThat(conf.getMtlsEndpointAliases()).isNull(); + } } From 0070a1cc9c95f50569e723ef4291760c11cc4ca7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 13:48:26 +0200 Subject: [PATCH 094/130] docs(review): clarify that the mTLS TLS-layer change is connector-wide Previously stated the dedicated /oauth/mtls/token endpoint routing meant 'without changing behavior for every other client on /oauth/token' -- this conflated endpoint-level authentication scoping (genuinely dedicated) with the underlying TLS-handshake configuration, which MtlsClientAuthTomcatCustomizer applies connector-wide via certificateVerification=optionalNoCA. Every TLS handshake to this UAA instance requests a client certificate when uaa.mtls-enabled is true, regardless of the endpoint ultimately routed to. Addresses PR review comment on docs/UAA-Client-Authentication.md:61. --- docs/UAA-Client-Authentication.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index f431b809a0f..c6fe7345134 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -56,9 +56,20 @@ fields (e.g. mapping a Cloud Foundry app instance identity certificate to `app_g `space_guid`/`org_guid` claims). The client is authenticated on a dedicated endpoint, `/oauth/mtls/token`, rather than the -regular `/oauth/token`. This lets the endpoint be given a servlet-container TLS configuration -that requests a client certificate ("mutual TLS"), without changing behavior for every other -client on `/oauth/token`. +regular `/oauth/token`. This dedicated endpoint routing is what's scoped: only requests to +`/oauth/mtls/token` (and its alias, if the mTLS endpoint alias is advertised in the OIDC +discovery document) attempt to authenticate the caller via a presented client certificate -- +requests to `/oauth/token` are never affected by this. + +The underlying TLS-layer change, however, is **connector-wide, not per-endpoint**: enabling +this feature (`uaa.mtls-enabled`) reconfigures the whole embedded Tomcat connector to request a +client certificate on *every* TLS handshake to this UAA instance (`certificateVerification= +optionalNoCA`; see `MtlsClientAuthTomcatCustomizer`), regardless of which path the request is +ultimately routed to. Any TLS client connecting to any UAA endpoint will therefore be prompted +for a certificate during the handshake -- well-behaved clients (including Go's `crypto/tls`) +simply respond with an empty `Certificate` message if they have no certificate matching the +connector's advertised acceptable-issuer list, so this doesn't outright break other endpoints, +but it is a deployment-wide TLS-layer change, not one isolated to `/oauth/mtls/token`. #### Deployment topology From 1babf25f01c32bef91e8f421900f8cea69485323 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 14:52:42 +0200 Subject: [PATCH 095/130] docs(review): add REST Docs coverage for the /oauth/mtls/token endpoint TokenEndpointDocs previously only documented /oauth/token's various grant types and client authentication methods -- the mTLS token endpoint added by this PR had no corresponding REST Docs coverage, so generated API documentation omitted its request format, authentication requirements, and response shape entirely. Adds a client_credentials + tls_client_auth documentation test: generates a CA and a leaf certificate, configures a dedicated documentation client with tls-client-auth-ca, presents the leaf certificate via the servlet request attribute the real TLS handshake (or XFCC mapper) would populate, and documents the resulting token response. Adds testImplementation(libs.bouncyCastlePkixFips) to uaa/build.gradle.kts (certificate-generation classes not previously on this module's test compile classpath), mirroring the existing declaration in server/build.gradle.kts for the same purpose. Addresses PR review comment on UaaTokenEndpoint.java:34. --- uaa/build.gradle.kts | 1 + .../identity/uaa/login/TokenEndpointDocs.java | 129 +++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/uaa/build.gradle.kts b/uaa/build.gradle.kts index d3874f12d61..f17dc5bf684 100644 --- a/uaa/build.gradle.kts +++ b/uaa/build.gradle.kts @@ -107,6 +107,7 @@ dependencies { testImplementation(libs.xmlUnit) testImplementation(libs.awaitility) testImplementation(libs.nimbusJwt) + testImplementation(libs.bouncyCastlePkixFips) testRuntimeOnly(libs.jacocoAgent) testRuntimeOnly(libs.junit5PlatformLauncher) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index b86c402f5f8..d67a9a34068 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -1,14 +1,26 @@ package org.cloudfoundry.identity.uaa.login; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.mock.token.AbstractTokenMockMvcTests; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.oauth.common.OAuth2RefreshToken; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.oauth.token.CompositeToken; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.cloudfoundry.identity.uaa.provider.IdentityProvider; @@ -55,10 +67,19 @@ import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import java.io.StringWriter; +import java.math.BigInteger; import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; import java.security.Security; +import java.security.cert.X509Certificate; import java.util.Base64; import java.util.Collections; +import java.util.Date; +import java.util.Map; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.MockSecurityContext; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.getClientCredentialsOAuthAccessToken; @@ -108,7 +129,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@TestPropertySource(properties = "login.entityBaseURL=") +@TestPropertySource(properties = {"login.entityBaseURL=", "uaa.mtls-enabled=true"}) @ExtendWith(JUnitRestDocumentationExtension.class) class TokenEndpointDocs extends AbstractTokenMockMvcTests { private static final Base64.Encoder ENCODER = Base64.getEncoder(); @@ -172,6 +193,18 @@ class TokenEndpointDocs extends AbstractTokenMockMvcTests { @Autowired FilterRegistrationBean zoneContextPathSessionFilterRegistration; + /** + * Registered in {@code SpringServletXmlFiltersConfiguration} but not automatically added to the + * servlet container by {@code MockMvcBuilders.webAppContextSetup(...)} -- must be added explicitly + * to the MockMvc filter chain (like the other filters below) so that + * {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#getCertificateChainFromRequest} + * can read {@link RawPeerCertificateCaptureFilter#RAW_PEER_CERTIFICATE_ATTRIBUTE} for + * {@code /oauth/mtls/token} requests. + */ + @Qualifier("rawPeerCertificateCaptureFilter") + @Autowired + FilterRegistrationBean rawPeerCertificateCaptureFilterRegistration; + @BeforeAll static void beforeAll() { Security.addProvider(new BouncyCastleFipsProvider()); @@ -189,6 +222,7 @@ void setUpContext(ManualRestDocumentation manualRestDocumentation) { mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) .addFilter(zonePathFilterRegistration.getFilter()) .addFilter(zoneContextPathSessionFilterRegistration.getFilter()) + .addFilter(rawPeerCertificateCaptureFilterRegistration.getFilter()) .addFilter(securityFilterChain) .apply(documentationConfiguration(manualRestDocumentation) .uris().withPort(80) @@ -470,6 +504,99 @@ void getTokenUsingClientCredentialGrantWithAuthorizationHeader() throws Exceptio .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), formParameters, requestHeaders, responseFields)); } + /** + * Documents {@code /oauth/mtls/token} (RFC 8705 mutual-TLS client authentication, {@code tls_client_auth}). + * Unlike {@code client_secret}/{@code client_assertion}, this method has no request parameter at all -- + * the client authenticates by presenting an X.509 certificate at the TLS layer itself (or, behind a + * trusted proxy, via the {@code X-Forwarded-Client-Cert} header), which the Gorouter/servlet container + * populates on the request before this endpoint's client authentication runs. This test simulates that by + * setting the standard {@code jakarta.servlet.request.X509Certificate} request attribute directly, which + * {@link RawPeerCertificateCaptureFilter} (added to the MockMvc filter chain in {@link #setUpContext}, as + * it would run in the real filter chain) copies into the attribute + * {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#getCertificateChainFromRequest} + * reads for {@code /oauth/mtls/*} requests -- exercising the same + * {@code ClientDetailsAuthenticationProvider.validateTlsClientAuth} path a genuine mTLS handshake would. + */ + @Test + void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { + KeyPair caKeyPair = generateKeyPair(); + X500Name caSubject = new X500Name("CN=Test mTLS CA"); + X509Certificate caCert = signCert(caSubject, caSubject, caKeyPair.getPublic(), caKeyPair.getPrivate(), true, BigInteger.valueOf(1)); + + KeyPair leafKeyPair = generateKeyPair(); + X500Name leafSubject = new X500Name("CN=mtls-doc-client"); + X509Certificate leafCert = signCert(leafSubject, caSubject, leafKeyPair.getPublic(), caKeyPair.getPrivate(), false, BigInteger.valueOf(2)); + + String clientId = "mtlsdocclient" + generator.generate(); + setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, + false, null, null, -1, IdentityZone.getUaa(), + Map.of( + "token-endpoint-auth-method", "tls_client_auth", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert) + )); + + MockHttpServletRequestBuilder postForToken = RestDocumentationRequestBuilders.post("/oauth/mtls/token") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_FORM_URLENCODED) + .param(CLIENT_ID, clientId) + .param(GRANT_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS) + .param(REQUEST_TOKEN_FORMAT, OPAQUE.getStringValue()) + // RawPeerCertificateCaptureFilter.isMtlsTokenPath(...) matches on the *effective* + // servlet path (post-ZonePathContextRewritingFilter); MockMvc does not compute this + // itself from the request URI the way a real DispatcherServlet mapping would, so it + // must be set explicitly here to simulate the real /oauth/mtls/token servlet path. + .servletPath("/oauth/mtls/token") + .requestAttr("jakarta.servlet.request.X509Certificate", new X509Certificate[]{leafCert}); + + Snippet formParameters = formParameters( + clientIdParameter, + grantTypeParameter.description("the type of authentication being used to obtain the token, in this case `client_credentials`"), + opaqueFormatParameter + ); + + Snippet responseFields = responseFields( + accessTokenFieldDescriptor, + tokenTypeFieldDescriptor, + expiresInFieldDescriptor, + scopeFieldDescriptorWhenClientCredentialsToken, + jtiFieldDescriptor + ); + + mockMvc.perform(postForToken) + .andExpect(status().isOk()) + .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), formParameters, responseFields)); + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + + private static String toPem(X509Certificate cert) throws Exception { + StringWriter sw = new StringWriter(); + try (PemWriter pemWriter = new PemWriter(sw)) { + pemWriter.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + return sw.toString(); + } + @Test void getTokenUsingPasswordGrantWithClientSecret() throws Exception { MockHttpServletRequestBuilder postForToken = post("/oauth/token") From 2390647cd2aa2d4c4e6cba588a9c6e6e33afee45 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 15:10:48 +0200 Subject: [PATCH 096/130] docs(review): wire the tls_client_auth REST Docs snippets into the rendered API docs TokenEndpointDocs.getTokenUsingClientCredentialGrantWithTlsClientAuth (added in 1babf25f0) generates REST Docs snippets, but index.html.md.erb was never updated to render them -- the generated customer-facing API documentation would have continued to omit /oauth/mtls/token entirely, the exact gap the PR review comment on UaaTokenEndpoint.java:34 was raised to close. Adds a 'Mutual TLS Client Authentication' subsection under Client Credentials Grant, following the same curl-request/http-request/ http-response/form-parameters/response-fields render() pattern already used for the Client Secret/Authorization Header/Client Assertion variants, plus a short prose note and a link to docs/UAA-Client-Authentication.md for deployment details. --- .../source/index.html.md.erb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 58e881b50c6..ee977d81c34 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -259,6 +259,28 @@ _Response Fields_ <%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithClientAssertion/response-fields.md') %> +### Mutual TLS Client Authentication ([RFC 8705](https://www.rfc-editor.org/rfc/rfc8705)) + +Authenticates using an X.509 certificate presented at the TLS layer, on the dedicated +`/oauth/mtls/token` endpoint, instead of a `client_secret`, `client_assertion`, or Basic +Authorization header. The client is identified by the certificate chaining to its configured +`tls-client-auth-ca`; no additional request parameter carries the certificate itself. See +[UAA Client Authentication](https://github.com/cloudfoundry/uaa/blob/master/docs/UAA-Client-Authentication.md#tls_client_auth-rfc-8705) +for deployment and configuration details, including the connector-wide `uaa.mtls-enabled` +requirement. + +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/curl-request.md') %> +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/http-request.md') %> +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/http-response.md') %> + +_Request Parameters_ + +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/form-parameters.md') %> + +_Response Fields_ + +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/response-fields.md') %> + ## Password Grant ### Form Authentication From c1bbd85fa38c0dab39a588390296eeab1ff0185c Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 24 Aug 2026 16:17:25 +0200 Subject: [PATCH 097/130] fix(test): enable uaa.mtls-enabled for pre-existing discovery-document tests a7b77e310/bf6149ae8 made tls_client_auth and mtls_endpoint_aliases only advertised in the OIDC discovery document when uaa.mtls-enabled is true (default false), correctly closing a gap where a discovery client could select an authentication method the server can't actually perform. Three pre-existing tests in the uaa module -- OpenIdConnectEndpointDocs (a REST Docs test, which broke the generate-api-docs CI job with a SnippetException since the documented mtls_endpoint_aliases.token_endpoint field was no longer present in the default-config response) and OpenIdConnectEndpointsMockMvcTests/OpenIdConnectEndpointsMockMvcZonePathTests -- were not updated at the time and started failing under the default (disabled) configuration once that fix landed, since they assert on tls_client_auth/mtls_endpoint_aliases being present unconditionally. Adds @TestPropertySource(properties = "uaa.mtls-enabled=true") to all three, matching the same pattern already used in TokenEndpointDocs, so these tests continue to exercise and document the mTLS-enabled discovery document shape they were originally written for. --- .../uaa/scim/endpoints/OpenIdConnectEndpointDocs.java | 5 +++++ .../scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java | 4 ++++ .../OpenIdConnectEndpointsMockMvcZonePathTests.java | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java index 58f7dba9bba..ae3169eafc5 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java @@ -3,6 +3,7 @@ import org.cloudfoundry.identity.uaa.mock.EndpointDocs; import org.junit.jupiter.api.Test; import org.springframework.restdocs.snippet.Snippet; +import org.springframework.test.context.TestPropertySource; import static org.cloudfoundry.identity.uaa.test.SnippetUtils.fieldWithPath; import static org.springframework.http.MediaType.APPLICATION_JSON; @@ -13,6 +14,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// mtls_endpoint_aliases is only present in the discovery document when uaa.mtls-enabled is true +// (the default is false) -- enabled here so this documented field is genuinely present in the +// response this test drives, rather than the endpoint silently omitting it. +@TestPropertySource(properties = "uaa.mtls-enabled=true") class OpenIdConnectEndpointDocs extends EndpointDocs { @Test void getWellKnownOpenidConf() throws Exception { diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java index 27ad32c822e..3fd27340280 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; @@ -25,7 +26,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// mtls_endpoint_aliases/tls_client_auth are only advertised when uaa.mtls-enabled is true (the +// default is false) -- enabled here since this test asserts on their presence. @DefaultTestContext +@TestPropertySource(properties = "uaa.mtls-enabled=true") class OpenIdConnectEndpointsMockMvcTests { private IdentityZone identityZone; diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java index 65c246c6147..156ac469859 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; import org.cloudfoundry.identity.uaa.extensions.EnabledIfZonePathsEnabled; @@ -30,6 +31,9 @@ @DefaultTestContext @EnabledIfZonePathsEnabled +// mtls_endpoint_aliases/tls_client_auth are only advertised when uaa.mtls-enabled is true (the +// default is false) -- enabled here since this test asserts on their presence. +@TestPropertySource(properties = "uaa.mtls-enabled=true") class OpenIdConnectEndpointsMockMvcZonePathTests { private IdentityZone identityZone; From b31e33f60bfcaeaf8ca2ea56b26319b9fb5b9203 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 10:14:46 +0200 Subject: [PATCH 098/130] fix(review): keep outbound IdP auth methods separate from inbound tls_client_auth --- .../uaa/constants/ClientAuthentication.java | 7 +++++++ .../uaa/constants/ClientAuthenticationTest.java | 9 +++++++++ .../ExternalOAuthAuthenticationManager.java | 4 ++++ ...nalOAuthIdentityProviderConfigValidator.java | 4 ++-- .../oauth/OauthIDPWrapperFactoryBean.java | 2 +- .../ExternalOAuthAuthenticationManagerTest.java | 17 +++++++++++++++++ ...AuthIdentityProviderConfigValidatorTest.java | 10 ++++++++++ ...entityProviderDefinitionFactoryBeanTest.java | 9 +++++++++ 8 files changed, 59 insertions(+), 3 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java index b4f6779a20d..f6d51c5da5c 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java @@ -24,6 +24,13 @@ private ClientAuthentication() { public static final List UAA_SUPPORTED_METHODS = List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT, TLS_CLIENT_AUTH); + public static final List EXTERNAL_OAUTH_SUPPORTED_METHODS = + List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT); + + public static boolean isExternalOAuthMethodSupported(String method) { + return Optional.ofNullable(method).map(EXTERNAL_OAUTH_SUPPORTED_METHODS::contains).orElse(true); + } + public static boolean secretNeeded(String method) { return method == null || CLIENT_SECRET_POST.equals(method) || CLIENT_SECRET_BASIC.equals(method); } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java index 914caf7f791..90da419c95a 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java @@ -25,6 +25,15 @@ void isMethodSupported() { assertThat(ClientAuthentication.isMethodSupported("foo")).isFalse(); } + @Test + void externalOAuthMethodsSupportStandardMethodsButNotTlsClientAuth() { + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(CLIENT_SECRET_BASIC)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(CLIENT_SECRET_POST)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(PRIVATE_KEY_JWT)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(NONE)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(TLS_CLIENT_AUTH)).isFalse(); + } + @Test void isValidMethodTrue() { assertThat(ClientAuthentication.isValidMethod(NONE, false, false)).isTrue(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java index 129efb18732..4b91d971123 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java @@ -1005,6 +1005,10 @@ public String oidcJwtBearerGrant(UaaAuthenticationDetails details, public String oauthTokenRequest(UaaAuthenticationDetails details, final IdentityProvider identityProvider, String grantType, MultiValueMap additionalParameters) { final OIDCIdentityProviderDefinition config = identityProvider.getConfig(); + if (ClientAuthentication.TLS_CLIENT_AUTH.equals(config.getAuthMethod())) { + throw new ProviderConfigurationException( + "External OpenID Connect provider configuration does not support tls_client_auth."); + } //Token per RestCall URL tokenUrl = config.getTokenUrl(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java index 1478658fcaf..4c7a4bec444 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java @@ -67,8 +67,8 @@ public void validate(AbstractIdentityProviderDefinition definition) { if (hasText(def.getAuthMethod())) { String authMethod = def.getAuthMethod(); - if (!ClientAuthentication.isMethodSupported(authMethod)) { - errors.add("Relying Party Authentication Method must be set to either " + String.join(" or ", ClientAuthentication.UAA_SUPPORTED_METHODS)); + if (!ClientAuthentication.isExternalOAuthMethodSupported(authMethod)) { + errors.add("Relying Party Authentication Method must be set to either " + String.join(" or ", ClientAuthentication.EXTERNAL_OAUTH_SUPPORTED_METHODS)); } else if (!ClientAuthentication.isAuthMethodEqual(ClientAuthentication.getCalculatedMethod(authMethod, def.getRelyingPartySecret() != null, hasKeyConfigured), (getAuthMethod(definition)))) { errors.add("Relying Party Authentication Method [%s] does not match with expected on [%s]".formatted(authMethod, getAuthMethod(definition))); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java index 0fef9235649..e168e4e4fdf 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java @@ -191,7 +191,7 @@ protected void setCommonProperties(Map idpDefinitionMap, Abstrac idpDefinition.setCacheJwks((boolean) idpDefinitionMap.get("cacheJwks")); } if (idpDefinitionMap.get("authMethod") instanceof String definedAuthMethod) { - if (ClientAuthentication.isMethodSupported(definedAuthMethod)) { + if (ClientAuthentication.isExternalOAuthMethodSupported(definedAuthMethod)) { idpDefinition.setAuthMethod(definedAuthMethod); } else { throw new IllegalArgumentException("Invalid IdP authentication method"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java index 709008a5580..386f7e52729 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java @@ -16,6 +16,7 @@ import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.cache.StaleUrlCache; import org.cloudfoundry.identity.uaa.client.UaaClient; +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.login.Prompt; import org.cloudfoundry.identity.uaa.oauth.KeyInfo; @@ -109,6 +110,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class ExternalOAuthAuthenticationManagerTest { @@ -852,6 +854,21 @@ void oidcPasswordGrant_requireAuthenticationStatement() { .hasMessage("External OpenID Connect provider configuration is missing relyingPartySecret, jwtClientAuthentication or authMethod."); } + @Test + void oauthTokenRequestRejectsStaleTlsClientAuthMethodBeforeSendingRequest() throws Exception { + oidcConfig.setAuthMethod(ClientAuthentication.TLS_CLIENT_AUTH); + oidcConfig.setTokenUrl(URI.create("https://idp.example.com/oauth/token").toURL()); + RestTemplate restTemplate = mock(RestTemplate.class); + authManager = new ExternalOAuthAuthenticationManager(identityProviderProvisioning, new IdentityZoneManagerImpl(), restTemplate, restTemplate, + tokenEndpointBuilder, new KeyInfoService(UAA_ISSUER_BASE_URL), oidcMetadataFetcher, false); + + assertThatThrownBy(() -> authManager.oauthTokenRequest( + null, provider, GRANT_TYPE_PASSWORD, new LinkedMaskingMultiValueMap<>())) + .isInstanceOf(ProviderConfigurationException.class) + .hasMessage("External OpenID Connect provider configuration does not support tls_client_auth."); + verifyNoInteractions(restTemplate); + } + @Test void oidcPasswordGrantProviderJwtClientCredentials() throws Exception { // Given diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java index 1e67be21cd0..99c1b38b517 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java @@ -111,6 +111,16 @@ void configWithInvalidAuthMethod_ThrowsException() { validator.validate(definition)).asInstanceOf(InstanceOfAssertFactories.throwable(IllegalArgumentException.class)); } + @Test + void configWithTlsClientAuthMethod_ThrowsException() { + definition.setAuthMethod(ClientAuthentication.TLS_CLIENT_AUTH); + + assertThatThrownBy(() -> validator.validate(definition)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Relying Party Authentication Method") + .hasMessageNotContaining(ClientAuthentication.TLS_CLIENT_AUTH); + } + @Test void configWithShowLinkTextTrue_mustHaveLinkText() { definition.setShowLinkText(true); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java index 21c2a0b17ab..b96a286bbf6 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java @@ -328,6 +328,15 @@ void authMethodSetInvalidValue() { assertThatThrownBy(() -> factoryBean.setCommonProperties(idpDefinitionMap, providerDefinition)).asInstanceOf(InstanceOfAssertFactories.throwable(IllegalArgumentException.class)); } + @Test + void authMethodSetToTlsClientAuthIsRejected() { + idpDefinitionMap.put("authMethod", ClientAuthentication.TLS_CLIENT_AUTH); + + assertThatThrownBy(() -> factoryBean.setCommonProperties(idpDefinitionMap, providerDefinition)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid IdP authentication method"); + } + @Test void authMethodSet() { // given: jwtclientAuthentication, but overrule it with authMethod=none From d0a04977a7bfba89925ad8b76ccd528404cdfcbe Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 11:04:07 +0200 Subject: [PATCH 099/130] fix(review): reject tls client auth in code exchange --- .../ExternalOAuthAuthenticationManager.java | 4 ++++ .../ExternalOAuthAuthenticationManagerTest.java | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java index 4b91d971123..789b9e9b7b2 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java @@ -807,6 +807,10 @@ protected > String final IdentityProvider provider ) { final T config = provider.getConfig(); + if (ClientAuthentication.TLS_CLIENT_AUTH.equals(config.getAuthMethod())) { + throw new ProviderConfigurationException( + "External OpenID Connect provider configuration does not support tls_client_auth."); + } if (StringUtils.hasText(codeToken.getIdToken()) && ID_TOKEN.equals(getResponseType(config))) { log.debug("ExternalOAuthCodeToken contains id_token, not exchanging code."); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java index 386f7e52729..1a526b582b9 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java @@ -869,6 +869,23 @@ void oauthTokenRequestRejectsStaleTlsClientAuthMethodBeforeSendingRequest() thro verifyNoInteractions(restTemplate); } + @Test + void authorizationCodeExchangeRejectsStaleTlsClientAuthMethodBeforeSendingRequest() throws Exception { + oidcConfig.setAuthMethod(ClientAuthentication.TLS_CLIENT_AUTH); + oidcConfig.setTokenUrl(URI.create("https://idp.example.com/oauth/token").toURL()); + RestTemplate restTemplate = mock(RestTemplate.class); + authManager = new ExternalOAuthAuthenticationManager(identityProviderProvisioning, new IdentityZoneManagerImpl(), restTemplate, restTemplate, + tokenEndpointBuilder, new KeyInfoService(UAA_ISSUER_BASE_URL), oidcMetadataFetcher, false); + + ExternalOAuthCodeToken codeToken = new ExternalOAuthCodeToken( + "authorization-code", ORIGIN, "https://uaa.example.com/callback", null, null, null); + + assertThatThrownBy(() -> authManager.authenticate(codeToken)) + .isInstanceOf(ProviderConfigurationException.class) + .hasMessage("External OpenID Connect provider configuration does not support tls_client_auth."); + verifyNoInteractions(restTemplate); + } + @Test void oidcPasswordGrantProviderJwtClientCredentials() throws Exception { // Given From b13db7ccfe6febb1e8abafa4a67ac6597c8d62d6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 11:55:36 +0200 Subject: [PATCH 100/130] fix(review): derive inbound mTLS solely from tls-client-auth-ca and fixed path --- .../uaa/account/OpenIdConnectEndpoints.java | 7 +++-- .../uaa/client/ClientAdminBootstrap.java | 2 ++ .../client/ClientAdminEndpointsValidator.java | 7 +++++ .../account/OpenIdConnectEndpointsTest.java | 4 +-- .../uaa/client/ClientAdminBootstrapTests.java | 26 +++++++++++++++++++ .../identity/uaa/login/TokenEndpointDocs.java | 5 +--- 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java index ff4b792e115..4744caa35c5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java @@ -16,13 +16,12 @@ @Controller public class OpenIdConnectEndpoints { + private static final String MTLS_TOKEN_ENDPOINT_PATH = "/oauth/mtls/token"; + private final String issuer; private final IdentityZoneManager identityZoneManager; private final boolean mtlsEnabled; - @Value("${mtls.endpoint:/oauth/mtls/token}") - private String mtlsEndpointPath = "/oauth/mtls/token"; - public OpenIdConnectEndpoints( final @Value("${issuer.uri}") String issuer, final IdentityZoneManager identityZoneManager, @@ -41,7 +40,7 @@ public ResponseEntity getOpenIdConfiguration(HttpServletReq String contextPath = getServerContextPath(request); OpenIdConfiguration conf = new OpenIdConfiguration(contextPath, getTokenEndpoint(), mtlsEnabled); if (mtlsEnabled) { - conf.setMtlsEndpointAliases(Map.of("token_endpoint", contextPath + mtlsEndpointPath)); + conf.setMtlsEndpointAliases(Map.of("token_endpoint", contextPath + MTLS_TOKEN_ENDPOINT_PATH)); } return new ResponseEntity<>(conf, OK); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java index d6036226428..f49f8065da3 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java @@ -221,6 +221,8 @@ private void addNewClients() { client.setAdditionalInformation(info); ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled, clientId); + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig( + client.getAdditionalInformation(), clientId); ClientJwtConfiguration keyConfig = null; if (map.get(JWKS_URI) instanceof String || map.get(JWKS) instanceof String) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 25516ebbae6..de4379f7a69 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -90,6 +90,8 @@ public class ClientAdminEndpointsValidator implements InitializingBean, ClientDe private final boolean mtlsEnabled; + private static final String TOKEN_ENDPOINT_AUTH_METHOD = "token-endpoint-auth-method"; + private final Set reservedClientIds = StringUtils.commaDelimitedListToSet(OriginKeys.UAA); private final Set invalidClientIdsCharacters = Set.of('/', '\\'); @@ -367,6 +369,11 @@ public static void checkRequestedGrantTypes(Set requestedGrantTypes) { } public static void checkMtlsClientConfigAllowed(Map additionalInfo, boolean mtlsEnabled, String clientId) { + if (additionalInfo.containsKey(TOKEN_ENDPOINT_AUTH_METHOD)) { + throw new InvalidClientDetailsException( + "token-endpoint-auth-method is not supported; configure tls-client-auth-ca to enable mTLS for client_id=" + + clientId); + } if (!mtlsEnabled && (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) || additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA))) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java index bc23b445b20..c4084afd933 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java @@ -37,9 +37,7 @@ void mtlsEndpointAliasesIsPopulatedInDiscovery() throws Exception { assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().getMtlsEndpointAliases()) .isNotNull() - .containsKey("token_endpoint"); - assertThat(response.getBody().getMtlsEndpointAliases().get("token_endpoint")) - .endsWith("/oauth/mtls/token"); + .containsEntry("token_endpoint", "https://uaa.example.com/oauth/mtls/token"); } @Test diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java index f0fea1c2b34..65520c8ecf1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java @@ -694,6 +694,32 @@ void mtlsClientConfigAllowedWhenMtlsEnabled() { assertThat(created.getAdditionalInformation()).containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); } + @Test + void invalidTlsClientAuthClaimConfigIsRejectedDuringBootstrap() { + ClientAdminBootstrap mtlsEnabledBootstrap = new ClientAdminBootstrap( + passwordEncoder, + multitenantJdbcClientDetailsService, + clientMetadataProvisioning, + true, + clients, + Collections.singleton(autoApproveId), + Collections.emptySet(), + null, + Collections.singleton(allowPublicId), + true); + + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "invalid", "claim", "cf_app"))); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(mtlsEnabledBootstrap::afterPropertiesSet) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("tls-client-auth-claim-mappings") + .hasMessageContaining("invalid field"); + } + @Test void ordinaryClientBootstrapsSuccessfullyWhenMtlsDisabled() { Map map = createClientMap("foo"); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index d67a9a34068..c859b53c6c5 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -530,10 +530,7 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { String clientId = "mtlsdocclient" + generator.generate(); setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, false, null, null, -1, IdentityZone.getUaa(), - Map.of( - "token-endpoint-auth-method", "tls_client_auth", - TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert) - )); + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); MockHttpServletRequestBuilder postForToken = RestDocumentationRequestBuilders.post("/oauth/mtls/token") .accept(APPLICATION_JSON) From 36aa21f041ad21e14d34a5bc7e11c5e0a5686f62 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 12:28:35 +0200 Subject: [PATCH 101/130] test(review): make mTLS docs client secretless --- .../org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index c859b53c6c5..c96f603cad6 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -528,7 +528,7 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { X509Certificate leafCert = signCert(leafSubject, caSubject, leafKeyPair.getPublic(), caKeyPair.getPrivate(), false, BigInteger.valueOf(2)); String clientId = "mtlsdocclient" + generator.generate(); - setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, + UaaClientDetails client = setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, false, null, null, -1, IdentityZone.getUaa(), Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); From cc5bf5d2f1de080f957e5d2891157a7a1f0a8b0b Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 12:29:18 +0200 Subject: [PATCH 102/130] fix(review): persist secretless mTLS docs client --- .../org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index c96f603cad6..9f07c4c8923 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -531,6 +531,9 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { UaaClientDetails client = setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, false, null, null, -1, IdentityZone.getUaa(), Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); + client.setClientSecret(null); + clientDetailsService.updateClientDetails(client); + assertThat(clientDetailsService.loadClientByClientId(clientId).getClientSecret()).isNull(); MockHttpServletRequestBuilder postForToken = RestDocumentationRequestBuilders.post("/oauth/mtls/token") .accept(APPLICATION_JSON) From 67f7cf278564275e5f2d8cfcea18fe06d2169dbc Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 12:42:23 +0200 Subject: [PATCH 103/130] fix(review): clear persisted mTLS docs client secret --- .../org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index 9f07c4c8923..f32c3c9954a 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -531,8 +531,7 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { UaaClientDetails client = setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, false, null, null, -1, IdentityZone.getUaa(), Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); - client.setClientSecret(null); - clientDetailsService.updateClientDetails(client); + clientDetailsService.updateClientSecret(clientId, null); assertThat(clientDetailsService.loadClientByClientId(clientId).getClientSecret()).isNull(); MockHttpServletRequestBuilder postForToken = RestDocumentationRequestBuilders.post("/oauth/mtls/token") From 8ebd5ebbc1e4e0532a4311a1b20b1de22078f247 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 12:43:18 +0200 Subject: [PATCH 104/130] refactor(review): remove unused mTLS docs client binding --- .../org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index f32c3c9954a..d243d011362 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -528,7 +528,7 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { X509Certificate leafCert = signCert(leafSubject, caSubject, leafKeyPair.getPublic(), caKeyPair.getPrivate(), false, BigInteger.valueOf(2)); String clientId = "mtlsdocclient" + generator.generate(); - UaaClientDetails client = setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, + setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, false, null, null, -1, IdentityZone.getUaa(), Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); clientDetailsService.updateClientSecret(clientId, null); From 3b889d4437e6a9d1dd8d77c303723127058b6320 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 13:40:41 +0200 Subject: [PATCH 105/130] docs: document CA-only inbound mTLS selection --- docs/UAA-Client-Authentication.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index c6fe7345134..6cbd779051b 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -55,10 +55,10 @@ a per-client trusted CA and, optionally, derives JWT claims from the certificate fields (e.g. mapping a Cloud Foundry app instance identity certificate to `app_guid`/ `space_guid`/`org_guid` claims). -The client is authenticated on a dedicated endpoint, `/oauth/mtls/token`, rather than the -regular `/oauth/token`. This dedicated endpoint routing is what's scoped: only requests to -`/oauth/mtls/token` (and its alias, if the mTLS endpoint alias is advertised in the OIDC -discovery document) attempt to authenticate the caller via a presented client certificate -- +The client is authenticated on the fixed dedicated endpoint, `/oauth/mtls/token`, rather than +the regular `/oauth/token`. A nonblank `tls-client-auth-ca` is the sole inbound mTLS selector +for a client. This dedicated endpoint routing is what's scoped: only requests to +`/oauth/mtls/token` attempt to authenticate the caller via a presented client certificate -- requests to `/oauth/token` are never affected by this. The underlying TLS-layer change, however, is **connector-wide, not per-endpoint**: enabling @@ -131,10 +131,12 @@ registers them as two separate UAA clients, only the latter configuring Per-client properties (set via the client-admin API, `oauth.clients` bootstrap, or the client admin UI, alongside the client's other properties such as `authorized-grant-types`): +The nonblank `tls-client-auth-ca` property is the sole inbound mTLS selector. Inbound mTLS uses +the fixed `/oauth/mtls/token` endpoint. + | Property | Required | Description | |----------|----------|--------------| -| `token-endpoint-auth-method: tls_client_auth` | yes | Selects mTLS client authentication for this client. | -| `tls-client-auth-ca` | yes | PEM-encoded CA certificate. The client's own presented (leaf) certificate must chain to this CA. | +| `tls-client-auth-ca` | yes | Nonblank PEM-encoded CA certificate. The client's presented leaf certificate must chain to this CA. | | `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | | `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values already produced by `tls-client-auth-claim-mappings`. When configured, authentication fails unless every entry matches exactly -- e.g. `{space_guid: ""}` scopes this client to a single CF space, even if other clients share the same `tls-client-auth-ca`. | | `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`) to JWT claim names, optionally extracting a capture group via `pattern`. | @@ -145,7 +147,6 @@ Example (Gorouter-fronted; a Cloud Foundry app instance identity certificate map `cf_instance_guid`/`app_guid`/`space_guid`/`org_guid` claims): ```yaml -token-endpoint-auth-method: tls_client_auth tls-client-auth-ca: tls-client-auth-trusted-proxy-ca: tls-client-auth-claim-mappings: From be41a4ebfea093b489113244dcd1728c2c22af5d Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 13:40:49 +0200 Subject: [PATCH 106/130] fix: reject blank CA for secretless zone clients --- .../zone/ZoneEndpointsClientDetailsValidator.java | 5 +++-- .../ZoneEndpointsClientDetailsValidatorTests.java | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 5979853ce3a..a443e4e4bf2 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -56,8 +56,9 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled, clientDetails.getClientId()); validateTlsClientAuthClaimConfig(clientDetails.getAdditionalInformation(), clientDetails.getClientId()); - boolean hasTlsClientAuthCa = clientDetails.getAdditionalInformation() - .containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + Object tlsClientAuthCa = clientDetails.getAdditionalInformation() + .get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + boolean hasTlsClientAuthCa = tlsClientAuthCa instanceof String && !((String) tlsClientAuthCa).isBlank(); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_USER_TOKEN) || diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index a1b01074aaa..cc7241e638a 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -163,6 +163,21 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaConfigured() { assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); } + @Test + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsBlank() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, " "); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client_secret cannot be blank"); + } + @Test void stillValidatesSuppliedSecretWhenTlsClientAuthCaConfigured() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); From 0a194a189af4d0c24115a6ba97e5920d882269fd Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 14:55:01 +0200 Subject: [PATCH 107/130] fix: narrow mTLS token endpoint boundary --- .../SpringServletXmlFiltersConfiguration.java | 8 ++++---- .../ClientDetailsAuthenticationProvider.java | 3 ++- .../OauthEndpointSecurityConfiguration.java | 4 +++- .../uaa/oauth/tls/MtlsPathGuardedFilter.java | 2 +- .../tls/RawPeerCertificateCaptureFilter.java | 16 +++++++++------- ...ientDetailsAuthenticationProviderTests.java | 14 ++++++++++++++ .../tls/ClientCertificateMapperFilterTest.java | 4 ++-- ...rtificateCaptureFilterRegistrationTest.java | 8 ++++++-- .../uaa/oauth/token/UaaTokenEndpointTests.java | 18 ++++++++++++++++++ .../identity/uaa/login/TokenEndpointDocs.java | 2 +- 10 files changed, 60 insertions(+), 19 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index 049e847551a..6e70f0f695a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -240,10 +240,10 @@ public FilterRegistrationBean rawPeerCertificat new FilterRegistrationBean<>(new RawPeerCertificateCaptureFilter()); // No addUrlPatterns(...): registered on the default (all-requests) pattern, like every other // filter in this class. RawPeerCertificateCaptureFilter internally no-ops unless the request's - // effective (post-ZonePathContextRewritingFilter) servlet path is /oauth/mtls/* -- see + // effective (post-ZonePathContextRewritingFilter) servlet path is /oauth/mtls/token/** -- see // RawPeerCertificateCaptureFilter.isMtlsTokenPath(...) -- so it still runs for zone-path- // prefixed mTLS requests (e.g. /z/{subdomain}/oauth/mtls/token). A container URL-pattern - // registration for a literal "/oauth/mtls/*" is matched against the request's original, + // registration for a literal "/oauth/mtls/token/**" is matched against the request's original, // pre-rewrite URI, so it would never include this filter in the chain for such a request. // Must run before clientCertificateMapperFilter() (order -200) so it captures the genuine // TLS-handshake peer certificate before that filter overwrites the same standard @@ -268,8 +268,8 @@ public FilterRegistrationBean clientCertificateMapperFil new FilterRegistrationBean<>(new MtlsPathGuardedFilter(delegate)); // No addUrlPatterns(...): see rawPeerCertificateCaptureFilter() above. // MtlsPathGuardedFilter internally scopes the delegate ClientCertificateMapper to the - // effective (post-ZonePathContextRewritingFilter) /oauth/mtls/* servlet path, so a literal - // "/oauth/mtls/*" URL-pattern registration -- which would not match zone-path-prefixed + // effective (post-ZonePathContextRewritingFilter) /oauth/mtls/token/** servlet path, so a literal + // "/oauth/mtls/token/**" URL-pattern registration -- which would not match zone-path-prefixed // requests -- is not needed here either. // Spring Boot registers its Security filter in the servlet container at order -100 // (org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index c29d7190671..e8f81ff9df0 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -19,6 +19,7 @@ import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -186,7 +187,7 @@ private boolean validatePrivateKeyJwt(Object uaaAuthenticationDetails, UaaClient static boolean isTlsClientAuthPath(Object uaaAuthenticationDetails) { UaaAuthenticationDetails details = getUaaAuthenticationDetails(uaaAuthenticationDetails); String path = details != null ? details.getRequestPath() : null; - return path != null && path.startsWith("/oauth/mtls"); + return RawPeerCertificateCaptureFilter.isMtlsTokenPath(path); } boolean validateTlsClientAuth(UaaClient uaaClient) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java index b72711ded4f..88053fde005 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java @@ -29,6 +29,7 @@ import org.cloudfoundry.identity.uaa.oauth.UaaTokenServices; import org.cloudfoundry.identity.uaa.oauth.UserManagedAuthzApprovalHandler; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2RequestFactory; import org.cloudfoundry.identity.uaa.oauth.provider.TokenGranter; import org.cloudfoundry.identity.uaa.oauth.provider.authentication.OAuth2AuthenticationProcessingFilter; @@ -475,7 +476,8 @@ UaaFilterChain externalOAuthCallbackEndpointSecurity(HttpSecurity http) throws E @Order(FilterChainOrder.OAUTH_11) UaaFilterChain mtlsTokenEndpointSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher("/oauth/mtls/token", "/oauth/mtls/token/**") + .securityMatcher(RawPeerCertificateCaptureFilter.MTLS_TOKEN_PATH, + RawPeerCertificateCaptureFilter.MTLS_TOKEN_PATH + "/**") .authenticationManager(clientAuthenticationManager) .authorizeHttpRequests(auth -> { auth.requestMatchers("/**").access(anyOf().fullyAuthenticated()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java index e56d5decdf3..349be684ff8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java @@ -11,7 +11,7 @@ /** * Wraps a delegate {@link Filter} so it only runs for requests whose effective (post - * {@code ZonePathContextRewritingFilter}) servlet path is {@code /oauth/mtls/*} -- see + * {@code ZonePathContextRewritingFilter}) servlet path is {@code /oauth/mtls/token/**} -- see * {@link RawPeerCertificateCaptureFilter#isMtlsTokenPath(HttpServletRequest)}. * *

Used in {@code SpringServletXmlFiltersConfiguration} to scope the third-party, package-private diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java index 25a77bb8f74..d5d97667eb5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java @@ -35,7 +35,7 @@ * authenticated client certificate itself. * *

Registered in {@code SpringServletXmlFiltersConfiguration} on the default (all-requests) URL - * pattern, not a literal {@code /oauth/mtls/*} one: {@link #isMtlsTokenPath(HttpServletRequest)} guards + * pattern, not a literal {@code /oauth/mtls/token/**} one: {@link #isMtlsTokenPath(HttpServletRequest)} guards * this filter's work internally instead, checking the request's effective servlet path (i.e. * after {@code ZonePathContextRewritingFilter}, which runs first, has rewritten it). A container * URL-pattern registration is matched against the request's original, pre-rewrite URI, so it would @@ -49,8 +49,8 @@ public class RawPeerCertificateCaptureFilter implements Filter { "org.cloudfoundry.identity.uaa.oauth.tls.rawPeerCertificate"; private static final String X509_CERTIFICATE_ATTRIBUTE = "jakarta.servlet.request.X509Certificate"; - private static final String MTLS_SERVLET_PATH = "/oauth/mtls"; - private static final String MTLS_SERVLET_PATH_PREFIX = MTLS_SERVLET_PATH + "/"; + public static final String MTLS_TOKEN_PATH = "/oauth/mtls/token"; + private static final String MTLS_TOKEN_PATH_PREFIX = MTLS_TOKEN_PATH + "/"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) @@ -64,12 +64,14 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha /** * Matches the request's effective servlet path -- i.e. after {@code ZonePathContextRewritingFilter} * (which runs first in the filter chain) has stripped any {@code /z/{subdomain}} prefix -- against - * {@code /oauth/mtls/*}. Also used by {@link MtlsPathGuardedFilter} to scope the (externally + * {@code /oauth/mtls/token/**}. Also used by {@link MtlsPathGuardedFilter} to scope the (externally * supplied, package-private) {@code ClientCertificateMapper} filter to the same effective path. */ static boolean isMtlsTokenPath(HttpServletRequest request) { - String servletPath = request.getServletPath(); - return servletPath != null - && (servletPath.equals(MTLS_SERVLET_PATH) || servletPath.startsWith(MTLS_SERVLET_PATH_PREFIX)); + return isMtlsTokenPath(request.getServletPath()); + } + + public static boolean isMtlsTokenPath(String path) { + return path != null && (path.equals(MTLS_TOKEN_PATH) || path.startsWith(MTLS_TOKEN_PATH_PREFIX)); } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java index 30784d0b0bc..7c08c442691 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -56,6 +56,20 @@ void tlsClientAuthPathIsDetectedAsTlsClientAuth() { assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isTrue(); } + @Test + void tlsClientAuthPathIncludesTokenDescendants() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/mtls/token/alias"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isTrue(); + } + + @Test + void unrelatedMtlsPathIsNotTlsClientAuth() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/mtls/not-token"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isFalse(); + } + @Test void regularTokenPathIsNotTlsClientAuth() { UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java index 8ab88f30a36..1a54489f041 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java @@ -59,7 +59,7 @@ void doesNotInvokeTheDelegateForUnrelatedPaths() throws Exception { FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); MockHttpServletRequest request = new MockHttpServletRequest(); - request.setServletPath("/login"); + request.setServletPath("/oauth/mtls/not-token"); request.addHeader("X-Forwarded-Client-Cert", Base64.getEncoder().encodeToString(generateSelfSignedCert().getEncoded())); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -67,7 +67,7 @@ void doesNotInvokeTheDelegateForUnrelatedPaths() throws Exception { mapperBean.getFilter().doFilter(request, response, (req, res) -> { }); assertThat(request.getAttribute("jakarta.servlet.request.X509Certificate")) - .as("ClientCertificateMapper must not run for a path other than /oauth/mtls/*") + .as("ClientCertificateMapper must not run for a path other than /oauth/mtls/token/**") .isNull(); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java index 65a3291bd86..9bcba47b0d4 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java @@ -75,8 +75,12 @@ void isMtlsTokenPathAcceptsTheEffectivePostZoneRewriteServletPath() { .as("must match the effective (post zone-path-rewrite) servlet path") .isTrue(); + MockHttpServletRequest tokenDescendant = new MockHttpServletRequest(); + tokenDescendant.setServletPath("/oauth/mtls/token/alias"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(tokenDescendant)).isTrue(); + MockHttpServletRequest unrelated = new MockHttpServletRequest(); - unrelated.setServletPath("/login"); + unrelated.setServletPath("/oauth/mtls/not-token"); assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(unrelated)).isFalse(); } @@ -84,7 +88,7 @@ void isMtlsTokenPathAcceptsTheEffectivePostZoneRewriteServletPath() { void doesNotCaptureAnAttributeForUnrelatedPaths() throws Exception { RawPeerCertificateCaptureFilter filter = new RawPeerCertificateCaptureFilter(); MockHttpServletRequest request = new MockHttpServletRequest(); - request.setServletPath("/login"); + request.setServletPath("/oauth/mtls/not-token"); request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{generateSelfSignedCert("CN=some-peer")}); MockHttpServletResponse response = new MockHttpServletResponse(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java index 90101779814..e6e83c8f27b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java @@ -10,9 +10,13 @@ import org.springframework.http.ResponseEntity; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; import jakarta.servlet.http.HttpServletRequest; import java.security.Principal; +import java.util.Map; import java.util.Set; import static java.util.Collections.emptyMap; @@ -68,6 +72,20 @@ void setAllowedRequestMethods() { .containsExactlyInAnyOrder(POST, GET); } + @Test + void mapsMtlsTokenEndpointAndItsDescendants() throws NoSuchMethodException { + RequestMapping mapping = UaaTokenEndpoint.class.getAnnotation(RequestMapping.class); + + assertThat(mapping.value()) + .contains("/oauth/mtls/token"); + assertThat(UaaTokenEndpoint.class.getDeclaredMethod("doDelegateGet", Principal.class, Map.class) + .getAnnotation(GetMapping.class).value()) + .containsExactly("**"); + assertThat(UaaTokenEndpoint.class.getDeclaredMethod("doDelegatePost", Principal.class, Map.class, + HttpServletRequest.class).getAnnotation(PostMapping.class).value()) + .containsExactly("**"); + } + @Test void callToGetAlwaysThrowsSuperMethod() { endpoint = new UaaTokenEndpoint(null, null, null, null, false); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index d243d011362..5dd198a5157 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -514,7 +514,7 @@ void getTokenUsingClientCredentialGrantWithAuthorizationHeader() throws Exceptio * {@link RawPeerCertificateCaptureFilter} (added to the MockMvc filter chain in {@link #setUpContext}, as * it would run in the real filter chain) copies into the attribute * {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#getCertificateChainFromRequest} - * reads for {@code /oauth/mtls/*} requests -- exercising the same + * reads for {@code /oauth/mtls/token/**} requests -- exercising the same * {@code ClientDetailsAuthenticationProvider.validateTlsClientAuth} path a genuine mTLS handshake would. */ @Test From e5249559f45882abb6ab9653e6a109dce3fae320 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 26 Aug 2026 14:55:12 +0200 Subject: [PATCH 108/130] docs: correct mTLS configuration references --- docs/UAA-Configuration-Reference.md | 3 +-- ...eEndpointsClientDetailsValidatorTests.java | 20 +++++++++++++++++-- .../endpoints/OpenIdConnectEndpointDocs.java | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 73759664bff..651c6179fe1 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -1245,8 +1245,7 @@ FIPS BouncyCastle JSSE provider, required for TLS 1.3 client-certificate support does not implement server-side TLS 1.3 post-handshake client-certificate requests). When `false` (the default), no client certificate is requested at the TLS layer at all, and any -client configured with `token-endpoint-auth-method: tls_client_auth` or a `tls-client-auth-ca` -property fails validation at creation/update time. +client configured with a `tls-client-auth-ca` property fails validation at creation/update time. [Back to table](#oauth-clients--users) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index cc7241e638a..7578c49757a 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -163,14 +163,30 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaConfigured() { assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); } + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t"}) + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsBlank(final String ca) { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, ca); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client_secret cannot be blank"); + } + @Test - void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsBlank() { + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsNotAString() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, " "); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, 42); clientDetails.setAdditionalInformation(additionalInfo); assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java index ae3169eafc5..593832820f0 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java @@ -41,7 +41,7 @@ void getWellKnownOpenidConf() throws Exception { fieldWithPath("service_documentation").description("URL of a page containing human-readable information that developers might want or need to know when using the OpenID Provider."), fieldWithPath("code_challenge_methods_supported").description("UAA 75.5.0JSON array containing a list of [PKCE](https://tools.ietf.org/html/rfc7636) code challenge methods supported by this authorization endpoint."), fieldWithPath("ui_locales_supported").description("Languages and scripts supported for the user interface."), - fieldWithPath("mtls_endpoint_aliases.token_endpoint").description("mTLS-specific token endpoint alias for RFC 8705 mutual-TLS client authentication (proxy-terminated via Gorouter XFCC).") + fieldWithPath("mtls_endpoint_aliases.token_endpoint").description("mTLS-specific token endpoint alias for RFC 8705 mutual-TLS client authentication.") ); mockMvc.perform( From d479e856b6fc9807ea3b360a599a95a631f26861 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 27 Aug 2026 14:20:53 +0200 Subject: [PATCH 109/130] docs(review): render a TLS-capable mTLS curl and align client-auth selector docs --- docs/UAA-Client-Authentication.md | 8 +++++--- uaa/slateCustomizations/source/index.html.md.erb | 14 +++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 6cbd779051b..8764075e34e 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -131,12 +131,14 @@ registers them as two separate UAA clients, only the latter configuring Per-client properties (set via the client-admin API, `oauth.clients` bootstrap, or the client admin UI, alongside the client's other properties such as `authorized-grant-types`): -The nonblank `tls-client-auth-ca` property is the sole inbound mTLS selector. Inbound mTLS uses -the fixed `/oauth/mtls/token` endpoint. +The mTLS token endpoint is fixed at `/oauth/mtls/token`; it is not configurable. A client opts +into mTLS by configuring a nonblank `tls-client-auth-ca`. The client must use that endpoint and +present a certificate whose chain validates to the configured CA; no separate +`token-endpoint-auth-method` property is used or supported. | Property | Required | Description | |----------|----------|--------------| -| `tls-client-auth-ca` | yes | Nonblank PEM-encoded CA certificate. The client's presented leaf certificate must chain to this CA. | +| `tls-client-auth-ca` | yes | PEM-encoded CA certificate. This is the per-client mTLS selector: requests to the fixed `/oauth/mtls/token` endpoint authenticate with a presented leaf certificate only when it chains to this CA. | | `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | | `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values already produced by `tls-client-auth-claim-mappings`. When configured, authentication fails unless every entry matches exactly -- e.g. `{space_guid: ""}` scopes this client to a single CF space, even if other clients share the same `tls-client-auth-ca`. | | `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`) to JWT claim names, optionally extracting a capture group via `pattern`. | diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index ee977d81c34..4b66a82577b 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -269,7 +269,19 @@ Authorization header. The client is identified by the certificate chaining to it for deployment and configuration details, including the connector-wide `uaa.mtls-enabled` requirement. -<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/curl-request.md') %> +The certificate and private key are TLS-layer credentials, not form parameters. Substitute paths +and the UAA URL for your deployment: + +```bash +curl --cert /path/to/client-cert.pem \ + --key /path/to/client-key.pem \ + --cacert /path/to/uaa-server-ca.pem \ + --request POST \ + --header 'Accept: application/json' \ + --data 'grant_type=client_credentials&client_id=&token_format=opaque' \ + https://uaa.example.com/oauth/mtls/token +``` + <%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/http-request.md') %> <%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/http-response.md') %> From b8cc7bbc838eb2812a8808602247dcf3f59da2cd Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 10:15:31 +0200 Subject: [PATCH 110/130] fix(review): normalize mTLS CA representations for zone clients --- .../ZoneEndpointsClientDetailsValidator.java | 29 ++++++++-- ...eEndpointsClientDetailsValidatorTests.java | 53 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index a443e4e4bf2..753c83f9432 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -7,12 +7,14 @@ import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; +import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.authority.AuthorityUtils; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.springframework.stereotype.Component; import java.util.Collections; +import java.util.Map; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkRequestedGrantTypes; @@ -56,9 +58,7 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled, clientDetails.getClientId()); validateTlsClientAuthClaimConfig(clientDetails.getAdditionalInformation(), clientDetails.getClientId()); - Object tlsClientAuthCa = clientDetails.getAdditionalInformation() - .get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); - boolean hasTlsClientAuthCa = tlsClientAuthCa instanceof String && !((String) tlsClientAuthCa).isBlank(); + boolean hasTlsClientAuthCa = hasNonblankTlsClientAuthCa(clientDetails.getAdditionalInformation()); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_USER_TOKEN) || @@ -92,6 +92,29 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv throw new IllegalStateException("This validator must be called with a mode"); } + static boolean hasNonblankTlsClientAuthCa(Map additionalInformation) { + if (additionalInformation == null) { + return false; + } + + Object rawConfig = additionalInformation.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (rawConfig instanceof String pem) { + return !pem.isBlank(); + } + if (rawConfig instanceof TlsClientAuthConfiguration config) { + return TlsClientAuthConfiguration.isConfigured(config); + } + if (rawConfig instanceof Map) { + try { + return TlsClientAuthConfiguration.isConfigured( + JsonUtils.convertValue(rawConfig, TlsClientAuthConfiguration.class)); + } catch (Exception e) { + return false; + } + } + return false; + } + @Override public ClientSecretValidator getClientSecretValidator() { return this.clientSecretValidator; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index 7578c49757a..e0bc6c5cd6b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -163,6 +163,59 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaConfigured() { assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); } + @Test + void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsTypedConfiguration() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration("ca-pem", null)); + + assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); + } + + @Test + void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem")); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); + } + + @Test + void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthCaIsBlank() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration(" ", null)); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client_secret cannot be blank"); + } + + @Test + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapIsMalformed() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, Map.of("unexpected", "value")); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client_secret cannot be blank"); + } + @ParameterizedTest @ValueSource(strings = {"", " ", "\t"}) void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsBlank(final String ca) { From 47d9d72bfba29710b74bdb60b9e2d55b780a476a Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 10:31:14 +0200 Subject: [PATCH 111/130] fix(review): handle null zone client additional information --- .../zone/ZoneEndpointsClientDetailsValidator.java | 12 ++++++++---- .../ZoneEndpointsClientDetailsValidatorTests.java | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 753c83f9432..94b433f45d9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -46,6 +46,10 @@ public ZoneEndpointsClientDetailsValidator( public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws InvalidClientDetailsException { if (mode == Mode.CREATE) { + Map additionalInformation = clientDetails.getAdditionalInformation(); + if (additionalInformation == null) { + additionalInformation = Collections.emptyMap(); + } if (!Collections.singleton("openid").equals(clientDetails.getScope())) { throw new InvalidClientDetailsException("only openid scope is allowed"); } @@ -56,9 +60,9 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv throw new InvalidClientDetailsException("client_id cannot be blank"); } checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); - checkMtlsClientConfigAllowed(clientDetails.getAdditionalInformation(), mtlsEnabled, clientDetails.getClientId()); - validateTlsClientAuthClaimConfig(clientDetails.getAdditionalInformation(), clientDetails.getClientId()); - boolean hasTlsClientAuthCa = hasNonblankTlsClientAuthCa(clientDetails.getAdditionalInformation()); + checkMtlsClientConfigAllowed(additionalInformation, mtlsEnabled, clientDetails.getClientId()); + validateTlsClientAuthClaimConfig(additionalInformation, clientDetails.getClientId()); + boolean hasTlsClientAuthCa = hasNonblankTlsClientAuthCa(additionalInformation); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_USER_TOKEN) || @@ -72,7 +76,7 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv } clientSecretValidator.validate(clientDetails.getClientSecret()); } - if (!Collections.singletonList(OriginKeys.UAA).equals(clientDetails.getAdditionalInformation().get(ClientConstants.ALLOWED_PROVIDERS))) { + if (!Collections.singletonList(OriginKeys.UAA).equals(additionalInformation.get(ClientConstants.ALLOWED_PROVIDERS))) { throw new InvalidClientDetailsException("only the internal IdP ('uaa') is allowed"); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index e0bc6c5cd6b..882fbfba9cc 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -88,6 +88,20 @@ void createClientNoSecretIsInvalid(final String grantType) { .hasMessageContaining("client_secret cannot be blank"); } + @Test + void rejectsSecretlessClientCredentialsClientWhenAdditionalInformationIsNull() { + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource") { + @Override + public Map getAdditionalInformation() { + return null; + } + }; + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client_secret cannot be blank"); + } + @Test void createClientNoSecretForImplicitIsValid() { UaaClientDetails clientDetails = new UaaClientDetails("client", null, "openid", "implicit", "uaa.resource"); From 889976046ec4ea5a3a437f76b949152f20dc25f9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 11:19:29 +0200 Subject: [PATCH 112/130] fix(review): validate nested zone mTLS claim configuration --- .../ZoneEndpointsClientDetailsValidator.java | 28 +++++++++++++++ ...eEndpointsClientDetailsValidatorTests.java | 36 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 94b433f45d9..30d38b0d884 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Component; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed; @@ -62,6 +63,7 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); checkMtlsClientConfigAllowed(additionalInformation, mtlsEnabled, clientDetails.getClientId()); validateTlsClientAuthClaimConfig(additionalInformation, clientDetails.getClientId()); + validateTlsClientAuthClaimConfig(getNestedTlsClientAuthClaimConfig(additionalInformation), clientDetails.getClientId()); boolean hasTlsClientAuthCa = hasNonblankTlsClientAuthCa(additionalInformation); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || @@ -119,6 +121,32 @@ static boolean hasNonblankTlsClientAuthCa(Map additionalInformat return false; } + private static Map getNestedTlsClientAuthClaimConfig(Map additionalInformation) { + Object rawConfig = additionalInformation.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + Map nestedConfig = new HashMap<>(); + if (rawConfig instanceof TlsClientAuthConfiguration config) { + if (config.getClaimMappings() != null) { + nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, config.getClaimMappings()); + } + if (config.getSubTemplate() != null) { + nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, config.getSubTemplate()); + } + if (config.getAudTemplates() != null) { + nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, config.getAudTemplates()); + } + if (config.getRequiredClaims() != null) { + nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, config.getRequiredClaims()); + } + } else if (rawConfig instanceof Map config) { + config.forEach((key, value) -> { + if (key instanceof String name) { + nestedConfig.put(name, value); + } + }); + } + return nestedConfig; + } + @Override public ClientSecretValidator getClientSecretValidator() { return this.clientSecretValidator; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index 882fbfba9cc..d5591435a55 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -202,6 +202,42 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); } + @Test + void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthClaimMappingHasInvalidField() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "ca-pem", Collections.singletonList(new TlsClientAuthConfiguration.ClaimMapping(null, null, "claim")))); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("invalid field"); + } + + @Test + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaJsonMapClaimMappingHasInvalidField() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map malformedClaimMapping = new HashMap<>(); + malformedClaimMapping.put("field", null); + malformedClaimMapping.put("claim", "claim"); + Map tlsClientAuthConfig = new HashMap<>(); + tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + Collections.singletonList(malformedClaimMapping)); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("invalid field"); + } + @Test void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthCaIsBlank() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); From 696799064cd16fa39133f1c99e8d949a145dfa1b Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 11:55:35 +0200 Subject: [PATCH 113/130] fix(review): validate nested zone mTLS configuration --- .../ZoneEndpointsClientDetailsValidator.java | 12 ++- ...eEndpointsClientDetailsValidatorTests.java | 91 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 30d38b0d884..95fff450ec8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -110,7 +110,10 @@ static boolean hasNonblankTlsClientAuthCa(Map additionalInformat if (rawConfig instanceof TlsClientAuthConfiguration config) { return TlsClientAuthConfiguration.isConfigured(config); } - if (rawConfig instanceof Map) { + if (rawConfig instanceof Map config) { + if (!(config.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) instanceof String pem) || pem.isBlank()) { + return false; + } try { return TlsClientAuthConfiguration.isConfigured( JsonUtils.convertValue(rawConfig, TlsClientAuthConfiguration.class)); @@ -144,6 +147,13 @@ private static Map getNestedTlsClientAuthClaimConfig(Map zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); } + @ParameterizedTest + @MethodSource("unsupportedNestedTlsClientAuthCaValues") + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUnsupportedCaValue(Object ca) { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map tlsClientAuthConfig = new HashMap<>(); + tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, ca); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client_secret cannot be blank"); + } + + @ParameterizedTest + @MethodSource("invalidNestedTypedTlsClientAuthConfigurations") + void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthConfigurationHasUndeclaredClaimReference( + String property, TlsClientAuthConfiguration tlsClientAuthConfig) { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + clientDetails.setTlsClientAuthConfiguration(tlsClientAuthConfig); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(property) + .hasMessageContaining("undeclared"); + } + + @ParameterizedTest + @MethodSource("invalidNestedMapTlsClientAuthConfigurations") + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUndeclaredClaimReference( + String property, Map tlsClientAuthConfig) { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(property) + .hasMessageContaining("undeclared"); + } + @Test void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthClaimMappingHasInvalidField() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); @@ -325,4 +380,40 @@ void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { assertThat(validated.getClientId()).isEqualTo(clientDetails.getClientId()); } + + private static Stream unsupportedNestedTlsClientAuthCaValues() { + return Stream.of(42, true); + } + + private static Stream invalidNestedTypedTlsClientAuthConfigurations() { + TlsClientAuthConfiguration subTemplateConfig = new TlsClientAuthConfiguration("ca-pem", null); + subTemplateConfig.setSubTemplate("{undeclared}"); + TlsClientAuthConfiguration audTemplatesConfig = new TlsClientAuthConfiguration("ca-pem", null); + audTemplatesConfig.setAudTemplates(Collections.singletonList("{undeclared}")); + TlsClientAuthConfiguration requiredClaimsConfig = new TlsClientAuthConfiguration("ca-pem", null); + requiredClaimsConfig.setRequiredClaims(Map.of("undeclared", "value")); + return Stream.of( + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, subTemplateConfig), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, audTemplatesConfig), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, requiredClaimsConfig)); + } + + private static Stream invalidNestedMapTlsClientAuthConfigurations() { + return Stream.of( + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + nestedTlsClientAuthConfig(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{undeclared}")), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + nestedTlsClientAuthConfig(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + Collections.singletonList("{undeclared}"))), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + nestedTlsClientAuthConfig(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("undeclared", "value")))); + } + + private static Map nestedTlsClientAuthConfig(String property, Object value) { + Map config = new HashMap<>(); + config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + config.put(property, value); + return config; + } } From eaafe83c0f1478b94a0e5f01076e5706839752a9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 12:47:23 +0200 Subject: [PATCH 114/130] fix(review): fail closed for nested mTLS configuration --- .../client/ClientAdminEndpointsValidator.java | 6 +++- .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 4 +++ .../ZoneEndpointsClientDetailsValidator.java | 22 ++++++++++++-- .../ClientAdminEndpointsValidatorTests.java | 30 +++++++++++++++++++ ...eEndpointsClientDetailsValidatorTests.java | 30 +++++++++++++++++++ .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 21 +++++++++++++ 6 files changed, 110 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index de4379f7a69..9bf1e02ad3d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -496,7 +496,11 @@ public static void validateTlsClientAuthClaimConfig(Map addition } if (audTemplates != null) { for (String template : audTemplates) { - if (template != null && !template.isBlank()) { + if (template == null) { + throw new InvalidClientDetailsException( + "tls-client-auth-aud-templates entry cannot be null for client_id=" + clientId); + } + if (!template.isBlank()) { checkTemplateLength(template, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, clientId); validateTemplatePlaceholders(template, declaredClaims, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, clientId); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 5aa5cbc62ac..4b03c94d504 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -165,6 +165,10 @@ public Map enhance(Map claims, OAuth2Authenticat if (config.getAudTemplates() != null && !config.getAudTemplates().isEmpty()) { List audList = new ArrayList<>(); for (String tmpl : config.getAudTemplates()) { + // Legacy persisted clients can contain null entries created before validation. + if (tmpl == null) { + continue; + } String rendered = renderTemplate(tmpl, vars); if (rendered != null) { audList.add(rendered); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 95fff450ec8..b350f6882be 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -12,9 +12,11 @@ import org.springframework.security.core.authority.AuthorityUtils; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.springframework.stereotype.Component; +import tools.jackson.core.type.TypeReference; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed; @@ -150,13 +152,29 @@ private static Map getNestedTlsClientAuthClaimConfig(Map nestedConfig) { + Object rawMappings = nestedConfig.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings == null) { + return true; + } + if (rawMappings instanceof String mappingsJson) { + try { + return JsonUtils.readValue(mappingsJson, + new TypeReference>() {}) == null; + } catch (Exception e) { + // Leave malformed JSON to the shared validator so it preserves its existing error. + return false; + } + } + return false; + } + @Override public ClientSecretValidator getClientSecretValidator() { return this.clientSecretValidator; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index ad720d9394f..cb98643c0cd 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -474,6 +474,36 @@ void validateTlsClientAuthClaimConfig_rejectsAudTemplateReferencingUndeclaredCla .isInstanceOf(InvalidClientDetailsException.class); } + @Test + void validateTlsClientAuthClaimConfig_rejectsNativeNullAudTemplateEntry() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid")), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + Collections.singletonList(null)); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES) + .hasMessageContaining("entry cannot be null") + .hasMessageContaining("client-id"); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsJsonNullAudTemplateEntry() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid")), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + "[null]"); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES) + .hasMessageContaining("entry cannot be null") + .hasMessageContaining("client-id"); + } + @Test void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsReferencingUndeclaredClaim() { Map info = new java.util.HashMap<>(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index c24240ed3ca..8a845dbf29b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -257,6 +257,27 @@ void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUndeclared .hasMessageContaining("undeclared"); } + @ParameterizedTest + @MethodSource("parserNullNestedMapClaimMappings") + void rejectsClientWithSuppliedSecretWhenNestedTlsClientAuthClaimMappingsParseToNull( + String claimMappings, String property, Object value) { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("supplied-secret"); + Map tlsClientAuthConfig = nestedTlsClientAuthConfig(property, value); + tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, claimMappings); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(property) + .hasMessageContaining("undeclared"); + } + @Test void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthClaimMappingHasInvalidField() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); @@ -410,6 +431,15 @@ private static Stream invalidNestedMapTlsClientAuthConfigurations() { Map.of("undeclared", "value")))); } + private static Stream parserNullNestedMapClaimMappings() { + return Stream.of( + Arguments.of("", TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{undeclared}"), + Arguments.of(" ", TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + Collections.singletonList("{undeclared}")), + Arguments.of("null", TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("undeclared", "value"))); + } + private static Map nestedTlsClientAuthConfig(String property, Object value) { Map config = new HashMap<>(); config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 8c3aa9911c5..66083ca1743 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -15,6 +15,7 @@ import java.io.Serializable; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -260,6 +261,26 @@ void audTemplatesRenderedAndOverrideDefault() throws Exception { ); } + @Test + void skipsNullAudTemplateEntryForPersistedClient() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(Arrays.asList(null, "o/{cf.org}")); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result.get("aud")).asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class)) + .containsExactly("o/org-guid"); + } + @Test void subOmittedWhenTemplateVarMissing() throws Exception { // Cert has no OU fields → cf.org will not be in vars From 34581fd7b8cec93ae404e68385ec5cf5be7cf108 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 15:44:26 +0200 Subject: [PATCH 115/130] docs(review): align mTLS examples with JWT federation --- docs/UAA-Client-Authentication.md | 2 +- .../source/index.html.md.erb | 2 +- .../identity/uaa/login/TokenEndpointDocs.java | 25 ++++++++++++++----- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 8764075e34e..478e12fa44a 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -141,7 +141,7 @@ present a certificate whose chain validates to the configured CA; no separate | `tls-client-auth-ca` | yes | PEM-encoded CA certificate. This is the per-client mTLS selector: requests to the fixed `/oauth/mtls/token` endpoint authenticate with a presented leaf certificate only when it chains to this CA. | | `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | | `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values already produced by `tls-client-auth-claim-mappings`. When configured, authentication fails unless every entry matches exactly -- e.g. `{space_guid: ""}` scopes this client to a single CF space, even if other clients share the same `tls-client-auth-ca`. | -| `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`) to JWT claim names, optionally extracting a capture group via `pattern`. | +| `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`, `subject_o`) to JWT claim names, optionally extracting a capture group via `pattern`. | | `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. | | `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. | diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 4b66a82577b..b5cf3df1bd2 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -278,7 +278,7 @@ curl --cert /path/to/client-cert.pem \ --cacert /path/to/uaa-server-ca.pem \ --request POST \ --header 'Accept: application/json' \ - --data 'grant_type=client_credentials&client_id=&token_format=opaque' \ + --data 'grant_type=client_credentials&client_id=&token_format=jwt' \ https://uaa.example.com/oauth/mtls/token ``` diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index 5dd198a5157..12cadf50aa8 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -18,6 +18,7 @@ import org.cloudfoundry.identity.uaa.mock.token.AbstractTokenMockMvcTests; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.oauth.common.OAuth2RefreshToken; +import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; @@ -334,7 +335,7 @@ void getTokenUsingAuthCodeGrantWithAuthorizationHeader() throws Exception { .param(CLIENT_ID, "login") .param(GRANT_TYPE, GRANT_TYPE_AUTHORIZATION_CODE) .param("code", auth.code()) - .param(REQUEST_TOKEN_FORMAT, OPAQUE.getStringValue()) + .param(REQUEST_TOKEN_FORMAT, JWT.getStringValue()) .param(PkceValidationService.CODE_VERIFIER, UaaTestAccounts.CODE_VERIFIER) .param(REDIRECT_URI, auth.redirect()); @@ -530,7 +531,11 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { String clientId = "mtlsdocclient" + generator.generate(); setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, false, null, null, -1, IdentityZone.getUaa(), - Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); + Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + Collections.singletonList(new TlsClientAuthConfiguration.ClaimMapping( + "subject_cn", null, "instance_guid")))); clientDetailsService.updateClientSecret(clientId, null); assertThat(clientDetailsService.loadClientByClientId(clientId).getClientSecret()).isNull(); @@ -539,7 +544,7 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { .contentType(APPLICATION_FORM_URLENCODED) .param(CLIENT_ID, clientId) .param(GRANT_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS) - .param(REQUEST_TOKEN_FORMAT, OPAQUE.getStringValue()) + .param(REQUEST_TOKEN_FORMAT, JWT.getStringValue()) // RawPeerCertificateCaptureFilter.isMtlsTokenPath(...) matches on the *effective* // servlet path (post-ZonePathContextRewritingFilter); MockMvc does not compute this // itself from the request URI the way a real DispatcherServlet mapping would, so it @@ -550,7 +555,8 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { Snippet formParameters = formParameters( clientIdParameter, grantTypeParameter.description("the type of authentication being used to obtain the token, in this case `client_credentials`"), - opaqueFormatParameter + parameterWithName(REQUEST_TOKEN_FORMAT).optional("jwt").type(STRING) + .description("Set to `jwt` to receive a JSON Web Token containing the mTLS certificate-derived claims and RFC 8705 confirmation claim.") ); Snippet responseFields = responseFields( @@ -561,9 +567,16 @@ void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { jtiFieldDescriptor ); - mockMvc.perform(postForToken) + MvcResult result = mockMvc.perform(postForToken) .andExpect(status().isOk()) - .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), formParameters, responseFields)); + .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), formParameters, responseFields)) + .andReturn(); + + Map tokenResponse = JsonUtils.readValue(result.getResponse().getContentAsString(), Map.class); + Map claims = JsonUtils.readValue(JwtHelper.decode((String) tokenResponse.get("access_token")).getClaims(), Map.class); + assertThat(claims).containsEntry("instance_guid", "mtls-doc-client"); + assertThat((Map) claims.get("cnf")).containsKey("x5t#S256"); + assertThat((String) ((Map) claims.get("cnf")).get("x5t#S256")).isNotBlank(); } private static KeyPair generateKeyPair() throws Exception { From 7abdc2686d7ece16a70b7dcfbb026e4c10e48f9a Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 15:45:17 +0200 Subject: [PATCH 116/130] docs(review): retain opaque auth code example --- .../org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index 12cadf50aa8..6741df90fca 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -335,7 +335,7 @@ void getTokenUsingAuthCodeGrantWithAuthorizationHeader() throws Exception { .param(CLIENT_ID, "login") .param(GRANT_TYPE, GRANT_TYPE_AUTHORIZATION_CODE) .param("code", auth.code()) - .param(REQUEST_TOKEN_FORMAT, JWT.getStringValue()) + .param(REQUEST_TOKEN_FORMAT, OPAQUE.getStringValue()) .param(PkceValidationService.CODE_VERIFIER, UaaTestAccounts.CODE_VERIFIER) .param(REDIRECT_URI, auth.redirect()); From 316de20500da37808e1111537e58ab24d63f4b05 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 28 Aug 2026 15:53:36 +0200 Subject: [PATCH 117/130] fix: extract all OU values from multi-valued RDNs --- .../oauth/tls/TlsClientAuthentication.java | 28 +++++++++++++------ .../tls/TlsClientAuthenticationTest.java | 19 +++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index 06da79b7270..fd7502f56d5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -461,19 +461,34 @@ private static List parseRdnsMostSpecificFirst(String dn) { * is case-insensitive, per LDAP semantics. Returns {@code null} if not present. */ private static String rdnAttributeValue(Rdn rdn, String type) { + List values = rdnAttributeValues(rdn, type); + return values.isEmpty() ? null : values.get(0); + } + + /** + * Returns every value of an attribute from an RDN. A multi-valued RDN can contain the same + * attribute type more than once, as Diego instance identity certificates do for OU. + */ + private static List rdnAttributeValues(Rdn rdn, String type) { + List values = new ArrayList<>(); try { NamingEnumeration attrs = rdn.toAttributes().getAll(); while (attrs.hasMore()) { Attribute attr = attrs.next(); if (attr.getID().equalsIgnoreCase(type)) { - Object value = attr.get(); - return value == null ? null : value.toString(); + NamingEnumeration attributeValues = attr.getAll(); + while (attributeValues.hasMore()) { + Object value = attributeValues.next(); + if (value != null) { + values.add(value.toString()); + } + } } } } catch (NamingException e) { - // fall through to null + // fall through to values collected so far } - return null; + return values; } /** @@ -498,10 +513,7 @@ private static String extractRdnValue(String dn, String type) { private static List extractOus(String dn) { List ous = new ArrayList<>(); for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { - String value = rdnAttributeValue(rdn, "OU"); - if (value != null) { - ous.add(value); - } + ous.addAll(rdnAttributeValues(rdn, "OU")); } return ous; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 19a5e89ae04..127ca4afa9c 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -79,6 +79,25 @@ void extractClaimMappingValuesExtractsSubjectCnOuAndO() throws Exception { assertThat(vars).containsEntry("org_name", "Cloud Foundry"); } + @Test + void extractClaimMappingValuesExtractsEveryOuFromMultiValuedRdn() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=organization:org-guid+OU=space:space-guid+OU=app:app-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "org_guid") + )); + + assertThat(service.extractClaimMappingValues(cert, config)).containsExactlyInAnyOrderEntriesOf(Map.of( + "app_guid", "app-guid", + "space_guid", "space-guid", + "org_guid", "org-guid" + )); + } + @Test void extractClaimMappingValuesReturnsEmptyMapWhenNoClaimMappingsConfigured() throws Exception { KeyPair kp = generateKeyPair(); From e902aef94bf416688019337d028f1049a7a58484 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 31 Aug 2026 11:53:32 +0200 Subject: [PATCH 118/130] docs(review): verify mTLS JWT certificate binding --- docs/UAA-Client-Authentication.md | 2 +- .../oauth/tls/TlsClientAuthentication.java | 13 +++++++---- .../identity/uaa/login/TokenEndpointDocs.java | 23 +++++++++++++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 478e12fa44a..7c4445c2d85 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -141,7 +141,7 @@ present a certificate whose chain validates to the configured CA; no separate | `tls-client-auth-ca` | yes | PEM-encoded CA certificate. This is the per-client mTLS selector: requests to the fixed `/oauth/mtls/token` endpoint authenticate with a presented leaf certificate only when it chains to this CA. | | `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | | `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values already produced by `tls-client-auth-claim-mappings`. When configured, authentication fails unless every entry matches exactly -- e.g. `{space_guid: ""}` scopes this client to a single CF space, even if other clients share the same `tls-client-auth-ca`. | -| `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`, `subject_o`) to JWT claim names, optionally extracting a capture group via `pattern`. | +| `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`, `subject_o`) to JWT claim names. `subject_cn` and `subject_o` map their values directly; `pattern` is supported only for `subject_ou`, where it extracts a capture group. | | `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. | | `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. | diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java index fd7502f56d5..222b8ba28e9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -456,9 +456,10 @@ private static List parseRdnsMostSpecificFirst(String dn) { } /** - * Returns the value of the given attribute {@code type} (e.g. {@code "CN"}) from an RDN, + * Returns one value of the given attribute {@code type} (e.g. {@code "CN"}) from an RDN, * including multi-valued RDNs (attributes joined by {@code +}). Attribute type matching - * is case-insensitive, per LDAP semantics. Returns {@code null} if not present. + * is case-insensitive, per LDAP semantics. Where an RDN contains repeated AVAs of the same + * type, the chosen value follows provider enumeration order. Returns {@code null} if not present. */ private static String rdnAttributeValue(Rdn rdn, String type) { List values = rdnAttributeValues(rdn, type); @@ -507,8 +508,8 @@ private static String extractRdnValue(String dn, String type) { } /** - * Collects all OU values from a RFC 2253 DN string, in order. - * Handles multi-valued RDNs (attributes joined by {@code +}). + * Collects all matching OU AVAs from an RFC 2253 DN string, including multi-valued RDNs + * (attributes joined by {@code +}). */ private static List extractOus(String dn) { List ous = new ArrayList<>(); @@ -520,7 +521,9 @@ private static List extractOus(String dn) { /** * Returns the first captured group from the first OU that matches {@code patternStr}. - * When {@code patternStr} is null or blank, returns the first OU value verbatim. + * When {@code patternStr} is null or blank, returns the first collected OU value verbatim; + * selection among repeated AVAs follows collected/provider order. Use a pattern to select a + * specific value. */ private static String matchFirstOu(List ous, String patternStr) { if (patternStr == null || patternStr.isBlank()) { diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index 6741df90fca..504fe43d6d0 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -18,6 +18,7 @@ import org.cloudfoundry.identity.uaa.mock.token.AbstractTokenMockMvcTests; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.oauth.common.OAuth2RefreshToken; +import org.cloudfoundry.identity.uaa.oauth.jwt.Jwt; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; @@ -73,6 +74,7 @@ import java.net.URI; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.MessageDigest; import java.security.PrivateKey; import java.security.PublicKey; import java.security.Security; @@ -105,6 +107,7 @@ import static org.cloudfoundry.identity.uaa.provider.saml.TestCredentialObjects.legacyPassphrase; import static org.cloudfoundry.identity.uaa.provider.saml.idp.SamlTestUtils.createLocalSamlIdpDefinition; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.cloudfoundry.identity.uaa.test.SnippetUtils.parameterWithName; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpHeaders.HOST; @@ -552,8 +555,12 @@ TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), .servletPath("/oauth/mtls/token") .requestAttr("jakarta.servlet.request.X509Certificate", new X509Certificate[]{leafCert}); + ParameterDescriptor mtlsClientIdParameter = parameterWithName(CLIENT_ID).required().type(STRING) + .description("The client ID whose tls-client-auth-ca selects the certificate trust anchor for this mTLS token request."); + assertThat(mtlsClientIdParameter.getAttributes()).containsEntry("constraints", SnippetUtils.REQUIRED); + Snippet formParameters = formParameters( - clientIdParameter, + mtlsClientIdParameter, grantTypeParameter.description("the type of authentication being used to obtain the token, in this case `client_credentials`"), parameterWithName(REQUEST_TOKEN_FORMAT).optional("jwt").type(STRING) .description("Set to `jwt` to receive a JSON Web Token containing the mTLS certificate-derived claims and RFC 8705 confirmation claim.") @@ -573,10 +580,18 @@ TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), .andReturn(); Map tokenResponse = JsonUtils.readValue(result.getResponse().getContentAsString(), Map.class); - Map claims = JsonUtils.readValue(JwtHelper.decode((String) tokenResponse.get("access_token")).getClaims(), Map.class); + Jwt accessToken = JwtHelper.decode((String) tokenResponse.get("access_token")); + String kid = accessToken.getHeader().getKid(); + assertThat(kid).isNotBlank(); + assertThatCode(() -> accessToken.verifySignature(keyInfoService.getKey(kid).getVerifier())) + .doesNotThrowAnyException(); + + Map claims = JsonUtils.readValue(accessToken.getClaims(), Map.class); assertThat(claims).containsEntry("instance_guid", "mtls-doc-client"); - assertThat((Map) claims.get("cnf")).containsKey("x5t#S256"); - assertThat((String) ((Map) claims.get("cnf")).get("x5t#S256")).isNotBlank(); + String expectedThumbprint = Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256").digest(leafCert.getEncoded())); + assertThat((Map) claims.get("cnf")) + .containsEntry("x5t#S256", expectedThumbprint); } private static KeyPair generateKeyPair() throws Exception { From 86174f39aeafff8a65dad4a0f751b0f892d8812f Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 31 Aug 2026 12:25:22 +0200 Subject: [PATCH 119/130] docs(review): render required mTLS client ID --- .../identity/uaa/login/TokenEndpointDocs.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index 504fe43d6d0..10e94157f0a 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -72,6 +72,8 @@ import java.io.StringWriter; import java.math.BigInteger; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.MessageDigest; @@ -556,7 +558,7 @@ TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), .requestAttr("jakarta.servlet.request.X509Certificate", new X509Certificate[]{leafCert}); ParameterDescriptor mtlsClientIdParameter = parameterWithName(CLIENT_ID).required().type(STRING) - .description("The client ID whose tls-client-auth-ca selects the certificate trust anchor for this mTLS token request."); + .description("Required. The client ID whose tls-client-auth-ca selects the certificate trust anchor for this mTLS token request."); assertThat(mtlsClientIdParameter.getAttributes()).containsEntry("constraints", SnippetUtils.REQUIRED); Snippet formParameters = formParameters( @@ -579,6 +581,14 @@ TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), formParameters, responseFields)) .andReturn(); + String formParametersSnippet = Files.readString(Path.of( + System.getProperty("docs.build.generated.snippets.dir"), + "TokenEndpointDocs", + "getTokenUsingClientCredentialGrantWithTlsClientAuth", + "form-parameters.md")); + assertThat(formParametersSnippet) + .contains("`client_id`", "Required."); + Map tokenResponse = JsonUtils.readValue(result.getResponse().getContentAsString(), Map.class); Jwt accessToken = JwtHelper.decode((String) tokenResponse.get("access_token")); String kid = accessToken.getHeader().getKid(); From d3a2bda73d210ade52ff76bf61b218f37059c132 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 31 Aug 2026 14:15:22 +0200 Subject: [PATCH 120/130] fix(review): validate mTLS properties without mappings --- .../client/ClientAdminEndpointsValidator.java | 36 +++++++++---------- .../ClientAdminEndpointsValidatorTests.java | 13 +++++++ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 9bf1e02ad3d..7c223f3c92f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -411,33 +411,33 @@ public static void checkMtlsClientConfigAllowed(Map additionalIn * exception at token-issuance time (see {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#extractClaimMappingValues} * and {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer#enhance}). * - *

A no-op when {@code additionalInfo} is {@code null} or has no - * {@code tls-client-auth-claim-mappings} key at all. + *

A no-op when {@code additionalInfo} is {@code null}. */ public static void validateTlsClientAuthClaimConfig(Map additionalInfo, String clientId) { - if (additionalInfo == null - || !additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS)) { + if (additionalInfo == null) { return; } - List claimMappings; - try { - Object rawMappings = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); - if (rawMappings instanceof String mappingsJson) { - claimMappings = JsonUtils.readValue(mappingsJson, - new TypeReference>() {}); - } else { - claimMappings = JsonUtils.readValue( - JsonUtils.writeValueAsString(rawMappings), - new TypeReference>() {}); + List claimMappings = List.of(); + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS)) { + try { + Object rawMappings = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings instanceof String mappingsJson) { + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } else { + claimMappings = JsonUtils.readValue( + JsonUtils.writeValueAsString(rawMappings), + new TypeReference>() {}); + } + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-claim-mappings for client_id=" + clientId + ": " + e.getMessage(), e); } - } catch (Exception e) { - throw new InvalidClientDetailsException( - "Invalid tls-client-auth-claim-mappings for client_id=" + clientId + ": " + e.getMessage(), e); } if (claimMappings == null) { - return; + claimMappings = List.of(); } Set declaredClaims = new HashSet<>(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index cb98643c0cd..b8358d7252b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -516,6 +516,19 @@ void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsReferencingUndeclared .isInstanceOf(InvalidClientDetailsException.class); } + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsWithoutClaimMappings() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_instance_guid", "instance-guid")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS) + .hasMessageContaining("undeclared claim") + .hasMessageContaining("client-id"); + } + @Test void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsWithNullValue() { Map info = new java.util.HashMap<>(); From 1e83ee707954d3865eab4eb41d5e95868c3a4253 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 31 Aug 2026 15:03:42 +0200 Subject: [PATCH 121/130] fix(review): validate trusted proxy CA --- .../client/ClientAdminEndpointsValidator.java | 10 ++++++ .../ClientAdminEndpointsValidatorTests.java | 32 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 7c223f3c92f..21cc6c5ee35 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -18,6 +18,7 @@ import org.cloudfoundry.identity.uaa.resources.QueryableResourceManager; import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor; import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.PemCertificateParser; import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.zone.ClientSecretValidator; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; @@ -381,6 +382,15 @@ public static void checkMtlsClientConfigAllowed(Map additionalIn "tls-client-auth-ca / tls-client-auth-trusted-proxy-ca require uaa.mtls-enabled " + "to be true on this UAA deployment. ClientID: " + clientId); } + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA)) { + try { + PemCertificateParser.parseCertificate((String) additionalInfo.get( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA)); + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-trusted-proxy-ca for client_id=" + clientId + ": " + e.getMessage(), e); + } + } } /** diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index b8358d7252b..6d0ebd97bd6 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -353,7 +353,6 @@ void allowsTlsClientAuthCaWhenMtlsEnabled() { client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); Map additionalInfo = new java.util.HashMap<>(); additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); client.setAdditionalInformation(additionalInfo); ClientDetails validated = mtlsEnabledValidator.validate(client, false, false); @@ -362,6 +361,37 @@ void allowsTlsClientAuthCaWhenMtlsEnabled() { .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); } + @Test + void rejectsBlankTlsClientAuthTrustedProxyCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, " "); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA) + .hasMessageContaining("blank"); + } + + @Test + void rejectsMalformedTlsClientAuthTrustedProxyCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "not-a-certificate"); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + } + @Test void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( From bb516a8589dcc614ca72fb8e53dcba32d11c0275 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 31 Aug 2026 15:23:30 +0200 Subject: [PATCH 122/130] test: remove invalid trusted proxy CA fixture --- .../uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index 8a845dbf29b..459d2a18e13 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -157,14 +157,12 @@ void allowsTlsClientAuthCaWhenMtlsEnabled() { Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); clientDetails.setAdditionalInformation(additionalInfo); ClientDetails validated = zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); assertThat(validated.getAdditionalInformation()) - .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem") - .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); } @Test From db63eebc4c492e0d3fe8e750d2f0c567a17756d6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 31 Aug 2026 15:57:03 +0200 Subject: [PATCH 123/130] fix(review): validate configured client auth CA --- .../client/ClientAdminEndpointsValidator.java | 23 ++++++ .../ClientAdminEndpointsValidatorTests.java | 48 ++++++++++- ...eEndpointsClientDetailsValidatorTests.java | 80 ++++++++++++++----- 3 files changed, 130 insertions(+), 21 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 21cc6c5ee35..7882c5e6636 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -391,6 +391,29 @@ public static void checkMtlsClientConfigAllowed(Map additionalIn "Invalid tls-client-auth-trusted-proxy-ca for client_id=" + clientId + ": " + e.getMessage(), e); } } + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA)) { + try { + PemCertificateParser.parseCertificate(getTlsClientAuthCaPem(additionalInfo)); + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-ca for client_id=" + clientId + ": " + e.getMessage(), e); + } + } + } + + private static String getTlsClientAuthCaPem(Map additionalInfo) { + Object rawConfig = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (rawConfig instanceof String pem) { + return pem; + } + if (rawConfig instanceof TlsClientAuthConfiguration config) { + return config.getTrustedCaPem(); + } + if (rawConfig instanceof Map config + && config.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) instanceof String pem) { + return pem; + } + throw new IllegalArgumentException("Not a supported tls-client-auth-ca configuration."); } /** diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 6d0ebd97bd6..186dfa57a49 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -15,6 +15,7 @@ package org.cloudfoundry.identity.uaa.client; import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.cloudfoundry.identity.uaa.resources.QueryableResourceManager; import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor; @@ -35,6 +36,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.security.Security; import static org.assertj.core.api.Assertions.*; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; @@ -50,6 +52,30 @@ class ClientAdminEndpointsValidatorTests { + private static final String VALID_CERT = """ + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAOpOBuLToBXJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQwHhcNMTcwNzE0MTcxNDE4WhcNMTcwODEzMTcxNDE4WjBF + MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 + ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEA3+07F4S5Fz3wv/UFm/OWsJXm6s3pKI2mp4fSAY8rx9+0cyLAHsedWzeq + 5uKcDeRW858DOdnClaTOZC73FcvOmv1bw2eYcmfsbqHEhyR0dp+rDHt/7pr6kajC + yUvAW+hoRRSMpooiZckxrjJ7LOa5iqRyZRwshfGN+mFSygfVguMDKrsE2rvpK6/K + tkG/lcToLHiw4OnMnZ9ocrNRDAoCkzKGZTLJkUEr3MgOKmr2EO0P6KOAmNnOEmCf + 05ohcrUXeFZVnS5MMUzoGAOzBstZhA0dd7l297IDnWH9uIhCANCvZ9sovZWz/o3J + pc2LyXsaI1cV7O1cGV4aEEn8zzWWGwIDAQABo1AwTjAdBgNVHQ4EFgQUXBO1+qo7 + w6iiiv1pnm+zdrQ3CzkwHwYDVR0jBBgwFoAUXBO1+qo7w6iiiv1pnm+zdrQ3Czkw + DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT78lT5VEIetWPGk3szPz + CT9zNpR1F+7o3rvRTI6Psyjz4tGlyX5iU0Z99Xa9yimIEhWme2UVsgQ9uOzk2IgH + wMbB2TTP/RRK5+eO4BUu4zWWIXsIcfC6Rqw9Y3Hki+mRpuWMv+5pcOz/H+aYeSfy + WvVYfRZJOhcztysII4HWIxw8qqwBrf5kX8IRKZXay+A2W04A6kjjX3zfN2OzljTA + jZbtHedUGxSHvK8x6tHEwS0lZ9eZh+V4DWyRvrunwDCtA7zJQmrJd1qbM84H/1C8 + cAC6dglvc82n1BTAZbZwWHYt+Ro3Vp0GMPsZLOXJ0g03LbkhXg4krwXjJPD42nus + 3A== + -----END CERTIFICATE----- + """; + UaaClientDetails client; UaaClientDetails caller; ClientAdminEndpointsValidator validator; @@ -70,6 +96,7 @@ class ClientAdminEndpointsValidatorTests { @BeforeEach void createClient() { + Security.addProvider(new BouncyCastleFipsProvider()); client = new UaaClientDetails("newclient", "", "", "client_credentials", ""); client.setClientSecret("secret"); caller = new UaaClientDetails("caller", "", "", "client_credentials", "clients.write"); @@ -322,7 +349,7 @@ void rejectsTlsClientAuthCaWhenMtlsDisabled() { client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); Map additionalInfo = new java.util.HashMap<>(); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); client.setAdditionalInformation(additionalInfo); assertThatThrownBy(() -> mtlsDisabledValidator.validate(client, false, false)) @@ -352,13 +379,13 @@ void allowsTlsClientAuthCaWhenMtlsEnabled() { client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); Map additionalInfo = new java.util.HashMap<>(); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); client.setAdditionalInformation(additionalInfo); ClientDetails validated = mtlsEnabledValidator.validate(client, false, false); assertThat(validated.getAdditionalInformation()) - .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); } @Test @@ -392,6 +419,21 @@ void rejectsMalformedTlsClientAuthTrustedProxyCaWhenMtlsEnabled() { .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); } + @Test + void rejectsMalformedTlsClientAuthCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "not-a-certificate"); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + @Test void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index 459d2a18e13..fe3d46e4e36 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.oauth; import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.cloudfoundry.identity.uaa.client.ClientDetailsValidator.Mode; import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; @@ -22,6 +23,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.security.Security; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -40,9 +42,38 @@ @ExtendWith(PollutionPreventionExtension.class) class ZoneEndpointsClientDetailsValidatorTests { + private static final String VALID_CERT = """ + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAOpOBuLToBXJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQwHhcNMTcwNzE0MTcxNDE4WhcNMTcwODEzMTcxNDE4WjBF + MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 + ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEA3+07F4S5Fz3wv/UFm/OWsJXm6s3pKI2mp4fSAY8rx9+0cyLAHsedWzeq + 5uKcDeRW858DOdnClaTOZC73FcvOmv1bw2eYcmfsbqHEhyR0dp+rDHt/7pr6kajC + yUvAW+hoRRSMpooiZckxrjJ7LOa5iqRyZRwshfGN+mFSygfVguMDKrsE2rvpK6/K + tkG/lcToLHiw4OnMnZ9ocrNRDAoCkzKGZTLJkUEr3MgOKmr2EO0P6KOAmNnOEmCf + 05ohcrUXeFZVnS5MMUzoGAOzBstZhA0dd7l297IDnWH9uIhCANCvZ9sovZWz/o3J + pc2LyXsaI1cV7O1cGV4aEEn8zzWWGwIDAQABo1AwTjAdBgNVHQ4EFgQUXBO1+qo7 + w6iiiv1pnm+zdrQ3CzkwHwYDVR0jBBgwFoAUXBO1+qo7w6iiiv1pnm+zdrQ3Czkw + DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT78lT5VEIetWPGk3szPz + CT9zNpR1F+7o3rvRTI6Psyjz4tGlyX5iU0Z99Xa9yimIEhWme2UVsgQ9uOzk2IgH + wMbB2TTP/RRK5+eO4BUu4zWWIXsIcfC6Rqw9Y3Hki+mRpuWMv+5pcOz/H+aYeSfy + WvVYfRZJOhcztysII4HWIxw8qqwBrf5kX8IRKZXay+A2W04A6kjjX3zfN2OzljTA + jZbtHedUGxSHvK8x6tHEwS0lZ9eZh+V4DWyRvrunwDCtA7zJQmrJd1qbM84H/1C8 + cAC6dglvc82n1BTAZbZwWHYt+Ro3Vp0GMPsZLOXJ0g03LbkhXg4krwXjJPD42nus + 3A== + -----END CERTIFICATE----- + """; + @Mock private ClientSecretValidator mockClientSecretValidator; + @org.junit.jupiter.api.BeforeAll + static void addBouncyCastleFipsProvider() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + private ZoneEndpointsClientDetailsValidator zoneEndpointsClientDetailsValidator; @org.junit.jupiter.api.BeforeEach @@ -140,7 +171,7 @@ void rejectsTlsClientAuthCaWhenMtlsDisabled() { clientDetails.setClientSecret("secret"); Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); clientDetails.setAdditionalInformation(additionalInfo); assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) @@ -156,13 +187,13 @@ void allowsTlsClientAuthCaWhenMtlsEnabled() { clientDetails.setClientSecret("secret"); Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); clientDetails.setAdditionalInformation(additionalInfo); ClientDetails validated = zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); assertThat(validated.getAdditionalInformation()) - .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); } @Test @@ -172,7 +203,7 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaConfigured() { UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); clientDetails.setAdditionalInformation(additionalInfo); assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); @@ -184,11 +215,24 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsTypedConfigurat UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration("ca-pem", null)); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration(VALID_CERT, null)); assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); } + @Test + void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthCaIsMalformed() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration("not-a-certificate", null)); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + @Test void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); @@ -197,7 +241,7 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, - Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem")); + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT)); clientDetails.setAdditionalInformation(additionalInfo); assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); @@ -218,7 +262,7 @@ void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUnsupporte assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("client_secret cannot be blank"); + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); } @ParameterizedTest @@ -283,7 +327,7 @@ void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthClaimMappingH UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( - "ca-pem", Collections.singletonList(new TlsClientAuthConfiguration.ClaimMapping(null, null, "claim")))); + VALID_CERT, Collections.singletonList(new TlsClientAuthConfiguration.ClaimMapping(null, null, "claim")))); assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) @@ -299,7 +343,7 @@ void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaJsonMapClaimMapp malformedClaimMapping.put("field", null); malformedClaimMapping.put("claim", "claim"); Map tlsClientAuthConfig = new HashMap<>(); - tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, Collections.singletonList(malformedClaimMapping)); Map additionalInfo = new HashMap<>(); @@ -322,7 +366,7 @@ void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthCaIsBlank() { assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("client_secret cannot be blank"); + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); } @Test @@ -337,7 +381,7 @@ void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapIsMalformed() assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("client_secret cannot be blank"); + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); } @ParameterizedTest @@ -353,7 +397,7 @@ void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsBlank(final St assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("client_secret cannot be blank"); + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); } @Test @@ -368,7 +412,7 @@ void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsNotAString() { assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("client_secret cannot be blank"); + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); } @Test @@ -379,7 +423,7 @@ void stillValidatesSuppliedSecretWhenTlsClientAuthCaConfigured() { clientDetails.setClientSecret("supplied-secret"); Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); clientDetails.setAdditionalInformation(additionalInfo); zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); @@ -405,11 +449,11 @@ private static Stream unsupportedNestedTlsClientAuthCaValues() { } private static Stream invalidNestedTypedTlsClientAuthConfigurations() { - TlsClientAuthConfiguration subTemplateConfig = new TlsClientAuthConfiguration("ca-pem", null); + TlsClientAuthConfiguration subTemplateConfig = new TlsClientAuthConfiguration(VALID_CERT, null); subTemplateConfig.setSubTemplate("{undeclared}"); - TlsClientAuthConfiguration audTemplatesConfig = new TlsClientAuthConfiguration("ca-pem", null); + TlsClientAuthConfiguration audTemplatesConfig = new TlsClientAuthConfiguration(VALID_CERT, null); audTemplatesConfig.setAudTemplates(Collections.singletonList("{undeclared}")); - TlsClientAuthConfiguration requiredClaimsConfig = new TlsClientAuthConfiguration("ca-pem", null); + TlsClientAuthConfiguration requiredClaimsConfig = new TlsClientAuthConfiguration(VALID_CERT, null); requiredClaimsConfig.setRequiredClaims(Map.of("undeclared", "value")); return Stream.of( Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, subTemplateConfig), @@ -440,7 +484,7 @@ private static Stream parserNullNestedMapClaimMappings() { private static Map nestedTlsClientAuthConfig(String property, Object value) { Map config = new HashMap<>(); - config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem"); + config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); config.put(property, value); return config; } From 78e348fd290f20ac56e89f80f0025ec4a9c8e3d9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 31 Aug 2026 16:26:04 +0200 Subject: [PATCH 124/130] test: use valid client auth CA in bootstrap tests --- .../uaa/client/ClientAdminBootstrapTests.java | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java index 65520c8ecf1..c713e6cc9b1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.client; import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.cloudfoundry.identity.uaa.annotations.WithDatabaseContext; import org.cloudfoundry.identity.uaa.audit.event.EntityDeletedEvent; import org.cloudfoundry.identity.uaa.authentication.SystemAuthentication; @@ -35,6 +36,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.security.Security; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -61,6 +63,30 @@ @WithDatabaseContext class ClientAdminBootstrapTests { + private static final String VALID_CERT = """ + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAOpOBuLToBXJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQwHhcNMTcwNzE0MTcxNDE4WhcNMTcwODEzMTcxNDE4WjBF + MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 + ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEA3+07F4S5Fz3wv/UFm/OWsJXm6s3pKI2mp4fSAY8rx9+0cyLAHsedWzeq + 5uKcDeRW858DOdnClaTOZC73FcvOmv1bw2eYcmfsbqHEhyR0dp+rDHt/7pr6kajC + yUvAW+hoRRSMpooiZckxrjJ7LOa5iqRyZRwshfGN+mFSygfVguMDKrsE2rvpK6/K + tkG/lcToLHiw4OnMnZ9ocrNRDAoCkzKGZTLJkUEr3MgOKmr2EO0P6KOAmNnOEmCf + 05ohcrUXeFZVnS5MMUzoGAOzBstZhA0dd7l297IDnWH9uIhCANCvZ9sovZWz/o3J + pc2LyXsaI1cV7O1cGV4aEEn8zzWWGwIDAQABo1AwTjAdBgNVHQ4EFgQUXBO1+qo7 + w6iiiv1pnm+zdrQ3CzkwHwYDVR0jBBgwFoAUXBO1+qo7w6iiiv1pnm+zdrQ3Czkw + DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT78lT5VEIetWPGk3szPz + CT9zNpR1F+7o3rvRTI6Psyjz4tGlyX5iU0Z99Xa9yimIEhWme2UVsgQ9uOzk2IgH + wMbB2TTP/RRK5+eO4BUu4zWWIXsIcfC6Rqw9Y3Hki+mRpuWMv+5pcOz/H+aYeSfy + WvVYfRZJOhcztysII4HWIxw8qqwBrf5kX8IRKZXay+A2W04A6kjjX3zfN2OzljTA + jZbtHedUGxSHvK8x6tHEwS0lZ9eZh+V4DWyRvrunwDCtA7zJQmrJd1qbM84H/1C8 + cAC6dglvc82n1BTAZbZwWHYt+Ro3Vp0GMPsZLOXJ0g03LbkhXg4krwXjJPD42nus + 3A== + -----END CERTIFICATE----- + """; + private ClientAdminBootstrap clientAdminBootstrap; private MultitenantJdbcClientDetailsService multitenantJdbcClientDetailsService; private ClientMetadataProvisioning clientMetadataProvisioning; @@ -83,6 +109,7 @@ class ClientAdminBootstrapTests { @BeforeEach void setUpClientAdminTests() { + Security.addProvider(new BouncyCastleFipsProvider()); randomValueStringGenerator = new RandomValueStringGenerator(); IdentityZoneManager mockIdentityZoneManager = mock(IdentityZoneManager.class); @@ -651,7 +678,7 @@ void clientWithoutGrantTypeFails() { @Test void mtlsClientConfigRejectedWhenMtlsDisabled() { Map map = createClientMap("foo"); - map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); clients.put((String) map.get("id"), map); assertThatThrownBy(() -> clientAdminBootstrap.afterPropertiesSet()) @@ -685,13 +712,13 @@ void mtlsClientConfigAllowedWhenMtlsEnabled() { true); Map map = createClientMap("foo"); - map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); clients.put((String) map.get("id"), map); mtlsEnabledBootstrap.afterPropertiesSet(); ClientDetails created = multitenantJdbcClientDetailsService.loadClientByClientId("foo"); - assertThat(created.getAdditionalInformation()).containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + assertThat(created.getAdditionalInformation()).containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); } @Test @@ -709,7 +736,7 @@ void invalidTlsClientAuthClaimConfigIsRejectedDuringBootstrap() { true); Map map = createClientMap("foo"); - map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "some-ca-cert"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, List.of(Map.of("field", "invalid", "claim", "cf_app"))); clients.put((String) map.get("id"), map); From ad7a4dfb0ed9cf0ae6232845eedfc795b7f2a52a Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 1 Sep 2026 10:02:55 +0200 Subject: [PATCH 125/130] fix(review): gate mTLS endpoint and require flat client config --- docs/UAA-Client-Authentication.md | 2 +- .../client/ClientAdminEndpointsValidator.java | 4 +++ .../OauthEndpointSecurityConfiguration.java | 2 ++ .../uaa/client/ClientAdminBootstrapTests.java | 25 +++++++++++++++++++ .../ClientAdminEndpointsValidatorTests.java | 15 +++++++++++ ...uthEndpointSecurityConfigurationTests.java | 20 +++++++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfigurationTests.java diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 7c4445c2d85..073d2479b8f 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -141,7 +141,7 @@ present a certificate whose chain validates to the configured CA; no separate | `tls-client-auth-ca` | yes | PEM-encoded CA certificate. This is the per-client mTLS selector: requests to the fixed `/oauth/mtls/token` endpoint authenticate with a presented leaf certificate only when it chains to this CA. | | `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | | `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values already produced by `tls-client-auth-claim-mappings`. When configured, authentication fails unless every entry matches exactly -- e.g. `{space_guid: ""}` scopes this client to a single CF space, even if other clients share the same `tls-client-auth-ca`. | -| `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`, `subject_o`) to JWT claim names. `subject_cn` and `subject_o` map their values directly; `pattern` is supported only for `subject_ou`, where it extracts a capture group. | +| `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`, `subject_o`) to JWT claim names. `subject_cn` and `subject_o` map their values directly; `pattern` is supported only for `subject_ou`, where it extracts a capture group. Patterns are UAA administrator-controlled configuration and are evaluated on every mTLS authentication request; use efficient Java regular expressions and avoid patterns with catastrophic backtracking. | | `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. | | `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. | diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 7882c5e6636..c6db148e67a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -392,6 +392,10 @@ public static void checkMtlsClientConfigAllowed(Map additionalIn } } if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA)) { + if (!(additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) instanceof String)) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-ca for client_id=" + clientId + ": must be a PEM string."); + } try { PemCertificateParser.parseCertificate(getTlsClientAuthCaPem(additionalInfo)); } catch (Exception e) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java index 88053fde005..f12c59b991f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java @@ -51,6 +51,7 @@ import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -473,6 +474,7 @@ UaaFilterChain externalOAuthCallbackEndpointSecurity(HttpSecurity http) throws E * CSRF is disabled because this is a stateless machine-to-machine API endpoint. */ @Bean + @ConditionalOnProperty(name = "uaa.mtls-enabled", havingValue = "true") @Order(FilterChainOrder.OAUTH_11) UaaFilterChain mtlsTokenEndpointSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java index c713e6cc9b1..b674ceacbd5 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java @@ -721,6 +721,31 @@ void mtlsClientConfigAllowedWhenMtlsEnabled() { assertThat(created.getAdditionalInformation()).containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); } + @Test + void nestedTlsClientAuthConfigurationIsRejectedDuringBootstrap() { + ClientAdminBootstrap mtlsEnabledBootstrap = new ClientAdminBootstrap( + passwordEncoder, + multitenantJdbcClientDetailsService, + clientMetadataProvisioning, + true, + clients, + Collections.singleton(autoApproveId), + Collections.emptySet(), + null, + Collections.singleton(allowPublicId), + true); + + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT)); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(mtlsEnabledBootstrap::afterPropertiesSet) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + .hasMessageContaining("PEM string"); + } + @Test void invalidTlsClientAuthClaimConfigIsRejectedDuringBootstrap() { ClientAdminBootstrap mtlsEnabledBootstrap = new ClientAdminBootstrap( diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index 186dfa57a49..fce0197d3f5 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -388,6 +388,21 @@ void allowsTlsClientAuthCaWhenMtlsEnabled() { .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); } + @Test + void rejectsNestedTlsClientAuthConfigurationWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + client.setAdditionalInformation(Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT))); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + .hasMessageContaining("PEM string"); + } + @Test void rejectsBlankTlsClientAuthTrustedProxyCaWhenMtlsEnabled() { ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfigurationTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfigurationTests.java new file mode 100644 index 00000000000..2f9b4911eda --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfigurationTests.java @@ -0,0 +1,20 @@ +package org.cloudfoundry.identity.uaa.oauth.beans; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import static org.assertj.core.api.Assertions.assertThat; + +class OauthEndpointSecurityConfigurationTests { + + @Test + void createsMtlsTokenSecurityChainOnlyWhenMtlsIsEnabled() throws NoSuchMethodException { + ConditionalOnProperty condition = OauthEndpointSecurityConfiguration.class + .getDeclaredMethod("mtlsTokenEndpointSecurity", org.springframework.security.config.annotation.web.builders.HttpSecurity.class) + .getAnnotation(ConditionalOnProperty.class); + + assertThat(condition).isNotNull(); + assertThat(condition.name()).containsExactly("uaa.mtls-enabled"); + assertThat(condition.havingValue()).isEqualTo("true"); + } +} From 38a6a73a6af53010f27dbc95a9ace950a089aaab Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 1 Sep 2026 11:23:00 +0200 Subject: [PATCH 126/130] fix: remove nested mTLS client configuration --- .../identity/uaa/client/UaaClientDetails.java | 23 ++- .../uaa/client/UaaClientDetailsTest.java | 7 +- .../ClientDetailsAuthenticationProvider.java | 10 -- .../client/ClientAdminEndpointsValidator.java | 7 - .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 13 +- .../ZoneEndpointsClientDetailsValidator.java | 68 --------- ...entDetailsAuthenticationProviderTests.java | 28 ++-- ...eEndpointsClientDetailsValidatorTests.java | 134 +----------------- 8 files changed, 36 insertions(+), 254 deletions(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index e5292851882..cffab574439 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java @@ -313,15 +313,32 @@ public TlsClientAuthConfiguration getTlsClientAuthConfiguration() { public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAuthConfiguration) { this.tlsClientAuthConfiguration = tlsClientAuthConfiguration; - // Keep additionalInformation in sync so JDBC-loaded clients (which only - // persist the additional_information JSON column) see the same value. if (tlsClientAuthConfiguration != null) { - this.additionalInformation.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfiguration); + this.additionalInformation.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + tlsClientAuthConfiguration.getTrustedCaPem()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + tlsClientAuthConfiguration.getClaimMappings()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + tlsClientAuthConfiguration.getSubTemplate()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + tlsClientAuthConfiguration.getAudTemplates()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, + tlsClientAuthConfiguration.getTrustedProxyCaPem()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + tlsClientAuthConfiguration.getRequiredClaims()); } else { this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); } } + private void putOrRemove(String key, Object value) { + if (value == null) { + this.additionalInformation.remove(key); + } else { + this.additionalInformation.put(key, value); + } + } + @Override public boolean equals(Object obj) { if (this == obj) { diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java index 07d38f6a402..54d773546f7 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java @@ -220,14 +220,9 @@ void tlsClientAuthConfigRoundTripsViaJson() throws Exception { String json = new JsonMapper().writeValueAsString(details); UaaClientDetails deserialized = new JsonMapper().readValue(json, UaaClientDetails.class); - // @JsonIgnore on the typed field: after JSON round-trip the config is persisted via - // additionalInformation (@JsonAnySetter), not the typed getter. - // Authentication providers read it from additionalInformation and convert as needed. Object raw = deserialized.getAdditionalInformation() .get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); - assertThat(raw).isInstanceOf(Map.class); - assertThat(((Map) raw).get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA)) - .isEqualTo(config.getTrustedCaPem()); + assertThat(raw).isEqualTo(config.getTrustedCaPem()); } @Test diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index e8f81ff9df0..994310c11c9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -211,16 +211,6 @@ static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaCli return null; } Object rawConfig = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); - if (rawConfig instanceof TlsClientAuthConfiguration cfg) { - return cfg; // in-memory client (tests) - } - if (rawConfig instanceof Map) { - try { - return JsonUtils.convertValue(rawConfig, TlsClientAuthConfiguration.class); - } catch (Exception e) { - return null; - } - } if (rawConfig instanceof String pem) { try { List claimMappings = null; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index c6db148e67a..4c427173a33 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -410,13 +410,6 @@ private static String getTlsClientAuthCaPem(Map additionalInfo) if (rawConfig instanceof String pem) { return pem; } - if (rawConfig instanceof TlsClientAuthConfiguration config) { - return config.getTrustedCaPem(); - } - if (rawConfig instanceof Map config - && config.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) instanceof String pem) { - return pem; - } throw new IllegalArgumentException("Not a supported tls-client-auth-ca configuration."); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index 4b03c94d504..9c29a33824d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -216,24 +216,13 @@ private String renderTemplate(String template, Map vars) { /** * Builds a {@link TlsClientAuthConfiguration} from the client's {@code additionalInformation} map. - * Mirrors {@code ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration} so that - * DB-loaded clients (whose {@code tlsClientAuthConfiguration} field is null) are handled correctly. + * Reads the documented flat configuration from a DB-loaded client's additional information. */ private static TlsClientAuthConfiguration loadTlsConfig(Map info) { if (info == null) { return null; } Object raw = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); - if (raw instanceof TlsClientAuthConfiguration cfg) { - return cfg; // in-memory / test client - } - if (raw instanceof Map) { - try { - return JsonUtils.convertValue(raw, TlsClientAuthConfiguration.class); - } catch (Exception e) { - return null; - } - } if (raw instanceof String pem) { try { List claimMappings = null; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 55be5689ae6..ba10729f4c7 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -6,16 +6,12 @@ import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; -import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.authority.AuthorityUtils; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.springframework.stereotype.Component; -import tools.jackson.core.type.TypeReference; import java.util.Collections; -import java.util.HashMap; -import java.util.List; import java.util.Map; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed; @@ -64,7 +60,6 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); checkMtlsClientConfigAllowed(additionalInformation, mtlsEnabled, clientDetails.getClientId()); validateTlsClientAuthClaimConfig(additionalInformation, clientDetails.getClientId()); - validateTlsClientAuthClaimConfig(getNestedTlsClientAuthClaimConfig(additionalInformation), clientDetails.getClientId()); boolean hasTlsClientAuthCa = hasNonblankTlsClientAuthCa(additionalInformation); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || @@ -109,69 +104,6 @@ static boolean hasNonblankTlsClientAuthCa(Map additionalInformat if (rawConfig instanceof String pem) { return !pem.isBlank(); } - if (rawConfig instanceof TlsClientAuthConfiguration config) { - return TlsClientAuthConfiguration.isConfigured(config); - } - if (rawConfig instanceof Map config) { - if (!(config.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) instanceof String pem) || pem.isBlank()) { - return false; - } - try { - return TlsClientAuthConfiguration.isConfigured( - JsonUtils.convertValue(rawConfig, TlsClientAuthConfiguration.class)); - } catch (Exception e) { - return false; - } - } - return false; - } - - private static Map getNestedTlsClientAuthClaimConfig(Map additionalInformation) { - Object rawConfig = additionalInformation.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); - Map nestedConfig = new HashMap<>(); - if (rawConfig instanceof TlsClientAuthConfiguration config) { - if (config.getClaimMappings() != null) { - nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, config.getClaimMappings()); - } - if (config.getSubTemplate() != null) { - nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, config.getSubTemplate()); - } - if (config.getAudTemplates() != null) { - nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, config.getAudTemplates()); - } - if (config.getRequiredClaims() != null) { - nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, config.getRequiredClaims()); - } - } else if (rawConfig instanceof Map config) { - config.forEach((key, value) -> { - if (key instanceof String name) { - nestedConfig.put(name, value); - } - }); - } - if ((nestedConfig.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE) - || nestedConfig.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES) - || nestedConfig.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS)) - && hasNullNestedTlsClientAuthClaimMappings(nestedConfig)) { - nestedConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, Collections.emptyList()); - } - return nestedConfig; - } - - private static boolean hasNullNestedTlsClientAuthClaimMappings(Map nestedConfig) { - Object rawMappings = nestedConfig.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); - if (rawMappings == null) { - return true; - } - if (rawMappings instanceof String mappingsJson) { - try { - return JsonUtils.readValue(mappingsJson, - new TypeReference>() {}) == null; - } catch (Exception e) { - // Leave malformed JSON to the shared validator so it preserves its existing error. - return false; - } - } return false; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java index 7c08c442691..574dcf36f4e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -78,15 +78,10 @@ void regularTokenPathIsNotTlsClientAuth() { } @Test - void tlsConfigIsDeserializedFromRawMapInAdditionalInfo() { - // Simulate what happens when additionalInformation comes from the DB: - // the JSON is parsed to a LinkedHashMap, not TlsClientAuthConfiguration - Map rawMap = Map.of( - TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, - "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n" - ); + void tlsConfigIsReadFromFlatAdditionalInfo() { Map additionalInfo = new HashMap<>(); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, rawMap); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"); UaaClient mockClient = mock(UaaClient.class); when(mockClient.getAdditionalInformation()).thenReturn(additionalInfo); @@ -219,20 +214,17 @@ void validateTlsClientAuthEnforcesRequiredClaimsAgainstAClientSharingTheSameCa() request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, presentedChain); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); try { - TlsClientAuthConfiguration unconstrainedConfig = new TlsClientAuthConfiguration(toPem(caCert), null); - - TlsClientAuthConfiguration constrainedConfig = new TlsClientAuthConfiguration(toPem(caCert), List.of( - new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid") - )); - constrainedConfig.setRequiredClaims(Map.of("space_guid", "the-expected-space-guid")); - UaaClient unconstrainedClient = mock(UaaClient.class); when(unconstrainedClient.getAdditionalInformation()).thenReturn(Map.of( - TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, unconstrainedConfig)); + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); UaaClient constrainedClient = mock(UaaClient.class); - when(constrainedClient.getAdditionalInformation()).thenReturn(Map.of( - TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, constrainedConfig)); + when(constrainedClient.getAdditionalInformation()).thenReturn(Map.ofEntries( + Map.entry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert)), + Map.entry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "pattern", "^space:(.+)$", "claim", "space_guid"))), + Map.entry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("space_guid", "the-expected-space-guid")))); ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( mock(UserDetailsService.class), mock(PasswordEncoder.class), diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index fe3d46e4e36..469cba9a5c1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -210,31 +210,7 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaConfigured() { } @Test - void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsTypedConfiguration() { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration(VALID_CERT, null)); - - assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); - } - - @Test - void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthCaIsMalformed() { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration("not-a-certificate", null)); - - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) - .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); - } - - @Test - void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); @@ -244,126 +220,24 @@ void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT)); clientDetails.setAdditionalInformation(additionalInfo); - assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); - } - - @ParameterizedTest - @MethodSource("unsupportedNestedTlsClientAuthCaValues") - void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUnsupportedCaValue(Object ca) { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - Map tlsClientAuthConfig = new HashMap<>(); - tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, ca); - Map additionalInfo = new HashMap<>(); - additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); - clientDetails.setAdditionalInformation(additionalInfo); - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); } @ParameterizedTest - @MethodSource("invalidNestedTypedTlsClientAuthConfigurations") - void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthConfigurationHasUndeclaredClaimReference( - String property, TlsClientAuthConfiguration tlsClientAuthConfig) { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - clientDetails.setTlsClientAuthConfiguration(tlsClientAuthConfig); - - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) - .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining(property) - .hasMessageContaining("undeclared"); - } - - @ParameterizedTest - @MethodSource("invalidNestedMapTlsClientAuthConfigurations") - void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUndeclaredClaimReference( - String property, Map tlsClientAuthConfig) { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - Map additionalInfo = new HashMap<>(); - additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); - clientDetails.setAdditionalInformation(additionalInfo); - - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) - .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining(property) - .hasMessageContaining("undeclared"); - } - - @ParameterizedTest - @MethodSource("parserNullNestedMapClaimMappings") - void rejectsClientWithSuppliedSecretWhenNestedTlsClientAuthClaimMappingsParseToNull( - String claimMappings, String property, Object value) { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - clientDetails.setClientSecret("supplied-secret"); - Map tlsClientAuthConfig = nestedTlsClientAuthConfig(property, value); - tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, claimMappings); - Map additionalInfo = new HashMap<>(); - additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); - clientDetails.setAdditionalInformation(additionalInfo); - - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) - .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining(property) - .hasMessageContaining("undeclared"); - } - - @Test - void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthClaimMappingHasInvalidField() { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( - VALID_CERT, Collections.singletonList(new TlsClientAuthConfiguration.ClaimMapping(null, null, "claim")))); - - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) - .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("invalid field"); - } - - @Test - void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaJsonMapClaimMappingHasInvalidField() { + @MethodSource("unsupportedNestedTlsClientAuthCaValues") + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUnsupportedCaValue(Object ca) { zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - Map malformedClaimMapping = new HashMap<>(); - malformedClaimMapping.put("field", null); - malformedClaimMapping.put("claim", "claim"); Map tlsClientAuthConfig = new HashMap<>(); - tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); - tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, - Collections.singletonList(malformedClaimMapping)); + tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, ca); Map additionalInfo = new HashMap<>(); additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); clientDetails.setAdditionalInformation(additionalInfo); - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) - .isInstanceOf(InvalidClientDetailsException.class) - .hasMessageContaining("invalid field"); - } - - @Test - void rejectsSecretlessClientCredentialsClientWhenTypedTlsClientAuthCaIsBlank() { - zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); - - UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); - clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); - clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration(" ", null)); - assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) .isInstanceOf(InvalidClientDetailsException.class) .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); From ca9398b7b845a6f971f591ae62f6c451002bc122 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 1 Sep 2026 12:41:13 +0200 Subject: [PATCH 127/130] fix(review): require mTLS authentication for mTLS clients --- docs/UAA-Configuration-Reference.md | 4 ++++ .../ClientDetailsAuthenticationProvider.java | 13 +++++++++++++ .../UaaClientAuthenticationProviderTest.java | 10 ++++++++++ uaa/slateCustomizations/source/index.html.md.erb | 2 +- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 651c6179fe1..e5451ae4eae 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -1247,6 +1247,10 @@ does not implement server-side TLS 1.3 post-handshake client-certificate request When `false` (the default), no client certificate is requested at the TLS layer at all, and any client configured with a `tls-client-auth-ca` property fails validation at creation/update time. +```yaml +uaa.mtls-enabled: true +``` + [Back to table](#oauth-clients--users) --- diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index 994310c11c9..d3f72f3dc77 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -85,6 +85,19 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP for (String pwd : passwordList) { try { UaaClient uaaClient = new UaaClient(userDetails, pwd); + if (TlsClientAuthConfiguration.isConfigured(getTlsClientAuthConfiguration(uaaClient))) { + if (!ObjectUtils.isEmpty(authentication.getCredentials()) + || !isTlsClientAuthPath(authentication.getDetails())) { + error = new BadCredentialsException( + "tls_client_auth: configured clients must authenticate at /oauth/mtls/token without client credentials"); + } else { + setAuthenticationMethod(authentication, CLIENT_AUTH_TLS_CLIENT_AUTH); + if (!validateTlsClientAuth(uaaClient)) { + error = new BadCredentialsException("tls_client_auth: certificate validation failed"); + } + } + break; + } if (ObjectUtils.isEmpty(authentication.getCredentials())) { if (isPublicGrantTypeUsageAllowed(authentication.getDetails()) && uaaClient.isAllowPublic()) { // in case of grant_type=authorization_code and code_verifier passed (PKCE) we check if client has option allowpublic with true and continue even if no secret is in request diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java index b822e9d9d84..f12c48a83bf 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java @@ -126,6 +126,16 @@ void provider_authenticate_client_with_one_password() { testClientAuthentication(a); } + @Test + void provider_rejectsClientSecretForClientConfiguredForTlsClientAuth() { + client = createClient("tls-client-auth-ca", "-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----"); + Authentication authentication = getToken(client.getClientId(), SECRET); + + assertThatThrownBy(() -> authenticationProvider.authenticate(authentication)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("tls_client_auth"); + } + @Test void provider_authenticate_client_without_password_public_string() { client = createClient(ClientConstants.ALLOW_PUBLIC, "true"); diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index b5cf3df1bd2..0e677c1239d 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -265,7 +265,7 @@ Authenticates using an X.509 certificate presented at the TLS layer, on the dedi `/oauth/mtls/token` endpoint, instead of a `client_secret`, `client_assertion`, or Basic Authorization header. The client is identified by the certificate chaining to its configured `tls-client-auth-ca`; no additional request parameter carries the certificate itself. See -[UAA Client Authentication](https://github.com/cloudfoundry/uaa/blob/master/docs/UAA-Client-Authentication.md#tls_client_auth-rfc-8705) +[UAA Client Authentication](https://github.com/cloudfoundry/uaa/blob/develop/docs/UAA-Client-Authentication.md#tls_client_auth-rfc-8705) for deployment and configuration details, including the connector-wide `uaa.mtls-enabled` requirement. From 2bc2d2a4eddb27b5a71e7e8b5a224e7300a51ffa Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 1 Sep 2026 16:21:39 +0200 Subject: [PATCH 128/130] fix(review): validate and clear mTLS client config --- .../identity/uaa/client/UaaClientDetails.java | 5 +++++ .../uaa/client/UaaClientDetailsTest.java | 22 +++++++++++++++++++ .../client/ClientAdminEndpointsValidator.java | 5 +++++ .../ClientAdminEndpointsValidatorTests.java | 11 ++++++++++ 4 files changed, 43 insertions(+) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index cffab574439..fa7fc0e0869 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java @@ -328,6 +328,11 @@ public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAu tlsClientAuthConfiguration.getRequiredClaims()); } else { this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); } } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java index 54d773546f7..8bf60a9f4cc 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java @@ -225,6 +225,28 @@ void tlsClientAuthConfigRoundTripsViaJson() throws Exception { assertThat(raw).isEqualTo(config.getTrustedCaPem()); } + @Test + void setTlsClientAuthConfiguration_whenCleared_removesAllPersistedSettings() { + UaaClientDetails details = new UaaClientDetails(); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + "trusted-ca", List.of(new TlsClientAuthConfiguration.ClaimMapping("field", "pattern", "claim"))); + config.setSubTemplate("sub-template"); + config.setAudTemplates(List.of("aud-template")); + config.setTrustedProxyCaPem("trusted-proxy-ca"); + config.setRequiredClaims(Map.of("required-claim", "value")); + + details.setTlsClientAuthConfiguration(config); + details.setTlsClientAuthConfiguration(null); + + assertThat(details.getAdditionalInformation()).doesNotContainKeys( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + } + @Test void autoApprove() { UaaClientDetails details = new UaaClientDetails(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 4c427173a33..4716dfab347 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -503,6 +503,11 @@ public static void validateTlsClientAuthClaimConfig(Map addition } Object rawSubTemplate = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE) + && !(rawSubTemplate instanceof String)) { + throw new InvalidClientDetailsException( + "tls-client-auth-sub-template must be a string for client_id=" + clientId); + } if (rawSubTemplate instanceof String subTemplate && !subTemplate.isBlank()) { checkTemplateLength(subTemplate, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, clientId); validateTemplatePlaceholders(subTemplate, declaredClaims, diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index fce0197d3f5..dcbd6e44108 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -549,6 +549,17 @@ void validateTlsClientAuthClaimConfig_rejectsSubTemplateReferencingUndeclaredCla .isInstanceOf(InvalidClientDetailsException.class); } + @Test + void validateTlsClientAuthClaimConfig_rejectsNonStringSubTemplate() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, List.of("template")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE) + .hasMessageContaining("client-id"); + } + @Test void validateTlsClientAuthClaimConfig_rejectsAudTemplateReferencingUndeclaredClaim() { Map info = new java.util.HashMap<>(); From aeb43980599ea9c4b4f1104d59e3661c53a2caff Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 1 Sep 2026 17:43:23 +0200 Subject: [PATCH 129/130] fix(review): preserve flat mTLS client config on copy --- .../identity/uaa/client/UaaClientDetails.java | 4 +++- .../uaa/client/UaaClientDetailsTest.java | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index fa7fc0e0869..a7e25674c43 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java @@ -107,7 +107,9 @@ public UaaClientDetails(ClientDetails prototype) { this.setAdditionalInformation(prototype.getAdditionalInformation()); if (prototype instanceof UaaClientDetails uaa) { this.setClientJwtConfig(uaa.getClientJwtConfig()); - this.setTlsClientAuthConfiguration(uaa.getTlsClientAuthConfiguration()); + if (uaa.getTlsClientAuthConfiguration() != null) { + this.setTlsClientAuthConfiguration(uaa.getTlsClientAuthConfiguration()); + } } } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java index 8bf60a9f4cc..bdb3145c6ed 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java @@ -59,6 +59,22 @@ void copiesAdditionalInformation() { .containsEntry("key", "value"); } + @Test + void copiesFlatTlsClientAuthAdditionalInformationWhenTypedConfigurationIsNull() { + Map tlsClientAuthAdditionalInformation = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "trusted-ca", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, "claim-mappings", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "sub-template", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, "aud-templates", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "trusted-proxy-ca", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, "required-claims"); + testClient.setAdditionalInformation(tlsClientAuthAdditionalInformation); + + UaaClientDetails copy = new UaaClientDetails(testClient); + + assertThat(copy.getAdditionalInformation()).containsExactlyInAnyOrderEntriesOf(tlsClientAuthAdditionalInformation); + } + @Test void clientJwtConfig() { UaaClientDetails copy = new UaaClientDetails(testClient); From 471ad5780c04cf8453c02bfee2d17427198faa5a Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 4 Sep 2026 10:34:40 +0200 Subject: [PATCH 130/130] test: reject expired mTLS client certificates --- .../tls/TlsClientAuthenticationTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java index 127ca4afa9c..42c932c9480 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -190,6 +190,23 @@ void validateClientCertSucceedsWhenChainOmitsTrustAnchor() throws Exception { assertThat(result).contains(leafCert); } + @Test + void validateClientCertRejectsExpiredLeafCertificate() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate expiredLeafCert = signCertWithValidity( + new X500Name("CN=expired-leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, + BigInteger.TWO, new Date(System.currentTimeMillis() - 7_200_000), new Date(System.currentTimeMillis() - 3_600_000)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{expiredLeafCert}, config)) + .hasMessageContaining("validity check failed"); + } + @Test void validateClientCertSucceedsWhenChainIncludesTrustAnchor() throws Exception { // Reproduces the reviewer's concern (PR #3972 discussion on TlsClientAuthentication.java:113): @@ -805,6 +822,17 @@ private static X509Certificate signCert(X500Name subject, X500Name issuer, Publi Integer keyUsageBits, List ekuPurposes) throws Exception { Date notBefore = new Date(System.currentTimeMillis() - 60_000); Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + return signCertWithValidity(subject, issuer, subjectKey, signerKey, isCa, serial, notBefore, notAfter, keyUsageBits, ekuPurposes); + } + + private static X509Certificate signCertWithValidity(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial, Date notBefore, Date notAfter) throws Exception { + return signCertWithValidity(subject, issuer, subjectKey, signerKey, isCa, serial, notBefore, notAfter, null, null); + } + + private static X509Certificate signCertWithValidity(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial, Date notBefore, Date notAfter, + Integer keyUsageBits, List ekuPurposes) throws Exception { JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( issuer, serial, notBefore, notAfter, subject, subjectKey); builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa));