Parse and verify a WS Trust XML Token

2.1k views Asked by At

I have a webservice written in c#/.NET that redirects unauthenticated users to a WS Federation identity provider, which then redirects back to my webservice with a SAML token which has the roles of that user. This is as per the passive WS federation specification - http://docs.oasis-open.org/wsfed/federation/v1.2/os/ws-federation-1.2-spec-os.html#_Toc223175008

Having got this, I get a request which has the wresult set to be the token. In my code I've got a string wresult which is the string for the xml document. What I know is the realm im on, the thumbprint of the identity provider, the wctx (if it was sent).

The security token is a standard WS-Trust token described here: http://specs.xmlsoap.org/ws/2005/02/trust/WS-Trust.pdf

What I want to get is the SecurityToken and eventually the IPrincipal for that user just from that string which is the XML document/security token.

An example of the string would be (with a few things obfuscated).

<?xml version="1.0"?>
<t:RequestSecurityTokenResponse xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust">
  <t:Lifetime>
    <wsu:Created xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">2018-09-14T13:40:25.164Z</wsu:Created>
    <wsu:Expires xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">2018-09-14T14:40:25.164Z</wsu:Expires>
  </t:Lifetime>
  <wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
    <wsa:EndpointReference xmlns:wsa="http://www.w3.org/2005/08/addressing">
      <wsa:Address>https://localhost:44366/</wsa:Address>
    </wsa:EndpointReference>
  </wsp:AppliesTo>
  <t:RequestedSecurityToken>
    <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion" MajorVersion="1" MinorVersion="1" AssertionID="_e1580903-02ac-453d-a157-ae27c8614cc9" Issuer="http://adfs.ORGANISATION.com/adfs/services/trust" IssueInstant="2018-09-14T13:40:25.164Z">
      <saml:Conditions NotBefore="2018-09-14T13:40:25.164Z" NotOnOrAfter="2018-09-14T14:40:25.164Z">
        <saml:AudienceRestrictionCondition>
          <saml:Audience>https://localhost:44366/</saml:Audience>
        </saml:AudienceRestrictionCondition>
      </saml:Conditions>
      <saml:AttributeStatement>
        <saml:Subject>
          <saml:SubjectConfirmation>
            <saml:ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:bearer</saml:ConfirmationMethod>
          </saml:SubjectConfirmation>
        </saml:Subject>
        <saml:Attribute AttributeName="emailaddress" AttributeNamespace="http://schemas.xmlsoap.org/ws/2005/05/identity/claims">
          <saml:AttributeValue>[email protected]</saml:AttributeValue>
        </saml:Attribute>
        <saml:Attribute AttributeName="givenname" AttributeNamespace="http://schemas.xmlsoap.org/ws/2005/05/identity/claims">
          <saml:AttributeValue>Jeff</saml:AttributeValue>
        </saml:Attribute>
        <saml:Attribute AttributeName="surname" AttributeNamespace="http://schemas.xmlsoap.org/ws/2005/05/identity/claims">
          <saml:AttributeValue>Mandelson</saml:AttributeValue>
        </saml:Attribute>
        <saml:Attribute AttributeName="windowsaccountname" AttributeNamespace="http://schemas.microsoft.com/ws/2008/06/identity/claims">
          <saml:AttributeValue>jeff.mandelson</saml:AttributeValue>
        </saml:Attribute>
        <saml:Attribute AttributeName="role" AttributeNamespace="http://schemas.microsoft.com/ws/2008/06/identity/claims">
          <saml:AttributeValue>Stuff\Domain Users</saml:AttributeValue>
          <saml:AttributeValue>Stuff\DevTeam</saml:AttributeValue>
          <saml:AttributeValue>Stuff\RDS-MSSQLDEV-RW</saml:AttributeValue>
        </saml:Attribute>
        <saml:Attribute AttributeName="upn" AttributeNamespace="http://schemas.xmlsoap.org/ws/2005/05/identity/claims">
          <saml:AttributeValue>[email protected]</saml:AttributeValue>
        </saml:Attribute>
        <saml:Attribute AttributeName="name" AttributeNamespace="http://schemas.xmlsoap.org/ws/2005/05/identity/claims">
          <saml:AttributeValue>Jeff Mandelson</saml:AttributeValue>
        </saml:Attribute>
      </saml:AttributeStatement>
      <saml:AuthenticationStatement AuthenticationMethod="urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" AuthenticationInstant="2018-09-14T11:59:16.147Z">
        <saml:Subject>
          <saml:SubjectConfirmation>
            <saml:ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:bearer</saml:ConfirmationMethod>
          </saml:SubjectConfirmation>
        </saml:Subject>
      </saml:AuthenticationStatement>
      <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:SignedInfo>
          <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
          <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
          <ds:Reference URI="#_e1580903-02ac-453d-a157-ae27c8614cc9">
            <ds:Transforms>
              <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
              <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
            </ds:Transforms>
            <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
            <ds:DigestValue>a_digest_value_removed</ds:DigestValue>
          </ds:Reference>
        </ds:SignedInfo>
        <ds:SignatureValue>signature</ds:SignatureValue>
        <KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
          <X509Data>
            <X509Certificate>certificate</X509Certificate>
          </X509Data>
        </KeyInfo>
      </ds:Signature>
    </saml:Assertion>
  </t:RequestedSecurityToken>
  <t:TokenType>urn:oasis:names:tc:SAML:1.0:assertion</t:TokenType>
  <t:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</t:RequestType>
  <t:KeyType>http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</t:KeyType>
</t:RequestSecurityTokenResponse>

I've tried using the inbuilt methods such as WSFederationAuthenticationModule, however, this seems to have problems unless you're using System.Web.Request. An inbuilt .NET/C# function would be preferable!

2

There are 2 answers

0
Wiktor Zychla On BEST ANSWER

The solution is to think of the token as it was a regular XMLDsig signed XML - the assertion node is signed and the signature's reference points back to it. The code is rather simple, what's interesting however is that the SignedXml class has to be inherited to have the signature validator that follows the AssertionID attribute (the default convention is that the signed node's id attribute is called just ID and the default validator just won't find the node that has the id attribute called differently).

public class SamlSignedXml : SignedXml
{
    public SamlSignedXml(XmlElement e) : base(e) { }

    public override XmlElement GetIdElement(XmlDocument document, string idValue)
    {
        XmlNamespaceManager mgr = new XmlNamespaceManager(document.NameTable);
        mgr.AddNamespace("trust", "http://docs.oasis-open.org/ws-sx/ws-trust/200512");
        mgr.AddNamespace("wsu", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd");
        mgr.AddNamespace("saml", "urn:oasis:names:tc:SAML:1.0:assertion");

        XmlElement assertionNode = 
               (XmlElement)document.SelectSingleNode("//trust:RequestSecurityTokenResponseCollection/trust:RequestSecurityTokenResponse/"+
                                                     "trust:RequestedSecurityToken/saml:Assertion", mgr);

        if (assertionNode.Attributes["AssertionID"] != null &&
            string.Equals(assertionNode.Attributes["AssertionID"].Value, idValue, StringComparison.InvariantCultureIgnoreCase)
            )
            return assertionNode;

        return null;
    }
}

Note that the XPath assumes the token has the RequestSecurityTokenResponseCollection in the root, make sure your tokens follow this convention (in case of a single token, the collection node can be missing and the token's root could be just RequestSecurityTokenResponse, update the code accordingly).

The validation code is then

// token is the string representation of the SAML1 token
// expectedCertThumb is the expected certificate's thumbprint
protected bool ValidateToken( string token, string expectedCertThumb, out string userName )
{
 userName = string.Empty;

 if (string.IsNullOrEmpty(token)) return false;

 var xd = new XmlDocument();
 xd.PreserveWhitespace = true;
 xd.LoadXml(token);

 XmlNamespaceManager mgr = new XmlNamespaceManager(xd.NameTable);
 mgr.AddNamespace("trust", "http://docs.oasis-open.org/ws-sx/ws-trust/200512");
 mgr.AddNamespace("wsu", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd");
 mgr.AddNamespace("saml", "urn:oasis:names:tc:SAML:1.0:assertion");

 // assertion
 XmlElement assertionNode = (XmlElement)xd.SelectSingleNode("//trust:RequestSecurityTokenResponseCollection/trust:RequestSecurityTokenResponse/trust:RequestedSecurityToken/saml:Assertion", mgr);

 // signature
 XmlElement signatureNode = (XmlElement)xd.GetElementsByTagName("Signature")[0];

 var signedXml = new SamlSignedXml( assertionNode );
 signedXml.LoadXml(signatureNode);

 X509Certificate2 certificate = null;
 foreach (KeyInfoClause clause in signedXml.KeyInfo)
 {
  if (clause is KeyInfoX509Data)
  {
   if (((KeyInfoX509Data)clause).Certificates.Count > 0)
   {
    certificate =
    (X509Certificate2)((KeyInfoX509Data)clause).Certificates[0];
   }
  }
 }

 // cert node missing
 if (certificate == null) return false;

 // check the signature and return the result.
 var signatureValidationResult = signedXml.CheckSignature(certificate, true);

 if (signatureValidationResult == false) return false;

 // validate cert thumb
 if ( !string.IsNullOrEmpty( expectedCertThumb ) )
 {
  if ( !string.Equals( expectedCertThumb, certificate.Thumbprint ) )
   return false;
 }

 // retrieve username

 // expires = 
 var expNode = xd.SelectSingleNode("//trust:RequestSecurityTokenResponseCollection/trust:RequestSecurityTokenResponse/trust:Lifetime/wsu:Expires", mgr );

 DateTime expireDate;

 if (!DateTime.TryParse(expNode.InnerText, out expireDate)) return false; // wrong date

 if (DateTime.UtcNow > expireDate) return false; // token too old

 // claims
 var claimNodes =                 
   xd.SelectNodes("//trust:RequestSecurityTokenResponseCollection/trust:RequestSecurityTokenResponse/trust:RequestedSecurityToken/"+
                  "saml:Assertion/saml:AttributeStatement/saml:Attribute", mgr );
 foreach ( XmlNode claimNode in claimNodes )
 {
  if ( claimNode.Attributes["AttributeName"] != null && 
              claimNode.Attributes["AttributeNamespace"] != null &&
       string.Equals( claimNode.Attributes["AttributeName"].Value, "name", StringComparison.InvariantCultureIgnoreCase ) &&   
                     string.Equals( claimNode.Attributes["AttributeNamespace"].Value, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims", StringComparison.InvariantCultureIgnoreCase ) &&
         claimNode.ChildNodes.Count == 1 
      )
  {
   userName = claimNode.ChildNodes[0].InnerText;
   return true;
  }
 }

 return false;
}

With some minor tweaks, you should be able to do what you want.

BTW. Most of the answer is copied from my blog entry

https://www.wiktorzychla.com/2018/09/parsing-saml-11-ws-federation-tokens.html

that documents the approach we are using internally in one of our apps. I planned to make this entry for some time and your question was just a trigger I needed.

0
Nick On

Another way is to use the IdentityModel SamlTokenVerifier and Parser:

public static bool AuthenticateXmlToken(String wresult)
        {


            String pstrXML = wresult;

            // write it down 
            File.WriteAllText("C:\\Users\\USER\\Downloads\\asdf4.xml", wresult);

            // extract the SAML Assertion
            XmlReader reader = XmlReader.Create(new StringReader(pstrXML));
            reader.ReadToFollowing("Assertion", "urn:oasis:names:tc:SAML:1.0:assertion");

            // saml requirements 
            SamlSecurityTokenRequirement pRequirements = new SamlSecurityTokenRequirement();
            pRequirements.CertificateValidator = new CertificateValidator();

            SecurityTokenHandlerConfiguration pConfig = new SecurityTokenHandlerConfiguration();
            pConfig.AudienceRestriction = new AudienceRestriction(AudienceUriMode.Never);
            pConfig.IssuerNameRegistry = new IssuerNames();

            //pRequirements.ValidateAudienceRestriction()
            SamlSecurityTokenHandler pHandler = new SamlSecurityTokenHandler(pRequirements);
            pHandler.Configuration = pConfig;


            SecurityTokenHandlerCollection tokenHandlerCollection = SecurityTokenHandlerCollection.CreateDefaultSecurityTokenHandlerCollection();
            SamlSecurityToken token = (SamlSecurityToken)pHandler.ReadToken(reader.ReadSubtree());

            ReadOnlyCollection<ClaimsIdentity> pClaims = pHandler.ValidateToken(token);

            return pClaims.Count > 0;
        }


        public class IssuerNames : IssuerNameRegistry
        {
            public override string GetIssuerName(SecurityToken securityToken)
            {

                return "Issuer";
                throw new NotImplementedException();
            }
        }


        public class CertificateValidator : X509CertificateValidator
        {
            public override void Validate(X509Certificate2 certificate)
            {
                if (certificate == null)
                {
                    throw new Exception("certificate is null");
                }

                if (certificate.Thumbprint.ToLower() != "mythumprint")
                {
                    throw new Exception("X509 certficate is signed with the wrong public key!");
                }
            }
        }