Rob Lacey

Brighton, UK - contact@robl.me

Software Engineer specialising in Ruby on Rails. I do occasional freelance work, I love working for SARD JV Ltd, they are awesome and give me my pocket money every month. By all means dangle a carrot.


The Magical Devise Journey - Limiting Warden Strategies on specific controllers.

Today I was solving how to skip particular Devise strategies on specific controllers. So here’s the thing, we use Devise and recently added a Doorkeeper setup for JWT API Authentication. This is all very nice, but the more strategies you add the more overhead there is when authenticating. 6 strategies when you know you’re only going to need one is too much. But perhaps the biggest problem is if you’re logged in with a session cookie, API responses work out of the box for the current authenticated user and we want/need our public facing API to be stateless.

This begs the question how does all this magic work anyway. Well, Devise is built on Warden, a Rack middleware. You define strategies in your User model and this in turn indirectly configures a Warden::Proxy object which is stored in the Request#env of your incoming request. I assume this is just flexible and can be accessed inside/outside of controllers in middleware, etc. When your controller needs the current user it queries the Warden::Proxy and all of the prefined strategies are consulted in turn to find it.

You can see this here in https://github.com/heartcombo/devise/blob/master/lib/devise/controllers/helpers.rb

module Devise
  module Controllers
    module Helpers
      module ClassMethods
        def self.define_helpers(mapping) #:nodoc:
          mapping = mapping.name

          class_eval <<-METHODS, __FILE__, __LINE__ + 1
            def authenticate_#{mapping}!(opts = {})
              opts[:scope] = :#{mapping}
              warden.authenticate!(opts) if !devise_controller? || opts.delete(:force)
            end

            def #{mapping}_signed_in?
              !!current_#{mapping}
            end

            def current_#{mapping}
              @current_#{mapping} ||= warden.authenticate(scope: :#{mapping})
            end

            def #{mapping}_session
              current_#{mapping} && warden.session(:#{mapping})
            end
          METHODS

          ActiveSupport.on_load(:action_controller) do
            if respond_to?(:helper_method)
              helper_method "current_#{mapping}", "#{mapping}_signed_in?", "#{mapping}_session"
            end
          end
        end

        # The main accessor for the warden proxy instance
        def warden
          request.env['warden'] or raise MissingWarden
        end
      end
    end
  end
end

You give Warden a scope and it will find your User or not.

def current_user
  warden.authenticate(scope: :user)
end

However, this is consulting every strategy and in our API base controller we only care about the JWT strategy. Warden allows you to query all or specific strategies (https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb)

module Warden
  class Proxy
    # Example:
    #   env['warden'].authenticate(:password, :basic, :scope => :sudo)
    #
    # :api: public
    def authenticate(*args)
      user, _opts = _perform_authentication(*args)
      user
    end
  end
end

We can overwrite the authenticate_user!, current_user methods in our API base controller.

def authenticate_user!(opts = {})
  opts[:scope] = :user
  warden.authenticate!(:jwt, opts) if !devise_controller? || opts.delete(:force)
end

def current_user
  @current_user ||= warden.authenticate(:jwt, scope: :user)
end

Or to be more flexible

class ApplicationController < ActionController::Base
  class_attribute :warden_strategies

  def authenticate_user!(opts = {})
    opts[:scope] = :user
    warden.authenticate!(*self.class.warden_strategies, opts) if !devise_controller? || opts.delete(:force)
  end

  def current_user
    @current_user ||= warden.authenticate(*self.class.warden_strategies, scope: :user)
  end
end

class ApiController < ApplicationController
  self.warden_strategies = :jwt
end

Yay, stateless API requests. Phew.

I gone did a Pull Request in case it is valuable to anyone else https://github.com/heartcombo/devise/pull/5392/files