Cyrus Stoller home about consulting

Supporting multiple OmniAuth providers with Devise

One of the things I appreciate most about the Rails ecosystem is that there are so many well-maintained gems that can be used to jumpstart a project. This means that I can spend more time focusing on what makes my application unique, instead of needing to write boilerplate code that most users will take for granted. For example, it takes a surprising amount of time to build all of the different workflows associated with user authentication (e.g. allowing users to reset their passwords or locking accounts after too many failed login attempts). Thankfully, Devise makes it so you can easily start a project with secure email / password authentication.

But even when this is in place, I’ve found that users typically grumble about needing to create and remember yet another set of login credentials. They ask whether they can just sign in with Google, Twitter, Facebook, etc … Fortunately it’s relatively straightforward to use one of these additional identities using OAuth2-based on OmniAuth. The Devise wiki provides clear instructions for how to add this functionality with a few extra columns on your user table. The challenge with this approach is that it assumes that each user will only ever want to use one strategy. In other words, if a user connects their Google account, there’s an assumption that the user won’t want to also connect a Twitter or Facebook account later.

In this blog post, I’m going to review how to build a Rails application that can support an arbitrary number OAuth2 providers. This way a single user can pull in data from Google, Twitter, and Facebook at the same time. In the default implementation, if a user first connects to Google and then later connects to Twitter, the Rails application is no longer able to communicate with Google.

I’m going to walk through how to implement the design discussed on the OmniAuth wiki for Rails. I’ll assume that you’ve already installed Devise for basic email / password authentication before. But if you haven’t, check out the Devise get started guide.

Conceptually how OAuth2 works

OAuth2 is an open standard for access delegation. Typically this mechanism is used to allow users to share information about their accounts from other services (e.g., allowing an app to see your Facebook friends or post tweets on their behalf). In this post, I’m only going to talk about the process of allowing users to login, but this can be extended to access other data and services that are made available via API.

There are three main exchanges in what I like to call the OAuth dance:

This tutorial is focused on how to manage the first two exchanges.

These exchanges are dependent on public key cryptography. So before we can begin, we need to be issued a public key and a secret. To do this you need to register your application with the provider that you’re hoping to connect with. In this example, I’ll register with Google as the provider.

Register your application

To register a Google application:

To register an app with Twitter, Facebook, etc … follow a similar process.

Once you have your credentials, download them and be sure that they’re not added to version control. I use a .env file (that’s listed in my .gitignore) to store these environment variables.

# .env
GSUITE_CLIENT_ID=XYZ.apps.googleusercontent.com
GSUITE_CLIENT_SECRET=XYZ

Find a gem that implements the OmniAuth strategy

Most large OAuth providers have well-maintained OmniAuth strategies that you can you use. For Google, I chose omniauth-google-oauth2. If, for some reason, a publicly available strategy doesn’t exist yet, they’re relatively straightforward to write on your own.

Once you’ve found a gem that implements the strategy for your OAuth provider, add it to your Gemfile and run bundle install.

A key benefit of using OmniAuth is that you will be provided with a normalized data for each strategy. This means that most of the code you need to write can be reused with a few exceptions (e.g. Twitter does not necessarily provide the user’s email address).

Registering your provider with Devise

Once you have your OmniAuth strategy installed, you need to register it with Devise.

# config/initializers/devise.rb
Devise.setup do |config|
  ... 
  config.omniauth :google_oauth2, 
    ENV['GSUITE_CLIENT_ID'], ENV['GSUITE_CLIENT_SECRET'], {
    scope: "userinfo.email, userinfo.profile",
    prompt: 'select_account', name: "google"
  }
  ...
end

By default, this strategy will be called google-oauth2. Cosmetically, you may want to add the :name option so you can refer to the strategy simply as google.

Registering your provider with the user model

A limitation of Devise is that only one resource can be used for OAuth at a time. In general, this shouldn’t be a problem. In this tutorial, I’ll assume that you’re only using a user model.

# app/models/user.rb
class User < ApplicationRecord
  ....
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :lockable, :confirmable,
         :omniauthable, omniauth_providers: [:google],
         # because this user model also has a username column
         authentication_keys: [:login] 
  ...
end

As you add more strategies, you can add more providers to the omniauth_providers array.

Creating a model to store the information provided by the OAuth providers

I decided to call this the authorizations table, but I’ve seen other people call this the identities table. Naming is hard (h/t Phil Karlton). Pick the name that makes sense to you.

This model will store the relevant information to check whether an OAuth user has authenticated before and to provide you with the necessary information to access that API in the future. Be sure not to store your API keys in your code repository. And if you opt to store access_tokens in your database, please be sure to encrypt them before persisting them because if an adversary gains access to the access_tokens they can access your user’s Google account as though they are your Rails application!

To encrypt sensitive fields in your database so that you can retrieve them later you can make use of ActiveSupport::MessageEncryptor as described here OR you can use attr_encrypted. I would be hesitant to rely on attr_encrypted since it hasn’t been updated recently. For simplicity, I’m going to assume that these integrations are only for user authentication, so I will only be concerned with storing the provider and uid which you’ll use to associate a user with an OAuth provider.

  create_table "authorizations", force: :cascade do |t|
    t.integer "user_id"
    t.string "provider"
    t.string "uid"
    t.string "email"
    # t.string "encrypted_token"
    # t.string "encrypted_secret"
    # t.string "encrypted_refresh_token" 
    # t.boolean "expires"
    # t.datetime "expires_at"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["provider", "uid"], name: "index_authorizations_on_provider_and_uid"
    t.index ["provider"], name: "index_authorizations_on_provider"
    t.index ["uid"], name: "index_authorizations_on_uid"
    t.index ["user_id"], name: "index_authorizations_on_user_id"
  end

Create an Authorization model that will store this information and ensure that a Google account is only associated with one account in your application and connect it to your User model.

# app/models/authorization.rb
class Authorization < ApplicationRecord
  # include Encryptable
  # attr_encrypted :token, :secret, :refresh_token

  belongs_to :user, optional: true
  validates_uniqueness_of :uid, scope: [:provider]
end
# app/models/user.rb
class User < ApplicationRecord
  ...
  has_many :authorizations, dependent: :destroy
  ...
end

Adding methods to the user model to use create Authorizations

Now that we have a place to store authorization data, we need to build functionality to handle four use cases:

  1. New user creation
  2. Matching to an existing user (i.e. a user had previously been created with that email)
  3. Asking for additional information (i.e. a user record couldn’t fully be created because the user needs to choose a username)
  4. Returning users that have already granted access via an OAuth provider

To do this, we’ll add a couple class methods to the User model and a convenience method for building new Authorizations. These will be used when receiving in the OmniAuth callback.

# app/models/user.rb
class User < ApplicationRecord
  ...
  def self.from_omniauth(auth)
    # find an existing user or create a user and authorizations
    # schema of auth https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema
    
    # returning users
    authorization = Authorization.find_by(provider: auth.provider, uid: auth.uid)
    if authorization
      return authorization.user
    end

    email = auth['info']['email']

    # match existing users
    existing_user = find_for_database_authentication(email: email.downcase)
    if existing_user
      existing_user.add_oauth_authorization(auth).save
      return existing_user
    end

    create_new_user_from_oauth(auth, email)
  end

  # Maintaining state if a user was not able to be saved
  def self.new_with_session(params, session)
    super.tap do |user|
      if data = session["devise.oauth.data"]
        user.email = data['info']['email'] if user.email.blank?
        user.add_oauth_authorization(data)
      end
    end
  end

  def add_oauth_authorization(data)
    authorizations.build({
      provider: data['provider'],
      uid: data['uid'],
      # token: data['credentials']['token'],
      # secret: data['credentials']['secret'],
      # refresh_token: data['credentials']['refresh_token'],
      # expires: data['credentials']['expires'],
      # expires_at: (Time.at(data['credentials']['expires_at']) rescue nil),
      # Human readable label if a user connects multiple Google accounts
      email: data['info']['email']
    })
  end

  private

  def self.create_new_user_from_oauth(auth, email)
    user = User.new({
      email: email,
      username: email.split('@').first.gsub('.', ''),
      password: Devise.friendly_token[0,20]
    })
    if %w(google).include?(auth.provider)
      user.skip_confirmation!
    end
    user.add_oauth_authorization(auth)
    user.save
    user
  end
  ...
end

Creating a controller to handle the OAuth callbacks

So far, we’ve provided devise with the details it needs to redirect a user to the OAuth provider to request an authorization grant. Now, we need to handle the second phase of the OAuth dance when we exchange the authorization grant for an access_token.

To do this, we need to add an omniauth_callbacks_controller.rb. This will respond to /auth/:provider/callback. In our case, that will be /auth/google/callback.

# config/routes.rb
Rails.application.routes.draw do
  ...
  devise_for :users, controllers: {
    omniauth_callbacks: 'users/omniauth_callbacks'
  }
  ...
end

The routes for the particular providers are set up when we added the array of providers to the User model. Next, let’s work on the callbacks controller:

# app/controllers/users/omniauth_callbacks_controller.rb

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def google
    # A class method we need to define
    @user = User.from_omniauth(auth_data)

    if @user.persisted?
      flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: 'Google'
      sign_in_and_redirect @user, event: :authentication
    else
      # We couldn't save the user for some reason (i.e. need to add a username)
      # Removing extra as it can overflow some session stores
      data = auth_data.except('extra') 
      # So data will be available after this request when creating the user
      session['devise.oauth.data'] = data
      msg = @user.errors.full_messages.join("\n")
      redirect_to new_user_registration_url, alert: msg
    end
  end
  
  private

  def auth_data
    request.env['omniauth.auth']
  end
end

With the basic routing in place, we need to ensure that our application is not vulnerable to CVE-2015-9284 that’s discussed in this pull request. Here are the remediation instructions recommended in the OmniAuth wiki.

<%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}",
  omniauth_authorize_path(resource_name, provider),
  method: :post %>

Conclusion

I hope this tutorial helps you get started with how to add multiple OmniAuth providers to your Rails apps. I’m looking forward to seeing the cool applications you build.

Category Tutorial