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.
- Provider – https://github.com/rishabhp/sso-rails-provider
- Client – https://github.com/rishabhp/sso-rails-client
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!