In the last post we created step definitions for the first part of a sign in scenario, and we got as far as adding a dead "Sign In" link to the page. Now we're ready to actually build the sign in system. We've decided that we do not want to be storing passwords in our system, instead we'll depend on 3rd party authentication providers, namely GitHub and Twitter to start.

OmniAuth is a great gem that handles most of the work of delegating authentication. The Simple OmniAuth RailsCast has a great overview of getting started with OmniAuth. We're going to use a slightly modified version of the approach in the RailsCast.

We want to allow a user to connect both their Twitter account and their GitHub account, and potentially other providers down the road. So, we'll have the standard User model that will represent an individual, and then we'll have an Aunthentication model that will represent one linked account. When a signed out user authenticates through one of the providers for the first time, we'll create an Authentication record for that provider, and also a User record to represent the individual. Then we'll prompt them to link other accounts. Each new linked account will result in a new Authentication record linked to that User.

Getting started

To follow BDD/TDD best practices, let's just start with the next step definition that we need to implement. When he signs in with GitHub.

This sounds like a simple step, but it's actually going to be comprised of a few steps. For simplicity of UI, we'll want to have a single "Sign In" link in the header of the site. When that is clicked the user should be presented with the available auth providers so that they can choose which one to use. In this case we'll assume that they choose to authenticate through GitHub.

First let's use this as the step definition

When(/^he signs in with GitHub$/) do
  click_on "Sign In"
  page.should have_content("Sign In With GitHub")
  click_on "Sign In With GitHub"
end

Now when we run Cucumber we see this:

$ cucumber features/users/signin.feature -r features/
Using the default profile...
Feature: Sign In
  As a visiting user
  I want to sign in
  Sign in will happen via GitHub (and possibly other auth providers)

  Scenario: Sucessful sign in     # features/users/signin.feature:6
    Given a signed out user       # features/step_definitions/users_steps.rb:1
    When he visits the home page  # features/step_definitions/users_steps.rb:5
    Then he should see "Sign In"  # features/step_definitions/users_steps.rb:9
    When he signs in with GitHub  # features/step_definitions/users_steps.rb:13
      expected to find text "Sign In With GitHub" in "Sign In Home#index Find me in app/views/home/index.html.erb" (RSpec::Expectations::ExpectationNotMetError)
      features/users/signin.feature:10:in `When he signs in with GitHub'
    Then he should see "Sign Out" # features/step_definitions/users_steps.rb:9

The error message shows that we're still viewing our home/index.html.erb template. That's because the "Sign In" link is still empty. We need to point that link somewhere, so let's create a SessionsController to handle prompting the user for a new session and creating the session when the auth provider returns. We'll tell the generator that we want a new action including a template.

rails g controller sessions new

Also add this line to config/routes.rb:

get "signin" => "sessions#new", :as => :signin

Then update the views/layouts/_header.html.erb template to point to the real sign in page.

<%%= link_to "Sign In", signin_path %>

Now we can add an empty "Sign In With GitHub" link to views/sessions/new.html.erb and our current step definition will pass, but we'll get an error that the "Sign Out" link has not appeared.

<%%= link_to "Sign In With GitHub", "#" %>

OmniAuth

Now we need to add OmniAuth to the Gemfile. Since we need strategies for GitHub and Twitter, we can just include those gems, and they'll include OmniAuth itself as a dependency.

gem "omniauth-github", "~> 1.1.1"
gem "omniauth-twitter", "~> 1.0.1"

Then run bundle install.

Now we can change the link to point to /auth/github and OmniAuth with take care of the rest. We'll also add a link to the built in 'developer' strategy that comes with omni-auth. This is handy for local testing so that you don't have to set up GitHub or Twitter integration yet.

<%%= link_to "Sign In With GitHub", "/auth/github" %>

<%% unless Rails.env.production? %>
  <br />
  <%%= link_to "Sign In With Developer", "/auth/developer" %>
<%% end %>

But now Cucumber will fail, saying that there's no route for /auth/github.

...
  When he signs in with GitHub  # features/step_definitions/users_steps.rb:13
      No route matches [GET] "/auth/github" (ActionController::RoutingError)
      features/users/signin.feature:10:in `When he signs in with GitHub'

This is because we haven't configured OmniAuth yet. Put this at config/initilizers/omniauth.rb

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :developer unless Rails.env.production?
  provider :twitter, ENV['TWITTER_CONSUMER_KEY'], ENV['TWITTER_CONSUMER_SECRET']
  provider :github, ENV['GITHUB_CONSUMER_KEY'], ENV['GITHUB_CONSUMER_SECRET']
end

We also need to setup OmniAuth for testing. Add this to features/support/omniauth.rb

Before('@omniauth_test') do
  OmniAuth.config.test_mode = true
 
  # the symbol passed to mock_auth is the same as the name of the provider set up in the initializer
  OmniAuth.config.mock_auth[:github] = {
      "provider"=>"github",
      "uid"=>"abc123",
      "user_info"=>{"email"=>"test@xxxx.com", "first_name"=>"Test", "last_name"=>"User", "name"=>"Test User"}
  }
  OmniAuth.config.mock_auth[:twitter] = {
      "provider"=>"twitter",
      "uid"=>"def456",
      "user_info"=>{"email"=>"test@xxxx.com", "first_name"=>"Test", "last_name"=>"User", "name"=>"Test User"}
  }

end
 
After('@omniauth_test') do
  OmniAuth.config.test_mode = false
end

Then we need to update the feature file (features/users/signin.feature) to indicate that the sing in test is an @omniauth_test.

Feature: Sign In
  As a visiting user
  I want to sign in
  Sign in will happen via GitHub (and possibly other auth providers)

  @omniauth_test
  Scenario: Sucessful sign in
    Given a signed out user
    When he visits the home page
    Then he should see "Sign In"
    When he signs in with GitHub
    Then he should see "Sign Out"

...

Now Cucumber will complain about a missing route for /auth/github/callback.

...
    When he signs in with GitHub  # features/step_definitions/users_steps.rb:13
      No route matches [GET] "/auth/github/callback" (ActionController::RoutingError)
      features/users/signin.feature:11:in `When he signs in with GitHub'

This means that OmniAuth is properly simulating the "OAuth Dance" with GitHub and is now ready for the application to handle an authenticated user. Now we need to add handling for that route and point it to SessionsController#new.

# add to config/routes.rb
match "/auth/:provider/callback" => "sessions#create", :via => [:get, :post]

Generate the models

Before we can implement the session handling we're going to need models to represent the User and Authentication. Let's generate the models and run the DB migrations.

rails g model user name:string
rails g model authentication user_id:integer provider:string uid:string name:string
rake db:migrate; rake db:test:prepare;

We can also set up the relationship between the models.

class Authentication < ActiveRecord::Base
  belongs_to :user
end

class User < ActiveRecord::Base
  has_many :authentications
end

Creating a Session

Now we can get back to creating the user session. Handling the info that we get back from OmniAuth is potentially messy, so in order to keep the controller code nice and light, let's use a from_omniauth method on the Authentication class to handle looking up or creating an Authentication based on the OAuth info. Then we'll grab the User for that Authentication. So the create method on SessionsController looks like this.

protect_from_forgery :except => :create
def create
  authentication = Authentication.from_omniauth(env["omniauth.auth"])
  user = authentication.user
  session[:user_id] = user.id
  redirect_to root_url, notice: "Signed in!"
end

In the from_omniauth method, we're just going to return the first Authentication that matches the provider and uid, or create a new one.

class Authentication < ActiveRecord::Base
  belongs_to :user

  def self.from_omniauth
    where(auth.slice("provider", "uid")).first || create_from_omniauth(auth)
  end

  def self.create_from_omniauth(auth)
    info = auth["user_info"] || auth["info"]
    name = info["name"] rescue ""
    create! do |authentication|
      authentication.provider = auth["provider"]
      authentication.uid = auth["uid"]
      authentication.name = name
      authentication.user = User.create(:name => name)
    end
  end
end

Now we'll actually be logged in, but Cucumber still isn't happy because we haven't added the "Sign Out" link to the page.

First we'll add a helper function that will give us easy access to the currently signed in user. How about we call it current_user?

class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception

  helper_method :current_user

  private

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

end

Now we can update the app/views/layouts/_header.html.erb partial to have a sign out link.

<%% if current_user %>
  Welcome <%%= current_user.name %>!
  <%%= link_to "Sign Out", signout_path %>
<%% else %>
  <%%= link_to "Sign In", signin_path %>
<%% end %>

signout_path doesn't exist yet, so we need to add a route to config/routes.rb

get "signout" => "sessions#destroy", :as => :signout

And we need to add the destroy method to SessionsController

def destroy
  session[:user_id] = nil
  redirect_to root_url, notice: "Signed out!"
end

And that's it! Kind of. Not really….

Now a user can sign in through a single provider, but they can't yet connect multiple accounts.

Next time we'll look at allowing multiple connections as well as smoothing out some of the existing rough spots. We'll also cover getting environment variables set up correctly to allow local testing of the GitHub integration. Right now things only work with the simulated OAuth dance inside of Cucumber.

As always feel free to check out the Techlahoma code in progress and don't hesitate to contact us or submit a pull request if you'd like to get involved.