Using Stormpath as an SSO Provider for Discourse


Stormpath: User Auth & Management as a Service [UAMaaS?]

Discourse: an open source forum, designed for reading

SSO: single sign-on; “… a user logs in once and gains access to all systems without being prompted to log in again at each of them.” [src]

As we’re moving toward an open beta for the company’s new product, web work to promote, support, and disseminate information is ramping up. I was tasked with using Stormpath as the SSO provider for our Discourse forums. If I made a mistake with this configuration, I would be locked out of any settings panel on the Discourse side. The only way to regain access would be ssh-ing into the Discourse box and executing some commands from irb. The catch was that no one knew the address for that hosted box. The solution?

Do as Ru says.

Protip: when testing out the SSO configuration, leave your admin settings open in a separate window and muck around with a different user in a different window

Manufactured tension out of the way, let’s go over how I implemented this in our Node.js app. First off, we’ll need some packages to make this easier: npm install express-stormpath --save and npm install discourse-sso --save are good starts. [Read more about express-stormpath here and discourse-sso here.]

To start out, I was handed a project-already-in-progress. Lots of the basic functionality was already in place for handling the routes and rendering the Jade templates. Since integration of the main app with Stormpath was already handled, we’ll be skipping that step and getting right into the SSO action.

Thankfully, Discourse has a good overview of what’s needed on their side to get SSO going – check it out here and read through the section, “Enabling SSO”. After you finish following their instructions, we’ll pick up here.

All set? Ok, cool. Let’s pop over to that Node.js app. I created a new file discourse.js in the routes directory. In there, I start with pulling in the router and requiring the packages we installed earlier.

var express         = require('express'),
    router          = express.Router(),
    stormpath       = require('express-stormpath'),
    discourse_sso   = require('discourse-sso');

Once a coworker learned that I like Ruby, my use of this structure finally made sense to him.

Then, construct a new SSO object using the sso_secret you added when configuring SSO on the Discourse side: var sso = new discourse_sso('i_love_sso_a_lot');. This sso object contains some helper functions which will be useful for handling the payload coming from Discourse, as we’ll see shortly.

Next, set up the endpoint we told Discourse to hit. To my app.js, I added

var discourse = require('./routes/discourse');
app.use('/sso', discourse);

Back to routes/discourse.js. Since I set the endpoint in the sso url field to on Discourse, I started by adding a GET route: router.get('/discourse', function(req, res){...});.

Time to get into the SSO action. We’ll be receiving two query params from Discourse, sso and sig, so let’s start by snagging those

var payload = req.query.sso,
    sig     = req.query.sig;

and then put them to work.

if (req.user && sso.validate(payload, sig)){
              var nonce = sso.getNonce(payload);
              var userParams = {
                "nonce": nonce,
                "external_id": req.user.href.split('accounts/')[1],

Hey, it’s those helper functions that the sso object provides! And (.split > new RegExp) === true.

After we make sure that there’s a user who’s signed-in, we validate the info from the query params, get the nonce, and create a userParams object. In it, we send back the nonce to verify with Discourse that it’s us, an external ID—which, in this case, is pulled from Stormpath—unique to the user, and the user’s email address. Since we’re letting Discourse handle all of the username business, we’re skipping that and the also-optional name params.

The last part of the process involves packaging all of this info up and sending it back to Discourse.

var q = sso.buildLoginString(userParams);
res.redirect('' + q);

That last line - hacky? Yup. Now accepting pull requests? Always.

Finally, wrap up with the else condition and export the whole thing to use back in app.js, as we saw earlier.

  } else {

module.exports = router;

Next time, we’ll handle single-sign-out!