Devise Part 9: Setting Up 302 Redirection vs 401 Unauthorized Handlers
Published: Mar 9, 2022
Last updated: Mar 9, 2022
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.
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.