SAML 2.0 tokens and WIF – bridging the divide

Background

We all know the following limitations about Windows Identity Foundation (WIF) and passive (browser) federation protocols, right?

  • WIF does not support SAML2.0 protocol (SAML2P)
    • There is a WIF extension out there to support SAML2P but it is a technology preview
  • WIF does support SAML2.0 (SAML2) tokens
  • WS-Federation conveys SAML1.1 tokens

Therefore, unless you use the WIF extension there is no way of getting a SAML2 token into a Relying Party (RP) application.

Well, did you know the following?

  • WS-Federation responses convey WS-Trust RequestSecurityTokenResponse (RSTR) elements containing security tokens
  • The RSTR element is not tied into a particular token format
    • i.e. – it is a <xs:any> element in the schema

This means that it is perfectly valid for a WS-Federation response to convey a SAML2.0 token and it is only because WS-Federation existed before SAML2P that it became a de facto convention for WS-Federation to convey SAML1.1 tokens.

So, it IS possible to get a SAML2 token into a RP application as long as it is possible to get a SAML2 token into a WS-Federation response.

Here’s how.

Methodology

To achieve this I am using Active Directory Federation Services (ADFS 2.0) as it is simple to integrate with WIF and it understands SAML2P, and therefore SAML2 tokens.

The setup is a Windows Server 2012 domain controller with the ADFS 2.1 role installed. All code was written using Visual Studio 2012 and .NET framework 4.5 although the same could be achieved with Windows Server 2008 R2, Visual Studio 2010 and .NET framework 4.0.

As discussed, this won’t work:

image

But this should:

image

The strategy is to place an intermediary RP-STS between RP and ADFS which performs protocol transition from SAML2P to WS-Federation.

To make things a little simpler, I have applied the following restrictions to the RP-STS:

  • It is not a signing authority
    • That is, it cannot create authoritative tokens itself but simply passes on tokens received from other providers
  • It does not validate either the protocol wrapper or token itself
    • This would involve a lot of extra code and the RP is going to do it anyway
    • The SAMLResponse wrapper element received from ADFS containing a SAML2 token is not digitally signed
      • The SAML2P specification does allow for a digital signature though
  • The RP-STS only recognises messages transmitted using the SAML 2.0 HTTP redirect and POST bindings

The RP-STS itself is simply a ASP.NET HTTP module hosted in IIS. The first event in the ASP.NET pipeline, BeginRequest, is handled as there is nothing more for the module to do than parse tokens.

The flow of events is as follows:

image

The actual code is shown below:

 namespace RelyingPartySTS
{
    using System;
    using System.Configuration;
    using System.IdentityModel.Protocols.WSTrust;
    using System.IdentityModel.Services;
    using System.IdentityModel.Tokens;
    using System.IO;
    using System.Web;
    using System.Xml;

    public class TranslatorModule : IHttpModule
    {
        private const string SamlRequestTemplate = "<samlp:AuthnRequest ID=\"id-{0}\" Version=\"2.0\" IssueInstant=\"{1}\" Destination=\"https://www.bradsts.com/adfs/ls/\" Consent=\"urn:oasis:names:tc:SAML:2.0:consent:unspecified\" xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\"><Issuer xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\">{2}</Issuer><samlp:NameIDPolicy Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\" AllowCreate=\"true\" /></samlp:AuthnRequest>";

        private const string SamlHandlerContent = "<html><head><title>Working...</title></head><body><form method='POST' name='hiddenform' action='{0}'><input type='hidden' name='SAMLRequest' value='{1}' /><noscript><p>Script is disabled. Click Submit to continue.</p><input type='submit' value='Submit' /></noscript></form><script language='javascript'>window.setTimeout('document.forms[0].submit()', 0);</script></body></html>";

        public void Dispose()
        {
            //clean-up code here.
        }

        public void Init(HttpApplication context)
        {
            context.BeginRequest += new EventHandler(OnBeginRequest);
        }

        public void OnBeginRequest(Object source, EventArgs e)
        {
            try
            {
                WSFederationMessage mess = WSFederationMessage.CreateFromUri(HttpContext.Current.Request.Url);

                if (mess is SignInRequestMessage)
                {
                    SignInRequestMessage messIn = mess as SignInRequestMessage;

                    // Convert the WS-Fed request into a SAML2 request
                    string parsedRequest = System.Web.HttpUtility.HtmlEncode(
                        System.Convert.ToBase64String(
                        System.Text.Encoding.UTF8.GetBytes(
                        string.Format(
                            SamlRequestTemplate, 
                            Guid.NewGuid().ToString(), 
                            DateTime.UtcNow.ToString("yyyy-MM-ddTHH:MM:ss.fffZ"), 
                            messIn.Realm))));

                    // Redirect the request to ADFS
                    string finalisedRedirectString = string.Format(
                        SamlHandlerContent,
                        ConfigurationManager.AppSettings["stsUrl"],
                        parsedRequest);

                    HttpContext.Current.Response.Write(finalisedRedirectString);
                    HttpContext.Current.ApplicationInstance.CompleteRequest();
                }
            }
            catch (WSFederationMessageException)
            {
                // Parse the SAMLResponse
                if (HttpContext.Current.Request.Form.HasKeys())
                {
                    if (HttpContext.Current.Request.Form["SAMLResponse"] != null)
                    {
                        // Decode the response
                        string samlResponse = HttpContext.Current.Request.Form["SAMLResponse"];
                        string responseDecoded = System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String(System.Web.HttpUtility.HtmlDecode(samlResponse)));

                        Saml2SecurityToken token = null;

                        // Pick out the token
                        using (StringReader sr = new StringReader(responseDecoded))
                        {
                            using (XmlReader reader = XmlReader.Create(sr))
                            {
                                reader.ReadToFollowing("Assertion", "urn:oasis:names:tc:SAML:2.0:assertion");
                                
                                // Deserialize the token so that data can be taken from it and plugged into the RSTR
                                SecurityTokenHandlerCollection coll = SecurityTokenHandlerCollection.CreateDefaultSecurityTokenHandlerCollection();
                                token = (Saml2SecurityToken)coll.ReadToken(reader.ReadSubtree());
                            }
                        }

                        // Create a WS-Fed sign in response
                        RequestSecurityTokenResponse rstr = new RequestSecurityTokenResponse();
                        rstr.TokenType = "urn:oasis:names:tc:SAML:2.0:assertion";
                        rstr.RequestType = "https://schemas.xmlsoap.org/ws/2005/02/trust/Issue";
                        rstr.KeyType = "https://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey";
                        rstr.Lifetime = new Lifetime(token.Assertion.IssueInstant, token.Assertion.Conditions.NotOnOrAfter);
                        rstr.AppliesTo = new EndpointReference(token.Assertion.Conditions.AudienceRestrictions[0].Audiences[0].OriginalString);
                        rstr.RequestedSecurityToken = new RequestedSecurityToken(token);
                        WSTrustFeb2005ResponseSerializer ser = new WSTrustFeb2005ResponseSerializer();
                        MemoryStream ms = new MemoryStream();

                        SignInResponseMessage messOut = null;

                        using (StreamReader sr = new StreamReader(ms))
                        {
                            using (XmlWriter writer = XmlWriter.Create(ms))
                            {
                                ser.WriteXml(rstr, writer, new WSTrustSerializationContext());
                            }

                            ms.Position = 0;

                            messOut = new SignInResponseMessage(new Uri(ConfigurationManager.AppSettings["rpUrl"]), sr.ReadToEnd());
                        }                    

                        // Redirect the browser to RP
                        messOut.WriteFormPost();
                        HttpContext.Current.Response.Write(messOut.WriteFormPost());
                        HttpContext.Current.ApplicationInstance.CompleteRequest();
                    }
                }
            }
        }
    }
}

This ‘almost’ works in that the SAML2 token is successfully extracted from the ADFS response and plugged into a WS-Federation response which is forwarded to the RP website.

However, it turns out that WIF takes exception to the token! You will get the following error message:

“ID4154: A Saml2SecurityToken cannot be created from the Saml2Assertion because it contains a SubjectConfirmationData which specifies an InResponseTo value. Enforcement of this value is not supported by default. To customize SubjectConfirmationData processing, extend Saml2SecurityTokenHandler and override ValidateConfirmationData.”

If, as the error suggests, a custom SecurityTokenHandler implementation is created that derives from the default Saml2SecurityTokenHandler implementation, and the ValidateConfirmationData method is overridden, this error can be avoided.

An aside:

In fact, upon inspection of the SAML2 token, the InResponseTo attribute referred to above is actually inappropriate for the RP website as it contains the URL for the RP-STS website. However, as WIF appears to block tokens containing this attribute anyway, there can’t very well be much meaningful code beyond the error that would worry about it.

I believe that this inaccuracy in the URL could be circumvented by combining the RP and RP-STS websites together so that they exist under a common domain name. The conversion from WS-Federation request to SAMLRequest could take place in the OnRedirectingToIdentityProvider event handler and conversely, the conversion from ADFS SAMLResponse to WS-Federation response could take place in a HTTP module which fires before the default WIF pipeline takes over.

Conclusion

I have shown that it is possible to use relatively few lines of code to facilitate the transmission of SAML2 tokens into a standard WIF protected website by transitioning to/from WS-Federation to SAML2P federation protocols.

I believe that in the absence of proper SAML2P support in Windows Identity Foundation this approach, with a bit of work to bring the code up to production standards, is a viable one.

[BradleyCotier]