Acceptance tests with Cucumber, Devise, Omniauth and Twitter

While building our new website, we decided to protect the admin using a really simple way: we can only access it signing in with Twitter. There are already some blog posts and documentation about using Devise, Omniauth, Twitter and Cucumber, but I couldn't find anything that really fitted my needs.

Let's cook some nice cukes!

Disclaimer: I'm going to assume Devise and Cucumber are already installed in your application and you know how to use them. If you need further information on how to do this (together with Omniauth), check these great Railscasts or codegram's website repo:

Ingredients

Your Gemfile should look something similar to this:

Gemfile
gem 'oa-oauth', '0.2.0beta4', :require => 'omniauth/oauth'
gem 'devise', :git => 'git://github.com/plataformatec/devise' 

group :test do
  gem 'cucumber-rails', 'v0.4.0.beta.1'
end

You may ask, why is this guy using bleeding-edge versions, instead of just stable releases? Well:

  • Devise: the version that works OK with Omniauth is 1.2rc, but due to the last security update, sticking to master is recommended.
  • Omniauth: we need 0.2 to use the new test mode, so that any call to /auth/provider will redirect immediately to /auth/provider/callback, avoiding the use of FakeWeb or similar web mocks. (And Devise stubs only work for OAuth2 providers, Twitter is just OAuth)

Directions

First things first, setup Devise with Twitter:

config/initializers/devise.rb
Devise.setup do |config|
  \# other stuff
  config.omniauth :twitter, ENV['TWITTER_KEY'], ENV['TWITTER_SECRET']
end

Since with Heroku it is so easy to set up environment variables we're storing our Twitter credentials there, you can either load them from an YML or just write them directly in your initializer, it's your choice.

Next step, write a really simple feature:

features/admin/admin_logs_in.feature
Feature: Admin signs in
  In order to use the backend
  As an admin
  I want to sign in with Twitter

  Scenario: Admin signs in with Twitter
    Given I am registered as and admin
    And I am on the login page
    When I follow "Sign in with Twitter"
    Then I should see "Successfully authorized from Twitter account"
    And I should be on the admin dashboard

When the user clicks the "Sign in with Twitter" button, it gets redirected to '/users/auth/twitter' and thanks to latest Omniauth, it get redirected to the callback in the test environment. Now let's write some code to make Cucumber happy:

Views

Devise already adds a "Sign in with Twitter" link, really easy to customize:

app/views/devise/sessions/new.html.slim
h2 Sign-in
= link_to image_tag('http://a0.twimg.com/images/dev/buttons/sign-in-with-twitter-l.png', alt: 'Sign in with Twitter'), user_omniauth_authorize_path(:twitter)

Models

Add Omniauth and a Twitter finder to your model (if they aren't already there):

app/models/user.rb
class User < ActiveRecord::Base

    devise :database_authenticatable, :omniauthable
    attr_accessible :email

    has_many :user_tokens

    def self.find_for_twitter_oauth(omniauth)
      authentication = UserToken.find_by_provider_and_uid(omniauth['provider'], omniauth['uid'])
      if authentication && authentication.user
        authentication.user
      else
        User.new
        \# In a typical app you would create a new user here:
        \# User.create!(:email => data['email'], :password => Devise.friendly_token[0,20]) 
      end
    end 

  end

In our application we're using the model UserToken (migration available here) to store the different permissions a user may have with different Omniauth providers. Feel free to change the above code to suit your application.

Controllers

Create a controller to handle the authentication callback:

app/controllers/admin/omniauth_callbacks_controller.rb
class Admin::OmniauthCallbacksController < Devise::OmniauthCallbacksController

    def twitter
      @user = User.find_for_twitter_oauth(env['omniauth.auth'])

      if @user.persisted?
        flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: 'Twitter'
        sign_in_and_redirect @user, event: :authentication
      else
        flash[:notice] = I18n.t 'devise.omniauth_callbacks.failure', kind: 'Twitter', reason: 'User not found'
        redirect_to new_user_session_path
      end
    end

  end
Routes

And finally setup Devise to use the controller:

config/routes.rb
CodegramWeb::Application.routes.draw do
    devise_for :users, controllers: { omniauth_callbacks: 'admin/omniauth_callbacks' }
    \# other routes
  end

That's it! You should have a working application with Twitter authentication and a nice test suite. If you need more information I recommend checking the source code on Github or just leave a comment.