I have separated the SAML authentication into an ExternalAuthenticationService layer (called from the controller) for easier unit testing:
Controller
[Route("samlConsume")]
public async Task<IActionResult> AssertionConsumerAsync()
{
var samlLoginInfo = await externalAuthenticationService.AuthenticateAsync(HttpContext);
// ...
}
Service
public async Task<LoginInfo> AuthenticateAsync(HttpContext context)
{
var req = context.Request.ToGenericHttpRequest();
var binding = new Saml2PostBinding();
var saml2AuthnResponse = new Saml2AuthnResponse(config);
binding.ReadSamlResponse(req, saml2AuthnResponse);
if (saml2AuthnResponse.Status != Saml2StatusCodes.Success)
{
throw new AuthenticationException($"SAML Response status: {saml2AuthnResponse.Status}");
}
binding.Unbind(req, saml2AuthnResponse);
var sessionPrincipal = await saml2AuthnResponse.CreateSession(
context, claimsTransform: (claimsPrincipal) => ClaimsTransform.Transform(claimsPrincipal)
);
// do stuff with the sessionPrincipal and return a LoginInfo
}
This is all pretty much boilerplate stuff from the TestWebAppCore sample.
Unit testing the Service is proving to be problematic. I have a unit test that creates a request, fakes certificate, creates a Mock<HttpContext>
, and tries to execute the AuthenticateAsync
method:
Unit Test
namespace unit_tests.Service
{
public class ExternalAuthenticationServiceTests
{
private readonly string Destination = "http://testdestination.com";
private readonly IExternalAuthenticationService service;
public ExternalAuthenticationServiceTests()
{
var testCertificate = GenerateCertificate("Test Certificate");
var config = new Saml2Configuration()
{
AudienceRestricted = false,
CertificateValidationMode = X509CertificateValidationMode.None,
Issuer = "http://wonderwoman.com",
RevocationMode = X509RevocationMode.NoCheck,
SignatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
SigningCertificate = testCertificate,
SingleSignOnDestination = new Uri(Destination),
ValidateArtifact = false,
};
service = new ExternalAuthenticationService(config);
}
[Fact]
public async void AuthenticateAsync_sets_the_proper_values_in_SamlLoginInfo()
{
var contextMock = new Mock<HttpContext>();
contextMock
.SetupProperty(m => m.Request.Method, "POST")
.SetupProperty(m => m.Request.QueryString, QueryString.Empty)
.SetupProperty(m => m.Request.Query, QueryCollection.Empty)
.SetupProperty(m => m.Request.Form, FakeSamlResponse())
.Setup(m => m.Request.Headers).Returns(new HeaderDictionary());
var result = await service.AuthenticateAsync(contextMock.Object);
// assert some stuff
}
private X509Certificate2 GenerateCertificate(string subjectName)
{
// Generate a new RSA key pair
using var rsa = RSA.Create(2048);
// Create a certificate request
CertificateRequest request = new($"CN={subjectName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Set certificate validity dates
DateTimeOffset now = DateTimeOffset.UtcNow;
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
// Create a self-signed certificate
X509Certificate2 certificate = request.CreateSelfSigned(now, now.AddYears(1));
// Return the generated certificate
return certificate;
}
private static FormCollection FakeSamlResponse()
{
var samlResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(SamlResponseXml));
var fields = new Dictionary<string, StringValues>
{
["SAMLResponse"] = samlResponse
};
var formData = new FormCollection(fields);
return formData;
}
private static string SamlResponseXml =>
@"<samlp:Response xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol"" ID=""_some-id_"" Version=""2.0"" IssueInstant=""2023-07-01T12:00:00Z"">
<saml:Issuer xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion"">https://example.com/idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value=""urn:oasis:names:tc:SAML:2.0:status:Success"" />
</samlp:Status>
<saml:Assertion xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion"" ID=""_some-assertion-id_"" Version=""2.0"" IssueInstant=""2023-07-01T12:00:00Z"">
<saml:Issuer>https://example.com/idp</saml:Issuer>
<saml:Subject>
<saml:NameID Format=""urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"">john.doe@example.com</saml:NameID>
<saml:SubjectConfirmation Method=""urn:oasis:names:tc:SAML:2.0:cm:bearer"">
<saml:SubjectConfirmationData NotOnOrAfter=""3000-07-01T12:00:00Z"" Recipient=""https://example.com/sp"" />
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore=""2020-06-27T00:00:00Z"" NotOnOrAfter=""3000-06-28T00:00:00Z"">
<saml:AudienceRestriction>
<saml:Audience>https://example.com/sp</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant=""2023-06-27T12:00:00Z"" SessionIndex=""_some-session-index_"">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
</saml:Assertion>
</samlp:Response>";
}
}
…but I am getting an error:
Message: ITfoxtec.Identity.Saml2.Cryptography.InvalidSignatureException : Signature is invalid.
Stack Trace: Saml2Request.ValidateXmlSignature(SignatureValidation documentValidationResult)
How can I configure this so that the signing certificate I'm generating matches the SAML response?