wissel.net

Usability - Productivity - Business - The web - Singapore & Twins

SAML and the Command Line


One of the best kept secrets of Connections Cloud S1 is the Traveler API. The API allows interactions that are missing from the Admin UI, like deleting a specific device or implementing an approval workflow.
Unfortunately the API only offers authentication via SAML, OAuth or BasicAuth are missing. So any application interacting with the API needs to do The SAML Dance. That's annoying when you have an UI to use, and a formidable challenge when you have a command line application, like a cron Job running unsupervised at interval.
One lovely step in the process: the IBM IdP returns a HTML page with a hidden form containing the SAML assertion result to be posted back to the application provider. Quite interesting, when your application provider is a command line app. Let's get to work.
The script is written in node.js and uses request and fast-html-parser npm package. The first step is to load the login form (which comes with a first set of cookies)
var requestOptionsTemplate = {
    headers: {
        'Origin': 'https://api.notes.ap.collabserv.com/api/traveler/',
        'User-Agent': 'ancy CommandLine Script',
        'Connection': 'keep-alive',
        'Cache-Control': 'max-age=0',
        'Upgrade-Insecure-Requests': 1
    },
    'method': 'GET'
};

function scLoginPart1() {
    console.log('Authenticating to SmartCloud ...');
    var requestOptions = Object.assign({}, requestOptionsTemplate);
    requestOptions.url = 'https://apps.na.collabserv.com/manage/account/dashboardHandler/input';
    request(requestOptions, scLoginPart2);
}

The function calls the URL where the login form can be found. The result gets delivered to the function scLoginPart2. That function makes use of a global configuration variable config that was created through const config = require("./config.json") and contains all the credentials we need. Step2 submits the form and hands over to Step3.
function scLoginPart2(err, httpResponse, body) {
    if (err) {
        return console.error(err);
    }
    // Capture cookies
    var outgoingCookies = captureCookies(httpResponse);
    var requestOptions = Object.assign({}, requestOptionsTemplate);
    requestOptions.headers.Cookie = outgoingCookies.join('; ');
    requestOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
    requestOptions.method = 'POST';
    requestOptions.url = 'https://apps.ap.collabserv.com/pkmslogin.form';
    requestOptions.form = {
        'login-form-type': 'pwd',
        'error-code': '',
        'username': config.smartcloud.user,
        'password': config.smartcloud.password,
        'show_login': 'showLoginAgain'
    }
    request(requestOptions, scLoginPart3);
}

function captureCookies(response) {
    var incomingCookies = response.headers['set-cookie'];
    var outgoingCookies = [];
    if (incomingCookies) {
        incomingCookies.forEach(function(cookie) {
            outgoingCookies.push(cookie.split(';')[0]);
        });
    }
    // Array, allows for duplicate coolie names
    return outgoingCookies;
}

Part 3 / 4 finally collect all the cookies we need, so to turn attention to getting the API token in step 5
function scLoginPart3(err, httpResponse, body) {
    if (err) {
        console.error('Login failed miserably');
        return console.error(err);
    }
    // Login returns not 200 but 302
    // see https://developer.ibm.com/social/2015/06/23/slight-changes-to-the-form-based-login/
    if (httpResponse.statusCode !== 302) {
        return console.error('Wrong status code received: ' + httpResponse.statusCode);
    }

    var outgoingCookies = captureCookies(httpResponse);
    var redirect = httpResponse.headers.location;

    // This is the 3rd request we need to make to get finally all cookies for app.na
    var requestOptions = Object.assign({}, requestOptionsTemplate);
    requestOptions.headers.Cookie = outgoingCookies.join('; ');
    requestOptions.url = redirect;
    request(requestOptions, scLoginPart4);
}

function scLoginPart4(err, httpResponse, body) {
    if (err) {
        console.error('Login redirect failed miserably');
        return console.error(err);
    }
    var cookieHarvest = captureCookies(httpResponse);
    // Now we have some cookies in app, we need the SAML dance for api.notes
    scLoginPart5(cookieHarvest)
}

In Part 5 we first request the URL with actual data (devices in our case), but get another SAML dance step, since we have apps.na vs api.notes in the URL
function scLoginPart5(incomingCookies) {
    console.log("Executing SAML postback to SmartCloud");
    var requestOptions = Object.assign({}, requestOptionsTemplate);
    requestOptions.headers.Cookie = incomingCookies.join('; ');
    // Here is the first time wa actually request the data we want
    requestOptions.url = 'https://api.notes.ap.collabserv.com/api/traveler/devices';
    request(requestOptions, scLoginPart6);
}

Part 6 is the interesting one. If not authenticated against the api.notes URL yet, the server will return an HTML form with a JavaScript action that posts that form, containing the SAML assertion to the api URL. Since we don't use a browser to handle that automatically, we need to grab the html, extract the form and post it ourselves
function scLoginPart6(err, httpResponse, body) {
    if (err) {
        return console.error(err);
    }
    // Check the content for HTML
    var contentType = httpResponse.headers['content-type'];
    if (contentType.indexOf('html') > 0) {
        var root = htmlparser.parse(body);
        var samlForm = root.querySelector('form');
        // We need action
        var samlAttr = samlForm.attributes;
        var action = samlAttr['action'];
        console.log('SmartCloud login action:' + action);
        // checking if the action is a full qualified URL
        if (action.substring(0, 4) != 'http') {
            // That would be an error condition
            console.error('Authentication to SmartCloud failed');
            process.exit(1);
        }
        var samlFields = samlForm.querySelectorAll('input');
        var postbackform = {};
        samlFields.forEach(function(field) {
            var attr = field.attributes;
            var fName = attr['name'];
            var fValue = attr['value'];
            postbackform[fName] = fValue;
        });

        var newOptions = Object.assign({}, requestOptionsTemplate);
        newOptions.method = 'POST';
        newOptions.form = postbackform;
        newOptions.url = action;
        newOptions.headers['Cookie'] = captureCookies(httpResponse).join('; ');
        request(newOptions, scLoginPart7);
    } else {
        console.error('Authentication to SmartCloud failed');
        process.exit(1);
    }
}

Part 7 then processes an successful redirect to get our first actual payload.
function scLoginPart7(err, response, body) {
    if (err) {
        return console.error(err);
    }
    var resultCookies = captureCookies(response);
    var location = response.headers['location'];
    var nextDance = Object.assign({}, requestOptionsTemplate);
    nextDance.headers['Cookie'] = resultCookies.join('; ');
    nextDance.url = location;
    nextDance.method = 'GET';
    delete nextDance.headers['Content-Type'];

    request(nextDance, function(err, danceResponse, goodBody) {
        // Here is the body of the first page
        doSomethingUseful(goodBody, resultCookies);
    });
}

The error path in the code isn't very well modeled, so there's work left to do. So there were just 7 bridges to cross.
As usual YMMV.

Posted by on 2017-01-30 09:41 | Comments (1) | categories: NodeJS JavaScript

Comments

  1. posted by Mikkel Heisterberg on Tuesday 31 January 2017 AD - 03:23 Singapore Time:

    Great code example.

    No basic auth or OAuth?! If only IBM would get around to using the great API gateways they develop and sell for their own services that code would be done in 5-10 lines... What a shame.