Testing OmniAuth authentication

By Exequiel Rozas

Adding OAuth, a.k.a. social login, to a Rails application can be an opaque process, especially if we're using a gem like omniauth.

The gem does the heavy lifting for us, and we tend not to care about the details and move on with the next feature. But this can result in issues with our integration in the long term.

In this article, we will learn how to test an OAuth implementation using Rails to ensure our code quality and user experiences don't degrade over time.

Let's start by understanding what we will build:

What we will build

Because we already covered how to implement OmniAuth authentication with the Rails 8 auth, we won't be building an app from the ground up.

Instead, we'll just focus on testing it.

In case you didn't read the article, the requirements for the social login feature were:

  • A user should be able to register and authenticate with our application using either an email address and password or any given OAuth providers like Google, GitHub, etc.
  • A user should be able to connect any provider from an account view.
  • If a user created an account using email but didn't connect any provider, we will ask them to sign in with their email and connect the provider before

In this article, we will test the OAuth part, there's no need to test the Rails generated email and password auth flow.

We will only be adding tests for the github strategy because every other test flow is basically the same.

If you want to test more than one strategy, you can do so by configuring the OmniAuth.config.mock_auth appropriately.

So let's see what we'll be testing:

What to test

An OAuth integration can be tested in multiple ways:

  • Unit testing: in the case of extracting part of the behavior to one or more objects, like an object that receives the auth params and returns an instance of a user, we can test those using unit testing.
  • Controller testing: our integration used two controller actions: create to generate a session and failure to handle a failure scenario. We can test those requests using controller testing.
  • Integration testing: we test our integration by simulating a user visit to our login page and making sure the appropriate results are produced.

In our previous article we kept all the code for the feature within the OmniAuth::SessionsController so there's no need to do unit testing as we don't have a separate class to test.

And, because there's basically no UI interaction happening beyond a simple click to the provider button, adding integration tests will not be very useful.

So, we will focus our efforts on testing the controller.

Let's start with the app setup:

Setup

Because of the way OAuth works, every time we authenticate using a service that implements the protocol, we might basically expect for an auth hash or a failure.

The auth hash follows a known schema, but the exact information that gets returned from the provider can vary depending on permissions or how they implement the protocol.

The auth hash structure is something like this:

{
  provider: "GitHub", # The name of the provider
  uid: "123456789", # A unique identifier, must be a string
  info: {
    name: "Best display name",
    email: "gillybibbons@gmail.com",
    image: "https://avatars.githubusercontent.com/u/1123456?v=4",
    urls: {GitHub: "https://github.com/gillygibbons"}, # A list of added URLS
  },
  credentials: {
    expire: false,
    token: "gho_Qx7nL3kP9zT2mJ5hF8wR1sX6bN4cK0vG"
  },
  extra: {
    raw_info: {} ## Hash containing extra information given by the provider
  }
}

It has 5 parent root keys, which are:

  • Provider: the name of the provider. In OAuth jargon, this represents the authorization server.
  • UID: it's a unique identifier that represents the user with the provider. It should be immutable for the account and stored as a string.
  • Info: it's a hash that contains information about the authenticated user. It usually contains: name, email, nickname, image and urls as keys.
  • Credentials: if the authorization server provides some kind of access token or credential, they are passed here. The main use for them is when we need to access to resources on behalf of the user. Like, if we're building a social media scheduler, for example.
  • Extra: it contains extra information in the raw_info hash with the same format it was gathered with. This means that it varies by provider, and we must know the format if we need this information for our application.

This means that, to test our implementation, we need to replicate the response by mocking the auth hash.

We will explore two ways of doing this:

Using the Faker gem

Faker is a popular gem that's useful to generate fake, but realistic, data that we can use for tests, database seeds for quick prototyping, among other possible uses.

Besides having data generators for things like addresses, books, movies, TV shows, etc., it also comes with a Omniauth model that we can access using the Faker::Omniauth class.

It comes with mocks for the following strategies:

  • Google
  • Facebook
  • Twitter
  • LinkedIn
  • GitHub
  • Apple
  • Auth0

To use any of them, we just have to call the method for the desired strategy:

# Running
Faker::Omniauth.github

# We get:
{:provider=>"github",
 :uid=>"27910187",
 :info=>
  {:nickname=>"shanel-leuschke",
   :email=>"shanel.leuschke@hayes-denesik.test",
   :name=>"Shanel Leuschke",
   :image=>"https://via.placeholder.com/300x300.png",
   :urls=>{:GitHub=>"https://github.com/shanel-leuschke"}},
 :credentials=>{:token=>"491e66ac65f392635d8317d523a46878", :expires=>false},
 :extra=>
  {:raw_info=>
    {:login=>"shanel-leuschke",
     :id=>"27910187",
     :avatar_url=>"https://via.placeholder.com/300x300.png",
     :gravatar_id=>"",
     :url=>"https://api.github.com/users/shanel-leuschke",
     :html_url=>"https://github.com/shanel-leuschke",
     :followers_url=>"https://api.github.com/users/shanel-leuschke/followers",
     :following_url=>"https://api.github.com/users/shanel-leuschke/following{/other_user}",
     :gists_url=>"https://api.github.com/users/shanel-leuschke/gists{/gist_id}",
     :starred_url=>"https://api.github.com/users/shanel-leuschke/starred{/owner}{/repo}",
     :subscriptions_url=>"https://api.github.com/users/shanel-leuschke/subscriptions",
     :organizations_url=>"https://api.github.com/users/shanel-leuschke/orgs",
     :repos_url=>"https://api.github.com/users/shanel-leuschke/repos",
     :events_url=>"https://api.github.com/users/shanel-leuschke/events{/privacy}",
     :received_events_url=>"https://api.github.com/users/shanel-leuschke/received_events",
     :type=>"User",
     :site_admin=>false,
     :name=>"Shanel Leuschke",
     :company=>nil,
     :blog=>nil,
     :location=>"Levishire, Utah",
     :email=>"shanel.leuschke@hayes-denesik.test",
     :hireable=>nil,
     :bio=>nil,
     :public_repos=>620,
     :public_gists=>398,
     :followers=>21,
     :following=>540,
     :created_at=>"2002-01-16T06:36:07-04:00",
     :updated_at=>"2025-01-22T08:18:56-04:00"
     }
   }
 }

As you can see, the mock returns a detailed response that we can use to test our OAuth integration.

If you need to test a different strategy, you might want to create a PR to add it to the library or mock the auth hash manually.

Next, we will learn how we can create a mock for the auth hash manually:

Manually mocking the auth hash

The first thing to consider when creating a manual mock is how much data we really need for our tests.

For example: in the previously mentioned article we only used the provider, the uid the email and the name, so we could create a simple mock like the following:

{:provider =>"github",
 :uid =>"27910187",
 :info => {
   :email => "gilly.bibbons@gmail.com",
   :name => "Gilly Bibbons"
 }
}

And this should work correctly because we don't need the rest of the auth hash.

Of course, if your app needs are different, you might want to mock the complete hash, especially if you are not just signing a user to your application.

However, for our particular requirements, we won't be needing to add any extra information to the auth hash.

Controller tests

To test our social login feature, we only need to test the OmniAuth::SessionsController that has two actions: create and failure.

The create action should result in the creation of a new Session if the authentication flow was successful.

Otherwise, the authentication server would return an access_denied error message. In our integration, we added the redirect_to_failure option, which makes a GET request to the auth/failure with query params that we can use to provide feedback to our users.

So, let's start by creating our test. To keep our tests organized similarly to our files, we will create the test in test/controllers/omni_auth/sessions_controller_test.rb

Then, we add the setup by invoking the setup method, which will run before each test method:

# test/controllers/omni_auth/sessions_controller_test.rb
require 'test_helper'

class OmniAuth::SessionsControllerTest < ActionDispatch::IntegrationTest
  setup do
    OmniAuth.config.test_mode = true
  end
end

If the test_mode is enabled, all requests to OmniAuth will be short-circuited to use the mock authentication hash we provide to avoid performing actual requests to the authorization servers.

Note that we will add the mock_auth configuration option for every test to produce the results we want.

Next, we will add our first test to check if a user can actually sign up to our application:

test "a user can sign up with a GitHub account" do
  OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({
    provider: "github",
    uid: "1234567890",
    info: {
      email: "test@example.com",
      name: "Test User"
    }
  })

  assert_difference("User.count", 1) do
    assert_difference("ConnectedService.count", 1) do
      get "/auth/github/callback"
    end
  end

  assert_redirected_to root_path
end

Because we haven't created a user yet, by going through the OAuth flow successfully we know that a User should be created and that it should be associated with a ConnectedService which represents the auth provider connection.

Next, we will add a test for the scenario where a user has already connected a GitHub account and is trying to sign in to our application:

test "a user can sign in with GitHub if they have already signed up and connected the GitHub service" do
  user = User.create!(email_address: "test@example.com", password: "password")
  service = ConnectedService.create!(user: user, provider: "github", uid: "1234567890")

  OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({
    provider: "github",
    uid: "1234567890",
    info: {
      email: "test@example.com",
      name: "Test User"
    }
  })

  assert_difference("User.count", 0) do
    assert_difference("ConnectedService.count", 0) do
      get "/auth/github/callback"
    end
  end

  assert_redirected_to root_path
end

Now, we will add a test for the scenario when a user created an account with email/password but doesn't have a ConnectedService:

test "a user has an email/password account but no ConnectedService" do
  user = User.create!(email_address: "test@example.com", password: "password")

  OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({
    provider: "github",
    uid: "012892323",
    info: {
      email: "test@example.com",
      name: "Test User"
    }
  })

  assert_difference("User.count", 0) do
    assert_difference("ConnectedService.count", 0) do
      get "/auth/github/callback"
    end
  end

  assert_includes flash[:notice], "There's already an account with this email address."
  assert_redirected_to new_session_path
end

After this test, which is passing, we will add a test for a User that has an account but a different service provider connected:

  test "a user has an account with a different service connected" do
    user = User.create!(email_address: "test3@example.com", password: "password")
    service = ConnectedService.create!(user: user, provider: "google_oauth2", uid: "12345678")

    OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({
      provider: "github",
      uid: "01234567",
      info: {
        email: "test3@example.com",
        name: "Test User"
      }
    })

    assert_difference("User.count", 0) do
      assert_difference("ConnectedService.count", 0) do
        get "/auth/github/callback"
      end
    end

    assert_redirected_to new_session_path
    assert_includes flash[:notice], "There's already an account with this email address. Please sign in with it using your"
  end

Failure flows

Now that we've added tests for the expected OAuth flows, we can test the failure flows.

Even though there are more than a couple of possible errors when going through an OAuth flow, we will focus on two of them: invalid_credentials and access_denied.

Because we're using the following configuration on the omniauth.rb initializer:

# config/initializers/omniauth.rb
OmniAuth.config.on_failure = Proc.new { |env|
  OmniAuth::FailureEndpoint.new(env).redirect_to_failure
}

And the following route to redirect on failure:

# config/routes.rb
get 'auth/failure', to: 'omniauth/sessions#failure', as: :omniauth_failure

We will expect a redirection to the omniauth_failure_path with the appropriate message and strategy to
confirm our failure flow.

So, we will add a test for invalid_credentials:

  test "Github login with invalid credentials" do
    OmniAuth.config.mock_auth[:github] = :invalid_credentials
    get "/auth/github/callback"
    assert_redirected_to omniauth_failure_path(message: "invalid_credentials", strategy: "github")
  end

After running it, we should get everything passing.

So lastly, we will add a test for when the user cancels the process once it's initiated:

test "Github login with access denied by user" do
  OmniAuth.config.mock_auth[:github] = :access_denied
  get "/auth/github/callback"
  assert_redirected_to omniauth_failure_path(message: "access_denied", strategy: "github")
end

These tests cover the basic use cases for our feature requirements.

Mocking the auth hash is basically most of the process when it comes to testing an OmniAuth integration using Rails.

Once you take that into consideration, there's really no mystery around testing a feature like this.

Your mileage may vary but, testing OAuth integrations is usually centered around mocking the auth_hash and the different scenarios that might be produced in the auth process.

Don't hesitate to add test cases after you've implemented a feature. After all, you will end up with better code eventually anyway.

TL;DR

Adding tests to a social login feature is important to make our code more maintainable and to have the peace of mind that things are working down the road.

To test our feature, we need a way to mock the auth hash that the OAuth service provider returns after a successful authentication attempt.

Roughly, we have two ways to generate this hash: using the Faker::Omniauth class by calling the provider we want, like Faker::Omniauth.github or by defining it manually.

For the social login feature, we don't need the whole auth hash. We actually just require the provider, uid, email and name but we can still use the Faker generated hash if we like.

Next, we added tests for the following flows:

  • User sign up using a GitHub account.
  • User sign in using a previously signed-up GitHub account.
  • Trying to sign in using with an OAuth account that matched an existing email account.
  • Trying to sign in using an OAuth for an account with a different service already connected.
  • Failure flows: invalid credentials and access denied failure flows.

All in all, testing an OmniAuth authentication flow is highly dependent on the way you want the feature to work.

Adding tests for your authentication flows, even if they don't cover every little detail out there, is probably a good bet because there's a high chance your implementation code will improve.

I hope you liked the article and that you found it useful. Have a good one and happy coding!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.