Skip to content
This repository was archived by the owner on Dec 13, 2018. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});