Advanced Usages of Devise for Rails
Tags: ruby, rails
Published: 2023-08-09 13:38:04

In part one of this series, we introduced Devise using an example app to explore modules, helpers, views, controllers, and routes.

In this part, we'll explore more advanced usages of Devise, specifically the use of OmniAuth, API authentication, and Authtrail.

Let's dive straight in!

## Authentication with OmniAuth for Ruby

Nowadays, almost every web application you come across will offer you the option to log in via a wide selection of authentication providers, ranging from social networks like Twitter and Facebook to Google, GitHub, and many more.

In many cases, this convenient multi-provider authentication is powered by a library called [OmniAuth](https://github.com/omniauth/omniauth). OmniAuth is a flexible and powerful authentication library for Ruby that allows you to integrate with multiple external providers.

It provides a simple and unified API for connecting to various OAuth providers. OmniAuth is particularly useful in situations where you want to give your users the option to sign up or log in using their social media accounts. With OmniAuth, you can easily add social login functionality to your Rails application.

When OmniAuth is used alongside the Devise gem, it becomes even easier to manage user authentication and authorization. You can take advantage of Devise's built-in authentication features, while using OmniAuth for external provider sign-in options.

## Getting Started with OmniAuth and Devise

As mentioned, OmniAuth allows you to integrate with a number of third-party authentication providers. For the purposes of this article, we'll use GitHub.

### Install OmniAuth Gems

In your app's `Gemfile`, add the following lines:

```ruby
# Gemfile

gem 'omniauth'
gem 'omniauth-github' # each provider will have their own gem to work with Omniauth
```

And if you're using the OmniAuth 2.0+ gem, you'll also need to add:

```ruby
#  Gemfile

gem 'omniauth-rails_csrf_protection'
```

The `omniauth-rails_csrf_protection` gem ensures that any `GET` requests to the OAuth flow are disabled. It also inserts a Rails CSRF token verifier before the OAuth request phase. These two actions are meant to mitigate cross-site forgery attacks aimed at the OAuth authentication flow.

Next, run `bundle install` to install the gems.

### Create a New GitHub OAuth App

Now we need to create a new OAuth app on GitHub. This app will act as a user with authentication permissions which can be easily revoked, if needed.

The first step is to go to the settings page under your GitHub account profile. Then, on the left-hand menu, click on "Developer settings". You'll see a screen like the one below, where you can create a new OAuth app:

![GitHub developer settings page](https://blog.appsignal.com/images/blog/2023-08/create-new-github-oauth-app.png)

When you click on "Register new application", you'll get a screen like this one:

![Create new OAuth app](https://blog.appsignal.com/images/blog/2023-08/new-github-oauth-app.png)

Fill in the form details as follows:

- **Application name** - Give your new OAuth app an appropriate name.
- **Homepage url** - For now, use `http://localhost:3000/`. In production, you would use your app's actual homepage url.
- **Application description** - Not necessary, but you can still have one if you have many apps and need to differentiate between them.
- **Authorization callback url** - This is required input and will generally follow an OAuth callback url format like `http://<app-url>/users/auth/<application-provider>/callback`. That said, some OAuth providers like Google may not follow this format, so you need to take note of that.

With that done, click on "Register application". In the following screen, generate a new app secret and note it down someplace safe (as it will only be shown to you once).

### Configure the Devise Initializer

Open up the Devise initializer `config/initializers/devise.rb` and navigate to the OmniAuth section specific to GitHub. It will likely be commented out, so uncomment it and edit it with your new GitHub OAuth app's ID and secret:

```ruby
# config/initializers/devise.rb

...
  config.omniauth :github, ENV['GITHUB_APP_ID'], ENV['GITHUB_APP_SECRET'], scope: 'user,public_repo'
...
```

### Create the OmniAuth Callbacks Controller

If you have already generated Devise controllers, you'll find the `OmniauthCallbacksController` ready for you to customize accordingly. If yours isn't there, just create one manually, and edit it as follows:

```ruby
# app/controllers/users/omniauth_callbacks_controller.rb

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  # You can easily add more OmniAuth providers here
  ...

  def github
    @user = User.from_omniauth(request.env["omniauth.auth"])
    sign_in_and_redirect @user # sign_in_and_redirect is an OAuth method
  end
  ...
end
```

Some things to note about the above code:

- `from_omniauth` is a method we'll implement inside our `User` model.
- `sign_in_and_redirect` is a method inside of OAuth.

### Add a Migration to Modify the User Model

We now need to add some columns to the User model, specifically the `provider` column and a `uid` column:

```shell
bundle exec rails g migration AddOmniauthToUsers provider:string uid:string
```

Run `bundle exec rails db:migrate` to finalize this step.

### Make the Devise Model Omniauthable

Here, we'll need to edit our `User` model by adding the Devise Omniauthable module to it:

```ruby
# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable
  ...
end
```

After this, add the `from_omni_auth` method. This will be called from the `Users::OmniauthCallbacksController` that we just set up:

```ruby
# app/models/user.rb

class User < ApplicationRecord
  ...

  def self.from_omniauth(auth)
    where(provider: auth.provider, uid: auth.uid).first_or_create do |user |
      user.provider = auth.provider
      user.uid = auth.uid
      user.email = auth.info.email
      user.password = Devise.friendly_token[0,20]
    end
  end

  ...

end
```

Now we're left with just one more thing: adding the login links to our Devise views.

### Setup the Login Links

By default, Devise will automatically add the appropriate provider's login link for you in the user registration and login views. This link will use the `GET` method, but we know that OmniAuth 2.0+ prefers `POST` requests. So, we need to disable the link and insert our own using `POST` requests:

```ruby
# app/views/users/sessions/new.html.erb

# notice we use a button_to which results in POST requests by default

<%= button_to "Sign in with Github", user_github_omniauth_authorize_path %>
```

With that, we've successfully set up a Ruby on Rails 7 app with Devise and GitHub OAuth authentication. [You can grab the full source code of the companion app here](https://github.com/iamaestimo/tasker_app).

Next, we'll get into another advanced use case: using Devise to authenticate an API call.

## API Authentication with Devise for Ruby

Today, it's not uncommon for users to expect to be able to connect to your app via an API. In this section, we'll look at how we can safely authenticate such user requests using Devise.

Where browser-based authentication is generally cookie-based, most API authentication happens via tokens called JSON Web Tokens (or simply JWTs), which are passed around in the headers.

_**Tip**: For the purposes of this section, we'll assume we're working with a Rails API-only app. To follow along, create one with `rails new app_name --api`_

### The JWT-based Authentication Flow

As we mentioned, API authentication is based on JWT tokens, and it's important that we understand how a JWT-based authentication flow happens. Basically, it follows a general sequence of steps as outlined below:

- A user client makes a call to the API app.
- The API app responds with a JSON Web Token (JWT), an authentication token that can be used in place of cookies.
- Subsequent requests by the user client are made using this token in the `Authorization` header.
- The user can then hit the Devise 'session destroy' action, which results in the destruction of the token and the user being logged out.

Now, let's put this flow into action, starting with what is termed cross-origin resource sharing (CORS).

### Setting Up CORS

CORS sets up our API app to allow requests from external sources. CORS is a HTTP-based security policy that defines how external requests will be handled by your application. By default, CORS will block any request that originates from a domain different to the one that made the initial request (in other words, a request coming from a different "origin").

To handle CORS properly, we'll use the nifty gem `rack-cors`. In the `Gemfile`, uncomment the line below, then run `bundle install`:

```ruby
# Gemfile
gem 'rack-cors'
```

Also, open up a corresponding CORS initializer file and modify it as below:

```ruby
# config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "*"

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: %w[Authorization Uid]
  end
end
```

Some important notes about what we've just done:

- `origins "*"` - Simply means that our API app can now receive requests from any other source.
- `expose: %w[Authorization Uid]` - By default, the `rack-cors` gem doesn't expose the authorization and UID headers, but we need them since we'll be passing authorization tokens through.

With that done, let's install Devise and the accompanying Devise-JWT gem.

### Add Devise and Devise-JWT Gems to Your Rails App

The [`devise-jwt` gem](https://github.com/waiting-for-dev/devise-jwt) is an extension of Devise that will allow us to work with JWT tokens. Add the gems to `Gemfile`, then run `bundle install`:

```ruby
# Gemfile

gem 'devise'
gem 'devise-jwt'
```

Run the Devise install generator `bundle exec rails g devise:install`.

### Generate and Configure the Models

We need to set up two models: the normal Devise user model (`bundle exec rails g devise User`), and a model that we'll use for the revocation strategy (in other words, how a user will sign out of the API):

```shell
bundle exec rails g model jwt_revocation
```

We modify the normal Devise user model for API authentication by adding the JWT token authenticatable module and defining the token revocation strategy to use our second model, `JwtDenylist`:

```ruby
# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
  :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
```

Next, we configure our second model by referencing the revocation strategy and the revocation table to be used:

```ruby
# app/models/jwt_denylist.rb

class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist
  self.table_name = 'jwt_denylist'
end
```

In the following section, we'll outline token revocation and why we need it.

### The Importance of Token Revocation

Why is token revocation important? Because JWT tokens are stateless. The server knows nothing about them other than signing them. In such a scenario, the server has no way to sign out a user by revoking the corresponding token. Since there's no way to revoke individual tokens, we need to build one out and tell the server to use it.

When revoking a token, what's really happening under the hood is that a unique piece of the token, the `jti` (JWT ID), is extracted and used according to the defined revocation strategy.

Of course, this raises another question of what a token revocation strategy is. In a nutshell, it's a definition of how token revocation will be handled by your server. There are three basic revocation strategies:

- **JTIMatcher strategy** - Here, a unique column called "jti" is added to the user model, which also acts as the revocation table. Whenever a user makes a request, the `jti` in the header is matched against the stored tokens and access is allowed only when a match is found.
- **Denylist strategy** - For this one, the `jti` and the token expiry (`exp` of revoked tokens) are stored in a database table. For every request made by a user, a check is made against this table comparing the user's current token `jti` against the revoked ones in the database. If a match is found, that user's requests are denied.
- **Allowlist strategy** - In a way, this strategy is similar to the first strategy, except that here the table storing the JWT IDs is in a one-to-many relationship with another table storing the user tokens. Whenever a request is made, the user's `jti` stored in the Allowlist table is matched against what is stored in the matching table with tokens. Access is only allowed if a match is found.

Obviously, this is a very simplified overview of token revocation, and [you can learn more about it here](https://github.com/waiting-for-dev/devise-jwt).

### Set Up a Signing Key for JWT Tokens

Since we'll be using secure tokens to authenticate users and their requests, we need a way of signing them. This is where our secret key comes in. It is recommended that you generate a new key different from the Rails secret key, the `secret_key_base`.

Run `bundle exec rake secret` to generate a unique key, then edit the Devise initializer to include this key:

```ruby
# config/initializers/devise.rb
Devise.setup do |config|
  ...
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET']
  end
  ...
end
```

Finally, let's set up the controllers.

### Controller Set Up

The final step to implement Devise for API authentication is to set up our controllers. For the sake of simplicity, we'll set up two controllers: one to handle registration and the other one for sessions.

Let's create these manually, starting with the registrations controller:

```ruby
# app/controllers/users/registrations/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController

 respond_to :json

 private

 def respond_with(resource, _opts={})
  successful_registration && return if resource.persisted?
  failed_registration
 end

 def successful_registration
  render json: { message: "You've registered successfully", user: current_user }, status: :ok
 end

 def failed_registration
  render json: { message: "Something went wrong. Please try again" }, status: :unprocessable_entity
 end

end
```

Here's what's going on with this controller:

- We configure it to respond to requests with JSON.
- We specify a `respond_with` action that returns the result of a successful or a failed registration.

And now for the sessions controller:

```ruby
# app/controllers/users/sessions/sessions_controller.rb

class Users::SessionsController < Devise::SessionsController

 respond_to :json

 private

 def respond_with(resource, _opts={})
  render json: { message: "Welcome, you're in", user: current_user }, status: :ok
 end

 def respond_to_on_destroy
  successful_logout && return if current_user
  failed_logout
 end

 def successful_logout
  render json: { message: "You've logged out" }, status: :ok
 end

 def failed_logout
  render json: { message: "Something went wrong." }, status: :unauthorized
 end

end
```

Just like the registrations controller, here we specify that the controller will respond with JSON. We also define a `respond_with` action for when a user successfully logs in and a `respond_to_on_destroy` to handle a user signing out.

And with that, you should have a working API authentication flow powered by Devise and JWT tokens!

Our final section will take a quick look at how you can track user logins using Devise and Authtrail.

## Tracking Devise Logins with Authtrail

Let's say you want to send your app users a notification email whenever someone logs in to their account, with details such as the IP address and the timestamp of the login. How can you accomplish this?

You'll need to track user logins, then use that information in the notification emails you send your users. But first, you'll need a way of tracking user logins. This can be accomplished using a nifty gem called [Authtrail](https://github.com/ankane/authtrail), which also pairs well with Devise.

### Installing Authtrail

The first step is to install the gem with `bundle add authtrail`. Additionally, since you'll be storing user-identifiable information such as emails and IP addresses in your app database, it's highly recommended that you encrypt this data in production using a combination of [Lockbox](https://github.com/ankane/lockbox) and [Blindindex](https://github.com/ankane/blind_index) gems.

Next, run the Authtrail generator to create its initializer and an accompanying table migration that will store the login data:

```shell
# Notice here we run the generator with the encryption flag set to 'lockbox', you can also indicate --encryption=none for no encryption

bundle exec rails g authtrail:install --encryption=lockbox
bundle exec rails db:migrate
```

### How Authtrail Works

Whenever a user tries to log in, a new Authtrail record is created with the following important details:

- The login email address used
- Whether the login was successful or not
- The reason a login failed (if the login was a failure)
- The user's IP address, `referrer`, and a lot more

You can then use this information as you wish. For example, you can send a notification email to a user, including the email and IP address information, to let them know that a login attempt was made on their account.

Go through [Authtrail's documentation](https://github.com/ankane/authtrail) to see all the possibilities available to you.

## Wrapping Up

In this series, we took a deep dive into the Devise gem.

First, we got to grips with the basics of Devise, including how its modules, helpers, views, controllers, and routes work. In this second and final part, we explored how to use Devise with OAuth, Authtrail, and for API authentication.

Hopefully, this series will serve as a helpful guide for all things Devise authentication.

Happy coding!

**P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, [subscribe to our Ruby Magic newsletter and never miss a single post](https://blog.appsignal.com/ruby-magic)!**