Clean OAuth for Rails: An Object-Oriented Approach
Note:This tutorial is intended for programmers who have implemented OAuth for Rails before and are looking for a better way to do so. It is not a beginner's tutorial. It assumes the reader already has knowledge of the OAuth gems and how to integrate them into a Rails app.
While Ruby's OAuth gems go a long way towards making OAuth implementation with Rails a pain-free process, the standard method of implementation (see here and here) rapidly breaks down when used in a full-scale production app:
It uses one model, User, to combine both OAuth logic and validations with "Standard User" (i.e, a non-OAuth user, created through a standard sign up form) logic and validations. It doesn't take long for clashes to begin. For example, your Standard User must enter an email address and password, but Twitter OAuth provides neither, which causes those validations to fail. The only way out is with messy conditionals.
Logging in with an OAuth account for the first time automatically creates a new user record, providing no way for a Standard User to later integrate an OAuth account into an existing user account.
It provides no easy way to link multiple OAuth accounts (Facebook and Twitter and LinkedIn and Github) with a single user.
The OAuth gems try to standardize the hash of data returned by the various platforms (Facebook, Twitter etc), but they are still not exactly the same. This means that separate, long, almost-identical-but-not-exactly-identical methods need to be added to your user model to deal with each type of OAuth platform.
There is no clean way to trigger different types of callbacks without lots of nasty conditionals. For example, when a Foursquare user logs in, perhaps you want to refresh his Foursquare checkins data, but when a Facebook user logs in, you want to retrieve his latest list of friends.
Taking an Object-Oriented Approach
Most of these problems are caused by us squishing both OAuth users and Standard Users into a single User model even though their validations and requirements are quite different.
We are going to solve this by adding some Plain Old Ruby Objects.
Here's the general idea (we will go through the code shortly):
Standard User-specific logic and OAuth User-specific logic will be moved from the User model into RegularUser and OAuthUser models respectively. These will not be regular models. They will not be table-backed and won't inherit from ActiveRecord::Base. In many ways, they will be more similar to service objects than to models, and you may be more comfortable putting them in a services directory than in the models directory.
And here is the crucial bit: They will only be used in the User creation phase. They will act as intermediary layers between the core User model and the visitor using the website to create his User object.
The result is that the site visitor, creating or editing his account, always interacts via the RegularUser or OAuthUser class and its specific validations and logic. But once the user has been created, the app itself (e.g., the current_user helper) then interacts with the core User model directly, thereby bypassing any issues with RegularUser-specific or OAuthUser-specific validation.
Nice and clean.
Bring Out The Models!
Let's start modeling this out. The first model to create is an Account model. Each Account will represent a single OAuth account (Facebook, Twitter etc) and will belong to a User, so that a User can have multiple OAuth accounts.
rails g model Account user:references uid provider username oauth_token oauth_secret oauth_expires:datetime
app/models/account.rb
class Account < ActiveRecord::Base
# Account records contain OAuth data from third parties: Facebook, Twitter, Foursquare, and so on.
# Associations
belongs_to :user
# Attributes
attr_accessible :user_id, :oauth_expires, :oauth_token, :provider, :uid, :username, :oauth_secret
end
Our user model will look pretty standard, except that it will contain no validations. It won't even have has_secure_password.
rails g model User first_name last_name email password_digest
app/models/user.rb
class User < ActiveRecord::Base
# Associations
has_many :accounts, :dependent => :destroy
# Attributes
attr_accessible :email, :first_name, :last_name, :password, :password_confirmation
# Instance Methods
def has_facebook?
accounts.where(provider: 'facebook').any?
end
def has_twitter?
accounts.where(provider: 'twitter').any?
end
def has_foursquare?
accounts.where(provider: 'foursquare').any?
end
end
So how do we deal with Standard Users - those users that sign up using a regular form rather than OAuth? We create a RegularUser model that inherits from User, and layers Standard User-specific logic on top.
app/models/regular_user.rb
class RegularUser < User
has_secure_password
validates :first_name, :last_name, :email, presence: true
validates :email, email: true
validates :email, uniqueness: { case_sensitive: false }
end
Now we update the Users Controller to use RegularUser. Remember that users signing up via OAuth will be redirected by the gems and your routes to the Sessions Controller. The only users using the User Controller are Standard Users. So we update the User Controller to use our new RegularUser object, thereby ensuring that all Standard Users will be required to pass standard validations, such as a valid password.
app/controllers/user_controller.rb
class UsersController < ApplicationController
before_filter :authenticate_user!, only: [:edit, :update]
def new
@user = RegularUser.new
end
def create
@user = RegularUser.new(params[:regular_user])
if @user.save
session[:user_id] = @user.id
redirect_to root_path
else
render action: 'new'
end
end
def show
@user = current_user
end
# etc etc.
end
The very small catch here is that our user form, which is now interacting with a RegularUser object, tries submitting to /regular_users. Explicitly defining the submit URL in the form corrects this.
app/views/users/_form.html.haml
= simple_form_for @user, :url => (@user.new_record? ? users_path : user_path(@user)) do |f|
= f.input :first_name
= f.input :last_name
= f.input :email
= f.input :password
= f.input :password_confirmation
= f.button :submit
Perfect. Our Standard Users can sign up using a form which will force them to adhere to Standard User validations, without those validations bleeding over into our OAuth users and creating trouble there.
To reiterate, the entire point is that the RegularUser model acts as a shield between our visitor and the app. The app itself doesn't use RegularUser ever. It is able to use User directly and never worry about validations clashing or failing (for example, a password validation failing for an OAuth User that has none). For example, the app's standard current_user helper method will use User directly:
app/controllers/application_controller.rb
class ApplicationController
def current_user
@current_user ||= User.find_by_id(session[:user_id])
end
end
Now that our Standard Users are taken care of, let's move on to OAuth.
Plugging In OAuth
I want to create a model/object to deal with the creation and login of OAuth users specifically, which will have the ability to deal with every use-case. I want the Sessions Controller, which the OAuth gems redirect to, to simply pass the OAuth data and the current_user into this OAuth model, and let the OAuth model deal with every possible case on its own:
- If a user is not logged in, create a new user account and log the user in.
- If a user is already logged in, simply add this OAuth account to his existing User account, so that one user can have many different OAuth accounts.
- If a user is not logged in, but this OAuth account has been used before, find the corresponding user and log him in.
This OAuth model will be named OAuthUser (in contrast to RegularUser).
app/models/o_auth_user.rb
class OAuthUser
attr_reader :provider, :user
def initialize creds, user = nil
@auth = creds
@user = user
@provider = @auth.provider
end
end
But before we proceed beyond this small start, we have a problem to tackle.
If you've integrated multiple OAuth gems into a single app before, you'd have noticed that the hash of user data returned by the gems varies from gem to gem. Twitter returns no email address, but Facebook does. Twitter returns a user's entire name ("John Doe") whereas Facebook splits a user's first name from his last name. And so on.
You've probably dealt with this in the past by creating separate methods in your User model for each type of OAuth platform, a solution which rapidly turns the User model into an ugly mess. So how can we deal with the differences between the OAuth gems cleanly and simply?
Policy Objects to the Rescue
There are multiple ways of defining what a policy object is and how it differs from a service object, but the general idea is that a policy object only contains business rules to inform other objects. In other words, a policy object generally does no work on its own; it simply gives the other objects that do the work the information they need to get their job done.
Our OAuth problem here is an ideal use-case for policy objects.
We will make a separate policy object for each platform - one for Facebook, one for Twitter, and so on. These objects will be small and simple, and each will contain the exact same method names: first_name, last_name, email etc. We simply pass the policy object the complete hash we get from the OAuth gem, and let the policy object define the values. To get started, add a folder named policies under the app directory, restart your Rails app, and add the objects. Here are what my Facebook, Twitter and LinkedIn Policy objects look like.
app/policies/facebook_policy.rb
class FacebookPolicy
def initialize auth
@auth = auth
end
def first_name
@auth.info.first_name
end
def last_name
@auth.info.last_name
end
def email
@auth.info.email
end
def username
@auth.info.nickname
end
def image_url
"http://graph.facebook.com/#{auth.info.nickname}/picture?type=large"
end
def uid
@auth.uid
end
def oauth_token
@auth.credentials.token
end
def oauth_expires
Time.at(@auth.credentials.expires_at)
end
def oauth_secret
nil
end
def create_callback account
# Place any methods you want to trigger on Facebook OAuth creation here.
end
def refresh_callback account
# Place any methods you want to trigger on subsequent Facebook OAuth logins here.
end
end
app/policies/twitter_policy.rb
class TwitterPolicy
def initialize auth
@auth = auth
end
def first_name
split_name.first
end
def last_name
split_name.last
end
def email
nil
end
def username
@auth.info.nickname
end
def image_url
"https://api.twitter.com/1/users/profile_image?screen_name=#{@auth.info.nickname}&size=original"
end
def uid
@auth.uid
end
def oauth_token
@auth.credentials.token
end
def oauth_expires
nil
end
def oauth_secret
@auth.credentials.secret
end
def create_callback account
# Place any methods you want to trigger on Twitter OAuth creation here.
end
def refresh_callback account
# Place any methods you want to trigger on Twitter OAuth creation here.
end
private
def split_name
name = @auth.info.name
if name.include?(" ")
last_name = name.split(" ").last
first_name = name.split(" ")[0...-1].join(" ")
else
first_name = name
last_name = nil
end
[first_name, last_name]
end
end
app/policies/linkedin_policy.rb
class LinkedinPolicy
def initialize auth
@auth = auth
end
def first_name
@auth.info.first_name
end
def last_name
@auth.info.last_name
end
def email
@auth.info.email
end
def username
@auth.info.urls.public_profile
end
def image_url
@auth.info.image
end
def uid
@auth.uid
end
def oauth_token
@auth.credentials.token
end
def oauth_expires
nil
end
def oauth_secret
@auth.credentials.secret
end
def create_callback account
# Place any methods you want to trigger on LinkedIn OAuth creation here.
end
def refresh_callback account
# Place any methods you want to trigger on LinkedIn OAuth creation here.
end
end
The idea is that each of these objects follow the same pattern. They each have identical method names with the rules of how to obtain each piece of information. FacebookPolicy's email method returns the email from the data hash, whereas TwitterPolicy's email method returns nil because Twitter does not give that information.
So how do these policy objects interact with our app? Time to return to the OAuthUser model.
The OAuthUser Model
So coming back to the initialize method in our OAuthUser model, we can now grab the correct policy object, and assign it the the @policy instance variable.
app/models/o_auth_user.rb
class OAuthUser
attr_reader :provider, :user
def initialize creds, user = nil
@auth = creds
@user = user
@provider = @auth.provider
@policy = "#{@provider}_policy".classify.constantize.new(@auth)
end
end
We can now call methods on @policy, such as @policy.first_name, confident that the policy object will return the correct information.
Let's flesh out the rest of the OAuthUser model. We'll create a method called login_or_create
which will automatically deal with all the possible scenarios.
If a user is logged in to our app when he uses OAuth, the logged in user will be passed by the Sessions Controller into this OAuthUser model and will be assigned to the @user variable. So if we do have a user assigned to the @user variable, it's because there is a user currently logged in. In that scenario, we simply create a new Account record (remember, Account, which we dealt with at the very beginning of this article, is the resource that stores OAuth login data for users) and associate it with the logged in user.
If there is no user logged in, we first use the OAuth data given by the gem to check the Accounts table and see if this user has logged in to our app before. If he has, we log him in again. If not, we create a new User account.
app/models/o_auth_user.rb
class OAuthUser
attr_reader :provider, :user
def initialize creds, user = nil
@auth = creds
@user = user
@provider = @auth.provider
@policy = "#{@provider}_policy".classify.constantize.new(@auth)
end
def login_or_create
logged_in? ? create_new_account : (login || create_new_account)
end
def logged_in?
@user.present?
end
end
Let's write the methods for logging in a user. We search the Account table to see if this user logging in with OAuth now has logged in before. If he has, we find the Account record and refresh the OAuth tokens data stored in the Account table.
class OAuthUser
# truncated
def login
@account = Account.where(@auth.slice("provider", "uid")).first
if @account.present?
refresh_tokens
@user = @account.user
@policy.refresh_callback(@account)
else
false
end
end
def refresh_tokens
@account.update_attributes(
:oauth_token => @policy.oauth_token,
:oauth_expires => @policy.oauth_expires,
:oauth_secret => @policy.oauth_secret
)
end
# truncated
end
If we cannot find any user, we create one.
class OAuthUser
# truncated
def create_new_account
create_new_user if @user.nil?
unless account_already_exists?
@account = @user.accounts.create!(
:provider => @provider,
:uid => @policy.uid,
:oauth_token => @policy.oauth_token,
:oauth_expires => @policy.oauth_expires,
:oauth_secret => @policy.oauth_secret,
:username => @policy.username
)
@policy.create_callback(@account)
end
end
def account_already_exists?
@user.accounts.exists?(provider: @provider, uid: @policy.uid)
end
def create_new_user
@user = User.create!(
:first_name => @policy.first_name,
:last_name => @policy.last_name,
:email => @policy.email,
:picture => image
)
end
def image
image = open(URI.parse(@policy.image_url), :ssl_verify_mode => OpenSSL::SSL::VERIFY_NONE)
def image.original_filename; base_uri.path.split('/').last; end
image
end
# truncated
end
Note that we call @policy.create_callback(@account)
after creating an account and @policy.refresh_callback(@account)
after logging in. This allows you to call methods for specific platforms. If you wanted to do something only when a Facebook OAuth user logs in, such as get his latest list of Facebook friends, you could put that code in the FacebookPolicy object's refresh_callback
method and it would be triggered for Facebook users only.
The complete OAuthUser model looks like this:
app/models/o_auth_user.rb
class OAuthUser
attr_reader :provider, :user
def initialize creds, user = nil
@auth = creds
@user = user
@provider = @auth.provider
@policy = "#{@provider}_policy".classify.constantize.new(@auth)
end
def login_or_create
logged_in? ? create_new_account : (login || create_new_account)
end
def logged_in?
@user.present?
end
private
def login
@account = Account.where(@auth.slice("provider", "uid")).first
if @account.present?
refresh_tokens
@user = @account.user
@policy.refresh_callback(@account)
else
false
end
end
def account_already_exists?
@user.accounts.exists?(provider: @provider, uid: @policy.uid)
end
def create_new_account
create_new_user if @user.nil?
unless account_already_exists?
@account = @user.accounts.create!(
:provider => @provider,
:uid => @policy.uid,
:oauth_token => @policy.oauth_token,
:oauth_expires => @policy.oauth_expires,
:oauth_secret => @policy.oauth_secret,
:username => @policy.username
)
@policy.create_callback(@account)
end
end
def create_new_user
@user = User.create!(
:first_name => @policy.first_name,
:last_name => @policy.last_name,
:email => @policy.email,
:picture => image
)
end
def image
image = open(URI.parse(@policy.image_url), :ssl_verify_mode => OpenSSL::SSL::VERIFY_NONE)
def image.original_filename; base_uri.path.split('/').last; end
image
end
def refresh_tokens
@account.update_attributes(
:oauth_token => @policy.oauth_token,
:oauth_expires => @policy.oauth_expires,
:oauth_secret => @policy.oauth_secret
)
end
end
Last, let's update our Sessions Controller to use the new OAuthUser model.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
if request.env["omniauth.auth"].present?
oauth = OAuthUser.new(request.env["omniauth.auth"], current_user)
oauth.login_or_create
session[:user_id] = oauth.user.id
redirect_to root_path
else
user = RegularUser.find_by_email(params[:session][:email])
if user && user.authenticate(params[:session][:password])
session[:user_id] = user.id
redirect_to root_path
else
flash.now[:error] = "Invalid login credentials."
render action: 'new'
end
end
end
def destroy
session[:user_id] = nil
redirect_to root_url
end
end
Let's walk through that quickly: If request.env["omniauth.auth"]
exists, our user has just used OAuth. In that case, we pass the OAuth gem data, as well as the current_user
into the OAuthUser model. If a user is logged in, OAuthUser handles it by adding this new OAuth account to his accounts. If a user is not logged in user, current_user
equates to nil, which OAuthUser handles by finding the user and logging him in, or creating the user account if none can be found.
If request.env["omniauth.auth"]
does not exist, the user is using standard email login, in which case we use RegularUser instead to log him in.
And We're Done
Though this approach takes a long time to explain, it is really a simple and straightforward concept. We separate Standard User logic from OAuth User logic, ensuring the app gets tripped up by neither, and use Policy Objects to deal with the differences between the OAuth gems.
In doing so, we remove gobs of spaghetti code from our User model and replace it with a small set of confident and robust objects which will not break down or get in your way as you add more domain logic to your users.
The best bit is that once you code this once, you can simply drag and drop the files into any Rails app for an immediate yet robust OAuth implementation.
The code used in this tutorial is viewable on Github: https://github.com/davidlesches/clean-oauth-core
We'd love to meet you.