Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Signout request #81

Merged
merged 2 commits into from
Jun 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AspNetSaml.Tests/UnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@
[TestMethod]
public void TestSamlRequest()
{
var samlEndpoint = "http://saml-provider-that-we-use.com/login/";

Check warning on line 93 in AspNetSaml.Tests/UnitTests.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'samlEndpoint' is assigned but its value is never used

Check warning on line 93 in AspNetSaml.Tests/UnitTests.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'samlEndpoint' is assigned but its value is never used

var request = new AuthRequest(
"http://www.myapp.com",
"http://www.myapp.com/SamlConsume"
);

var r = request.GetRequest(AuthRequest.AuthRequestFormat.Base64);
var r = request.GetRequest();

//decode the compressed base64
var ms = new MemoryStream(Convert.FromBase64String(r));
Expand Down
270 changes: 176 additions & 94 deletions AspNetSaml/Saml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,29 @@ Use this freely under the Apache license (see https://choosealicense.com/license

namespace Saml
{
public class Response
public abstract class BaseResponse
{
protected XmlDocument _xmlDoc;
protected readonly X509Certificate2 _certificate;
protected XmlNamespaceManager _xmlNameSpaceManager; //we need this one to run our XPath queries on the SAML XML

protected XmlNamespaceManager _xmlNameSpaceManager; //we need this one to run our XPath queries on the SAML XML
public string Xml { get { return _xmlDoc.OuterXml; } }

public Response(string certificateStr, string responseString)
public BaseResponse(string certificateStr, string responseString)
: this(Encoding.ASCII.GetBytes(certificateStr), responseString) { }

public Response(byte[] certificateBytes, string responseString) : this(certificateBytes)
public BaseResponse(byte[] certificateBytes, string responseString) : this(certificateBytes)
{
LoadXmlFromBase64(responseString);
}

public Response(string certificateStr) : this(Encoding.ASCII.GetBytes(certificateStr)) { }
public BaseResponse(string certificateStr) : this(Encoding.ASCII.GetBytes(certificateStr)) { }

public Response(byte[] certificateBytes)
public BaseResponse(byte[] certificateBytes)
{
_certificate = new X509Certificate2(certificateBytes);
}

}
/// <summary>
/// Parse SAML response XML (in case was it not passed in constructor)
/// </summary>
Expand All @@ -49,37 +49,21 @@ public void LoadXml(string xml)
_xmlDoc.XmlResolver = null;
_xmlDoc.LoadXml(xml);

_xmlNameSpaceManager = GetNamespaceManager(); //lets construct a "manager" for XPath queries
_xmlNameSpaceManager = GetNamespaceManager(); //lets construct a "manager" for XPath queries
}

public void LoadXmlFromBase64(string response)
{
UTF8Encoding enc = new UTF8Encoding();
LoadXml(enc.GetString(Convert.FromBase64String(response)));
}

/// <summary>
/// Checks the validity of SAML response (validate signature, check expiration date etc)
/// </summary>
/// <returns></returns>
public bool IsValid()
{
XmlNodeList nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);

SignedXml signedXml = new SignedXml(_xmlDoc);

if (nodeList.Count == 0) return false;

signedXml.LoadXml((XmlElement)nodeList[0]);
return ValidateSignatureReference(signedXml) && signedXml.CheckSignature(_certificate, true) && !IsExpired();
}

//an XML signature can "cover" not the whole document, but only a part of it
//.NET's built in "CheckSignature" does not cover this case, it will validate to true.
//We should check the signature reference, so it "references" the id of the root document element! If not - it's a hack
private bool ValidateSignatureReference(SignedXml signedXml)
}

//an XML signature can "cover" not the whole document, but only a part of it
//.NET's built in "CheckSignature" does not cover this case, it will validate to true.
//We should check the signature reference, so it "references" the id of the root document element! If not - it's a hack
protected bool ValidateSignatureReference(SignedXml signedXml)
{
if (signedXml.SignedInfo.References.Count != 1) //no ref at all
if (signedXml.SignedInfo.References.Count != 1) //no ref at all
return false;

var reference = (Reference)signedXml.SignedInfo.References[0];
Expand All @@ -89,14 +73,53 @@ private bool ValidateSignatureReference(SignedXml signedXml)

if (idElement == _xmlDoc.DocumentElement)
return true;
else //sometimes its not the "root" doc-element that is being signed, but the "assertion" element
else //sometimes its not the "root" doc-element that is being signed, but the "assertion" element
{
var assertionNode = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion", _xmlNameSpaceManager) as XmlElement;
if (assertionNode != idElement)
return false;
}

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()
{
XmlNamespaceManager manager = new XmlNamespaceManager(_xmlDoc.NameTable);
manager.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl);
manager.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion");
manager.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol");

return manager;
}
}

public class Response : BaseResponse
{
public Response(string certificateStr, string responseString) : base(certificateStr, responseString) { }

public Response(byte[] certificateBytes, string responseString) : base(certificateBytes, responseString) { }

public Response(string certificateStr) : base(certificateStr) { }

public Response(byte[] certificateBytes) : base(certificateBytes) { }

/// <summary>
/// Checks the validity of SAML response (validate signature, check expiration date etc)
/// </summary>
/// <returns></returns>
public bool IsValid()
{
XmlNodeList nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager);

SignedXml signedXml = new SignedXml(_xmlDoc);

if (nodeList.Count == 0) return false;

signedXml.LoadXml((XmlElement)nodeList[0]);
return ValidateSignatureReference(signedXml) && signedXml.CheckSignature(_certificate, true) && !IsExpired();
}

private bool IsExpired()
Expand Down Expand Up @@ -188,54 +211,108 @@ public List<string> GetCustomAttributeAsList(string attr)
{
XmlNodeList nodes = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager);
return nodes == null ? null : nodes.Cast<XmlNode>().Select(x => x.InnerText).ToList();
}

//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);
manager.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion");
manager.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol");

return manager;
}
}

public class AuthRequest
public class SignoutResponse : BaseResponse
{
public string _id;
private string _issue_instant;
public SignoutResponse(string certificateStr, string responseString) : base(certificateStr, responseString) { }

private string _issuer;
private string _assertionConsumerServiceUrl;
public SignoutResponse(byte[] certificateBytes, string responseString) : base(certificateBytes, responseString) { }

/// <summary>
/// get or sets if ForceAuthn attribute is sent to IdP
/// </summary>
public bool ForceAuthn { get; set; }
public SignoutResponse(string certificateStr) : base(certificateStr) { }

public enum AuthRequestFormat
public SignoutResponse(byte[] certificateBytes) : base(certificateBytes) { }

public string GetLogoutStatus()
{
Base64 = 1
XmlNode node = _xmlDoc.SelectSingleNode("/samlp:LogoutResponse/samlp:Status/samlp:StatusCode", _xmlNameSpaceManager);
return node == null ? null : node.Attributes["Value"].Value.Replace("urn:oasis:names:tc:SAML:2.0:status:", string.Empty);
}
}

public abstract class BaseRequest
{
public string _id;
protected string _issue_instant;

protected string _issuer;

public AuthRequest(string issuer, string assertionConsumerServiceUrl)
public BaseRequest(string issuer)
{
_id = "_" + Guid.NewGuid().ToString();
_issue_instant = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);

_issuer = issuer;
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
}

public abstract string GetRequest();

protected static string ConvertToBase64Deflated(string input)
{
//byte[] toEncodeAsBytes = System.Text.ASCIIEncoding.ASCII.GetBytes(input);
//return System.Convert.ToBase64String(toEncodeAsBytes);

//https://stackoverflow.com/questions/25120025/acs75005-the-request-is-not-a-valid-saml2-protocol-message-is-showing-always%3C/a%3E
var memoryStream = new MemoryStream();
using (var writer = new StreamWriter(new DeflateStream(memoryStream, CompressionMode.Compress, true), new UTF8Encoding(false)))
{
writer.Write(input);
writer.Close();
}
string result = Convert.ToBase64String(memoryStream.GetBuffer(), 0, (int)memoryStream.Length, Base64FormattingOptions.None);
return result;
}

/// <summary>
/// returns the URL you should redirect your users to (i.e. your SAML-provider login URL with the Base64-ed request in the querystring
/// </summary>
/// <param name="samlEndpoint">SAML provider login url</param>
/// <param name="relayState">Optional state to pass through</param>
/// <returns></returns>
public string GetRedirectUrl(string samlEndpoint, string relayState = null)
{
var queryStringSeparator = samlEndpoint.Contains("?") ? "&" : "?";

var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + Uri.EscapeDataString(GetRequest());

if (!string.IsNullOrEmpty(relayState))
{
url += "&RelayState=" + Uri.EscapeDataString(relayState);
}

return url;
}
}

public class AuthRequest : BaseRequest
{
private string _assertionConsumerServiceUrl;

public AuthRequest(string issuer, string assertionConsumerServiceUrl) : base(issuer)
{
_assertionConsumerServiceUrl = assertionConsumerServiceUrl;
}

/// <summary>
/// get or sets if ForceAuthn attribute is sent to IdP
/// </summary>
public bool ForceAuthn { get; set; }

public enum AuthRequestFormat
{
Base64 = 1
}

[Obsolete("Obsolete, will be removed, use GetRequest()")]
public string GetRequest(AuthRequestFormat format) => GetRequest();

/// <summary>
/// returns SAML request as compressed and Base64 encoded XML. You don't need this method
/// </summary>
/// <param name="format"></param>
/// <returns></returns>
public string GetRequest(AuthRequestFormat format)
public override string GetRequest()
{
using (StringWriter sw = new StringWriter())
{
Expand All @@ -260,54 +337,59 @@ public string GetRequest(AuthRequestFormat format)
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.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();*/
xw.WriteEndElement();
}
}

return ConvertToBase64Deflated(sw.ToString());
}
}
}

if (format == AuthRequestFormat.Base64)
{
//byte[] toEncodeAsBytes = System.Text.ASCIIEncoding.ASCII.GetBytes(sw.ToString());
//return System.Convert.ToBase64String(toEncodeAsBytes);

//https://stackoverflow.com/questions/25120025/acs75005-the-request-is-not-a-valid-saml2-protocol-message-is-showing-always%3C/a%3E
var memoryStream = new MemoryStream();
var writer = new StreamWriter(new DeflateStream(memoryStream, CompressionMode.Compress, true), new UTF8Encoding(false));
writer.Write(sw.ToString());
writer.Close();
string result = Convert.ToBase64String(memoryStream.GetBuffer(), 0, (int)memoryStream.Length, Base64FormattingOptions.None);
return result;
}
public class SignoutRequest : BaseRequest
{
private string _nameId;

return null;
}
public SignoutRequest(string issuer, string nameId) : base(issuer)
{
_nameId = nameId;
}

/// <summary>
/// returns the URL you should redirect your users to (i.e. your SAML-provider login URL with the Base64-ed request in the querystring
/// </summary>
/// <param name="samlEndpoint">SAML provider login url</param>
/// <param name="relayState">Optional state to pass through</param>
/// <returns></returns>
public string GetRedirectUrl(string samlEndpoint, string relayState = null)
public override string GetRequest()
{
var queryStringSeparator = samlEndpoint.Contains("?") ? "&" : "?";
using (StringWriter sw = new StringWriter())
{
XmlWriterSettings xws = new XmlWriterSettings();
xws.OmitXmlDeclaration = true;

var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + Uri.EscapeDataString(GetRequest(AuthRequestFormat.Base64));
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);

if (!string.IsNullOrEmpty(relayState))
{
url += "&RelayState=" + Uri.EscapeDataString(relayState);
}
xw.WriteStartElement("saml", "Issuer", "urn:oasis:names:tc:SAML:2.0:assertion");
xw.WriteString(_issuer);
xw.WriteEndElement();

return url;
xw.WriteStartElement("saml", "NameID", "urn:oasis:names:tc:SAML:2.0:assertion");
xw.WriteString(_nameId);
xw.WriteEndElement();

xw.WriteEndElement();
}

return ConvertToBase64Deflated(sw.ToString());
}
}
}
}