Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions docs/UAA-Configuration-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> templateResolver = this.templateResolver(baseUrl, relyingPartyRegistration);
String relyingPartyEntityId = templateResolver.apply(relyingPartyRegistration.getEntityId());
String assertionConsumerServiceLocation = templateResolver.apply(relyingPartyRegistration.getAssertionConsumerServiceLocation());
Expand Down Expand Up @@ -152,6 +156,63 @@ private static Map<String, String> 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:
*
* <ul>
* <li><b>Subdomain-based zones</b> ({@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}.</li>
* <li><b>Path-based zones</b> ({@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.</li>
* </ul>
*
* 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.
Comment on lines +181 to +183
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This is already explained in the javadoc above and can be deleted.

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.
Comment on lines +192 to +193
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one as well

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");

Expand All @@ -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.
*
* <p>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.
*
* <p>Request setup mirrors what {@link org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter}
* actually produces in the MockMvc (no-{@code /uaa}-context-path) environment:
* <ul>
* <li>{@code ZONE_ORIGINAL_CONTEXT_PATH = ""} — original context path before filter runs</li>
* <li>{@code getContextPath() = "/z/myzone"} — context path after filter rewrites it</li>
* </ul>
* 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)
Expand All @@ -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");
}
}
Loading
Loading