Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,22 @@ public void setIdentityProviders(Map<String, Map<String, Object>> providers) {

IdentityProvider<SamlIdentityProviderDefinition> 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<SamlIdentityProviderDefinition> wrapper = new IdentityProviderWrapper<>(provider);
wrapper.setOverride(override == null || override);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

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

Expand Down Expand Up @@ -411,4 +416,84 @@ void setAddShadowUserOnLoginFromYaml() {
}
}
}

/**
* Tests for the bootstrap-time entity ID resolution for URL-type SAML IDPs.
*
* <p>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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,24 @@
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;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
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;

Expand Down Expand Up @@ -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<AttributeStatement> attributeStatements) {
return responseWithAssertions(null, username, attributeStatements);
}
Expand Down
Loading
Loading