From e0034d642ae091c040b9eb8fa71dc4bd01ba10b6 Mon Sep 17 00:00:00 2001 From: Brett Hazen Date: Thu, 19 Feb 2026 11:30:01 -0600 Subject: [PATCH] Added missing SAML-relevant tests --- Directory.Packages.props | 1 + .../Saml/Infrastructure/SecureXmlParser.cs | 93 + .../Saml/Models/EndpointType.cs | 13 - .../Endpoints/Saml/CookieHandler.cs | 46 + .../Endpoints/Saml/SamlClaimsMappingTests.cs | 307 ++ .../Endpoints/Saml/SamlData.cs | 26 + .../Endpoints/Saml/SamlDataBuilder.cs | 140 + .../Endpoints/Saml/SamlEncryptionTests.cs | 460 +++ .../Endpoints/Saml/SamlFixture.cs | 239 ++ .../Saml/SamlIdpInitiatedEndpointTests.cs | 342 ++ ...dpointTests.CanReturnMetadata.verified.txt | 20 + .../Saml/SamlMetadataEndpointTests.cs | 232 ++ .../Saml/SamlSigninCallbackEndpointTests.cs | 475 +++ .../Endpoints/Saml/SamlSigninEndpointTests.cs | 2787 +++++++++++++++++ .../SamlSingleLogoutCallbackEndpointTests.cs | 150 + .../Saml/SamlSingleLogoutEndpointTests.cs | 528 ++++ .../Endpoints/Saml/SamlTestHelpers.cs | 868 +++++ .../Saml/SustainSysSamlTestFixture.cs | 165 + .../Endpoints/Saml/SustainSysSigninTests.cs | 106 + .../IdentityServer.IntegrationTests.csproj | 1 + .../Common/MockSamlSigningService.cs | 25 + .../Common/TestSamlClaimsMapper.cs | 28 + ...AuthenticationPropertiesExtensionsTests.cs | 214 ++ .../Models/SamlSpSessionDataTests.cs | 177 ++ .../Saml/AuthNRequestParserTests.cs | 155 + .../Saml/InMemoryTestServiceProviderStore.cs | 24 + .../Saml/LimitedReadStreamTests.cs | 361 +++ .../Saml/LogoutRequestParserTests.cs | 125 + .../Saml/RequestedAuthnContextParsingTests.cs | 217 ++ .../Saml/SamlAssertionEncryptorTests.cs | 316 ++ .../Saml/SamlClaimsServiceTests.cs | 368 +++ ...mlFrontChannelLogoutRequestBuilderTests.cs | 386 +++ .../SamlHttpPostFrontChannelLogoutTests.cs | 67 + ...SamlHttpRedirectFrontChannelLogoutTests.cs | 49 + .../Saml/SamlLogoutCallbackProcessorTests.cs | 272 ++ .../SamlLogoutNotificationServiceTests.cs | 293 ++ .../Saml/SamlProtocolMessageSignerTests.cs | 248 ++ .../Saml/SamlSigningServiceTests.cs | 188 ++ .../Saml/SecureXmlParserTests.cs | 324 ++ .../Saml/XmlSignatureHelperTests.cs | 346 ++ ...ltIdentityServerInteractionServiceTests.cs | 77 + 41 files changed, 11246 insertions(+), 13 deletions(-) delete mode 100644 identity-server/src/IdentityServer/Saml/Models/EndpointType.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/InMemoryTestServiceProviderStore.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlSigningServiceTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs 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, + @"]+action=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + actionMatch.Success.ShouldBeTrue("Form action not found in HTML"); + var acsUrl = HttpUtility.HtmlDecode(actionMatch.Groups[1].Value); + + // Decode the SAML response + var decodedBytes = Convert.FromBase64String(HttpUtility.HtmlDecode(encodedResponse)); + var responseXml = Encoding.UTF8.GetString(decodedBytes); + + // Parse the SAML response XML + var doc = System.Xml.Linq.XDocument.Parse(responseXml); + var samlpNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:protocol"); + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + + var responseElement = doc.Root; + responseElement.ShouldNotBeNull(); + responseElement.Name.ShouldBe(samlpNs + "Response"); + + var responseId = responseElement.Attribute("ID")?.Value; + var inResponseTo = responseElement.Attribute("InResponseTo")?.Value; + var destination = responseElement.Attribute("Destination")?.Value; + var issueInstant = responseElement.Attribute("IssueInstant")?.Value; + + var issuer = responseElement.Element(samlNs + "Issuer")?.Value; + + var statusElement = responseElement.Element(samlpNs + "Status"); + statusElement.ShouldNotBeNull(); + + var statusCodeElement = statusElement.Element(samlpNs + "StatusCode"); + statusCodeElement.ShouldNotBeNull(); + var statusCode = statusCodeElement.Attribute("Value")?.Value; + + var statusMessage = statusElement.Element(samlpNs + "StatusMessage")?.Value; + + // Check for sub-status code + var subStatusCodeElement = statusCodeElement.Element(samlpNs + "StatusCode"); + var subStatusCode = subStatusCodeElement?.Attribute("Value")?.Value; + + return new SamlErrorResponseData + { + ResponseId = responseId, + InResponseTo = inResponseTo, + Destination = destination, + IssueInstant = issueInstant, + Issuer = issuer, + StatusCode = statusCode, + StatusMessage = statusMessage, + SubStatusCode = subStatusCode, + RelayState = relayState, + AssertionConsumerServiceUrl = acsUrl + }; + } + + private record SamlErrorResponseData + { + public string? ResponseId { get; init; } + public string? InResponseTo { get; init; } + public string? Destination { get; init; } + public string? IssueInstant { get; init; } + public string? Issuer { get; init; } + public string? StatusCode { get; init; } + public string? StatusMessage { get; init; } + public string? SubStatusCode { get; init; } + public string? RelayState { get; init; } + public string? AssertionConsumerServiceUrl { get; init; } + } + + [Fact] + [Trait("Category", Category)] + public async Task redirects_to_consent_when_require_consent_is_true() + { + var sp = Build.SamlServiceProvider(); + sp.RequireConsent = true; + Fixture.ServiceProviders.Add(sp); + + 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.ConsentUrl.ToString()); + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + queryStringValues["ReturnUrl"].ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_is_passive_and_user_not_authenticated() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(isPassive: true)); + + 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:NoPassive"); + errorResponse.StatusMessage.ShouldBe("The user is not currently logged in and passive login was requested."); + errorResponse.Issuer.ShouldBe(Fixture.Host!.Uri()); + errorResponse.InResponseTo.ShouldNotBeNullOrEmpty(); + errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_no_passive_error_when_both_force_authn_and_is_passive_are_true() + { + 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, isPassive: true); + 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:NoPassive"); + errorResponse.StatusMessage.ShouldBe("The user is not currently logged in"); + errorResponse.Issuer.ShouldBe(Fixture.Host!.Uri()); + errorResponse.InResponseTo.ShouldNotBeNullOrEmpty(); + errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_is_passive_true_and_user_authenticated() + { + 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(isPassive: true); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task forces_authentication_when_force_authn_true_and_user_authenticated() + { + 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); + + // Verify user is authenticated by doing a normal request first + var normalRequestXml = Build.AuthNRequestXml(forceAuthn: false); + var normalUrlEncoded = await EncodeRequest(normalRequestXml); + var normalResult = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={normalUrlEncoded}", _ct); + + // Without ForceAuthn, authenticated user goes directly to callback + normalResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(normalResult, _ct); + samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + + var forceAuthnRequestXml = Build.AuthNRequestXml(forceAuthn: true); + var forceAuthnUrlEncoded = await EncodeRequest(forceAuthnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={forceAuthnUrlEncoded}", _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); + var returnUrl = queryStringValues["ReturnUrl"]; + returnUrl.ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_issue_instant_at_exact_clock_skew_boundary() + { + var clockSkew = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = clockSkew; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var exactBoundaryTime = Data.Now.Add(clockSkew); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: exactBoundaryTime); + 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 error_when_issue_instant_one_second_beyond_clock_skew() + { + var clockSkew = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = clockSkew; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var beyondBoundaryTime = Data.Now.Add(clockSkew).AddSeconds(1); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: beyondBoundaryTime); + 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.ShouldBe("Request IssueInstant is in the future"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_clock_skew_is_zero() + { + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = TimeSpan.Zero; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var exactTime = Data.Now; + var authnRequestXml = Build.AuthNRequestXml(issueInstant: exactTime); + 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 error_when_clock_skew_is_zero_and_time_differs() + { + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = TimeSpan.Zero; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var futureTime = Data.Now.AddSeconds(1); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: 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.ShouldBe("Request IssueInstant is in the future"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_issue_instant_at_max_age_boundary() + { + var maxAge = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.RequestMaxAge = maxAge; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var exactMaxAgeTime = Data.Now.Subtract(maxAge); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: exactMaxAgeTime); + 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 error_when_issue_instant_one_second_beyond_max_age() + { + var maxAge = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.RequestMaxAge = maxAge; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var beyondMaxAgeTime = Data.Now.Subtract(maxAge).AddSeconds(-1); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: beyondMaxAgeTime); + 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.ShouldBe("Request has expired (IssueInstant too old)"); + } + + [Fact] + [Trait("Category", Category)] + public async Task uses_acs_url_when_both_url_and_index_provided() + { + var primaryAcsUrl = new Uri("https://sp.example.com/callback1"); + var secondaryAcsUrl = new Uri("https://sp.example.com/callback2"); + + var sp = Build.SamlServiceProvider(); + sp.AssertionConsumerServiceUrls = [primaryAcsUrl, secondaryAcsUrl]; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml( + acsUrl: primaryAcsUrl, + acsIndex: 1 // Points to secondary URL + ); + 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()); + + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + var returnUrl = queryStringValues["ReturnUrl"]; + returnUrl.ShouldBe("/saml/signin_callback"); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var signinCallbackResponse = await Fixture.Client.GetAsync(returnUrl, _ct); + + var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(signinCallbackResponse, _ct); + samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + samlSuccessResponse.Destination.ShouldBe(primaryAcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_has_no_id_attribute() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var xmlWithoutId = authnRequestXml.Replace($"ID=\"{Data.RequestId}\"", ""); + + var urlEncoded = await EncodeRequest(xmlWithoutId); + 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(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_has_empty_id() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(requestId: ""); + 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(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_issuer_is_empty() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(issuer: ""); + 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(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_issuer_is_missing() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var xmlWithoutIssuer = authnRequestXml.Replace( + $"{Data.EntityId}", + "" + ); + + var urlEncoded = await EncodeRequest(xmlWithoutIssuer); + + 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(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_signed_with_sign_response_behavior() + { + 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.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); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false); + + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + VerifySignaturePositionAfterIssuer(responseElement); + + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_signed_with_sign_assertion_behavior() + { + 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.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); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true); + + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + VerifySignaturePositionAfterIssuer(assertionElement!); + + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_signed_with_sign_both_behavior() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + 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); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: true); + + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + VerifySignaturePositionAfterIssuer(responseElement); + + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + VerifySignaturePositionAfterIssuer(assertionElement!); + + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_not_signed_with_do_not_sign_behavior() + { + 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.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); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: false); + + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_uses_default_signing_behavior_with_null() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = null; + 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); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + // Default behavior should be SignAssertion per SAML best practices + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true); + + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_contains_correct_reference_to_response_id() + { + 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.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 (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract signature and verify Reference URI points to Response ID + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull("Response should have a Signature element"); + + var referenceUri = GetSignatureReferenceUri(signatureElement!); + referenceUri.ShouldNotBeNull(); + referenceUri.ShouldStartWith("#"); + + var responseId = responseElement.Attribute("ID")?.Value; + responseId.ShouldNotBeNullOrEmpty(); + referenceUri.ShouldBe($"#{responseId}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task assertion_signature_contains_correct_reference_to_assertion_id() + { + 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.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 (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract Assertion and its signature + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull("Response should contain an Assertion"); + + var signatureElement = ExtractSignatureElement(assertionElement!); + signatureElement.ShouldNotBeNull(); + + var referenceUri = GetSignatureReferenceUri(signatureElement!); + referenceUri.ShouldNotBeNull(); + referenceUri.ShouldStartWith("#"); + + var assertionId = assertionElement!.Attribute("ID")?.Value; + assertionId.ShouldNotBeNullOrEmpty(); + referenceUri.ShouldBe($"#{assertionId}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_contains_key_info_with_certificate() + { + 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.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 (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract signature and verify KeyInfo structure + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull(); + + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var keyInfoElement = signatureElement!.Element(dsNs + "KeyInfo"); + keyInfoElement.ShouldNotBeNull("Signature should contain KeyInfo element"); + + var x509DataElement = keyInfoElement!.Element(dsNs + "X509Data"); + x509DataElement.ShouldNotBeNull("KeyInfo should contain X509Data element"); + + var x509CertificateElement = x509DataElement!.Element(dsNs + "X509Certificate"); + x509CertificateElement.ShouldNotBeNull("X509Data should contain X509Certificate element"); + + var certificateData = x509CertificateElement!.Value; + certificateData.ShouldNotBeNullOrEmpty("X509Certificate should contain certificate data"); + + var signingCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(SamlFixture.StableSigningCert), null); + certificateData.ShouldBe(Convert.ToBase64String(signingCert.Export(X509ContentType.Cert))); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_uses_sha256_digest_method() + { + 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.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 (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Parse signature algorithms + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull(); + + var signatureInfo = ParseSignatureInfo(signatureElement!); + + // Verify SHA256 digest method + signatureInfo.DigestMethod.ShouldNotBeNull("Signature should specify DigestMethod"); + signatureInfo.DigestMethod.ShouldBe("http://www.w3.org/2001/04/xmlenc#sha256", + "DigestMethod should be SHA256"); + + // Verify RSA-SHA256 signature method + signatureInfo.SignatureMethod.ShouldNotBeNull("Signature should specify SignatureMethod"); + signatureInfo.SignatureMethod.ShouldBe("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "SignatureMethod should be RSA-SHA256"); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_uses_exclusive_canonicalization() + { + 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.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 (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Parse signature canonicalization + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull(); + + var signatureInfo = ParseSignatureInfo(signatureElement!); + + // Verify Exclusive Canonicalization (C14N) + signatureInfo.CanonicalizationMethod.ShouldNotBeNull("Signature should specify CanonicalizationMethod"); + signatureInfo.CanonicalizationMethod.ShouldBe("http://www.w3.org/2001/10/xml-exc-c14n#", + "CanonicalizationMethod should be Exclusive C14N"); + } + + [Fact] + [Trait("Category", Category)] + public async Task assertion_signature_uses_correct_algorithms() + { + 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.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 (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract Assertion signature and verify algorithms + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + + var signatureElement = ExtractSignatureElement(assertionElement!); + signatureElement.ShouldNotBeNull(); + + var signatureInfo = ParseSignatureInfo(signatureElement!); + + // Verify all algorithms match SAML 2.0 best practices + signatureInfo.CanonicalizationMethod.ShouldBe("http://www.w3.org/2001/10/xml-exc-c14n#"); + signatureInfo.SignatureMethod.ShouldBe("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"); + signatureInfo.DigestMethod.ShouldBe("http://www.w3.org/2001/04/xmlenc#sha256"); + } + + [Fact] + [Trait("Category", Category)] + public async Task sign_both_uses_consistent_algorithms_for_both_signatures() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + 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 (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract and verify Response signature + var responseSignature = ExtractSignatureElement(responseElement); + responseSignature.ShouldNotBeNull(); + var responseSignatureInfo = ParseSignatureInfo(responseSignature!); + + // Extract and verify Assertion signature + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + var assertionSignature = ExtractSignatureElement(assertionElement!); + assertionSignature.ShouldNotBeNull(); + var assertionSignatureInfo = ParseSignatureInfo(assertionSignature!); + + // Both signatures should use the same algorithms + responseSignatureInfo.CanonicalizationMethod.ShouldBe(assertionSignatureInfo.CanonicalizationMethod, + "Both signatures should use same canonicalization method"); + responseSignatureInfo.SignatureMethod.ShouldBe(assertionSignatureInfo.SignatureMethod, + "Both signatures should use same signature method"); + responseSignatureInfo.DigestMethod.ShouldBe(assertionSignatureInfo.DigestMethod, + "Both signatures should use same digest method"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_includes_relay_state() + { + 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.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + var relayStateValue = "test-relay-state-123"; + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}&RelayState={relayStateValue}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false); + + successResponse.RelayState.ShouldBe(relayStateValue); + + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_works_with_force_authn() + { + 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.Client.GetAsync("/__signin", _ct); + + var normalRequestXml = Build.AuthNRequestXml(forceAuthn: false); + var normalUrlEncoded = await EncodeRequest(normalRequestXml); + var normalResult = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={normalUrlEncoded}", _ct); + var normalResponse = await ExtractSamlSuccessFromPostAsync(normalResult, _ct); + normalResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + + var forceAuthnRequestXml = Build.AuthNRequestXml(forceAuthn: true); + var forceAuthnUrlEncoded = await EncodeRequest(forceAuthnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={forceAuthnUrlEncoded}", _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); + var returnUrl = queryStringValues["ReturnUrl"]; + returnUrl.ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_works_with_is_passive() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + 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(isPassive: true); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: true); + + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_works_with_custom_acs_index() + { + var primaryAcsUrl = new Uri("https://sp.example.com/callback1"); + var secondaryAcsUrl = new Uri("https://sp.example.com/callback2"); + + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + sp.AssertionConsumerServiceUrls = [primaryAcsUrl, secondaryAcsUrl]; + 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(acsIndex: 1); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false); + + successResponse.Destination.ShouldBe(secondaryAcsUrl.ToString()); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_request_not_signed_redirect_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Missing signature parameter"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_request_not_signed_post_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + 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); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Signature element not found"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_signature_invalid_redirect_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var wrongCert = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Wrong Certificate"); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, wrongCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_signature_invalid_post_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var wrongCert = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Wrong Certificate"); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var signedXml = SignAuthNRequestXml(Build.AuthNRequestXml(), wrongCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_no_sp_certificates_configured() + { + var sp = Build.SamlServiceProvider(signingCertificate: null, requireSignedAuthnRequests: true); + Fixture.ServiceProviders.Add(sp); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.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.ShouldBe($"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a SAML signin request which requires signature validation"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_redirect_binding_request_correctly_signed() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + 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 (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_post_binding_request_correctly_signed() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var signedXml = SignAuthNRequestXml(Build.AuthNRequestXml(), signingCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_signature_optional_and_request_not_signed() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(requireSignedAuthnRequests: false)); + + 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}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_signature_optional_and_request_is_signed() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: false)); + + 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 (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_multiple_certificates_and_one_matches() + { + var cert1 = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Certificate 1"); + var cert2 = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Certificate 2"); + var cert3 = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Certificate 3"); + + var sp = new SamlServiceProvider + { + EntityId = Data.EntityId, + DisplayName = "Example SP", + Description = "Example SP", + Enabled = true, + RequireSignedAuthnRequests = true, + SigningCertificates = [cert1, cert2, cert3], + AssertionConsumerServiceUrls = [Data.AcsUrl], + AssertionConsumerServiceBinding = SamlBinding.HttpPost, + RequestMaxAge = TimeSpan.FromMinutes(5), + ClockSkew = TimeSpan.FromMinutes(5) + }; + + 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 urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, cert2); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_redirect_binding_signature_algorithm_unsupported() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + // Sign with SHA1 (deprecated/unsupported) + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert, + "http://www.w3.org/2000/09/xmldsig#rsa-sha1"); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Unsupported signature algorithm: http://www.w3.org/2000/09/xmldsig#rsa-sha1"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_redirect_binding_signature_has_query_order_incorrect() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var relayState = "relayState"; + // Pass urlEncoded and relayState in incorrect order for signing to cause a bad signature + var (signature, sigAlg) = SignAuthNRequestRedirect(relayState, urlEncoded, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&SigAlg={Uri.EscapeDataString(sigAlg)}&Signature={Uri.EscapeDataString(signature)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_redirect_binding_includes_relay_state_in_signature() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + 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 relayState = "test-relay-state-value"; + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, relayState, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&RelayState={Uri.EscapeDataString(relayState)}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.RelayState.ShouldBe(relayState); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_post_binding_signature_element_has_empty_reference() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var authNRequestXml = Build.AuthNRequestXml(); + var signedXml = SignAuthNRequestXmlWithEmptyReference(authNRequestXml, signingCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_post_binding_signature_reference_wrong_id() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var authNRequestXml = Build.AuthNRequestXml(); + var signedXml = SignAuthNRequestXml(authNRequestXml, signingCert); + signedXml = signedXml.Replace($"#{Fixture.Data.RequestId}", "#_bogus_id"); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_post_binding_signature_uses_exclusive_canonicalization() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // SignAuthNRequestXml already uses ExcC14N, so this validates it works + var signedXml = SignAuthNRequestXml(Build.AuthNRequestXml(), signingCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_certificate_expired() + { + var expiredCert = CreateExpiredTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(expiredCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, expiredCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Responder.Value); + samlError.StatusMessage.ShouldBe("No valid certificates configured for service provider"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_certificate_not_yet_valid() + { + var notYetValidCert = CreateNotYetValidTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(notYetValidCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, notYetValidCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCode.Responder.Value); + samlError.StatusMessage.ShouldBe("No valid certificates configured for service provider"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_signature_certificate_within_validity_period() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + 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 (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_requested_authn_context_and_requirement_is_satisfied() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // First, initiate SAML signin to create state (use NonRedirectingClient for all requests to share cookies) + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + signinResult.StatusCode.ShouldBe(HttpStatusCode.Redirect); + + // Then sign in the user with authn context requirements + Fixture.UserMetRequestedAuthnContextRequirements = true; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("saml:acr", "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + ], "Test")); + + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + // Act - complete the callback + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_requested_authn_context_and_requirement_is_not_satisfied() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // First, initiate SAML signin to create state (use NonRedirectingClient for all requests to share cookies) + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + signinResult.StatusCode.ShouldBe(HttpStatusCode.Redirect); + + // Then sign in the user with authn context requirements + Fixture.UserMetRequestedAuthnContextRequirements = false; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("saml:acr", "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + ], "Test")); + + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + // Act - complete the callback + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + // Assert + var samlResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + samlResponse.SubStatusCode.ShouldBe(SamlStatusCode.NoAuthnContext.Value); + samlResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_requested_authn_context_but_no_claim_returns_error() + { + // Arrange + 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 requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var samlResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + samlResponse.SubStatusCode.ShouldBe(SamlStatusCode.NoAuthnContext.Value); + samlResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_without_requested_authn_context_returns_authn_context_if_claim_is_present() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("saml:acr", "urn:oasis:names:tc:SAML:2.0:ac:classes:X509") + ], "Test")); + + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:X509"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_multiple_authn_context_class_refs_parsed_correctly() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + urn:oasis:names:tc:SAML:2.0:ac:classes:X509 + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // Act + var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.Found); + + var parsedRequestedAuthnContext = await Fixture.NonRedirectingClient.GetFromJsonAsync("/__authentication-request", _ct); + + // Assert + parsedRequestedAuthnContext.ShouldNotBeNull(); + parsedRequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Better); + parsedRequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(3); + parsedRequestedAuthnContext.AuthnContextClassRefs.ShouldBe(["urn:oasis:names:tc:SAML:2.0:ac:classes:Password", "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "urn:oasis:names:tc:SAML:2.0:ac:classes:X509"]); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_name_id_format_is_supported() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(nameIdFormat: SamlConstants.NameIdentifierFormats.Persistent); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + 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 error_when_name_id_format_is_not_supported() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var unsupportedFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos"; + var authnRequestXml = Build.AuthNRequestXml(nameIdFormat: unsupportedFormat); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe(SamlStatusCode.Responder.Value); + errorResponse.SubStatusCode.ShouldBe(SamlStatusCode.InvalidNameIdPolicy.Value); + errorResponse.StatusMessage.ShouldBe($"Requested NameID format '{unsupportedFormat}' is not supported by this IdP"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_name_id_policy_element_present_without_format() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // NameIDPolicy with SPNameQualifier but no Format - should succeed + var authnRequestXml = Build.AuthNRequestXml(spNameQualifier: "https://custom.sp.com"); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + 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_no_name_id_policy_element() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + 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 response_uses_requested_name_id_format_from_policy() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id"), new Claim(JwtClaimTypes.Email, "test@test.com")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(nameIdFormat: SamlConstants.NameIdentifierFormats.EmailAddress); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("test@test.com"); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.EmailAddress); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_uses_sp_default_name_id_format_when_no_policy() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var persistentIdentifier = Guid.NewGuid().ToString(); + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id"), new Claim(ClaimTypes.NameIdentifier, persistentIdentifier)], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); // No NameIDPolicy + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe(persistentIdentifier); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_uses_email_format_when_no_policy_and_no_sp_default() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = null; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(JwtClaimTypes.Email, "user@example.com") + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); // No NameIDPolicy + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Unspecified); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task transient_format_generates_different_ids_per_request() + { + 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( + nameIdFormat: SamlConstants.NameIdentifierFormats.Transient); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // First request + var result1 = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var response1 = await ExtractSamlSuccessFromPostAsync(result1, _ct); + var nameId1 = response1.Assertion.Subject?.NameId; + + // Second request with same parameters + var result2 = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var response2 = await ExtractSamlSuccessFromPostAsync(result2, _ct); + var nameId2 = response2.Assertion.Subject?.NameId; + + // Verify both responses succeeded + response1.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + response2.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + + // Verify format is transient + response1.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Transient); + response2.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Transient); + + // Verify IDs are different (transient should be unique per request) + nameId1.ShouldNotBeNull(); + nameId2.ShouldNotBeNull(); + nameId1.ShouldNotBe(nameId2, "Transient NameIDs should be unique per authentication"); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_uses_default_claim_type_from_service_provider_options() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var persistentId = "persistent-id-12345"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, persistentId) // Default claim type + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); // No NameIDPolicy, uses SP default + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe(persistentId); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + successResponse.Assertion.Subject?.SPNameQualifier.ShouldBe(Data.EntityId.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_uses_sp_specific_claim_type_override() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + sp.DefaultPersistentNameIdentifierClaimType = "custom_persistent_id"; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var spSpecificId = "sp-specific-id-67890"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, "default-id"), + new Claim("custom_persistent_id", spSpecificId) // SP-specific claim + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe(spSpecificId); // Uses SP override, not default + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + successResponse.Assertion.Subject?.SPNameQualifier.ShouldBe(Data.EntityId.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_fails_when_claim_missing() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User WITHOUT ClaimTypes.NameIdentifier claim + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(JwtClaimTypes.Email, "user@example.com") + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_fails_when_claim_value_is_empty() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User with empty ClaimTypes.NameIdentifier + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, "") // Empty claim + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_sets_sp_name_qualifier_to_sp_entity_id() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var persistentId = "persistent-abc123"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, persistentId) + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.SPNameQualifier.ShouldBe(Data.EntityId.ToString()); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_works_with_custom_global_claim_type() + { + // Arrange + Fixture.ConfigureSamlOptions = options => + { + options.DefaultPersistentNameIdentifierClaimType = "app_persistent_id"; + }; + + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var customPersistentId = "global-custom-id-xyz"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("app_persistent_id", customPersistentId), // Custom global claim type + // Note: ClaimTypes.NameIdentifier NOT present + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe(customPersistentId); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + } + + [Fact] + [Trait("Category", Category)] + public async Task multiple_users_get_different_persistent_ids_same_sp() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // User A + var userAPersistentId = "user-a-persistent-123"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-a"), + new Claim(ClaimTypes.NameIdentifier, userAPersistentId) + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var resultA = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var responseA = await ExtractSamlSuccessFromPostAsync(resultA, _ct); + + // User B (re-authenticate as different user) + var userBPersistentId = "user-b-persistent-456"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-b"), + new Claim(ClaimTypes.NameIdentifier, userBPersistentId) + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var resultB = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var responseB = await ExtractSamlSuccessFromPostAsync(resultB, _ct); + + // Assert + responseA.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + responseB.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + + responseA.Assertion.Subject?.NameId.ShouldBe(userAPersistentId); + responseB.Assertion.Subject?.NameId.ShouldBe(userBPersistentId); + + // Verify IDs are distinct + responseA.Assertion.Subject?.NameId.ShouldNotBe(responseB.Assertion.Subject?.NameId, + "Different users should have different persistent identifiers"); + } + + [Fact] + [Trait("Category", Category)] + public async Task email_format_fails_when_claim_missing() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.EmailAddress; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User WITHOUT ClaimTypes.NameIdentifier claim + 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); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task email_format_fails_when_claim_value_is_empty() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.EmailAddress; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User with empty ClaimTypes.NameIdentifier + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.Email, "") // Empty claim + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task email_format_returns_expected_value_when_claim_is_present() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.EmailAddress; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User with empty ClaimTypes.NameIdentifier + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.Email, "test@testing.com") + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + successResponse.Assertion.Subject?.NameId.ShouldBe("test@testing.com"); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.EmailAddress); + } +} + diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs new file mode 100644 index 000000000..85512f57b --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Stores; +using Xunit.Abstractions; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlSingleLogoutCallbackEndpointTests(ITestOutputHelper output) +{ + private const string Category = "SAML single logout 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_with_post_method_should_return_method_not_allowed() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.PostAsync("/saml/logout_callback", new StringContent(""), CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_missing_logout_id_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.GetAsync("/saml/logout_callback", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_invalid_logout_id_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.GetAsync("/saml/logout_callback?logoutId=invalid", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_valid_logout_id_should_return_success_response() + { + // Arrange + var sp = Build.SamlServiceProvider(); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Store a logout message as if SP-initiated logout occurred + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session456", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_abc123", + SamlRelayState = null + }; + var messageStore = Fixture.Get>(); + var logoutId = await messageStore.WriteAsync(new Message(logoutMessage, DateTime.UtcNow)); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout_callback?logoutId={logoutId}", CancellationToken.None); + + // Assert + var samlResponse = await SamlTestHelpers.ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + samlResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_should_include_relay_state_if_present() + { + // Arrange + var sp = Build.SamlServiceProvider(); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session456", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_abc123", + SamlRelayState = "mystate123" + }; + var messageStore = Fixture.Get>(); + var logoutId = await messageStore.WriteAsync(new Message(logoutMessage, DateTime.UtcNow)); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout_callback?logoutId={logoutId}", CancellationToken.None); + + // Assert + var response = await SamlTestHelpers.ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + response.RelayState.ShouldBe(logoutMessage.SamlRelayState); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_disabled_service_provider_should_return_bad_request() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.Enabled = false; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session456", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_abc123" + }; + var messageStore = Fixture.Get>(); + var logoutId = await messageStore.WriteAsync(new Message(logoutMessage, DateTime.UtcNow)); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout_callback?logoutId={logoutId}", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs new file mode 100644 index 000000000..269feb50d --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs @@ -0,0 +1,528 @@ +// 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.Text; +using System.Web; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Mvc; +using Xunit.Abstractions; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlSingleLogoutEndpointTests(ITestOutputHelper output) +{ + private const string Category = "SAML single logout endpoint"; + + private SamlFixture Fixture = new(output); + + private SamlData Data => Fixture.Data; + + private SamlDataBuilder Build => Fixture.Builder; + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_no_saml_request_in_redirect_binding_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.GetAsync("/saml/logout", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' query parameter in SAML logout request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_post_binding_with_wrong_content_type_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml(); + var stringContent = new StringContent(logoutRequestXml, Encoding.UTF8, "application/xml"); + + // Act + var result = await Fixture.Client.PostAsync("/saml/logout", stringContent, CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("POST request does not have form content type for SAML logout request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_post_binding_with_missing_saml_request_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml(); + var encodedRequest = await EncodeRequest(logoutRequestXml, CancellationToken.None); + var formData = new Dictionary + { + { "wrong_form_key", encodedRequest } + }; + var content = new FormUrlEncodedContent(formData); + + // Act + var result = await Fixture.Client.PostAsync("/saml/logout", content, CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' form parameter in SAML logout request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_service_provider_not_found_should_return_bad_request() + { + // Arrange + await Fixture.InitializeAsync(); + + var issuer = "https://wrong-issuer.com"; + var logoutRequestXml = Build.LogoutRequestXml(issuer: issuer); + var urlEncoded = await EncodeRequest(logoutRequestXml, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{issuer}' is not registered or is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_service_provider_is_disabled_should_return_bad_request() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.Enabled = false; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml(issuer: sp.EntityId); + var urlEncoded = await EncodeRequest(logoutRequestXml, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + 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 logout_endpoint_when_service_provider_has_no_single_logout_service_url_configured_should_return_error() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + sp.SingleLogoutServiceUrl = null; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", CancellationToken.None); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' has no SingleLogoutServiceUrl configured"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_saml_version_in_logout_request_is_invalid_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + version: "1.0"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.VersionMismatch.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_issue_instant_is_in_future_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var futureTime = Data.Now.AddMinutes(10); + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + issueInstant: futureTime, + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + logoutResponse.StatusMessage.ShouldBe("Request IssueInstant is in the future"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_issue_instant_is_too_old_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var oldTime = Data.Now.AddMinutes(-10); + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + issueInstant: oldTime, + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + logoutResponse.StatusMessage.ShouldBe("Request has expired (IssueInstant too old)"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_destination_does_not_match_endpoint_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri("https://wrong-destination.com/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + logoutResponse.StatusMessage.ShouldBe($"Invalid destination. Expected '{Fixture.Host!.Uri()}/saml/logout'"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_service_provider_has_no_signing_certificates_configured_should_return_bad_request() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.SigningCertificates = []; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeRequest(logoutRequestXml, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(CancellationToken.None); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a SAML logout request which requires signature validation"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_request_without_signature_received_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Create a logout request without a signature + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeRequest(logoutRequestXml, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + logoutResponse.StatusMessage.ShouldBe("Missing signature parameter"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_not_on_or_after_is_in_past_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var expiredTime = Data.Now.AddMinutes(-10); + var logoutRequestXml = Build.LogoutRequestXml( + notOnOrAfter: expiredTime, + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Requester.Value); + logoutResponse.StatusMessage.ShouldBe("Logout request expired (NotOnOrAfter is in the past)"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_no_authenticated_session_should_return_success_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Don't sign in a user - no authenticated session + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_no_session_found_for_session_index_should_return_success_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + var anotherSp = Build.SamlServiceProvider(signingCertificate: signingCert); + sp.EntityId = "https://another-sp.com"; + Fixture.ServiceProviders.Add(anotherSp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", CancellationToken.None); + + // Use a different service provider than what was established + var logoutRequestXml = Build.LogoutRequestXml( + issuer: anotherSp.EntityId, // Use a different SP so session will not be found + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout")); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_wrong_session_index_sent_should_return_success() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", CancellationToken.None); + + // Use a different session index than what was established + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + sessionIndex: "wrong-session-index"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, CancellationToken.None); + logoutResponse.StatusCode.ShouldBe(SamlStatusCode.Success.Value); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_request_is_valid_should_redirect_to_logout() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", CancellationToken.None); + + // Perform logout to get correct session index from the response + var sessionIndex = await PerformSigninAndExtractSessionIndex(sp); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + sessionIndex: sessionIndex); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LogoutUrl.ToString()); + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + queryStringValues["logoutId"].ShouldNotBeNullOrWhiteSpace(); + } + + [Fact(Skip = "Endpoint is no longer responsible for logout as Host app logout page is now responsible. Return to this after propagated logout is complete and adjust/remove as appropriate")] + [Trait("Category", Category)] + public async Task logout_endpoint_when_logout_is_successful_should_terminate_user_session() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", CancellationToken.None); + + // Ensure user can access protected resource + var initialProtectedResourceResult = await Fixture.Client.GetAsync("__protected-resource", CancellationToken.None); + initialProtectedResourceResult.StatusCode.ShouldBe(HttpStatusCode.OK); + + var sessionIndex = await PerformSigninAndExtractSessionIndex(sp); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Host!.Uri()}/saml/logout"), + sessionIndex: sessionIndex); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, CancellationToken.None); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", CancellationToken.None); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); // Follows redirect + + // Verify user can no longer access protected resource and is redirected to login + var finalProtectedResourceResult = await Fixture.Client.GetAsync("__protected-resource", CancellationToken.None); + finalProtectedResourceResult.StatusCode.ShouldBe(HttpStatusCode.OK); + finalProtectedResourceResult.RequestMessage?.RequestUri?.AbsoluteUri.ShouldStartWith($"{Fixture.Host!.Uri()}{Fixture.LoginUrl.ToString()}"); + } + + private static async Task EncodeAndSignRequest( + string xml, + SamlServiceProvider sp, + CancellationToken cancellationToken) + { + var encoded = await EncodeRequest(xml, cancellationToken); + + // Sign the request using the SP's certificate + var certificate = sp.SigningCertificates!.First(); + var (signature, sigAlg) = SignAuthNRequestRedirect(encoded, null, certificate); + + return $"{encoded}&SigAlg={Uri.EscapeDataString(sigAlg)}&Signature={Uri.EscapeDataString(signature)}"; + } + + private async Task PerformSigninAndExtractSessionIndex(SamlServiceProvider samlServiceProvider) + { + var signinRequest = Build.AuthNRequestXml(); + var encoded = await EncodeAndSignRequest(signinRequest, samlServiceProvider, CancellationToken.None); + var signinResult = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={encoded}", CancellationToken.None); + var samlResult = await ExtractSamlSuccessFromPostAsync(signinResult, CancellationToken.None); + if (string.IsNullOrWhiteSpace(samlResult.Assertion.AuthnStatement?.SessionIndex)) + { + throw new InvalidOperationException("SAMLResult did not have a valid session index"); + } + + return samlResult.Assertion.AuthnStatement.SessionIndex; + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs new file mode 100644 index 000000000..994fc2424 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs @@ -0,0 +1,868 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; +using System.Text; +using System.Web; +using System.Xml; +using Microsoft.Net.Http.Headers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal static class SamlTestHelpers +{ + public static async Task EncodeRequest(string authenticationRequest, CancellationToken cancellationToken) + { + 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, cancellationToken); + } + + var compressedBytes = outputStream.ToArray(); + var base64 = Convert.ToBase64String(compressedBytes); + var urlEncoded = Uri.EscapeDataString(base64); + return urlEncoded; + } + + public static string ConvertToBase64Encoded(string authenticationRequest) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(authenticationRequest)); + + /// + /// Extracts SAML error response from an HTTP-POST binding auto-submit form. + /// + public static async Task ExtractSamlErrorFromPostAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, cancellationToken); + var (samlpNs, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var baseData = ParseCommonResponseElements(responseElement, samlNs, samlpNs, relayState, acsUrl); + + return new SamlErrorResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl + }; + } + + public static async Task ExtractSamlLogoutResponseFromPostAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, cancellationToken); + var (samlpNs, samlNs, logoutResponseElement) = ParseSamlLogoutResponseXml(responseXml); + var baseData = ParseCommonResponseElements(logoutResponseElement, samlNs, samlpNs, relayState, acsUrl); + + return new SamlLogoutResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl + }; + } + + public static async Task ExtractSamlSuccessFromPostAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, cancellationToken); + var (samlpNs, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var baseData = ParseCommonResponseElements(responseElement, samlNs, samlpNs, relayState, acsUrl); + + var assertion = ParseAssertion(responseElement, samlNs); + + return new SamlSuccessResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl, + Assertion = assertion + }; + } + + public static async Task<(string responseXml, string? relayState, string acsUrl)> ExtractSamlResponse(HttpResponseMessage response, CancellationToken cancellationToken) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html"); + + var html = await response.Content.ReadAsStringAsync(cancellationToken); + + // 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, + @"]+action=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + actionMatch.Success.ShouldBeTrue("Form action not found in HTML"); + var acsUrl = HttpUtility.HtmlDecode(actionMatch.Groups[1].Value); + + // Decode the SAML response + var decodedBytes = Convert.FromBase64String(HttpUtility.HtmlDecode(encodedResponse)); + var responseXml = Encoding.UTF8.GetString(decodedBytes); + + return (responseXml, relayState, acsUrl); + } + + public static (System.Xml.Linq.XNamespace samlpNs, System.Xml.Linq.XNamespace samlNs, System.Xml.Linq.XElement responseElement) ParseSamlResponseXml(string responseXml) + { + var doc = System.Xml.Linq.XDocument.Parse(responseXml); + var samlpNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:protocol"); + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + + var responseElement = doc.Root; + responseElement.ShouldNotBeNull(); + responseElement.Name.ShouldBe(samlpNs + "Response"); + + return (samlpNs, samlNs, responseElement); + } + + public static (System.Xml.Linq.XNamespace samlpNs, System.Xml.Linq.XNamespace samlNs, System.Xml.Linq.XElement logoutResponseElement) ParseSamlLogoutResponseXml(string responseXml) + { + var doc = System.Xml.Linq.XDocument.Parse(responseXml); + var samlpNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:protocol"); + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + + var logoutResponseElement = doc.Root; + logoutResponseElement.ShouldNotBeNull(); + logoutResponseElement.Name.ShouldBe(samlpNs + "LogoutResponse"); + + return (samlpNs, samlNs, logoutResponseElement); + } + + public static SamlResponseBase ParseCommonResponseElements( + System.Xml.Linq.XElement responseElement, + System.Xml.Linq.XNamespace samlNs, + System.Xml.Linq.XNamespace samlpNs, + string? relayState, + string acsUrl) + { + var responseId = responseElement.Attribute("ID")?.Value; + var inResponseTo = responseElement.Attribute("InResponseTo")?.Value; + var destination = responseElement.Attribute("Destination")?.Value; + var issueInstant = responseElement.Attribute("IssueInstant")?.Value; + var issuer = responseElement.Element(samlNs + "Issuer")?.Value; + + var statusElement = responseElement.Element(samlpNs + "Status"); + statusElement.ShouldNotBeNull(); + + var statusCodeElement = statusElement.Element(samlpNs + "StatusCode"); + statusCodeElement.ShouldNotBeNull(); + var statusCode = statusCodeElement.Attribute("Value")?.Value; + var statusMessage = statusElement.Element(samlpNs + "StatusMessage")?.Value; + + var subStatusCodeElement = statusCodeElement.Element(samlpNs + "StatusCode"); + var subStatusCode = subStatusCodeElement?.Attribute("Value")?.Value; + + return new SamlResponseBase + { + ResponseId = responseId, + InResponseTo = inResponseTo, + Destination = destination, + IssueInstant = issueInstant, + Issuer = issuer, + StatusCode = statusCode, + StatusMessage = statusMessage, + SubStatusCode = subStatusCode, + RelayState = relayState, + AssertionConsumerServiceUrl = acsUrl + }; + } + + public static Assertion ParseAssertion(System.Xml.Linq.XElement responseElement, System.Xml.Linq.XNamespace samlNs) + { + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + + var assertionId = assertionElement.Attribute("ID")?.Value; + var assertionVersion = assertionElement.Attribute("Version")?.Value; + var assertionIssueInstant = assertionElement.Attribute("IssueInstant")?.Value; + var assertionIssuer = assertionElement.Element(samlNs + "Issuer")?.Value; + + var subjectElement = assertionElement.Element(samlNs + "Subject"); + Subject? subject = null; + if (subjectElement != null) + { + var nameIdElement = subjectElement.Element(samlNs + "NameID"); + var subjectConfirmationElement = subjectElement.Element(samlNs + "SubjectConfirmation"); + + SubjectConfirmation? subjectConfirmation = null; + if (subjectConfirmationElement != null) + { + var subjectConfirmationDataElement = subjectConfirmationElement.Element(samlNs + "SubjectConfirmationData"); + SubjectConfirmationData? subjectConfirmationData = null; + if (subjectConfirmationDataElement != null) + { + subjectConfirmationData = new SubjectConfirmationData + { + NotOnOrAfter = subjectConfirmationDataElement.Attribute("NotOnOrAfter")?.Value, + Recipient = subjectConfirmationDataElement.Attribute("Recipient")?.Value, + InResponseTo = subjectConfirmationDataElement.Attribute("InResponseTo")?.Value + }; + } + + subjectConfirmation = new SubjectConfirmation + { + Method = subjectConfirmationElement.Attribute("Method")?.Value, + SubjectConfirmationData = subjectConfirmationData + }; + } + + subject = new Subject + { + NameId = nameIdElement?.Value, + NameIdFormat = nameIdElement?.Attribute("Format")?.Value, + SPNameQualifier = nameIdElement?.Attribute("SPNameQualifier")?.Value, + SubjectConfirmation = subjectConfirmation + }; + } + + var conditionsElement = assertionElement.Element(samlNs + "Conditions"); + Conditions? conditions = null; + if (conditionsElement != null) + { + var audienceRestrictionElement = conditionsElement.Element(samlNs + "AudienceRestriction"); + var audienceElement = audienceRestrictionElement?.Element(samlNs + "Audience"); + + conditions = new Conditions + { + NotBefore = conditionsElement.Attribute("NotBefore")?.Value, + NotOnOrAfter = conditionsElement.Attribute("NotOnOrAfter")?.Value, + Audience = audienceElement?.Value + }; + } + + var authnStatementElement = assertionElement.Element(samlNs + "AuthnStatement"); + AuthnStatement? authnStatement = null; + if (authnStatementElement != null) + { + var authnContextElement = authnStatementElement.Element(samlNs + "AuthnContext"); + var authnContextClassRefElement = authnContextElement?.Element(samlNs + "AuthnContextClassRef"); + + authnStatement = new AuthnStatement + { + AuthnInstant = authnStatementElement.Attribute("AuthnInstant")?.Value, + SessionIndex = authnStatementElement.Attribute("SessionIndex")?.Value, + AuthnContextClassRef = authnContextClassRefElement?.Value + }; + } + + var attributeStatementElement = assertionElement.Element(samlNs + "AttributeStatement"); + List? attributes = null; + if (attributeStatementElement != null) + { + attributes = attributeStatementElement.Elements(samlNs + "Attribute") + .Select(attr => + { + var attributeValues = attr.Elements(samlNs + "AttributeValue") + .Select(av => av.Value) + .ToList(); + + return new SamlAttribute + { + Name = attr.Attribute("Name")?.Value, + NameFormat = attr.Attribute("NameFormat")?.Value, + FriendlyName = attr.Attribute("FriendlyName")?.Value, + Value = attributeValues.FirstOrDefault(), // For backward compatibility + Values = attributeValues + }; + }) + .ToList(); + } + + return new Assertion + { + Id = assertionId, + Version = assertionVersion, + IssueInstant = assertionIssueInstant, + Issuer = assertionIssuer, + Subject = subject, + Conditions = conditions, + AuthnStatement = authnStatement, + Attributes = attributes + }; + } + + public record SamlResponseBase + { + public string? ResponseId { get; init; } + public string? InResponseTo { get; init; } + public string? Destination { get; init; } + public string? IssueInstant { get; init; } + public string? Issuer { get; init; } + public string? StatusCode { get; init; } + public string? StatusMessage { get; init; } + public string? SubStatusCode { get; init; } + public string? RelayState { get; init; } + public string? AssertionConsumerServiceUrl { get; init; } + } + + public record SamlErrorResponseData : SamlResponseBase + { + } + + public record SamlSuccessResponseData : SamlResponseBase + { + public required Assertion Assertion { get; init; } + } + + public record Assertion + { + public string? Id { get; init; } + public string? Version { get; init; } + public string? IssueInstant { get; init; } + public string? Issuer { get; init; } + public Subject? Subject { get; init; } + public Conditions? Conditions { get; init; } + public AuthnStatement? AuthnStatement { get; init; } + public List? Attributes { get; init; } + } + + public record Subject + { + public string? NameId { get; init; } + public string? NameIdFormat { get; init; } + public string? SPNameQualifier { get; init; } + public SubjectConfirmation? SubjectConfirmation { get; init; } + } + + public record SubjectConfirmation + { + public string? Method { get; init; } + public SubjectConfirmationData? SubjectConfirmationData { get; init; } + } + + public record SubjectConfirmationData + { + public string? NotOnOrAfter { get; init; } + public string? Recipient { get; init; } + public string? InResponseTo { get; init; } + } + + public record Conditions + { + public string? NotBefore { get; init; } + public string? NotOnOrAfter { get; init; } + public string? Audience { get; init; } + } + + public record AuthnStatement + { + public string? AuthnInstant { get; init; } + public string? SessionIndex { get; init; } + public string? AuthnContextClassRef { get; init; } + } + + public record SamlAttribute + { + public string? Name { get; init; } + public string? NameFormat { get; init; } + public string? FriendlyName { get; init; } + public string? Value { get; init; } + public List Values { get; init; } = new(); + } + + public static void VerifySignaturePresence(string responseXml, bool expectResponseSignature, bool expectAssertionSignature) + { + var hasResponseSig = HasResponseSignature(responseXml); + var hasAssertionSig = HasAssertionSignature(responseXml); + + if (expectResponseSignature) + { + hasResponseSig.ShouldBeTrue("Expected Response to have a Signature element"); + } + else + { + hasResponseSig.ShouldBeFalse("Expected Response to NOT have a Signature element"); + } + + if (expectAssertionSignature) + { + hasAssertionSig.ShouldBeTrue("Expected Assertion to have a Signature element"); + } + else + { + hasAssertionSig.ShouldBeFalse("Expected Assertion to NOT have a Signature element"); + } + } + + public static void VerifySignaturePositionAfterIssuer(System.Xml.Linq.XElement parentElement) + { + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + + var issuerElement = parentElement.Element(samlNs + "Issuer"); + var signatureElement = parentElement.Element(dsNs + "Signature"); + + issuerElement.ShouldNotBeNull("Parent element must have an Issuer"); + signatureElement.ShouldNotBeNull("Parent element must have a Signature"); + + // Check that Signature comes after Issuer in document order + var elements = parentElement.Elements().ToList(); + var issuerIndex = elements.IndexOf(issuerElement!); + var signatureIndex = elements.IndexOf(signatureElement!); + + signatureIndex.ShouldBeGreaterThan(issuerIndex, + "Signature element must appear after Issuer element per SAML specification"); + } + + public static System.Xml.Linq.XElement? ExtractSignatureElement(System.Xml.Linq.XElement parentElement) + { + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + return parentElement.Element(dsNs + "Signature"); + } + + public static string? GetSignatureReferenceUri(System.Xml.Linq.XElement signatureElement) + { + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var referenceElement = signatureElement + .Element(dsNs + "SignedInfo") + ?.Element(dsNs + "Reference"); + + return referenceElement?.Attribute("URI")?.Value; + } + + public static SignatureInfo ParseSignatureInfo(System.Xml.Linq.XElement signatureElement) + { + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + + var signedInfo = signatureElement.Element(dsNs + "SignedInfo"); + signedInfo.ShouldNotBeNull("Signature must have SignedInfo element"); + + var canonicalizationMethod = signedInfo! + .Element(dsNs + "CanonicalizationMethod") + ?.Attribute("Algorithm")?.Value; + + var signatureMethod = signedInfo + .Element(dsNs + "SignatureMethod") + ?.Attribute("Algorithm")?.Value; + + var reference = signedInfo.Element(dsNs + "Reference"); + var referenceUri = reference?.Attribute("URI")?.Value; + + var digestMethod = reference? + .Element(dsNs + "DigestMethod") + ?.Attribute("Algorithm")?.Value; + + return new SignatureInfo + { + CanonicalizationMethod = canonicalizationMethod, + SignatureMethod = signatureMethod, + ReferenceUri = referenceUri, + DigestMethod = digestMethod + }; + } + + private static bool HasResponseSignature(string responseXml) + { + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var signatureElement = responseElement.Element(dsNs + "Signature"); + return signatureElement != null; + } + + private static bool HasAssertionSignature(string responseXml) + { + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + + var assertionElement = responseElement.Element(samlNs + "Assertion"); + if (assertionElement == null) + { + return false; + } + + var signatureElement = assertionElement.Element(dsNs + "Signature"); + return signatureElement != null; + } + + public record SignatureInfo + { + public string? CanonicalizationMethod { get; init; } + public string? SignatureMethod { get; init; } + public string? ReferenceUri { get; init; } + public string? DigestMethod { get; init; } + } + + public static string? ExtractStateIdFromCookie(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + return null; + } + + if (!CookieHeaderValue.TryParseList(setCookies.ToList(), out var cookieHeaderValues)) + { + return null; + } + + var targetCookie = cookieHeaderValues.FirstOrDefault(cookie => cookie.Name == "__IdsSvr_SamlSigninState"); + + return targetCookie?.Value.ToString(); + } + + public static X509Certificate2 CreateTestSigningCertificate(TimeProvider timeProvider, string subject = "CN=Test SP Signing Certificate") + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + + var certificate = request.CreateSelfSigned( + timeProvider.GetUtcNow().AddDays(-1), + timeProvider.GetUtcNow().AddYears(10)); + + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable); + } + + public static string SignAuthNRequestXml(string authNRequestXml, X509Certificate2 certificate) + { + var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + doc.LoadXml(authNRequestXml); + + var signedXml = new SignedXml(doc) { SigningKey = certificate.GetRSAPrivateKey() }; + + var reference = new Reference { Uri = "#" + doc.DocumentElement!.GetAttribute("ID") }; + reference.AddTransform(new XmlDsigEnvelopedSignatureTransform()); + reference.AddTransform(new XmlDsigExcC14NTransform()); + signedXml.AddReference(reference); + + signedXml.KeyInfo = new KeyInfo(); + signedXml.KeyInfo.AddClause(new KeyInfoX509Data(certificate)); + + signedXml.ComputeSignature(); + + var signatureElement = signedXml.GetXml(); + + // Insert signature after Issuer element per SAML spec + var issuerElement = doc.DocumentElement!.GetElementsByTagName("Issuer", "urn:oasis:names:tc:SAML:2.0:assertion")[0]; + doc.DocumentElement.InsertAfter(signatureElement, issuerElement); + + return doc.OuterXml; + } + + public static (string signature, string sigAlg) SignAuthNRequestRedirect( + string samlRequest, + string? relayState, + X509Certificate2 certificate, + string algorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") + { + // Build the query string to sign (order matters!) + var queryToSign = $"SAMLRequest={samlRequest}"; + + if (!string.IsNullOrEmpty(relayState)) + { + queryToSign += $"&RelayState={Uri.EscapeDataString(relayState)}"; + } + + queryToSign += $"&SigAlg={Uri.EscapeDataString(algorithm)}"; + + var bytesToSign = Encoding.UTF8.GetBytes(queryToSign); + + using var rsa = certificate.GetRSAPrivateKey(); + var hashAlgorithm = algorithm.Contains("sha512") ? HashAlgorithmName.SHA512 : HashAlgorithmName.SHA256; + var signatureBytes = rsa!.SignData(bytesToSign, hashAlgorithm, RSASignaturePadding.Pkcs1); + + var signature = Convert.ToBase64String(signatureBytes); + + return (signature, algorithm); + } + + public static X509Certificate2 CreateExpiredTestSigningCertificate(TimeProvider timeProvider, string subject = "CN=Expired Test SP Certificate") + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + + // Create certificate that expired yesterday + var certificate = request.CreateSelfSigned( + timeProvider.GetUtcNow().AddYears(-2), + timeProvider.GetUtcNow().AddDays(-1)); + + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable); + } + + public static X509Certificate2 CreateNotYetValidTestSigningCertificate(TimeProvider timeProvider, string subject = "CN=Future Test SP Certificate") + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + + // Create certificate that won't be valid until tomorrow + var certificate = request.CreateSelfSigned( + timeProvider.GetUtcNow().AddDays(1), + timeProvider.GetUtcNow().AddYears(10)); + + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable); + } + + public static string SignAuthNRequestXmlWithEmptyReference(string authNRequestXml, X509Certificate2 certificate) + { + var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + doc.LoadXml(authNRequestXml); + + var signedXml = new SignedXml(doc) { SigningKey = certificate.GetRSAPrivateKey() }; + + // Create reference with empty URI - this should fail validation + var reference = new Reference { Uri = "" }; + reference.AddTransform(new XmlDsigEnvelopedSignatureTransform()); + reference.AddTransform(new XmlDsigExcC14NTransform()); + signedXml.AddReference(reference); + + signedXml.KeyInfo = new KeyInfo(); + signedXml.KeyInfo.AddClause(new KeyInfoX509Data(certificate)); + + signedXml.ComputeSignature(); + + var signatureElement = signedXml.GetXml(); + + // Insert signature after Issuer element per SAML spec + var issuerElement = doc.DocumentElement!.GetElementsByTagName("Issuer", "urn:oasis:names:tc:SAML:2.0:assertion")[0]; + doc.DocumentElement.InsertAfter(signatureElement, issuerElement); + + return doc.OuterXml; + } + + public static void ValidateEncryptedStructure(System.Xml.Linq.XElement response) + { + ArgumentNullException.ThrowIfNull(response); + + var samlNs = System.Xml.Linq.XNamespace.Get( + "urn:oasis:names:tc:SAML:2.0:assertion"); + var encNs = System.Xml.Linq.XNamespace.Get( + "http://www.w3.org/2001/04/xmlenc#"); + + // Verify present + var encAssertion = response.Descendants(samlNs + "EncryptedAssertion") + .FirstOrDefault(); + encAssertion.ShouldNotBeNull( + "Response should contain element"); + + // Verify present + var encData = encAssertion.Descendants(encNs + "EncryptedData") + .FirstOrDefault(); + encData.ShouldNotBeNull( + " should contain element"); + + // Verify present + var encKey = encAssertion.Descendants(encNs + "EncryptedKey") + .FirstOrDefault(); + encKey.ShouldNotBeNull( + " should contain element"); + } + + public static bool HasEncryptedAssertion(string responseXml) + { + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var encryptedAssertion = responseElement.Element(samlNs + "EncryptedAssertion"); + return encryptedAssertion != null; + } + + public static bool HasPlainAssertion(string responseXml) + { + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var assertion = responseElement.Element(samlNs + "Assertion"); + return assertion != null; + } + + public static System.Xml.Linq.XElement DecryptAssertion( + System.Xml.Linq.XElement encryptedAssertion, + X509Certificate2 decryptionCertificate) + { + ArgumentNullException.ThrowIfNull(encryptedAssertion); + ArgumentNullException.ThrowIfNull(decryptionCertificate); + + using var privateKey = decryptionCertificate.GetRSAPrivateKey(); + if (privateKey == null) + { + throw new CryptographicException("Certificate does not contain an RSA private key"); + } + + // Convert to XmlDocument for EncryptedXml API + var xmlDoc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + using (var reader = encryptedAssertion.CreateReader()) + { + xmlDoc.Load(reader); + } + + var nsManager = new XmlNamespaceManager(xmlDoc.NameTable); + nsManager.AddNamespace("xenc", "http://www.w3.org/2001/04/xmlenc#"); + + var encryptedDataElement = xmlDoc.SelectSingleNode("//xenc:EncryptedData", nsManager) as XmlElement; + if (encryptedDataElement == null) + { + throw new InvalidOperationException("No EncryptedData element found"); + } + + var encryptedData = new EncryptedData(); + encryptedData.LoadXml(encryptedDataElement); + + // The encryption was done with EncryptedXml.Encrypt(element, certificate) + // which embeds an EncryptedKey with the certificate info + // We need to decrypt the key first, then decrypt the data + + // Find and decrypt the EncryptedKey + var encryptedKeyElement = xmlDoc.SelectSingleNode("//xenc:EncryptedKey", nsManager) as XmlElement; + if (encryptedKeyElement == null) + { + throw new InvalidOperationException("No EncryptedKey element found"); + } + + var encryptedKey = new EncryptedKey(); + encryptedKey.LoadXml(encryptedKeyElement); + if (encryptedKey.CipherData.CipherValue == null) + { + throw new InvalidOperationException("No CipherValue found in encrypted key element"); + } + + // Decrypt the session key using our RSA private key + byte[] sessionKey; + + // The Encrypt method uses RSA-OAEP by default + if (encryptedKey.EncryptionMethod?.KeyAlgorithm == EncryptedXml.XmlEncRSAOAEPUrl) + { + sessionKey = privateKey.Decrypt(encryptedKey.CipherData.CipherValue, RSAEncryptionPadding.OaepSHA1); + } + else if (encryptedKey.EncryptionMethod?.KeyAlgorithm == EncryptedXml.XmlEncRSA15Url) + { + sessionKey = privateKey.Decrypt(encryptedKey.CipherData.CipherValue, RSAEncryptionPadding.Pkcs1); + } + else + { + throw new CryptographicException($"Unsupported key encryption algorithm: {encryptedKey.EncryptionMethod?.KeyAlgorithm}"); + } + + // Now decrypt the data using the session key + var encryptedXml = new EncryptedXml(); + byte[] decryptedBytes; + + // Determine the symmetric algorithm used + var algorithm = encryptedData.EncryptionMethod?.KeyAlgorithm; + if (string.IsNullOrEmpty(algorithm)) + { + throw new CryptographicException("No encryption algorithm specified"); + } + + // Create the appropriate symmetric algorithm + SymmetricAlgorithm? symmetricAlgorithm = algorithm switch + { + EncryptedXml.XmlEncAES256Url => Aes.Create(), + EncryptedXml.XmlEncAES192Url => Aes.Create(), + EncryptedXml.XmlEncAES128Url => Aes.Create(), + EncryptedXml.XmlEncTripleDESUrl => TripleDES.Create(), + _ => throw new CryptographicException($"Unsupported encryption algorithm: {algorithm}") + }; + + if (symmetricAlgorithm == null) + { + throw new CryptographicException("Failed to create symmetric algorithm"); + } + + using (symmetricAlgorithm) + { + symmetricAlgorithm.Key = sessionKey; + + // Decrypt the data + decryptedBytes = encryptedXml.DecryptData(encryptedData, symmetricAlgorithm); + } + + // Convert to string and parse + var decryptedXml = System.Text.Encoding.UTF8.GetString(decryptedBytes); + return System.Xml.Linq.XElement.Parse(decryptedXml); + } + + public static async Task ExtractAndDecryptSamlSuccessFromPostAsync( + HttpResponseMessage response, + X509Certificate2 decryptionCertificate, + CancellationToken cancellationToken) + { + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, cancellationToken); + var (samlpNs, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var baseData = ParseCommonResponseElements(responseElement, samlNs, samlpNs, relayState, acsUrl); + + // Get the EncryptedAssertion element + var encryptedAssertion = responseElement.Element(samlNs + "EncryptedAssertion"); + if (encryptedAssertion == null) + { + throw new InvalidOperationException("Response does not contain an EncryptedAssertion element"); + } + + // Decrypt it - this returns the Assertion element + var decryptedAssertion = DecryptAssertion(encryptedAssertion, decryptionCertificate); + + // Create a temporary container to hold the decrypted assertion for parsing + var tempResponse = new System.Xml.Linq.XElement(samlpNs + "Response", + new System.Xml.Linq.XAttribute("ID", "_temp"), + new System.Xml.Linq.XAttribute("Version", "2.0"), + decryptedAssertion); + + // Parse the decrypted assertion + var assertion = ParseAssertion(tempResponse, samlNs); + + return new SamlSuccessResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl, + Assertion = assertion + }; + } + + public record SamlLogoutResponseData : SamlResponseBase + { + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs new file mode 100644 index 000000000..70e0445f0 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs @@ -0,0 +1,165 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +// NOTE: This file requires the Sustainsys.Saml2.AspNetCore2 package to be added to the project. +// Add this to IdentityServer.IntegrationTests.csproj: +// + +#nullable enable + +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Sustainsys.Saml2.AspNetCore2; +using Xunit.Abstractions; +using IdentityProvider = Sustainsys.Saml2.IdentityProvider; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal class SustainSysSamlTestFixture(ITestOutputHelper output) : IAsyncLifetime +{ + public TestFramework.GenericHost? Host = null!; + public HttpClient? BrowserClient = null!; + public X509Certificate2? SigningCertificate { get; private set; } + + public Uri IdentityProviderLoginUri => new Uri(new Uri(_samlFixture.Host!.Url()), _samlFixture.LoginUrl); + + private readonly SamlFixture _samlFixture = new(output); + private bool _shouldGenerateSigningCertificate; + private bool _shouldRequireEncryptedAssertions; + + public async Task LoginUserAtIdentityProvider() + { + _samlFixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id"), new Claim("name", "Test User"), new Claim(JwtClaimTypes.AuthenticationMethod, "urn:oasis:names:tc:SAML:2.0:ac:classes:Password")], "Test")); + await BrowserClient!.GetAsync($"{_samlFixture.Host!.Url()}/__signin", CancellationToken.None); + } + + public void GenerateSigningCertificate() => + // We cannot create this now because we need to defer creating it until after we've set + // the fake time provider because the SustainSys library does not rely on TimeProvider + // and we need to use current times + _shouldGenerateSigningCertificate = true; + + public void RequireEncryptedAssertions() => _shouldRequireEncryptedAssertions = true; + + public async Task InitializeAsync() + { + // need to use current time because the SustainSys library does not rely on an abstraction such + // as TimeProvider and times need to be current + _samlFixture.Data.FakeTimeProvider = new FakeTimeProvider(DateTime.UtcNow); + + // Generate certificates before initialization if needed + X509Certificate2? signingCertificate = null; + X509Certificate2? publicCertificate = null; + if (_shouldGenerateSigningCertificate) + { + signingCertificate = SamlTestHelpers.CreateTestSigningCertificate(_samlFixture.Data.FakeTimeProvider); + SigningCertificate = signingCertificate; // Expose for tests + publicCertificate = X509CertificateLoader.LoadCertificate(signingCertificate.Export(X509ContentType.Cert)); + } + + // Initialize the SAML fixture first so we can get the IDP URI + await _samlFixture.InitializeAsync(); + + // Now initialize the service provider host with the correct IDP URI + await InitializeServiceProvider(_samlFixture.Host!.Url(), signingCertificate); + + // Configure the service provider with the actual host URI and add it to the SAML fixture + var serviceProvider = new SamlServiceProvider + { + EntityId = "https://localhost:5001/Saml2", + DisplayName = "Test Service Provider", + Enabled = true, + AssertionConsumerServiceUrls = [new Uri($"{Host!.Url()}/Saml2/Acs")], + SigningBehavior = SamlSigningBehavior.SignAssertion, + RequireSignedAuthnRequests = publicCertificate != null, + SigningCertificates = publicCertificate == null ? null : new[] { publicCertificate }, + EncryptionCertificates = publicCertificate == null ? null : new[] { publicCertificate }, + EncryptAssertions = _shouldRequireEncryptedAssertions + }; + + // Note: With InMemorySamlServiceProviderStore, we cannot add SPs after initialization + // So we need to add it to the fixture before initialization + // This is a known limitation of the current implementation + // For now, we'll add it to the _samlFixture.ServiceProviders list before it was initialized + // But since we already initialized it, we need to work around this + // The best approach is to initialize both fixtures together, but that requires refactoring + // For now, we'll just note this limitation + _samlFixture.ServiceProviders.Add(serviceProvider); + } + + private async Task InitializeServiceProvider(string identityProviderHostUri, X509Certificate2? signingCertificate = null) + { + Host = new TestFramework.GenericHost(identityProviderHostUri.Replace("https://server", "https://sp-server")); + Host.OnConfigureServices += services => + { + services.AddAuthentication(opt => + { + opt.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + opt.DefaultChallengeScheme = Saml2Defaults.Scheme; + }) + .AddCookie() + .AddSaml2(opt => + { + opt.SPOptions.EntityId = new Sustainsys.Saml2.Metadata.EntityId("https://localhost:5001/Saml2"); + opt.SPOptions.WantAssertionsSigned = false; + if (signingCertificate != null) + { + opt.SPOptions.ServiceCertificates.Add(signingCertificate); + } + + opt.IdentityProviders.Add( + new IdentityProvider(new Sustainsys.Saml2.Metadata.EntityId(identityProviderHostUri), opt.SPOptions) + { + LoadMetadata = true, + MetadataLocation = $"{identityProviderHostUri}/saml/metadata", + SingleSignOnServiceUrl = new Uri($"{identityProviderHostUri}/saml/signin"), + WantAuthnRequestsSigned = signingCertificate != null + }); + }); + services.AddAuthorization(); + }; + + Host.OnConfigure += app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapGet("/protected-resource", () => "Protected Resource").RequireAuthorization(); + + app.MapGet("/user-name-identifier", async context => + { + var userId = context.User.FindFirst(ClaimTypes.NameIdentifier); + if (userId == null || string.IsNullOrWhiteSpace(userId.Value)) + { + throw new InvalidOperationException("No name identifier claim found for user or claim had no value."); + } + + await context.Response.WriteAsync(userId.Value, context.RequestAborted); + }).RequireAuthorization(); + }; + + await Host.InitializeAsync(); + + BrowserClient = Host.HttpClient; + } + + public async Task DisposeAsync() + { + if (Host != null) + { + // GenericHost doesn't implement IAsyncDisposable + await Task.CompletedTask; + } + + await _samlFixture.DisposeAsync(); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs new file mode 100644 index 000000000..2d4280983 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using System.Web; +using Xunit.Abstractions; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SustainSysSigninTests(ITestOutputHelper output) +{ + private const string Category = "SustainSys SAML signin"; + + private SustainSysSamlTestFixture Fixture = new(output); + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_saml_signin_request() + { + // Arrange + await Fixture.InitializeAsync(); + + await Fixture.LoginUserAtIdentityProvider(); + + // Act + var result = await Fixture.BrowserClient!.GetAsync("/protected-resource"); + + // Assert + var acsResult = await ManuallySubmitSamlFormResponse(result); + + // completing the flow should result in receiving a response from the initial protected resource request + acsResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var acsResponse = await acsResult.Content.ReadAsStringAsync(); + acsResponse.ShouldBe("Protected Resource"); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_signed_saml_signin_request() + { + // Arrange + Fixture.GenerateSigningCertificate(); + await Fixture.InitializeAsync(); + + await Fixture.LoginUserAtIdentityProvider(); + + // Act + var result = await Fixture.BrowserClient!.GetAsync("/protected-resource"); + + // Assert + var acsResult = await ManuallySubmitSamlFormResponse(result); + + // completing the flow should result in receiving a response from the initial protected resource request + acsResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var acsResponse = await acsResult.Content.ReadAsStringAsync(); + acsResponse.ShouldBe("Protected Resource"); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_signin_request_for_encrypted_assertions() + { + // Arrange + Fixture.GenerateSigningCertificate(); + Fixture.RequireEncryptedAssertions(); + await Fixture.InitializeAsync(); + + await Fixture.LoginUserAtIdentityProvider(); + + // Act + var result = await Fixture.BrowserClient!.GetAsync("/protected-resource"); + + // Assert + var acsResult = await ManuallySubmitSamlFormResponse(result); + + // completing the flow should result in receiving a response from the initial protected resource request + acsResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var acsResponse = await acsResult.Content.ReadAsStringAsync(); + acsResponse.ShouldBe("Protected Resource"); + + // verify subject id was also parsed correctly after decrypting assertions + var userInfo = await Fixture.BrowserClient!.GetAsync("/user-name-identifier"); + var userInfoResponse = await userInfo.Content.ReadAsStringAsync(); + userInfoResponse.ShouldBe("user-id"); + } + + private async Task ManuallySubmitSamlFormResponse(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + // since HttpClient doesn't support JavaScript, we need to extra the content from the auto form post and manually + // complete the callback to the Service Provider's ACS URL the same way a user in a browser with JavaScript disabled + // would have to manually submit the form + var (samlResponse, relayState, acsUrl) = await ExtractSamlResponse(response, CancellationToken.None); + var formData = new Dictionary { { "SAMLResponse", ConvertToBase64Encoded(samlResponse) } }; + if (!string.IsNullOrEmpty(relayState)) + { + formData.Add("RelayState", HttpUtility.UrlEncode(relayState)); + } + using var formContent = new FormUrlEncodedContent(formData); + var acsResult = await Fixture.BrowserClient!.PostAsync(acsUrl, formContent, CancellationToken.None); + + return acsResult; + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj b/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj index a4c1d02c6..321b0860b 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj +++ b/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj @@ -15,6 +15,7 @@ + diff --git a/identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs new file mode 100644 index 000000000..2c31daf75 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityServer.Internal.Saml.Infrastructure; + +namespace UnitTests.Common; + +/// +/// Mock implementation of for testing. +/// +internal class MockSamlSigningService : ISamlSigningService +{ + private readonly X509Certificate2 _certificate; + + public MockSamlSigningService(X509Certificate2 certificate) => _certificate = certificate; + + public Task GetSigningCertificateAsync() => Task.FromResult(_certificate); + + public Task GetSigningCertificateBase64Async() + { + var certBytes = _certificate.Export(X509ContentType.Cert); + return Task.FromResult(Convert.ToBase64String(certBytes)); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs b/identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs new file mode 100644 index 000000000..1162f198f --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; + +namespace UnitTests.Common; + +/// +/// Test implementation of ISamlClaimsMapper that returns a single custom attribute. +/// Used by both unit and integration tests. +/// +public class TestSamlClaimsMapper : ISamlClaimsMapper +{ + public Task> MapClaimsAsync(SamlClaimsMappingContext mappingContext) + { + var attributes = new List + { + new() + { + Name = "CUSTOM_MAPPED", + NameFormat = "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + Values = new List { "custom_value" } + } + }; + return Task.FromResult>(attributes); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs new file mode 100644 index 000000000..9c4db4b92 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs @@ -0,0 +1,214 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Authentication; + +namespace UnitTests.Extensions; + +public class AuthenticationPropertiesExtensionsTests +{ + private const string Category = "AuthenticationPropertiesExtensions"; + + [Fact] + [Trait("Category", Category)] + public void get_saml_session_list_when_no_sessions_should_return_empty() + { + var properties = new AuthenticationProperties(); + + var sessions = properties.GetSamlSessionList(); + + sessions.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public void add_saml_session_when_new_session_should_add_to_list() + { + var properties = new AuthenticationProperties(); + var session = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp1.example.com"); + sessions[0].SessionIndex.ShouldBe("abc123"); + sessions[0].NameId.ShouldBe("user@example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void add_saml_session_when_multiple_sessions_should_add_all_to_list() + { + var properties = new AuthenticationProperties(); + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + var session2 = new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "def456", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session1); + properties.AddSamlSession(session2); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.Count.ShouldBe(2); + sessions.ShouldContain(s => s.EntityId == "https://sp1.example.com"); + sessions.ShouldContain(s => s.EntityId == "https://sp2.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void add_saml_session_when_duplicate_entity_id_should_update_session() + { + var properties = new AuthenticationProperties(); + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + var session2 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", // Same EntityId + SessionIndex = "abc123", // Same SessionIndex (reused) + NameId = "updated@example.com", // Updated NameId + NameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + }; + + properties.AddSamlSession(session1); + properties.AddSamlSession(session2); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp1.example.com"); + sessions[0].SessionIndex.ShouldBe("abc123"); + sessions[0].NameId.ShouldBe("updated@example.com"); + sessions[0].NameIdFormat.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + } + + [Fact] + [Trait("Category", Category)] + public void remove_saml_session_when_session_exists_should_remove_it() + { + var properties = new AuthenticationProperties(); + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + var session2 = new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "def456", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session1); + properties.AddSamlSession(session2); + properties.RemoveSamlSession("https://sp1.example.com"); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp2.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void remove_saml_session_when_session_does_not_exist_should_do_nothing() + { + var properties = new AuthenticationProperties(); + var session = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session); + properties.RemoveSamlSession("https://sp2.example.com"); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp1.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void saml_session_data_serialization_roundtrip_should_preserve_data() + { + var properties = new AuthenticationProperties(); + var originalSession = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123def456", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(originalSession); + + // Retrieve the session + var retrievedSessions = properties.GetSamlSessionList().ToList(); + + retrievedSessions.ShouldHaveSingleItem(); + var retrievedSession = retrievedSessions[0]; + retrievedSession.EntityId.ShouldBe(originalSession.EntityId); + retrievedSession.SessionIndex.ShouldBe(originalSession.SessionIndex); + retrievedSession.NameId.ShouldBe(originalSession.NameId); + retrievedSession.NameIdFormat.ShouldBe(originalSession.NameIdFormat); + } + + [Fact] + [Trait("Category", Category)] + public void set_saml_session_list_when_empty_list_should_remove_key() + { + var properties = new AuthenticationProperties(); + var session = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session); + properties.SetSamlSessionList(Array.Empty()); + + properties.Items.ContainsKey("saml_session_list").ShouldBeFalse(); + properties.GetSamlSessionList().ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public void session_index_generation_should_be_unique() + { + var sessionIndex1 = Guid.NewGuid().ToString("N"); + var sessionIndex2 = Guid.NewGuid().ToString("N"); + + sessionIndex1.ShouldNotBe(sessionIndex2); + sessionIndex1.Length.ShouldBe(32); // GUID without hyphens + sessionIndex2.Length.ShouldBe(32); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs b/identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs new file mode 100644 index 000000000..862fe3ca8 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml.Models; + +namespace UnitTests.Models; + +public class SamlSpSessionDataTests +{ + private const string Category = "SamlSpSessionData"; + + [Fact] + [Trait("Category", Category)] + public void equals_should_return_true_for_same_entity_id_and_session_index() + { + var session1 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + var session2 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session123", + NameId = "different@example.com", // Different NameId shouldn't matter + NameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" // Different format shouldn't matter + }; + + session1.Equals(session2).ShouldBeTrue(); + session1.GetHashCode().ShouldBe(session2.GetHashCode()); + } + + [Fact] + [Trait("Category", Category)] + public void equals_should_return_false_for_different_entity_id() + { + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "session123", + NameId = "user@example.com" + }; + + var session2 = new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "session123", + NameId = "user@example.com" + }; + + session1.Equals(session2).ShouldBeFalse(); + } + + [Fact] + [Trait("Category", Category)] + public void equals_should_return_false_for_different_session_index() + { + var session1 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session123", + NameId = "user@example.com" + }; + + var session2 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session456", + NameId = "user@example.com" + }; + + session1.Equals(session2).ShouldBeFalse(); + } + + [Fact] + [Trait("Category", Category)] + public void union_should_deduplicate_sessions() + { + var list1 = new List + { + new() + { + EntityId = "https://sp1.example.com", + SessionIndex = "session1", + NameId = "user@example.com" + }, + new() + { + EntityId = "https://sp2.example.com", + SessionIndex = "session2", + NameId = "user@example.com" + } + }; + + var list2 = new List + { + new() + { + EntityId = "https://sp1.example.com", + SessionIndex = "session1", + NameId = "different@example.com" // Duplicate session (different NameId shouldn't matter) + }, + new() + { + EntityId = "https://sp3.example.com", + SessionIndex = "session3", + NameId = "user@example.com" + } + }; + + var result = list1.Union(list2).ToList(); + + // Should have 3 unique sessions (sp1/session1, sp2/session2, sp3/session3) + result.Count.ShouldBe(3); + result.Count(s => s.EntityId == "https://sp1.example.com").ShouldBe(1); + result.Count(s => s.EntityId == "https://sp2.example.com").ShouldBe(1); + result.Count(s => s.EntityId == "https://sp3.example.com").ShouldBe(1); + } + + [Fact] + [Trait("Category", Category)] + public void distinct_should_remove_duplicate_sessions() + { + var sessions = new List + { + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "user1@example.com" + }, + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "user2@example.com" // Duplicate (different NameId) + }, + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session2", + NameId = "user@example.com" + } + }; + + var result = sessions.Distinct().ToList(); + + result.Count.ShouldBe(2); + } + + [Fact] + [Trait("Category", Category)] + public void contains_should_work_correctly() + { + var sessions = new List + { + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "user@example.com" + } + }; + + var lookupSession = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "different@example.com" // Different NameId shouldn't matter + }; + + sessions.Contains(lookupSession).ShouldBeTrue(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs new file mode 100644 index 000000000..b338e7f22 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.Saml; + +/// +/// Unit tests for AuthNRequestParser, focusing on NameIDPolicy parsing +/// +public class AuthNRequestParserTests +{ + private const string Category = "SAML AuthN Request Parser"; + + private readonly AuthNRequestParser _parser = new(NullLogger.Instance); + + private static XDocument CreateAuthNRequest(string? nameIdPolicyXml = null) + { + var xml = $@" + + https://sp.example.com + {nameIdPolicyXml ?? ""} +"; + + return XDocument.Parse(xml); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_format_only_should_succeed() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + result.NameIdPolicy.SPNameQualifier.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_sp_name_qualifier_should_succeed() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.SPNameQualifier.ShouldBe("https://custom.sp.com"); + result.NameIdPolicy.Format.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_all_attributes_should_succeed() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:transient"); + result.NameIdPolicy.SPNameQualifier.ShouldBe("https://sp.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_without_name_id_policy_should_return_null() + { + // Arrange + var doc = CreateAuthNRequest(null); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_empty_name_id_policy_element_should_return_non_null() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBeNull(); + result.NameIdPolicy.SPNameQualifier.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_whitespace_in_format_should_trim() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_whitespace_in_sp_name_qualifier_should_trim() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.SPNameQualifier.ShouldBe("https://sp.example.com"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/InMemoryTestServiceProviderStore.cs b/identity-server/test/IdentityServer.UnitTests/Saml/InMemoryTestServiceProviderStore.cs new file mode 100644 index 000000000..a8d924cc8 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/InMemoryTestServiceProviderStore.cs @@ -0,0 +1,24 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; + +namespace UnitTests.Saml; + +/// +/// Simple in-memory implementation of ISamlServiceProviderStore for unit testing. +/// +internal class InMemoryTestServiceProviderStore : ISamlServiceProviderStore +{ + private readonly List _providers = []; + + public InMemoryTestServiceProviderStore() { } + public InMemoryTestServiceProviderStore(params SamlServiceProvider[] providers) => _providers.AddRange(providers); + + public void Add(SamlServiceProvider provider) => _providers.Add(provider); + + public Task FindByEntityIdAsync(string entityId) + => Task.FromResult(_providers.FirstOrDefault(p => p.EntityId == entityId)); +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs new file mode 100644 index 000000000..e7887a88c --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs @@ -0,0 +1,361 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text; +using Duende.IdentityServer.Internal.Saml.Infrastructure; + +namespace UnitTests.Saml; + +public class LimitedReadStreamTests +{ + private const string Category = "SAML Limited Read Stream"; + + [Fact] + [Trait("Category", Category)] + public void read_within_limit_should_succeed() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer = new byte[data.Length]; + + // Act + var bytesRead = limitedStream.Read(buffer, 0, buffer.Length); + + // Assert + bytesRead.ShouldBe(data.Length); + Encoding.UTF8.GetString(buffer).ShouldBe("Hello World"); + } + + [Fact] + [Trait("Category", Category)] + public void read_exactly_at_limit_should_succeed() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, data.Length); + var buffer = new byte[data.Length]; + + // Act + var bytesRead = limitedStream.Read(buffer, 0, buffer.Length); + + // Assert + bytesRead.ShouldBe(data.Length); + Encoding.UTF8.GetString(buffer).ShouldBe("Hello"); + } + + [Fact] + [Trait("Category", Category)] + public void read_exceeds_limit_should_throw_invalid_operation_exception() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 5); + var buffer = new byte[data.Length]; + + // Act - first read should succeed + limitedStream.ReadExactly(buffer, 0, 5); + + // Assert - second read should throw + var exception = Should.Throw(() => + limitedStream.Read(buffer, 0, buffer.Length)); + + exception.Message.ShouldBe("Maximum stream size exceeded."); + } + + [Fact] + [Trait("Category", Category)] + public void read_multiple_reads_within_limit_should_succeed() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer1 = new byte[5]; + var buffer2 = new byte[6]; + + // Act + var bytesRead1 = limitedStream.Read(buffer1, 0, buffer1.Length); + var bytesRead2 = limitedStream.Read(buffer2, 0, buffer2.Length); + + // Assert + bytesRead1.ShouldBe(5); + bytesRead2.ShouldBe(6); + Encoding.UTF8.GetString(buffer1).ShouldBe("Hello"); + Encoding.UTF8.GetString(buffer2).ShouldBe(" World"); + } + + [Fact] + [Trait("Category", Category)] + public void read_multiple_reads_exceeding_limit_should_throw_on_excess() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 7); + var buffer = new byte[5]; + + // Act & Assert + limitedStream.Read(buffer, 0, 5).ShouldBe(5); // 5 bytes read + limitedStream.Read(buffer, 0, 2).ShouldBe(2); // 7 bytes total (at limit) + + Should.Throw(() => + limitedStream.Read(buffer, 0, 1)); // Would exceed limit + } + + [Fact] + [Trait("Category", Category)] + public void read_empty_stream_should_return_zero() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer = new byte[10]; + + // Act + var bytesRead = limitedStream.Read(buffer, 0, buffer.Length); + + // Assert + bytesRead.ShouldBe(0); + } + + [Fact] + [Trait("Category", Category)] + public void read_with_zero_max_bytes_should_throw_immediately() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 0); + var buffer = new byte[5]; + + // Act & Assert + Should.Throw(() => + limitedStream.Read(buffer, 0, buffer.Length)); + } + + [Fact] + [Trait("Category", Category)] + public void can_read_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.CanRead.ShouldBe(innerStream.CanRead); + limitedStream.CanRead.ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public void can_seek_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.CanSeek.ShouldBe(innerStream.CanSeek); + limitedStream.CanSeek.ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public void can_write_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.CanWrite.ShouldBe(innerStream.CanWrite); + limitedStream.CanWrite.ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public void length_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.Length.ShouldBe(innerStream.Length); + limitedStream.Length.ShouldBe(data.Length); + } + + [Fact] + [Trait("Category", Category)] + public void position_get_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + var buffer = new byte[3]; + limitedStream.ReadExactly(buffer, 0, 3); + + // Assert + limitedStream.Position.ShouldBe(innerStream.Position); + limitedStream.Position.ShouldBe(3); + } + + [Fact] + [Trait("Category", Category)] + public void position_set_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + limitedStream.Position = 2; + + // Assert + limitedStream.Position.ShouldBe(2); + innerStream.Position.ShouldBe(2); + } + + [Fact] + [Trait("Category", Category)] + public void seek_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + var newPosition = limitedStream.Seek(6, SeekOrigin.Begin); + + // Assert + newPosition.ShouldBe(6); + limitedStream.Position.ShouldBe(6); + innerStream.Position.ShouldBe(6); + } + + [Fact] + [Trait("Category", Category)] + public void flush_should_delegate_to_inner_stream() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act & Assert - should not throw + Should.NotThrow(() => limitedStream.Flush()); + } + + [Fact] + [Trait("Category", Category)] + public void write_should_delegate_to_inner_stream() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var data = Encoding.UTF8.GetBytes("Hello"); + + // Act + limitedStream.Write(data, 0, data.Length); + + // Assert + innerStream.ToArray().ShouldBe(data); + } + + [Fact] + [Trait("Category", Category)] + public void set_length_should_delegate_to_inner_stream() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + limitedStream.SetLength(50); + + // Assert + limitedStream.Length.ShouldBe(50); + innerStream.Length.ShouldBe(50); + } + + [Fact] + [Trait("Category", Category)] + public void dispose_should_dispose_inner_stream() + { + // Arrange + var innerStream = new MemoryStream(); + var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + limitedStream.Dispose(); + + // Assert + Should.Throw(() => innerStream.ReadByte()); + } + + [Fact] + [Trait("Category", Category)] + public async Task dispose_async_should_dispose_inner_stream() + { + // Arrange + var innerStream = new MemoryStream(); + var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + await limitedStream.DisposeAsync(); + + // Assert + Should.Throw(() => innerStream.ReadByte()); + } + + [Fact] + [Trait("Category", Category)] + public void read_limits_read_size_when_requested_count_exceeds_limit() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 5); + var buffer = new byte[100]; + + // Act - request 100 bytes but limit is 5 + var bytesRead = limitedStream.Read(buffer, 0, 100); + + // Assert + bytesRead.ShouldBe(5); + Encoding.UTF8.GetString(buffer, 0, bytesRead).ShouldBe("Hello"); + } + + [Fact] + [Trait("Category", Category)] + public void read_with_offset_should_write_to_correct_position() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer = new byte[10]; + + // Act + var bytesRead = limitedStream.Read(buffer, 3, 5); + + // Assert + bytesRead.ShouldBe(5); + buffer[0].ShouldBe((byte)0); + buffer[1].ShouldBe((byte)0); + buffer[2].ShouldBe((byte)0); + Encoding.UTF8.GetString(buffer, 3, bytesRead).ShouldBe("Hello"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs new file mode 100644 index 000000000..95c8494b7 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.Saml; + +public class LogoutRequestParserTests +{ + private const string Category = "Logout Request Parser"; + + private readonly LogoutRequestParser _parser = new(NullLogger.Instance); + + private static string CreateLogoutRequest( + string? id = null, + string? issuer = null, + string? destination = null, + string? nameId = null, + string? sessionIndex = null) + { + id ??= "_test-logout-id"; + issuer ??= "https://sp.example.com"; + destination ??= "https://idp.example.com/saml/logout"; + nameId ??= "user@example.com"; + sessionIndex ??= "_session123"; + + return $@" + + {issuer} + {nameId} + {sessionIndex} +"; + } + + [Fact] + [Trait("Category", Category)] + public void parse_valid_logout_request_returns_success() + { + var xmlString = CreateLogoutRequest(); + var doc = XDocument.Parse(xmlString); + + var result = _parser.Parse(doc); + + result.ShouldNotBeNull(); + result.Id.Value.ShouldBe("_test-logout-id"); + result.Issuer.ShouldBe("https://sp.example.com"); + result.Destination!.ToString().ShouldBe("https://idp.example.com/saml/logout"); + result.NameId.Value.ShouldBe("user@example.com"); + result.SessionIndex.ShouldBe("_session123"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_id_throws_exception() + { + var xml = @" + + https://sp.example.com +"; + var doc = XDocument.Parse(xml); + + Should.Throw(() => _parser.Parse(doc)) + .Message.ShouldContain("ID"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_issuer_throws_exception() + { + var xml = @" + +"; + var doc = XDocument.Parse(xml); + + Should.Throw(() => _parser.Parse(doc)) + .Message.ShouldContain("Issuer"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_invalid_xml_throws_exception() + { + var xml = ""; + var doc = XDocument.Parse(xml); + + Should.Throw(() => _parser.Parse(doc)); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_destination_still_succeeds() + { + var xml = @" + + https://sp.example.com + user@example.com + _session123 +"; + var doc = XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.ShouldNotBeNull(); + result.Destination.ShouldBeNull(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs new file mode 100644 index 000000000..a4b88c9d5 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs @@ -0,0 +1,217 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.Saml; + +public class RequestedAuthnContextParsingTests +{ + private const string Category = "Requested AuthN Context Parsing"; + + private readonly AuthNRequestParser _parser = new(NullLogger.Instance); + + private const string BaseAuthNRequest = """ + + https://sp.example.com + {0} + + """; + + [Fact] + [Trait("Category", Category)] + public void parse_single_authn_context_class_ref_succeeds() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(1); + result.RequestedAuthnContext.AuthnContextClassRefs.First().ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + result.RequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Exact); + } + + [Fact] + [Trait("Category", Category)] + public void parse_multiple_authn_context_class_ref_succeeds() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + urn:oasis:names:tc:SAML:2.0:ac:classes:X509 + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(3); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:X509"); + result.RequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Minimum); + } + + [Theory] + [InlineData("exact", AuthnContextComparison.Exact)] + [InlineData("minimum", AuthnContextComparison.Minimum)] + [InlineData("maximum", AuthnContextComparison.Maximum)] + [InlineData("better", AuthnContextComparison.Better)] + [InlineData("EXACT", AuthnContextComparison.Exact)] + [InlineData("MINIMUM", AuthnContextComparison.Minimum)] + [InlineData("Exact", AuthnContextComparison.Exact)] + [Trait("Category", Category)] + public void parse_comparison_attribute_succeeds(string comparisonValue, AuthnContextComparison expected) + { + var requestedAuthnContext = $""" + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.Comparison.ShouldBe(expected); + } + + [Fact] + [Trait("Category", Category)] + public void parse_omitted_comparison_defaults_to_exact() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Exact); + } + + [Fact] + [Trait("Category", Category)] + public void parse_invalid_comparison_throws() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = Should.Throw(() => _parser.Parse(doc)); + + result.Message.ShouldBe("Unknown AuthnContextComparison: invalid"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_no_authn_context_class_ref_throws() + { + var requestedAuthnContext = """ + + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = Should.Throw(() => _parser.Parse(doc)); + + result.Message.ShouldBe("No AuthnContextClassRef element found in requestedAuthnContext"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_requested_authn_context_returns_null() + { + var xml = string.Format(BaseAuthNRequest, ""); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_empty_authn_context_class_ref_skipped() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + urn:oasis:names:tc:SAML:2.0:ac:classes:X509 + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(2); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:X509"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_whitespace_authn_context_class_ref_trimmed() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.First().ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_only_empty_authn_context_class_ref_throws() + { + var requestedAuthnContext = """ + + + + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = Should.Throw(() => _parser.Parse(doc)); + + result.Message.ShouldBe("No AuthnContextClassRef element found in requestedAuthnContext"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs new file mode 100644 index 000000000..40a07ac1c --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs @@ -0,0 +1,316 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace UnitTests.Saml; + +public class SamlAssertionEncryptorTests +{ + private const string Category = "SAML Assertion Encryptor"; + + private static readonly XNamespace SamlNs = "urn:oasis:names:tc:SAML:2.0:assertion"; + private static readonly XNamespace SamlpNs = "urn:oasis:names:tc:SAML:2.0:protocol"; + private static readonly XNamespace EncNs = "http://www.w3.org/2001/04/xmlenc#"; + + private readonly SamlAssertionEncryptor _encryptor = new(new FakeTimeProvider(DateTimeOffset.UtcNow), NullLogger.Instance); + + private static X509Certificate2 CreateTestEncryptionCertificate(DateTimeOffset? notBefore = null, DateTimeOffset? notAfter = null, int? keySize = 2048) + { + using var rsa = RSA.Create(keySize!.Value); + 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( + notBefore ?? DateTimeOffset.UtcNow.AddDays(-1), + notAfter ?? DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + private static XElement CreateTestResponse() => new(SamlpNs + "Response", + new XAttribute("ID", "_" + Guid.NewGuid().ToString("N")), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", DateTime.UtcNow.ToString("o")), + new XElement(SamlNs + "Issuer", "https://idp.example.com"), + new XElement(SamlpNs + "Status", + new XElement(SamlpNs + "StatusCode", + new XAttribute("Value", "urn:oasis:names:tc:SAML:2.0:status:Success"))), + new XElement(SamlNs + "Assertion", + new XAttribute("ID", "_" + Guid.NewGuid().ToString("N")), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", DateTime.UtcNow.ToString("o")), + new XElement(SamlNs + "Issuer", "https://idp.example.com"), + new XElement(SamlNs + "Subject", + new XElement(SamlNs + "NameID", "user@example.com")), + new XElement(SamlNs + "AttributeStatement", + new XElement(SamlNs + "Attribute", + new XAttribute("Name", "email"), + new XElement(SamlNs + "AttributeValue", "user@example.com"))))); + + [Fact] + [Trait("Category", Category)] + public void no_encryption_certificates_configured_for_service_provider_should_throw() + { + // Arrange + var response = CreateTestResponse(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")] + // No EncryptionCertificates configured + }; + + var originalXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var result = Should.Throw(() => _encryptor.EncryptAssertion(originalXml, sp)); + + result.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void valid_certificate_should_encrypt_assertion() + { + // Arrange + var response = CreateTestResponse(); + var cert = CreateTestEncryptionCertificate(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + var originalXml = response.ToString(SaveOptions.DisableFormatting); + + // Act + var resultXml = _encryptor.EncryptAssertion(originalXml, sp); + + // Assert + resultXml.ShouldNotBeNull(); + + var result = XElement.Parse(resultXml); + + // Verify plain assertion removed + var plainAssertion = result.Element(SamlNs + "Assertion"); + plainAssertion.ShouldBeNull("Plain assertion should be removed after encryption"); + + // Verify encrypted assertion added + var encryptedAssertion = result.Element(SamlNs + "EncryptedAssertion"); + encryptedAssertion.ShouldNotBeNull("Encrypted assertion should be present"); + + // Verify structure (EncryptedKey is inside KeyInfo) + var encryptedData = encryptedAssertion.Element(EncNs + "EncryptedData"); + encryptedData.ShouldNotBeNull(); + + var dsNs = XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var keyInfo = encryptedData.Element(dsNs + "KeyInfo"); + keyInfo.ShouldNotBeNull(); + + var encryptedKey = keyInfo.Element(EncNs + "EncryptedKey"); + encryptedKey.ShouldNotBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void expired_certificate_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + var expiredCert = CreateTestEncryptionCertificate(DateTimeOffset.UtcNow.AddDays(-365), DateTimeOffset.UtcNow.AddDays(-1)); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [expiredCert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void not_yet_valid_certificate_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + var notYetValidCert = CreateTestEncryptionCertificate(DateTimeOffset.UtcNow.AddDays(1), DateTimeOffset.UtcNow.AddDays(5)); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [notYetValidCert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void no_rsa_public_key_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + + // Create certificate without RSA key (EC key instead) + using var ecdsa = ECDsa.Create(); + var request = new CertificateRequest(new X500DistinguishedName("CN=Test EC Certificate"), ecdsa, HashAlgorithmName.SHA256); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => + _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void insufficient_key_size_in_certificate_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + + // Create certificate with RSA key with too small of key size + var certWithInsufficientKeySizeInCertificate = CreateTestEncryptionCertificate(keySize: 1024); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [certWithInsufficientKeySizeInCertificate] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => + _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void no_assertion_should_throw_exception() + { + // Arrange - Response without assertion + var response = new XElement(SamlpNs + "Response", + new XAttribute("ID", "_" + Guid.NewGuid().ToString("N")), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", DateTime.UtcNow.ToString("o")), + new XElement(SamlNs + "Issuer", "https://idp.example.com"), + new XElement(SamlpNs + "Status", + new XElement(SamlpNs + "StatusCode", + new XAttribute("Value", "urn:oasis:names:tc:SAML:2.0:status:Success")))); + // No Assertion element + + var cert = CreateTestEncryptionCertificate(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"SAML Response does not contain an Assertion element for {sp.EntityId}"); + } + + [Fact] + [Trait("Category", Category)] + public void valid_input_should_replace_assertion() + { + // Arrange + var response = CreateTestResponse(); + var cert = CreateTestEncryptionCertificate(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + // Capture original response structure + var originalResponseId = response.Attribute("ID")?.Value; + var originalIssuer = response.Element(SamlNs + "Issuer")?.Value; + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act + var resultXml = _encryptor.EncryptAssertion(responseXml, sp); + + // Assert - Response structure preserved + var result = XElement.Parse(resultXml); + result.Name.ShouldBe(SamlpNs + "Response"); + result.Attribute("ID")?.Value.ShouldBe(originalResponseId); + result.Element(SamlNs + "Issuer")?.Value.ShouldBe(originalIssuer); + + var status = result.Element(SamlpNs + "Status"); + status.ShouldNotBeNull(); + status.Element(SamlpNs + "StatusCode")?.Attribute("Value")?.Value + .ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + + // Assert - Only assertion changed + var children = result.Elements().ToList(); + children.Count.ShouldBe(3); // Issuer, Status, EncryptedAssertion (was Assertion) + + // Issuer should be first + children[0].Name.ShouldBe(SamlNs + "Issuer"); + + // Status should be second + children[1].Name.ShouldBe(SamlpNs + "Status"); + + // EncryptedAssertion should be third (replaced Assertion) + children[2].Name.ShouldBe(SamlNs + "EncryptedAssertion"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs new file mode 100644 index 000000000..f5878ef61 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs @@ -0,0 +1,368 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Collections.ObjectModel; +using System.Security.Claims; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using UnitTests.Common; + +namespace UnitTests.Saml; + +public class SamlClaimsServiceTests +{ + private const string Category = "SAML Claims Service"; + + private readonly SamlOptions _samlOptions; + private readonly IOptions _options; + private readonly MockProfileService _profileService; + private readonly SamlClaimsService _service; + + public SamlClaimsServiceTests() + { + _samlOptions = new SamlOptions(); + _options = Options.Create(_samlOptions); + _profileService = new MockProfileService(); + _service = new SamlClaimsService(_profileService, NullLogger.Instance, _options); + } + + [Fact] + [Trait("Category", Category)] + public async Task default_mappings_should_map_common_oidc_claims() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("name", "John Doe"), + new Claim("email", "test@example.com"), + new Claim("role", "Admin") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(3); + + var nameAttr = attributes.First(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"); + nameAttr.Values.Count.ShouldBe(1); + nameAttr.Values[0].ShouldBe("John Doe"); + + var emailAttr = attributes.First(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"); + emailAttr.Values.Count.ShouldBe(1); + emailAttr.Values[0].ShouldBe("test@example.com"); + + var roleAttr = attributes.First(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/role"); + roleAttr.Values.Count.ShouldBe(1); + roleAttr.Values[0].ShouldBe("Admin"); + } + + [Fact] + [Trait("Category", Category)] + public async Task claim_types_constants_should_map_correctly() + { + // Arrange - use custom OID mappings for this test + var customMappings = new Dictionary + { + [ClaimTypes.NameIdentifier] = "urn:oid:0.9.2342.19200300.100.1.1", + [ClaimTypes.Email] = "urn:oid:0.9.2342.19200300.100.1.3", + [ClaimTypes.GivenName] = "urn:oid:2.5.4.42", + [ClaimTypes.Surname] = "urn:oid:2.5.4.4" + }; + var optionsWithOidMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(customMappings) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithOidMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user123"), + new Claim(ClaimTypes.Email, "user@example.com"), + new Claim(ClaimTypes.GivenName, "Jane"), + new Claim(ClaimTypes.Surname, "Smith") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(4); + attributes.ShouldAllBe(a => a.Name.StartsWith("urn:oid:")); + } + + [Fact] + [Trait("Category", Category)] + public async Task cleared_default_mappings_should_exclude_unmapped_claims() + { + // Arrange + var optionsWithNoMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(new Dictionary()) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithNoMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("custom_claim", "custom_value") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(0); // No mappings, so no attributes + } + + [Fact] + [Trait("Category", Category)] + public async Task custom_global_mappings_should_apply_custom_mappings() + { + // Arrange + var customMappings = new Dictionary + { + ["email"] = "emailAddress", + ["department"] = "ou" + }; + var optionsWithCustomMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(customMappings) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithCustomMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("department", "Engineering"), + new Claim("unmapped", "value") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(2); // Only email and department are mapped; sub and unmapped are excluded + attributes.ShouldContain(a => a.Name == "emailAddress"); + attributes.ShouldContain(a => a.Name == "ou"); + attributes.ShouldNotContain(a => a.Name == "sub"); + attributes.ShouldNotContain(a => a.Name == "unmapped"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_mappings_should_override_global() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("department", "Engineering") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") }, + ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + ["email"] = "mail", // Override default OID mapping + ["department"] = "businessUnit" + }) + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(2); // email and department from SP mappings; sub not mapped + attributes.ShouldContain(a => a.Name == "mail" && a.Values[0] == "test@example.com"); + attributes.ShouldContain(a => a.Name == "businessUnit" && a.Values[0] == "Engineering"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_mappings_should_fall_back_to_global_for_unmapped() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("given_name", "John") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") }, + ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + ["email"] = "mail" // Override only email + }) + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(1); // Only email is mapped (overridden by SP); sub and given_name are not in defaults + attributes.ShouldContain(a => a.Name == "mail"); + } + + [Fact] + [Trait("Category", Category)] + public async Task multi_valued_claims_should_group_into_single_attribute() + { + // Arrange + var customMappings = new Dictionary + { + ["sub"] = "urn:oid:0.9.2342.19200300.100.1.1", + ["role"] = "role" // Map role to itself for this test + }; + var optionsWithCustomMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(customMappings) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithCustomMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("role", "Admin"), + new Claim("role", "User"), + new Claim("role", "Developer") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(2); // sub + role (multi-valued) + + var roleAttr = attributes.First(a => a.Name == "role"); + roleAttr.Values.Count.ShouldBe(3); + roleAttr.Values.ShouldContain("Admin"); + roleAttr.Values.ShouldContain("User"); + roleAttr.Values.ShouldContain("Developer"); + + attributes.ShouldContain(a => a.Name == "urn:oid:0.9.2342.19200300.100.1.1"); // sub mapped to OID + } + + [Fact] + [Trait("Category", Category)] + public async Task custom_mapper_should_use_custom_mapper() + { + // Arrange + var customMapper = new TestSamlClaimsMapper(); + var service = new SamlClaimsService(_profileService, NullLogger.Instance, _options, customMapper); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.Count.ShouldBe(1); + attributes.First().Name.ShouldBe("CUSTOM_MAPPED"); + attributes.First().Values.Count.ShouldBe(1); + attributes.First().Values[0].ShouldBe("custom_value"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_attribute_name_format() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp)).ToList(); + + // Assert + attributes.ShouldAllBe(a => a.NameFormat == _samlOptions.DefaultAttributeNameFormat); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs new file mode 100644 index 000000000..43318c9cc --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs @@ -0,0 +1,386 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Globalization; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using UnitTests.Common; + +namespace UnitTests.Saml; + +public class SamlFrontChannelLogoutRequestBuilderTests +{ + private const string Category = "SAML Front Channel Logout Request Builder"; + + private readonly FakeTimeProvider _timeProvider; + private readonly SamlProtocolMessageSigner _signer; + private readonly SamlFrontChannelLogoutRequestBuilder _subject; + + public SamlFrontChannelLogoutRequestBuilderTests() + { + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 5, 12, 0, 0, TimeSpan.Zero)); + _signer = CreateSigner(); + _subject = new SamlFrontChannelLogoutRequestBuilder(_timeProvider, _signer); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_with_no_single_logout_url_should_throw_exception() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = null; + + await Should.ThrowAsync(async () => + await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com") + ); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_redirect_binding_should_return_redirect_logout() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com"); + + result.SamlBinding.ShouldBe(SamlBinding.HttpRedirect); + result.Destination.ShouldBe(sp.SingleLogoutServiceUrl!.Location); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_post_binding_should_return_post_logout() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpPost, + Location = new Uri("https://sp.example.com/slo") + }; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com"); + + result.SamlBinding.ShouldBe(SamlBinding.HttpPost); + } + + [Fact] + [Trait("Category", Category)] + public async Task unsupported_binding_should_throw_exception() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = (SamlBinding)999, // Unsupported binding + Location = new Uri("https://sp.example.com/slo") + }; + + await Should.ThrowAsync(async () => + await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com") + ); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_redirect_should_encode_and_compress_request() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com"); + + result.EncodedContent.ShouldNotBeNullOrEmpty(); + result.EncodedContent.ShouldContain("SAMLRequest="); + result.EncodedContent.ShouldContain("&SigAlg="); + result.EncodedContent.ShouldContain("&Signature="); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_redirect_should_be_decodable_and_decompressible() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com"); + + var queryString = result.EncodedContent; + var samlRequestPart = queryString.Split('&')[0].Replace("?SAMLRequest=", ""); + var decodedBytes = Convert.FromBase64String(Uri.UnescapeDataString(samlRequestPart)); + + using var input = new MemoryStream(decodedBytes); + using var deflateStream = new DeflateStream(input, CompressionMode.Decompress); + using var reader = new StreamReader(deflateStream); + var xml = await reader.ReadToEndAsync(); + + xml.ShouldContain("{issuer}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_name_id() + { + var sp = CreateServiceProvider(); + var nameId = "user@example.com"; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + nameId, + null, + "session123", + "https://idp.example.com"); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain($"{nameId}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_name_id_format_when_provided() + { + var sp = CreateServiceProvider(); + var nameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + nameIdFormat, + "session123", + "https://idp.example.com"); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain($"Format=\"{nameIdFormat}\""); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_omit_name_id_format_when_null() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + null, + "session123", + "https://idp.example.com"); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + var doc = XDocument.Parse(xml); + var nameIdElement = doc.Descendants().First(e => e.Name.LocalName == "NameID"); + nameIdElement.Attribute("Format").ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_session_index() + { + var sp = CreateServiceProvider(); + var sessionIndex = "session123"; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + null, + sessionIndex, + "https://idp.example.com"); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain($"{sessionIndex}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_generate_unique_request_id() + { + var sp = CreateServiceProvider(); + + var result1 = await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com"); + var result2 = await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com"); + + var xml1 = await DecodeRedirectRequest(result1.EncodedContent); + var xml2 = await DecodeRedirectRequest(result2.EncodedContent); + + var doc1 = XDocument.Parse(xml1); + var doc2 = XDocument.Parse(xml2); + var id1 = doc1.Root!.Attribute("ID")!.Value; + var id2 = doc2.Root!.Attribute("ID")!.Value; + + id1.ShouldNotBe(id2); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_saml_version_2() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + null, + "session123", + "https://idp.example.com"); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain("Version=\"2.0\""); + } + + private static async Task DecodeRedirectRequest(string encodedContent) + { + var samlRequestPart = encodedContent.Split('&')[0].Replace("?SAMLRequest=", ""); + var decodedBytes = Convert.FromBase64String(Uri.UnescapeDataString(samlRequestPart)); + + using var input = new MemoryStream(decodedBytes); + using var deflateStream = new DeflateStream(input, CompressionMode.Decompress); + using var reader = new StreamReader(deflateStream); + return await reader.ReadToEndAsync(); + } + + private static SamlServiceProvider CreateServiceProvider() => new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpRedirect, + Location = new Uri("https://sp.example.com/slo") + } + }; + + private static SamlProtocolMessageSigner CreateSigner() + { + var cert = CreateTestCertificate(); + var mockSigningService = new UnitTests.Common.MockSamlSigningService(cert); + + return new SamlProtocolMessageSigner( + mockSigningService, + NullLogger.Instance); + } + + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs new file mode 100644 index 000000000..734334986 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; + +namespace UnitTests.Saml; + +public class SamlHttpPostFrontChannelLogoutTests +{ + private const string Category = "SAML HTTP POST Front Channel Logout"; + + [Fact] + [Trait("Category", Category)] + public void constructor_should_set_properties() + { + var destination = new Uri("https://sp.example.com/slo"); + var logoutRequest = "base64encodedrequest"; + var relayState = "state123"; + + var subject = new SamlHttpPostFrontChannelLogout(destination, logoutRequest, relayState); + + subject.Destination.ShouldBe(destination); + subject.EncodedContent.ShouldBe(logoutRequest); + subject.SamlBinding.ShouldBe(SamlBinding.HttpPost); + subject.RelayState.ShouldNotBeNull(); + subject.RelayState.ShouldBe(relayState); + } + + [Fact] + [Trait("Category", Category)] + public void constructor_with_null_relay_state_should_set_relay_state_to_null() + { + var subject = new SamlHttpPostFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "base64encodedrequest", + null); + + subject.RelayState.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void saml_binding_should_return_http_post() + { + var subject = new SamlHttpPostFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "base64encodedrequest", + null); + + subject.SamlBinding.ShouldBe(SamlBinding.HttpPost); + } + + [Fact] + [Trait("Category", Category)] + public void relay_state_should_parse_from_string() + { + var relayState = "mystate"; + var subject = new SamlHttpPostFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "base64encodedrequest", + relayState); + + subject.RelayState.ShouldNotBeNull(); + subject.RelayState.ShouldBe(relayState); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs new file mode 100644 index 000000000..f270f9d9b --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; + +namespace UnitTests.Saml; + +public class SamlHttpRedirectFrontChannelLogoutTests +{ + private const string Category = "SAML HTTP Redirect Front Channel Logout"; + + [Fact] + [Trait("Category", Category)] + public void constructor_should_set_properties() + { + var destination = new Uri("https://sp.example.com/slo"); + var encodedContent = "?SAMLRequest=abc123&SigAlg=xyz&Signature=sig"; + + var subject = new SamlHttpRedirectFrontChannelLogout(destination, encodedContent); + + subject.Destination.ShouldBe(destination); + subject.EncodedContent.ShouldBe(encodedContent); + subject.SamlBinding.ShouldBe(SamlBinding.HttpRedirect); + subject.RelayState.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void saml_binding_should_return_http_redirect() + { + var subject = new SamlHttpRedirectFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "?SAMLRequest=abc"); + + subject.SamlBinding.ShouldBe(SamlBinding.HttpRedirect); + } + + [Fact] + [Trait("Category", Category)] + public void relay_state_should_always_return_null() + { + var subject = new SamlHttpRedirectFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "?SAMLRequest=abc"); + + subject.RelayState.ShouldBeNull(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs new file mode 100644 index 000000000..8e7b9f5f6 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using UnitTests.Common; + +namespace UnitTests.Saml; + +public class SamlLogoutCallbackProcessorTests +{ + private const string Category = "SAML Logout Callback Processor"; + + private readonly UnitTests.Common.MockMessageStore _logoutMessageStore = new(); + private readonly MockServiceProviderStore _serviceProviderStore = new(); + private readonly LogoutResponseBuilder _logoutResponseBuilder; + private readonly SamlLogoutCallbackProcessor _subject; + + public SamlLogoutCallbackProcessorTests() + { + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 5, 12, 0, 0, TimeSpan.Zero)); + var issuerNameService = new MockIssuerNameService { IssuerName = "https://idp.example.com" }; + _logoutResponseBuilder = new LogoutResponseBuilder(issuerNameService, timeProvider); + + _subject = new SamlLogoutCallbackProcessor( + _logoutMessageStore, + _serviceProviderStore, + _logoutResponseBuilder, + NullLogger.Instance); + } + + [Fact] + [Trait("Category", Category)] + public async Task invalid_logout_id_should_return_error() + { + var result = await _subject.ProcessAsync("invalid", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("No logout message found"); + } + + [Fact] + [Trait("Category", Category)] + public async Task missing_saml_service_provider_entity_id_should_return_error() + { + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("does not contain SAML SP entity ID"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_not_found_should_return_error() + { + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = "https://unknown-sp.com", + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("Service Provider not found"); + } + + [Fact] + [Trait("Category", Category)] + public async Task disabled_service_provider_should_return_error() + { + var sp = CreateServiceProvider(); + sp.Enabled = false; + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_with_no_single_logout_url_should_return_error() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = null; + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("has no SingleLogoutServiceUrl"); + } + + [Fact] + [Trait("Category", Category)] + public async Task missing_saml_logout_request_id_should_return_error() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("does not contain SAML logout request ID"); + } + + [Fact] + [Trait("Category", Category)] + public async Task valid_request_should_return_success() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123", + SamlRelayState = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeTrue(); + var logoutResponse = result.Value; + logoutResponse.InResponseTo.ShouldBe("_request123"); + logoutResponse.Destination.ShouldBe(sp.SingleLogoutServiceUrl!.Location); + logoutResponse.Status.StatusCode.ShouldBe(SamlStatusCode.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task relay_state_should_be_included_in_response() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123", + SamlRelayState = "state456" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeTrue(); + var logoutResponse = result.Value; + logoutResponse.RelayState.ShouldNotBeNull(); + logoutResponse.RelayState.ShouldBe("state456"); + } + + [Fact] + [Trait("Category", Category)] + public async Task without_relay_state_should_have_null_relay_state_in_response() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123", + SamlRelayState = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeTrue(); + result.Value.RelayState.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_issuer_in_response() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", CancellationToken.None); + + result.Success.ShouldBeTrue(); + result.Value.Issuer.ShouldBe("https://idp.example.com"); + } + + private static SamlServiceProvider CreateServiceProvider() => new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpPost, + Location = new Uri("https://sp.example.com/slo") + }, + Enabled = true + }; + + private class MockServiceProviderStore : ISamlServiceProviderStore + { + public Dictionary ServiceProviders { get; } = []; + + public Task FindByEntityIdAsync(string entityId) + { + ServiceProviders.TryGetValue(entityId, out var sp); + return Task.FromResult(sp); + } + } + + private class MockIssuerNameService : IIssuerNameService + { + public string IssuerName { get; set; } = "https://idp.example.com"; + + public Task GetCurrentAsync() => Task.FromResult(IssuerName); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs new file mode 100644 index 000000000..466293c02 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs @@ -0,0 +1,293 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using UnitTests.Common; +using UnitTests.Validation.Setup; +using SamlBinding = Duende.IdentityServer.Models.SamlBinding; + +namespace UnitTests.Saml; + +public class SamlLogoutNotificationServiceTests +{ + private const string Category = "SAML Logout Notification Service"; + + private readonly MockUserSession _userSession = new(); + private readonly TestIssuerNameService _issuerNameService = new(); + + private SamlLogoutNotificationService CreateSubject(params SamlServiceProvider[] samlServiceProviders) + { + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 5, 12, 0, 0, TimeSpan.Zero)); + var signer = CreateSigner(); + var frontChannelLogoutRequestBuilder = new SamlFrontChannelLogoutRequestBuilder(timeProvider, signer); + + return new SamlLogoutNotificationService( + _issuerNameService, + new InMemoryTestServiceProviderStore(samlServiceProviders), + frontChannelLogoutRequestBuilder, + NullLogger.Instance); + } + + private static SamlProtocolMessageSigner CreateSigner() + { + var cert = CreateTestCertificate(); + var mockSigningService = new MockSamlSigningService(cert); + return new SamlProtocolMessageSigner(mockSigningService, NullLogger.Instance); + } + + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_no_service_providers_should_return_empty_list() + { + var context = new LogoutNotificationContext + { + SamlSessions = [] + }; + var subject = CreateSubject(); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_service_provider_not_found_should_skip_it() + { + var unknownEntityId = "https://unknown-sp.com"; + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = unknownEntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_service_provider_disabled_should_skip_it() + { + var sp = CreateServiceProvider(); + sp.Enabled = false; + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_service_provider_has_no_single_logout_url_should_skip_it() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = null; + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_valid_service_provider_should_build_logout_url() + { + var sp = CreateServiceProvider(); + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.ShouldHaveSingleItem(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_multiple_valid_service_providers_should_build_multiple_logout_urls() + { + var sp1 = CreateServiceProvider("https://sp1.com"); + var sp2 = CreateServiceProvider("https://sp2.com"); + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp1.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + }, + new SamlSpSessionData + { + EntityId = sp2.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session456" + } + ] + }; + var subject = CreateSubject(sp1, sp2); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.Count().ShouldBe(2); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_should_use_context_saml_sessions_not_user_session() + { + // Regression test: Ensure we use context.SamlSessions instead of IUserSession + // This prevents a bug where logout fails because the user session is already cleared + var sp = CreateServiceProvider(); + + var context = new LogoutNotificationContext + { + SubjectId = "user123", + SessionId = "session-abc", + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.ShouldHaveSingleItem(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_with_multiple_saml_sessions_should_generate_multiple_logouts() + { + // Regression test: Multiple SPs with different session data + var sp1 = CreateServiceProvider("https://sp1.com"); + var sp2 = CreateServiceProvider("https://sp2.com"); + + var context = new LogoutNotificationContext + { + SubjectId = "user123", + SessionId = "session-abc", + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp1.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + }, + new SamlSpSessionData + { + EntityId = sp2.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session456" + } + ] + }; + + var subject = CreateSubject(sp1, sp2); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context); + + result.Count().ShouldBe(2); + } + + private static SamlServiceProvider CreateServiceProvider(string entityId = "https://sp.example.com") => new SamlServiceProvider + { + EntityId = entityId, + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri($"{entityId}/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpRedirect, + Location = new Uri($"{entityId}/slo") + }, + Enabled = true + }; +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs new file mode 100644 index 000000000..5d80637ed --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging.Abstractions; +using UnitTests.Common; + +namespace UnitTests.Saml; + +public class SamlProtocolMessageSignerTests +{ + private const string Category = "SAML Protocol Message Signer"; + + private readonly SamlServiceProvider _samlServiceProvider = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/saml/acs")] + }; + + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + private SamlProtocolMessageSigner CreateSigner() + { + var cert = CreateTestCertificate(); + var mockSigningService = new MockSamlSigningService(cert); + + return new SamlProtocolMessageSigner( + mockSigningService, + NullLogger.Instance); + } + + private static XElement CreateLogoutResponseElement() + { + var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol); + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + + return new XElement(protocolNs + "LogoutResponse", + new XAttribute("ID", "_test123"), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", "2026-01-29T15:00:00.000Z"), + new XAttribute("Destination", "https://sp.example.com/slo"), + new XAttribute("InResponseTo", "_request123"), + new XElement(assertionNs + "Issuer", "https://idp.example.com"), + new XElement(protocolNs + "Status", + new XElement(protocolNs + "StatusCode", + new XAttribute("Value", SamlStatusCode.Success.ToString())))); + } + + [Fact] + [Trait("Category", Category)] + public async Task sign_protocol_message_should_add_signature() + { + var signer = CreateSigner(); + var logoutResponse = CreateLogoutResponseElement(); + + var signedXml = await signer.SignProtocolMessage(logoutResponse, _samlServiceProvider); + + signedXml.ShouldContain("Signature"); + signedXml.ShouldContain("SignatureValue"); + signedXml.ShouldContain("X509Certificate"); + } + + [Fact] + [Trait("Category", Category)] + public async Task sign_protocol_message_signature_should_be_placed_after_issuer() + { + var signer = CreateSigner(); + var logoutResponse = CreateLogoutResponseElement(); + + var signedXml = await signer.SignProtocolMessage(logoutResponse, _samlServiceProvider); + + var indexOfIssuer = signedXml.IndexOf(" + _signingService = new SamlSigningService( + _mockKeyMaterialService, + NullLogger.Instance); + + private static X509Certificate2 CreateTestCertificate(bool includePrivateKey = true) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test Signing Cert", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + critical: true)); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + var certWithKey = X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + + if (!includePrivateKey) + { + // Export without private key + var publicOnly = certWithKey.Export(X509ContentType.Cert); + return X509CertificateLoader.LoadCertificate(publicOnly); + } + + return certWithKey; + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_valid_x509_certificate_should_return_certificate() + { + // Arrange + var cert = CreateTestCertificate(); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act + var result = await _signingService.GetSigningCertificateAsync(); + + // Assert + result.ShouldNotBeNull(); + result.HasPrivateKey.ShouldBeTrue(); + result.Subject.ShouldContain("CN=Test Signing Cert"); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_non_x509_security_key_should_throw_invalid_operation_exception() + { + // Arrange + var key = new SymmetricSecurityKey(new byte[32]); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateAsync()); + + ex.Message.ShouldBe("Signing credential must be an X509 certificate with private key."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_certificate_without_private_key_should_throw_invalid_operation_exception() + { + // Arrange + var cert = CreateTestCertificate(includePrivateKey: false); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateAsync()); + + ex.Message.ShouldBe("Signing certificate must have a private key."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_no_signing_credential_should_throw_invalid_operation_exception() + { + // Arrange - no credentials added to mock service + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateAsync()); + + ex.Message.ShouldBe("No signing credential available. Configure a signing certificate."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_with_valid_x509_certificate_should_return_base64_string() + { + // Arrange + var cert = CreateTestCertificate(); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act + var result = await _signingService.GetSigningCertificateBase64Async(); + + // Assert + result.ShouldNotBeNullOrEmpty(); + // Verify it's valid base64 + var bytes = Convert.FromBase64String(result); + bytes.ShouldNotBeEmpty(); + + // Verify it can be loaded as a certificate + var loadedCert = X509CertificateLoader.LoadCertificate(bytes); + loadedCert.Subject.ShouldContain("CN=Test Signing Cert"); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_with_non_x509_security_key_should_throw_invalid_operation_exception() + { + // Arrange + var key = new SymmetricSecurityKey(new byte[32]); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateBase64Async()); + + ex.Message.ShouldBe("Signing credential key is not an X509SecurityKey and cannot be used to extract an X509 certificate for SAML metadata."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_with_no_signing_credential_should_throw_invalid_operation_exception() + { + // Arrange - no credentials added to mock service + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateBase64Async()); + + ex.Message.ShouldBe("No signing credential available. Configure a signing certificate."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_should_export_public_key_only() + { + // Arrange + var cert = CreateTestCertificate(); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act + var result = await _signingService.GetSigningCertificateBase64Async(); + var bytes = Convert.FromBase64String(result); + var exportedCert = X509CertificateLoader.LoadCertificate(bytes); + + // Assert + exportedCert.HasPrivateKey.ShouldBeFalse(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs new file mode 100644 index 000000000..1b1650acb --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs @@ -0,0 +1,324 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Xml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; + +namespace UnitTests.Saml; + +/// +/// Security tests for SecureXmlParser to ensure protection against common XML attacks +/// +public class SecureXmlParserTests +{ + private const string Category = "Secure XML Parser"; + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_valid_xml_should_succeed() + { + // Arrange + var validXml = "value"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(validXml); + + // Assert + doc.ShouldNotBeNull(); + doc.DocumentElement.ShouldNotBeNull(); + doc.DocumentElement!.Name.ShouldBe("root"); + doc.SelectSingleNode("//child")!.InnerText.ShouldBe("value"); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_element_with_valid_xml_should_succeed() + { + // Arrange + var validXml = "value"; + + // Act + var element = SecureXmlParser.LoadXElement(validXml); + + // Assert + element.ShouldNotBeNull(); + element.Name.LocalName.ShouldBe("root"); + element.Element("child")!.Value.ShouldBe("value"); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_document_with_valid_xml_should_succeed() + { + // Arrange + var validXml = "value"; + + // Act + var doc = SecureXmlParser.LoadXDocument(validXml); + + // Assert + doc.ShouldNotBeNull(); + doc.Root.ShouldNotBeNull(); + doc.Root!.Name.LocalName.ShouldBe("root"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_null_xml_should_throw_argument_null_exception() => + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(null!)); + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_empty_xml_should_throw_argument_null_exception() => + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(string.Empty)); + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_xxe_attack_should_throw_xml_exception() + { + // Arrange - XXE attack attempting to read local file + var xxeAttack = @" + +]> +&xxe;"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXmlDocument(xxeAttack)); + + exception.Message.ShouldContain("prohibited constructs"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_dtd_attack_should_throw_xml_exception() + { + // Arrange - DTD declaration should be prohibited + var dtdAttack = @" + +]> +content"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXmlDocument(dtdAttack)); + + exception.Message.ShouldContain("prohibited constructs"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_billion_laughs_attack_should_throw_xml_exception() + { + // Arrange - Billion laughs (entity expansion) attack + var billionLaughsAttack = @" + + + + +]> +&lol4;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(billionLaughsAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_external_entity_reference_should_throw_xml_exception() + { + // Arrange - External entity reference attack + var externalEntityAttack = @" + +]> +&external;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(externalEntityAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_message_exceeding_max_size_should_throw_xml_exception() + { + // Arrange - Create XML larger than 1MB + var largeContent = new string('X', SecureXmlParser.MaxMessageSize + 1); + var largeXml = $"{largeContent}"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXmlDocument(largeXml)); + + exception.Message.ShouldContain("exceeds maximum allowed size"); + exception.Message.ShouldContain(SecureXmlParser.MaxMessageSize.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_comments_should_ignore_comments() + { + // Arrange - XML with comments + var xmlWithComments = @" + + value + +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(xmlWithComments); + + // Assert + doc.ShouldNotBeNull(); + // Comments should be ignored (not present in the document) + var comments = doc.SelectNodes("//comment()"); + comments.ShouldNotBeNull(); + comments!.Count.ShouldBe(0); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_processing_instructions_should_ignore_processing_instructions() + { + // Arrange - XML with processing instructions + var xmlWithPI = @" + + + value +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(xmlWithPI); + + // Assert + doc.ShouldNotBeNull(); + // Processing instructions should be ignored + var pis = doc.SelectNodes("//processing-instruction()"); + pis.ShouldNotBeNull(); + pis!.Count.ShouldBe(0); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_malformed_xml_should_throw_xml_exception() + { + // Arrange + var malformedXml = ""; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(malformedXml)); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_saml_response_should_succeed() + { + // Arrange - Real SAML Response structure + var samlResponse = @" + https://idp.example.com + + + + + https://idp.example.com + + user@example.com + + +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(samlResponse); + + // Assert + doc.ShouldNotBeNull(); + doc.DocumentElement!.LocalName.ShouldBe("Response"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_preserves_whitespace() + { + // Arrange - XML with specific whitespace (important for signatures) + var xmlWithWhitespace = @" + value +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(xmlWithWhitespace); + + // Assert + doc.ShouldNotBeNull(); + doc.PreserveWhitespace.ShouldBeTrue(); + // Whitespace should be preserved + doc.InnerXml.ShouldContain("\n"); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_element_with_xxe_attack_should_throw_xml_exception() + { + // Arrange + var xxeAttack = @" + +]> +&xxe;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXElement(xxeAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_document_with_billion_laughs_attack_should_throw_xml_exception() + { + // Arrange + var billionLaughsAttack = @" + + +]> +&lol2;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXDocument(billionLaughsAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_element_with_message_exceeding_max_size_should_throw_xml_exception() + { + // Arrange + var largeContent = new string('X', SecureXmlParser.MaxMessageSize + 1); + var largeXml = $"{largeContent}"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXElement(largeXml)); + + exception.Message.ShouldContain("exceeds maximum allowed size"); + } + + [Fact] + [Trait("Category", Category)] + public void max_message_size_should_be_1_mb() => + // Assert + SecureXmlParser.MaxMessageSize.ShouldBe(1048576); +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs new file mode 100644 index 000000000..4e5030a49 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs @@ -0,0 +1,346 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Xml; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using SamlStatusCode = Duende.IdentityServer.Saml.Models.SamlStatusCode; + +namespace UnitTests.Saml; + +public class XmlSignatureHelperTests +{ + private const string Category = "XML Signature Helper"; + + private readonly ISamlResultSerializer _responseSerializer = new SamlResponse.Serializer(); + + private readonly SamlServiceProvider _samlServiceProvider = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/saml/acs")] + }; + + private static X509Certificate2 CreateTestCertificate() + { + // Create a self-signed certificate for testing + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + // Export and re-import to ensure private key is available + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + [Fact] + [Trait("Category", Category)] + public void sign_response_valid_response_adds_signature() + { + var response = new SamlResponse + { + ServiceProvider = _samlServiceProvider, + IssueInstant = DateTime.UtcNow, + Issuer = "https://idp.example.com", + Destination = new Uri("https://sp.example.com/acs"), + Status = new Status + { + StatusCode = SamlStatusCode.Success + }, + }; + + var responseElement = _responseSerializer.Serialize(response); + var cert = CreateTestCertificate(); + + var signedXml = XmlSignatureHelper.SignResponse(responseElement, cert); + + signedXml.ShouldContain("Signature"); + signedXml.ShouldContain("SignatureValue"); + signedXml.ShouldContain("X509Certificate"); + + signedXml.ShouldContain("(() => + XmlSignatureHelper.SignResponse(responseElement, cert)) + .Message.ShouldContain("ID attribute"); + } + + [Fact] + [Trait("Category", Category)] + public void sign_response_invalid_element_throws_exception() + { + var invalidElement = new XElement("SomethingElse", + new XAttribute("ID", "_test")); + var cert = CreateTestCertificate(); + + Should.Throw(() => + XmlSignatureHelper.SignResponse(invalidElement, cert)) + .Message.ShouldContain("Response"); + } + + [Fact] + [Trait("Category", Category)] + public void sign_response_null_certificate_throws_exception() + { + var response = new SamlResponse + { + ServiceProvider = _samlServiceProvider, + IssueInstant = DateTime.UtcNow, + Issuer = "https://idp.example.com", + Destination = new Uri("https://sp.example.com/acs"), + Status = new Status { StatusCode = SamlStatusCode.Success } + }; + + var responseElement = _responseSerializer.Serialize(response); + + Should.Throw(() => + XmlSignatureHelper.SignResponse(responseElement, null!)); + } + + [Fact] + [Trait("Category", Category)] + public void sign_assertion_in_response_no_assertion_throws_exception() + { + var response = new SamlResponse + { + ServiceProvider = _samlServiceProvider, + IssueInstant = DateTime.UtcNow, + Issuer = "https://idp.example.com", + Destination = new Uri("https://sp.example.com/acs"), + Status = new Status { StatusCode = SamlStatusCode.Success } + // No Assertion! + }; + + var responseElement = _responseSerializer.Serialize(response); + var cert = CreateTestCertificate(); + + Should.Throw(() => + XmlSignatureHelper.SignAssertionInResponse(responseElement, cert)) + .Message.ShouldContain("Assertion"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs index 33c387aa0..7ceba035a 100644 --- a/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs @@ -5,6 +5,7 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; using Duende.IdentityServer.Validation; @@ -174,4 +175,80 @@ public class DefaultIdentityServerInteractionServiceTests var consentRequest = new ConsentRequest(req, "bob"); _mockConsentStore.Messages.First().Key.ShouldBe(consentRequest.Id); } + + [Fact] + public async Task CreateLogoutContextAsync_with_saml_sessions_only_should_create_context() + { + _mockUserSession.User = new IdentityServerUser("123").CreatePrincipal(); + _mockUserSession.SessionId = "session"; + _mockUserSession.SamlSessions.Add(new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "idx1", + NameId = "user123" + }); + + var context = await _subject.CreateLogoutContextAsync(); + + context.ShouldNotBeNull(); + _mockLogoutMessageStore.Messages.ShouldNotBeEmpty(); + var message = _mockLogoutMessageStore.Messages[context]; + message.Data.SamlSessions.ShouldNotBeNull(); + message.Data.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + message.Data.ClientIds.ShouldBeEmpty(); + } + + [Fact] + public async Task CreateLogoutContextAsync_with_mixed_sessions_should_include_both() + { + _mockUserSession.User = new IdentityServerUser("123").CreatePrincipal(); + _mockUserSession.SessionId = "session"; + _mockUserSession.Clients.Add("client1"); + _mockUserSession.Clients.Add("client2"); + _mockUserSession.SamlSessions.Add(new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "idx1", + NameId = "user123" + }); + _mockUserSession.SamlSessions.Add(new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "idx2", + NameId = "user123" + }); + + var context = await _subject.CreateLogoutContextAsync(); + + context.ShouldNotBeNull(); + _mockLogoutMessageStore.Messages.ShouldNotBeEmpty(); + var message = _mockLogoutMessageStore.Messages[context]; + + message.Data.ClientIds.ShouldNotBeNull(); + message.Data.ClientIds.Count().ShouldBe(2); + message.Data.ClientIds.ShouldContain("client1"); + message.Data.ClientIds.ShouldContain("client2"); + + message.Data.SamlSessions.ShouldNotBeNull(); + message.Data.SamlSessions.Count().ShouldBe(2); + message.Data.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + message.Data.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp2.example.com"); + } + + [Fact] + public async Task CreateLogoutContextAsync_with_oidc_sessions_should_not_populate_saml() + { + _mockUserSession.User = new IdentityServerUser("123").CreatePrincipal(); + _mockUserSession.SessionId = "session"; + _mockUserSession.Clients.Add("client1"); + + var context = await _subject.CreateLogoutContextAsync(); + + context.ShouldNotBeNull(); + _mockLogoutMessageStore.Messages.ShouldNotBeEmpty(); + var message = _mockLogoutMessageStore.Messages[context]; + message.Data.ClientIds?.ShouldContain("client1"); + + message.Data.SamlSessions.ShouldBeEmpty(); + } }