Build a Rails 6 JWT JSON:API with Devise

Short on time? Get the code as a Rails template on GitHub. It's a one-line install.

I love Rails. Its rich ecosystem of tried and tested functionality makes it a great choice for building a backend. In this post, we'll look at how to build an API-only Rails authentication system which you might connect to a client-side frontend or app.

We'll be using Devise for Authentication, JSON:API for API responses, and Rspec for testing. Here's the structure we're aiming for:

notion image

We'll create our own session and registration controller which will extend the default Devise controllers for those same actions. On successful login / signup, we return an Authorization header with a Bearer TOKEN value. We'll use this token to authenticate subsequent requests.

On the client-side, we will send our Authorization: Bearer TOKEN header when we make our API requests. Devise then authenticates our requests, either rendering the controller action or returning a 401 error as appropriate.

Step 1. Rails API setup

Install Rails

Start by creating a new Rails App with the --api flag. This gives you a limited set of Rails features without the whole shebang you might use for a full-stack web application:

$ rails new your_app_name --api

Install Devise for authentication

We'll use Devise for user authentication. You could say it's overkill to use Devise for a relatively simple API authentication task, but it's long-standing place as the de facto Rails authentication solution is hard to argue with:

To install Devise, add the following to your Gemfile:

gem 'devise'

Then run bundle install, followed by the generator:

$ rails generate devise:install

Finally, run rails db:migrate. That will be enough to get you up and running, but check out the Devise documentation for more options.

Install devise-jwt for token-based authentication with Devise

A big thanks to Adam Mazur whose post on installing devise-jwt I'm summarising here.

Devise doesn't include token authentication out of the box, so we'll use the devise-jwt gem. Add it to your Gemfile:

gem 'devise-jwt', '~> 0.7.0'

bundle install then add a secret key in the Devise initializer. (Pro tip: you can generate secrets on the command line in Rails with bundle exec rake secret). This is used to generate signed JWT tokens. We also add login/logout request routes here, together with our token expiration date.

# config/initializers/devise.rb
Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
		jwt.dispatch_requests = [
	    ['POST', %r{^/api/login$}]
	  ]
	  jwt.revocation_requests = [
	    ['DELETE', %r{^/api/logout$}]
	  ]
	  jwt.expiration_time = 1.day.to_i
  end
end

We will revoke JWT tokens using the DenyList strategy. This means we create a new table in our database to store expired tokens, and cross-reference tokened requests to check they are valid. Create the migration:

$ rails generate migration CreateJwtDenylist

The migration creates a simple table with an indexed string column which contains the expired token, together with an exp DateTime column.

# db/migrate/xxxxx_create_jwt_denylist.rb
class CreateJwtDenylist < ActiveRecord::Migration[6.0]
  def change
    create_table :jwt_denylist do |t|
      t.string :jti, null: false
      t.datetime :expired_at, null: false
    end
    add_index :jwt_denylist, :jti
  end
end

Run rails db:migrate , then create a model to implement the revocation strategy:

# app/models/jwt_denylist.rb
class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist

  self.table_name = 'jwt_denylist'
end

Finally, we need to tell our User model that we're going to authenticate with JWT, and our revocation strategy:

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

Install rack-cors to allow cross-origin requests

Rails blocks cross-origin requests out of the box. To access our API from a different URL, we need to install the rack-cors gem. Add it to your Gemfile:

gem 'rack-cors'

Then bundle install. Finally, create an initializer. We allow all origins here, but you should limit origins to a whitelist of domains in production.

# config/initializers/rack_cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'
    resource '*', 
		headers: :any, 
		methods: [:get, :post, :patch, :put, :options]
  end
end

Install jsonapi-rails for easy JSON:API serialisation

We'll use the jsonapi-rails gem to return our models as valid JSON:API objects. Add it to your Gemfile:

gem 'jsonapi-rails'

Then bundle install.

Define the serialized version of your User model in the SerializableUser class. This tells the controller which attributes to return in a JSON:API response for a given user. We only want email to start with, so we'll add that as an attribute, and a link to self.

Learn more about the JSON:API format as a whole, or the ruby-specific jsonapi-rb gem.

# app/serializable/SerializableUser.rb
class SerializableUser < JSONAPI::Serializable::Resource
  type 'users'

  attributes :email

  link :self do
    @url_helpers.api_user_url(@object.id)
  end
end

Step 2. RSpec test setup

Install rspec-rails

Our tests will use RSpec. Start by adding rspec-rails to the test section of your Gemfile:

group :test do
	gem 'rspec-rails'
	#...
end

Then bundle install. Next, run the RSpec generator on the command line:

$ rails generate rspec:install 

Install factory_bot_rails for fixtures

We'll use factory_bot_rails to create fixtures to use in our tests. Add to the test section of your Gemfile:

group :test do
	gem 'factory_bot_rails'
	#...
end

Then bundle install. Lastly, create a simple user factory:

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
  end
end

Install faker for test data

Faker helps us create real-looking test data. Add to the test group in your Gemfile:

group :test do
	gem 'faker'
	#...
end

Then bundle install.

Add UserHelpers support module

To simplify creating users during our tests, we will add a couple of helper methods:

# spec/support/user_helpers.rb
require 'faker'
require 'factory_bot_rails'

module UserHelpers

  def create_user
    FactoryBot.create(:user, 
			email: Faker::Internet.email, 
			password: Faker::Internet.password
		)
  end

	def build_user
    FactoryBot.build(:user, 
			email: Faker::Internet.email, 
			password: Faker::Internet.password
		)
  end

end

Note that FactoryBot.create actually creates the user in the database, whereas FactoryBot.build just gives us the attributes.

Require all support files, and include our new UserHelpers module in RSpec.configure:

# spec/rails_helper.rb
# ...
# Requires supporting ruby files 
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

RSpec.configure do |config|
  # ...
  config.include UserHelpers
	# ...
end

Now we can use create_user and build_user in our specs.

Install jsonapi-rspec for response matching

The jsonapi-rspec gem provides some neat matchers for JSON:API responses. Add to the test group in your Gemfile:

group :test do
	gem 'jsonapi-rspec'
	#...
end

Then bundle install. Finally, require the gem in your rails_helper, then include it in your RSpec config.

# spec/rails_helper.rb
# ... 
require 'jsonapi/rspec'

RSpec.configure do |config|
	# ...
  config.include JSONAPI::RSpec
	# ...
end

Add ApiHelpers support module

To complete our test set up, we'll create a support file to cut down on repetition when working with API responses:

#spec/support/api_helpers.rb
module ApiHelpers

  def json
    JSON.parse(response.body)
  end

  def login_with_api(user)
    post '/api/login', params: {
      user: {
        email: user.email,
        password: user.password
      }
    }
  end

end

As with our UserHelper, ApiHelper will be automatically require -d in our rails_helper. All we have to do is include it in RSpec.configure:

# spec/rails_helper.rb
# ...
# Requires supporting ruby files 
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

RSpec.configure do |config|
  # ...
  config.include ApiHelpers
	# ...
end
 

Allow users to Sign Up

Now our test framework is all set up, let's start TDD-ing. We'll kick off with our routes.

All routes are prefixed with /api. To avoid conflicts with Devise's own namespacing logic we'll manually set our authentication routes in path_names. We'll also default to a json response format while we're at it.

All other API controllers go under namespace :api, and are stored in app/controllers/api/ folder. We'll just add a users#show action for now.

# config/routes.rb
Rails.application.routes.draw do

  namespace :api, defaults: { format: :json } do
    resources :users, only: %w[show]
  end

  devise_for :users,
    defaults: { format: :json },
    path: '',
    path_names: {
      sign_in: 'api/login',
      sign_out: 'api/logout',
      registration: 'api/signup'
    },
    controllers: {
      sessions: 'sessions',
      registrations: 'registrations'
    }
end

Add a JSON:API response helper to ApplicationController

We'll use this to render JSON:API responses in our Devise controllers.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  
  def render_jsonapi_response(resource)
    if resource.errors.empty?
      render jsonapi: resource
    else
      render jsonapi_errors: resource.errors, status: 400
    end
  end

end

Create a Registration Spec

We use request specs, not controller specs. Unlike controller specs, request specs load the whole stack and return the Authorization headers which are critical to test.

# spec/controllers/api/registrations_controller_spec.rb
require 'rails_helper'

describe RegistrationsController, type: :request do

  let (:user) { build_user }
  let (:existing_user) { create_user }
  let (:signup_url) { '/api/signup' }

  context 'When creating a new user' do
    before do
      post signup_url, params: {
        user: {
          email: user.email,
          password: user.password
        }
      }
    end

    it 'returns 200' do
      expect(response.status).to eq(200)
    end

    it 'returns a token' do
      expect(response.headers['Authorization']).to be_present
    end

    it 'returns the user email' do
      expect(json['data']).to have_attribute(:email).with_value(user.email)
    end
  end

  context 'When an email already exists' do
    before do
      post signup_url, params: {
        user: {
          email: existing_user.email,
          password: existing_user.password
        }
      }
    end

    it 'returns 400' do
      expect(response.status).to eq(400)
    end
  end

end

Create a Registrations Controller

We inherit from Devise's own Registrations controller, so not much to add here. We attempt to save the user on signup, then return our custom response with the render_jsonapi_response method we added in ApplicationController earlier on.

# app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController

  def create
    build_resource(sign_up_params)
    resource.save
    sign_up(resource_name, resource) if resource.persisted?

    render_jsonapi_response(resource)
  end
end

Run foreman run rake again and you should see your tests pass.

Allow users to Log In & Log Out

As with our RegistrationController, we'll write our (failing) spec before writing the code to make it pass:

# spec/controllers/api/sessions_controller_spec.rb
require 'rails_helper'

describe SessionsController, type: :request do

  let (:user) { create_user }
  let (:login_url) { '/api/login' }
  let (:logout_url) { '/api/logout' }

  context 'When logging in' do
    before do
      login(user)
    end

    it 'returns a token' do
      expect(response.headers['Authorization']).to be_present
    end

    it 'returns 200' do
      expect(response.status).to eq(200)
    end
  end

  context 'When password is missing' do
    before do
      post login_url, params: {
        user: {
          email: user.email,
          password: nil
        }
      }
    end

    it 'returns 401' do
      expect(response.status).to eq(401)
    end

  end

  context 'When logging out' do
    it 'returns 204' do
      delete logout_url

      expect(response).to have_http_status(204)
    end
  end

end

Create a Sessions Controller

Similarly to Registrations, we don't need to add much to the default Devise Sessions Controller. We'll send a response with the render_jsonapi_response method we previously added to our ApplicationController, and a 204 status when logging out. Other than that we're just inheriting everything from Devise.

# app/controllers/sessions_controller.rb
class SessionsController < Devise::SessionsController

  private

  def respond_with(resource, _opts = {})
    render_jsonapi_response(resource)
  end

  def respond_to_on_destroy
    head :no_content
  end

end

Again, run foreman run rake and you'll be off to the races.

Congratulations. You've just set up JWT-powered authentication. Next, let's create an authenticated route which uses our new setup.

Make an API Request

We're no longer inheriting from the Devise, so we will create a Base API controller to hold shared functionality.

As want to authorise all API requests, we use Devise's :authenticate_user!. This means we will automatically authenticate all our API controller actions. Pretty neat.

# app/controllers/api/base_controller.rb
class Api::BaseController < ApplicationController

  before_action :authenticate_user!

  rescue_from ActiveRecord::RecordNotFound, with: :not_found

  def not_found
    render json: {
      'errors': [
        {
          'status': '404',
          'title': 'Not Found'
        }
      ]
    }, status: 404
  end

end

We'll add a generic 404 handler while we're at it.

Create a User Controller Spec

We'll use the login_with_api method from our ApiHelpers to log in before making each request. This ensures we set the necessary Authentication headers.

# spec/controllers/api/users_controller_spec.rb
require 'rails_helper'

describe Api::UsersController, type: :request do

  let (:user) { create_user }

  context 'When fetching a user' do
    before do
      login_with_api(user)
      get "/api/users/#{user.id}", headers: {
        'Authorization': response.headers['Authorization']
      }
    end

    it 'returns 200' do
      expect(response.status).to eq(200)
    end

    it 'returns the user' do
      expect(json['data']).to have_id(user.id.to_s)
      expect(json['data']).to have_type('users')
    end
  end

  context 'When a user is missing' do
    before do
      login_with_api(user)
      get "/api/users/blank", headers: {
        'Authorization': response.headers['Authorization']
      }
    end

    it 'returns 404' do
      expect(response.status).to eq(404)
    end
  end

  context 'When the Authorization header is missing' do
    before do
      get "/api/users/#{user.id}"
    end

    it 'returns 401' do
      expect(response.status).to eq(401)
    end
  end

end

As before a foreman run rake will show a failing test, so let's set up the controller:

It's just connecting the dots at this point. As we authenticate API requests and handle 404s in our base controller, so all we have to do is find the user and render the JSON:API response. Easy peasy:

# app/controllers/api/users_controller.rb
class Api::UsersController < Api::BaseController

  before_action :find_user, only: %w[show]

  def show
    render_jsonapi_response(@user)
  end

  private

  def find_user
    @user = User.find(params[:id])
  end

end

Conclusion

And that's it! It takes a bit of setup, but once you've done the legwork you've got yourself a flexible, test-driven authenticated API to use on the frontend of your choice.

🏅
Get the code in this post as a Rails template on GitHub.
James Chambers
Good morning. I'm James.

I send a twice-monthly newsletter about building indie software products. It's called Build Notes, and you can sign up below.

© 2014-2020 James Chambers