Back to home

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

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

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

Introduction

Share this post