mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
Added missing SAML-relevant tests
This commit is contained in:
parent
72df8704fc
commit
e0034d642a
41 changed files with 11246 additions and 13 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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\"");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue