Demystifying the Discourse authentication flow

Recently, I have been working on a fairly ambitious Ember.js single page application. One of the things that you almost inevitably end up having to implement is authentication. When I first started working with Ember.js towards the end of 2013, I found that most, if not all, of the content on the topic of integrating Ember.js and authentication involved implementing form based authentication.

In form based authentication, your application handles the user’s credentials. In other words, you build a form that usually has one input box for their username (or email address) and another input box for their password. This type of authentication is acceptable if there is no requirement to integrate an existing authentication system with your application. Of course, I did not have the luxury to implement a standalone authentication system. I had to integrate with a single sign-on system that uses SAML authentication.

I could not find much information about integrating Ember.js with single sign-on systems, so I looked towards what is perhaps the most well known application in the Ember.js community, Discourse. If you are not familiar with Discourse, it is a modern take on Internet forum software that uses, as you guessed it, Ember.js as its client-side framework. The server-side is written in Ruby on Rails.

Discourse supports authentication with external authentication systems such as Google and Facebook. One of the things you will notice when you login to Discourse using external authentication is that a popup window opens. This popup window redirects to the third party’s login page. You enter your credentials, login, do something to approve sharing account information with Discourse, and then the popup window closes. You are then automatically logged in at the main Discourse window. This authentication flow is what I will attempt to dissect and describe.

The best place to get a deeper look at how Discourse works is through its source code. It turns out that Discourse uses a few infrequently used (in my opinion) but interesting browser APIs to help orchestrate the authentication flow.

First, a popup window is opened. This is fairly straightforward to do in JavaScript. Call window.open, make the target _blank and configure the window size and decoration. The page that is opened in the popup should do something to initiate authentication with the third party system. In Discourse’s implementation, the OmniAuth gem is what handles the authentication flow, so the popup loads the page that OmniAuth uses to set up the parameters used to direct the user to the third party’s login page. Once the user is logged in, the third party system will redirect the user to the callback endpoint specified by OmniAuth along with any other necessary payload. OmniAuth then processes the payload and tells the Rails application that someone is now logged in. Up to this point, everything is fairly ordinary. There should be no problems implementing a similar authentication flow in other web frameworks. In fact, the strategies that Discourse uses can all be implemented in other web frameworks.

After a user successfully authenticates against the Rails application, a page that handles the next sequence in the authentication flow is rendered. Here is the interesting code excerpt from the page:

<script>
window.opener.Discourse.authenticationComplete(<%=@data.to_client_hash.to_json.html_safe%>);
window.close();
</script>

Let’s take a closer look at what is going on. The script is able to get a reference to the already running Discourse Ember.js application window using window.opener. This works because the first thing that Discourse does when you try to authenticate against a third party, is that it opens a popup window. With the window reference in hand, the script then calls Discourse.authenticationComplete with the JSON serialized form of the user’s authentication status and account state. After that the window is closed which works because the window was opened using JavaScript.

The Discourse.authenticationComplete method forwards the authentication data it receives to the login controller’s authenticationComplete method. This method figures out if it needs to halt or continue the authentication flow depending on the data it receives. Looking at the code, we can see that it halts the authentication flow if the account requires an invite, approval, or activation. Assuming that the account does not need any of that, the method instead reloads the page. When the page is reloaded, the Discourse Rails application generates a page with the logged in user’s account details (among many other things) loaded into a temporary key-value store in JavaScript. The Ember.js application sources data from this temporary store before falling back on AJAX requests. This concludes Discourse’s authentication flow.

To summarize, Discourse’s authentication flow against third party login systems is as follows:

  1. User tries to sign in and chooses their choice of third party login system.
  2. Popup window is opened, loading a page that the Discourse Rails application uses to set up the state necessary for the user to authenticate against the third party system, then redirects to the relevant login page.
  3. User authenticates against third party system.
  4. Successful authentication results in the third party system redirecting to authentication callback route in the Discourse Rails application.
  5. Discourse Rails application notifies the Ember.js application of authentication state then closes its own popup window.
  6. Discourse Ember.js application reloads its own page.
  7. User is now signed in. Interface elements corresponding to the user’s account, like their name and avatar are now displayed.