From d02700def4d52e530ea36cd4bd58b8f1efb52328 Mon Sep 17 00:00:00 2001 From: "javier.ayllon" Date: Wed, 16 Jul 2025 09:20:27 +0200 Subject: [PATCH] Added new BaseRequest overload to pass a certificate and use it to sign the SAML requests Tested against spanish public ID service Cl@ave https://clave.gob.es/clave --- AspNetSaml/Saml.cs | 249 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 222 insertions(+), 27 deletions(-) diff --git a/AspNetSaml/Saml.cs b/AspNetSaml/Saml.cs index 76ab5b0..d979717 100644 --- a/AspNetSaml/Saml.cs +++ b/AspNetSaml/Saml.cs @@ -14,6 +14,10 @@ Use this freely under the Apache license (see https://choosealicense.com/license using System.IO.Compression; using System.Text; using System.Runtime; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; +using System.Xml; + namespace Saml { @@ -93,9 +97,42 @@ protected bool ValidateSignatureReference(SignedXml signedXml) return true; } - //returns namespace manager, we need one b/c MS says so... Otherwise XPath doesnt work in an XML doc with namespaces - //see https://stackoverflow.com/questions/7178111/why-is-xmlnamespacemanager-necessary - private XmlNamespaceManager GetNamespaceManager() + protected string SignAuthnRequest(string samlRequestXml, X509Certificate2 certificate) + { + XmlDocument xmlDoc = new XmlDocument(); + xmlDoc.PreserveWhitespace = true; + xmlDoc.LoadXml(samlRequestXml); + + SignedXml signedXml = new SignedXml(xmlDoc); + signedXml.SigningKey = certificate.PrivateKey; + + Reference reference = new Reference(); + reference.Uri = "#" + xmlDoc.DocumentElement.GetAttribute("ID"); + + XmlDsigEnvelopedSignatureTransform env = new XmlDsigEnvelopedSignatureTransform(); + reference.AddTransform(env); + + signedXml.AddReference(reference); + + KeyInfo keyInfo = new KeyInfo(); + keyInfo.AddClause(new KeyInfoX509Data(certificate)); + signedXml.KeyInfo = keyInfo; + + signedXml.ComputeSignature(); + + XmlElement xmlDigitalSignature = signedXml.GetXml(); + xmlDoc.DocumentElement.InsertBefore(xmlDigitalSignature, xmlDoc.DocumentElement.FirstChild); + + return xmlDoc.OuterXml; + } + + + + + + //returns namespace manager, we need one b/c MS says so... Otherwise XPath doesnt work in an XML doc with namespaces + //see https://stackoverflow.com/questions/7178111/why-is-xmlnamespacemanager-necessary + private XmlNamespaceManager GetNamespaceManager() { XmlNamespaceManager manager = new XmlNamespaceManager(_xmlDoc.NameTable); manager.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl); @@ -118,8 +155,10 @@ public bool IsValid() if (nodeList.Count == 0) return false; signedXml.LoadXml((XmlElement)nodeList[0]); - return ValidateSignatureReference(signedXml) && signedXml.CheckSignature(_certificate, true) && !IsExpired(); - } + + return ValidateSignatureReference(signedXml) && signedXml.CheckSignature(_certificate, true) && !IsExpired(); + + } protected virtual bool IsExpired() { @@ -288,7 +327,9 @@ public abstract class BaseRequest protected string _issuer; - public BaseRequest(string issuer) + private X509Certificate2? _signingCertificate; + + public BaseRequest(string issuer) { _id = "_" + Guid.NewGuid().ToString(); _issue_instant = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture); @@ -296,9 +337,21 @@ public BaseRequest(string issuer) _issuer = issuer; } - public abstract string GetRequest(); + public BaseRequest(string issuer, X509Certificate2 certificate) + { + _id = "_" + Guid.NewGuid().ToString(); + _issue_instant = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture); + + _issuer = issuer; + + _signingCertificate = certificate; + } + + public abstract string GetRequest(); + + public abstract string GetSignedRequest(X509Certificate2 certificate); - protected static string ConvertToBase64Deflated(string input) + protected static string ConvertToBase64Deflated(string input) { //byte[] toEncodeAsBytes = System.Text.ASCIIEncoding.ASCII.GetBytes(input); //return System.Convert.ToBase64String(toEncodeAsBytes); @@ -323,8 +376,16 @@ protected static string ConvertToBase64Deflated(string input) public string GetRedirectUrl(string samlEndpoint, string relayState = null) { var queryStringSeparator = samlEndpoint.Contains("?") ? "&" : "?"; + string samlRequest = GetRequest(); + if (_signingCertificate != null) + { + samlRequest = GetSignedRequest(_signingCertificate); + } + else + samlRequest = GetRequest(); + - var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + Uri.EscapeDataString(GetRequest()); + var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + Uri.EscapeDataString(samlRequest); if (!string.IsNullOrEmpty(relayState)) { @@ -333,26 +394,70 @@ public string GetRedirectUrl(string samlEndpoint, string relayState = null) return url; } - } - public class AuthRequest : BaseRequest + + public static string SignAuthnRequest(string samlRequestXml, X509Certificate2 certificate) + { + XmlDocument xmlDoc = new XmlDocument(); + xmlDoc.PreserveWhitespace = true; + xmlDoc.LoadXml(samlRequestXml); + + SignedXml signedXml = new SignedXml(xmlDoc); + signedXml.SigningKey = certificate.PrivateKey; + + Reference reference = new Reference(); + reference.Uri = "#" + xmlDoc.DocumentElement.GetAttribute("ID"); + + XmlDsigEnvelopedSignatureTransform env = new XmlDsigEnvelopedSignatureTransform(); + reference.AddTransform(env); + + signedXml.AddReference(reference); + + KeyInfo keyInfo = new KeyInfo(); + keyInfo.AddClause(new KeyInfoX509Data(certificate)); + signedXml.KeyInfo = keyInfo; + + signedXml.ComputeSignature(); + + XmlElement xmlDigitalSignature = signedXml.GetXml(); + xmlDoc.DocumentElement.InsertBefore(xmlDigitalSignature, xmlDoc.DocumentElement.FirstChild); + + return xmlDoc.OuterXml; + } + + } + + public class AuthRequest : BaseRequest { private string _assertionConsumerServiceUrl; + + /// - /// Initializes new instance of AuthRequest - /// - /// put your EntityID here - /// put your return URL here - public AuthRequest(string issuer, string assertionConsumerServiceUrl) : base(issuer) + /// Initializes new instance of AuthRequest + /// + /// put your EntityID here + /// put your return URL here + public AuthRequest(string issuer, string assertionConsumerServiceUrl) : base(issuer) { _assertionConsumerServiceUrl = assertionConsumerServiceUrl; } - /// - /// get or sets if ForceAuthn attribute is sent to IdP - /// - public bool ForceAuthn { get; set; } + /// + /// Initializes new instance of SIGNED AuthRequest + /// + /// put your EntityID here + /// put your return URL here + /// X509Certificate2 to sign the request + public AuthRequest(string issuer, string assertionConsumerServiceUrl, X509Certificate2 certificate) : base(issuer, certificate) + { + _assertionConsumerServiceUrl = assertionConsumerServiceUrl; + } + + /// + /// get or sets if ForceAuthn attribute is sent to IdP + /// + public bool ForceAuthn { get; set; } [Obsolete("Obsolete, will be removed")] public enum AuthRequestFormat @@ -393,12 +498,12 @@ public override string GetRequest() xw.WriteAttributeString("AllowCreate", "true"); xw.WriteEndElement(); - /*xw.WriteStartElement("samlp", "RequestedAuthnContext", "urn:oasis:names:tc:SAML:2.0:protocol"); + xw.WriteStartElement("samlp", "RequestedAuthnContext", "urn:oasis:names:tc:SAML:2.0:protocol"); xw.WriteAttributeString("Comparison", "exact"); xw.WriteStartElement("saml", "AuthnContextClassRef", "urn:oasis:names:tc:SAML:2.0:assertion"); - xw.WriteString("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + xw.WriteString("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + xw.WriteEndElement(); xw.WriteEndElement(); - xw.WriteEndElement();*/ xw.WriteEndElement(); } @@ -406,7 +511,59 @@ public override string GetRequest() return ConvertToBase64Deflated(sw.ToString()); } } - } + + + /// + /// returns SAML request as compressed and Base64 encoded XML. You don't need this method + /// + /// + public override string GetSignedRequest(X509Certificate2 certificate) + { + using (StringWriter sw = new StringWriter()) + { + XmlWriterSettings xws = new XmlWriterSettings { OmitXmlDeclaration = true }; + + using (XmlWriter xw = XmlWriter.Create(sw, xws)) + { + xw.WriteStartElement("samlp", "AuthnRequest", "urn:oasis:names:tc:SAML:2.0:protocol"); + xw.WriteAttributeString("ID", _id); + xw.WriteAttributeString("Version", "2.0"); + xw.WriteAttributeString("IssueInstant", _issue_instant); + xw.WriteAttributeString("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"); + xw.WriteAttributeString("AssertionConsumerServiceURL", _assertionConsumerServiceUrl); + if (ForceAuthn) + xw.WriteAttributeString("ForceAuthn", "true"); + + xw.WriteStartElement("saml", "Issuer", "urn:oasis:names:tc:SAML:2.0:assertion"); + xw.WriteString(_issuer); + xw.WriteEndElement(); + + xw.WriteStartElement("samlp", "NameIDPolicy", "urn:oasis:names:tc:SAML:2.0:protocol"); + xw.WriteAttributeString("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"); + xw.WriteAttributeString("AllowCreate", "true"); + xw.WriteEndElement(); + + /*xw.WriteStartElement("samlp", "RequestedAuthnContext", "urn:oasis:names:tc:SAML:2.0:protocol"); + xw.WriteAttributeString("Comparison", "exact"); + xw.WriteStartElement("saml", "AuthnContextClassRef", "urn:oasis:names:tc:SAML:2.0:assertion"); + xw.WriteString("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + xw.WriteEndElement(); + xw.WriteEndElement();*/ + + xw.WriteEndElement(); + } + + // Sign the request + string signedXml = SignAuthnRequest(sw.ToString(), certificate); + + + return ConvertToBase64Deflated(signedXml); + } + } + + + + } /// /// Represents an SP-initiated Logout Request to be sent to the IdP. @@ -415,12 +572,16 @@ public class SignoutRequest : BaseRequest { private string _nameId; - public SignoutRequest(string issuer, string nameId) : base(issuer) + public SignoutRequest(string issuer, string nameId, X509Certificate2 certificate) : base(issuer, certificate) { _nameId = nameId; } + public SignoutRequest(string issuer, string nameId) : base(issuer) + { + _nameId = nameId; + } - public override string GetRequest() + public override string GetRequest() { using (StringWriter sw = new StringWriter()) { @@ -447,7 +608,41 @@ public override string GetRequest() return ConvertToBase64Deflated(sw.ToString()); } } - } + + public override string GetSignedRequest(X509Certificate2 certificate) + { + using (StringWriter sw = new StringWriter()) + { + XmlWriterSettings xws = new XmlWriterSettings { OmitXmlDeclaration = true }; + + using (XmlWriter xw = XmlWriter.Create(sw, xws)) + { + xw.WriteStartElement("samlp", "LogoutRequest", "urn:oasis:names:tc:SAML:2.0:protocol"); + xw.WriteAttributeString("ID", _id); + xw.WriteAttributeString("Version", "2.0"); + xw.WriteAttributeString("IssueInstant", _issue_instant); + + xw.WriteStartElement("saml", "Issuer", "urn:oasis:names:tc:SAML:2.0:assertion"); + xw.WriteString(_issuer); + xw.WriteEndElement(); + + xw.WriteStartElement("saml", "NameID", "urn:oasis:names:tc:SAML:2.0:assertion"); + xw.WriteString(_nameId); + xw.WriteEndElement(); + + xw.WriteEndElement(); + } + + + // Sign the request + string signedXml = SignAuthnRequest(sw.ToString(), certificate); + + return ConvertToBase64Deflated(signedXml); + + } + } + + } public static class MetaData {