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.
Source code can be found here
Prerequisites
- Catch up with previous parts in this series Devise with Ruby on Rails 7.
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
Devise Part 9: Setting Up 302 Redirection vs 401 Unauthorized Handlers
Introduction