Mauro Morales

software developer

Tag: routing

  • Rails Routing Advanced Constraints for User Authentication Without Devise

    Many times we mount engines and restrict access to admin users via Devise. In this post, I’ll show you how to do the same when using a different authentication mechanism.

    Let’s take for example the Sidekiq engine. According to their wiki, all we need to do is surround the mount using the authenticate method.

    # config/routes.rb
    authenticate :user, ->(user) { user.admin? } do
      mount Sidekiq::Web => '/sidekiq'
    end

    But since this method is a Devise helper method, how can we achieve the same results when we use a different authentication mechanism?

    Turns out it’s actually very simple. We can use a Rails’ advanced constraint.

    # config/routes.rb
    mount Sidekiq::Web, at: '/sidekiq', constraints: AdminConstraint

    Not too shabby! It looks even better than the Devise helper method IMO. But let’s dive into this constraint.

    For the sake of simplification, I will assume that our authentication mechanism consist of a JWT token which gets saved on a cookie and a service which takes care of verifying that token. This service will also return a user when successful or nil otherwise. Replace this behaviour for whatever mechanism you have instead.

    # app/constraints/admin_contraint.rb
    class AdminConstraint
      class << self
        def matches?(request)
          user = TokenAuthenticationService.new(request.cookies['authToken']).call
          user.present? && user.admin?
        end
      end
    end

    Yes, it’s a bit more code, but not that much and it allows us to leave the routes file a bit cleaner and to have a single place where to define what access to admin means.

    Let’s finish the job by adding a test. I like RSpec, so I’ll write a request tests.

    I’ll also assume that you have a token generation service.

    # spec/constraints/admin_constraint_spec.rb
    require "rails_helper"
    
    # we won't want to rely on sidekiq for our test, so we'll create a dummy Engine
    module MyEngine
      class Engine < ::Rails::Engine
        isolate_namespace MyEngine
      end
    
      class LinksController < ::ActionController::Base
        def index
          render plain: 'hit_engine_route'
        end
      end
    end
    
    MyEngine::Engine.routes.draw do
      resources :links, :only => [:index]
    end
    
    module MyEngine
      RSpec.describe "Links", :type => :request do
        include Engine.routes.url_helpers
    
        before do
          Rails.application.routes.draw do
            mount MyEngine::Engine => "/my_engine", constraints: AdminConstraint
          end
    
          cookies['authToken'] = token
        end
    
        after do
          Rails.application.routes_reloader.reload!
        end
    
        let(:token) { TokenGeneratorService.new(user).call }
    
        context 'with an admin token cookie' do
          let(:user) { create(:user, admin: true) }
    
          it "is found" do
            get links_url
    
            expect(response).to have_http_status(:ok)
            expect(response.body).to eq('hit_engine_route')
          end
        end
    
        context 'with a non-admin user' do
          let(:user) { create(:user, admin: false) }
    
          it "is not found" do
            expect {
              get links_url
            }.to raise_error(ActionController::RoutingError)
          end
        end
      end
    end

    Et voila! We’re sure that our constraint behaves as expected.

    Resources

    All the code in this post was based on the documentation from the following projects: