diff --git a/lib/config.yml b/lib/config.yml index f25ab415..3337a30c 100644 --- a/lib/config.yml +++ b/lib/config.yml @@ -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. @@ -236,4 +247,4 @@ web: view: null unauthorized: - view: "unauthorized" \ No newline at end of file + view: "unauthorized" diff --git a/lib/controllers/index.js b/lib/controllers/index.js index a8235a59..dfb6bbd4 100644 --- a/lib/controllers/index.js +++ b/lib/controllers/index.js @@ -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') }; diff --git a/lib/controllers/login.js b/lib/controllers/login.js index 7c8d5e9b..dd546ba4 100644 --- a/lib/controllers/login.js +++ b/lib/controllers/login.js @@ -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; @@ -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); diff --git a/lib/controllers/saml-redirect.js b/lib/controllers/saml-redirect.js new file mode 100644 index 00000000..41336725 --- /dev/null +++ b/lib/controllers/saml-redirect.js @@ -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; + + 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(); + }); +}; diff --git a/lib/controllers/saml-verify.js b/lib/controllers/saml-verify.js new file mode 100644 index 00000000..6b97f95a --- /dev/null +++ b/lib/controllers/saml-verify.js @@ -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); + }); +}; diff --git a/lib/helpers/get-form-view-model.js b/lib/helpers/get-form-view-model.js index b0771ae5..a1a0683d 100644 --- a/lib/helpers/get-form-view-model.js +++ b/lib/helpers/get-form-view-model.js @@ -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 } }; } diff --git a/lib/stormpath.js b/lib/stormpath.js index 75e1c207..2f567682 100644 --- a/lib/stormpath.js +++ b/lib/stormpath.js @@ -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); diff --git a/lib/views/login.jade b/lib/views/login.jade index 0047e7a3..8bfa72c7 100644 --- a/lib/views/login.jade +++ b/lib/views/login.jade @@ -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 @@ -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: @@ -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? diff --git a/lib/views/saml_login_form.jade b/lib/views/saml_login_form.jade new file mode 100644 index 00000000..5129a04a --- /dev/null +++ b/lib/views/saml_login_form.jade @@ -0,0 +1,5 @@ +button.btn.btn-saml(onclick='samlLogin()') SAML +script(type='text/javascript'). + function samlLogin() { + window.location = '#{stormpathConfig.web.saml.uri}'; + } diff --git a/test/controllers/test-saml.js b/test/controllers/test-saml.js new file mode 100644 index 00000000..8ef10bf1 --- /dev/null +++ b/test/controllers/test-saml.js @@ -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); + }); +});