2015-01-21

Calling a cross-domain web service using JavaScript and SAML2


When you have a client application and a webservice application but they both reside on different (sub-) domains, you'll want to link them using single sign-on, using for instance ADFS, or your user experience will suffer. Nowadays with OAuth2, this isn't too much of an issue. But when you're forced to work with SAML2 (for instance because your infrastructure is still at Windows Server 2008R2 and ADFS v2), things aren't that simple.

Since cookies may not be shared between domains as a security measure, one must find a way around this. Luckily, ADFS supports you in this. You just need to get JavaScript to do the same.

Starting with the actual call to a WebApi service. Assume the following JavaScript is gained from a secure part of a client application, residing on https://www.mycompany.com/:

var serviceUrl = "https://services.mycompany.com/api/Employee";

function makeCall() {
    return $.ajax({
        url: serviceUrl,
        type: 'GET',
        contenttype: 'application/json',
        dataType: 'json',
        xhrFields: { withCredentials: true },
        headers: {
            'X-Requested-With': 'XMLHttpRequest',
        },
        crossDomain: true
    });
};

Using these parameters, authentication when you're already logged in is automatically handled. And since the above snippet is retrieved from a secure site, that should be the case, right? Sadly, no. We're logged into the domain of the client application, but for this example we're calling a WebApi service on another sub-domain. Because of this, invoking makeCall() will result in a 401 (not authorized) error. So, the following line is added to the JavaScript code:

$.support.cors = true;

This tells JQuery we will be making cross-domain requests and to set up CORS pre-flight checks. Sadly, JQuery (or Angular, for that matter) doesn't handle the redirects that are inherent to ADFS authentication very well, nor does it handle the Set-Cookie headers that give you the FedAuth cookies you're after. The browser, however, is stellar at this. So we let the browser handle logging in, using an iframe:

var serviceUrl = "https://services.mycompany.com/api/Employee",
    loginUrl = "https://services.mycompany.com/api/Login",
    authenticating = false,
    retryCount = 5,
    iFrame;

function authenticate() {

    return $.Deferred(function (d) {
        // Potentially could make this into a little popup layer
        // that shows we are authenticating, 
        // and allows for re-authentication if needed

        if (!authenticating) {

            authenticating = true;

            iFrame = $("<iframe></iframe>");
            iFrame.hide();
            iFrame.appendTo("body");
            iFrame.load(function (data) {
                d.resolve();
            });
           
            iFrame.attr('src', loginUrl);

         } else {
            d.resolve();
        }
    });
};

function makeCall() {
    return $.ajax({
        url: serviceUrl,
        type: 'GET',
        contenttype: 'application/json',
        dataType: 'json',
        xhrFields: { withCredentials: true },
        headers: {
            'X-Requested-With': 'XMLHttpRequest',
        },
        crossDomain: true
    })
    .error(function (error) {

        // In IE10 we can get status = 0 in case of some errors, 
        // including 401 because of a bug...
        if ((error.status == 401 || error.status == 0)
            && retryCount > 0) {
            //Authenticating,

            retryCount--;

            return authenticate().then(function () {
                // Making the call again, just wait a bit 
                // for the login to complete. Better than to keep firing 
                // requests while the iframe is still busy

                setTimeout(function () { makeCall() }, 1000);
            });
        } else {
            return $.Deferred(function (d) {
                d.reject(error);
            });
        }
    })

    .success(function (data) {

        if (iFrame) {
            iFrame.remove();
            authenticating = false;
        }

        console.log(data);
    });
};

In this case, the service behind the LoginUrl simply returns an empty result, but does enforce authorization. This makes sure the authentication and authorization process is kicked off, but won't unnecessarily burden your server. If your ADFS server and webservice are configured correctly, the user won't be prompted for credentials again and will be logged in to the webservice automatically.

No comments:

Post a Comment