Devise Part 9: Setting Up 302 Redirection vs 401 Unauthorized Handlers

In part nine, we take a look at how we can set up our API endpoints to return a 401 response if a user is not signed in instead of the default 302 redirect to the login page.

On this page

Devise currently handles redirects for us when we are not signed in. This is nice default for our application controller, but what happens when we want to return a 401 Unauthorized response instead of a 304 redirect?

In this part, we will adjust our Devise initializer to do just that for us in any controller that maps to an /api/* route.

Source code can be found here

Prerequisites

Getting started

We will be working from the source code found here.

# Clone and change into the project
$ git clone https://github.com/okeeffed/demo-rails-7-with-devise-series
$ cd demo-rails-7-with-devise-series

# Continue from part 8 - ensure you've gone through the previous parts
$ git checkout 8-authorization

# Create a helper
$ mkdir lib/helpers
$ touch lib/helpers/devise_failure_app.rb

# Prepare a folder for our /api/v1 namespace
$ mkdir -p app/controllers/api/v1

At this stage, we are ready to write our helper

Our FailureApp helper

Inside of lib/helpers/devise_failure_app.rb, add the following:

module Helpers
	class DeviseFailureApp < Devise::FailureApp
		def respond
			if request.env['REQUEST_PATH'].start_with?('/api')
				http_auth
			else
				redirect
			end
		end
	end
end

This code effectively overrides the default Devise behavior of redirecting to the login page.

Instead, we will return a 401 Unauthorized response for any route that starts with /api.

Updating our Devise initializer

Inside of config/initializers/devise.rb, we need to update the config.warden manager for the failure_app:

# frozen_string_literal: true
require_relative '../../lib/helpers/devise_failure_app'

# ... rest omitted ...

Devise.setup do |config|
  # ... rest omitted ...

  # ==> Warden configuration
  # If you want to use other strategies, that are not supported by Devise, or
  # change the failure app, you can configure them inside the config.warden block.
  #
  config.warden do |manager|
    # manager.intercept_401 = false
    # manager.default_strategies(scope: :user).unshift :some_external_strategy
    manager.failure_app = Helpers::DeviseFailureApp
  end
end

I have omitted a whole bunch of jargon, so ensure you do not remove anything unnecessary and that was already there.

Updating our routes

To test this, let's update the config/routes.rb file so that the documents controller is actually under the /api/v1 namespace:

Rails.application.routes.draw do
  devise_for :users,
             controllers: { omniauth_callbacks: 'users/omniauth_callbacks', sessions: 'users/sessions',
                            registrations: 'users/registrations' }
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
  namespace :api do
    namespace :v1 do
      resources :documents, only: %i[index create update destroy show]
    end
  end

  # Defines the root path route ("/")
  resources :users
  resources :home, only: %i[index create]
  resources :session, only: [:index]
  root 'home#index'
end

We will need to adjust the controller file too.

Updating our DocumentsController

Finally, move app/controllers/documents_controller.rb to app/controllers/api/v1/documents_controller.rb.

We also need to update the class name to Api::V1::DocumentsController:

class Api::V1::DocumentsController < ApplicationController
  include Pundit

  def create
    @doc = Document.new(body: params[:body])
    authorize @doc, :create?
    @doc.save!

    render json: @doc, status: :created
  rescue Pundit::NotAuthorizedError
    render json: { error: 'You are not authorized to create a document' }, status: :unauthorized
  end

  def index
    @docs = Document.all
    render json: @docs
  end

  def update
    @doc = Document.find(params[:id])
    authorize @doc, :update?
    @doc.update!(document_params)
    render json: @doc
  rescue Pundit::NotAuthorizedError
    render json: { error: 'You are not authorized to create a document' }, status: :unauthorized
  end

  def destroy
    @doc = Document.find(params[:id])
    authorize @doc, :destroy?
    @doc.destroy
    render status: :no_content
  rescue Pundit::NotAuthorizedError
    render json: { error: 'You are not authorized to create a document' }, status: :unauthorized
  end

  private

  # Using a private method to encapsulate the permissible parameters
  # is just a good pattern since you'll be able to reuse the same
  # permit list between create and update. Also, you can specialize
  # this method with per-user checking of permissible attributes.
  def document_params
    params.require(:document).permit(:body)
  end
end

Testing our work

Start up the Rails server bin/rails s.

In another terminal, I use httpie to make two requests to check that both the 401 redirect works and so too does our 302 redirect for the home page.

$ http GET localhost:3000/api/v1/documents
HTTP/1.1 401 Unauthorized

# ... omitted

You need to sign in or sign up before continuing.

$ http GET localhost:3000/home --header
HTTP/1.1 302 Found
Cache-Control: no-cache
Content-Type: text/html; charset=utf-8
Location: http://localhost:3000/users/sign_in

Updating our tests

We just broke a lot of tests that we wrote in the previous part.

In order to fix this, we just need to do some tweaking.

For consistency, move spec/controllers/documents_controller_spec.rb to spec/controllers/api/v1/documents_controller_spec.rb.

Then, inside of spec/controllers/api/v1/documents_controller_spec.rb change RSpec.describe DocumentsController to RSpec.describe Api::V1::DocumentsController.

If we run our tests now, we will get two failures:

$ bundler exec rspec
.F....F.

# ... omitted

Finished in 0.35224 seconds (files took 4.38 seconds to load)
8 examples, 2 failures

Failed examples:

rspec ./spec/controllers/api/v1/documents_controller_spec.rb:19 # Api::V1::DocumentsController GET #index unsuccessful responses not authenticated redirects user when they are not logged in
rspec ./spec/controllers/api/v1/documents_controller_spec.rb:78 # Api::V1::DocumentsController GET #show unsuccessful responses redirects user when they are not logged in

Both are due to the fact that we are still looking for the redirect response for two of our tests as well as the fact that request.env['REQUEST_PATH'] is not defined in our tests.

Adjust both tests that assert for the 302 and set the request path and you'll end up with a file like this:

require 'rails_helper'

RSpec.describe Api::V1::DocumentsController, type: :controller do
  describe 'GET #index' do
    let(:subject) { create(:document) }

    context 'successful responses' do
      login_admin
      it 'returns all posts when user is authorized' do
        get :index, params: { body: subject.body }

        expect(response.status).to eq(200)
        expect(response.parsed_body).to eq([subject.as_json])
      end
    end

    context 'unsuccessful responses' do
      context 'not authenticated' do
        it 'responds with not authorized when they are not logged in' do
          request.headers['REQUEST_PATH'] = api_v1_documents_path
          get :create, params: { body: subject.body }

          expect(response.status).to eq(401)
        end
      end

      context 'not authorized' do
        login_user

        it 'redirects user when they are not logged in' do
          get :create, params: { body: subject.body }

          expect(response.status).to eq(401)
        end
      end
    end
  end

  describe 'GET #show' do
    context 'successful responses' do
      context 'admin' do
        login_admin

        let(:subject) { create(:document, users: [User.first]) }
        let(:document_two) { create(:document) }

        it 'can view their own post' do
          get :show, params: { id: subject.id }

          expect(response.status).to eq(200)
          expect(response.parsed_body).to eq(subject.as_json)
        end

        it 'can view another post' do
          get :show, params: { id: document_two.id }

          expect(response.status).to eq(200)
          expect(response.parsed_body).to eq(document_two.as_json)
        end
      end

      context 'basic user' do
        login_user

        let(:subject) { create(:document, users: [User.first]) }

        it 'can view their own post' do
          get :show, params: { id: subject.id }

          expect(response.status).to eq(200)
          expect(response.parsed_body).to eq(subject.as_json)
        end
      end
    end

    context 'unsuccessful responses' do
      let(:subject) { create(:document) }

      it 'responds with not authorized when they are not logged in' do
        request.headers['REQUEST_PATH'] = api_v1_documents_show_path
        get :create, params: { id: subject.id }

        expect(response.status).to eq(401)
      end

      context 'basic user is not related to document' do
        login_user

        it 'can view their own post' do
          get :show, params: { id: subject.id }

          expect(response.status).to eq(401)
        end
      end
    end
  end
end

Re-run our tests and you will see the success:

$ bundler exec rspec
........

Finished in 0.23749 seconds (files took 2 seconds to load)
8 examples, 0 failures

Summary

Today's post demonstrated how we can set up a unauthorized response for our API endpoints and keep the redirect for the others when using our devise authentication.

Resources and further reading

Photo credit: ross_savchyn