Rails Authentication with OAuth 2.0 and OmniAuth

This is the third article in the Authentication with Rails series. We’ve build a classic login/password authentication systems with features like remember me, reset password, confirm e-mail, and the like.

Today we are going to talk about authentication via social networks with the help of the OAuth 2 protocol. We will discuss OmniAuth and four of its strategies: Twitter, Facebook, Google+, and LinkedIn, allowing users to log in with any network they like. I will instruct you how to integrate each strategy, set it up, and handle errors.

Source code for this article is available on GitHub.

A working demo can be found at sitepoint-oauth2.herokuapp.com.

OAuth 2 and OmniAuth

OAuth 2 is an authorization protocol that enables a third-party applications to obtain limited access to an HTTP service. One of the main aspects of this protocol is the access token that is issued to the application. This token is used by the app to perform various actions on the user’s behalf. However, it can’t perform something that was not approved (for example, the user may only allow an app to fetch information about friends, but not post on the user’s wall).

This is cool because, under no circumstances should a third-party app access a user’s password. It only gets a special token that expires after some time (typically, users may also revoke access manually, as well) and may only be used to perform a list of approved tasks, called a scope.

How is a third-party app identified? Well, when talking about social networks, an app has to registered first. The developer provides the app’s URL, name, contact data, and other information that will be visible to the user. The authorization provider (Twitter or Facebook) then issues a key pair that uniquely identifies the app for the social network/provider.

Here is the simplistic overview of what happens when a user visits the app and tries to authenticate via a social network:

  • User clicks “Login” link
  • User is redirected to the social network’s website. The app’s data (client_id) are sent along for identification
  • User sees the app’s details (like name, logo, description, etc.) and which actions it would like to perform on his behalf (the scope). Think of it like you coming to the user and saying: “Hey, my name is Jack and I want to get the list of all your friends! If you agree, I’ll show you interesting statistics about them”
  • If the user does not trust this app, they just cancel the authorization
  • If the user trusts the app, the authorization is approved and the user is redirected back to the app (via callback URL). Information about the authenticated user and a generated token (sometimes with a secret key) is sent along.

There are numerous of services that support OAuth 2 authentication, so to standardize the process of creating authentication solutions OmniAuth was created by Intridea, Inc. It supports anything from OAuth 2 to LDAP. In this article, we will focus on omniauth-oauth2, an abstract OAuth 2 strategy. Basically it is used as a building block to easily craft authentication strategies on top of it. Here is the huge list of all strategies available for OmniAuth (it also includes those which are not related to OAuth 2).

Having a standard approach for strategies is great because you can integrate as many as you want without issue. Still, you have to remember that some social networks may return a different data about the authenticating user, so testing how your app works is absolutely necessary (I will show you some examples on this.)

If you have never integrated OAuth 2 into an app, this all might seem complicated, but don’t worry, soon it will become very clear! It is nice to learn by example, so let’s dive into the code and create something fancy.

Preparing the Demo App

Okay, create a simple app allowing users to authenticate via one of the presented social networks. I’m calling mine SocialFreak:

$ rails new SocialFreak -T

Rails 4 will be used for this demo, but Rails 3 should work just as well.

Hook up Bootstrap to style the app (optional):

Gemfile

[...]
gem 'bootstrap-sass'
[...]

and run

$ bundle install

Import the necessary files:

stylesheets/application.css.scss

@import "bootstrap-sprockets";
@import "bootstrap";
@import 'bootstrap/theme';

Modify the layout:

layouts/application.html.erb

[...]
<nav class="navbar navbar-inverse">
  <div class="container">
    <div class="navbar-header">
      <%= link_to 'Social Freak', root_path, class: 'navbar-brand' %>
    </div>
    <div id="navbar">
      <ul class="nav navbar-nav">

      </ul>
    </div>
  </div>
</nav>

<div class="container">
  <% flash.each do |key, value| %>
    <div class="alert alert-<%= key %>">
      <%= value %>
    </div>
  <% end %>

  <%= yield %>
</div>
[...]

This sets up main menu (with no items yet) and an area to render flash messages.

Create the root page:

config/routes.rb

[...]
root to: 'pages#index'
[...]

pages_controller.rb

class PagesController < ApplicationController
  def index

  end
end

views/pages/html.erb

<div class="jumbotron">
  <h1>Welcome!</h1>
  <p>Authenticate via one of the social networks to get started.</p>
</div>

Authentication Via Twitter

Adding a Strategy

Let’s use the omniauth-twitter gem created by Arun Agrawal. It is one of the numerous strategies for OmniAuth. Drop it in the Gemfile:

Gemfile

[...]
gem 'omniauth-twitter'
[...]

and run

$ bundle install

Configuration for all the OmniAuth strategies lives in the omniauth.rb initializer file, so create it now:

config/initializers/omniauth.rb

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :twitter, ENV['TWITTER_KEY'], ENV['TWITTER_SECRET']
end

Registering a new strategy (provider) is a matter of providing a key pair to identify the app to the provider. Of course, we need to obtain those two keys, so do the following:

  • Navigate to apps.twitter.com
  • Click “Create new app”
  • Fill in the form. For the callback URL, provide your site’s address plus “/auth/twitter/callback”. If you are on a local machine, provide “http://localhost:3000/auth/twitter/callback”. We will discuss this callback URL shortly.
  • Click “Create”.
  • You will be redirected to the app’s information page on Twitter. Navigate to the “Keys and Access Tokens” tab.
  • Copy the Consumer Key and Consumer Secret and paste them into the initializer file.
  • You may also browse other tabs, but for this demo we won’t change anything else. For example, inside “Permissions” you can set which actions the app will be able to perform after a user has authenticated. If you needed, for example, to be able to post tweets on behalf of the user, you’d have to change permissions accordingly. This controls the aforementioned “scope” of the authorization.

Please note that you may further set up this strategy as described here. For this demo, default values will work just fine.

Now let’s return to the Callback URL. This is the URL where a user will be redirected to inside the app after successful authentication and approved authorization (the request will also contain user’s data and token). All OmniAuth strategies expect the callback URL to equal to /auth/:provider/callback”. :provider takes the name of the strategy (“twitter”, “facebook”, “linkedin”, etc.) as listed in the initializer.

With this knowledge, let’s set up routes accordingly:

config/routes.rb

[...]
get '/auth/:provider/callback', to: 'sessions#create'
[...]

and add the first link to our main menu:

layouts/application.html.erb

[...]
<ul class="nav navbar-nav">
  <li><%= link_to 'Twitter', '/auth/twitter' %></li>
</ul>
[...]

Note that the /auth/twitter route is provided by the strategy and redirects to the Twitter login page.

Authentication Hash

The create method inside the SessionsController will parse the user data, save it, into the database, and perform sign in to the app. However, what does the user data look like? That’s easy to check:

sessions_controller.rb

class SessionsController < ApplicationController
  def create
    render text: request.env['omniauth.auth'].to_yaml
  end
end

request.env['omniauth.auth'] contains the Authentication Hash with all the data about a user.

Reload the server and try to authenticate via Twitter. You will see something like this. Note that, apart from your name, location, avatar, and other basic info, there is also some special data like followers and tweets count. Of course, this data will vary between providers (and some social networks send only basic information, by default).

Saving User Data

Now that you know what the authentication hash looks like and what data can be fetched, it’s time to decide which information to store. Since we want to craft multi-provider authentication, saving something like tweets count is not the top priority (however, you could just serialize and save the “extra” part of user’s authentication hash in a separate field).

For this demo let’s store the following:

  • Provider’s name. This is absolutely necessary because we will have multiple providers.
  • User’s unique identifier. This identifier is generated by a social network and may contain various symbols. Some networks use only numbers, while others use letters as well. Combination of provider’s name and uID will uniquely identify user inside our app.
  • User’s full name. Some networks also provide name and surname separately (and some, like Twitter, also separate name and nickname) but we don’t need such complexity.
  • User’s location. Not all networks provide this, but let’s research which ones do and in what format.
  • User’s avatar URL. We will display user’s avatar in the main menu, so it has to be pretty small. Fortunately, most social networks allow you to choose from one of a several available sizes. Use the image_size option for Twitter to control this (the default is 48x48px which suites us perfectly). Also, note that, by default, avatar URLs have http as the protocol. If you want everything on your page to use https, many social networks allow this. For example, with Twitter you have to set secure_image_url to true.
  • User’s profile URL. Every social network provides this. However, in many cases, the urls key has a nested hash like so:


:urls => {
:Website => 'http://example.com',
:Twitter => "https://twitter.com/xxx"
}

We are not storing the user’s token because we won’t actually perform any actions on their behalf. If you need this, create the token and secret fields with a string data type (some social networks provide secret as well). Use those when sending API requests to the service.

Okay, now we can generate the appropriate migration:

$ rails g model User provider:string uid:string name:string location:string image_url:string url:string

Open migration’s file and modify it like this:

xxx_create_users.rb

[...]
t.string :provider, null: false
t.string :uid, null: false
add_index :users, :provider
add_index :users, :uid
add_index :users, [:provider, :uid], unique: true
[...]

provider and uid cannot be null and should be indexed. add_index :users, [:provider, :uid], unique: true adds a clustered index on those two fields and makes sure that their combination is unique.

Apply the migration:

$ rake db:migrate

Now re-write the create action

sessions_controller.rb

[...]
def create
  begin
    @user = User.from_omniauth(request.env['omniauth.auth'])
    session[:user_id] = @user.id
    flash[:success] = "Welcome, #{@user.name}!"
  rescue
    flash[:warning] = "There was an error while trying to authenticate you..."
  end
  redirect_to root_path
end
[...]

from_omniauth is a yet non-existent method that will parse the authentication hash and return the user record. Next, just save the user’s id inside the sessions and redirect to the main page.

It is time to take care of the from_omniauth class method:

user.rb

[...]
class << self
  def from_omniauth(auth_hash)
    user = find_or_create_by(uid: auth_hash['uid'], provider: auth_hash['provider'])
    user.name = auth_hash['info']['name']
    user.location = auth_hash['info']['location']
    user.image_url = auth_hash['info']['image']
    user.url = auth_hash['info']['urls']['Twitter']
    user.save!
    user
  end
end
[...]

find_or_create_by ensures that we are not creating the same user multiple times. The method stores all the required data, saves the user, and returns it. If you are interested, the user’s token can normally be accessed as auth_hash['credentials']['token'] (auth_hash['credentials']['secret'] for secret).

Current User and Logging Out

We need a way to find out whether a user is logged in or not. current_user is a method that, by convention, either returns a user record or nil:

application_controller.rb

[...]
private

def current_user
  @current_user ||= User.find_by(id: session[:user_id])
end

helper_method :current_user
[...]

helper_method :current_user ensures that it can be called from the views, as well.

Great, now the user can log in, but can’t log out. Add a logout link:

layouts/application.html.erb

[...]
<nav class="navbar navbar-inverse">
  <div class="container">
    <div class="navbar-header">
      <%= link_to 'Social Freak', root_path, class: 'navbar-brand' %>
    </div>
    <div id="navbar">
      <% if current_user %>
        <ul class="nav navbar-nav pull-right">
          <li><%= image_tag current_user.image_url, alt: current_user.name %></li>
          <li><%= link_to 'Log Out', logout_path, method: :delete %></li>
        </ul>
      <% else %>
        <ul class="nav navbar-nav">
          <li><%= link_to 'Twitter', '/auth/twitter' %></li>
        </ul>
      <% end %>
    </div>
  </div>
</nav>
[...]

Note that I’ve also added the user’s avatar to the main menu.

The corresponding route:

config/routes.rb

[...]
delete '/logout', to: 'sessions#destroy'
[...]

and the controller action:

sessions_controller.rb

[...]
def destroy
  if current_user
    session.delete(:user_id)
    flash[:success] = 'See you!'
  end
  redirect_to root_path
end
[...]

Lastly, modify the main page to display information about the currently logged in user:

views/pages/index.html.erb

<% if current_user %>
  <div class="page-header">
    <h1>Here is some info about you...</h1>
  </div>
  <div class="panel panel-default">
    <div class="panel-body">
      <ul>
        <li><strong>Name:</strong> <%= current_user.name %></li>
        <li><strong>Provider:</strong> <%= current_user.provider %></li>
        <li><strong>uID:</strong> <%= current_user.uid %></li>
        <li><strong>Location:</strong> <%= current_user.location %></li>
        <li><strong>Avatar URL:</strong> <%= current_user.image_url %></li>
        <li><strong>URL:</strong> <%= current_user.url %></li>
      </ul>
    </div>
  </div>
<% else %>
  <%= render 'welcome' %>
<% end %>

I’ve moved the welcoming message to a separate partial:

views/pages/_welcome.html.erb

<div class="jumbotron">
  <h1>Welcome!</h1>
  <p>Authenticate via one of the social networks to get started.</p>
</div>

That was a lot to cover, but now we’ve set up the base and adding new providers will be really easy (mostly)!

Authenticating via Facebook

We are going to use the omniauth-facebook gem by Mark Dodwell.

Drop it into the Gemfile

Gemfile

[...]
gem 'omniauth-facebook'
[...]

and run

$ bundle install

The idea is the same: We have to set up the new provider and make sure that the authentication hash is parsed correctly.

config/initializers/omniauth.rb

[...]
provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET']
[...]

To get Facebook key and secret:

  • Navigate to developers.facebook.com.
  • Click “Add new app” under “My Apps” menu item.
  • Click “Website” in the dialog.
  • Click “Skip and Create ID”.
  • Enter a name for your app and choose a category, click “Create”.
  • You will be redirected to the app’s page. Click “Show” next to the “App Secret” and enter your password to reveal the key. Copy and paste those keys into your initializer file.

Don’t leave this page yet. You have to do some set up to make the app active:

  • Open “Settings” section.
  • Click “Add Platform” and choose “Website”.
  • Fill in “Site URL” (“http://localhost:3000” for local machine) and “App Domains” (must be derived from the Site URL or Mobile Site URL).
  • Fill in “Contact E-mail” (it is required to make app active) and click “Save Changes”
  • Navigate to the “Status & Review” section and set the “Do you want to make this app and all its live features available to the general public?” switch to “Yes”.

This is the minimal set up – there is much more you can do with your app, so feel free to visit other sections as well.

For the Facebook provider we also have to do some set up in the Rails app:

config/initializers/omniauth.rb

[...]
provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'],
  scope: 'public_profile', info_fields: 'id,name,link'
[...]

scope is the list of permissions that we are requesting from the user upon authorization. public_profile means that only basic info will be available to our app and no actions on user’s behalf may be performed. There are loads of permissions that may be requested from the user, including Extended ones that actually allow you to get access to sensitive data and do some actions on user’s behalf.

info_fields is the list of fields that we want to be included in the Authentication Hash. Please note that some fields will always be present. Here is the list of other fields that are available.

Like Twitter, Facebook supports various avatar sizes (image_size, the default is 50×50 that suits us well) and supports https to access the avatar (secure_image_url). Here are all the settings that you may utilize.

Great, now add a new link to the main menu:

layouts/application.html.erb

[...]
<li><%= link_to 'Facebook', '/auth/facebook' %></li>
[...]

It is time to tweak the from_omniauth method (here is an example of the authentication hash). Actually, only one line has to be modified:

models/user.rb

[...]
user.url = auth_hash['info']['urls'][user.provider.capitalize]
[...]

Now you may restart the server and check it out!

Authenticating via Google+

We will employ omniauth-google-oauth2 gem by Josh Ellithorpe.

Drop it in the Gemfile:

Gemfile

[...]
gem "omniauth-google-oauth2"
[...]

and run

$ bundle install

You know the rules. Add the new provider to the initializer file:

config/initializers/omniauth.rb

[...]
provider :google_oauth2, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_SECRET"]
[...]

To obtain the Google key and secret:

  • Navigate to console.developers.google.com.
  • Click “Create Project” and give it a name.
  • Open “APIs” section from the right-side menu and make sure Google+ API is enabled.
  • Open “Consent Screen” and fill in “Product Name” (and other fields if you wish).
  • Open “Credentials” and click “Create new Client ID”.
  • Choose “Web Application”.
  • Enter your app’s URL in the “Authorized JavaScript origins” (“http://localhost:3000” for local machine).
  • Enter your app’s URL plus “/auth/google_oauth2/callback” in “Authorized redirect URIs”.

Copy the Client ID and Client Secret and paste it into the initializer file.

Now some settings in our Rails app:

config/initializers/omniauth.rb

[...]
provider :google_oauth2, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_SECRET"],
  scope: 'profile', image_aspect_ratio: 'square', image_size: 48, access_type: 'online'
[...]

scope: 'profile' means that we only want to receive basic user info. Google provides a whole bunch of APIs so visit Playground to see a full list of available scopes.

image_aspect_ratio: 'square' means that we want to get user’s avatar with equal width and height (original is the default value).

image_size: 48 means that user avatar will have 48px size for width and height.

access_type: 'online' means that we do not want to receive a refresh token. This means that when a token that was given for us by Google expires, we won’t be able to refresh it (using this special refresh token) so the user will have to log in once again. Offline access type is useful when you want to perform some periodic actions on user’s behalf without asking to constantly log in. Even if user’s token expires, your program will be able to refresh it automatically. This is a bit beyond the scope of this post.

There are some other configuration options that you may want to utilize.

Return to the from_omniauth method and have a look at this line:

models/user.rb

[...]
user.url = auth_hash['info']['urls'][user.provider.capitalize]
[...]

For Google+, the provider’s name will be “google_oauth2”, but a matching key won’t be found inside the urls nested hash. It will be something like:

:urls => {
  :Google => "https://plus.google.com/xxx"
}

We could add a basic conditional statement here, but it’s better to rename our provider, like this:

config/initializers/omniauth.rb

[...]
provider :google_oauth2, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_SECRET"],
         scope: 'profile', image_aspect_ratio: 'square', image_size: 48, access_type: 'online', name: 'google'
[...]

Don’t forget to change the Callback URL in the Google Developer Console to “/auth/google/callback”!

Now add another link to the main menu:

layouts/application.html.erb

[...]
<li><%= link_to 'Google+', '/auth/google' %></li>
[...]

Reload the server and try to authenticate!

Authentication with LinkedIn

This will be the last social network for today and will use omniauth-linkedin-oauth2 gem by Decio Ferreira:

Gemfile

[...]
gem 'omniauth-linkedin-oauth2'
[...]

Don’t forget to run

$ bundle install

Register a new provider:

[...]
provider :linkedin, ENV['LINKEDIN_KEY'], ENV['LINKEDIN_SECRET']
[...]

To obtain LinkedIn key and secret:

  • Navigate to linkedin.com/secure/developer.
  • Click “Add new application”.
  • Fill in the required fields. This form is much bigger (and a bit buggy) than you’ve seen when registering apps for other social networks, so be patient.
  • Fill in “OAuth 2.0 Redirect URLs” with a URL in the format “/auth/linkedin/callback”.
  • After submitting the form, open your app’s page and copy Consumer key and Consumer secret into your initializer file.

Add some configuration options:

[...]
provider :linkedin, ENV['LINKEDIN_KEY'], ENV['LINKEDIN_SECRET'],
  scope: 'r_basicprofile',
  fields: ['id', 'first-name', 'last-name', 'location', 'picture-url', 'public-profile-url']
[...]

scope: 'r_basicprofile' means that we only want to get basic user data.

fields contains an array of all the fields that should be returned in the Authentication Hash. The list of default fields can be found here.

Add another link to the main menu:

layouts/application.html.erb

[...]
<li><%= link_to 'LinkedIn', '/auth/linkedin' %></li>
[...]

This is where things start getting slightly more complicated, because LinkedIn’s authentication hash is a bit different from the ones that we’ve seen up to now:

  • location is a nested hash with country’s code and name.
  • urls is also a nested hash, but there is no LinkedIn key. Instead, there is a public_profile key and probably a bunch of others (storing personal web site and things like that).

This introduces some complexity to the parsing method:

models/user.rb

class << self
  def from_omniauth(auth_hash)
    user = find_or_create_by(uid: auth_hash['uid'], provider: auth_hash['provider'])
    user.name = auth_hash['info']['name']
    user.location = get_social_location_for user.provider, auth_hash['info']['location']
    user.image_url = auth_hash['info']['image']
    user.url = get_social_url_for user.provider, auth_hash['info']['urls']
    user.save!
    user
  end

  private

  def get_social_location_for(provider, location_hash)
    case provider
      when 'linkedin'
        location_hash['name']
      else
        location_hash
    end
  end

  def get_social_url_for(provider, urls_hash)
    case provider
      when 'linkedin'
        urls_hash['public_profile']
      else
        urls_hash[provider.capitalize]
    end
  end
end

I’ve used location_hash['name'] to get the name of the country where user resides and urls_hash['public_profile'] to get only user’s profile URL. Of course, we may use an if conditional instead, but some time in the future you may add more providers and who knows which inconsistencies that will bring. Feel free to refactor this code further.

Unfortunately, LinkedIn does not allow to specify avatar’s dimensions (at least, I have not found a way – if you know one, please share it with me :)), so add this simple style to your stylesheet:

stylesheets/application.scss

[...]
nav {
  img {
    max-width: 48px;
  }
}

Now you may reload your server and log in via LinkedIn!

Rescuing from Errors

The last thing that we are going to discuss today is rescuing from errors that occurs during authentication phase. You may recall that inside the create method we have:

sessions_controller.rb

[...]
begin
  @user = User.from_omniauth(request.env['omniauth.auth'])
  session[:user_id] = @user.id
  flash[:success] = "Welcome, #{@user.name}!"
rescue
 flash[:warning] = "There was an error while trying to authenticate you..."
end
[...]

This indeed rescues any error that was raised inside the from_omniauth method. However, this does not protect us from the errors that happened during the authentication. For example, if you disable cookies and try to authenticate via one of the social networks, you’ll get a SessionExpired error.

Rescuing such errors should be done inside the initializer file:

config/initializers/omniauth.rb

[...]
OmniAuth.config.on_failure = Proc.new do |env|
  SessionsController.action(:auth_failure).call(env)
end

We simply call the auth_failure action inside the SessionsController. It could look like this:

sessions_controller.rb

[...]
def auth_failure
  redirect_to root_path
end
[...]

Here is another (uglier) possible option:

config/initializers/omniauth.rb

[...]
OmniAuth.config.on_failure do |env|
  error_type = env['omniauth.error.type']
  new_path = "#{env['SCRIPT_NAME']}#{OmniAuth.config.path_prefix}/failure?message=#{error_type}"
  ,[301, {'Location' => new_path, 'Content-Type' => 'text/html'}, []]
end

This will perform a redirect to “/failure?message=xxx”. Don’t forget to set up the corresponding route and controller action to handle the request.

Conclusion

We’ve created a multi-provider authentication system that may be further expanded any way you like. Hopefully you’ve enjoyed this post! Have you ever integrated authentication via social networks in your projects? Share your experience and don’t hesitate to post your questions. If you’d like me to cover a specific topic, please send an e-mail, I will respond as soon as possible.

Happy coding and stay social!


Sponsors

No Reader comments