For the past three weeks, My teammates and I have been working hard on an exciting new enhancement for the Webmaker project. After many hours of researching, whiteboarding, OMGWTF's and many Anakin Skywalkers, we've finally shipped the new system to production.
If you want to skip directly to the implementation guide, click one of the following:
Dependencies
Server Configuration
Front End Configuration (HTML+CSS)
Front End Configuration (JavaScript)
In order to fully understand what we've done, you will need to understand where we came from.
When we set out to build Webmaker in April of 2013, One of the requirements was to have a single sign on mechanism across the Webmaker site and tools. In order to accomplish this in the time we had, we were forced to move very quickly. The result was a log-in system, built with Persona, that accomplished the task. Unfortunately, it was a nightmare to implement and caused us many issues.
This log-in system spread its dependencies across two applications. This meant that if you want to develop popcorn maker, you have to be running webmaker.org and a Login Server to log in. The limitations of this system became apparent almost immediately.
Logging in centred around storing a session cookie on one subdomain, and checking for that cookie on all other subdomains through a complicated iframe post message API. This created an obvious lag between page load and sign-in and proved to be a complete nightmare to implement.
Today, I'm going to go over the new Webmaker authentication API, and guide you through an implementation using real, working code (available on Github!). This guide will teach you how to implement the system in a node server that's running the express web framework, using nunjucks as the template engine as well as how hook up your front end to the system.
Before I begin, we need to get a basic understanding of the system, and all the parts involved. In the log-in process, there are four parties.
- The browser or "client"
- The App Server or "App"
- The "Login Server"
- The Persona Identity Provider or "Persona"
This scenario will assume the user has already created a Webmaker Account. When the client application initiates the log in process, they will be presented with a typical persona log-in pop-up. After completing the Persona sign in process, the client will get what's known as an assertion (a cryptographically signed piece of text that proves the user is the owner of a specific email address). The Client will send this assertion, along with the email and the App's hostname, to an endpoint on the App, typically '/verify'.
The verify endpoint will forward the assertion, email and hostname to the Login Server. Upon receipt of the request, the Login Server will verify the assertion with Persona, and determine whether or not this is a valid log-in. If it is, the user's account information (username, email, etc) is returned to the App. If the request checks out, the App will create and sign a session cookie and return the cookie to the client. Once the client has the cookie, the user has successfully logged in!
The special part of this whole system is that the cookie which is assigned to the client is what we refer to as a "Super Cookie". The "super" indicates that it can be sent on connections to subdomains of a parent subdomain. For example, a cookie for webmaker.org
can be sent on connections to all apps that exist on a subdomain of it - ie. popcorn.webmaker.org
and thimble.webmaker.org
. This enables apps to detect valid sessions without having to check yuing a complicated iframe post-message API.
Implementation
Lets implement this login strategy!
Dependencies
Lets start with the npm requirements for setting up your express server. At a minimum, you will need:
- bower (front-end package management)
- express (web app framework)
- less-middleware (less middleware)
- nunjucks (templating engine and middleware)
- webmaker-auth (middlware and route handlers for webmaker SSO)
- webmaker-i18n (Webmaker focused internationalisation middleware)
- webmaker-locale-mapping (locale mappings for Webmaker)
Optionally, you can use habitat for managing your environment variables and helmet for adding security header middleware (recommended!)
The back and front ends of your app will need the webmaker-auth-client library. If you're using a front-end package manager like bower, you can include it in your bower.json
file as a dependency.
A sample implementation lives at https://github.com/mozilla/webmaker-login-example and will serve as the basis for this guide. It is not unlikely that one day this guide will be out of date, so be sure to read the documentation of the Login Server, webmaker-auth-client and webmaker-auth repositories to be sure that nothing has changed significantly.
Server configuration
Here I'll describe the important things to do when setting up your server. You can find the full app.js file here.
Step 1:
Load the Webmaker-auth npm module
var WebmakerAuth = require('webmaker-auth');
Step 2:
Add the bower_components
directory to your nunjucks path.
This will let you include the create-user-form html snippet in your views.
var nunjucks = require('nunjucks');
var nunjucksEnv = new nunjucks.Environment([
new nunjucks.FileSystemLoader(__dirname + '/views'),
new nunjucks.FileSystemLoader(__dirname + '/bower_components')
], {
autoescape: true
});
Step 3:
Add an instantiate filter to your nunjucks environment
This is required because the create-user-form is localised.
nunjucksEnv.addFilter("instantiate", function(input) {
var tmpl = new nunjucks.Template(input);
return tmpl.render(this.getVariables());
});
Step 4:
Instantiate and add the webmaker-i18n and webmaker-locale-mapping middleware to your express instance
app.use(i18n.middleware({
supported_languages: env.get("SUPPORTED_LANGS"),
default_lang: "en-US",
mappings: require("webmaker-locale-mapping"),
translation_directory: path.resolve(__dirname, "locale")
}));
Step 5:
Merge the create user form localisations with the app's localisations using i18n.addLocaleObject()
.
i18n.addLocaleObject({
"en-US": require("./bower_components/webmaker-auth-client/locale/en_US/create-user-form.json")
}, function (result) {});
Step 6:
Setup the webmaker-auth middleware module.
For production/staging environments, you can specify a domain parameter, enabling SSO for apps hosted on the same domain (they must be configured with the same session secret, otherwise they won't be able to decrypt cookies issued by one another.)
var login = new WebmakerLogin({
loginURL: env.get('LOGIN_URL'),
secretKey: env.get('SECRET_KEY'),
domain: env.get('DOMAIN', null),
forceSSL: env.get('FORCE_SSL', false)
});
Step 7:
Set up webmaker-auth's cookie parser and cookie session middleware
app.use(login.cookieParser());
app.use(login.cookieSession());
Step 8:
Set up less-middleware to compile the webmaker-auth-client's CSS
var optimize = env.get('NODE_ENV') !== 'development',
tmpDir = path.join(require('os' ).tmpDir(), 'mozilla.webmaker-login-example.build');
app.use(lessMiddleware({
once: optimize,
debug: !optimize,
dest: tmpDir,
// NOTE: we'll use a LESS include in our public/css to include the create user form styles
src: __dirname + '/public',
compress: optimize,
yuicompress: optimize,
optimization: optimize ? 0 : 2
}));
Step 9:
Set up routes for logging in, using the webmaker-auth route handler functions.
If you don't need to add middleware to the login routes, you can use the .bind()
function, which accepts your express instance as a parameter and automatically sets up routes.
app.post('/verify', login.handlers.verify);
app.post('/authenticate', login.handlers.authenticate);
app.post('/create', login.handlers.create);
app.post('/logout', login.handlers.logout);
app.post('/check-username', login.handlers.exists);
Step 10:
Statically serve the bower_components
folder, so the front end can load webmaker-auth-client.js
app.use('/bower', express.static(__dirname + '/bower_components'));
Step 11:
Add the app's hostname and port to the Login Server's ALLOWED_DOMAINS
variable.
This is an external configuration step that must be done to the login server you wish to connect with.
Front End Configuration (HTML+CSS)
Here we'll configure the view to work with our login strategy. Check out the full file here, and check out the CSS file here
Step 1:
Load in the create user form css
In this example, the create user form CSS is imported into the a separate CSS file using a less import directive - you do not necessarily have to load the new user form css in this way.
<link href="/css/login-example.css" rel="stylesheet">
// Import the create user form less.
// NOTE: the "(less)" import directive forces less to interpret the file as LESS, regardless
// of the ".css" extension on the file. This means you must use LESS > v1.3
@import (less) "../../bower_components/webmaker-auth-client/create-user/create-user-form.css";
Step 2:
Add some log-in and log-out buttons
<button class="btn btn-primary login">Login</button>
<button class="btn btn-warning logout">Logout</button>
Step 3:
Using nunjucks, include the create user form
{% include "/webmaker-auth-client/create-user/create-user-form.html" %}
Step 4:
Load the Persona include.js
script
<script src="https://login.persona.org/include.js"></script>
Step 5:
If not using the minified version of webmaker-auth-client, you must include EventEmitter.js
<script src="/bower/eventEmitter/EventEmitter.js"></script>
Step 6:
Load webmaker-auth-client.js
You can also load webmaker-auth-client with require-js
<script src="/bower/webmaker-auth-client/webmaker-auth-client.js"></script>
Step 7:
Load in the JS for setting up webmaker-auth actions and event listeners
<script src="/js/login-example.js"></script>
Front End Configuration (JS)
Now lets write some JavaScript that uses the webmaker-auth-client to log in a user. View the entire file here
Step 1:
Instantiate the WebmakerAuthClient
For a list of all options check out the webmaker-auth-client documentation
var auth = new WebmakerAuthClient({});
Step 2:
Create login, logout and error event listeners
auth.on('login', function(data, message) {
usernameEl.innerHTML = data.email;
});
auth.on('logout', function() {
usernameEl.innerHTML = '';
});
auth.on('error', function(err) {
console.error(err);
});
Step 3:
Call the verify
function
Verify checks with the server to see if the client currently has a valid session, and will trigger a login event or logout event based on the result.
auth.verify();
Step 4:
Hook the logout
and login
functions up to the buttons
loginEl.addEventListener('click', auth.login, false);
logoutEl.addEventListener('click', auth.logout, false);
All Done!
The sum of all these steps is a simple app that can log in a webmaker user!
Some may say that this is still a lot of work, but to be perfectly honest, the previous SSO solution was so bad that I'm certain that simply writing a guide for it would have been a massive undertaking.
We're always going to be improving, so as we go, hopefully some of the rougher edges here disappear. We're always open to feedback and suggestions, so let us know what you think! And as always, file bugs if there are any problems.