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/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/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..0d8a1f5b102 --- /dev/null +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -0,0 +1,112 @@ +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; +import java.util.Objects; + +@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"; + 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; + + @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) { + 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 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(subTemplate, that.subTemplate) && + Objects.equals(audTemplates, that.audTemplates); + } + + @Override + public int hashCode() { + return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates); + } + + 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; } + + @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/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index 92418c12927..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 @@ -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; + @JsonIgnore + 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,21 @@ public void setClientJwtConfig(String clientJwtConfig) { this.clientJwtConfig = clientJwtConfig; } + public TlsClientAuthConfiguration getTlsClientAuthConfiguration() { + return tlsClientAuthConfiguration; + } + + 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 public boolean equals(Object obj) { if (this == obj) { @@ -344,7 +364,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 +401,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/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/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"; 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/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..98fbceae9e2 --- /dev/null +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -0,0 +1,140 @@ +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"); + } + + @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); + } + + @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"); + + 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); + } +} 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..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 @@ -210,6 +210,28 @@ 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); + + // @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 void autoApprove() { UaaClientDetails details = new UaaClientDetails(); @@ -223,7 +245,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(); } } 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(); + } } 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", diff --git a/server/build.gradle.kts b/server/build.gradle.kts index ea3147b40af..48e3490894b 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(libs.bouncyCastleTlsFips) implementation(libs.bouncyCastleUtilFips) + implementation(libs.javaBuildpackClientCertificateMapper) + implementation(libs.guava) implementation(libs.aspectJWeaver) 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..3ce4164bba5 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,26 @@ public FilterRegistrationBean httpHeaderSecurityFilter bean.setEnabled(false); return bean; } + + @Bean + 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); + @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); + } + } } 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..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 @@ -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("${mtls.endpoint:/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/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index 13b5cfbf5a0..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 @@ -13,9 +13,12 @@ *******************************************************************************/ 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.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; import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -29,13 +32,18 @@ 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; 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 +58,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 +95,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 +182,76 @@ 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[] chain = tlsClientAuthentication.getCertificateChainFromRequest(); + if (chain == null || chain.length == 0) { + return false; + } + TlsClientAuthConfiguration config = getTlsClientAuthConfiguration(uaaClient); + return tlsClientAuthentication.validateClientCert(chain, config).isPresent(); + } + + 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; // in-memory client (tests) + } + if (rawConfig instanceof Map) { + try { + return JsonUtils.convertValue(rawConfig, TlsClientAuthConfiguration.class); + } catch (Exception e) { + return null; + } + } + if (rawConfig instanceof String pem) { + try { + List claimMappings = null; + Object rawMappings = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + 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>() {}); + } + 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; + } + } + return null; + } } 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/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/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/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/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..e68e8fdafac --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -0,0 +1,308 @@ +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.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; +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 static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}"); + + 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<>(); + } + + // 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<>(); + } + + // PHASE 1 — extract cert subject fields into vars (keyed by claim name) + Map vars = new HashMap<>(); + if (config.getClaimMappings() != null) { + X500Principal subject = cert.getSubjectX500Principal(); + String dn = subject.getName(X500Principal.RFC2253); + String cn = extractRdnValue(dn, "CN="); + List ous = extractOus(dn); + + for (TlsClientAuthConfiguration.ClaimMapping mapping : config.getClaimMappings()) { + String value = switch (mapping.getField()) { + case "subject_cn" -> cn; + case "subject_ou" -> matchFirstOu(ous, mapping.getPattern()); + case "subject_o" -> extractRdnValue(dn, "O="); + default -> null; + }; + if (value != null && !value.isBlank()) { + vars.put(mapping.getClaim(), value); + } + } + } + + // 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(); + // 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); + 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(); + 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 + } + + // PHASE 3 — template rendering for sub and aud + if (config.getSubTemplate() != null) { + String rendered = renderTemplate(config.getSubTemplate(), vars); + 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); + if (rendered != null) { + audList.add(rendered); + } + } + if (!audList.isEmpty()) { + result.put("aud", audList); + } + } + + return result; + } + + /** + * 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(",")) { + // 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; + } + + /** + * 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(",")) { + // 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; + } + + /** + * Returns the first captured group from the first OU that matches {@code patternStr}. + * When {@code patternStr} is null or blank, returns the first OU value verbatim. + */ + private String matchFirstOu(List ous, String patternStr) { + if (patternStr == null || patternStr.isBlank()) { + return ous.isEmpty() ? null : ous.get(0); + } + Pattern pat = Pattern.compile(patternStr); + for (String ou : ous) { + Matcher m = pat.matcher(ou); + if (m.matches() && m.groupCount() >= 1) { + return m.group(1); + } + } + return null; + } + + /** + * Renders a template string by substituting all {@code {varName}} placeholders + * from {@code vars}. Returns {@code null} if any placeholder has no corresponding + * 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) { + StringBuilder sb = new StringBuilder(); + 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 + * 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>() {}); + } 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>() {}); + } + 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; + } + } + return null; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java new file mode 100644 index 00000000000..ee187b9127f --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -0,0 +1,140 @@ +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.Arrays; +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() { + X509Certificate[] chain = getCertificateChainFromRequest(); + return (chain != null && chain.length > 0) ? chain[0] : null; + } + + /** + * Returns the full X.509 certificate chain from the current request's + * {@code jakarta.servlet.request.X509Certificate} attribute + * (populated by the ClientCertificateMapper filter). + * Index 0 is the end-entity (leaf) certificate. + * + * @return the client certificate chain, or {@code null} if none is present + */ + public X509Certificate[] getCertificateChainFromRequest() { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return null; + } + HttpServletRequest request = attrs.getRequest(); + X509Certificate[] certs = (X509Certificate[]) + request.getAttribute("jakarta.servlet.request.X509Certificate"); + return (certs != null && certs.length > 0) ? certs : null; + } + + /** + * 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} + * @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) { + 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 (chain == null || chain.length == 0 || !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(Arrays.asList(chain)); + + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + validator.validate(certPath, params); + + return Optional.of(chain[0]); + + } 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/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; 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; 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"); + } +} 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..2b7c5a22319 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -0,0 +1,50 @@ +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; + +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(); + } + + @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"); + } +} 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() { 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); 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); + } +} 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..2bab7c3fe27 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -0,0 +1,380 @@ +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"); + } + + @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"); + } + + @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"); + } + + @Test + void subTemplateRendered() 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"); + } + + @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}); + 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); + OAuth2Authentication auth = mock(OAuth2Authentication.class); + when(auth.getOAuth2Request()).thenReturn(request); + return auth; + } +} 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..8b40676556c --- /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((X509Certificate) 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"); + } +} 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, 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); } }