Added missing SAML-relevant tests

This commit is contained in:
Brett Hazen 2026-02-19 11:30:01 -06:00
parent 72df8704fc
commit e0034d642a
41 changed files with 11246 additions and 13 deletions

View file

@ -98,6 +98,7 @@
<PackageVersion Include="SimpleFeedReader" Version="2.0.4" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.53.0" />
<PackageVersion Include="Spectre.Console.Json" Version="0.53.0" />
<PackageVersion Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Text.Json" Version="10.0.0" />

View file

@ -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);
}
}
/// <summary>
/// Loads an XElement from a string with secure settings.
/// </summary>
/// <param name="xml">The XML string to parse</param>
/// <returns>A parsed XElement</returns>
/// <exception cref="ArgumentNullException">Thrown when xml is null or empty</exception>
/// <exception cref="XmlException">Thrown when XML is malformed or violates security constraints</exception>
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
}
}
/// <summary>
/// Loads an XDocument from a string with secure settings.
/// </summary>
/// <param name="xml">The XML string to parse</param>
/// <returns>A parsed XDocument</returns>
/// <exception cref="ArgumentNullException">Thrown when xml is null or empty</exception>
/// <exception cref="XmlException">Thrown when XML is malformed or violates security constraints</exception>
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))

View file

@ -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; }
}

View file

@ -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<HttpResponseMessage> 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;
}
}

View file

@ -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<Claim>
{
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<Claim>
{
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<string, string>(new Dictionary<string, string>
{
["email"] = "mail", // Override default mapping
["department"] = "ou" // Custom mapping
});
Fixture.ServiceProviders.Add(spWithCustomMappings);
await Fixture.InitializeAsync();
var claims = new List<Claim>
{
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<ISamlClaimsMapper>(customMapper);
};
Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
await Fixture.InitializeAsync();
var claims = new List<Claim>
{
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<Claim>
{
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<string, string>(new Dictionary<string, string>
{
["email"] = "emailAddress",
["department"] = "dept"
})
}));
};
Fixture.ServiceProviders.Add(Build.SamlServiceProvider());
await Fixture.InitializeAsync();
var claims = new List<Claim>
{
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");
}
}

View file

@ -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";
}

View file

@ -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 = $"""<samlp:NameIDPolicy {formatAttr}{spNameQualifierAttr}/>""";
}
return $"""
<?xml version="1.0" encoding="UTF-8"?>
<samlp:AuthnRequest
ID="{id}"
Version="{version ?? "2.0"}"
Destination="{destination}"
IssueInstant="{(issueInstant ?? data.Now):yyyy-MM-ddTHH:mm:ssZ}"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
{acsAttributes}
ForceAuthn="{forceAuthn}"
IsPassive="{isPassive}"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:Issuer>{issuerValue}</saml:Issuer>
{requestedAuthnContext}
{nameIdPolicyElement}
</samlp:AuthnRequest>
""".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
? $"<samlp:SessionIndex>{sessionIndex}</samlp:SessionIndex>"
: "";
var notOnOrAfterAttr = notOnOrAfter.HasValue
? $"NotOnOrAfter=\"{notOnOrAfter.Value:yyyy-MM-ddTHH:mm:ssZ}\""
: "";
return $"""
<?xml version="1.0" encoding="UTF-8"?>
<samlp:LogoutRequest
ID="{id}"
Version="{version ?? "2.0"}"
Destination="{destination}"
IssueInstant="{(issueInstant ?? data.Now):yyyy-MM-ddTHH:mm:ssZ}"
{notOnOrAfterAttr}
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:Issuer>{issuerValue}</saml:Issuer>
<saml:NameID Format="{nameIdFormatValue}">{nameIdValue}</saml:NameID>
{sessionIndexElement}
</samlp:LogoutRequest>
""".Trim();
}
}

View file

@ -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<string, string>(new Dictionary<string, string>
{
[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();
}
}

View file

@ -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<IdentityServerOptions> ConfigureIdentityServerOptions = _ => { };
public Action<SamlOptions> ConfigureSamlOptions = _ => { };
public Action<IServiceCollection> ConfigureServices = _ => { };
private List<SamlServiceProvider> _serviceProviders = [];
private bool _isInitialized = false;
/// <summary>
/// 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.
/// </summary>
public List<SamlServiceProvider> 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<T>() where T : notnull => Host!.Resolve<T>();
public async Task InitializeAsync()
{
var selfSignedCertificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(StableSigningCert), null);
Host = new TestFramework.GenericHost();
Host.OnConfigureServices += services =>
{
services.AddSingleton<TimeProvider>(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<SamlOptions>(ConfigureSamlOptions);
// Register in-memory SAML service provider store with our service providers
services.AddSingleton<ISamlServiceProviderStore>(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;
}
}
/// <summary>
/// Adds a service provider to the fixture after initialization.
/// </summary>
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.");
}
/// <summary>
/// Removes all service providers from the fixture after initialization.
/// </summary>
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.");
}
}

View file

@ -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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<Uri>();
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<ProblemDetails>(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);
}
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor entityID="https://localhost" validUntil="2000-01-09T03:04:05Z" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTA</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://localhost/saml/signin" />
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://localhost/saml/signin" />
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://localhost/saml/logout" />
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://localhost/saml/logout" />
</IDPSSODescriptor>
</EntityDescriptor>

View file

@ -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("<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>");
content.ShouldContain("<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>");
content.ShouldNotContain("<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>");
}
[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("<SingleLogoutService");
content.ShouldContain("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"");
content.ShouldContain("Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\"");
content.ShouldContain("/saml/logout");
}
[Fact]
[Trait("Category", Category)]
public async Task metadata_urls_should_not_have_trailing_slashes()
{
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)
{
location.ShouldNotEndWith("/", $"Service Location should not end with trailing slash: {location}");
}
}
[Fact]
[Trait("Category", Category)]
public async Task metadata_urls_should_not_contain_double_slashes()
{
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)
{
var uri = new Uri(location);
uri.PathAndQuery.ShouldNotContain("//");
}
}
[Fact]
[Trait("Category", Category)]
public async Task metadata_urls_should_be_correct_when_route_has_trailing_slash()
{
Fixture.ConfigureSamlOptions = options =>
{
// 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<string> 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<string>();
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;
}
}

View file

@ -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<ISamlSigninStateStore>();
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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<string, string>(new Dictionary<string, string>
{
[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<Claim>
{
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<Claim>
{
new Claim(JwtClaimTypes.Subject, "user-id"),
new Claim(JwtClaimTypes.Name, "Test <User> & \"Company\""),
new Claim("description", "Value with <tags> & \"quotes\" and 'apostrophes'"),
new Claim("xml_data", "<root><element attr=\"value\">text</element></root>")
};
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 <User> & \"Company\"");
}
}

View file

@ -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<IMessageStore<LogoutMessage>>();
var logoutId = await messageStore.WriteAsync(new Message<LogoutMessage>(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<IMessageStore<LogoutMessage>>();
var logoutId = await messageStore.WriteAsync(new Message<LogoutMessage>(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<IMessageStore<LogoutMessage>>();
var logoutId = await messageStore.WriteAsync(new Message<LogoutMessage>(logoutMessage, DateTime.UtcNow));
// Act
var result = await Fixture.Client.GetAsync($"/saml/logout_callback?logoutId={logoutId}", CancellationToken.None);
// Assert
result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
}

View file

@ -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<ProblemDetails>(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<ProblemDetails>(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<string, string>
{
{ "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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<string> 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<string> 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;
}
}

View file

@ -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<string> 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));
/// <summary>
/// Extracts SAML error response from an HTTP-POST binding auto-submit form.
/// </summary>
public static async Task<SamlErrorResponseData> 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<SamlLogoutResponseData> 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<SamlSuccessResponseData> 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,
@"<input[^>]+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,
@"<input[^>]+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,
@"<form[^>]+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<SamlAttribute>? 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<SamlAttribute>? 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<string> 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 <EncryptedAssertion> present
var encAssertion = response.Descendants(samlNs + "EncryptedAssertion")
.FirstOrDefault();
encAssertion.ShouldNotBeNull(
"Response should contain <EncryptedAssertion> element");
// Verify <EncryptedData> present
var encData = encAssertion.Descendants(encNs + "EncryptedData")
.FirstOrDefault();
encData.ShouldNotBeNull(
"<EncryptedAssertion> should contain <EncryptedData> element");
// Verify <EncryptedKey> present
var encKey = encAssertion.Descendants(encNs + "EncryptedKey")
.FirstOrDefault();
encKey.ShouldNotBeNull(
"<EncryptedAssertion> should contain <EncryptedKey> 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<SamlSuccessResponseData> 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
{
}
}

View file

@ -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:
// <PackageReference Include="Sustainsys.Saml2.AspNetCore2" />
#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();
}
}

View file

@ -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<HttpResponseMessage> 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<string, string> { { "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;
}
}

View file

@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="AngleSharp" />

View file

@ -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;
/// <summary>
/// Mock implementation of <see cref="ISamlSigningService"/> for testing.
/// </summary>
internal class MockSamlSigningService : ISamlSigningService
{
private readonly X509Certificate2 _certificate;
public MockSamlSigningService(X509Certificate2 certificate) => _certificate = certificate;
public Task<X509Certificate2> GetSigningCertificateAsync() => Task.FromResult(_certificate);
public Task<string> GetSigningCertificateBase64Async()
{
var certBytes = _certificate.Export(X509ContentType.Cert);
return Task.FromResult(Convert.ToBase64String(certBytes));
}
}

View file

@ -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;
/// <summary>
/// Test implementation of ISamlClaimsMapper that returns a single custom attribute.
/// Used by both unit and integration tests.
/// </summary>
public class TestSamlClaimsMapper : ISamlClaimsMapper
{
public Task<IEnumerable<SamlAttribute>> MapClaimsAsync(SamlClaimsMappingContext mappingContext)
{
var attributes = new List<SamlAttribute>
{
new()
{
Name = "CUSTOM_MAPPED",
NameFormat = "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
Values = new List<string> { "custom_value" }
}
};
return Task.FromResult<IEnumerable<SamlAttribute>>(attributes);
}
}

View file

@ -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<SamlSpSessionData>());
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);
}
}

View file

@ -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<SamlSpSessionData>
{
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<SamlSpSessionData>
{
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<SamlSpSessionData>
{
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<SamlSpSessionData>
{
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();
}
}

View file

@ -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;
/// <summary>
/// Unit tests for AuthNRequestParser, focusing on NameIDPolicy parsing
/// </summary>
public class AuthNRequestParserTests
{
private const string Category = "SAML AuthN Request Parser";
private readonly AuthNRequestParser _parser = new(NullLogger<AuthNRequestParser>.Instance);
private static XDocument CreateAuthNRequest(string? nameIdPolicyXml = null)
{
var xml = $@"<?xml version=""1.0"" encoding=""UTF-8""?>
<samlp:AuthnRequest xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion""
ID=""_test123""
Version=""2.0""
IssueInstant=""2024-01-01T00:00:00Z""
Destination=""https://idp.example.com/saml/sso"">
<saml:Issuer>https://sp.example.com</saml:Issuer>
{nameIdPolicyXml ?? ""}
</samlp:AuthnRequest>";
return XDocument.Parse(xml);
}
[Fact]
[Trait("Category", Category)]
public void parse_with_format_only_should_succeed()
{
// Arrange
var nameIdPolicyXml = @"<samlp:NameIDPolicy xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
Format=""urn:oasis:names:tc:SAML:2.0:nameid-format:persistent""/>";
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 = @"<samlp:NameIDPolicy xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
SPNameQualifier=""https://custom.sp.com""/>";
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 = @"<samlp:NameIDPolicy xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
Format=""urn:oasis:names:tc:SAML:2.0:nameid-format:transient""
SPNameQualifier=""https://sp.example.com""/>";
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 = @"<samlp:NameIDPolicy xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""/>";
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 = @"<samlp:NameIDPolicy xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
Format="" urn:oasis:names:tc:SAML:2.0:nameid-format:persistent ""/>";
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 = @"<samlp:NameIDPolicy xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
SPNameQualifier="" https://sp.example.com ""/>";
var doc = CreateAuthNRequest(nameIdPolicyXml);
// Act
var result = _parser.Parse(doc);
// Assert
result.NameIdPolicy.ShouldNotBeNull();
result.NameIdPolicy.SPNameQualifier.ShouldBe("https://sp.example.com");
}
}

View file

@ -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;
/// <summary>
/// Simple in-memory implementation of ISamlServiceProviderStore for unit testing.
/// </summary>
internal class InMemoryTestServiceProviderStore : ISamlServiceProviderStore
{
private readonly List<SamlServiceProvider> _providers = [];
public InMemoryTestServiceProviderStore() { }
public InMemoryTestServiceProviderStore(params SamlServiceProvider[] providers) => _providers.AddRange(providers);
public void Add(SamlServiceProvider provider) => _providers.Add(provider);
public Task<SamlServiceProvider?> FindByEntityIdAsync(string entityId)
=> Task.FromResult(_providers.FirstOrDefault(p => p.EntityId == entityId));
}

View file

@ -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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<ObjectDisposedException>(() => 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<ObjectDisposedException>(() => 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");
}
}

View file

@ -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<LogoutRequestParser>.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 $@"<?xml version=""1.0"" encoding=""UTF-8""?>
<samlp:LogoutRequest xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion""
ID=""{id}""
Version=""2.0""
IssueInstant=""2024-01-01T00:00:00Z""
Destination=""{destination}"">
<saml:Issuer>{issuer}</saml:Issuer>
<saml:NameID Format=""urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"">{nameId}</saml:NameID>
<samlp:SessionIndex>{sessionIndex}</samlp:SessionIndex>
</samlp:LogoutRequest>";
}
[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 = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<samlp:LogoutRequest xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion""
Version=""2.0""
IssueInstant=""2024-01-01T00:00:00Z"">
<saml:Issuer>https://sp.example.com</saml:Issuer>
</samlp:LogoutRequest>";
var doc = XDocument.Parse(xml);
Should.Throw<FormatException>(() => _parser.Parse(doc))
.Message.ShouldContain("ID");
}
[Fact]
[Trait("Category", Category)]
public void parse_missing_issuer_throws_exception()
{
var xml = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<samlp:LogoutRequest xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion""
ID=""_test-id""
Version=""2.0""
IssueInstant=""2024-01-01T00:00:00Z"">
</samlp:LogoutRequest>";
var doc = XDocument.Parse(xml);
Should.Throw<InvalidOperationException>(() => _parser.Parse(doc))
.Message.ShouldContain("Issuer");
}
[Fact]
[Trait("Category", Category)]
public void parse_invalid_xml_throws_exception()
{
var xml = "<InvalidElement></InvalidElement>";
var doc = XDocument.Parse(xml);
Should.Throw<FormatException>(() => _parser.Parse(doc));
}
[Fact]
[Trait("Category", Category)]
public void parse_missing_destination_still_succeeds()
{
var xml = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<samlp:LogoutRequest xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion""
ID=""_test-id""
Version=""2.0""
IssueInstant=""2024-01-01T00:00:00Z"">
<saml:Issuer>https://sp.example.com</saml:Issuer>
<saml:NameID>user@example.com</saml:NameID>
<samlp:SessionIndex>_session123</samlp:SessionIndex>
</samlp:LogoutRequest>";
var doc = XDocument.Parse(xml);
var result = _parser.Parse(doc);
result.ShouldNotBeNull();
result.Destination.ShouldBeNull();
}
}

View file

@ -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<AuthNRequestParser>.Instance);
private const string BaseAuthNRequest = """
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_12345"
Version="2.0"
IssueInstant="2024-01-01T00:00:00Z">
<saml:Issuer>https://sp.example.com</saml:Issuer>
{0}
</samlp:AuthnRequest>
""";
[Fact]
[Trait("Category", Category)]
public void parse_single_authn_context_class_ref_succeeds()
{
var requestedAuthnContext = """
<samlp:RequestedAuthnContext Comparison="exact">
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
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 = """
<samlp:RequestedAuthnContext Comparison="minimum">
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:X509</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
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 = $"""
<samlp:RequestedAuthnContext Comparison="{comparisonValue}">
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
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 = """
<samlp:RequestedAuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
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 = """
<samlp:RequestedAuthnContext Comparison="invalid">
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
var xml = string.Format(BaseAuthNRequest, requestedAuthnContext);
var doc = System.Xml.Linq.XDocument.Parse(xml);
var result = Should.Throw<ArgumentException>(() => _parser.Parse(doc));
result.Message.ShouldBe("Unknown AuthnContextComparison: invalid");
}
[Fact]
[Trait("Category", Category)]
public void parse_no_authn_context_class_ref_throws()
{
var requestedAuthnContext = """
<samlp:RequestedAuthnContext Comparison="exact">
</samlp:RequestedAuthnContext>
""";
var xml = string.Format(BaseAuthNRequest, requestedAuthnContext);
var doc = System.Xml.Linq.XDocument.Parse(xml);
var result = Should.Throw<InvalidOperationException>(() => _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 = """
<samlp:RequestedAuthnContext Comparison="exact">
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
<saml:AuthnContextClassRef> </saml:AuthnContextClassRef>
<saml:AuthnContextClassRef></saml:AuthnContextClassRef>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:X509</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
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 = """
<samlp:RequestedAuthnContext>
<saml:AuthnContextClassRef> urn:oasis:names:tc:SAML:2.0:ac:classes:Password </saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
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 = """
<samlp:RequestedAuthnContext>
<saml:AuthnContextClassRef></saml:AuthnContextClassRef>
<saml:AuthnContextClassRef> </saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
""";
var xml = string.Format(BaseAuthNRequest, requestedAuthnContext);
var doc = System.Xml.Linq.XDocument.Parse(xml);
var result = Should.Throw<InvalidOperationException>(() => _parser.Parse(doc));
result.Message.ShouldBe("No AuthnContextClassRef element found in requestedAuthnContext");
}
}

View file

@ -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<SamlAssertionEncryptor>.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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() => _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");
}
}

View file

@ -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<SamlOptions> _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<SamlClaimsService>.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<string, string>
{
[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<string, string>(customMappings)
};
var service = new SamlClaimsService(_profileService, NullLogger<SamlClaimsService>.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<string, string>(new Dictionary<string, string>())
};
var service = new SamlClaimsService(_profileService, NullLogger<SamlClaimsService>.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<string, string>
{
["email"] = "emailAddress",
["department"] = "ou"
};
var optionsWithCustomMappings = new SamlOptions
{
DefaultClaimMappings = new ReadOnlyDictionary<string, string>(customMappings)
};
var service = new SamlClaimsService(_profileService, NullLogger<SamlClaimsService>.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<string, string>(new Dictionary<string, string>
{
["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<string, string>(new Dictionary<string, string>
{
["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<string, string>
{
["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<string, string>(customMappings)
};
var service = new SamlClaimsService(_profileService, NullLogger<SamlClaimsService>.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<SamlClaimsService>.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);
}
}

View file

@ -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<InvalidOperationException>(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<InvalidOperationException>(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("<LogoutRequest");
xml.ShouldContain("user@example.com");
xml.ShouldContain("session123");
}
[Fact]
[Trait("Category", Category)]
public async Task http_post_should_encode_request_as_base64()
{
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.EncodedContent.ShouldNotBeNullOrEmpty();
var decodedBytes = Convert.FromBase64String(result.EncodedContent);
var xml = Encoding.UTF8.GetString(decodedBytes);
xml.ShouldContain("<LogoutRequest");
xml.ShouldContain("Signature");
}
[Fact]
[Trait("Category", Category)]
public async Task should_set_correct_issue_instant()
{
var sp = CreateServiceProvider();
var expectedTime = _timeProvider.GetUtcNow().UtcDateTime;
var result = await _subject.BuildLogoutRequestAsync(
sp,
"user@example.com",
null,
"session123",
"https://idp.example.com");
var xml = await DecodeRedirectRequest(result.EncodedContent);
var expectedIssueInstant = expectedTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
xml.ShouldContain($"IssueInstant=\"{expectedIssueInstant}\"");
}
[Fact]
[Trait("Category", Category)]
public async Task should_set_correct_destination()
{
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($"Destination=\"{sp.SingleLogoutServiceUrl!.Location}\"");
}
[Fact]
[Trait("Category", Category)]
public async Task should_set_correct_issuer()
{
var sp = CreateServiceProvider();
var issuer = "https://idp.example.com";
var result = await _subject.BuildLogoutRequestAsync(
sp,
"user@example.com",
null,
"session123",
issuer);
var xml = await DecodeRedirectRequest(result.EncodedContent);
xml.ShouldContain($"<Issuer xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\">{issuer}</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 xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\">{nameId}</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>{sessionIndex}</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<string> 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<SamlProtocolMessageSigner>.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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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<LogoutMessage> _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<SamlLogoutCallbackProcessor>.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>(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>(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>(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>(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>(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>(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>(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>(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>(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<string, SamlServiceProvider> ServiceProviders { get; } = [];
public Task<SamlServiceProvider?> 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<string> GetCurrentAsync() => Task.FromResult(IssuerName);
}
}

View file

@ -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<SamlLogoutNotificationService>.Instance);
}
private static SamlProtocolMessageSigner CreateSigner()
{
var cert = CreateTestCertificate();
var mockSigningService = new MockSamlSigningService(cert);
return new SamlProtocolMessageSigner(mockSigningService, NullLogger<SamlProtocolMessageSigner>.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
};
}

View file

@ -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<SamlProtocolMessageSigner>.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("<Issuer", StringComparison.InvariantCulture);
var indexOfSignature = signedXml.IndexOf("<Signature", StringComparison.InvariantCulture);
indexOfSignature.ShouldBeGreaterThan(indexOfIssuer);
}
[Fact]
[Trait("Category", Category)]
public async Task sign_protocol_message_should_use_rsa_sha256_algorithm()
{
var signer = CreateSigner();
var logoutResponse = CreateLogoutResponseElement();
var signedXml = await signer.SignProtocolMessage(logoutResponse, _samlServiceProvider);
signedXml.ShouldContain("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256");
signedXml.ShouldContain("http://www.w3.org/2001/04/xmlenc#sha256");
}
[Fact]
[Trait("Category", Category)]
public async Task sign_protocol_message_should_include_certificate_in_key_info()
{
var signer = CreateSigner();
var logoutResponse = CreateLogoutResponseElement();
var signedXml = await signer.SignProtocolMessage(logoutResponse, _samlServiceProvider);
signedXml.ShouldContain("KeyInfo");
signedXml.ShouldContain("X509Data");
signedXml.ShouldContain("X509Certificate");
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_should_add_signature_and_sig_alg()
{
var signer = CreateSigner();
var queryString = "?SAMLRequest=encodedrequest";
var signedQueryString = await signer.SignQueryString(queryString);
signedQueryString.ShouldContain("&SigAlg=");
signedQueryString.ShouldContain("&Signature=");
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_should_preserve_original_query_string()
{
var signer = CreateSigner();
var queryString = "?SAMLRequest=encodedrequest&RelayState=state123";
var signedQueryString = await signer.SignQueryString(queryString);
signedQueryString.ShouldStartWith(queryString);
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_should_include_sig_alg_parameter()
{
var signer = CreateSigner();
var queryString = "?SAMLRequest=encodedrequest";
var signedQueryString = await signer.SignQueryString(queryString);
// The SigAlg parameter should be present
signedQueryString.ShouldContain("&SigAlg=");
var sigAlgPart = signedQueryString.Split("&SigAlg=")[1].Split("&")[0];
sigAlgPart.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_signature_should_be_base64_encoded()
{
var signer = CreateSigner();
var queryString = "?SAMLRequest=encodedrequest";
var signedQueryString = await signer.SignQueryString(queryString);
var signaturePart = signedQueryString.Split("&Signature=")[1];
var decodedSignature = Uri.UnescapeDataString(signaturePart);
// Should be valid base64
var bytes = Convert.FromBase64String(decodedSignature);
bytes.ShouldNotBeEmpty();
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_signature_should_be_url_encoded()
{
var signer = CreateSigner();
var queryString = "?SAMLRequest=encodedrequest";
var signedQueryString = await signer.SignQueryString(queryString);
// Base64 can contain + and / which should be URL encoded
signedQueryString.ShouldNotContain("Signature= "); // No unencoded spaces
signedQueryString.ShouldContain("Signature="); // But has the parameter
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_with_relay_state_should_sign_complete_string()
{
var signer = CreateSigner();
var queryString = "?SAMLRequest=encoded&RelayState=mystate";
var signedQueryString = await signer.SignQueryString(queryString);
// SigAlg should come after RelayState but before Signature
var sigAlgIndex = signedQueryString.IndexOf("&SigAlg=", StringComparison.Ordinal);
var relayStateIndex = signedQueryString.IndexOf("RelayState=", StringComparison.Ordinal);
var signatureIndex = signedQueryString.IndexOf("&Signature=", StringComparison.Ordinal);
sigAlgIndex.ShouldBeGreaterThan(relayStateIndex);
signatureIndex.ShouldBeGreaterThan(sigAlgIndex);
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_should_produce_consistent_signature_for_same_input()
{
var signer = CreateSigner();
var queryString = "?SAMLRequest=encodedrequest";
var signedQueryString1 = await signer.SignQueryString(queryString);
var signedQueryString2 = await signer.SignQueryString(queryString);
// Signatures should be identical for same input with same key
signedQueryString1.ShouldBe(signedQueryString2);
}
[Fact]
[Trait("Category", Category)]
public async Task sign_query_string_should_produce_different_signature_for_different_input()
{
var signer = CreateSigner();
var queryString1 = "?SAMLRequest=request1";
var queryString2 = "?SAMLRequest=request2";
var signedQueryString1 = await signer.SignQueryString(queryString1);
var signedQueryString2 = await signer.SignQueryString(queryString2);
// Extract just the signature parts
var signature1 = signedQueryString1.Split("&Signature=")[1];
var signature2 = signedQueryString2.Split("&Signature=")[1];
signature1.ShouldNotBe(signature2);
}
}

View file

@ -0,0 +1,188 @@
// 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 Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IdentityModel.Tokens;
using UnitTests.Common;
namespace UnitTests.Saml;
public class SamlSigningServiceTests
{
private const string Category = "SAML Signing Service";
private readonly MockKeyMaterialService _mockKeyMaterialService = new();
private readonly SamlSigningService _signingService;
public SamlSigningServiceTests() =>
_signingService = new SamlSigningService(
_mockKeyMaterialService,
NullLogger<SamlSigningService>.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<InvalidOperationException>(
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<InvalidOperationException>(
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<InvalidOperationException>(
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<InvalidOperationException>(
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<InvalidOperationException>(
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();
}
}

View file

@ -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;
/// <summary>
/// Security tests for SecureXmlParser to ensure protection against common XML attacks
/// </summary>
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 = "<root><child>value</child></root>";
// 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 = "<root><child>value</child></root>";
// 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 = "<root><child>value</child></root>";
// 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<ArgumentNullException>(() =>
SecureXmlParser.LoadXmlDocument(null!));
[Fact]
[Trait("Category", Category)]
public void load_xml_document_with_empty_xml_should_throw_argument_null_exception() =>
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
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 = @"<?xml version=""1.0""?>
<!DOCTYPE root [
<!ENTITY xxe SYSTEM ""file:///etc/passwd"">
]>
<root>&xxe;</root>";
// Act & Assert
var exception = Should.Throw<XmlException>(() =>
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 = @"<?xml version=""1.0""?>
<!DOCTYPE root [
<!ELEMENT root ANY>
]>
<root>content</root>";
// Act & Assert
var exception = Should.Throw<XmlException>(() =>
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 = @"<?xml version=""1.0""?>
<!DOCTYPE lolz [
<!ENTITY lol ""lol"">
<!ENTITY lol2 ""&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"">
<!ENTITY lol3 ""&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;"">
<!ENTITY lol4 ""&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;"">
]>
<lolz>&lol4;</lolz>";
// Act & Assert
Should.Throw<XmlException>(() =>
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 = @"<?xml version=""1.0""?>
<!DOCTYPE root [
<!ENTITY external SYSTEM ""http://evil.com/malicious.dtd"">
]>
<root>&external;</root>";
// Act & Assert
Should.Throw<XmlException>(() =>
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 = $"<root>{largeContent}</root>";
// Act & Assert
var exception = Should.Throw<XmlException>(() =>
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 = @"<root>
<!-- This is a comment -->
<child>value</child>
<!-- Another comment -->
</root>";
// 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 = @"<?xml version=""1.0""?>
<?xml-stylesheet type=""text/xsl"" href=""style.xsl""?>
<root>
<child>value</child>
</root>";
// 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 = "<root><unclosed>";
// Act & Assert
Should.Throw<XmlException>(() =>
SecureXmlParser.LoadXmlDocument(malformedXml));
}
[Fact]
[Trait("Category", Category)]
public void load_xml_document_with_saml_response_should_succeed()
{
// Arrange - Real SAML Response structure
var samlResponse = @"<samlp:Response xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol""
xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion""
ID=""_response123""
Version=""2.0""
IssueInstant=""2024-01-01T00:00:00Z"">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value=""urn:oasis:names:tc:SAML:2.0:status:Success""/>
</samlp:Status>
<saml:Assertion ID=""_assertion123"" Version=""2.0"" IssueInstant=""2024-01-01T00:00:00Z"">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<saml:Subject>
<saml:NameID>user@example.com</saml:NameID>
</saml:Subject>
</saml:Assertion>
</samlp:Response>";
// 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 = @"<root>
<child>value</child>
</root>";
// 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 = @"<?xml version=""1.0""?>
<!DOCTYPE root [
<!ENTITY xxe SYSTEM ""file:///etc/passwd"">
]>
<root>&xxe;</root>";
// Act & Assert
Should.Throw<XmlException>(() =>
SecureXmlParser.LoadXElement(xxeAttack));
}
[Fact]
[Trait("Category", Category)]
public void load_x_document_with_billion_laughs_attack_should_throw_xml_exception()
{
// Arrange
var billionLaughsAttack = @"<?xml version=""1.0""?>
<!DOCTYPE lolz [
<!ENTITY lol ""lol"">
<!ENTITY lol2 ""&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"">
]>
<lolz>&lol2;</lolz>";
// Act & Assert
Should.Throw<XmlException>(() =>
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 = $"<root>{largeContent}</root>";
// Act & Assert
var exception = Should.Throw<XmlException>(() =>
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);
}

View file

@ -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<SamlResponse> _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("<Response");
var indexOfResponse = signedXml.IndexOf("<Response", StringComparison.InvariantCulture);
var indexOfSignature = signedXml.IndexOf("<Signature", StringComparison.InvariantCulture);
indexOfSignature.ShouldBeGreaterThan(indexOfResponse);
}
[Fact]
[Trait("Category", Category)]
public void sign_assertion_in_response_valid_response_adds_signature_to_assertion()
{
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 },
Assertion = new Assertion
{
IssueInstant = DateTime.UtcNow,
Issuer = "https://idp.example.com",
Subject = new Subject
{
NameId = new NameIdentifier
{
Value = "user@example.com",
Format = SamlConstants.NameIdentifierFormats.EmailAddress
}
}
}
};
var responseElement = _responseSerializer.Serialize(response);
var cert = CreateTestCertificate();
var signedXml = XmlSignatureHelper.SignAssertionInResponse(responseElement, cert);
signedXml.ShouldContain("Signature");
var xmlDoc = new XmlDocument();
xmlDoc.LoadXml(signedXml);
var nsmgr = new XmlNamespaceManager(xmlDoc.NameTable);
nsmgr.AddNamespace("saml", SamlConstants.Namespaces.Assertion);
nsmgr.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#");
var signatureInAssertion = xmlDoc.SelectSingleNode("//saml:Assertion/ds:Signature", nsmgr);
signatureInAssertion.ShouldNotBeNull();
}
[Fact]
[Trait("Category", Category)]
public void sign_both_valid_response_adds_both_signatures()
{
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 },
Assertion = new Assertion
{
IssueInstant = DateTime.UtcNow,
Issuer = "https://idp.example.com"
}
};
var responseElement = _responseSerializer.Serialize(response);
var cert = CreateTestCertificate();
var signedXml = XmlSignatureHelper.SignBoth(responseElement, cert);
var xmlDoc = new XmlDocument();
xmlDoc.LoadXml(signedXml);
var nsmgr = new XmlNamespaceManager(xmlDoc.NameTable);
nsmgr.AddNamespace("samlp", SamlConstants.Namespaces.Protocol);
nsmgr.AddNamespace("saml", SamlConstants.Namespaces.Assertion);
nsmgr.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#");
// Should have signature in Response
var responseSignature = xmlDoc.SelectSingleNode("//samlp:Response/ds:Signature", nsmgr);
responseSignature.ShouldNotBeNull();
// Should have signature in Assertion
var assertionSignature = xmlDoc.SelectSingleNode("//saml:Assertion/ds:Signature", nsmgr);
assertionSignature.ShouldNotBeNull();
}
[Fact]
[Trait("Category", Category)]
public void sign_response_signature_placed_after_issuer()
{
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);
var xmlDoc = new XmlDocument();
xmlDoc.LoadXml(signedXml);
var responseXmlElement = xmlDoc.DocumentElement;
responseXmlElement.ShouldNotBeNull();
// Find Issuer and Signature elements
XmlNode? issuerNode = null;
XmlNode? signatureNode = null;
foreach (XmlNode child in responseXmlElement.ChildNodes)
{
if (child.LocalName == "Issuer")
{
issuerNode = child;
}
else if (child.LocalName == "Signature")
{
signatureNode = child;
}
}
issuerNode.ShouldNotBeNull();
signatureNode.ShouldNotBeNull();
// Signature should come after Issuer in document order
var issuerIndex = 0;
var signatureIndex = 0;
var index = 0;
foreach (XmlNode child in responseXmlElement.ChildNodes)
{
if (child == issuerNode)
{
issuerIndex = index;
}
if (child == signatureNode)
{
signatureIndex = index;
}
index++;
}
signatureIndex.ShouldBeGreaterThan(issuerIndex);
}
[Fact]
[Trait("Category", Category)]
public void sign_response_uses_rsa_sha256_algorithm()
{
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("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256");
signedXml.ShouldContain("http://www.w3.org/2001/04/xmlenc#sha256");
}
[Fact]
[Trait("Category", Category)]
public void sign_response_includes_certificate_in_key_info()
{
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("KeyInfo");
signedXml.ShouldContain("X509Data");
signedXml.ShouldContain("X509Certificate");
}
[Fact]
[Trait("Category", Category)]
public void sign_response_element_without_id_throws_exception()
{
var samlp = XNamespace.Get("urn:oasis:names:tc:SAML:2.0:protocol");
var saml = XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion");
var responseElement = new XElement(samlp + "Response",
new XAttribute(XNamespace.Xmlns + "samlp", samlp),
new XAttribute(XNamespace.Xmlns + "saml", saml),
new XElement(saml + "Issuer", "https://idp.example.com"));
var cert = CreateTestCertificate();
Should.Throw<ArgumentException>(() =>
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<ArgumentException>(() =>
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<ArgumentNullException>(() =>
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<ArgumentException>(() =>
XmlSignatureHelper.SignAssertionInResponse(responseElement, cert))
.Message.ShouldContain("Assertion");
}
}

View file

@ -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();
}
}