Skip to content
This repository has been archived by the owner on Dec 13, 2018. It is now read-only.

Saml integration #557

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
13 changes: 12 additions & 1 deletion lib/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,17 @@ web:
uri: "/callbacks/linkedin"
scope: "r_basicprofile r_emailaddress"

# If SAML is enabled, the user should be served a SAML button in the login
# form in traditional websites, and the SAML provider in SPA application
# /login requests. Traditional applications should route login requests
# through the redirect uri, and these requests should automatically be
# verified or rejected through the verification uri.
saml:
enabled: false
uri: "/saml-redirect"
verifyUri: "/saml-verify"
nextUri: "/"

# The /me route is for front-end applications, it returns a JSON object with
# the current user object. The developer can opt-in to expanding account
# resources on this enpdoint.
Expand Down Expand Up @@ -236,4 +247,4 @@ web:
view: null

unauthorized:
view: "unauthorized"
view: "unauthorized"
4 changes: 3 additions & 1 deletion lib/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ module.exports = {
login: require('./login'),
logout: require('./logout'),
register: require('./register'),
verifyEmail: require('./verify-email')
verifyEmail: require('./verify-email'),
samlRedirect: require('./saml-redirect'),
samlVerify: require('./saml-verify')
};
4 changes: 3 additions & 1 deletion lib/controllers/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ module.exports = function (req, res, next) {
var view = config.web.login.view;
var oauthStateToken = oauth.common.resolveStateToken(req, res);
var formActionUri = (config.web.login.uri + (nextUri ? ('?next=' + nextUri) : ''));
var hasSamlProvider = config.web.saml.enabled;

var hasSocialProviders = _.some(config.web.social, function (socialProvider) {
return socialProvider.enabled;
Expand All @@ -85,7 +86,8 @@ module.exports = function (req, res, next) {
form: form,
formActionUri: formActionUri,
oauthStateToken: oauthStateToken,
hasSocialProviders: hasSocialProviders
hasSocialProviders: hasSocialProviders,
hasSamlProvider: hasSamlProvider
});

helpers.render(req, res, view, options);
Expand Down
40 changes: 40 additions & 0 deletions lib/controllers/saml-redirect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use strict';

var stormpath = require('stormpath');

/**
* This controller initiates a SAML login process, allowing the user to register
* via a registered SAML provider.
*
* When the user logs in through the SAML provider, they will be redirected back
* (to the SAML verification URL).
*
* @method
*
* @param {Object} req - The http request.
* @param {Object} res - The http response.
*/
module.exports = function (req, res) {
var application = req.app.get('stormpathApplication');
var builder = new stormpath.SamlIdpUrlBuilder(application);
var config = req.app.get('stormpathConfig');
var cbUri = req.protocol + '://' + req.get('host') + config.web.saml.verifyUri;
Copy link

@mheisig mheisig Jan 13, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using a similar approach to support SAML login. I'd recommend supporting a host config option here instead of assuming the req.host. Running this in a Docker container behind an nginx proxy winds up with the host being reported as the Docker service name instead of the public URI.


var samlOptions = {
cb_uri: cbUri
};

builder.build(samlOptions, function (err, url) {
if (err) {
throw err;
}

res.writeHead(302, {
'Cache-Control': 'no-store',
'Location': url,
'Pragma': 'no-cache'
});

res.end();
});
};
89 changes: 89 additions & 0 deletions lib/controllers/saml-verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use strict';

var url = require('url');
var stormpath = require('stormpath');

var helpers = require('../helpers');

/**
* This controller handles a Stormpath SAML authentication. Once a user is
* authenticated, they'll be returned to the site.
*
* The returned JWT is verified and an attempt is made to exchange it for an
* access token and refresh token, using the `stormpath_token` grant type, and
* recording the user in the session.
*
* @method
*
* @param {Object} req - The http request.
* @param {Object} res - The http response.
*/
module.exports = function (req, res) {
var application = req.app.get('stormpathApplication');
var config = req.app.get('stormpathConfig');
var logger = req.app.get('stormpathLogger');

var params = req.query || {};
var stormpathToken = params.jwtResponse || '';
var assertionAuthenticator = new stormpath.StormpathAssertionAuthenticator(application);

assertionAuthenticator.authenticate(stormpathToken, function (err) {
if (err) {
logger.info('During a SAML login attempt, we were unable to verify the JWT response.');
return helpers.writeJsonError(res, err);
}

function redirectNext() {
var nextUri = config.web.saml.nextUri;
var nextQueryPath = url.parse(params.next || '').path;
res.redirect(302, nextQueryPath || nextUri);
}

function authenticateToken(callback) {
var stormpathTokenAuthenticator = new stormpath.OAuthStormpathTokenAuthenticator(application);

stormpathTokenAuthenticator.authenticate({ stormpath_token: stormpathToken }, function (err, authenticationResult) {
if (err) {
logger.info('During a SAML login attempt, we were unable to create a Stormpath session.');
return helpers.writeJsonError(res, err);
}

authenticationResult.getAccount(function (err, account) {
if (err) {
logger.info('During a SAML login attempt, we were unable to retrieve an account from the authentication result.');
return helpers.writeJsonError(res, err);
}

helpers.expandAccount(account, config.expand, logger, function (err, expandedAccount) {
if (err) {
logger.info('During a SAML login attempt, we were unable to expand the Stormpath account.');
return helpers.writeJsonError(res, err);
}

helpers.createSession(authenticationResult, expandedAccount, req, res);

callback(null, expandedAccount);
});
});
});
}

function handleAuthRequest(callback) {
var handler = config.postLoginHandler;

if (handler) {
authenticateToken(function (err, expandedAccount) {
if (err) {
return callback(err);
}

handler(expandedAccount, req, res, callback);
});
} else {
authenticateToken(callback);
}
}

handleAuthRequest(redirectNext);
});
};
4 changes: 3 additions & 1 deletion lib/helpers/get-form-view-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ function getAccountStoreModel(accountStore) {
href: provider.href,
providerId: provider.providerId,
callbackUri: provider.callbackUri,
clientId: provider.clientId
clientId: provider.clientId,
ssoLoginUrl: provider.ssoLoginUrl,
ssoLogoutUrl: provider.ssoLogoutUrl
}
};
}
Expand Down
5 changes: 5 additions & 0 deletions lib/stormpath.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ module.exports.init = function (app, opts) {
router.all(web.oauth2.uri, bodyParser.form(), stormpathMiddleware, controllers.getToken);
}

if (web.saml.enabled) {
addGetRoute(web.saml.uri, controllers.samlRedirect);
addGetRoute(web.saml.verifyUri, controllers.samlVerify);
}

client.getApplication(config.application.href, function (err, application) {
if (err) {
throw new Error('Cannot fetch application ' + config.application.href);
Expand Down
5 changes: 4 additions & 1 deletion lib/views/login.jade
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ block vars
- var description = 'Log into your account!'
- var bodytag = 'login'
- var socialProviders = stormpathConfig.web.social
- var samlProvider = stormpathConfig.web.saml
- var loginFields = stormpathConfig.web.login.form.fields

block body
Expand Down Expand Up @@ -98,7 +99,7 @@ block body
div
button.login.btn.btn-login.btn-sp-green(type='submit') Log In

if hasSocialProviders
if hasSocialProviders || hasSamlProvider
.social-area.col-xs-12.col-sm-4
.header  
label Easy 1-click login:
Expand All @@ -110,6 +111,8 @@ block body
include linkedin_login_form.jade
if socialProviders.github && socialProviders.github.enabled
include github_login_form.jade
if samlProvider && samlProvider.enabled
include saml_login_form.jade

if stormpathConfig.web.verifyEmail.enabled
a.forgot(style="float:left", href="#{stormpathConfig.web.verifyEmail.uri}") Resend Verification Email?
Expand Down
5 changes: 5 additions & 0 deletions lib/views/saml_login_form.jade
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
button.btn.btn-saml(onclick='samlLogin()') SAML
script(type='text/javascript').
function samlLogin() {
window.location = '#{stormpathConfig.web.saml.uri}';
}
152 changes: 152 additions & 0 deletions test/controllers/test-saml.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
'use strict';

var assert = require('assert');
var cheerio = require('cheerio');
var request = require('supertest');
var uuid = require('uuid');

var helpers = require('../helpers');

function isSamlRedirect(res) {
var location = res && res.headers && res.headers.location;
var error = new Error('Expected Location header with redirect to saml/sso, but got ' + location);

if (location) {
var match = location.match(/\/saml\/sso\/idpRedirect/);
return match ? null : error;
}

return error;
}

function prepareSaml(app, callbackUri, cb) {
if (app.authorizedCallbackUris.indexOf(callbackUri) === -1) {
app.authorizedCallbackUris.push(callbackUri);
}

app.save(cb);
}

function revertSaml(app, callbackUri, cb) {
var index = app.authorizedCallbackUris.indexOf(callbackUri);

if (index !== -1) {
app.authorizedCallbackUris.splice(index, 1);
}

app.save(cb);
}

function initSamlApp(application, options, cb) {
var webOpts = {
login: {
enabled: true
},
register: {
enabled: true
},
saml: {
enabled: true
}
};

Object.keys(options).forEach(function (key) {
webOpts[key] = options[key];
});

var app = helpers.createStormpathExpressApp({
application: {
href: application.href
},
web: webOpts
});

app.on('stormpath.ready', function () {
var config = app.get('stormpathConfig');
var server = app.listen(function () {
var address = server.address().address === '::' ? 'http://localhost' : server.address().address;
address = address === '0.0.0.0' ? 'http://localhost' : address;
var host = address + ':' + server.address().port;
var callbackUri = host + config.web.saml.verifyUri;
prepareSaml(app.get('stormpathApplication'), callbackUri, function (err) {
if (err) {
return cb(err);
}

cb(null, {
application: app,
config: config,
host: host
});
});
});
});
}

describe('saml', function () {
var stormpathApplication, app, host, config, callbackUri;

var accountData = {
givenName: uuid.v4(),
surname: uuid.v4(),
email: uuid.v4() + '@test.com',
password: uuid.v4() + uuid.v4().toUpperCase() + '!'
};

before(function (done) {
var client = helpers.createClient().on('ready', function () {
helpers.createApplication(client, function (err, _app) {
if (err) {
return done(err);
}

stormpathApplication = _app;

stormpathApplication.createAccount(accountData, function (err) {
if (err) {
return done(err);
}

initSamlApp(stormpathApplication, {}, function (err, data) {
if (err) {
return done(err);
}

app = data.application;
config = data.config;
host = data.host;

done();
});
});
});
});
});

after(function (done) {
revertSaml(app.get('stormpathApplication'), callbackUri, function () {
helpers.destroyApplication(stormpathApplication, done);
});
});

it('should contain the saml link in the login form, if saml is enabled', function (done) {
request(host)
.get(config.web.login.uri)
.expect(200)
.end(function (err, res) {
var $ = cheerio.load(res.text);
assert.equal($('.social-area').length, 1);
assert.equal($('.btn-saml').length, 1);

done(err);
});
});

it('should perform a redirect in the SAML verification flow', function (done) {
request(host)
.get(config.web.saml.uri)
.expect(302)
.expect(isSamlRedirect)
.end(done);
});
});