/* Was Jitbit's simple SAML 2.0 component for ASP.NET https://github.com/jitbit/AspNetSaml/ (c) Jitbit LP, 2016 Use this freely under the Apache license (see https://choosealicense.com/licenses/apache-2.0/) version 1.2.3 */ using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.Xml; using System.Text; using System.Web; using System.Xml; namespace Jellyfin.Plugin.SSO_Auth; /// /// Represents a SAML response. /// public class Response { private readonly X509Certificate2 _certificate; private XmlDocument _xmlDoc; private XmlNamespaceManager _xmlNameSpaceManager; // we need this one to run our XPath queries on the SAML XML /// /// Initializes a new instance of the class. /// /// The certificate formatted as a Base64 string. /// The SAML response formatted as a string. public Response(string certificateStr, string responseString) : this(Convert.FromBase64String(certificateStr), responseString) { } /// /// Initializes a new instance of the class. /// /// The certificate formatted as an array of bytes. /// The SAML response formatted as a string. public Response(byte[] certificateBytes, string responseString) : this(certificateBytes) { LoadXmlFromBase64(responseString); } /// /// Initializes a new instance of the class. /// /// The certificate formatted as a Base64 string. public Response(string certificateStr) : this(Convert.FromBase64String(certificateStr)) { } /// /// Initializes a new instance of the class. /// /// The certificate formatted as an array of bytes. public Response(byte[] certificateBytes) { _certificate = X509CertificateLoader.LoadCertificate(certificateBytes); } /// /// Gets the SAML response's XML data. /// public string Xml => _xmlDoc.OuterXml; /// /// Loads XML from the parameter into the instance's XML data. /// /// The XML string to put into the class. public void LoadXml(string xml) { _xmlDoc = new XmlDocument(); _xmlDoc.PreserveWhitespace = true; _xmlDoc.XmlResolver = null; _xmlDoc.LoadXml(xml); _xmlNameSpaceManager = GetNamespaceManager(); // lets construct a "manager" for XPath queries } /// /// Loads Base64 encoded XML from the parameter into the instance's XML data. /// /// The Base64 encoded XML string to put into the class. public void LoadXmlFromBase64(string response) { LoadXml(Encoding.UTF8.GetString(Convert.FromBase64String(response))); } /// /// Checks whether the XML response is valid by verifying the signature. /// /// Whether the XML response is valid. public bool IsValid() { var nodeList = _xmlDoc.SelectNodes("//ds:Signature", _xmlNameSpaceManager); var 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) { if (signedXml.SignedInfo.References.Count != 1) // no ref at all { return false; } var reference = (Reference)signedXml.SignedInfo.References[0]; var id = reference.Uri.Substring(1); var idElement = signedXml.GetIdElement(_xmlDoc, id); if (idElement == _xmlDoc.DocumentElement) { return true; } 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; } private bool IsExpired() { var expirationDate = DateTime.MaxValue; var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", _xmlNameSpaceManager); if (node != null && node.Attributes["NotOnOrAfter"] != null) { DateTime.TryParse(node.Attributes["NotOnOrAfter"].Value, out expirationDate); } return DateTime.UtcNow > expirationDate.ToUniversalTime(); } /// /// Gets the name ID attribute from the XML response. /// /// The name ID attribute. public string GetNameID() { var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:Subject/saml:NameID", _xmlNameSpaceManager); return node.InnerText; } /// /// Gets the UPN attribute from the XML response. /// /// The UPN attribute. public virtual string GetUpn() { return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"); } /// /// Gets the email attribute from the XML response. /// /// The email attribute. public virtual string GetEmail() { return GetCustomAttribute("User.email") // some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") // some providers put last name into an attribute named "mail" ?? GetCustomAttribute("mail"); } /// /// Gets the First Name attribute from the XML response. /// /// The First Name attribute. public virtual string GetFirstName() { return GetCustomAttribute("first_name") // some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname") ?? GetCustomAttribute("User.FirstName") // some providers put last name into an attribute named "givenName" ?? GetCustomAttribute("givenName"); } /// /// Gets the Last Name attribute from the XML response. /// /// The Last Name attribute. public virtual string GetLastName() { return GetCustomAttribute("last_name") // some providers (for example Azure AD) put last name into an attribute named "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname") ?? GetCustomAttribute("User.LastName") // some providers put last name into an attribute named "sn" ?? GetCustomAttribute("sn"); } /// /// Gets the department attribute from the XML response. /// /// The department attribute. public virtual string GetDepartment() { return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department") ?? GetCustomAttribute("department"); } /// /// Gets the phone attribute from the XML response. /// /// The phone attribute. public virtual string GetPhone() { return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone") ?? GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/telephonenumber"); } /// /// Gets the company attribute from the XML response. /// /// The company attribute. public virtual string GetCompany() { return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname") ?? GetCustomAttribute("organization") ?? GetCustomAttribute("User.CompanyName"); } /// /// Gets the location attribute from the XML response. /// /// The location attribute. public virtual string GetLocation() { return GetCustomAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/location") ?? GetCustomAttribute("physicalDeliveryOfficeName"); } /// /// Gets the first custom attribute from the XML response. /// /// The custom attribute to query. /// The custom attribute. public string GetCustomAttribute(string attr) { var node = _xmlDoc.SelectSingleNode("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); return node?.InnerText; } /// /// Gets the values for a custom attribute from the XML response. /// /// The custom attribute to query. /// The custom attributes. public List GetCustomAttributes(string attr) { var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); List output = new List(); foreach (XmlNode item in node) { output.Add(item?.InnerText); } return output; } // 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() { var 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; } } /// /// Represents a SAML request. /// public class AuthRequest { private readonly string _id; private readonly string _issueInstant; private readonly string _issuer; private readonly string _assertionConsumerServiceUrl; /// /// Initializes a new instance of the class.. /// /// The issuer of the SAML request. /// The SAML assertion URL. public AuthRequest(string issuer, string assertionConsumerServiceUrl) { _id = "_" + Guid.NewGuid().ToString(); _issueInstant = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture); _issuer = issuer; _assertionConsumerServiceUrl = assertionConsumerServiceUrl; } /// /// The formatting of the AuthRequest. /// public enum AuthRequestFormat { /// /// Base64 request. /// Base64 = 1 } /// /// Gets the SAML request. /// /// The format the request should be returned in. /// The request as a string, either Base64 or not, depending on the format parameter. public string GetRequest(AuthRequestFormat format) { using var sw = new StringWriter(); var xws = new XmlWriterSettings(); xws.OmitXmlDeclaration = true; using (var 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", _issueInstant); xw.WriteAttributeString("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"); xw.WriteAttributeString("AssertionConsumerServiceURL", _assertionConsumerServiceUrl); 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(); } 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(); var result = Convert.ToBase64String(memoryStream.GetBuffer(), 0, (int)memoryStream.Length, Base64FormattingOptions.None); return result; } return null; } /// /// Gets the the URL you should redirect your users to (i.e. your SAML-provider login URL with the Base64-ed request in the querystring. /// /// The SAML endpoint. /// The relay state. /// The redirect url. public string GetRedirectUrl(string samlEndpoint, string relayState = null) { var queryStringSeparator = samlEndpoint.Contains('?') ? "&" : "?"; var url = samlEndpoint + queryStringSeparator + "SAMLRequest=" + HttpUtility.UrlEncode(GetRequest(AuthRequestFormat.Base64)); if (!string.IsNullOrEmpty(relayState)) { url += "&RelayState=" + HttpUtility.UrlEncode(relayState); } return url; } }