diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderData.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderData.java index 639598ced18..62abb33a626 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderData.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderData.java @@ -184,8 +184,22 @@ public void setIdentityProviders(Map> providers) { IdentityProvider provider = parseSamlProvider(def); if (def.getType() == SamlIdentityProviderDefinition.MetadataLocation.DATA) { + // Inline XML: parse is synchronous; let exceptions propagate to fail fast on bad config. RelyingPartyRegistration metadataDelegate = samlConfigurator.getExtendedMetadataDelegate(def); def.setIdpEntityId(metadataDelegate.getAssertingPartyMetadata().getEntityId()); + } else { + // URL-type: remote fetch may fail transiently at startup (IdP unreachable, DNS not + // ready, etc.). Log an error so operators know the entity ID could not be stored in + // external_key, but continue so the server does not refuse to start. + try { + RelyingPartyRegistration metadataDelegate = samlConfigurator.getExtendedMetadataDelegate(def); + def.setIdpEntityId(metadataDelegate.getAssertingPartyMetadata().getEntityId()); + } catch (Exception e) { + log.error("Could not resolve entity ID for SAML IDP '{}' at startup from '{}'; " + + "external_key will be null. SAML logins via SP-alias ACS URL may fail " + + "until the metadata URL becomes reachable: {}", + alias, metaDataLocation, e.getMessage()); + } } IdentityProviderWrapper wrapper = new IdentityProviderWrapper<>(provider); wrapper.setOverride(override == null || override); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java index eea49983771..5dc3bad1730 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java @@ -48,7 +48,23 @@ public RelyingPartyRegistration findByRegistrationId(String registrationId) { } for (SamlIdentityProviderDefinition identityProviderDefinition : configurator.getIdentityProviderDefinitionsForZone(currentZone)) { - if (registrationId.equals(identityProviderDefinition.getIdpEntityAlias()) || registrationId.equals(identityProviderDefinition.getIdpEntityId())) { + if (registrationId.equals(identityProviderDefinition.getIdpEntityAlias())) { + return createRelyingPartyRegistration(identityProviderDefinition.getIdpEntityAlias(), identityProviderDefinition, currentZone); + } + // idpEntityId is populated from external_key at read time; if external_key was never + // stored (e.g. URL-type IDPs bootstrapped without idpEntityId being set), fall back + // to resolving the entity ID dynamically from the metadata. + String resolvedEntityId = identityProviderDefinition.getIdpEntityId(); + if (resolvedEntityId == null) { + try { + resolvedEntityId = configurator.getExtendedMetadataDelegate(identityProviderDefinition) + .getAssertingPartyMetadata().getEntityId(); + } catch (Exception e) { + log.warn("Could not resolve entity ID from metadata for SAML IDP '{}': {}", + identityProviderDefinition.getIdpEntityAlias(), e.getMessage()); + } + } + if (registrationId.equals(resolvedEntityId)) { return createRelyingPartyRegistration(identityProviderDefinition.getIdpEntityAlias(), identityProviderDefinition, currentZone); } } 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 256aa921287..5e0df635327 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 @@ -118,6 +118,16 @@ private String resolveFromRequest(HttpServletRequest request, String resolvedEnt relyingPartyRegistrationId = resolvedEntityId; } } + // Fallback for IDP-initiated SSO: when the ACS URL carries the IDP alias instead of + // the SP alias (e.g. /saml/SSO/alias/{idpAlias}), the endsWith check above does not + // fire and relyingPartyRegistrationId stays null. Use the URL path segment directly so + // ConfiguratorRelyingPartyRegistrationRepository can find the IDP by origin key / alias. + if (relyingPartyRegistrationId == null && resolvedEntityId != null && samlResponseParameter != null) { + if (log.isTraceEnabled()) { + log.trace("Falling back to URL alias '{}' as registrationId", resolvedEntityId); + } + relyingPartyRegistrationId = resolvedEntityId; + } return relyingPartyRegistrationId; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderDataTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderDataTests.java index 27327ccbc75..2910c7839cd 100755 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderDataTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/BootstrapSamlIdentityProviderDataTests.java @@ -19,12 +19,14 @@ import org.cloudfoundry.identity.uaa.provider.SamlIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.config.YamlMapFactoryBean; import org.springframework.beans.factory.config.YamlProcessor; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -35,7 +37,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class BootstrapSamlIdentityProviderDataTests { @@ -411,4 +416,84 @@ void setAddShadowUserOnLoginFromYaml() { } } } + + /** + * Tests for the bootstrap-time entity ID resolution for URL-type SAML IDPs. + * + *

When {@code idpMetadata} is a URL, {@code BootstrapSamlIdentityProviderData} must attempt + * to fetch the metadata and store the entity ID in {@code idpEntityId} (persisted as + * {@code external_key}). If the fetch fails the server must still start — the error is logged + * and {@code idpEntityId} stays {@code null}. + */ + @Nested + class UrlTypeEntityIdResolution { + + private static final String URL_IDP_ALIAS = "url-type-idp"; + private static final String URL_IDP_METADATA_URL = "https://idp.example.org/saml/metadata"; + private static final String EXPECTED_ENTITY_ID = "https://idp.example.org/metadata"; + + // Use the shared XML template that contains a valid signing certificate so that + // RelyingPartyRegistrations.fromMetadata can parse the metadata without errors. + private static final String URL_IDP_METADATA_XML = XML_WITHOUT_ID.formatted(EXPECTED_ENTITY_ID); + + private static final String URL_IDP_YAML = """ + providers: + %s: + idpMetadata: %s + metadataTrustCheck: false + skipSslValidation: true + """.formatted(URL_IDP_ALIAS, URL_IDP_METADATA_URL); + + private BootstrapSamlIdentityProviderData urlBootstrap(FixedHttpMetaDataProvider httpProvider) { + return new BootstrapSamlIdentityProviderData( + new SamlIdentityProviderConfigurator( + mock(JdbcIdentityProviderProvisioning.class), + new IdentityZoneManagerImpl(), + httpProvider)); + } + + @Test + void urlIdp_resolvesEntityIdFromMetadataAtBootstrap() throws Exception { + FixedHttpMetaDataProvider httpProvider = mock(FixedHttpMetaDataProvider.class); + when(httpProvider.fetchMetadata(anyString(), anyBoolean())) + .thenReturn(URL_IDP_METADATA_XML.getBytes(StandardCharsets.UTF_8)); + + BootstrapSamlIdentityProviderData subject = urlBootstrap(httpProvider); + subject.setIdentityProviders(parseYaml(URL_IDP_YAML)); + + SamlIdentityProviderDefinition def = subject.getIdentityProviderDefinitions() + .stream() + .filter(d -> URL_IDP_ALIAS.equals(d.getIdpEntityAlias())) + .findFirst() + .orElseThrow(); + + assertThat(def.getType()).isEqualTo(SamlIdentityProviderDefinition.MetadataLocation.URL); + assertThat(def.getIdpEntityId()) + .as("entity ID must be resolved from URL metadata so external_key is stored in DB") + .isEqualTo(EXPECTED_ENTITY_ID); + } + + @Test + void urlIdp_logsErrorAndContinuesWhenMetadataFetchFails() { + FixedHttpMetaDataProvider httpProvider = mock(FixedHttpMetaDataProvider.class); + when(httpProvider.fetchMetadata(anyString(), anyBoolean())) + .thenThrow(new RuntimeException("connection refused: idp.example.org:443")); + + BootstrapSamlIdentityProviderData subject = urlBootstrap(httpProvider); + + assertThatNoException() + .as("server startup must survive a metadata fetch failure") + .isThrownBy(() -> subject.setIdentityProviders(parseYaml(URL_IDP_YAML))); + + SamlIdentityProviderDefinition def = subject.getIdentityProviderDefinitions() + .stream() + .filter(d -> URL_IDP_ALIAS.equals(d.getIdpEntityAlias())) + .findFirst() + .orElseThrow(); + + assertThat(def.getIdpEntityId()) + .as("entity ID must be null when fetch failed; ConfiguratorRelyingPartyRegistrationRepository fallback handles it at request time") + .isNull(); + } + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/Saml2TestUtils.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/Saml2TestUtils.java index 6ea0d9860e7..0f471eafd7b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/Saml2TestUtils.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/Saml2TestUtils.java @@ -36,6 +36,7 @@ import org.opensaml.saml.saml2.core.impl.AttributeBuilder; import org.opensaml.xmlsec.signature.support.SignatureConstants; import org.springframework.security.saml2.Saml2Exception; +import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; @@ -43,9 +44,16 @@ import org.springframework.util.StringUtils; import org.w3c.dom.Element; +import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; import java.time.Duration; import java.time.Instant; +import java.util.Base64; import java.util.List; import java.util.Map; @@ -141,6 +149,50 @@ public static Response responseWithAssertions(String issuer) { return responseWithAssertions(issuer, null, TestOpenSamlObjects.attributeStatements()); } + /** + * Builds a SAML Response with a signed assertion, using a caller-supplied signing credential. + * Use this when the test IDP has its own distinct key material rather than sharing the default + * test credentials. + */ + public static Response responseWithAssertions(String issuer, Saml2X509Credential signingCredential) { + Response response = response(issuer); + Assertion assertion = assertion(issuer, null, null); + assertion.getAttributeStatements().addAll(TestOpenSamlObjects.attributeStatements()); + Assertion signedAssertion = TestOpenSamlObjects.signed(assertion, signingCredential, RELYING_PARTY_ENTITY_ID); + response.getAssertions().add(signedAssertion); + return response; + } + + /** + * Builds a SAML Response with a signed assertion using a signing credential created from the + * supplied PEM-encoded PKCS8 private key and PEM-encoded X.509 certificate. This overload lets + * callers in modules that do not have {@code spring-security-saml2-service-provider} on their + * test compile classpath provide key material without constructing a {@link Saml2X509Credential}. + */ + public static Response responseWithAssertions(String issuer, String pemPrivateKey, String pemCertificate) { + return responseWithAssertions(issuer, buildSigningCredential(pemPrivateKey, pemCertificate)); + } + + private static Saml2X509Credential buildSigningCredential(String pemPrivateKey, String pemCertificate) { + try { + String keyBody = pemPrivateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + byte[] keyDer = Base64.getDecoder().decode(keyBody); + PrivateKey privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(keyDer)); + + X509Certificate certificate = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream( + pemCertificate.getBytes(StandardCharsets.UTF_8))); + + return new Saml2X509Credential(privateKey, certificate, Saml2X509Credential.Saml2X509CredentialType.SIGNING); + } catch (Exception e) { + throw new Saml2Exception("Failed to build SAML signing credential from PEM material", e); + } + } + public static Response responseWithAssertions(String username, List attributeStatements) { return responseWithAssertions(null, username, attributeStatements); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/BootstrapSamlIdpSsoMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/BootstrapSamlIdpSsoMockMvcTests.java new file mode 100644 index 00000000000..1ae3d80db06 --- /dev/null +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/BootstrapSamlIdpSsoMockMvcTests.java @@ -0,0 +1,279 @@ +package org.cloudfoundry.identity.uaa.mock.saml; + +import org.cloudfoundry.identity.uaa.DefaultTestContext; +import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.provider.IdentityProvider; +import org.cloudfoundry.identity.uaa.provider.JdbcIdentityProviderProvisioning; +import org.cloudfoundry.identity.uaa.provider.SamlIdentityProviderDefinition; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opensaml.saml.saml2.core.Response; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.cloudfoundry.identity.uaa.provider.saml.Saml2TestUtils.responseWithAssertions; +import static org.cloudfoundry.identity.uaa.provider.saml.Saml2TestUtils.serializedResponse; +import static org.springframework.http.HttpHeaders.HOST; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Regression tests: a SAML IDP bootstrapped from {@code uaa.yml} with a URL-type + * metadata location cannot be resolved when a SAML response arrives at the legacy alias endpoint. + * + *

Root cause: {@code BootstrapSamlIdentityProviderData.setIdentityProviders} only calls + * {@code def.setIdpEntityId(...)} for {@code DATA} (inline XML) metadata, never for {@code URL} + * type. {@link org.cloudfoundry.identity.uaa.provider.JdbcIdentityProviderProvisioning} stores + * {@code external_key = saml.getIdpEntityId()}, so URL-type bootstrap IDPs end up with + * {@code external_key = null}. + * + *

This manifests in two distinct failure modes depending on which ACS URL the IDP uses: + *

    + *
  1. IDP-alias URL ({@code /saml/SSO/alias/{idpAlias}}): The SP-alias {@code endsWith} + * check in {@code resolveFromRequest} fails, the resolver returns {@code null}, and Spring + * Security throws {@code relying_party_registration_not_found}. Fixed by adding a fallback + * in {@link org.cloudfoundry.identity.uaa.provider.saml.UaaRelyingPartyRegistrationResolver} + * that uses the URL alias as the registration ID.
  2. + *
  3. SP-alias URL ({@code /saml/SSO/alias/{spAlias}}): The issuer is extracted from + * the SAML response body and used as the registration ID; because {@code external_key} is + * null the entity-ID-based lookup fails, the default-stub registration is returned instead, + * and Spring Security throws {@code invalid_issuer}. Fixed by + * {@link org.cloudfoundry.identity.uaa.provider.saml.ConfiguratorRelyingPartyRegistrationRepository} + * falling back to resolving the entity ID on-the-fly from the stored metadata.
  4. + *
+ */ +@DefaultTestContext +class BootstrapSamlIdpSsoMockMvcTests { + + /** Entity ID advertised in {@link #IDP_METADATA}. */ + private static final String IDP_ENTITY_ID = "https://test-saml-idp.example.org/metadata"; + + /** + * Alias (origin key) of the test IDP. + * + *

The SP entity-ID alias from the default test properties is + * {@code "integration-saml-entity-id"}. {@code "test-bootstrap-idp"} does not end + * with that string, so when it appears as the last path segment of the ACS URL, + * {@code resolveFromRequest}'s {@code endsWith} check fails and — without the fix in + * {@code UaaRelyingPartyRegistrationResolver} — the resolver returns {@code null}. + */ + private static final String IDP_ALIAS = "test-bootstrap-idp"; + + /** + * PKCS8-encoded RSA private key for the test IDP, generated solely for this test. + * The matching certificate is embedded in {@link #IDP_METADATA}. + */ + private static final String IDP_PRIVATE_KEY = """ + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC6Vrm0mO2lSCQw + k4pdCUPcPwq8bB8x4zHOWWpD6+AQgqXpKlQEqSjrC6NVqXKHip1mPA8QE/fOpxhd + yk3Y2B7VKcvk/5Xq4EaqVslc+Ghve27RnJeY04KgKYuRFCoTeUxm+B/z2IMKr0e8 + IY96Q0HD+azYpyCeBk0wqi3kgHs6Md/C1W8evNvKOcfY7GzrTBCxM7CFuRevod/4 + vti9IVc+pbRO1/8j+DPYBk//Ta2iw0pBOnKfUtoqXhgVIXgw+citPa8hIcdZbUrP + TMq/66w3LXe4EFChKuikN5KEe88u3OcZdPBlIamhbwti+tx/qTynoqIov/xW2rwe + OQxo7XjbAgMBAAECggEAME70lSgCkFOMIlXVzLnusGZdo6zKR5Y1nuAahyJbNByS + 48iYAJ9UXt9lCHvGF/KtTMhsRUhP+fDjBcnBdeLN14ie9i720G41k8qtKJ+z/5b6 + C3iz6qiHGHu81a9rGyJa1uUj74Vlr7ryd4kh19og7ixIDeECOUW79E5iWHegutyp + t4OKRCPK1w5BDMCnowVOXxZaR97kWGhJOFx+GMuV2A2L03gm/BjgjNb9qg/PqXOJ + /hsheJw1DWFV/y9tmIRFaz9o6wotBAsoZgbGu3fxEls5Kmvr3swnedzxOzsuL0Gz + I6pEO/dmX+WXam+1Mo++QFNLv7x7n2g8IKCERugSiQKBgQD9s8pe7flb2Q9ULebd + C2R375xW8ZQ9zWATRtQFFn/NLj166sejUMHJ0QZZrb+/4xYDVHwjjO+7vQpDsgNx + DT2UZmG0rN3Wz1h3Lmu9uTd7uDxzBYU9zXBoI3XDnvjlq2y7J5hAHUHa6Pnv76A1 + UJpGsS1hscrcGLVqmTLAe72+4wKBgQC8BsCar5VycbAgd3VinhWTdjD8LGkxX/Is + wQTdFCMR/6laqqlrg4n/WwOhiM5tntehxUIAwZYeeI/QboRM05B6458YvXXsojdq + Fk+BRCsGMKmm6q633s9jecRgL/W/mOkl639g8NBOQqHSdPjTtBaI72QJT4AdqQ67 + a/z6nBjHqQKBgFSfd80aS6abTEWj2fG5LxXiUp+djPjgXD+RzH619oMV/WPWlCih + c0JB+oBHOEJlGJ6bu5yQEhbpA1d5NTSsWfH6BHUjhAt2tedrEH0EHsGhvmgPW1Y2 + BFx4F3vctuDEwUvb9SjNmX3PYC7sGuAttogF6UFA8I1hoIGiAA+8NppJAoGAVj1W + m9xK1Hn2iX2hFoFhbgg4wYDxIpdaMVK6k1gIGdpEZ/R8znY/liK9kJp56+d+CZG7 + CzO/UeyEMdpuzfn/e43pS+SiMM3aUss23hhRD37EYW2kg2srffm8q010DtPoo97W + xrTNJggDxs6lzhv8dgQuwuJ25aPDwQzvtFZiOzkCgYEAsQtdeNnYHY3ng4tJITgF + R3V7HWEJmT5KCZeQXwqqLQj8YgHRtpz1VogSJJjxswJTJ8tUCFLZkjhp0MMp5Kus + ED9mjAtPv7UxOyG5FO5/vPWp5jKdbs4JYG8BOg6NjVDwcE30fYk2MtqspfdonLX1 + 7Q9h/7Se+5+BQcQLZVDib00= + -----END PRIVATE KEY----- + """; + + /** + * Self-signed X.509 certificate for the test IDP (CN=test-saml-idp.example.org). + * Also embedded as DER base64 in {@link #IDP_METADATA}. + */ + private static final String IDP_CERTIFICATE = """ + -----BEGIN CERTIFICATE----- + MIIDKTCCAhGgAwIBAgIUaIrvisXjWSSnEFRm2MzUBeMcT/8wDQYJKoZIhvcNAQEL + BQAwJDEiMCAGA1UEAwwZdGVzdC1zYW1sLWlkcC5leGFtcGxlLm9yZzAeFw0yNjA2 + MDQyMDI1MjZaFw0zNjA2MDEyMDI1MjZaMCQxIjAgBgNVBAMMGXRlc3Qtc2FtbC1p + ZHAuZXhhbXBsZS5vcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6 + Vrm0mO2lSCQwk4pdCUPcPwq8bB8x4zHOWWpD6+AQgqXpKlQEqSjrC6NVqXKHip1m + PA8QE/fOpxhdyk3Y2B7VKcvk/5Xq4EaqVslc+Ghve27RnJeY04KgKYuRFCoTeUxm + +B/z2IMKr0e8IY96Q0HD+azYpyCeBk0wqi3kgHs6Md/C1W8evNvKOcfY7GzrTBCx + M7CFuRevod/4vti9IVc+pbRO1/8j+DPYBk//Ta2iw0pBOnKfUtoqXhgVIXgw+cit + Pa8hIcdZbUrPTMq/66w3LXe4EFChKuikN5KEe88u3OcZdPBlIamhbwti+tx/qTyn + oqIov/xW2rweOQxo7XjbAgMBAAGjUzBRMB0GA1UdDgQWBBTd9Zb48hd5bMLVsHKs + n7o1xo42DDAfBgNVHSMEGDAWgBTd9Zb48hd5bMLVsHKsn7o1xo42DDAPBgNVHRMB + Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBGjL1XCr38xiyCKuxiHaMInhpI + CucJo+k/vdYBU+H9rJOnHbUFswz8ufPkLOOeMdx7DbU8E8ePP2Vbilb129lfgocL + 2WUYJKoFE6yBs37VTnzqWPu+ynjguib1Aa0kqBGal4ylZkHoDH2FlQ5r38ab5p8i + pRwkq/5v4+B1MmOSbRV2chFHBoa0oHSxsmpSxoQ2TgBElsr9GeLvr73dxANcT8W0 + rQKI7o+EPOmXcwTJktnfCrwJjN/UsH8t6DZ6eSp/1xKNHio5baEqI9uWDCOdSSu4 + fkyQqd14HeQqOjNDr2lF+h0LVW6zW0YKFytJPq4sk7ZfPuZk6ID6HFzfg7wl + -----END CERTIFICATE----- + """; + + /** + * Minimal SAML IDP metadata containing {@link #IDP_ENTITY_ID} and the DER base64 form of + * {@link #IDP_CERTIFICATE}. The certificate must match the private key used to sign assertions + * so that signature verification succeeds once the correct registration is resolved. + */ + private static final String IDP_METADATA = """ + + + + + + + MIIDKTCCAhGgAwIBAgIUaIrvisXjWSSnEFRm2MzUBeMcT/8wDQYJKoZIhvcNAQELBQAwJDEiMCAGA1UEAwwZdGVzdC1zYW1sLWlkcC5leGFtcGxlLm9yZzAeFw0yNjA2MDQyMDI1MjZaFw0zNjA2MDEyMDI1MjZaMCQxIjAgBgNVBAMMGXRlc3Qtc2FtbC1pZHAuZXhhbXBsZS5vcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6Vrm0mO2lSCQwk4pdCUPcPwq8bB8x4zHOWWpD6+AQgqXpKlQEqSjrC6NVqXKHip1mPA8QE/fOpxhdyk3Y2B7VKcvk/5Xq4EaqVslc+Ghve27RnJeY04KgKYuRFCoTeUxm+B/z2IMKr0e8IY96Q0HD+azYpyCeBk0wqi3kgHs6Md/C1W8evNvKOcfY7GzrTBCxM7CFuRevod/4vti9IVc+pbRO1/8j+DPYBk//Ta2iw0pBOnKfUtoqXhgVIXgw+citPa8hIcdZbUrPTMq/66w3LXe4EFChKuikN5KEe88u3OcZdPBlIamhbwti+tx/qTynoqIov/xW2rweOQxo7XjbAgMBAAGjUzBRMB0GA1UdDgQWBBTd9Zb48hd5bMLVsHKsn7o1xo42DDAfBgNVHSMEGDAWgBTd9Zb48hd5bMLVsHKsn7o1xo42DDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBGjL1XCr38xiyCKuxiHaMInhpICucJo+k/vdYBU+H9rJOnHbUFswz8ufPkLOOeMdx7DbU8E8ePP2Vbilb129lfgocL2WUYJKoFE6yBs37VTnzqWPu+ynjguib1Aa0kqBGal4ylZkHoDH2FlQ5r38ab5p8ipRwkq/5v4+B1MmOSbRV2chFHBoa0oHSxsmpSxoQ2TgBElsr9GeLvr73dxANcT8W0rQKI7o+EPOmXcwTJktnfCrwJjN/UsH8t6DZ6eSp/1xKNHio5baEqI9uWDCOdSSu4fkyQqd14HeQqOjNDr2lF+h0LVW6zW0YKFytJPq4sk7ZfPuZk6ID6HFzfg7wl + + + + + + + """; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcIdentityProviderProvisioning jdbcIdentityProviderProvisioning; + + @BeforeEach + void setUp() { + // Persist a SAML IDP without calling setIdpEntityId(): this leaves external_key null, + // reproducing exactly the state that BootstrapSamlIdentityProviderData creates for + // URL-type metadata IDPs configured in uaa.yml. + SamlIdentityProviderDefinition def = new SamlIdentityProviderDefinition() + .setMetaDataLocation(IDP_METADATA) + .setIdpEntityAlias(IDP_ALIAS) + .setZoneId(IdentityZone.getUaaZoneId()); + + IdentityProvider idp = new IdentityProvider() + .setType(OriginKeys.SAML) + .setOriginKey(IDP_ALIAS) + .setActive(true) + .setName("Test Bootstrap SAML IDP") + .setIdentityZoneId(IdentityZone.getUaaZoneId()) + .setConfig(def); + jdbcIdentityProviderProvisioning.create(idp, IdentityZone.getUaaZoneId()); + } + + @AfterEach + void tearDown() { + jdbcIdentityProviderProvisioning.deleteByOrigin(IDP_ALIAS, IdentityZone.getUaaZoneId()); + } + + /** + * Failure mode 1 — IDP-alias ACS URL ({@code relying_party_registration_not_found}). + * + *

When the IDP posts its SAML response to {@code /saml/SSO/alias/test-bootstrap-idp} + * (the IDP alias), {@code resolveFromRequest} checks whether the URL path ends with the SP + * entity-ID alias ({@code "integration-saml-entity-id"}). It does not, so without the fix + * {@code relyingPartyRegistrationId} stays {@code null}, the resolver returns {@code null}, + * and Spring Security logs: + *

+     *   Saml2AuthenticationException{error=[relying_party_registration_not_found]
+     *     No relying party registration found}
+     * 
+ * + *

The fix in {@link org.cloudfoundry.identity.uaa.provider.saml.UaaRelyingPartyRegistrationResolver} + * adds a fallback: when the {@code endsWith} check fails but a {@code SAMLResponse} parameter + * is present, the URL alias is used as the registration ID. + * {@link org.cloudfoundry.identity.uaa.provider.saml.ConfiguratorRelyingPartyRegistrationRepository} + * then finds the IDP by origin key and authentication succeeds. + */ + @Test + void samlResponse_viaIdpAliasUrl_authenticatesSuccessfully() throws Exception { + Response samlResponse = responseWithAssertions(IDP_ENTITY_ID, IDP_PRIVATE_KEY, IDP_CERTIFICATE); + String encodedSamlResponse = serializedResponse(samlResponse); + + MockHttpSession session = (MockHttpSession) mockMvc.perform( + post("/uaa/saml/SSO/alias/" + IDP_ALIAS) + .contextPath("/uaa") + .header(HOST, "localhost:8080") + .param("SAMLResponse", encodedSamlResponse)) + .andDo(print()) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/uaa/")) + .andReturn().getRequest().getSession(false); + + assertAuthenticated(session); + } + + /** + * Failure mode 2 — SP-alias ACS URL ({@code invalid_issuer}). + * + *

When the IDP posts to the canonical SP ACS URL + * ({@code /saml/SSO/alias/integration-saml-entity-id}), {@code resolveFromRequest} correctly + * extracts the issuer ({@link #IDP_ENTITY_ID}) from the SAML response body and uses it as + * the registration ID. The lookup then calls + * {@code configurator.getIdentityProviderDefinitionsForIssuer}, which queries by + * {@code external_key}. Because {@code external_key} is {@code null} for URL-type bootstrap + * IDPs, the query returns nothing. The loop also fails ({@code idpEntityId == null}), the + * {@code defaultRepo} returns a stub registration whose asserting-party entity ID is the SP + * itself, and Spring Security logs: + *

+     *   Saml2AuthenticationException{error=[invalid_issuer]
+     *     Invalid issuer [https://test-saml-idp.example.org/metadata] ...}
+     * 
+ * + *

The fix in + * {@link org.cloudfoundry.identity.uaa.provider.saml.ConfiguratorRelyingPartyRegistrationRepository} + * resolves the entity ID on-the-fly from the stored metadata XML when {@code idpEntityId} + * (sourced from {@code external_key}) is {@code null}, so the correct registration is found + * and authentication succeeds. + */ + @Test + void samlResponse_viaSpAliasUrl_authenticatesSuccessfully() throws Exception { + Response samlResponse = responseWithAssertions(IDP_ENTITY_ID, IDP_PRIVATE_KEY, IDP_CERTIFICATE); + String encodedSamlResponse = serializedResponse(samlResponse); + + MockHttpSession session = (MockHttpSession) mockMvc.perform( + post("/uaa/saml/SSO/alias/integration-saml-entity-id") + .contextPath("/uaa") + .header(HOST, "localhost:8080") + .param("SAMLResponse", encodedSamlResponse)) + .andDo(print()) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/uaa/")) + .andReturn().getRequest().getSession(false); + + assertAuthenticated(session); + } + + /** + * Asserts the user is authenticated by reading the {@link org.springframework.security.core.context.SecurityContext} + * from the zone-namespaced sub-session. UAA stores session attributes under a context-path + * prefix via {@link org.cloudfoundry.identity.uaa.zone.ZonePathHttpSession}; + * {@link MockMvcUtils#getZoneSession} applies the matching prefix — the same pattern used + * throughout other UAA MockMvc tests (e.g. {@code PasswordChangeEndpointMockMvcTests}). + */ + private void assertAuthenticated(MockHttpSession session) { + assertThat(session).isNotNull(); + SecurityContext ctx = (SecurityContext) MockMvcUtils.getZoneSession(session, "/uaa") + .getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + assertThat(ctx).isNotNull(); + assertThat(ctx.getAuthentication().isAuthenticated()).isTrue(); + assertThat(ctx.getAuthentication().getName()).isEqualTo("test@saml.user"); + } +}