.netsamladfsws-federationws-trust

Parse and verify a WS Trust XML Token


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>person@stuff.com</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>stuff@local.com</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!


Solution

  • 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.