DevelopMENTAL Madness

Monday, July 06, 2009

Building a Single Sign On Provider Using ASP.NET and WCF: Part 3

Using JSONP with WCF

This is the third article in a four part series on building a single sign on (SSO) provider using the ASP.NET platform. If you just want to know about JSONP and WCF and aren’t interested in implementing SSO read ahead. Otherwise, make sure to check out part 1 and part 2. (Part 4 and the source code are now available).

JSONP

Also known as “JSON with Padding” is more of a back door to allow cross domain AJAX requests. By dynamically generating <script /> tags in the current page it gets around the “Access to restricted URI denied” error you get when you try and access a resource which is not located within your domain.

Support for JSONP is built into the jQuery library, making it very easy to communicate between our client and SSO service. However, because the HTML <script /> tag is an http GET request, you cannot perform POST operations via JSONP.

Server-side JSONP Support

Even though client-side support for JSONP is built in if you’re using jQuery, server-side support is not. Unfortunately, you have to make special considerations for JSONP if you’re going to write services to support it. JSONP expects that your JSON data will be wrapped with a client-side callback like this:

callbackname({"property":"data"});

Where “callbackname” is the value of a query string parameter named “callback”. jQuery generates this callback at runtime, and expects it in the response so just check the value of Request.QueryString[“callback”] and replace “callbackname” with the value you get.

Do you see the problem yet? WCF controls the output stream here, so how do you handle this? According to Jason Kelly, you can download JSONP samples from MSDN, extract them and copy the following project files to your WCF project:

  • JSONPBehavior.cs
  • JSONPBindingElement.cs
  • JSONPBindingExtension.cs
  • JSONPEncoderFactory.cs

Then modify the service definition in your web.config by adding the extensions from the above files and modifing your bindings:

<system.serviceModel>
    <!-- add JSONP extensions -->
    <extensions>
        <bindingElementExtensions>
            <add name="jsonpMessageEncoding"
         type="Microsoft.Ajax.Samples.JsonpBindingExtension, SSO.Service, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/>
        </bindingElementExtensions>
    </extensions>
    
    <!-- using JSONP bindings -->
    <bindings>
        <customBinding>
            <binding name="userHttp">
                <jsonpMessageEncoding />
                <httpTransport manualAddressing="true"/>
            </binding>
        </customBinding>
    </bindings>
        
</system.serviceModel>

Now you can tell WCF which operations need to support JSONP by applying the JSONPBehavior attribute like in the ServiceContract interface definition below:

[ServiceContract]
public interface ISSOService
{
    [OperationContract]
    [WebGet(UriTemplate="/RequestToken",
        BodyStyle=WebMessageBodyStyle.WrappedRequest,
        ResponseFormat=WebMessageFormat.Json)]
    [JSONPBehavior(callback = "callback")]
    SSOToken RequestToken();
 
    [OperationContract]
    // javascript can't post w/ jsonp - has to be WebGet
    [WebGet(UriTemplate="/Login?username={username}&password={password}",
        BodyStyle = WebMessageBodyStyle.WrappedResponse,
        ResponseFormat = WebMessageFormat.Json)]
    [JSONPBehavior(callback = "callback")]
    SSOToken Login(string username, string password);
 
    [OperationContract]
    [WebGet(UriTemplate = "/Logout",
        BodyStyle = WebMessageBodyStyle.WrappedResponse,
        ResponseFormat = WebMessageFormat.Json)]
    [JSONPBehavior(callback = "callback")]
    bool Logout();
}
 

These custom bindings don’t seem to play well when you want to expose your ServiceContract to other clients. This was the main reason why I split my ServiceContract into 2 separate interfaces. By using separate contracts I could define an endpoint for the client and one for the application, each using separate bindings. The client endpoint uses the custom JSONP bindings and the application (partner) endpoint uses webHttpBinding.

Service Implementation

After all this the service implementation is pretty vanilla although if you’re not following along the entire series, make sure that you either configure WCF to enable AspNetCompatibility or remove the attribute from the class definition below or you’ll get an error.

The Login operation (if the credentials are valid) generates and encrypts a FormsAuthenticationTicket and adds it to the Response.Cookies collection. The RequestToken operation reads the FormsAuthenticationTicket from the current Identity and returns it to the client and the ValidateToken operation decrypts the token and reads the UserData property, if that fails then the token isn’t valid. Here it is:

[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
public class SSOService : ISSOService, ISSOPartnerService
{
    #region ISSOService Members
 
    public SSOToken RequestToken()
    {
        SSOToken token = new SSOToken
        {
            Token = string.Empty,
            Status = "DENIED"
        };
 
        if (HttpContext.Current.Request.IsAuthenticated)
        {
            FormsIdentity identity = (FormsIdentity)HttpContext.Current.User.Identity;
 
            token.Token = FormsAuthentication.Encrypt(identity.Ticket);
            token.Status = "SUCCESS";
        }
 
        return token;
    }
 
    public bool Logout()
    {
        HttpContext.Current.Session.Clear();
        FormsAuthentication.SignOut();
        HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName);
        cookie.Expires = DateTime.Now.AddDays(-10000.0);
        HttpContext.Current.Response.Cookies.Add(cookie);
        return true;
    }
 
    public SSOToken Login(string username, string password)
    {
        SSOToken token = new SSOToken
        {
            Token = string.Empty,
            Status = "DENIED"
        };
 
        // authenticate user
        if (string.CompareOrdinal("foo", username) == 0
            && string.CompareOrdinal("bar", password) == 0)
        {
            Guid temp = Guid.NewGuid();
 
            DateTime issueDate = DateTime.Now;
            DateTime expireDate = issueDate.AddMonths(1);
 
            FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(1, username, issueDate, expireDate, true, temp.ToString());
            string protectedTicket = FormsAuthentication.Encrypt(ticket);
 
            HttpCookie authorizationCookie = new HttpCookie(FormsAuthentication.FormsCookieName, protectedTicket);
            authorizationCookie.Expires = expireDate;
            authorizationCookie.HttpOnly = true;
 
            HttpContext.Current.Response.Cookies.Add(authorizationCookie);
 
            token.Status = "SUCCESS";
            token.Token = protectedTicket;
        }
 
        return token;
    }
 
    public SSOUser ValidateToken(string token)
    {
        try
        {
            FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(token);
 
            return new SSOUser { 
                Username = ticket.Name, 
                SessionToken = new Guid(ticket.UserData) 
            };
        }
        catch
        {
            return new SSOUser { 
                Username = string.Empty, 
                SessionToken = Guid.Empty 
            };
        }
    }
 
    #endregion
}

Client Communication

Now that we have a working service implementation, we need to access the service operations from our client. We’ll use jQuery to make this quick and simple:

$(function() {
    $("#logon").click(function() {
        $.get('http://localhost:21259/SSOService.svc/user/Login?callback=?',
            { username: $("#username").val(), password: $("#password").val() },
            function(ssodata) {
            if (ssodata.LoginResult.Status == 'DENIED') {
                // display some sort of 'login failed' message to the user
            } else {
                // the client now needs to get the authentication ticket from
                // the service and present it to the web application for 
                // verification
            }
        }, 'jsonp');
    });
});

The nice thing about jQuery here is that JSONP support is built-in. You can just use the $.get() method and specify the dataType as ‘jsonp’.

Notice the "?callback=?” query string added to the service url. “callback=?” tells jQuery to generate a callback method and forward that callback name to our service. The value of “callback” is immaterial since it will be handled behind the scenes between jQuery and WCF. Also, you can place “callback=?” anywhere in the query string. If you have other query string parameters you can either do as I have done and include them as part of the JSON object passed to the $.get() method or include them in the query string along with “callback=?”. So the URI could have looked like this:

'http://localhost:21259/SSOService.svc/user/Login?callback=?&username=foo&password=bar'

or like this:

'http://localhost:21259/SSOService.svc/user/Login?username=foo&password=bar&callback=?'

Security

If you’re following the entire series, I’m sure your tired of hearing this, but cross-domain browser communication is considered unsafe. Protect your application and your users from session hijacking by setting your cookies HttpOnly = true. Also, to prevent XSS attacks, make sure your service doesn’t allow unchecked user data to be included in your communication with the client.

HttpOnly = true prevents session hijacking by preventing any client-side scripts which may be in the web application (which is beyond the control of the service application) from reading the cookie.

jQuery takes the response from the service and passes it to EVAL(). Using whitelists to validate userdata sent in response to client requests will prevent XSS scripts from being executed by the client.

Conclusion

We’ve discussed SSO as well as configuring WCF to support FormsAuthentication and JSONP, next we’ll tie everything together and you’ll finally get your hands on the source code. See you for the next and final installment.

Labels: , ,