From dc1149dfbcdfff4de21923de90065bd43ebd1e44 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:27:25 +0200 Subject: [PATCH 01/30] 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 15b2859c5077ebb76223c8eafee4104611d310b1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:37:17 +0200 Subject: [PATCH 02/30] 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 07ac929132e35338c227d285a2997ffee9ca082e Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:42:43 +0200 Subject: [PATCH 03/30] 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 6a16d2143cb3240b219b670e3cdd1c1c310bc4f9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:52:24 +0200 Subject: [PATCH 04/30] 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 4eabfb44843..5181b9d9520 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 @@ -210,6 +210,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(); @@ -223,7 +240,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 8a2838d39d7b2e6f60122ded1740d8d299f25f13 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 10:57:54 +0200 Subject: [PATCH 05/30] 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 b3c41467031e3ee5b3c27514883767ccef0e2ea6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:04:12 +0200 Subject: [PATCH 06/30] 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 0897d44d595d2f4718ce99eeaf5962f8c1f0e644 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:09:32 +0200 Subject: [PATCH 07/30] 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 ea3147b40af..1204a11e4b0 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 8df59ecb8daf3a5e1a665880802748d07b3208b0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:18:14 +0200 Subject: [PATCH 08/30] 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 ec0f70605abbb253b641d9721653ac8cc17c593e Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:27:41 +0200 Subject: [PATCH 09/30] 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 046d6dd1a69cc1a3c9d5e6a38014fc0160b87cda Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:43:52 +0200 Subject: [PATCH 10/30] 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 a55037e52b8..eb1e5a6110f 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 @@ -44,6 +44,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; @@ -450,12 +451,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 d5b38c92819c3f8d9019a1b8bc37cc3d4c2c340a Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 11:59:34 +0200 Subject: [PATCH 11/30] 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 39b5c5477ac8fddab236cb153ca8642cfaa1a482 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:04:35 +0200 Subject: [PATCH 12/30] 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 f8f3e97a3fe2550b4de90c3eda90efe3d3f22a97 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:13:00 +0200 Subject: [PATCH 13/30] 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 2e23b24862325bab54889f1910b46d439c30aa6b Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:25:01 +0200 Subject: [PATCH 14/30] 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 7febe215e36e5ade9ded7ffb6b8f17f6bfa8b238 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 12:46:11 +0200 Subject: [PATCH 15/30] 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 f2974efbd47c7c3e357f38fd5cb71dfd4fe2d9ed Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 17:18:36 +0200 Subject: [PATCH 16/30] 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 fe244457a0ecd5218bbc834c1cab166fa9c37d35 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 17:18:50 +0200 Subject: [PATCH 17/30] 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 cf49990e6f9303dc3cb29c7ef35e2363731433e8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 3 Jul 2026 17:19:07 +0200 Subject: [PATCH 18/30] 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 2f77a0bb7e7def89619cedc32697805fc4494fc6 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 6 Jul 2026 09:45:50 +0200 Subject: [PATCH 19/30] 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 c3c9df04210..caba33933a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ greenmail = "2.1.9" guava = "33.6.0-jre" jacoco = "4.0.2" jacocoAgent = "0.8.15" +javaBuildpackClientCertificateMapper = "2.0.1" nimbusJwt = "10.9.1" opensaml = "5.2.3" orgJson = "20260522" @@ -71,6 +72,9 @@ bouncyCastleUtilFips = { module = "org.bouncycastle:bcutil-fips", version.ref = bytebuddy = { module = "net.bytebuddy:byte-buddy" } bytebuddyagent = { module = "net.bytebuddy:byte-buddy-agent" } +# 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 1204a11e4b0..48e3490894b 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 54389392504582e2aa29d042f3a1f48a9940d126 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 6 Jul 2026 13:10:56 +0200 Subject: [PATCH 20/30] 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 2856a5dbd094ae187d2ae082b3acfa8130ddf6b7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 6 Jul 2026 17:17:56 +0200 Subject: [PATCH 21/30] 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 864e086a47d614471f754ec47bf250bcbe0ef181 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 08:13:44 +0200 Subject: [PATCH 22/30] 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 5181b9d9520..36f564fc48c 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 @@ -222,9 +222,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 5d89307911f93007343a6e6558e6a1765ee54699 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:21:03 +0200 Subject: [PATCH 23/30] 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 7b31e1539eeb4f5bfb04abf25b4fa9df94bab394 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:26:23 +0200 Subject: [PATCH 24/30] 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 5d6d833f827cc79cd58ee8bd2eaafe10e857516f Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:34:02 +0200 Subject: [PATCH 25/30] 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 d937561d00b43349aee79465034152e9443a031a Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:40:50 +0200 Subject: [PATCH 26/30] 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 5fa537bfd24c4e1c556fedf5d9e0ec806e1f1208 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:46:48 +0200 Subject: [PATCH 27/30] 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 558717d353eb20e54f972db5e507828dafbcedcf Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:53:20 +0200 Subject: [PATCH 28/30] 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 0513c606d54d9bb186d315261e5224313c73930e Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 12:59:01 +0200 Subject: [PATCH 29/30] 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 98c09abfdbd7ccea75bb24e64e555f4903358a2d Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 7 Jul 2026 14:39:34 +0200 Subject: [PATCH 30/30] 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 c6cbebdd8b1..f03cf14c21d 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 @@ -533,10 +533,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)); @@ -567,6 +563,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 0a028c1eaa4..9f092ce9a24 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 @@ -827,6 +827,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,