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)
The function calls the URL where the login form can be found. The result gets delivered to the function
Part 3 / 4 finally collect all the cookies we need, so to turn attention to getting the API token in step 5
In Part 5 we first request the URL with actual data (devices in our case), but get another SAML dance step, since we have
Part 6 is the interesting one. If not authenticated against the
Part 7 then processes an successful redirect to get our first actual payload.
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.
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 Stephan H Wissel on 30 January 2017 | Comments (1) | categories: JavaScript NodeJS