This past week at Turing we spent some time hand rolling third party authentication as well as implementing third party sign in using omniauth gems in our various Rails Apps. There are an impressive number of step by step tutorials for oauth with Google, but none that I found start with a test. To fill that void, I present to you all a guide to implementing Google Sign-in for your Rails App that starts with a test. Pour yourself some coffee, put on some headphones, and enjoy following the next twenty steps to test drive your Google OAuth2 implementation.

Step 1: Create a New Rails App

rails new google-omniauth-demo -d postgresql --skip-turbolinks --skip-spring -T

Step 2: Add Gems

Available in all environments:

gem 'omniauth-google-oauth2'
gem 'figaro'

Available in group test:

gem 'rspec-rails'
gem 'capybara'

Step 3: Install gems

Omniauth uses a single gem for each third-party authentication we want to add to our application. So, we have to include the Google oauth2 gem, but this guide should hold similarly true for sign in using Twitter or GitHub (I know because I've done it) and I'm guessing any other service (Facebook, etc).

Figaro is a gem that allows us to automatically hide our API keys in an application.yml file so that if we push this app to GitHub, we don't have to worry about accidentally exposing our keys to the public.

RSpec is a popular testing framework for Ruby on Rails and Capybara is a library that allows our testing suite to simulate a user clicking through our site and is crucial for the feature testing methodology described below.

From terminal run:

bundle

And then:

rails g rspec:install

And finally:

bundle exec figaro install

STEP 4: Write a Feature Test

A feature test is one that describes a user story. In this case our story is that When a user visits our website, and clicks "Sign in with Google", and enters their Google account credentials, and clicks  "authorize", they are signed in and will see a page that says, "Welcome << name >>" and that contains a link "Logout." Starting first with the user story when developing a feature helps a developer stay focused as they go on to implement. Writing a test reinforces this focus.

First make a features directory and then a new spec file:

mkdir spec/features
touch spec/features/user_logs_in_with_google_spec.rb

Next, let's start writing our feature test in the spec/features/user_logs_in_with_google_spec.rb file:

require 'rails_helper'

RSpec.feature "user logs in" do
  scenario "using google oauth2" do
    
  end
end

We will create a method called 'stub_omniauth' that will be used to fake our login for our test.

require 'rails_helper'

RSpec.feature "user logs in" do
  scenario "using google oauth2" do
    stub_omniauth
  end
end

Creating stub_omniauth involves writing a hash to simulate the hash returned by Google's oauth request response. Unless you have done this before, it's basically impossible to know what this hash should look like without looking at an actual hash from Google or the docs assocaited with your authorization gem. Also, each third party authentication service sends information back in a slightly different form, so the stub_omniauth method will look different depending on what provider you are using.

def stub_omniauth
  # first, set OmniAuth to run in test mode
  OmniAuth.config.test_mode = true
  # then, provide a set of fake oauth data that
  # omniauth will use when a user tries to authenticate:
  OmniAuth.config.mock_auth[:google] = OmniAuth::AuthHash.new({

  })
end

We can find the structure of the Omniauth hash by looking at the documentation. But, who likes reading the docs (I'd much prefer a 20 step tutorial). Let's continue writing the test and then add the Hash later on once we are in a place to actually inspect what Google's Omniauth returns to us.

RSpec.feature "user logs in" do
  scenario "using google oauth2" do
    stub_omniauth
    visit root_path
    expect(page).to have_link("Sign in with Google")
    click_link "Sign in with Google"
    expect(page).to have_content("Jesse Spevack")
    expect(page).to have_link("Logout")
  end

Going back to our test, we give Capybara a few instructions. The first instruction is to visit our root_path, which is the same as "/" and has not been defined in our routes file yet. We then tell RSpec to expect that the root_path will have a link that reads, "Sign in with Google". We tell Capybara to click that link. We expect the page to then contain my name, signifying that I have signed in with Google and to have a link that reads, "Logout".

Before we run the test, we'll have to run:

rake db:create

From the root of our project, we can run 'rspec' in the terminal and if everything is working we should get a failure saying that the test can't find the root_path. 

Step 5: Create a Route

Rails.application.routes.draw do
  root to: 'home#index'
end

We'll next re-run our test which should fail because we have not defined the home controller that we specified in our route.

Step 6: Create a Controller

touch app/controllers/home_controller.rb

We can run the test again and it will blow up because we haven't yet defined the controller in the file we just created.

So we can go into the app/controllers/home_controller.rb file and define our controller as follows:

class HomeController < ApplicationController

end

When we run the test now, we'll get a failure because we have not defined our index method, specified in our routes file.

class HomeController < ApplicationController
  def index
  end
end

Next we should get a failure because we do not have an index template.

Step 7: Create an index page

First we'll create a home directory under the views directory and an index file:

mkdir app/views/home
touch app/views/home/index.html.erb

Running our test should let us know that Capybara can not find the "Sign in with Google" link:

So we'll add a "Sign in with Google" link to our app/views/home/index.html.erb file:

<%= link_to "Sign in with Google", "/auth/google" %>

We haven't defined the /auth/google path yet, so that should give us a failure when we run our test:

Step 8: Add a login route

We can add the following route to handle the call back from Google once the login button is clicked by our user.

Rails.application.routes.draw do
  root                            to: 'home#index'
  get 'auth/:provider/callback',  to: 'sessions#create'
end

Step 9: Create a Sessions Controller

When we run our test after adding the new route, we will get a controller failure because we have not yet defined a sessions controller to handle the login and logout process.

To create a sessions controller type the following command into terminal:

touch app/controllers/sessions_controller.rb

Next we'll get an uninitialized constant failure which tells us that it is time to define the sessions controller in the file we just created.

To define the sessions controller we add the following to the app/controllers/sessions_controller.rb file:

class SessionsController < ApplicationController

end

When we run our test, we'll get a failure because we have not defined a create method in our controller:

To fix this we'll add a create method to our controller, which will then give us a missing template failure.

class SessionsController < ApplicationController
  def create
  end
end

Step 9: API Keys

Before we can fix this failure, we need to get our API Keys from Google. To do so, go to the developers console. Click "Create Project."

Give the new project a name and click "Create".

Under library, search for "Contacts" to enable the contacts API.

Click "Contacts API", which should be the first search result and also a link. Next click "Enable" at the top of the screen.

Next we will go back to the library and follow the same process as before for the Google Plus API.

Next click "Credentials" from the menu on the left sidebar. Click "OAuth consent screen".

Add a product name and click "Save".

Back on the credentials page, click "Create Credentials" and select "OAuth client ID". This will ask the user for consent so that the app we are building can access the user's data. 

Select "Web application" for application type and then add the following url to the Authorized redirect URIs:

http://localhost:3000/auth/google/callback

After clicking "Create", a box will pop up with your client id and client secret. Copy these keys to a text file outside of the project directory.

Step 10: Figaro

With our keys from the previous step, we will configure two environment variables using Figaro. When we ran "bundle exec figaro install", the Figaro gem created an application.yml file and added this file to our .gitignore file. This will ensure that the information in the application.yml does not make its way into a public repository on github. Add the following two lines to the config/application.yml.

GOOGLE_CLIENT_ID: << MY-CLIENT-ID >>
GOOGLE_CLIENT_SECRET: << MY-CLIENT-SECRET >>

Replace << MY-CLIENT-ID >> and << MY-CLIENT-SECRET >> with the keys you just got from Google. No "<<" or ">>" should be included. No quotes are needed either. This allows us to call the following variables throughout our code:

ENV["GOOGLE_CLIENT_ID"]
ENV["GOOGLE_CLIENT_SECRET"]

Step 11: Omniauth Initializer

Next we need to create an omniauth initializer. Type the following command into terminal:

touch config/initializers/omniauth.rb

In the config/initializers/omnauth.rb file add the following code:

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :google_oauth2, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_CLIENT_SECRET"], {
    :name => "google",
    :scope => ['contacts','plus.login','plus.me','email','profile'],
    :prompt => "select_account",
    :image_aspect_ratio => "square",
    :image_size => 50,
    :access_type => 'offline'
  }
end

Notice that we reference the environment variables for our client id and client secret we created with Figaro in the previous step. We also pass in a hash with several key value pairs. The name is set to Google and I believe this is useful in case we want to add additional omniauth strategies to this app. For example, we may want our users to be able to login with their Twitter accounts. Scope is an argument that defines the permissions we are asking the user to grant this app. Prompt keyed to "select_account" means that the user will always be prompted to select an account. We are also defining the shape and size of the profile image of the user that our app will have access to. Finally, access type I beleive means that Google will send our app a refresh token that we can use when our user wants to login again or if the actual auth token expires. For more info about the hash options available in omniauth, checkout the docs.

Step 12: Make a fake login

When we run our test we should get an undefined template failure.

Now if we actually run our server by typing the following into terminal from our project root directory:

rails s

 and visit local host by typing the following into our browser:

localhost:3000

Click on the link, "Sign in with Google" and you should see a Google screen asking us to choose an account. Once we choose an account and provide the credentials, we should get an error like this:

Now lets think back to the stub_omniauth method that we created in our original feature test. We left the return hash blank and at this point we can try to fill it in. There are two ways to do this. First we can look at the omniauth documentation, which contains a sample hash output. We can also look in our own terminal for this info. Add a byebug into the create method in the app/controllers/sessions_controller.rb:

class SessionsController < ApplicationController
  def create
    byebug
  end
end

When we run "rails s" from terminal, and go through the login process just like before, instead of getting the error screen our execution will pause at the byebug we've just inserted. Take a look at the info in terminal.

We have access to all the variables in the create method in our terminal. Lets call the following:

env["omniauth.auth"]

Which should give us something like:

I like to look at the keys here to try to more fully understand what I am working with:

env["omniauth.auth"].keys

Should give us:

From here, we can look at each key individually to get an idea of what we are working with. Calling each key will give use the final:

env["omniauth.auth"].provider

env["omniauth.auth"].info

Calling uid, credentials, and extras works the same and should output their respective values, which I will not provide screenshots of because I'm not entirely clear on the security privacy repercussions of posting that data publicly so I'll err on the side of not posting that.

To make a fake login we will return to the stub_omniauth method. By investigating the env["omniauth.auth"] hash we can create a fake one for our tests that includes the information that we will want to store in our database.

def stub_omniauth
    # first, set OmniAuth to run in test mode
    OmniAuth.config.test_mode = true
    # then, provide a set of fake oauth data that
    # omniauth will use when a user tries to authenticate:
    OmniAuth.config.mock_auth[:google] = OmniAuth::AuthHash.new({
      provider: "google",
      uid: "12345678910",
      info: {
        email: "jesse@mountainmantechnologies.com",
        first_name: "Jesse",
        last_name: "Spevack"
      },
      credentials: {
        token: "abcdefg12345",
        refresh_token: "12345abcdefg",
        expires_at: DateTime.now,
      }
    })
  end

Let's remove byebug from our controller and run rspec to see that we are getting the same failure:

Step 13: Create a User Model

At this point in our code, a user should be able to click through the entire Google authentication process, but we get a Rails error because we have not yet written any instructions in our create method. Thinking a few steps ahead, when a user signs in with their Google account we are going to want to either save the data Google sends as a new user in our own database or update that information if the user already exists. To do all this we are going to need a user model, s let's create one by typing the following command into terminal:

rails g model user provider uid first_name last_name email token refresh_token oauth_expires_at:datetime

In the above command we are creating a user model with text attributes for storing uid (user id from Google), first name, last name, email, token, refresh token, and the expiration time for the token. While out of the scope of this guide, eventually we would need the expiration time for the token so we know if / when we need to get a new token from Google.

Run the database migration from the command line.

rake db:migrate

Step 14: Write a User Model Test

Next we'll jump into the spec/models/user_spec.rb to write our user unit test. We need to create a test that will describe how our app will update a user object with a given OAuth hash.

require 'rails_helper'

RSpec.describe User, type: :model do
  it "creates or updates itself from an oauth hash" do
    auth = {
      provider: "google",
      uid: "12345678910",
      info: {
        email: "jesse@mountainmantechnologies.com",
        first_name: "Jesse",
        last_name: "Spevack"
      },
      credentials: {
        token: "abcdefg12345",
        refresh_token: "12345abcdefg",
        expires_at: DateTime.now
      }
    }
    User.update_or_create(auth)
    new_user = User.first

    expect(new_user.provider).to eq("google")
    expect(new_user.uid).to eq("12345678910")
    expect(new_user.email).to eq("jesse@mountainmantechnologies.com")
    expect(new_user.first_name).to eq("Jesse")
    expect(new_user.last_name).to eq("Spevack")
    expect(new_user.token).to eq("abcdefg12345")
    expect(new_user.refresh_token).to eq("12345abcdefg")
    expect(new_user.oauth_expires_at).to eq(auth[:credentials][:expires_at])

  end
end

To begin the test, we'll first define an auth token that contains the values we expect to see from our Google Omniauth callback. Next we will call the class method "update_or_create", which has not been defined yet. We imagine that when we pass the "update_or_create" method our auth hash, it will return a newly created user with attributes that match those provided in the auth hash.  When we run this test we should get a no method failure because we have not yet defined "update_or_create" in the user model.

Step 15: Add an Update_or_create method

Update_or_create will be a class method because it is not a method that will be called on individual user. Rather we will use the method to find or create a user from the entire User class. Think about how we would typically create a user:

User.create(username: "PlanetEfficacy")

Since the User model inherits from ActiveRecord::Base, it includes the method create. Just like the ActiveRecord create method is called on a class (User in the above example), our update_or_create method will also be called on the User class. We need to first decide whether or not the auth hash is referring to a pre-existing user in our database.

def self.update_or_create(auth)
  user = User.find_by(uid: auth[:uid]) || User.new

If the User.find_by call returns a user, then we'll use that as our user moving forward. Otherwise, create a new user.

Next we will set or reset all of the attributes of our newly found or instantiated user. We set or reset every attribute because we want to save updates on to the Google account in our database. For example, if a person changes their last name on their Google account (I'm guessing this is possible) we would want to pull in that updated information into our app's database as well.

user.attributes = {
    provider: auth[:provider],
    uid: auth[:uid],
    email: auth[:info][:email],
    first_name: auth[:info][:first_name],
    last_name: auth[:info][:last_name],
    token: auth[:credentials][:token],
    refresh_token: auth[:credentials][:refresh_token],
    oauth_expires_at: auth[:credentials][:expires_at]
  }

Lastly we'll call the ActiveRecord method save to save the changes to the user to our database and then we return the user.

user.save!
user

All together we get:

# app/model/user.rb
class User < ApplicationRecord
  def self.update_or_create(auth)
    user = User.find_by(uid: auth[:uid]) || User.new
    user.attributes = {
      provider: auth[:provider],
      uid: auth[:uid],
      email: auth[:info][:email],
      first_name: auth[:info][:first_name],
      last_name: auth[:info][:last_name],
      token: auth[:credentials][:token],
      refresh_token: auth[:credentials][:refresh_token],
      oauth_expires_at: auth[:credentials][:expires_at]
    }
    user.save!
    user
  end
end

And when we run our test, we are back down to one failure - our feature test that we started with.

Step 16: add Update_or_create method to Sessions Controller

Next, we'll go back to our SessionsController. The above test says that there is a missing template, which makes sense because currently our SessionsController's create method is blank. Now we will use our new update_or_create method to take the auhentication hash passed into the SessionController's create method to find or create the user and store that user in the session, which as I understand it is an encrypted cookie.

First we update or create a user. Next, we store that user's id in our session. Finally we will redirect back to the root_path, our home page.

class SessionsController < ApplicationController
  def create
    user = User.update_or_create(env["omniauth.auth"])
    session[:id] = user.id
    redirect_to root_path
  end
end

And when we run the test... we are still failing. But it is a new kind of failure, which is the best kind of failure. Our failure says that we are missing the content "Jesse Spevack" we were expecting to see in our view.

Step 17: CREATE A CURRENT_USER

Before we jump into our view and correct this problem, it's best practice to create a current user helper method. To do so, we'll jump into our app/controller/application_controller.rb file and quickly add:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  helper_method :current_user

  def current_user
    @current_user ||= User.find(session[:id]) if session[:id]
  end
end

The above code creates an instance variable called current_user if the session[:id] exists. The instance variable @current_user will be accessible in our views because we say that it is a helper method.

Step 18: Update our View

Let's now open our app/views/home/index.html.erb file. We'll need to display the link to "Sign in with Google" if there isn't a logged in user. If there is a logged in user we will want to display the user's name and a link to logout.

<% if current_user  %>
  <h3>Welcome, <%= "#{current_user.first_name} #{current_user.last_name}" %></h3>
<% else %>
  <%= link_to "Sign in with Google", "/auth/google" %>
<% end %>

Now let's run our test:

We are so close! Let's add a logout button to our view:

<% if current_user  %>
  <h3>Welcome, <%= "#{current_user.first_name} #{current_user.last_name}" %></h3>
  <%= link_to "Logout", logout_path %>
<% else %>
  <%= link_to "Sign in with Google", "/auth/google" %>
<% end %>

And run our test again. Now we get a failure that the logout_path does not exist.

Step 19: Add A Logout Route

Let's go into our routes.rb file and add:

Rails.application.routes.draw do
  root                            to: 'home#index'
  get 'auth/:provider/callback',  to: 'sessions#create'
  get 'logout',                   to: 'sessions#destroy'
end

Next, lets jump into the sessions controller and add a destroy method so users can logout.

class SessionsController < ApplicationController
  def create
    user = User.update_or_create(env["omniauth.auth"])
    session[:id] = user.id
    redirect_to root_path
  end

  def destroy
    session.clear
    redirect_to root_path
  end
end

And when we re-run our test, it will pass!

Screen Shot 2016-10-19 at 8.46.03 AM.png

When we spin up our rails server and visit localhost:3000, we should get something like:

Step 20: Sanity Check

I like to go through the user story once my test is passing as a sanity check. So let's see if our app can actually complete the login process through Google.

I'll start by clicking the "Sign in with Google" link, which will take me to:

Things are looking good. Next I'll go through the login process with my Google account.

I will have to authorize the app.

After clicking allow, I will be returned to our app and be greeted with a warm welcome.

And when I click "Logout", I get:

Concluding Thoughts

My intention with this post is to lay out a test driven path to implement Google authentication in your Rails app. I confess I did not expect to write out a 20 step tutorial, but that's where we ended up. I know that two weeks ago if I had come across this post, I would have found it helpful. You can find the entire app on my GitHub Repository.