diff --git a/Directory.Packages.props b/Directory.Packages.props
index 28e7fb534..3689dd29f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -98,6 +98,7 @@
+
diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs
index 1baaa713f..7dfcd698f 100644
--- a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs
+++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs
@@ -50,6 +50,61 @@ internal static class SecureXmlParser
ConformanceLevel = ConformanceLevel.Document
};
+ internal static XElement LoadXElement(Stream input)
+ {
+ try
+ {
+ var streamReader = new StreamReader(input);
+ using var xmlReader = XmlReader.Create(streamReader, SecureSettings);
+ return XElement.Load(xmlReader);
+ }
+ catch (XmlException ex)
+ {
+ throw new XmlException(
+ "Failed to parse XML document with secure settings. " +
+ "The document may contain prohibited constructs (DTD, external entities) or be malformed.",
+ ex);
+ }
+ }
+
+ ///
+ /// Loads an XElement from a string with secure settings.
+ ///
+ /// The XML string to parse
+ /// A parsed XElement
+ /// Thrown when xml is null or empty
+ /// Thrown when XML is malformed or violates security constraints
+ internal static XElement LoadXElement(string xml)
+ {
+ if (string.IsNullOrEmpty(xml))
+ {
+ throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty");
+ }
+
+ // Check size before parsing
+ if (xml.Length > MaxMessageSize)
+ {
+ throw new XmlException(
+ $"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " +
+ $"Actual size: {xml.Length} bytes.");
+ }
+
+ try
+ {
+ using var stringReader = new StringReader(xml);
+ using var xmlReader = XmlReader.Create(stringReader, SecureSettings);
+
+ return XElement.Load(xmlReader);
+ }
+ catch (XmlException ex)
+ {
+ throw new XmlException(
+ "Failed to parse XML document with secure settings. " +
+ "The document may contain prohibited constructs (DTD, external entities) or be malformed.",
+ ex);
+ }
+ }
+
internal static XDocument LoadXDocument(Stream input)
{
try
@@ -66,6 +121,44 @@ internal static class SecureXmlParser
}
}
+ ///
+ /// Loads an XDocument from a string with secure settings.
+ ///
+ /// The XML string to parse
+ /// A parsed XDocument
+ /// Thrown when xml is null or empty
+ /// Thrown when XML is malformed or violates security constraints
+ internal static XDocument LoadXDocument(string xml)
+ {
+ if (string.IsNullOrEmpty(xml))
+ {
+ throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty");
+ }
+
+ // Check size before parsing
+ if (xml.Length > MaxMessageSize)
+ {
+ throw new XmlException(
+ $"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " +
+ $"Actual size: {xml.Length} bytes.");
+ }
+
+ try
+ {
+ using var stringReader = new StringReader(xml);
+ using var xmlReader = XmlReader.Create(stringReader, SecureSettings);
+
+ return XDocument.Load(xmlReader);
+ }
+ catch (XmlException ex)
+ {
+ throw new XmlException(
+ "Failed to parse XML document with secure settings. " +
+ "The document may contain prohibited constructs (DTD, external entities) or be malformed.",
+ ex);
+ }
+ }
+
internal static XmlDocument LoadXmlDocument(string xml)
{
if (string.IsNullOrEmpty(xml))
diff --git a/identity-server/src/IdentityServer/Saml/Models/EndpointType.cs b/identity-server/src/IdentityServer/Saml/Models/EndpointType.cs
deleted file mode 100644
index f9c65b9de..000000000
--- a/identity-server/src/IdentityServer/Saml/Models/EndpointType.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright (c) Duende Software. All rights reserved.
-// See LICENSE in the project root for license information.
-
-using Duende.IdentityServer.Models;
-
-namespace Duende.IdentityServer.Saml.Models;
-
-public record EndpointType
-{
- public required Uri Location { get; init; }
-
- public required SamlBinding Binding { get; init; }
-}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs
new file mode 100644
index 000000000..d601e5fa2
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+#nullable enable
+
+using System.Net;
+using Microsoft.Net.Http.Headers;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+internal class CookieHandler(HttpMessageHandler innerHandler, CookieContainer? cookies = null) : DelegatingHandler(innerHandler)
+{
+ public void ClearCookies() => CookieContainer = new CookieContainer();
+ public CookieContainer CookieContainer { get; private set; } = cookies ?? new CookieContainer();
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var requestUri = request.RequestUri;
+ var header = CookieContainer.GetCookieHeader(requestUri!);
+ if (!string.IsNullOrEmpty(header))
+ {
+ request.Headers.Add(HeaderNames.Cookie, header);
+ }
+
+ var response = await base.SendAsync(request, cancellationToken);
+
+ if (response.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders))
+ {
+ foreach (var cookieHeader in SetCookieHeaderValue.ParseList(setCookieHeaders.ToList()))
+ {
+ var cookie = new Cookie(cookieHeader.Name.Value!,
+ cookieHeader.Value.Value,
+ cookieHeader.Path.Value);
+ if (cookieHeader.Expires.HasValue)
+ {
+ cookie.Expires = cookieHeader.Expires.Value.UtcDateTime;
+ }
+
+ CookieContainer.Add(requestUri!, cookie);
+ }
+
+ }
+
+ return response;
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs
new file mode 100644
index 000000000..9f8f4849f
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs
@@ -0,0 +1,307 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Security.Claims;
+using Duende.IdentityModel;
+using Duende.IdentityServer.Configuration;
+using Duende.IdentityServer.Saml;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+public class SamlClaimsMappingTests(ITestOutputHelper output)
+{
+ private const string Category = "SAML Claims Mapping";
+
+ private SamlFixture Fixture = new(output);
+ private SamlDataBuilder Build => Fixture.Builder;
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task claims_should_use_default_mappings_for_standard_claims()
+ {
+ // Arrange - default mappings should be active
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ var claims = new List
+ {
+ new(JwtClaimTypes.Subject, "user123"),
+ new("name", "John Doe"),
+ new("email", "john@example.com"),
+ new("role", "Admin")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ // Act
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+
+ // Verify mapped attributes are present with correct names
+ var attributes = successResponse.Assertion.Attributes;
+ attributes.ShouldNotBeNull();
+
+ var nameAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name");
+ nameAttr.ShouldNotBeNull();
+ nameAttr.Value.ShouldBe("John Doe");
+
+ var emailAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");
+ emailAttr.ShouldNotBeNull();
+ emailAttr.Value.ShouldBe("john@example.com");
+
+ var roleAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/role");
+ roleAttr.ShouldNotBeNull();
+ roleAttr.Value.ShouldBe("Admin");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task unmapped_claims_should_be_excluded_from_assertion()
+ {
+ // Arrange - only default mappings active
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ var claims = new List
+ {
+ new(JwtClaimTypes.Subject, "user123"),
+ new("name", "John Doe"),
+ new("custom_claim_not_mapped", "should not appear"),
+ new("another_unmapped", "also excluded")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ // Act
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+
+ var attributes = successResponse.Assertion.Attributes;
+ attributes.ShouldNotBeNull();
+
+ // Verify only mapped claim (name) is present
+ var nameAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name");
+ nameAttr.ShouldNotBeNull();
+ nameAttr.Value.ShouldBe("John Doe");
+
+ // Verify unmapped claims are excluded
+ attributes.ShouldNotContain(a => a.Name != null && a.Name.Contains("custom_claim"));
+ attributes.ShouldNotContain(a => a.Name != null && a.Name.Contains("another_unmapped"));
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task service_provider_mappings_should_override_global_defaults()
+ {
+ // Arrange - SP with custom claim mappings
+ var spWithCustomMappings = Build.SamlServiceProvider();
+ spWithCustomMappings.ClaimMappings = new ReadOnlyDictionary(new Dictionary
+ {
+ ["email"] = "mail", // Override default mapping
+ ["department"] = "ou" // Custom mapping
+ });
+
+ Fixture.ServiceProviders.Add(spWithCustomMappings);
+ await Fixture.InitializeAsync();
+
+ var claims = new List
+ {
+ new(JwtClaimTypes.Subject, "user123"),
+ new("email", "jane@example.com"),
+ new("department", "Engineering")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ // Act
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+
+ var attributes = successResponse.Assertion.Attributes;
+ attributes.ShouldNotBeNull();
+
+ // Verify email uses SP's custom mapping (not default)
+ var emailAttr = attributes.FirstOrDefault(a => a.Name == "mail");
+ emailAttr.ShouldNotBeNull();
+ emailAttr.Value.ShouldBe("jane@example.com");
+
+ // Verify default email mapping is NOT present
+ attributes.ShouldNotContain(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");
+
+ // Verify custom department mapping
+ var deptAttr = attributes.FirstOrDefault(a => a.Name == "ou");
+ deptAttr.ShouldNotBeNull();
+ deptAttr.Value.ShouldBe("Engineering");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task custom_claim_mapper_should_replace_default_mapping_logic()
+ {
+ // Arrange - register custom mapper via ConfigureServices
+ var customMapper = new TestSamlClaimsMapper();
+ Fixture.ConfigureServices = services =>
+ {
+ services.AddSingleton(customMapper);
+ };
+
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ var claims = new List
+ {
+ new(JwtClaimTypes.Subject, "user123"),
+ new("email", "test@example.com"),
+ new("name", "Should be ignored")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ // Act
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+
+ var attributes = successResponse.Assertion.Attributes;
+ attributes.ShouldNotBeNull();
+
+ // Verify custom mapper output appears
+ var customAttr = attributes.FirstOrDefault(a => a.Name == "CUSTOM_MAPPED");
+ customAttr.ShouldNotBeNull();
+ customAttr.Value.ShouldBe("custom_value");
+
+ // Verify default mappings were NOT applied (custom mapper replaces everything)
+ attributes.ShouldNotContain(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name");
+ attributes.ShouldNotContain(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task multi_valued_claims_should_be_grouped_into_single_attribute()
+ {
+ // Arrange
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ var claims = new List
+ {
+ new(JwtClaimTypes.Subject, "user123"),
+ new("role", "Admin"),
+ new("role", "User"),
+ new("role", "Manager")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ // Act
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+
+ var attributes = successResponse.Assertion.Attributes;
+ attributes.ShouldNotBeNull();
+
+ // Verify only one role attribute exists
+ var roleAttributes = attributes.Where(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/role").ToList();
+ roleAttributes.Count.ShouldBe(1);
+
+ // Verify it has all three values
+ var roleAttr = roleAttributes.First();
+ roleAttr.Values.Count.ShouldBe(3);
+ roleAttr.Values.ShouldContain("Admin");
+ roleAttr.Values.ShouldContain("User");
+ roleAttr.Values.ShouldContain("Manager");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task custom_global_mappings_should_apply_to_all_service_providers()
+ {
+ // Arrange - configure custom global mappings via ConfigureServices
+ Fixture.ConfigureServices = services =>
+ {
+ // Replace the registered SamlOptions with our custom instance
+ services.AddSingleton(Microsoft.Extensions.Options.Options.Create(new SamlOptions
+ {
+ DefaultClaimMappings = new ReadOnlyDictionary(new Dictionary
+ {
+ ["email"] = "emailAddress",
+ ["department"] = "dept"
+ })
+ }));
+ };
+
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ var claims = new List
+ {
+ new(JwtClaimTypes.Subject, "user123"),
+ new("email", "test@example.com"),
+ new("department", "Sales")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ // Act
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+
+ var attributes = successResponse.Assertion.Attributes;
+ attributes.ShouldNotBeNull();
+
+ // Verify custom mappings are used
+ var emailAttr = attributes.FirstOrDefault(a => a.Name == "emailAddress");
+ emailAttr.ShouldNotBeNull();
+ emailAttr.Value.ShouldBe("test@example.com");
+
+ var deptAttr = attributes.FirstOrDefault(a => a.Name == "dept");
+ deptAttr.ShouldNotBeNull();
+ deptAttr.Value.ShouldBe("Sales");
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs
new file mode 100644
index 000000000..8dfcb3745
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+#nullable enable
+
+using Microsoft.Extensions.Time.Testing;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+internal class SamlData
+{
+ public DateTimeOffset Now => FakeTimeProvider.GetUtcNow();
+
+ public FakeTimeProvider FakeTimeProvider =
+ new FakeTimeProvider(new DateTimeOffset(2000, 1, 2, 3, 4, 5, TimeSpan.Zero));
+
+ public string EntityId = "https://sp.example.com";
+
+ public Uri AcsUrl = new Uri("https://sp.example.com/callback");
+
+ public Uri SingleLogoutServiceUrl = new Uri("https://sp.example.com/logout");
+
+ public string RequestId = "_request-123";
+
+ public string? RelayState = "some_state";
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs
new file mode 100644
index 000000000..1f74c1bba
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs
@@ -0,0 +1,140 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+#nullable enable
+
+using Duende.IdentityServer.Internal.Saml;
+using Duende.IdentityServer.Models;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+internal class SamlDataBuilder(SamlData data)
+{
+ public SamlServiceProvider SamlServiceProvider(
+ System.Security.Cryptography.X509Certificates.X509Certificate2? signingCertificate = null,
+ bool requireSignedAuthnRequests = false,
+ System.Security.Cryptography.X509Certificates.X509Certificate2[]? encryptionCertificates = null,
+ bool encryptAssertions = false,
+ string? entityId = null) => new SamlServiceProvider
+ {
+ EntityId = entityId ?? data.EntityId,
+ DisplayName = "Example SP",
+ Description = "Example SP",
+ Enabled = true,
+ RequireSignedAuthnRequests = requireSignedAuthnRequests || signingCertificate != null,
+ SigningCertificates = signingCertificate != null ? [signingCertificate] : null,
+ EncryptionCertificates = encryptionCertificates,
+ EncryptAssertions = encryptAssertions,
+ AssertionConsumerServiceUrls = [data.AcsUrl],
+ AssertionConsumerServiceBinding = SamlBinding.HttpPost,
+ SingleLogoutServiceUrl = new SamlEndpointType { Location = data.SingleLogoutServiceUrl, Binding = SamlBinding.HttpRedirect },
+ RequestMaxAge = TimeSpan.FromMinutes(5),
+ ClockSkew = TimeSpan.FromMinutes(5)
+ };
+
+
+ public string AuthNRequestXml(
+ DateTimeOffset? issueInstant = null,
+ Uri? destination = null,
+ Uri? acsUrl = null,
+ int? acsIndex = null,
+ string? version = null,
+ bool forceAuthn = false,
+ bool isPassive = false,
+ string? requestId = null,
+ string? issuer = null,
+ string? requestedAuthnContext = null,
+ string? nameIdFormat = null,
+ string? spNameQualifier = null
+ )
+ {
+ var id = requestId ?? data.RequestId;
+ var issuerValue = issuer ?? data.EntityId;
+
+ var acsAttributes = "";
+ if (acsUrl != null && acsIndex != null)
+ {
+ acsAttributes = $"""AssertionConsumerServiceURL="{acsUrl}" AssertionConsumerServiceIndex="{acsIndex}" """;
+ }
+ else if (acsUrl != null)
+ {
+ acsAttributes = $"""AssertionConsumerServiceURL="{acsUrl}" """;
+ }
+ else if (acsIndex != null)
+ {
+ acsAttributes = $"""AssertionConsumerServiceIndex="{acsIndex}" """;
+ }
+ else
+ {
+ acsAttributes = $"""AssertionConsumerServiceURL="{data.AcsUrl}" """;
+ }
+
+ var nameIdPolicyElement = "";
+ if (nameIdFormat != null || spNameQualifier != null)
+ {
+ var formatAttr = nameIdFormat != null ? $"""Format="{nameIdFormat}" """ : "";
+ var spNameQualifierAttr = spNameQualifier != null ? $"""SPNameQualifier="{spNameQualifier}" """ : "";
+ nameIdPolicyElement = $"""""";
+ }
+
+ return $"""
+
+
+ {issuerValue}
+ {requestedAuthnContext}
+ {nameIdPolicyElement}
+
+ """.Trim();
+ }
+
+ public string LogoutRequestXml(
+ DateTimeOffset? issueInstant = null,
+ Uri? destination = null,
+ string? version = null,
+ string? requestId = null,
+ string? issuer = null,
+ string? nameId = null,
+ string? nameIdFormat = null,
+ string? sessionIndex = "12345",
+ DateTimeOffset? notOnOrAfter = null)
+ {
+ var id = requestId ?? data.RequestId;
+ var issuerValue = issuer ?? data.EntityId;
+ var nameIdValue = nameId ?? "user@example.com";
+ var nameIdFormatValue = nameIdFormat ?? SamlConstants.NameIdentifierFormats.EmailAddress;
+
+ var sessionIndexElement = sessionIndex != null
+ ? $"{sessionIndex}"
+ : "";
+
+ var notOnOrAfterAttr = notOnOrAfter.HasValue
+ ? $"NotOnOrAfter=\"{notOnOrAfter.Value:yyyy-MM-ddTHH:mm:ssZ}\""
+ : "";
+
+ return $"""
+
+
+ {issuerValue}
+ {nameIdValue}
+ {sessionIndexElement}
+
+ """.Trim();
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs
new file mode 100644
index 000000000..8f5826f9b
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs
@@ -0,0 +1,460 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+#nullable enable
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Duende.IdentityModel;
+using Duende.IdentityServer.Models;
+using Xunit.Abstractions;
+using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers;
+using SamlStatusCode = Duende.IdentityServer.Saml.Models.SamlStatusCode;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+public class SamlEncryptionTests(ITestOutputHelper output)
+{
+ private const string Category = "SAML Encryption";
+
+ private SamlFixture Fixture = new(output);
+ private SamlData Data => Fixture.Data;
+ private SamlDataBuilder Build => Fixture.Builder;
+
+ private X509Certificate2 CreateTestEncryptionCertificate()
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(
+ "CN=Test SP Encryption",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ request.CertificateExtensions.Add(
+ new X509KeyUsageExtension(
+ X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment,
+ critical: true));
+
+ var cert = request.CreateSelfSigned(
+ Data.Now.AddDays(-7),
+ Data.Now.AddDays(365));
+
+ var exported = cert.Export(X509ContentType.Pfx, "test");
+ return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task successful_auth_should_return_encrypted_assertion()
+ {
+ // Arrange
+ var encryptionCert = CreateTestEncryptionCertificate();
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider(
+ encryptionCertificates: [encryptionCert],
+ encryptAssertions: true));
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(JwtClaimTypes.Subject, "user-123")],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ var responseXml = responseData.responseXml;
+
+ // Verify encrypted assertion is present
+ HasEncryptedAssertion(responseXml).ShouldBeTrue("Response should contain EncryptedAssertion");
+ HasPlainAssertion(responseXml).ShouldBeFalse("Response should not contain plain Assertion");
+
+ var (_, _, responseElement) = ParseSamlResponseXml(responseXml);
+ ValidateEncryptedStructure(responseElement);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task encrypted_assertion_should_be_decryptable_and_content_verified()
+ {
+ // Arrange
+ var encryptionCert = CreateTestEncryptionCertificate();
+ var sp = Build.SamlServiceProvider(encryptionCertificates: [encryptionCert], encryptAssertions: true);
+ sp.ClaimMappings = new ReadOnlyDictionary(new Dictionary
+ {
+ [JwtClaimTypes.Subject] = JwtClaimTypes.Subject,
+ [JwtClaimTypes.Email] = JwtClaimTypes.Email,
+ [JwtClaimTypes.Name] = JwtClaimTypes.Name,
+ [JwtClaimTypes.Role] = JwtClaimTypes.Role
+ });
+ Fixture.ServiceProviders.Add(sp);
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim(JwtClaimTypes.Subject, "user-decrypt-test"),
+ new Claim(JwtClaimTypes.Email, "decrypt@example.com"),
+ new Claim(JwtClaimTypes.Name, "Decrypt Test User"),
+ new Claim(JwtClaimTypes.Role, "admin")
+ ],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert - Decrypt and verify actual content
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var samlResponse = await ExtractAndDecryptSamlSuccessFromPostAsync(result, encryptionCert, CancellationToken.None);
+
+ samlResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value);
+ samlResponse.Assertion.ShouldNotBeNull();
+
+ samlResponse.Assertion.Subject.ShouldNotBeNull();
+ samlResponse.Assertion.Subject.NameId.ShouldBe("user-decrypt-test");
+
+ var attributes = samlResponse.Assertion.Attributes;
+ attributes.ShouldNotBeNull();
+ attributes.Count.ShouldBeGreaterThan(0);
+
+ var subjectAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Subject);
+ subjectAttr.ShouldNotBeNull();
+ subjectAttr.Value.ShouldBe("user-decrypt-test");
+
+ var emailAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Email);
+ emailAttr.ShouldNotBeNull();
+ emailAttr.Value.ShouldBe("decrypt@example.com");
+
+ var nameAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Name);
+ nameAttr.ShouldNotBeNull();
+ nameAttr.Value.ShouldBe("Decrypt Test User");
+
+ var roleAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Role);
+ roleAttr.ShouldNotBeNull();
+ roleAttr.Value.ShouldBe("admin");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task encrypted_assertion_should_contain_expected_attributes()
+ {
+ // Arrange
+ var encryptionCert = CreateTestEncryptionCertificate();
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider(
+ encryptionCertificates: [encryptionCert],
+ encryptAssertions: true));
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim(JwtClaimTypes.Subject, "user-verify-structure"),
+ new Claim(JwtClaimTypes.Email, "verify@example.com"),
+ new Claim(JwtClaimTypes.Name, "Verify Test User"),
+ new Claim(JwtClaimTypes.Role, "admin")
+ ],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert - Verify encrypted structure
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ var responseXml = responseData.responseXml;
+
+ // Verify encryption happened
+ HasEncryptedAssertion(responseXml).ShouldBeTrue();
+ HasPlainAssertion(responseXml).ShouldBeFalse();
+
+ // Parse and validate structure
+ var (_, _, responseElement) = ParseSamlResponseXml(responseXml);
+
+ // Validate encrypted structure per SAML spec
+ ValidateEncryptedStructure(responseElement);
+
+ // Verify EncryptedData contains ciphertext
+ var encNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2001/04/xmlenc#");
+ var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion");
+
+ var encryptedAssertion = responseElement.Element(samlNs + "EncryptedAssertion");
+ encryptedAssertion.ShouldNotBeNull();
+
+ var cipherValue = encryptedAssertion.Descendants(encNs + "CipherValue").FirstOrDefault();
+ cipherValue.ShouldNotBeNull();
+ cipherValue.Value.ShouldNotBeNullOrWhiteSpace("Encrypted data should contain cipher value");
+
+ // Verify it's actually encrypted (base64-encoded binary data)
+ var isBase64 = TryFromBase64String(cipherValue.Value, out var _);
+ isBase64.ShouldBeTrue("CipherValue should be valid base64");
+ }
+
+ private static bool TryFromBase64String(string s, out byte[]? result)
+ {
+ try
+ {
+ result = Convert.FromBase64String(s);
+ return true;
+ }
+ catch
+ {
+ result = null;
+ return false;
+ }
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task encrypted_assertion_structure_should_be_valid()
+ {
+ // Arrange
+ var encryptionCert = CreateTestEncryptionCertificate();
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider(
+ encryptionCertificates: [encryptionCert],
+ encryptAssertions: true));
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim(JwtClaimTypes.Subject, "user-456"),
+ new Claim(JwtClaimTypes.Email, "test@example.com"),
+ new Claim(JwtClaimTypes.Name, "Test User")
+ ],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert - Verify structure is valid (can't test decryption due to helper limitations)
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ var responseXml = responseData.responseXml;
+ var (_, _, responseElement) = ParseSamlResponseXml(responseXml);
+
+ HasEncryptedAssertion(responseXml).ShouldBeTrue();
+ ValidateEncryptedStructure(responseElement);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task encryption_should_preserve_response_signature()
+ {
+ // Arrange
+ var encryptionCert = CreateTestEncryptionCertificate();
+ var sp = Build.SamlServiceProvider(
+ encryptionCertificates: [encryptionCert],
+ encryptAssertions: true);
+ sp.SigningBehavior = SamlSigningBehavior.SignResponse;
+ Fixture.ServiceProviders.Add(sp);
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(JwtClaimTypes.Subject, "user-789")],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ var responseXml = responseData.responseXml;
+ var (_, _, responseElement) = ParseSamlResponseXml(responseXml);
+
+ // Verify response is signed
+ var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#");
+ var signature = responseElement.Element(dsNs + "Signature");
+ signature.ShouldNotBeNull("Response should be signed");
+
+ // Verify encrypted assertion is present
+ HasEncryptedAssertion(responseXml).ShouldBeTrue();
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task encryption_should_work_with_sign_assertion_behavior()
+ {
+ // Arrange
+ var encryptionCert = CreateTestEncryptionCertificate();
+ var sp = Build.SamlServiceProvider(
+ encryptionCertificates: [encryptionCert],
+ encryptAssertions: true);
+ sp.SigningBehavior = SamlSigningBehavior.SignAssertion;
+ Fixture.ServiceProviders.Add(sp);
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(JwtClaimTypes.Subject, "user-sign-assertion")],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert - Encryption should happen after signing
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ HasEncryptedAssertion(responseData.responseXml).ShouldBeTrue();
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task encryption_should_work_with_sign_both_behavior()
+ {
+ // Arrange
+ var encryptionCert = CreateTestEncryptionCertificate();
+ var sp = Build.SamlServiceProvider(
+ encryptionCertificates: [encryptionCert],
+ encryptAssertions: true);
+ sp.SigningBehavior = SamlSigningBehavior.SignBoth;
+ Fixture.ServiceProviders.Add(sp);
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(JwtClaimTypes.Subject, "user-sign-both")],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ var responseXml = responseData.responseXml;
+ var (_, _, responseElement) = ParseSamlResponseXml(responseXml);
+
+ // Verify response is signed
+ var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#");
+ var responseSignature = responseElement.Element(dsNs + "Signature");
+ responseSignature.ShouldNotBeNull("Response should be signed");
+
+ // Verify encrypted assertion is present
+ HasEncryptedAssertion(responseXml).ShouldBeTrue();
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task multiple_certificates_should_use_first_valid()
+ {
+ // Arrange
+ var validCert1 = CreateTestEncryptionCertificate();
+ var validCert2 = CreateTestEncryptionCertificate();
+
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider(
+ encryptionCertificates: [validCert1, validCert2],
+ encryptAssertions: true));
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(JwtClaimTypes.Subject, "user-multi-cert")],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert - Should encrypt successfully with first valid cert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ HasEncryptedAssertion(responseData.responseXml).ShouldBeTrue();
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task expired_certificate_should_cause_server_error()
+ {
+ // Arrange
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(
+ "CN=Expired Cert",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ var expiredCert = request.CreateSelfSigned(
+ Data.Now.AddDays(-365),
+ Data.Now.AddDays(-1));
+
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider(
+ encryptionCertificates: [expiredCert],
+ encryptAssertions: true));
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(JwtClaimTypes.Subject, "user-expired")],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert - Expired cert is a configuration error, so expect 500
+ result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task no_encryption_when_certificates_not_configured()
+ {
+ // Arrange
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider(
+ encryptionCertificates: null)); // No encryption certs
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(JwtClaimTypes.Subject, "user-no-encrypt")],
+ "Test"));
+
+ await Fixture.InitializeAsync();
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), CancellationToken.None);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ // Assert - Should return plain assertion
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var responseData = await ExtractSamlResponse(result, CancellationToken.None);
+ var responseXml = responseData.responseXml;
+
+ HasPlainAssertion(responseXml).ShouldBeTrue("Response should contain plain Assertion");
+ HasEncryptedAssertion(responseXml).ShouldBeFalse("Response should not be encrypted");
+
+ // Verify can parse as success
+ var samlResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+ samlResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value);
+ samlResponse.Assertion.ShouldNotBeNull();
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs
new file mode 100644
index 000000000..9cd1c0881
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs
@@ -0,0 +1,239 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+#nullable enable
+
+using System.Security.Claims;
+using System.Security.Cryptography.X509Certificates;
+using Duende.IdentityServer.Configuration;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Saml;
+using Duende.IdentityServer.Stores;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+internal class SamlFixture(ITestOutputHelper output) : IAsyncLifetime
+{
+ public SamlData Data = new SamlData();
+ public SamlDataBuilder Builder => new SamlDataBuilder(Data);
+
+ public const string StableSigningCert =
+ "MIIJKgIBAzCCCOYGCSqGSIb3DQEHAaCCCNcEggjTMIIIzzCCBZAGCSqGSIb3DQEHAaCCBYEEggV9MIIFeTCCBXUGCyqGSIb3DQEMCgECoIIE7jCCBOowHAYKKoZIhvcNAQwBAzAOBAjDpcH3qgMDswICB9AEggTI6G9c95q92L598SOzgmrJ+Rq8qJDITcRgkRV+2KBCCbmlrrawihxy4O/+T2FHk3xBkPiXuLTcxO3uhl1juEc4PWSZpL2JA41okYMn4O+z3R8I3H8aN6OrL1cYthNfLLC0d46BjSBvKVVo3zyeJxui0hE7wEbNQrqRilRqBZjvemY2Pnb+BbVHIpa6FHQsUG80Cru0Jz6Gm21qM0j+enFHgAhPjlLoIw31ar8QmAPSqwZHiCZujw7RraL5E9Y5sGIZh6JaqTR2+cjRO32mnguFFHZg6s6APeOyPDNGEP16U0tRgnnUqMD0w6cDz3f2GIkzZ6YqfaMXBhzYQYtkxL6OYEU1G9Ke3UTlFwBNP8uMMcK6CKD4oy9Qi7Q4OtCqSJ9RBjKCoRXioA301Uf7iFfKLMBxZzNKczBXUxSv8EFXJ9hbpwmZPzoyqrL6JrSx3r99yJPbMQnPvdJtu+Uuo5WeTDkLlGFcVk+gmF/6vsP8Xb89sdtbHA87zAGgwS9+9huQa1umaAU6ftnUUEj6q2GktMPkGOuBI1JCtKOySKObC89HTC6FzwCJjhxwdUFl7WdY3QgjaWv5/NtG1kivvuuFoyrsVAOe+oWMQ6rxvJzrmilXLjCpE+jlAcoZn21jGzIJ2JMky1Ni5p1zj0XYkSlQ8c6Kh65UX0Bcj1kMFntlAe/N7XtPjF8bI1Q7sRc2ft5OH4oNfmXZgZqqbEHmWsSbVaFFfIhwUDmvXftqj6H+E345a9RibCx98sgQ3Pv9Xb3sRemTXR8juSRmb6P/OWIK2zorxqNvqruVfQ7UcH8I7QLgq/8ai1SClLhyOm3j6eWZim1aRO2wErN+DdcapYMFAu0CVo8ziGR9EIyXsXhjXbEr3EJPf87/g36Xt+LNOzTLKxE7npy39xMKBUh8kIjvroqdkaG3f16QXmUtLzmjPKdEiCCxgg89YRRgOlnxAXx6Kl1FVvHIYmcEqZ5yZ32fhB8X2aydia+JZO6w6MpUbSdULaVi1rmDnPHi2eco2hu83Iv59TRRI5JfeTgZVnxyMEuDI4NEaffLQJpUd3gDKJ3XgMmy8jSaizlh17MXCxM7bUtlNGMSHEM6eCyL5SUJHK9d3q4Qbw/ZPPLqvut6Y41gyKFfLDTU3BanWeh4wbk4YwBnZU71MUFGrAIr/0oTTw+fjd2bf0Utp2quRj/d65WVSZ71L7GqgwWf81A48ztw7eUHeXAvpheKk8Qtm7yPNdXbEfWo1PaVx5upj/P+GLcSfKNiOM7XWFhgCkrXo5hTMee4hahHrWFO8yXIAwBeg1+fvnbkIvna1lSzaqfTkoYg8LFYjaf0H3MQlBku09y8uApdOr2k0yx+NkvuKGPpHhW2MxCrmTfmxN9CaYvUEHYiWNWWN8H2MHQOTMGNXWFGL4jvq3ZWC2Bve4nlGYTE6Xy8MSJFyvnHNzJI13V7ibvCXdWKSSp8RT7IggpULiotlYQltkidWq7LGgYTDJtuhlc6xq/jok+kk2wSLSochaht6IhER9KcIUo7ChWlFcoQhFwWHckVNnLoN7W8uj5Y3b+bjazQqHaeWwE3gN9wrRk8joZcxz1EBaIVpzGy0LgObxwZ2udLExvRHYIcNl+pWSAr46hqOUWnMXQwEwYJKoZIhvcNAQkVMQYEBAEAAAAwXQYJKwYBBAGCNxEBMVAeTgBNAGkAYwByAG8AcwBvAGYAdAAgAFMAbwBmAHQAdwBhAHIAZQAgAEsAZQB5ACAAUwB0AG8AcgBhAGcAZQAgAFAAcgBvAHYAaQBkAGUAcjCCAzcGCSqGSIb3DQEHBqCCAygwggMkAgEAMIIDHQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQMwDgQIqlETdqBe/T4CAgfQgIIC8ExRN8tCI6+rs5ZvRWyeBUfws5GtCIXbOOnIaSimlGUmh9pUJxWLqHyG1U8lZ6FBOSOB8QxUrYxWtVx1868Y7Z6v/YLEmIno1OJOtliuCihaxjmspGaBiuin9F1Lc2GWT45IQVyUd8vRysqpvCQu+0Gb6eShM2XjQvYHF0poMiwLTfg5QjAHwtKfjhyj83B52QQlsdEz9n8YdImyXJJ0vbqu+AnLiNIGY3s1huyQMVoVmBwDlXhDL3FNbfsY90K1TVG0U/DIChgD+wJbyMxlt7O82dMONy/FEXVEFS8N2/JvJKqVYdSz4qFm1Pwn6dLHNseqdLNJrOWAhZmYbvUeiaNoKT6JmEt245x9qQTClsY1/irY4w5vhQWgHbaAtnLbp/Zd67IZPmDD93WPcGL/5e8Ir2yN9RTVpjAtd2cRC9PH5+Cc4EQxbkWSWEpq3/cvGAddkO7JKfAGYHJG98ClwyDR3WrXYZQze8zFeS2S2U5Xg+oryx0DumvhHYdf9OkYr2JO2VJl7KZu33P7v64M74MRcmixMQSfu/zndI5oHj2WYfI+mYZdyqZh8vMbo/c43qNvOA+vjFA8WaN4TzJljfJeh8t5qakUXTGvbwqczIz7ZrqrDGtWKVJoh4EXRS18zMRtUT1UbVYH44Jl5uxB1wUIYLcOeWPKZ6qZ8eCZsWzfR+zfur/gXmA4XQ7jf/tupr+kVpfM4SxqTRuE4T1xjviza0grM41cwmkfVlQPf5LQthIxBJIURxp7reJ1LasIGlXsWqfXk6U9WvcqZSXJNPxrhtPdcGhqbY3AOXxvouhJ7WH5R9LIYm2j1jFFJJAr33t3hoSAOwexBq6AOz8zFOor6SCywRXvnOuDpN0TuZba/iKSZynphnNiPkcDkL4N1hKIedRvFIoqGCYyHy77USF4m5ROQTRX6m8zF7jO4scSBRh49Z1frXlgkJalBhZKGCjg92G+VEZxSc9MJIh8wHST9CmgE84DheKNANASGkLmMDswHzAHBgUrDgMCGgQUpzniHRXBmJOBcoAPTQv0qII5VBoEFM2apPkmEUkwaCpLAq+z0GGqhAGCAgIH0A==";
+
+ public Action ConfigureIdentityServerOptions = _ => { };
+
+ public Action ConfigureSamlOptions = _ => { };
+
+ public Action ConfigureServices = _ => { };
+
+ private List _serviceProviders = [];
+ private bool _isInitialized = false;
+
+ ///
+ /// Gets the list of service providers to seed during initialization.
+ /// IMPORTANT: This property can only be accessed before InitializeAsync is called.
+ /// After initialization, use AddServiceProviderAsync or ClearServiceProvidersAsync methods.
+ ///
+ public List ServiceProviders
+ {
+ get
+ {
+ if (_isInitialized)
+ {
+ throw new InvalidOperationException(
+ "Cannot access ServiceProviders after initialization. " +
+ "Use AddServiceProviderAsync() to add service providers after the fixture has been initialized, " +
+ "or ClearServiceProvidersAsync() to remove all service providers.");
+ }
+ return _serviceProviders;
+ }
+ }
+
+ public DateTimeOffset Now => Data.Now;
+
+ public Uri LoginUrl = new Uri("/account/login", UriKind.Relative);
+
+ public Uri ConsentUrl = new Uri("/consent", UriKind.Relative);
+
+ public Uri SignInCallbackUrl = new Uri("/saml/signin_callback", UriKind.Relative);
+
+ public Uri LogoutUrl = new Uri("/account/logout", UriKind.Relative);
+
+ public ClaimsPrincipal? UserToSignIn { get; set; }
+
+ public bool? UserMetRequestedAuthnContextRequirements { get; set; }
+
+ public AuthenticationProperties? PropsToSignIn { get; set; }
+
+ public TestFramework.GenericHost? Host { get; private set; }
+
+ public HttpClient Client { get; private set; } = null!;
+
+ public HttpClient NonRedirectingClient { get; private set; } = null!;
+
+ public T Get() where T : notnull => Host!.Resolve();
+
+ public async Task InitializeAsync()
+ {
+ var selfSignedCertificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(StableSigningCert), null);
+
+ Host = new TestFramework.GenericHost();
+ Host.OnConfigureServices += services =>
+ {
+ services.AddSingleton(Data.FakeTimeProvider);
+ services.AddDistributedMemoryCache();
+
+ services.AddIdentityServer(options =>
+ {
+ options.UserInteraction.LoginUrl = LoginUrl.ToString();
+ options.UserInteraction.LogoutUrl = LogoutUrl.ToString();
+ options.UserInteraction.ConsentUrl = ConsentUrl.ToString();
+ ConfigureIdentityServerOptions(options);
+ })
+ .AddSigningCredential(selfSignedCertificate)
+ .AddSamlServices();
+
+ // Configure SAML options
+ services.Configure(ConfigureSamlOptions);
+
+ // Register in-memory SAML service provider store with our service providers
+ services.AddSingleton(new InMemorySamlServiceProviderStore(_serviceProviders));
+
+ ConfigureServices(services);
+
+ services.AddProblemDetails(opt => opt.CustomizeProblemDetails = context =>
+ {
+ if (context.Exception is BadHttpRequestException ex)
+ {
+ context.HttpContext.Response.StatusCode = 400;
+ context.ProblemDetails.Detail = ex.Message;
+ context.ProblemDetails.Status = 400;
+ }
+ });
+ };
+
+ Host.OnConfigure += app =>
+ {
+ app.UseExceptionHandler();
+
+ app.UseIdentityServer();
+
+ app.MapGet(LoginUrl.ToString(), () => Microsoft.AspNetCore.Http.Results.Ok());
+ app.MapGet(ConsentUrl.ToString(), () => Microsoft.AspNetCore.Http.Results.Ok());
+ app.MapGet(LogoutUrl.ToString(), () => Microsoft.AspNetCore.Http.Results.Ok());
+
+ app.MapGet("/__signin", async (HttpContext ctx, ISamlInteractionService samlInteractionService) =>
+ {
+ var props = PropsToSignIn ?? new AuthenticationProperties();
+ if (UserToSignIn?.Identity == null)
+ {
+ throw new InvalidOperationException(
+ $"Must set {nameof(UserToSignIn)} prior to signin and must have an identity");
+ }
+
+ await ctx.SignInAsync(UserToSignIn, props);
+
+ if (UserMetRequestedAuthnContextRequirements.HasValue)
+ {
+ await samlInteractionService.StoreRequestedAuthnContextResultAsync(
+ UserMetRequestedAuthnContextRequirements.Value, ctx.RequestAborted);
+ }
+
+ ctx.Response.StatusCode = 204;
+ });
+
+ app.MapGet("/__signout", async ctx =>
+ {
+ await ctx.SignOutAsync();
+ ctx.Response.StatusCode = 204;
+ });
+
+ app.MapGet("/__authentication-request", async (ISamlInteractionService samlInteractionService) =>
+ {
+ var authenticationRequest =
+ await samlInteractionService.GetAuthenticationRequestContextAsync(CancellationToken.None);
+
+ if (authenticationRequest == null)
+ {
+ throw new InvalidOperationException("Could not find authentication request");
+ }
+
+ return authenticationRequest.RequestedAuthnContext;
+ });
+
+ app.MapGet("/__protected-resource", () => "Protected Resource").RequireAuthorization();
+ };
+
+ await Host.InitializeAsync();
+
+ // Mark as initialized after seeding
+ _isInitialized = true;
+
+ Client = Host!.HttpClient;
+ NonRedirectingClient = Host!.Server.CreateClient();
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (Host != null)
+ {
+ // GenericHost doesn't implement IAsyncDisposable, so nothing to dispose
+ await Task.CompletedTask;
+ }
+ }
+
+ ///
+ /// Adds a service provider to the fixture after initialization.
+ ///
+ public async Task AddServiceProviderAsync(SamlServiceProvider serviceProvider)
+ {
+ if (!_isInitialized)
+ {
+ throw new InvalidOperationException(
+ "Cannot call AddServiceProviderAsync before initialization. " +
+ "Add service providers to the ServiceProviders list before calling InitializeAsync.");
+ }
+
+ if (Host == null)
+ {
+ throw new InvalidOperationException("Host is not initialized");
+ }
+
+ // With InMemorySamlServiceProviderStore, we need to replace the store instance
+ // This is a limitation - in integration tests, we should add all SPs before initialization
+ _serviceProviders.Add(serviceProvider);
+
+ // Re-register the store with the updated list
+ var serviceProvider2 = Host.Server.Services;
+ var scope = serviceProvider2.CreateScope();
+
+ // This won't work properly with the current architecture
+ // The store is registered as a singleton, so we can't easily update it
+ // For now, throw an exception to indicate this is not supported
+ throw new NotSupportedException(
+ "Adding service providers after initialization is not currently supported with InMemorySamlServiceProviderStore. " +
+ "Please add all service providers to the ServiceProviders list before calling InitializeAsync.");
+ }
+
+ ///
+ /// Removes all service providers from the fixture after initialization.
+ ///
+ public async Task ClearServiceProvidersAsync()
+ {
+ if (!_isInitialized)
+ {
+ throw new InvalidOperationException(
+ "Cannot call ClearServiceProvidersAsync before initialization. " +
+ "Modify the ServiceProviders list directly before calling InitializeAsync.");
+ }
+
+ throw new NotSupportedException(
+ "Clearing service providers after initialization is not currently supported with InMemorySamlServiceProviderStore. " +
+ "The service provider store is immutable after initialization.");
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs
new file mode 100644
index 000000000..35afaddde
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs
@@ -0,0 +1,342 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Claims;
+using System.Web;
+using Duende.IdentityModel;
+using Duende.IdentityServer.Internal.Saml;
+using Microsoft.AspNetCore.Mvc;
+using Xunit.Abstractions;
+using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+public class SamlIdpInitiatedEndpointTests(ITestOutputHelper output)
+{
+ private const string Category = "SAML IdP-Initiated Endpoint";
+
+ private SamlFixture Fixture = new(output);
+
+ private SamlData Data => Fixture.Data;
+
+ private SamlDataBuilder Build => Fixture.Builder;
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task can_initiate_idp_sso_when_user_is_authenticated()
+ {
+ // Arrange
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = result.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+ redirectUri.ToString().ShouldBe($"{SamlConstants.Urls.SamlRoute}{SamlConstants.Urls.SigninCallback}");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task can_initiate_idp_sso_with_relay_state()
+ {
+ // Arrange
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var relayState = HttpUtility.UrlEncode("/my-app/dashboard");
+ var result = await Fixture.NonRedirectingClient.GetAsync(
+ $"/saml/idp-initiated?spEntityId={spEntityId}&relayState={relayState}", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = result.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+ redirectUri.ToString().ShouldBe($"{SamlConstants.Urls.SamlRoute}{SamlConstants.Urls.SigninCallback}");
+
+ var acsResult = await Fixture.NonRedirectingClient.GetAsync(redirectUri, CancellationToken.None);
+
+ // Assert
+ var samlResponse = await ExtractSamlSuccessFromPostAsync(acsResult, CancellationToken.None);
+ samlResponse.RelayState.ShouldBe(HttpUtility.UrlDecode(relayState));
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task redirects_to_login_when_user_not_authenticated()
+ {
+ // Arrange
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = result.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+ HttpUtility.UrlDecode(redirectUri.ToString()).ShouldBe($"{Fixture.LoginUrl}?ReturnUrl={Fixture.SignInCallbackUrl}");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task returns_error_when_sp_not_registered()
+ {
+ // Arrange
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Act
+ var unknownEntityId = HttpUtility.UrlEncode("https://unknown.example.com");
+ var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={unknownEntityId}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("Service Provider 'https://unknown.example.com' is not registered");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task returns_error_when_sp_is_disabled()
+ {
+ // Arrange
+ // Note: The InMemoryServiceProviderStore filters disabled SPs,
+ // so they appear as "not registered". This is correct behavior.
+ var sp = Build.SamlServiceProvider();
+ sp.Enabled = false;
+ sp.AllowIdpInitiated = true;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ // Disabled SPs are filtered by the store, so they appear as not registered
+ problemDetails.Detail.ShouldBe($"Service Provider '{Data.EntityId}' is not registered");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task returns_error_when_idp_initiated_not_allowed()
+ {
+ // Arrange
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = false;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe($"Service Provider '{Data.EntityId}' does not allow IdP-initiated SSO");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task returns_error_when_relay_state_exceeds_max_length()
+ {
+ // Arrange
+ Fixture.ConfigureSamlOptions = options =>
+ {
+ options.MaxRelayStateLength = 50;
+ };
+
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var longRelayState = HttpUtility.UrlEncode(new string('a', 100));
+ var result = await Fixture.Client.GetAsync(
+ $"/saml/idp-initiated?spEntityId={spEntityId}&relayState={longRelayState}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("RelayState exceeds maximum length of 50 bytes");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task returns_error_when_sp_has_no_acs_urls()
+ {
+ // Arrange
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ sp.AssertionConsumerServiceUrls = Array.Empty();
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe($"Service Provider '{Data.EntityId}' has no AssertionConsumerServiceUrls configured");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task uses_first_acs_url_when_multiple_configured()
+ {
+ // Arrange
+ var firstAcsUrl = new Uri("https://sp.example.com/acs/first");
+ var secondAcsUrl = new Uri("https://sp.example.com/acs/second");
+
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ sp.AssertionConsumerServiceUrls = [firstAcsUrl, secondAcsUrl];
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = result.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+ redirectUri.ToString().ShouldBe($"{SamlConstants.Urls.SamlRoute}{SamlConstants.Urls.SigninCallback}");
+
+ var acsResult = await Fixture.NonRedirectingClient.GetAsync(redirectUri.ToString(), CancellationToken.None);
+
+ // Assert
+ var samlResponse = await ExtractSamlSuccessFromPostAsync(acsResult, CancellationToken.None);
+ samlResponse.Destination.ShouldBe(firstAcsUrl.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task can_complete_full_idp_initiated_flow()
+ {
+ // Arrange
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim(JwtClaimTypes.Subject, "user-123"),
+ new Claim(JwtClaimTypes.Email, "user@example.com"),
+ new Claim(JwtClaimTypes.Name, "Test User")
+ ], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var relayState = HttpUtility.UrlEncode("/target/page");
+
+ // Act
+ // Step 1: Initiate IdP SSO
+ var initiateResult = await Fixture.NonRedirectingClient.GetAsync(
+ $"/saml/idp-initiated?spEntityId={spEntityId}&relayState={relayState}", CancellationToken.None);
+
+ initiateResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ initiateResult.Headers.Location.ShouldNotBeNull();
+ initiateResult.Headers.Location.ToString().ShouldBe("/saml/signin_callback");
+
+ var stateId = ExtractStateIdFromCookie(initiateResult);
+ stateId.ShouldNotBeNull();
+
+ // Step 2: Follow redirect to signin callback
+ var callbackResult = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ // Assert
+ callbackResult.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var samlResponse = await ExtractSamlSuccessFromPostAsync(callbackResult, CancellationToken.None);
+
+ samlResponse.ShouldNotBeNull();
+ samlResponse.Issuer.ShouldBe(Fixture.Host!.Uri());
+ samlResponse.Destination.ShouldBe(Data.AcsUrl.ToString());
+ samlResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success");
+
+ samlResponse.InResponseTo.ShouldBeNull();
+
+ samlResponse.RelayState.ShouldBe("/target/page");
+
+ samlResponse.Assertion.Subject.ShouldNotBeNull();
+ samlResponse.Assertion.Subject.NameId.ShouldNotBeNullOrEmpty();
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task endpoint_disabled_when_configuration_disables_it()
+ {
+ // Arrange
+ Fixture.ConfigureIdentityServerOptions = options =>
+ {
+ options.Endpoints.EnableSamlIdpInitiatedEndpoint = false;
+ };
+
+ var sp = Build.SamlServiceProvider();
+ sp.AllowIdpInitiated = true;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ // Act
+ var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString());
+ var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", CancellationToken.None);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task missing_sp_entity_id_parameter_returns_bad_request()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/idp-initiated", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt
new file mode 100644
index 000000000..c720b66d0
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+ MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTA
+
+
+
+ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
+ urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
+ urn:oasis:names:tc:SAML:2.0:nameid-format:transient
+ urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
+
+
+
+
+
+
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs
new file mode 100644
index 000000000..a4d78e1b9
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs
@@ -0,0 +1,232 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Xml.Linq;
+using Duende.IdentityServer.Internal.Saml;
+using Xunit.Abstractions;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+public class SamlMetadataEndpointTests(ITestOutputHelper output)
+{
+ private const string Category = "SAML Metadata Endpoint";
+
+ private SamlFixture Fixture = new(output);
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task metadata_endpoint_should_return_metadata()
+ {
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/metadata", CancellationToken.None);
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ result.Content.Headers.ContentType
+ .ShouldNotBeNull()
+ .MediaType
+ .ShouldBe(SamlConstants.ContentTypes.Metadata);
+
+ var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
+
+ var settings = new VerifySettings();
+ var hostUri = Fixture.Host!.Uri();
+ settings.AddScrubber(sb =>
+ {
+ sb.Replace(hostUri, "https://localhost");
+ });
+
+ await Verify(content, settings);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task metadata_should_include_valid_until_based_on_metadata_validity_duration()
+ {
+ Fixture.ConfigureSamlOptions = options =>
+ {
+ options.MetadataValidityDuration = TimeSpan.FromDays(30);
+ };
+
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/metadata", CancellationToken.None);
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
+
+ var expectedValidUntil = Fixture.Now.Add(TimeSpan.FromDays(30)).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
+ content.ShouldContain($"validUntil=\"{expectedValidUntil}\"");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task metadata_should_include_want_authn_requests_signed_when_enabled()
+ {
+ Fixture.ConfigureSamlOptions = options =>
+ {
+ options.WantAuthnRequestsSigned = true;
+ };
+
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/metadata", CancellationToken.None);
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
+
+ content.ShouldContain("WantAuthnRequestsSigned=\"true\"");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task metadata_should_include_supported_name_id_formats()
+ {
+ Fixture.ConfigureSamlOptions = options =>
+ {
+ options.SupportedNameIdFormats.Clear();
+ options.SupportedNameIdFormats.Add(SamlConstants.NameIdentifierFormats.EmailAddress);
+ options.SupportedNameIdFormats.Add(SamlConstants.NameIdentifierFormats.Persistent);
+ };
+
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/metadata", CancellationToken.None);
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
+
+ content.ShouldContain("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
+ content.ShouldContain("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent");
+ content.ShouldNotContain("urn:oasis:names:tc:SAML:2.0:nameid-format:transient");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task metadata_should_include_single_logout_service_endpoints()
+ {
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/metadata", CancellationToken.None);
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
+
+ content.ShouldContain("
+ {
+ // Configure with trailing slash to ensure it's handled correctly
+ options.UserInteraction.Route = "/saml/";
+ };
+
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/metadata", CancellationToken.None);
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
+
+ // Should not have double slashes
+ content.ShouldNotContain("saml//signin");
+ content.ShouldNotContain("saml//logout");
+
+ var locationUrls = GetServiceLocationUrls(content, "SingleSignOnService", "SingleLogoutService");
+
+ foreach (var location in locationUrls)
+ {
+ location.ShouldNotEndWith("/");
+ }
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task metadata_urls_should_handle_edge_case_route_configurations()
+ {
+ // Verify that default configuration produces clean URLs
+ // Edge cases (empty routes, whitespace, etc.) are comprehensively tested
+ // at the unit level in BuildServiceUrl unit tests
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/metadata", CancellationToken.None);
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var content = await result.Content.ReadAsStringAsync(CancellationToken.None);
+ var locationUrls = GetServiceLocationUrls(content, "SingleSignOnService", "SingleLogoutService");
+
+ foreach (var location in locationUrls)
+ {
+ // Should have clean paths without double slashes
+ var uri = new Uri(location);
+ uri.PathAndQuery.ShouldNotContain("//");
+
+ // Should not have trailing slashes
+ location.ShouldNotEndWith("/");
+ }
+ }
+
+ private static List GetServiceLocationUrls(string xmlContent, params string[] serviceElementNames)
+ {
+ var doc = XDocument.Parse(xmlContent);
+ var ns = XNamespace.Get("urn:oasis:names:tc:SAML:2.0:metadata");
+ var locations = new List();
+
+ foreach (var serviceName in serviceElementNames)
+ {
+ var services = doc.Descendants(ns + serviceName);
+ foreach (var service in services)
+ {
+ var location = service.Attribute("Location")?.Value;
+ location.ShouldNotBeNull($"{serviceName} should have a Location attribute");
+ locations.Add(location);
+ }
+ }
+
+ return locations;
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs
new file mode 100644
index 000000000..f0dd04044
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs
@@ -0,0 +1,475 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Claims;
+using System.Web;
+using Duende.IdentityModel;
+using Duende.IdentityServer.Internal.Saml.SingleSignin;
+using Duende.IdentityServer.Models;
+using Microsoft.AspNetCore.Mvc;
+using Xunit.Abstractions;
+using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers;
+using StateId = Duende.IdentityServer.Internal.Saml.SingleSignin.Models.StateId;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+public class SamlSigninCallbackEndpointTests(ITestOutputHelper output)
+{
+ private const string Category = "SAML Signin Callback Endpoint";
+
+ private SamlFixture Fixture = new(output);
+
+ private SamlData Data => Fixture.Data;
+
+ private SamlDataBuilder Build => Fixture.Builder;
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_error_when_state_not_found()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ signinResult.Headers.Location.ShouldNotBeNull();
+
+ var stateId = ExtractStateIdFromCookie(signinResult);
+ stateId.ShouldNotBeNull();
+
+ // Remove state from store so the next request is sent with a state id that for state which no longer exists
+ var samlSigninStateStore = Fixture.Get();
+ await samlSigninStateStore.RetrieveSigninRequestStateAsync(new StateId(Guid.Parse(stateId)), CancellationToken.None);
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe($"The request {stateId} could not be found.");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_error_when_state_id_is_missing()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.Client.GetAsync("/__signin", CancellationToken.None);
+
+ // Do not make request to the sign-in endpoint first so no state id is created
+
+ var result = await Fixture.Client.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("No state id could be found.");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_redirects_to_login_when_user_not_authenticated_and_state_is_found()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+ redirectUri.ToString().ShouldStartWith("/saml/signin_callback");
+
+ await Fixture.NonRedirectingClient.GetAsync("/__signout", CancellationToken.None);
+
+ var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var resultRedirectUri = result.Headers.Location;
+ resultRedirectUri.ShouldNotBeNull();
+ HttpUtility.UrlDecode(resultRedirectUri.ToString()).ShouldBe($"{Fixture.LoginUrl}?ReturnUrl={Fixture.SignInCallbackUrl}");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_error_when_service_provider_not_found()
+ {
+ var sp = Build.SamlServiceProvider();
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+
+ await Fixture.ClearServiceProvidersAsync();
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' is not registered or is disabled");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_error_when_service_provider_disabled()
+ {
+ var sp = Build.SamlServiceProvider();
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+
+ await Fixture.ClearServiceProvidersAsync();
+ var disabledSp = Build.SamlServiceProvider();
+ sp.Enabled = false;
+ await Fixture.AddServiceProviderAsync(disabledSp);
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' is not registered or is disabled");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_state_is_not_persisted_after_successful_login()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+
+ var firstResult = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ // First use succeeds
+ firstResult.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var html = await firstResult.Content.ReadAsStringAsync(CancellationToken.None);
+ html.ShouldContain("SAMLResponse");
+
+ // Second callback with same stateId (replay attack)
+ var secondResult = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ // Second use should fail
+ secondResult.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await secondResult.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("No state id could be found.");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_error_when_state_is_expired()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ signinResult.Headers.Location.ShouldNotBeNull();
+
+ var stateId = ExtractStateIdFromCookie(signinResult);
+
+ Fixture.Data.FakeTimeProvider.Advance(TimeSpan.FromMinutes(11));
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe($"The request {stateId} could not be found.");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task state_contains_correct_request_information()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var specificRelayState = "test-relay-state-123";
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}&RelayState={specificRelayState}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var samlResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+ samlResponse.ShouldNotBeNull();
+ samlResponse.RelayState.ShouldBe(specificRelayState);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_signed_response_when_sign_response_configured()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.SigningBehavior = SamlSigningBehavior.SignResponse;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var (responseXml, _, _) = await ExtractSamlResponse(result, CancellationToken.None);
+
+ VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false);
+
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+ successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success");
+ successResponse.Assertion.Subject?.NameId.ShouldBe("user-id");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_signed_assertion_when_sign_assertion_configured()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.SigningBehavior = SamlSigningBehavior.SignAssertion;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var (responseXml, _, _) = await ExtractSamlResponse(result, CancellationToken.None);
+
+ VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true);
+
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+ successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success");
+ successResponse.Assertion.Subject?.NameId.ShouldBe("user-id");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_returns_unsigned_response_when_do_not_sign_configured()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.SigningBehavior = SamlSigningBehavior.DoNotSign;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ var redirectUri = signinResult.Headers.Location;
+ redirectUri.ShouldNotBeNull();
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var (responseXml, _, _) = await ExtractSamlResponse(result, CancellationToken.None);
+
+ VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: false);
+
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+ successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success");
+ successResponse.Assertion.Subject?.NameId.ShouldBe("user-id");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_signature_works_with_maximum_attribute_payload()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.SigningBehavior = SamlSigningBehavior.SignBoth;
+ sp.ClaimMappings = new ReadOnlyDictionary(new Dictionary
+ {
+ [JwtClaimTypes.Subject] = "sub",
+ [JwtClaimTypes.Name] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
+ [JwtClaimTypes.Email] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+ [JwtClaimTypes.Role] = "http://schemas.xmlsoap.org/ws/2005/05/identity/role",
+ ["department"] = "ou",
+ ["location"] = "loc",
+ ["employee_id"] = "emp_id",
+ ["manager"] = "manager",
+ ["cost_center"] = "cc"
+ });
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ // Create user with many claims
+ var claims = new List
+ {
+ new Claim(JwtClaimTypes.Subject, "user-id"),
+ new Claim(JwtClaimTypes.Name, "Test User"),
+ new Claim(JwtClaimTypes.Email, "test@example.com"),
+ new Claim(JwtClaimTypes.Role, "Admin"),
+ new Claim(JwtClaimTypes.Role, "User"),
+ new Claim("department", "Engineering"),
+ new Claim("location", "Seattle"),
+ new Claim("employee_id", "EMP12345"),
+ new Claim("manager", "manager@example.com"),
+ new Claim("cost_center", "CC-1234")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ signinResult.Headers.Location.ShouldNotBeNull();
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var (responseXml, _, _) = await ExtractSamlResponse(result, CancellationToken.None);
+
+ VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: true);
+
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+ successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success");
+ successResponse.Assertion.Attributes.ShouldNotBeNull();
+ successResponse.Assertion.Attributes!.Count.ShouldBeGreaterThan(4); // At least the claims we added
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task callback_signature_preserves_xml_special_characters_in_attributes()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.SigningBehavior = SamlSigningBehavior.SignAssertion;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ var claims = new List
+ {
+ new Claim(JwtClaimTypes.Subject, "user-id"),
+ new Claim(JwtClaimTypes.Name, "Test & \"Company\""),
+ new Claim("description", "Value with & \"quotes\" and 'apostrophes'"),
+ new Claim("xml_data", "text")
+ };
+
+ Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
+ await Fixture.NonRedirectingClient.GetAsync("/__signin", CancellationToken.None);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml, CancellationToken.None);
+ var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", CancellationToken.None);
+
+ signinResult.StatusCode.ShouldBe(HttpStatusCode.Found);
+ signinResult.Headers.Location.ShouldNotBeNull();
+
+ var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", CancellationToken.None);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var (responseXml, _, _) = await ExtractSamlResponse(result, CancellationToken.None);
+
+ VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true);
+
+ var (_, _, responseElement) = ParseSamlResponseXml(responseXml);
+ responseElement.ShouldNotBeNull();
+
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, CancellationToken.None);
+ successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success");
+
+ var nameAttribute = successResponse.Assertion.Attributes?.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name");
+ nameAttribute.ShouldNotBeNull();
+ nameAttribute.Value.ShouldBe("Test & \"Company\"");
+ }
+}
diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs
new file mode 100644
index 000000000..d2d48e77b
--- /dev/null
+++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs
@@ -0,0 +1,2787 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+#nullable enable
+using System.Collections.ObjectModel;
+using System.IO.Compression;
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Claims;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Web;
+using Duende.IdentityModel;
+using Duende.IdentityServer.Internal.Saml;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Saml.Models;
+using Microsoft.AspNetCore.Mvc;
+using Xunit.Abstractions;
+using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers;
+using SamlStatusCode = Duende.IdentityServer.Saml.Models.SamlStatusCode;
+
+namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml;
+
+public class SamlSigninEndpointTests(ITestOutputHelper output)
+{
+ private const string Category = "SAML Signin Endpoint";
+
+ private readonly CancellationToken _ct = CancellationToken.None;
+
+ private SamlFixture Fixture = new(output);
+
+ private SamlData Data => Fixture.Data;
+
+ private SamlDataBuilder Build => Fixture.Builder;
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task can_initiate_binding_redirected_signin()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml());
+
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var requestUrl = result.RequestMessage?.RequestUri;
+ requestUrl.ShouldNotBeNull();
+ requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString());
+ var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query);
+ queryStringValues["ReturnUrl"].ShouldBe("/saml/signin_callback");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_on_binding_redirected_signin_with_no_saml_request()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var result = await Fixture.Client.GetAsync("/saml/signin", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' query parameter in SAML signin request");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_on_binding_redirected_signin_with_invalid_base64()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var invalidBase64 = "not-valid-base64!!!";
+
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={invalidBase64}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("Invalid base64 encoding in SAML signin request");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_on_binding_redirected_signin_with_invalid_saml_request()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var authnRequestXml = Build.AuthNRequestXml(version: "1.0");
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ // Should return SAML error response via HTTP-POST binding
+ var errorResponse = await ExtractSamlErrorFromPostAsync(result);
+
+ errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch");
+ errorResponse.StatusMessage.ShouldNotBeNull();
+ errorResponse.StatusMessage.ShouldContain("Only Version 2.0 is supported");
+ errorResponse.Issuer.ShouldBe(Fixture.Host!.Uri());
+ errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
+ errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task can_initiate_binding_redirected_signin_with_relay_state()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.Client.GetAsync("/__signin", _ct);
+
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml());
+
+ var result =
+ await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}&RelayState={Data.RelayState}", _ct);
+
+ var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(result, _ct);
+ samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value);
+ samlSuccessResponse.RelayState.ShouldBe(Data.RelayState);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task can_initiate_form_post_signin()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var encodedRequest = ConvertToBase64Encoded(Build.AuthNRequestXml());
+ var formData = new Dictionary
+ {
+ { "SAMLRequest", encodedRequest }
+ };
+ var content = new FormUrlEncodedContent(formData);
+
+ var result = await Fixture.Client.PostAsync($"/saml/signin?", content, _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var requestUrl = result.RequestMessage?.RequestUri;
+ requestUrl.ShouldNotBeNull();
+ requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString());
+ var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query);
+ queryStringValues["ReturnUrl"].ShouldBe("/saml/signin_callback");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_on_form_post_signin_with_no_saml_request()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var content = new FormUrlEncodedContent(new Dictionary());
+
+ var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' form parameter in SAML signin request");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_on_form_post_signin_with_invalid_base64()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var invalidBase64 = "not-valid-base64!!!";
+ var formData = new Dictionary
+ {
+ { "SAMLRequest", invalidBase64 }
+ };
+ var content = new FormUrlEncodedContent(formData);
+
+ var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldBe("Invalid base64 encoding in SAML signin request");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_on_binding_form_post_with_invalid_saml_request()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var encodedRequest = ConvertToBase64Encoded(Build.AuthNRequestXml(version: "1.0"));
+ var formData = new Dictionary
+ {
+ { "SAMLRequest", encodedRequest }
+ };
+ var content = new FormUrlEncodedContent(formData);
+
+ var result = await Fixture.Client.PostAsync($"/saml/signin?", content, _ct);
+
+ // Should return SAML error response via HTTP-POST binding
+ var errorResponse = await ExtractSamlErrorFromPostAsync(result);
+
+ errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch");
+ errorResponse.StatusMessage.ShouldNotBeNull();
+ errorResponse.StatusMessage.ShouldContain("Only Version 2.0 is supported");
+ errorResponse.Issuer.ShouldBe(Fixture.Host!.Uri());
+ errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
+ errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task can_initiate_form_post_signin_with_relay_state()
+ {
+ Data.RelayState = "test_relay_state";
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.Client.GetAsync("/__signin", _ct);
+
+ var encodedRequest = ConvertToBase64Encoded(Build.AuthNRequestXml());
+ var formData = new Dictionary
+ {
+ { "SAMLRequest", encodedRequest },
+ { "RelayState", Data.RelayState }
+ };
+ var content = new FormUrlEncodedContent(formData);
+
+ var result = await Fixture.Client.PostAsync($"/saml/signin?", content, _ct);
+
+ var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(result, _ct);
+ samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value);
+ samlSuccessResponse.RelayState.ShouldBe(Data.RelayState);
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task success_when_request_is_within_clock_skew_range()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Create a request with IssueInstant 2 minutes in the future (within default 5 minute clock skew)
+ var slightlyFutureTime = Data.Now.AddMinutes(2);
+ var authnRequestXml = Build.AuthNRequestXml(issueInstant: slightlyFutureTime);
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ // Should succeed and redirect to login
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var requestUrl = result.RequestMessage?.RequestUri;
+ requestUrl.ShouldNotBeNull();
+ requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task returns_error_when_request_issue_instant_is_in_future()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Create a request with IssueInstant 10 minutes in the future (beyond default clock skew)
+ var futureTime = Data.Now.AddMinutes(10);
+ var authnRequestXml = Build.AuthNRequestXml(futureTime);
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ var errorResponse = await ExtractSamlErrorFromPostAsync(result);
+ errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester");
+ errorResponse.StatusMessage.ShouldNotBeNull();
+ errorResponse.StatusMessage.ShouldContain("IssueInstant is in the future");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task returns_error_when_request_issue_instant_exceeds_service_provider_configured_clock_skew()
+ {
+ var serviceProviderClockSkew = TimeSpan.FromMinutes(2);
+ var sp = Build.SamlServiceProvider();
+ sp.ClockSkew = serviceProviderClockSkew;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ var futureIssueInstant = Data.Now.Add(serviceProviderClockSkew) + TimeSpan.FromSeconds(1);
+ var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(issueInstant: futureIssueInstant));
+
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ var errorResponse = await ExtractSamlErrorFromPostAsync(result);
+
+ errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester");
+ errorResponse.StatusMessage.ShouldBe("Request IssueInstant is in the future");
+ errorResponse.Issuer.ShouldBe(Fixture.Host!.Uri());
+ errorResponse.InResponseTo.ShouldNotBeNullOrEmpty();
+ errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_when_request_is_expired()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Create a request with IssueInstant 10 minutes in the past (beyond default max age)
+ var pastTime = Data.Now.AddMinutes(-10);
+ var authnRequestXml = Build.AuthNRequestXml(pastTime);
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ var errorResponse = await ExtractSamlErrorFromPostAsync(result);
+ errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester");
+ errorResponse.StatusMessage.ShouldNotBeNull();
+ errorResponse.StatusMessage.ShouldContain("expired");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_when_request_is_expired_with_custom_request_max_age()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.RequestMaxAge = TimeSpan.FromMinutes(20);
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ var pastTime = Data.Now.AddMinutes(-21);
+ var authnRequestXml = Build.AuthNRequestXml(pastTime);
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ var errorResponse = await ExtractSamlErrorFromPostAsync(result);
+ errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester");
+ errorResponse.StatusMessage.ShouldNotBeNull();
+ errorResponse.StatusMessage.ShouldContain("expired");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_when_destination_is_invalid()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Use an invalid destination URL
+ var authnRequestXml = Build.AuthNRequestXml(destination: new Uri("https://wrong.example.com/saml/sso"));
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ var errorResponse = await ExtractSamlErrorFromPostAsync(result);
+ errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester");
+ errorResponse.StatusMessage.ShouldNotBeNull();
+ errorResponse.StatusMessage.ShouldContain("Invalid destination");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_when_acs_url_is_not_registered()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Use an ACS URL that's not registered with the SP
+ var authnRequestXml = Build.AuthNRequestXml(acsUrl: new Uri("https://sp.example.com/wrong-callback"));
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldNotBeNull();
+ problemDetails.Detail.ShouldContain("AssertionConsumerServiceUrl");
+ problemDetails.Detail.ShouldContain("is not valid");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_when_acs_index_is_invalid()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Add an AssertionConsumerServiceIndex that doesn't exist
+ var authnRequestXml = Build.AuthNRequestXml()
+ .Replace("AssertionConsumerServiceURL=\"" + Data.AcsUrl + "\"",
+ "AssertionConsumerServiceIndex=\"99\"");
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldNotBeNull();
+ problemDetails.Detail.ShouldContain("AssertionConsumerServiceIndex");
+ problemDetails.Detail.ShouldContain("is not valid");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_when_service_provider_has_no_configured_acs_urls()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.AssertionConsumerServiceUrls = [];
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ var authnRequestXml = Build.AuthNRequestXml()
+ .Replace("AssertionConsumerServiceURL=\"" + Data.AcsUrl + "\"",
+ string.Empty);
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldNotBeNull();
+ problemDetails.Detail.ShouldContain(
+ $"The Service Provider '{Data.EntityId}' does not have any configured Assertion Consumer Service URLs");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task error_when_service_provider_is_not_enabled()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.Enabled = false;
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ var authnRequestXml = Build.AuthNRequestXml();
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ var problemDetails = await result.Content.ReadFromJsonAsync(_ct);
+ problemDetails.ShouldNotBeNull();
+ problemDetails.Detail.ShouldNotBeNull();
+ problemDetails.Detail.ShouldContain($"Service Provider '{Data.EntityId}' is not registered or is disabled");
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task success_when_request_uses_default_acs_url()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ var authnRequestXml = Build.AuthNRequestXml()
+ .Replace("AssertionConsumerServiceURL=\"" + Data.AcsUrl + "\"",
+ string.Empty);
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var requestUrl = result.RequestMessage?.RequestUri;
+ requestUrl.ShouldNotBeNull();
+ requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task success_when_destination_matches()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ // Use the correct Destination attribute
+ var correctDestination = new Uri(Fixture.Host!.Uri() + "/saml/signin");
+ var authnRequestXml = Build.AuthNRequestXml(destination: correctDestination);
+
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ // Should succeed and redirect to login
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var requestUrl = result.RequestMessage?.RequestUri;
+ requestUrl.ShouldNotBeNull();
+ requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task success_when_user_is_already_logged_in_at_identity_provider()
+ {
+ var sp = Build.SamlServiceProvider();
+ sp.ClaimMappings = new ReadOnlyDictionary(new Dictionary
+ {
+ ["sub"] = "sub",
+ ["idp"] = "idp",
+ ["amr"] = "amr",
+ ["auth_time"] = "auth_time"
+ });
+ Fixture.ServiceProviders.Add(sp);
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.Client.GetAsync("/__signin", _ct);
+
+ var authnRequestXml = Build.AuthNRequestXml();
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct);
+
+ successResponse.ResponseId.ShouldNotBeNullOrEmpty();
+ successResponse.Destination.ShouldBe(Fixture.Data.AcsUrl.ToString());
+ successResponse.IssueInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
+ successResponse.Issuer.ShouldBe(Fixture.Host?.Uri());
+ successResponse.InResponseTo.ShouldBe(Data.RequestId);
+
+ successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value);
+
+ var assertion = successResponse.Assertion;
+ assertion.ShouldNotBeNull();
+ assertion.Id.ShouldNotBeNullOrEmpty();
+ assertion.Version.ShouldBe("2.0");
+ assertion.IssueInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
+ assertion.Issuer.ShouldBe(Fixture.Host?.Uri());
+
+ var subject = assertion.Subject;
+ subject.ShouldNotBeNull();
+ subject.NameId.ShouldBe("user-id");
+ subject.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Unspecified);
+ subject.SPNameQualifier.ShouldBeNull();
+
+ var subjectConfirmation = subject.SubjectConfirmation;
+ subjectConfirmation.ShouldNotBeNull();
+ subjectConfirmation.Method.ShouldBe("urn:oasis:names:tc:SAML:2.0:cm:bearer");
+
+ var subjectConfirmationData = subjectConfirmation.SubjectConfirmationData;
+ subjectConfirmationData.ShouldNotBeNull();
+ subjectConfirmationData.NotOnOrAfter.ShouldBe(Data.FakeTimeProvider.GetUtcNow().Add(Build.SamlServiceProvider().RequestMaxAge!.Value).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
+ subjectConfirmationData.Recipient.ShouldBe(Fixture.Data.AcsUrl.ToString());
+ subjectConfirmationData.InResponseTo.ShouldBe(Data.RequestId);
+
+ var conditions = assertion.Conditions;
+ conditions.ShouldNotBeNull();
+ conditions.NotBefore.ShouldBe(Data.FakeTimeProvider.GetUtcNow().Subtract(Build.SamlServiceProvider().ClockSkew!.Value).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
+ conditions.NotOnOrAfter.ShouldBe(Data.FakeTimeProvider.GetUtcNow().Add(Build.SamlServiceProvider().RequestMaxAge!.Value).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
+ conditions.Audience.ShouldBe(Data.EntityId.ToString());
+
+ var authnStatement = assertion.AuthnStatement;
+ authnStatement.ShouldNotBeNull();
+ authnStatement.AuthnInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
+ authnStatement.SessionIndex.ShouldNotBeNullOrEmpty();
+ authnStatement.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified");
+
+ var attributes = assertion.Attributes;
+ attributes.ShouldNotBeNull();
+ attributes.Count.ShouldBe(4);
+
+ var subAttribute = attributes.FirstOrDefault(a => a.Name == "sub");
+ subAttribute.ShouldNotBeNull();
+ subAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri);
+ subAttribute.FriendlyName.ShouldBe("sub");
+ subAttribute.Value.ShouldBe("user-id");
+
+ var idpAttribute = attributes.FirstOrDefault(a => a.Name == "idp");
+ idpAttribute.ShouldNotBeNull();
+ idpAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri);
+ idpAttribute.FriendlyName.ShouldBe("idp");
+ idpAttribute.Value.ShouldBe("local");
+
+ var amrAttribute = attributes.FirstOrDefault(a => a.Name == "amr");
+ amrAttribute.ShouldNotBeNull();
+ amrAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri);
+ amrAttribute.FriendlyName.ShouldBe("amr");
+ amrAttribute.Value.ShouldBe("pwd");
+
+ var authTimeAttribute = attributes.FirstOrDefault(a => a.Name == "auth_time");
+ authTimeAttribute.ShouldNotBeNull();
+ authTimeAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri);
+ authTimeAttribute.FriendlyName.ShouldBe("auth_time");
+ authTimeAttribute.Value.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToUnixTimeSeconds().ToString());
+ }
+
+ [Fact]
+ [Trait("Category", Category)]
+ public async Task success_when_user_is_already_logged_in_at_identity_provider_but_request_includes_force_authn()
+ {
+ Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
+ await Fixture.InitializeAsync();
+
+ Fixture.UserToSignIn =
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test"));
+ await Fixture.Client.GetAsync("/__signin", _ct);
+
+ var authnRequestXml = Build.AuthNRequestXml(forceAuthn: true);
+ var urlEncoded = await EncodeRequest(authnRequestXml);
+
+ var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct);
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var requestUrl = result.RequestMessage?.RequestUri;
+ requestUrl.ShouldNotBeNull();
+ requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString());
+ }
+
+ private async Task EncodeRequest(string authenticationRequest)
+ {
+ var bytes = Encoding.UTF8.GetBytes(authenticationRequest);
+ using var outputStream = new MemoryStream();
+ await using (var deflateStream = new DeflateStream(outputStream, CompressionMode.Compress, leaveOpen: true))
+ {
+ await deflateStream.WriteAsync(bytes, 0, bytes.Length, _ct);
+ }
+
+ var compressedBytes = outputStream.ToArray();
+ var base64 = Convert.ToBase64String(compressedBytes);
+ var urlEncoded = Uri.EscapeDataString(base64);
+ return urlEncoded;
+ }
+
+ private string ConvertToBase64Encoded(string authenticationRequest) =>
+ Convert.ToBase64String(Encoding.UTF8.GetBytes(authenticationRequest));
+
+ ///
+ /// Extracts SAML error response from an HTTP-POST binding auto-submit form.
+ ///
+ private async Task ExtractSamlErrorFromPostAsync(HttpResponseMessage response)
+ {
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
+
+ var html = await response.Content.ReadAsStringAsync(_ct);
+
+ // Extract SAMLResponse from hidden input field
+ var samlResponseMatch = System.Text.RegularExpressions.Regex.Match(
+ html,
+ @"]+name=""SAMLResponse""[^>]+value=""([^""]+)""",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+
+ samlResponseMatch.Success.ShouldBeTrue("SAMLResponse input field not found in HTML");
+ var encodedResponse = samlResponseMatch.Groups[1].Value;
+
+ // Extract RelayState if present
+ string? relayState = null;
+ var relayStateMatch = System.Text.RegularExpressions.Regex.Match(
+ html,
+ @"]+name=""RelayState""[^>]+value=""([^""]+)""",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+
+ if (relayStateMatch.Success)
+ {
+ relayState = HttpUtility.HtmlDecode(relayStateMatch.Groups[1].Value);
+ }
+
+ // Extract form action (ACS URL)
+ var actionMatch = System.Text.RegularExpressions.Regex.Match(
+ html,
+ @"