Single Sign On (SSO) for Multiple Applications with Devise, OmniAuth and Custom OAuth2 Implementation in Rails

Recently I had to implement Single Sign On (SSO) for one of the Rails app I’d been working on. Since Devise is already fairly popular to integrate an authentication system in Rails app, I was more inclined towards using it to achieve SSO. So essentially what was required is a single user manager app that can act as a Provider (OAuth2 ?) and different applications (or Clients) that can authenticate themselves using this same user manager. An important part of SSO is, once you sign in to one of the client, you should automatically be authorized to access all the other clients (their login-protected sections/modules). Similarly, logging out from one service should log out from all other services.

To accomplish this, I found an excellent article by JoshSoftware that solved my problem. Although I’d to change a lot of the code to make it Rails 4 compatible (from Rails 3). I’ve even uploaded the source code on Github:

What's the one thing every developer wants? More screens! Enhance your coding experience with an external monitor to increase screen real estate.

Basically, OmniAuth on the client-side and some custom code on the provider-side is used to turn the provider into an OAuth2 provider, eventually accomplishing SSO. A lot of magic happens under the hood, so I decided to document the entire flow (even inside the gems) of the authentication process.

Authentication by Client

When a request is sent to the Client which is supposed to require authentication, it’ll check whether the user is logged in or not. If yes (session has data) then that’s fine otherwise the Client will redirect the user to a specific URL (of the Provider) where he can authenticate himself.

Implementation of this starts off by creating a custom OAuth2 Strategy which extends from OmniAuth::Strategies::OAuth2 (omniauth-oauth2). The omniauth-oauth2 gem is basically a generic OAuth2 strategy for OmniAuth which basically means that it can serve as a building block for custom providers like omniauth-facebook (FB auth), omniauth-twitter (Twitter auth) and so on. So if we decide to call our provider Sso then we’ll have to create a custom strategy that’ll extend the same class, so something like this class Sso < OmniAuth::Strategies::OAuth2. The Provider must also be specified in initializers/omniauth.rb so that it can be mounted as a Rack middleware.

If the user is not signed in then he's redirected to the Client's /auth/sso URI. This is important because it triggers further authentication process. So this is a single entry point for all non-authenticated requests to a Client for authentications's sake as it'll trigger the entire chain of events further, required for authentication.

The request to /auth/sso basically triggers the request_phase method of the custom strategy which calls the request_phase of OmniAuth::Strategies::OAuth2 (parent class). How does this all happen ? So when the OmniAuth::Builder middleware was mounted (in initializers/omniauth.rb), the call to provider mounted OmniAuth::Strategies::Sso which extends OmniAuth::Strategies::OAuth2. The initializer code looks something like this (for more context):

APP_ID = 'key'
APP_SECRET = 'secret'

CUSTOM_PROVIDER_URL = 'http://localhost:3000'

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :sso, APP_ID, APP_SECRET
end

To get the key and secret, a Client has to be registered first in the Provider. So basically the DB has a clients table with these columns (to maintain all the registered clients):

id | name | app_id | app_secret | created_at | updated_at

So the request_phase of OmniAuth::Strategies::OAuth2 (parent class) redirects the user to the authorize_url (on the Provider) specified in the custom strategy with certain parameters (looks something like /auth/sso/authorize?get_params). Here’s a sample URL:

http://localhost:3000/auth/sso/authorize?client_id=key&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fauth%2Fsso%2Fcallback&response_type=code&state=f73bd089efa7722364d10ed36625502bd19783e27b628fdb

The redirect_uri (http://localhost:3001/auth/sso/callback in this case) is generated by the callback_path method of OmniAuth::Strategy module (omniauth gem) which is included in the OmniAuth::Strategies::OAuth2 class (omniauth-oauth2 gem). The callback path is something like #{path_prefix}/#{name}/callback (evaluates to auth/sso/callback) by default (should be configurable).

Authentication by Provider

At this stage the User has been redirected from the Client to Provider for authentication with the help of authorize_url. As already mentioned this URL looks something like this /auth/sso/authorize?get_params and is protected by Devise. So if the user is not logged into the Provider he'll be taken to the signin/signup flow/page (http://provider/user/sign_in for instance) which once completed redirects the user back to this authorize_url. This post-signin/signup redirection happens via Devise automagically since it maintains the previous URL requested (that led to the sign in page) in session['user_return_to']. Once the user is authenticated (or if he was already authenticated by the Provider) then the action mapped to the /auth/sso/authorize route is executed.

The authorize action gets a param called state (if you notice the URL above) which is generated inside OmniAuth::Strategies::OAuth2 (in omniauth-oauth2 of Client) like this options.authorize_params[:state] = SecureRandom.hex(24). The state value is be later used in the callback_phase of omniauth-oauth2 for a CSRF check (protection). Basically the action creates an entry in a table called access_grants with these fields:

code | access_token | refresh_token | access_token_expires_at | user_id | client_id | state | created_at

The code, access_token, refresh_token fields are generated using SecureRandom.hex(16). After creating an access grant, the user is redirected to the callback URL with the same state param that was sent while authentication redirection and the newly generated code param. So then new redirection URL will be something like this - redirect_uri + "?code=#{code}&response_type=code&state=#{state}" where the redirect_uri is the same callback URL as above retrieved via params['redirect_uri'].

Client Callback Processing

After authorization that creates a secret code, the user is redirected to the callback URL which is on the Client. The callback URL looks something like this /auth/sso/callback?code=xxx&response_type=code&state=xxx. It is intercepted by OmniAuth::Strategies::Sso < OmniAuth::Strategies::OAuth2 (custom OAuth2 strategy) which was setup as a middleware by OmniAuth::Builder's provider method call. To recap, OmniAuth::Strategies::OAuth2 includes OmniAuth::Strategies and OmniAuth::Builder loads OmniAuth::Strategies::Sso as a middleware. On interception, the call method on OmniAuth::Strategies is called (because it is included by OmniAuth::Strategies::OAuth2 and it's a rack middleware) which triggers the callback_call method which in turn calls the callback_phase method whose job is to basically set omniauth.auth env variable (env['omniauth.auth']) to an AuthHash. While setting this AuthHash a couple of methods are called that should be defined in the custom strategy implementation like uid, info, credentials and extra.

It is very important to note that before the callback_phase of OmniAuth::Strategies module, the same of OmniAuth::Strategies::OAuth2 is called (since it includes OmniAuth::Strategies and its callback_phase calls super). This method does a couple of interesting things. It triggers build_access_token which basically makes a request to the Provider with the code that had been received. What this method does is, it uses the oauth2 library to get the access token from the token_url specified in the custom strategy (otherwise default is /oauth/token). So it hits the token_url with the code, client_id, client_secret to receive the access token. Feel free to check the rails developments logs while you try to login for all other params/info. Once the Provider gets this request, it checks if a client record exists in its DB with the client_id and client_secret and whether the code actually is associated with any AccessGrant which is associated to a User. In the end it will return a JSON object:

{"access_token": "access_token", "refresh_token" => "refresh_token", "expires_in": Devise.timeout_in.to_i}

Out of the object returned above, oauth2 gem creates an AccessToken object (accessed as access_token across codebase) which sets instance variable for each key and renames access_token to token - so it's accessed as access_token_ob.token.

Finally the token_url will hit the action mapped to it in routes.rb which has to be a custom implementation. There env['omniauth.auth'] will contain an object of the AuthHash type (because of that call to callback_phase of OmniAuth::Strategies module). Here's a sample dump of it to show how it'll look like:

{"provider"=>"sso",
 "uid"=>"2",
 "info"=>{"email"=>"[email protected]"},
 "credentials"=>
  {"token"=>"7c21854636f5d71c3dde2bde2cc93e16",
   "refresh_token"=>"5f5770f7028eefae6e775cba8aef7a5a",
   "expires_at"=>1441737106,
   "expires"=>true},
 "extra"=>{"first_name"=>"", "last_name"=>""}}

These info are fetched by executing the blocks passed to the respective methods (uid, info, extra, etc.). These methods will be defined in your custom strategy. The credentials is specified in OmniAuth::Strategies::OAuth2 (omniauth-oauth2).

Using the Access Token

Now that we have the access token object with us, we can start making HTTP requests to the Provider from Client and pass the access token with it which can be checked against the DB in Provider for authorization. We can even do a Devise sign_in call on the user object if one is found via the access_token.

The oauth2 gem's AccessToken object has a concept of refreshing tokens too. Right after the build_access_token is called and the access token is fetched, a call is made to access_token.refresh! if the access token has expired. So if the response of access token has expires_at which is less than current time (expires_at < Time.now.to_i) then the gem will make a new request to get the access token (same flow as above) but this time the token sent for verification will be a refresh_token which was generated the first time when access was granted and it should be used to return the old access token (might want to regenerate that). The other difference will be in the grant_type param which will be refresh_token this time compared to authorization_code earlier. This call should also re-set (update) the expiry time of the access token.

In the existing code (hosted on Github, linked above), on the client-side, if the user is logged in successfully and the relevant session expires after sometime (or destroyed), the user will be redirected to the Provider where he'll be logged in. In this case the Provider will create a new access token and redirect the user to the Client which will again make an internal call to the Provider to obtain the access token. The code (hosted on Github above) can be modified (AuthController#authorize) in a way where if the user is already logged in to the Provider and has a **recently used** access token, in the case the expiry of that token can be re-set to a future date and re-used.

Overall Conceptual Flow

  • Client redirects the user to the Provider for Authentication.
  • User logs into the Provider.
  • Provider redirects the user to Client with a special random code.
  • Client uses the special random code to make an API call to the Provider along with its ID and Secret keys to obtain the Access Token.
  • Further requests (for any operation like getting user details, making payment, etc.) are made with the Access Token which authorizes the User against the DB.
  • Logout clears the Session on Client as well as Provider and Database.

That's all folks!

Recommended from our users: Dynamic Network Monitoring from WhatsUp Gold from IPSwitch. Free Download

Author: Rishabh

Rishabh is a full stack web and mobile developer from India. Follow me on Twitter.

Leave a Reply

Your email address will not be published. Required fields are marked *