Nurturing a growing platform with an OmniAuth authentication flow
This post is about a pivotal moment in the evolution of Vox Media’s tech stack. To date, we’ve constructed a monolithic Rails app, called Chorus, which powers each of our 321 communities. As we begin to launch new applications outside of the Chorus mothership (e.g., Pick 6, a fantasy sports game, and Syllabus, a live blogging platform) we have a clear need for these apps to work together when it comes to things like utilizing sports data, tracking metrics, and the subject of this post: authentication.
Vox Media has 321 interconnected communities of awesome engaged users, and we strive to make it easy for our users to participate in several at once. It’s obvious that signing up and logging into yet another website is no fun, so it’s critical that we make it super easy to hop back and forth between all our apps and communities with a single login session.
This has been no big deal up until now, because all of our communities and sites run under the same Rails app, connected to the same database. When Pick 6 came along, we made the decision to develop it as an independent Rails app, making it a good experiment in transitioning to a service-oriented architecture. With this decision, we needed a way to extend our existing single sign on (SSO) solution from one to many coordinated apps.
When presented with the problem of securely sharing login credentials across apps, I immediately thought of the OAuth2 protocol, which I am familiar with from interacting with several popular APIs, including Facebook’s.
Instead of building a full OAuth2 server into Chorus, I (perhaps foolishly) chose to make the fewest codebase changes by devising a custom authentication flow. In hindsight, I may have chosen to go all out with OAuth2 by using rack-oauth.
Anyway, the authentication flow I came up with looks something like this:
Sequence of URLs and actions taken:
|1|| ||- Click login link|
|2|| ||- Remember referrer for later |
- 302 redirect
|3|| ||- Submit login form|
|4|| ||- Verify credentials |
- Generate a session token
- Look up the callback URL
- Append the session token to the callback URL
- 302 redirect to the callback URL
|5|| ||- Derive user info from API call using session token and shared secret |
- Save user info in session
- 302 redirect to remembered URL
|6|| ||- Arrive logged in and ready to go!|
In order to implement this flow, changes were needed on both sides: in Chorus and in Pick 6.
Changes to Pick 6
My favorite way of implementing third-party login in Rails apps is with the awesome OmniAuth library. Having OmniAuth’s request and callback phase architecture in mind when coming up with the above flow made it super easy to write up a reusable authentication library to handle steps 2 and 5 as an OmniAuth strategy: omniauth-chorus.
Step 2 is the request phase, and step 5 is the callback phase. Step 2 is simply a redirect to a hard-coded URL. Step 5 is where the guts of the strategy live. This phase has the responsibility of converting an api_session_token into an auth hash via a request of the Chorus user API. I found omniauth-oauth2 to be a great example of how to write omniauth-chorus.
Changes to Chorus
In order to provide omniauth-chorus with the session token and API endpoint needed to complete its work, some changes to the Chorus app were necessary. Chorus has a robust internal API that is used to power our native mobile apps for iOS and Android. omniauth-chorus makes use of this same API in step 5 of the flow to convert a session token into user info.
To date, our mobile apps securely authenticated themselves to this internal API with a shared secret. This secret was hard coded into Chorus with a line of code like this:
API_KEY = abc123... # TODO: Don’t use a hard-coded API key
This project provided the needed impetus to create a flexible system of API clients and secrets so that the mobile apps didn’t share credentials with Pick 6. This was solved by creating an api_client resource that gives each client of the Chorus API a distinct identity, shared secret (API key), and callback URL to be used in the above flow.
In step 5, the API key is only transmitted in a direct request from Pick 6 to Chorus (not through the user’s browser). This makes us reasonably certain of the secrecy of each API key.
If an outsider were able to observe the api_session_token parameter in step 5, they would be able to impersonate another user by visiting that URL. But because we enforce that steps 3, 4, and 5 all happen over SSL, we make it difficult for this man-in-the-middle attack to work.
Single Sign On
So far so good: We have the ability to log into Pick 6 by deferring to SB Nation using the above flow. But for a really seamless experience, we’d love if there was some way for my login status on Pick 6 and SB Nation to always be in sync. Logging in or out of SB Nation should change my login status on Pick 6 and vice-versa.
Chorus has a sweet system for sharing login status between all of the different domains of its communities. It works by placing a blocking
tag in the head of each page for non logged-in users that makes a request of
document.cookie = "_session_id=0516f8da05bb633a0cdbcfb8fc94189c;path=/"; window.location.reload();
This copies over the session cookie from the
www.sbnation.com domain to the other. In this way, we can ensure that if a user is logged into any of SB Nation’s sites, they are logged into all of them.
Single Sign On++
https://www.sbnation.com/sso/sync_api_client_session, which does one of 3 things:
- If the user is logged into Pick 6 but not SB Nation, tell Pick 6 to log out:
window.location = "https://pick6.sbnation.com/logout";
- If the user is logged into SB Nation but not Pick 6, tell Pick 6 to log in as a specific user
window.location = "https://pick6.sbnation.com/auth/chorus/callback?api_session_token=Y3SDB4zdr3KuUrFLQGbd";
- If the user is logged in to both or neither of Pick 6 and SB Nation, do nothing.
It works! It is trivially easy for existing SB Nation members to play Pick 6, and new signups to play Pick 6 translate into new users and potential contributors on the SB Nation network of communities.
As proof that it was time well spent to make omniauth-chorus a reusable library, we have already utilized it in another app: Syllabus (our new live blogging platform). The process of setting up Syllabus to authenticate users with omniauth-chorus platform took less than half an hour to configure.
There are still many opportunities to improve the omniauth-chorus library, such as:
- Longer session lengths (especially on mobile), reducing the number of times you ever have to see a login dialog.
- URL helpers for login, logout, and signup links.
- Role- and membership-based authorizations, i.e. Is a user is a network admin, member of lookoutlanding.com, etc.
- Improve callback URL management in different environments / domains.
By this last point, I mean that because each API client has one callback_url saved in the Chorus database, we need separate API client records for each domain client apps may be run under, i.e. development, sandbox and staging environments. Making this a more flexible system with a whitelist of domains or some other logic would simplify configuration of client apps running in development / staging environments.
Eventually, we may continue our quest for service-orientation by moving authentication logic out of the Chorus app into a separate service, perhaps making the move to OAuth2 at that time. Such an app would include a UI for registering and managing API clients of the Chorus platform, making it stupid simple to spin up a new app. This will also be invaluable if and when we are ready to support a public API.
Because omniauth-chorus already decouples authentication logic from secondary apps like Pick 6 and Syllabus, it won’t take much effort for those apps to utilize whatever authentication strategy we come up with in the future. A simple version upgrade of the omniauth-chorus gem and simple config changes would be all that is required.