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:


Leave a Reply

Your email address will not be published. Required fields are marked *

%d bloggers like this: