Thursday, July 30, 2009

.NET Google SSO Part 2 of 2

This is the second in a series of articles discussing the implementation of an SSO application for Google Apps. The first .NET Google SSO Part 1 of 2 began the discussion by laying out the steps needed to handle the initial Authentication Request made by Google. This final piece will complete the package by illustrating the generation of the Authentication Response.


Google will expect the Authentication Response to come back as a form posted to the AssertionConsumerServiceURL provided by Google in the inital request. The form needs to have at least two elements on it, a <textarea> named SAMLResponse and an <input> named RelayState. The RelayState element is the url to redirect the user to at Google once the response has been sent. This is usually the same as the RelayState that Google provided to you via the RelayState url parameter.

Building the SAMLResponse is a little bit more involved. The SAMLResponse will contain the xml body of the AuthnResponse, which has the following format:

<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response ID="RESPONSE_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"
        xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
    <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
        <SignedInfo>
            <CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" />
            <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
            <Reference URI="">
                <Transforms>
                    <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                </Transforms>
                <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
                <DigestValue>DIGEST_VALUE</DigestValue>
            </Reference>
        </SignedInfo>
        <SignatureValue>SIGNATURE_VALUE</SignatureValue>
        <KeyInfo>
            <KeyValue>
                <RSAKeyValue>
                    <Modulus>RSA_KEY_MODULUS</Modulus>
                    <Exponent>RSA_KEY_EXPONENT</Exponent>
                </RSAKeyValue>
            </KeyValue>
        </KeyInfo>
    </Signature>
    <samlp:Status>
        <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
    </samlp:Status>
    <Assertion ID="ASSERTION_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
        <Issuer>https://www.opensaml.org/IDP</Issuer>
        <Subject>
            <NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress">
                USERNAME_STRING
            </NameID>
            <SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
                <SubjectConfirmationData Recipient="ACS_URL" />
            </SubjectConfirmation>
        </Subject>
        <Conditions NotBefore="NOT_BEFORE" NotOnOrAfter="NOT_ON_OR_AFTER">
        </Conditions>
        <AuthnStatement AuthnInstant="AUTHN_INSTANT">
            <AuthnContext>
                <AuthnContextClassRef>
                    urn:oasis:names:tc:SAML:2.0:ac:classes:Password
                </AuthnContextClassRef>
            </AuthnContext>
        </AuthnStatement>
    </Assertion>
</samlp:Response>

Where the items in red will need to be replaced with actual values before sending the response to Google. Here are some specs for those items:

Attribute

Description 

Examples 

ASSERTION_ID 

A 160-bit string made up of randomly generated lower case alpha characters from a through p 

dfpccklacbmnmioodokleambcplpmfghahihdmna

ISSUE_INSTANT 

Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ

2000-01-01T23:01:01Z
2008-10-02T05:55:23Z 

AUTHN_INSTANT 

Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ

2000-01-01T23:01:01Z
2008-10-02T05:55:23Z 

NOT_BEFORE 

This defines the beginning timeframe for which the authentication is valid. Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ

2000-01-01T23:01:01Z
2008-10-02T05:55:23Z

NOT_ON_OR_AFTER 

This defines the ending timeframe for which the authentication is valid. Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ

2000-01-01T23:01:01Z
2008-10-02T05:55:23Z

ACS_URL 

The URL to send the authentication result

https://www.google.com/a/yourCompany.com/acs

USERNAME_STRING 

The username of the person validated 

jsmith
jon.smith 

DIGEST_VALUE 

The value of the digest of the Reference URI calculated using the algorithm listed in the DigestMethod

Qo9gKjwBgNtt6P07aIZmIPXP+uQ=

SIGNATURE_VALUE 

The value of the digest of the SignedInfo Element calculated using the algorithm listed in the SignatureMethod

55khyrdDp0SgPmlu5dH49CAfASW2psDLhgjaB+Yl06pfxLJWnIctb7pX0K3k/vhNU8sUMY6Ps582CS6+YUPJps45U0i
VDK/+PPZvFREOwDR54XSzXAPd7GZyk+jBdQZv6D/g5IgccIGiw3Zi9Qpfe64iewSFoRukvVUD0yGOfszA=

RSA_KEY_MODULUS 

For use in the RSA encryption algorithm

K88ntfSyp1JU9Nu1KJwk+KKOuunT9G1RLwJXm6WrDb2klVGXLNKcP72lu5AlyGgYoZXO2bY2d610LE7kGol3Hu
PKfOMLoKqO/m1etjBBhQnuc2aVL1vJjQiLV/KsCsgwocNIoGOF01ndlgJiazFUFf6IwFltNg/A1pz9I05kkj0=

RSA_KEY_EXPONENT 

For use in the RSA encryption algorithm

AQAB

Here are the basic steps we need to take to create the AuthnResponse:

  1. Authenticate the user
  2. Generate an xmlDocument object using the AuthnResponse Template
  3. Fill in the AuthnResponse parameters
  4. Create a new signedXml object based on the xmlDocument
  5. Submit an html form with the InnerXml of the signedXml object and the RelayState

If we continue with the project created using the first blog post, we currently have one item on our form, a label called lblError. You will need to add two text boxes, txtUser and txtPwd to allow the user to enter their credentials and a button, btnSubmit. Format the form to your liking, and then add some code to authenticate the user, this can be done against a directory, a database, etc. This example will authenticate a user against ADAM.

// Class level vars
string googleUserName = "";

///<summary>

/// Authenticate user and return the result
///</summary>
protected bool AuthenticateUser()
{
    // User parameters from the form

    bool authenticated = false;

    // User parameters from the form
    string user = Request.Params["txtUser"];
    string password = Request.Params["txtPwd"];

    // Variables
    SearchResult srPerson = null;
    DirectorySearcher dSearch = null;

    DirectoryEntry de = null;
    DirectoryEntry deAdmin = null;
    string dn = "";

    // Clear out our class level variable, googleUserName
    googleUserName = "";

    try
    {
        if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(password))
        {
            // All required params were not entered, throw an error

            // could also use some field validators to avoid this
            throw new Exception("Logon failure: unknown user name or bad password");
        }

        // Get the users dn based on the user name used the form, so
        // that we can use it later with our bind test
        dSearch = new DirectorySearcher();
        dSearch.SearchScope = SearchScope.Subtree;
        dSearch.PropertiesToLoad.Add("distinguishedName");
        dSearch.SearchRoot = new DirectoryEntry("LDAP://YourServer:389/C=YourRootPath", "AdamAdminUser", "AdamAdminPswd",
              AuthenticationTypes.Secure | AuthenticationTypes.ServerBind); 
        dSearch.Filter = "(userName=" + user + ")";
        srPerson = dSearch.FindOne();
        dn = srPerson == null ? null : srPerson.Properties["distinguishedName"][0].ToString();

                
        if (!string.IsNullOrEmpty(dn))
        {
            // Get the directory entry, using the users credentials to bind
            de = new DirectoryEntry("LDAP://YourServer:389/C=YourRootPath", dn, password,
                  AuthenticationTypes.ServerBind | AuthenticationTypes.FastBind);

                    
            // Get the native object to force authentication, an error will be throw here
            // if the wrong credentials were given, this will be captured below and the user will be notified

            object bind = de.NativeObject;
 
            // They have authenticated okay, we are now assuming that their google user name
            // is different than their ADAM login name, we need to get it for use later

            // we cant use the above object to get the data because we had to issue our connection
            // using the fast bind option, get a new handle on the object using
            // admin credentials and the secure auth type

            deAdmin = new DirectoryEntry("LDAP://YourServer:389/" + dn, "AdamAdminUser", "AdamAdminPswd",
                  AuthenticationTypes.ServerBind | AuthenticationTypes.Secure);

            deAdmin.RefreshCache(new string[] { "googleUserName" });
 
            // If googleUserName is an email address, we just want what comes before the @ symbol
            googleUserName = deAdmin.Properties.Contains("googleUserName") ?
                  deAdmin.Properties["googleUserName"][0].ToString() : "";

            googleUserName = googleUserName.IndexOf("@") > 0 ?
                  googleUserName.Substring(0, googleUserName.IndexOf("@")) : googleUserName;

        
            // We didn't get what we expected throw an error

            if (googleUserName == "")
            {
                throw new Exception("Logon failure: unable to retrieve your Google username");
            }
            else
            {
                authenticated = true;
            }                    

        }
        else
        {
            // We cant continue without a dn

            throw new Exception("Logon failure: unknown user name or bad password");
        }
 
    }
    catch(Exception ex)
    {
        // Let user know there was a problem

        lblError.Text = ex.Message;
    }
    finally
    {
        // Clean up
        if (dSearch!= null)

        {
            dSearch.Dispose();
        }

        if (de != null)

        {
            de.Dispose();
        }

        if (deAdmin != null)
        {
            deAdmin.Dispose();
        }
    }
}

Now that our user is authenticated and we have their Google UserName, the next step is to generate an xmlDocument object using the AuthnResponse template. The template that we need is similar to the AuthnResponse shown above, with one very important difference; we don't want the <Signature> element or its children. We will be adding this using the signedXml object later. Create an xml file named AuthnResponseTemplate in your project directory. Copy in the AuthnResponse xml below and save the document.

<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response ID="RESPONSE_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"
        xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
    <samlp:Status>
        <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
    </samlp:Status>
    <Assertion ID="ASSERTION_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
        <Issuer>https://www.opensaml.org/IDP</Issuer>
        <Subject>
            <NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress">
                USERNAME_STRING
            </NameID>
            <SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
                <SubjectConfirmationData Recipient="ACS_URL" />
            </SubjectConfirmation>
        </Subject>
        <Conditions NotBefore="NOT_BEFORE" NotOnOrAfter="NOT_ON_OR_AFTER">
        </Conditions>
        <AuthnStatement AuthnInstant="AUTHN_INSTANT">
            <AuthnContext>
                <AuthnContextClassRef>
                    urn:oasis:names:tc:SAML:2.0:ac:classes:Password
                </AuthnContextClassRef>
            </AuthnContext>
        </AuthnStatement>
    </Assertion>
</samlp:Response>

We will now add code to consume this new file and replace the elements in red with proper values:

///<summary>
/// Create our xml SAML Response base on the template
///</summary>
private string
CreateSamlResponse()

{
    // Grab our response template

    string filepath = Request.PhysicalApplicationPath + ("AuthnResponseTemplate.xml");
    XmlDocument doc = new XmlDocument();
    doc.PreserveWhitespace = true;
    doc.Load(filepath);
    string samlResponse = doc.InnerXml; 

    // Get our IDs and make sure that they arent the same (timing)

    string responseID = CreateID();
    string assertionID = CreateID();
 
    while (responseID == assertionID)
    {
        assertionID = CreateID();
    } 

    // Fill out out our template

    samlResponse = samlResponse.Replace("USERNAME_STRING", googleUserName);
    samlResponse = samlResponse.Replace("RESPONSE_ID", responseID);
    samlResponse = samlResponse.Replace("ISSUE_INSTANT", GetDateAndTime(0, 0));
    samlResponse = samlResponse.Replace("AUTHN_INSTANT", GetDateAndTime(0, 1));
    samlResponse = samlResponse.Replace("NOT_BEFORE", GetRequestAttributes(requestXmlString, "IssueInstant"));
    samlResponse = samlResponse.Replace("NOT_ON_OR_AFTER", GetDateAndTime(1, 0););
    samlResponse = samlResponse.Replace("ASSERTION_ID", assertionID);
    samlResponse = samlResponse.Replace("ACS_URL", GetRequestAttributes(requestXmlString, "AssertionConsumerServiceURL"));
            
    return samlResponse; 
}

///<summary>

/// Generate a random set of alpha characters
/// appropriate for our SAML IDs

///</summary>
public static
string CreateID()

{
    Random random = new Random();
    char[] charMapping = {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p'};
    byte[] bytes = new byte[20]; // 160 bits
    random.NextBytes(bytes);
 
    char[] chars = new char[40];
 
    for (int i = 0; i < bytes.Length; i++)
    {
        int left = (bytes[i] >> 4) & 0x0f;
        int right = bytes[i] & 0x0f;
        chars[i * 2] = charMapping[left];
        chars[i * 2 + 1] = charMapping[right];
    }
 
    string id = new string(chars);
 
    return id;
}

///<summary>

/// Create a datetime string in the appropriate SAML format
///</summary>
public static
string GetDateAndTime(int days, int minutes)

{
    DateTime dt = DateTime.Now;
 
    if (days > 0)
    {
        dt = dt.AddDays(days);
    }
 
    if (minutes > 0)
    {
        dt = dt.AddMinutes(minutes);
    }
 
    return dt.ToString("yyyy-MM-dd") + 'T' + dt.ToString("HH:mm:ss") + 'Z';
}

///<summary>

/// Get the specified attribute value from the SAMLRequest
///</summary>
private string
GetRequestAttributes(string xmlString, string attribute)

{
    XmlDocument doc = new XmlDocument();
    doc.LoadXml(xmlString);
 
    if (doc != null)
    {
        return doc.DocumentElement.GetAttribute(attribute).ToString();                
    }
    else
    {
        throw new Exception("Error parsing AuthnRequest XML: Null document");
    }
}

Next, we will be adding the xml signature. Be sure to update the file path to the x509 cert used for signing the xml. This certificate must contain the private key:

///<summary>
/// Sign the AuthnResponse xml adding the Signature element
///</summary>
private string
SignResponse(string xmlDocString)

{
    // Get our pfx cert w/private and public keys
    // we have the document on the file system, but you could also

    // get it from the certificate store
    // this is the same cert (with public keys only) that was provided
    // to Google during the SSO set-up
    X509Certificate2 x509 = new X509Certificate2();
    x509.Import("C:\FilePathToGoogleCert\Cert.pfx");
 
    // Create a new RSA signing key
    RSACryptoServiceProvider rsaKey = new RSACryptoServiceProvider();
    rsaKey = (RSACryptoServiceProvider) x509.PrivateKey; 

    // Load our xml into a doc

    XmlDocument doc = new XmlDocument();
    doc.PreserveWhitespace = true;
    doc.LoadXml(xmlDocString);
 
    // Sign the XML document.
    // Create a SignedXml object.
    SignedXml signedXml = new SignedXml(doc);
 
    // Add the key to the SignedXml document.
    signedXml.SigningKey = rsaKey;
            
    // Add KeyInfo
    KeyInfo keyInfo = new KeyInfo();
    keyInfo.AddClause(new RSAKeyValue(rsaKey));
    signedXml.KeyInfo = keyInfo;
                        
    // Create a reference to be signed.
    Reference reference = new Reference();
    reference.Uri = "";
 
    // Add an enveloped transformation to the reference.
    XmlDsigEnvelopedSignatureTransform env = new XmlDsigEnvelopedSignatureTransform();
    reference.AddTransform(env);
 
    // Add the reference to the SignedXml object.
    signedXml.AddReference(reference);
 
    // Compute the signature.
    signedXml.ComputeSignature();
 
    // Get the XML representation of the signature and save
    // it to an XmlElement object.
    XmlElement xmlDigitalSignature = signedXml.GetXml();
 
    // Append the element to the XML document.
    doc.DocumentElement.InsertBefore(doc.ImportNode(xmlDigitalSignature, true), doc.DocumentElement.FirstChild);
 
    return doc.InnerXml.Trim();
}

Finally, we will bring it all together in the submit button on_click event handler and generate the html form with the SAMLResponse and RelayState and send it on to Google:

///<summary>
/// Handle the submit button click event, authenticate the user,
/// then create the response and submit it to Google

///</summary>
protected void
btnSubmit_Click(object sender, System.EventArgs e)

{
    // Authenticate user
    if (AuthenticateUser())
    {

        // Get our SAMLResponse and sign it
        string samlResponse = CreateSamlResponse();

        string signedResponse = SignResponse(samlResponse);
 
        string formName = "authnResponse";
        string acsUrl = GetRequestAttributes(requestXmlString, "AssertionConsumerServiceURL");

        // Write the http response in such a way that the html document
        // will submit a form with our data on it to the requestor
 
        System.Web.HttpContext.Current.Response.Clear();
        System.Web.HttpContext.Current.Response.Write("<html><head/>");
        System.Web.HttpContext.Current.Response.Write(string.Format("<body onload='document.{0}.submit()'>",
              formName));

        System.Web.HttpContext.Current.Response.Write(string.Format("<form name='{0}' method='post' action='{1}'>",
              formName, acsUrl));

        System.Web.HttpContext.Current.Response.Write(string.Format("<textarea name=\"{0}\" style='visibility:hidden'>{1}</textarea>",
              "SAMLResponse", signedResponse));

        System.Web.HttpContext.Current.Response.Write(string.Format("<input name=\"{0}\" type=\"hidden\" value=\"{1}\">",
              "RelayState", Request.Params["RelayState"]));

        System.Web.HttpContext.Current.Response.Write("</form>");
        System.Web.HttpContext.Current.Response.Write("</body></html>");
        System.Web.HttpContext.Current.Response.End(); 
    }
}

That should do it! The user will then be allowed into their Google account.