From 09e2d096c77734f3fe0feac8f7f1ba0acdac713f Mon Sep 17 00:00:00 2001 From: Filip Hanik Date: Fri, 15 May 2026 09:53:43 -0700 Subject: [PATCH 1/2] fix: SAML ACS URL uses static entityBaseURL host for non-default zones (regression from #3739) --- .../UaaRelyingPartyRegistrationResolver.java | 63 ++++++- .../SamlMetadataEndpointKeyRotationTests.java | 5 +- ...RelyingPartyRegistrationResolverTests.java | 163 +++++++++++++++--- .../SamlMetadataEndpointMockMvcTests.java | 51 +++++- 4 files changed, 253 insertions(+), 29 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolver.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolver.java index 08e102eb217..c48e4a94bce 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolver.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolver.java @@ -16,6 +16,10 @@ package org.cloudfoundry.identity.uaa.provider.saml; import lombok.extern.slf4j.Slf4j; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter; import org.springframework.core.convert.converter.Converter; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; @@ -85,7 +89,7 @@ public RelyingPartyRegistration resolve(HttpServletRequest request, String relyi if (relyingPartyRegistration == null) { return null; } else { - String baseUrl = StringUtils.hasText(entityBaseURL) ? entityBaseURL : getApplicationUri(request); + String baseUrl = resolveBaseUrl(entityBaseURL, request); Function templateResolver = this.templateResolver(baseUrl, relyingPartyRegistration); String relyingPartyEntityId = templateResolver.apply(relyingPartyRegistration.getEntityId()); String assertionConsumerServiceLocation = templateResolver.apply(relyingPartyRegistration.getAssertionConsumerServiceLocation()); @@ -152,6 +156,63 @@ private static Map constructUriVariables(String baseUrl, Relying return uriVariables; } + /** + * Determines the base URL used to expand {@code {baseUrl}} template variables in + * relying-party endpoint locations, accounting for both zone-access patterns: + * + *
    + *
  • Subdomain-based zones ({@code http://zone.host/uaa/…}): when + * {@code entityBaseURL} is configured the zone subdomain is prepended to its + * host, e.g. {@code http://localhost:8080/uaa} → + * {@code http://zone.localhost:8080/uaa}.
  • + *
  • Path-based zones ({@code http://host/z/{subdomain}/…}): + * {@link ZonePathContextRewritingFilter} has already rewritten + * {@code request.getContextPath()} to include {@code /z/{subdomain}}, so + * {@link #getApplicationUri} produces the correct zone-specific base URL. + * {@code entityBaseURL} is a static operator setting that cannot encode the + * per-zone path dynamically, therefore it is ignored for this access pattern.
  • + *
+ * + * When {@code entityBaseURL} is not configured the base URL is always derived from + * the incoming HTTP request, which already carries the correct zone information + * (subdomain in the {@code Host} header, or zone path in the rewritten context path). + */ + private static String resolveBaseUrl(String entityBaseURL, HttpServletRequest request) { + // Path-based zone: ZonePathContextRewritingFilter has embedded /z/{subdomain} + // into the context path. entityBaseURL is a static value that cannot reflect this + // dynamically, so always derive from the request for this access pattern. + if (isZonePathRequest(request)) { + return getApplicationUri(request); + } + + if (!StringUtils.hasText(entityBaseURL)) { + return getApplicationUri(request); + } + + // Subdomain-based zone with entityBaseURL configured: prepend the zone + // subdomain to the host so ACS/SLO endpoints resolve to the right virtual host. + IdentityZone currentZone = IdentityZoneHolder.get(); + if (!currentZone.isUaa()) { + return UaaUrlUtils.addSubdomainToUrl(entityBaseURL, currentZone.getSubdomain()); + } + return entityBaseURL; + } + + /** + * Returns {@code true} when the current request is being served under a path-based + * zone URL ({@code /z/{subdomain}/}), as signalled by the + * {@link ZonePathContextRewritingFilter#ZONE_ORIGINAL_CONTEXT_PATH} request attribute. + */ + private static boolean isZonePathRequest(HttpServletRequest request) { + Object origAttr = request.getAttribute(ZonePathContextRewritingFilter.ZONE_ORIGINAL_CONTEXT_PATH); + if (origAttr instanceof String originalContextPath) { + String contextPath = request.getContextPath(); + return contextPath != null + && contextPath.startsWith(originalContextPath + ZonePathContextRewritingFilter.ZONE_PATH_PREFIX); + } + return false; + } + private static String getApplicationUri(HttpServletRequest request) { UriComponents uriComponents = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request)).replacePath(request.getContextPath()).replaceQuery(null).fragment(null).build(); return uriComponents.toUriString(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointKeyRotationTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointKeyRotationTests.java index 43706428aa7..d22c0582eac 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointKeyRotationTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointKeyRotationTests.java @@ -84,7 +84,10 @@ void beforeEach() { RelyingPartyRegistrationRepository registrationRepository = new DefaultRelyingPartyRegistrationRepository("entityId", "entityIdAlias", List.of(), NAME_ID_FORMAT); - RelyingPartyRegistrationResolver registrationResolver = new UaaRelyingPartyRegistrationResolver(registrationRepository, ENTITY_ID, "http://zone-id.localhost:8080/uaa"); + // entityBaseURL is the UAA base URL without any zone subdomain. For non-default zones accessed + // via subdomain, UaaRelyingPartyRegistrationResolver prepends the zone subdomain automatically + // (e.g. "http://localhost:8080/uaa" → "http://zone-id.localhost:8080/uaa"). + RelyingPartyRegistrationResolver registrationResolver = new UaaRelyingPartyRegistrationResolver(registrationRepository, ENTITY_ID, "http://localhost:8080/uaa"); endpoint = spy(new SamlMetadataEndpoint(registrationResolver, identityZoneManager, SignatureAlgorithm.SHA256, true)); IdentityZoneHolder.set(otherZone); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolverTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolverTests.java index 47f98c2c8b1..d5f45407c82 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolverTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/UaaRelyingPartyRegistrationResolverTests.java @@ -16,6 +16,10 @@ package org.cloudfoundry.identity.uaa.provider.saml; import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -62,6 +66,11 @@ void beforeEach() { resolver = new UaaRelyingPartyRegistrationResolver(repository, "cloudfoundry-saml-login", "http://localhost:8080/uaa"); } + @AfterEach + void afterEach() { + IdentityZoneHolder.clear(); + } + @Test void resolveWhenRequestContainsRegistrationIdThenResolves() { MockHttpServletRequest request = new MockHttpServletRequest(); @@ -142,21 +151,7 @@ void resolveWhenEntityBaseUrlIsNullOrEmptyUsesRequestUrl(String baseUrl) { String expectedBaseUrl = "https://uaa.example.com/uaa-security"; - RelyingPartyRegistration testRegistration = RelyingPartyRegistration.withRegistrationId("test-idp") - .entityId("{baseUrl}/saml/metadata") - .assertionConsumerServiceLocation("{baseUrl}/saml/SSO") - .assertionConsumerServiceBinding(Saml2MessageBinding.POST) - .singleLogoutServiceLocation("{baseUrl}/saml/SingleLogout") - .singleLogoutServiceResponseLocation("{baseUrl}/saml/SingleLogout") - .singleLogoutServiceBinding(Saml2MessageBinding.POST) - .assertingPartyMetadata(party -> party - .entityId("https://idp.example.com") - .singleSignOnServiceLocation("https://idp.example.com/sso") - .singleSignOnServiceBinding(Saml2MessageBinding.POST) - .wantAuthnRequestsSigned(false)) - .build(); - - doReturn(testRegistration).when(repository).findByRegistrationId("test-idp"); + doReturn(buildTestRegistration()).when(repository).findByRegistrationId("test-idp"); RelyingPartyRegistration result = resolverWithNullOrEmptyBaseUrl.resolve(request, "test-idp"); @@ -181,7 +176,134 @@ void resolveWhenEntityBaseUrlIsSetUsesConfiguredEntityBaseUrl(String configuredB String expectedBaseUrl = StringUtils.trimTrailingCharacter(configuredBaseUrl, '/'); - RelyingPartyRegistration testRegistration = RelyingPartyRegistration.withRegistrationId("test-idp") + doReturn(buildTestRegistration()).when(repository).findByRegistrationId("test-idp"); + + RelyingPartyRegistration result = resolverWithConfiguredBaseUrl.resolve(request, "test-idp"); + + assertThat(result).isNotNull(); + assertThat(result.getEntityId()).isEqualTo(expectedBaseUrl + "/saml/metadata"); + assertThat(result.getAssertionConsumerServiceLocation()).isEqualTo(expectedBaseUrl + "/saml/SSO"); + assertThat(result.getSingleLogoutServiceLocation()).isEqualTo(expectedBaseUrl + "/saml/SingleLogout"); + } + + @Test + void resolveWhenEntityBaseUrlIsSetAndNonDefaultZoneSubdomainPrependsSubdomainToHost() { + UaaRelyingPartyRegistrationResolver resolverWithConfiguredBaseUrl = + new UaaRelyingPartyRegistrationResolver(repository, "cloudfoundry-saml-login", "http://localhost:8080/uaa"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("http"); + request.setServerName("myzone.localhost"); + request.setServerPort(8080); + request.setContextPath("/uaa"); + request.setRequestURI("/uaa/saml/metadata/test-idp"); + + IdentityZone zone = new IdentityZone(); + zone.setId("myzone-id"); + zone.setSubdomain("myzone"); + IdentityZoneHolder.set(zone); + + RelyingPartyRegistration testRegistration = buildTestRegistration(); + doReturn(testRegistration).when(repository).findByRegistrationId("test-idp"); + + RelyingPartyRegistration result = resolverWithConfiguredBaseUrl.resolve(request, "test-idp"); + + assertThat(result).isNotNull(); + assertThat(result.getEntityId()).isEqualTo("http://myzone.localhost:8080/uaa/saml/metadata"); + assertThat(result.getAssertionConsumerServiceLocation()).isEqualTo("http://myzone.localhost:8080/uaa/saml/SSO"); + assertThat(result.getSingleLogoutServiceLocation()).isEqualTo("http://myzone.localhost:8080/uaa/saml/SingleLogout"); + } + + /** + * Validates that {@code entityBaseURL} is ignored when the request is path-based zone access, + * regardless of whether {@code zones.paths.enabled} is set as a system property. + * + *

This test is intentionally NOT gated by {@code @EnabledIfZonePathsEnabled}. The companion + * MockMvc test ({@code nonDefaultZoneSamlMetadataXMLValidationViaZonePath}) goes through the + * real {@link org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter} and is + * annotated with {@code @EnabledIfZonePathsEnabled}, so it can be silently skipped when + * {@code zones.paths.enabled} is not set (e.g. running from an IDE without Gradle's system + * property default). This unit test runs unconditionally and is the safety net that would + * fail if {@link org.cloudfoundry.identity.uaa.provider.saml.UaaRelyingPartyRegistrationResolver#isZonePathRequest} + * were removed or returned {@code false} for all inputs. + * + *

Request setup mirrors what {@link org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter} + * actually produces in the MockMvc (no-{@code /uaa}-context-path) environment: + *

    + *
  • {@code ZONE_ORIGINAL_CONTEXT_PATH = ""} — original context path before filter runs
  • + *
  • {@code getContextPath() = "/z/myzone"} — context path after filter rewrites it
  • + *
+ * The filter would produce {@code ZONE_ORIGINAL_CONTEXT_PATH = "/uaa"} in a production deployment + * with a servlet context path of {@code /uaa}; that is tested separately below. + */ + @Test + void resolveWhenEntityBaseUrlIsSetAndNonDefaultZonePathIgnoresEntityBaseUrlAndUsesRequestContextPath() { + UaaRelyingPartyRegistrationResolver resolverWithConfiguredBaseUrl = + new UaaRelyingPartyRegistrationResolver(repository, "cloudfoundry-saml-login", "http://localhost:8080/uaa"); + + // Realistic MockMvc scenario: no /uaa context path prefix. The filter rewrites + // contextPath from "" to "/z/myzone" and records ZONE_ORIGINAL_CONTEXT_PATH="". + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("http"); + request.setServerName("localhost"); + request.setServerPort(8080); + request.setContextPath("/z/myzone"); + request.setRequestURI("/z/myzone/saml/metadata/test-idp"); + request.setAttribute(ZonePathContextRewritingFilter.ZONE_ORIGINAL_CONTEXT_PATH, ""); + + IdentityZone zone = new IdentityZone(); + zone.setId("myzone-id"); + zone.setSubdomain("myzone"); + IdentityZoneHolder.set(zone); + + doReturn(buildTestRegistration()).when(repository).findByRegistrationId("test-idp"); + RelyingPartyRegistration result = resolverWithConfiguredBaseUrl.resolve(request, "test-idp"); + + // entityBaseURL ("http://localhost:8080/uaa") is ignored for zone-path requests. + // If isZonePathRequest returned false, the subdomain path would be taken instead and + // the base URL would be "http://myzone.localhost:8080/uaa" — a different host/path. + assertThat(result).isNotNull(); + assertThat(result.getEntityId()).isEqualTo("http://localhost:8080/z/myzone/saml/metadata"); + assertThat(result.getAssertionConsumerServiceLocation()).isEqualTo("http://localhost:8080/z/myzone/saml/SSO"); + assertThat(result.getSingleLogoutServiceLocation()).isEqualTo("http://localhost:8080/z/myzone/saml/SingleLogout"); + } + + /** + * Production-deployment variant: servlet context path is {@code /uaa}, so the filter sets + * {@code ZONE_ORIGINAL_CONTEXT_PATH="/uaa"} and rewrites {@code getContextPath()} to + * {@code /uaa/z/myzone}. entityBaseURL must still be ignored. + */ + @Test + void resolveWhenEntityBaseUrlIsSetAndNonDefaultZonePathWithServletContextPathIgnoresEntityBaseUrl() { + UaaRelyingPartyRegistrationResolver resolverWithConfiguredBaseUrl = + new UaaRelyingPartyRegistrationResolver(repository, "cloudfoundry-saml-login", "http://localhost:8080/uaa"); + + // Production scenario: UAA deployed at context path /uaa; filter rewrites contextPath + // from "/uaa" to "/uaa/z/myzone" and sets ZONE_ORIGINAL_CONTEXT_PATH="/uaa". + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("http"); + request.setServerName("localhost"); + request.setServerPort(8080); + request.setContextPath("/uaa/z/myzone"); + request.setRequestURI("/uaa/z/myzone/saml/metadata/test-idp"); + request.setAttribute(ZonePathContextRewritingFilter.ZONE_ORIGINAL_CONTEXT_PATH, "/uaa"); + + IdentityZone zone = new IdentityZone(); + zone.setId("myzone-id"); + zone.setSubdomain("myzone"); + IdentityZoneHolder.set(zone); + + doReturn(buildTestRegistration()).when(repository).findByRegistrationId("test-idp"); + RelyingPartyRegistration result = resolverWithConfiguredBaseUrl.resolve(request, "test-idp"); + + assertThat(result).isNotNull(); + assertThat(result.getEntityId()).isEqualTo("http://localhost:8080/uaa/z/myzone/saml/metadata"); + assertThat(result.getAssertionConsumerServiceLocation()).isEqualTo("http://localhost:8080/uaa/z/myzone/saml/SSO"); + assertThat(result.getSingleLogoutServiceLocation()).isEqualTo("http://localhost:8080/uaa/z/myzone/saml/SingleLogout"); + } + + private RelyingPartyRegistration buildTestRegistration() { + return RelyingPartyRegistration.withRegistrationId("test-idp") .entityId("{baseUrl}/saml/metadata") .assertionConsumerServiceLocation("{baseUrl}/saml/SSO") .assertionConsumerServiceBinding(Saml2MessageBinding.POST) @@ -194,14 +316,5 @@ void resolveWhenEntityBaseUrlIsSetUsesConfiguredEntityBaseUrl(String configuredB .singleSignOnServiceBinding(Saml2MessageBinding.POST) .wantAuthnRequestsSigned(false)) .build(); - - doReturn(testRegistration).when(repository).findByRegistrationId("test-idp"); - - RelyingPartyRegistration result = resolverWithConfiguredBaseUrl.resolve(request, "test-idp"); - - assertThat(result).isNotNull(); - assertThat(result.getEntityId()).isEqualTo(expectedBaseUrl + "/saml/metadata"); - assertThat(result.getAssertionConsumerServiceLocation()).isEqualTo(expectedBaseUrl + "/saml/SSO"); - assertThat(result.getSingleLogoutServiceLocation()).isEqualTo(expectedBaseUrl + "/saml/SingleLogout"); } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataEndpointMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataEndpointMockMvcTests.java index 8bfb9313f83..dc7cae1ff14 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataEndpointMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataEndpointMockMvcTests.java @@ -3,6 +3,7 @@ import org.cloudfoundry.identity.uaa.DefaultTestContext; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.mock.util.ZoneResolutionMode; import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; @@ -11,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; @@ -18,6 +20,7 @@ import java.net.URI; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.opensaml.xmlsec.signature.support.SignatureConstants.ALGO_ID_DIGEST_SHA256; import static org.opensaml.xmlsec.signature.support.SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256; import static org.springframework.http.HttpHeaders.HOST; @@ -76,7 +79,7 @@ void samlMetadataXMLValidation() throws Exception { xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='signing']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='encryption']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location") - .string(containsString("/saml/SSO/alias/integration-saml-entity-id")), // last path is the UAA-wide entity ID alias, set by login.saml.entityIDAlias; is not set, fall back to login.entityID + .string(equalTo("http://localhost:8080/uaa/saml/SSO/alias/integration-saml-entity-id")), // entityBaseURL (http://localhost:8080/uaa) + /saml/SSO/alias/ + login.entityID xpath("/EntityDescriptor/Signature").exists(), xpath("/EntityDescriptor/Signature/SignedInfo/SignatureMethod/@Algorithm").string(containsString(ALGO_ID_SIGNATURE_RSA_SHA256)), xpath("/EntityDescriptor/Signature/SignatureValue").exists(), @@ -102,10 +105,54 @@ void nonDefaultZoneSamlMetadataXMLValidation() throws Exception { xpath("/EntityDescriptor/SPSSODescriptor/NameIDFormat").string("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"), // matches UAA config login.saml.NameID??? xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='signing']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='encryption']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), - xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location").string(containsString("/saml/SSO/alias/%s.integration-saml-entity-id".formatted(subdomain))) // this needs to be: /saml/SSO/alias/[zone-subdomain].[UAA-wide SAML entity ID, aka UAA.yml's login.saml.entityIDAlias, or fall back on login.entityID in this case] + xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location").string(equalTo("http://%s.localhost:8080/uaa/saml/SSO/alias/%s.integration-saml-entity-id".formatted(subdomain, subdomain))) // this needs to be: /saml/SSO/alias/[zone-subdomain].[UAA-wide SAML entity ID, aka UAA.yml's login.saml.entityIDAlias, or fall back on login.entityID in this case] ); } + /** + * Zone accessed via the path prefix ({@code /z/{subdomain}/…}) instead of a subdomain. + *

+ * {@code zones.paths.enabled=true} is set explicitly via {@link TestPropertySource} so that the + * {@link org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter} is wired with + * {@code zonePathsEnabled=true} at context startup. This makes the test run unconditionally — + * no {@code @EnabledIfZonePathsEnabled} gate and no reliance on a Gradle system-property default. + */ + @Nested + @DefaultTestContext + @TestPropertySource(properties = {"zones.paths.enabled=true"}) + class SamlMetadataZonePathMockMvcTests { + @Autowired + private MockMvc mockMvc; + + /** + * {@link org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter} rewrites the context + * path to {@code /z/{subdomain}} so {@code entityBaseURL} (a static operator config) is ignored + * in favour of the request-derived base URL, which already carries the zone path. + * The resulting ACS Location must use plain {@code localhost} (no subdomain in the host) and + * include the zone path prefix in the URL path. + */ + @Test + void nonDefaultZoneSamlMetadataXMLValidationViaZonePath() throws Exception { + IdentityZone spZone = setupIdentityZone(true); + String subdomain = spZone.getSubdomain(); + + mockMvc.perform(ZoneResolutionMode.ZONE_PATH.createRequestBuilder(subdomain, HttpMethod.GET, "/saml/metadata")) + .andDo(print()) + .andExpectAll( + status().isOk(), + header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("filename=\"saml-%s-sp.xml\";".formatted(subdomain))), + xpath("/EntityDescriptor/@entityID").string(spZone.getConfig().getSamlConfig().getEntityID()), + xpath("/EntityDescriptor/SPSSODescriptor/@AuthnRequestsSigned").booleanValue(false), + xpath("/EntityDescriptor/SPSSODescriptor/@WantAssertionsSigned").booleanValue(false), + // Zone path: entityBaseURL ("http://localhost:8080/uaa") is ignored. + // Base URL comes from the rewritten context path /z/{subdomain}. + // MockMvc uses port 80 (default HTTP), so no port in the URL. + xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location") + .string(equalTo("http://localhost/z/%s/saml/SSO/alias/%s.integration-saml-entity-id".formatted(subdomain, subdomain))) + ); + } + } + @Nested @DefaultTestContext @TestPropertySource(properties = {"login.saml.signRequest = false", From dfade6c0a9d5c22afe200c11455b466c190c0bd9 Mon Sep 17 00:00:00 2001 From: Duane May Date: Fri, 15 May 2026 16:23:14 -0400 Subject: [PATCH 2/2] Update documentation to clarify `entityBaseURL` behavior Expanded `entityBaseURL` documentation to detail behavior across different zone access patterns, added examples, and explained implications for path-based zone access with SAML metadata generation. --- docs/UAA-Configuration-Reference.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 5223cd90a5e..77cb0ee81b4 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -1716,8 +1716,21 @@ When `true`, users see an account chooser UI that lets them pick from previously **Type:** `String` The base URL of this UAA instance for SAML SP metadata generation. This URL appears in the -SAML metadata as the service provider's base location. When `null`, UAA uses the request URL, -which enables automatic zone subdomain resolution. +SAML metadata as the service provider's base location and is used to construct AssertionConsumerService (ACS) and SingleLogoutService (SLS) endpoints. + +**When `null` or empty:** UAA derives the base URL from the incoming HTTP request, which works for subdomain-based zones but provides automatic hostname resolution. + +**Important:** The behavior of `entityBaseURL` varies depending on the identity zone access pattern: + +| Zone Access Pattern | Behavior | Example | +|---------------------|----------|---------| +| **Default (UAA) zone** | Uses `entityBaseURL` as-is | `http://localhost:8080/uaa` → ACS: `http://localhost:8080/uaa/saml/SSO/alias/...` | +| **Non-default zone, subdomain access** | Prepends zone subdomain to `entityBaseURL` host | `http://localhost:8080/uaa` + zone `myzone` → ACS: `http://myzone.localhost:8080/uaa/saml/SSO/alias/...` | +| **Non-default zone, path access** (`zones.paths.enabled=true`) | **Ignores** `entityBaseURL`; derives from request | Request to `/z/myzone/saml/metadata` → ACS: `http://localhost/z/myzone/saml/SSO/alias/...` | + +**Zone Path Access Pattern:** When [`zones.paths.enabled=true`](#zonespathsenabled) and zones are accessed via `/z/{subdomain}/` URLs, `entityBaseURL` is ignored because it's a static configuration value that cannot encode the dynamic zone path. The SAML metadata URLs are derived from the incoming request, which already contains the zone path information thanks to the [`ZonePathContextRewritingFilter`](../server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZonePathContextRewritingFilter.java). + +**When to set `entityBaseURL`:** Configure this when deploying behind load balancers or proxies where the internal server hostname/port differs from the externally accessible URL. This ensures SAML metadata contains stable, publicly reachable endpoints. [Back to table](#login--branding) @@ -2938,6 +2951,12 @@ When `true`, enables path-based identity zone routing via `/z/{subdomain}/` URL This allows multiple zones to be accessed through the same hostname using different URL paths instead of different subdomains. +**Examples:** +- Subdomain access (default): `https://myzone.uaa.example.com/login` +- Path access (when enabled): `https://uaa.example.com/z/myzone/login` + +**Important:** When path-based zone access is enabled, [`login.entityBaseURL`](#loginentitybaseurl) is ignored for SAML metadata generation in non-default zones, as the zone information is already encoded in the URL path. See the `login.entityBaseURL` documentation for details on how this affects SAML endpoint URLs. + [Back to table](#zone-paths) ---