Securing Rails routes using constraints with the authentication generator

With Rails 8 introducing the new authentication generator, some apps might benefit from using a simpler authentication mechanism instead of resorting to libraries, thereby reducing dependencies and making Rails even more batteries-included than before.

However, the Rails authentication generator was built with simplicity in mind, leaving it to the developer to implement more complex features.

One of these missing features is the ability to protect routes directly during declaration. Normally, you would use a before_action method inside a controller to authenticate users, but sometimes, you need to mount an engine in the Rails routing declaration instead, requiring a different method of protecting those routes.

An example of this, using devise, would be like so:

Rails.application.routes.draw do
# some defined routes...

  authenticate :user, ->(user) {user.admin?} do
    mount ExternalLibrary::Engine, at: "/external-library"
  end
end

or

Rails.application.routes.draw do
# some defined routes...

  authenticated do
    mount ExternalLibrary::Engine, at: "/external-library"
  end
end

See the authenticate function? That comes from devise, implementing a function that allows you to protect routes at the routes.rb file, as opposed to a traditional controller before_action approach. However, the rails authentication generator does not provide a similar function out-of-the-box. So let’s implement something similar!

For that, we will need to use Rails routing constraints. There are a lot of ways to use advanced constraints. One way is to define a class with a self method named matches?:

class SomeConstraint
    def self.matches?(request)
        # constraint logic
    end
end

I use a folder under /app/constraints to manager my constraints, but feel free to put where you think it fits better.

and then we will use it in block format inside the Rails routes like so:

class Rails.application.routes.draw do
    constraints SomeConstraint do
        mount ExternalLibrary::Engine, at: "/external-library"
    end
end

Now, all you need is to add the rails authentication logic into your constraints.

For example, at one application of mine, I use it like so:

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :houses, dependent: :destroy, foreign_key: :host_id
  has_many :projects, dependent: :destroy, foreign_key: :cleaner_id

  enum :role, {host: "host", manager: "manager", cleaner: "cleaner"}

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  validates :name, presence: true
  validates :email_address, presence: true, uniqueness: true
end
# /app/constraints/manager_contraint
class ManagerConstraint
  def self.matches?(request)
    session_id = request.cookie_jar.signed[:session_id]
    return false unless session_id

    session = Session.find_by(id: session_id)
    user = session&.user
    user&.manager?
  end
end
Rails.application.routes.draw do
  # some defined routes...

  # only allow managers to access litestream dashboard
  constraints ManagerConstraint do
    mount Litestream::Engine, at: "/litestream"
  end
end