Implementing SAML 2's SP-initiated SSO using WIF 4.5

|

A long time ago, I wrote a blog post explaining how to implement SAML's IdP initiated SSO in WIF. Since then, WIF has been incorporated into the .NET framework proper, but still does not support the SAML protocol (i.e., SAML-P). Also since my last post some extensions were released to add SAML support to WIF. In the three years since they were, however, they have idled in CTP where they are restricted by the license to non-production use. I know there are alternative frameworks that can help with this, but those may have their own licensing issues, do more than is needed, or come with other unwanted baggage. Shouldn't WIF be enough?! For those time when it is, the info in my old blog post is still very useful -- if you're implementing IdP-init SSO, that is. If you need SP-initiated SSO, however, you will still have a bit of work to do. In this post, I'll explain how to implement SP-initiated SSO using WIF 4.5. It is actually as easy as IdP-init (once you know how), and uses a lot of the same code.

Here's the bulk of the code.

Web Form

<%@ Page AutoEventWireup="true" CodeBehind="default.aspx.cs" Inherits="_Default" %>
<!DOCTYPE html>
<html>
<head>
    <title>Redirecting...</title>
</head>
<body onload="document.forms[0].submit()">
    <form id="form1" action="<%= acs %>" method="post">
        <input type="hidden" name="RelayState" id="RelayState" runat="server" />
        <input type="hidden" name="SAMLResponse" id="SAMLResponse" runat="server" />
        <noscript>
            <p>
                Please click Submit to continue. If you enable JavaScript in your browser,
                this manual interaction will not be needed.
            </p>
            <input type="submit" value="Submit" />
        </noscript>
    </form>
</body>
</html>

Code Behind

public partial class _Default : System.Web.UI.Page
{
    protected static readonly string acs = ConfigurationManager.AppSettings[
        "AssertionConsumerService"];

    protected HtmlInputHidden SAMLResponse;
    protected HtmlInputHidden RelayState;

    protected void Page_Load(object sender, EventArgs e)
    {
        var samlRequest = DecodeSamlRequest();

        RelayState.Value = Request.Params["RelayState"];
        SAMLResponse.Value = CreateSamlResponse(samlRequest);
    }

    private string CreateSamlResponse(SamlRequest samlRequest)
    {
        var claims = CreateClaims();
        var tokenHandler = new Saml2SecurityTokenHandler();
        var inResponseTo = samlRequest.Id;
        var token = CreateToken(claims, tokenHandler, inResponseTo);
        var samlResponse = CreateSamlResponseXml(tokenHandler, token, inResponseTo);

        return Convert.ToBase64String(Encoding.UTF8.GetBytes(samlResponse));
    }

    private static string CreateSamlResponseXml(Saml2SecurityTokenHandler tokenHandler,
        Saml2SecurityToken token, string inResponseTo)
    {
        var buffer = new StringBuilder();

        using (var stringWriter = new StringWriter(buffer))
        using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings()))
        {
            xmlWriter.WriteStartElement("Response",
                "urn:oasis:names:tc:SAML:2.0:protocol");
            xmlWriter.WriteAttributeString("IssueInstant", DateTime.UtcNow.ToString(
                "yyyy-MM-ddTHH:mm:ss.fffZ")); // Leaving that "Z" off cost me 2 days!!!
            xmlWriter.WriteAttributeString("ID", "_" + Guid.NewGuid());
            xmlWriter.WriteAttributeString("Version", "2.0");
            xmlWriter.WriteAttributeString("InResponseTo", inResponseTo);
            xmlWriter.WriteAttributeString("Destination", acs);

            xmlWriter.WriteElementString("Issuer", "urn:oasis:names:tc:SAML:2.0:assertion",
                issuer);

            xmlWriter.WriteStartElement("Status");
            xmlWriter.WriteStartElement("StatusCode");
            xmlWriter.WriteAttributeString("Value",
                "urn:oasis:names:tc:SAML:2.0:status:Success");
            xmlWriter.WriteEndElement();
            xmlWriter.WriteEndElement();

            tokenHandler.WriteToken(xmlWriter, token);

            xmlWriter.WriteEndElement();
        }

        return buffer.ToString();
    }

    private SAMLRequest DecodeSamlRequest()
    {
        // Assumes the SP is sending the authN request using the redirect binding
        var input = Convert.FromBase64String(Request.QueryString["SAMLRequest"]);
        XmlDocument doc = new XmlDocument();
        SAMLRequest samlRequest = new SAMLRequest(); // Simple POCO w/ a few properties

        using (var output = new MemoryStream())
        {
            using (var compressStream = new MemoryStream(input))
            {
                using (var decompressor = new DeflateStream(compressStream,
                    CompressionMode.Decompress))
                {
                    decompressor.CopyTo(output);
                }
            }

            output.Position = 0;

            doc.LoadXml(Encoding.UTF8.GetString(output.ToArray()));
        }

        samlRequest.Id = doc.FirstChild.Attributes["ID"].Value;

        return samlRequest;
    }

    private static Saml2SecurityToken CreateToken(IEnumerable<Claim> claims,
        Saml2SecurityTokenHandler tokenHandler, string inResponseTo)
    {
        var descriptor = CreateTokenDescriptor(claims);
        var token = tokenHandler.CreateToken(descriptor) as Saml2SecurityToken;

        AddAuthenticationStatement(token);
        AddConfirmationData(token, inResponseTo);

        return token;
    }

    private static void AddConfirmationData(Saml2SecurityToken token, string inResponseTo)
    {
        var confirmationData = new Saml2SubjectConfirmationData
        {
            Recipient = new Uri(acs),
            NotOnOrAfter = DateTime.UtcNow.AddMinutes(tokenLifetime),
        };

        // Shouldn't be blank for SP-init, but allows us to reuse this method w/ IdP-init
        if (!string.IsNullOrWhitespace(inResponseTo))
        {
            confirmationData.InResponseTo = inResponseTo;
        }

        token.Assertion.Subject.SubjectConfirmations.Clear();
        token.Assertion.Subject.SubjectConfirmations.Add(new Saml2SubjectConfirmation(new Uri(
            "urn:oasis:names:tc:SAML:2.0:cm:bearer"), confirmationData));
    }

The rest of the code that isn't listed here is pretty much as it was in my original post.

NOTE: I pulled this out of a much larger code base, and didn't thoroughly check it. If you find any non-trivial issues, leave a comment or let me know.

If you need to support things like name ID formats, authentication context class references, additional bindings, error handling (always good!), IdP-init, destination != acs, etc., etc., it becomes more complicated. If the complexity becomes too much, I would strongly discourage you from doing it yourself. If you're needs are relatively simple, however, this may work for you. Leave a comment or ping us if you have any questions.

Comments