Search code examples
ruby-on-railsrubydeviseactiveadmin

Active Admin login not working (Devise + ActiveAdmin + Devise JWT)


I'm using rails in API mode, with Devise and Devise JWT (for the API), and ActiveAdmin. I had everything working but I've been building out the API controllers and now ActiveAdmin auth is broken and I can't figure out what's going on.

So I tried to go to /admin/login directly and it works. I enter my username and password and when I click login, I get the following error:

NoMethodError in ActiveAdmin::Devise::SessionsController#create
private method `redirect_to' called for #<ActiveAdmin::Devise::SessionsController:0x0000000001d420>

I'm not quite sure why this would be broken since it's using mostly default settings.

My routes file:

Rails.application.routes.draw do
  devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)
  ...

I haven't changed anything in ActiveAdmin::Devise and I don't even have the files showing in my codebase.

In my Devise config:

config.authentication_method = :authenticate_admin_user!
config.current_user_method = :current_admin_user

and my non-activeadmin sessions controller looks like:

# frozen_string_literal: true

module Users
  class SessionsController < Devise::SessionsController
    respond_to :json

    private

    def respond_with(resource, _opts = {})
      render json: {
        status: { code: 200, message: 'Logged in sucessfully.' },
        data: UserSerializer.new(resource).serializable_hash
      }, status: :ok
    end

    def respond_to_on_destroy
      if current_user
        render json: {
          status: 200,
          message: 'logged out successfully'
        }, status: :ok
      else
        render json: {
          status: 401,
          message: 'Couldn\'t find an active session.'
        }, status: :unauthorized
      end
    end
  end
end

And here's my admin user model:

# frozen_string_literal: true

class AdminUser < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable,
         :recoverable, :rememberable, :validatable
end

I don't believe the login is actually working when I just ignore the redirect error. I try to go to any of the pages and I get the same message You need to sign in or sign up before continuing.

Here is my application config:

    config.load_defaults 7.0
    config.api_only = true
    config.session_store :cookie_store, key: '_interslice_session'

    # Required for all session management (regardless of session_store)
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use ActionDispatch::Session::CookieStore

    config.middleware.use config.session_store, config.session_options

What am I doing wrong?

UPDATED CODE:

class ApplicationController < ActionController::API
  # https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/api.rb#L104
  # skip modules that we need to load last
  ActionController::API.without_modules(:Instrumentation, :ParamsWrapper).each do |m|
    include m
  end

  # include what's missing
  include ActionController::ImplicitRender
  include ActionController::Helpers
  include ActionView::Layouts
  include ActionController::Flash
  include ActionController::MimeResponds

  # include modules that have to be last
  include ActionController::Instrumentation
  include ActionController::ParamsWrapper
  ActiveSupport.run_load_hooks(:action_controller_api, self)
  ActiveSupport.run_load_hooks(:action_controller, self)

  respond_to :json, :html

  def redirect_to(options = {}, response_options = {})
    super
  end
module Users
  class SessionsController < Devise::SessionsController
    respond_to :html
Rails.application.routes.draw do
  devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)

  devise_for :users, defaults: { format: :json }, path: '', path_names: {
    sign_in: 'login',
    sign_out: 'logout',
    registration: 'signup'
  },
                     controllers: {
                       sessions: 'users/sessions',
                       registrations: 'users/registrations'

application config:

  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 7.0
    config.api_only = true
    config.session_store :cookie_store, key: '_interslice_session'

    # Required for all session management (regardless of session_store)
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use ActionDispatch::Session::CookieStore
    config.middleware.use config.session_store, config.session_options

Solution

  • This is the setup that I'm using, hopefully it's self-explanatory so we can get to the actual error.

    # Gemfile
    # ...
    gem "sprockets-rails"
    gem "sassc-rails"
    gem 'activeadmin'
    gem 'devise'
    gem 'devise-jwt'
    
    # config/application.rb
    require_relative "boot"
    require "rails/all"
    require "action_controller/railtie"
    require "action_view/railtie"
    require "sprockets/railtie"
    Bundler.require(*Rails.groups)
    module Rails7api
      class Application < Rails::Application
        config.load_defaults 7.0
        config.api_only = true
        config.session_store :cookie_store, key: '_interslice_session'
        config.middleware.use ActionDispatch::Cookies
        config.middleware.use Rack::MethodOverride
        config.middleware.use ActionDispatch::Flash
        config.middleware.use config.session_store, config.session_options
      end
    end
    
    # config/routes.rb
    Rails.application.routes.draw do
      # Admin
      devise_for :admin_users, ActiveAdmin::Devise.config
      ActiveAdmin.routes(self)
    
      # Api (api_users, name is just for clarity)
      devise_for :api_users, defaults: { format: :json }
      namespace :api, defaults: { format: :json } do
        resources :users
      end
    end
    
    # config/initializers/devise.rb
    Devise.setup do |config|
      # ...
      config.jwt do |jwt|
        # jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
        jwt.secret = Rails.application.credentials.devise_jwt_secret_key!
      end
    end
    
    # db/migrate/20220424045738_create_authentication.rb
    class CreateAuthentication < ActiveRecord::Migration[7.0]
      def change
        create_table :admin_users do |t|
          t.string :email,              null: false, default: ""
          t.string :encrypted_password, null: false, default: ""
          t.timestamps null: false
        end
        add_index :admin_users, :email, unique: true
    
        create_table :api_users do |t|
          t.string :email,              null: false, default: ""
          t.string :encrypted_password, null: false, default: ""
          t.timestamps null: false
        end
        add_index :api_users, :email, unique: true
    
        create_table :jwt_denylist do |t|
          t.string   :jti, null: false
          t.datetime :exp, null: false
        end
        add_index :jwt_denylist, :jti
      end
    end
    
    # app/models/admin_user.rb
    class AdminUser < ApplicationRecord
      devise :database_authenticatable
    end
    
    # app/models/api_user.rb
    class ApiUser < ApplicationRecord
      devise :database_authenticatable, :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
      self.skip_session_storage = [:http_auth, :params_auth] # https://github.com/waiting-for-dev/devise-jwt#session-storage-caveat
    end
    
    # app/models/jwt_denylist.rb
    class JwtDenylist < ApplicationRecord
      include Devise::JWT::RevocationStrategies::Denylist
      self.table_name = 'jwt_denylist'
    end
    
    # app/application_controller.rb
    class ApplicationController < ActionController::Base      # for devise and active admin
      respond_to :json, :html
    end
    
    # app/api/application_controller.rb
    module Api
      class ApplicationController < ActionController::API     # for api
        before_action :authenticate_api_user!
      end
    end
    
    # app/api/users_controller.rb
    module Api
      class UsersController < ApplicationController
        def index
          render json: User.all
        end
      end
    end
    

    There are a few different ways people get this error, but they seem to be variations on the same issue. There is only one private redirect_to method I could find and it's even in the docs

    https://api.rubyonrails.org/classes/ActionController/Flash.html#method-i-redirect_to

    Both active_admin and devise inherit from ApplicationController

    # ActiveAdmin::Devise::SessionsController < Devise::SessionsController < DeviseController < Devise.parent_controller.constantize # <= @@parent_controller = "ApplicationController"
    
    # ActiveAdmin::BaseController < ::InheritedResources::Base < ::ApplicationController
    

    When ApplicationController inherits from ActionController::API, active admin breaks due to missing dependencies. So we have to include them one by one until rails boots and controller looks like this

    class ApplicationController < ActionController::API
      include ActionController::Helpers       # FIXES undefined method `helper' for ActiveAdmin::Devise::SessionsController:Class (NoMethodError)
      include ActionView::Layouts             # FIXES undefined method `layout' for ActiveAdmin::Devise::SessionsController:Class (NoMethodError)
      include ActionController::Flash         # FIXES undefined method `flash' for #<ActiveAdmin::Devise::SessionsController:0x0000000000d840>):
    
      respond_to :json, :html                 # FIXES ActionController::UnknownFormat (ActionController::UnknownFormat):
    end
    

    This works until you try to log in and get private method 'redirect_to' error. A little bit of debugging and back-tracing points to responders gem, it is responding with html, which is ok, even if our controller is api and calls redirect_to but hits Flash#redirect_to instead of Redirecting#redirect_to

      #0    ActionController::Flash#redirect_to(options="/admin", response_options_and_flash={}) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/flash.rb:52
      #1    ActionController::Responder#redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:147
      #2    ActionController::Responder#navigation_behavior(error=#<ActionView::MissingTemplate: Missing t...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:207
      #3    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:174
      #4    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:171
      #5    ActionController::Responder#respond at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:165
      #6    ActionController::Responder.call(args=[#<ActiveAdmin::Devise::SessionsControll...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:158
      #7    ActionController::RespondWith#respond_with(resources=[#<AdminUser id: 1, email: "admin@user.c..., block=nil) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/respond_with.rb:213
      #8    Devise::SessionsController#create at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/devise-4.8.1/app/controllers/devise/sessions_controller.rb:23
    

    Since API controller is quite a bit slimmer

    https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/api.rb#L112

    than Base controller

    https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/base.rb#L205

    it looks like something is missing. So a little bit of debugging and back-tracing with a Base controller does reveal an itsy bitsy difference.

      #0    ActionController::Flash#redirect_to(options="/admin", response_options_and_flash={}) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/flash.rb:52
      #1    block {|payload={:request=>#<ActionDispatch::Request POS...|} in redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/instrumentation.rb:42
      #2    block in instrument at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2.3/lib/active_support/notifications.rb:206
      #3    ActiveSupport::Notifications::Instrumenter#instrument(name="redirect_to.action_controller", payload={:request=>#<ActionDispatch::Request POS...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2.3/lib/active_support/notifications/instrumenter.rb:24
      #4    ActiveSupport::Notifications.instrument(name="redirect_to.action_controller", payload={:request=>#<ActionDispatch::Request POS...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2.3/lib/active_support/notifications.rb:206
      #5    ActionController::Instrumentation#redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/instrumentation.rb:41
      #6    ActionController::Responder#redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:147
      #7    ActionController::Responder#navigation_behavior(error=#<ActionView::MissingTemplate: Missing t...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:207
      #8    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:174
      #9    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:171
      #10   ActionController::Responder#respond at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:165
      #11   ActionController::Responder.call(args=[#<ActiveAdmin::Devise::SessionsControll...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:158
      #12   ActionController::RespondWith#respond_with(resources=[#<AdminUser id: 1, email: "admin@user.c..., block=nil) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/respond_with.rb:213
      #13   Devise::SessionsController#create at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/devise-4.8.1/app/controllers/devise/sessions_controller.rb:23
    

    I guess we were meant to hit Instrumentation#redirect_to first. It is noted that the Instrumentation needs to be loaded later than other modules. In the Base controller Flash module comes before Instrumentation. But we included Flash last and messed things up. I don't know if there is a better way of changing the order of these modules:

    class ApplicationController < ActionController::Metal
      # https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/api.rb#L104
      # skip modules that we need to load last
      ActionController::API.without_modules(:Instrumentation, :ParamsWrapper).each do |m|
        include m
      end
    
      # include what's missing
      include ActionController::ImplicitRender
      include ActionController::Helpers
      include ActionView::Layouts
      include ActionController::Flash
      include ActionController::MimeResponds
    
      # include modules that have to be last
      include ActionController::Instrumentation
      include ActionController::ParamsWrapper
      ActiveSupport.run_load_hooks(:action_controller_api, self)
      ActiveSupport.run_load_hooks(:action_controller, self)
    
      respond_to :json, :html
    end
    

    It fixes the error. But I feel like ApplicationController should inherit from Base, it makes things much simpler, because it is used by devise and active admin, using API and adding modules back for active admin seems like running in circles.

    @brcebn workaround does work. Just get in that private method like all the cool kids do. https://github.com/heartcombo/responders/issues/222#issue-661963658

    def redirect_to(options = {}, response_options = {})
      super
    end
    

    Also this got a little hairy, so I had to write some tests. These only work when ApplicationController inherits from Base.

    # spec/requests/authentication_spec.rb
    require 'rails_helper'
    
    RSpec.describe 'Authentication', type: :request do
      describe 'Edge case for Devise + JWT + RailsAPI + ActiveAdmin configuration' do
        # This set up will raise private method error
        #
        #   class ApplicationController < ActionController::API
        #     include ActionController::Helpers
        #     include ActionView::Layouts
        #     include ActionController::Flash    # <= has private.respond_to
        #
        #     respond_to :json, :html # when responding with html in an api controller
        #   end
        #
        before { AdminUser.create!(params) }
        let(:params) { { email: 'admin@user.com', password: '123456' } }
    
        it do
          RSpec::Expectations.configuration.on_potential_false_positives = :nothing
          expect{
            post(admin_user_session_path, params: { admin_user: params })
          }.to_not raise_error(NoMethodError)
        end
    
        it do
          expect{
            post(admin_user_session_path, params: { admin_user: params })
          }.to_not raise_error
        end
      end
    
      describe 'POST /api/users/sign_in' do
        before { ApiUser.create!(params) }
        before { post api_user_session_path, params: { api_user: params } }
    
        let(:params) { { email: 'api@user.com', password: '123456' } }
    
        it { expect(response).to have_http_status(:created) }
        it { expect(headers['Authorization']).to include 'Bearer' }
        it 'should not have admin access' do
          get admin_dashboard_path
          expect(response).to have_http_status(:redirect)
          follow_redirect!
          expect(request.path).to eq '/admin/login'
        end
      end
    
      describe 'GET /api/users' do
        context 'when signed out' do
          before { get api_users_path }
    
          it { expect(response.body).to include 'You need to sign in or sign up before continuing.' }
        end
    
        context 'when signed in' do
          before { ApiUser.create!(params) }
          before { post api_user_session_path, params: { api_user: params } }
    
          let(:params) { { email: 'api@user.com', password: '123456' } }
    
          it 'should not authorize without Authorization header' do
            get api_users_path
            expect(response.body).to include 'You need to sign in or sign up before continuing.'
          end
    
          it 'should authorize with Authorization header' do
            get api_users_path, headers: { 'Authorization': headers['Authorization'] }
            expect(response.body).to_not include 'You need to sign in or sign up before continuing.'
          end
        end
      end
    
      describe 'GET /admin' do
        it do
          get admin_root_path
          expect(response).to have_http_status(:redirect)
        end
    
        context 'when api_user is authorized' do
          before { ApiUser.create!(params) }
          before { post api_user_session_path, params: { api_user: params } }
    
          let(:params) { { email: 'api@user.com', password: '123456' } }
    
          it 'should redirect without raising' do
            get admin_root_path
            expect(response).to have_http_status(:redirect)
          end
        end
      end
    
      describe 'POST /admin/login' do
        before { AdminUser.create!(params) }
        before { post admin_user_session_path, params: { admin_user: params } }
    
        let(:params) { { email: 'admin@user.com', password: '123456' } }
    
        it do
          expect(response).to have_http_status(:redirect)
          follow_redirect!
          expect(response.body).to include 'Signed in successfully.'
        end
      end
    
      describe 'DELETE /admin/logout' do
        before { AdminUser.create!(params) }
        before { post admin_user_session_path, params: { admin_user: params } }
    
        let(:params) { { email: 'admin@user.com', password: '123456' } }
    
        it 'should sign out' do
          delete destroy_admin_user_session_path
          expect(response).to have_http_status(:redirect)
          follow_redirect!
          expect(request.path).to eq '/unauthenticated' # <= what?
          follow_redirect!
          expect(response.body).to include 'Signed out successfully.'
          expect(request.path).to eq '/admin/login'
        end
      end
    end
    
    $ rspec spec/requests/authentication_spec.rb
    ...........
    
    Finished in 0.48745 seconds (files took 0.83 seconds to load)
    11 examples, 0 failures
    

    Update

    The above solution with ActionController::API.without_modules seems to be super buggy or not the correct way to do that or ActiveSupport hooks were not meant to be run inside ApplicationController.

    The only other way I've found is to define full custom controller and inherit from it. The inheritance part seems to be important (drop a comment if you know why).

    # app/controllers/base_controller.rb
    
    class BaseController < ActionController::Metal
      abstract!
    
      # Order of modules is important
      # See: https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/base.rb#L205
      MODULES = [
        AbstractController::Rendering,
    
        # Extra modules #################
    
        ActionController::Helpers,
        ActionView::Layouts,
        ActionController::MimeResponds,
        ActionController::Flash,
        
        #################################
    
        ActionController::UrlFor,
        ActionController::Redirecting,
        ActionController::ApiRendering,
        ActionController::Renderers::All,
        ActionController::ConditionalGet,
        ActionController::BasicImplicitRender,
        ActionController::StrongParameters,
    
        ActionController::DataStreaming,
        ActionController::DefaultHeaders,
        ActionController::Logging,
    
        # Before callbacks should also be executed as early as possible, so
        # also include them at the bottom.
        AbstractController::Callbacks,
    
        # Append rescue at the bottom to wrap as much as possible.
        ActionController::Rescue,
    
        # Add instrumentations hooks at the bottom, to ensure they instrument
        # all the methods properly.
        ActionController::Instrumentation,
    
        # Params wrapper should come before instrumentation so they are
        # properly showed in logs
        ActionController::ParamsWrapper
      ]
    
      MODULES.each do |mod|
        include mod
      end
    
      ActiveSupport.run_load_hooks(:action_controller_api, self)
      ActiveSupport.run_load_hooks(:action_controller, self)
    end
    
    # app/application_controller.rb
    class ApplicationController < BaseController   # use for everything
      respond_to :json, :html
    end
    
    # app/api/users_controller.rb
    module Api
      class UsersController < ApplicationController
        before_action :authenticate_api_user!
        def index
          render json: User.all
        end
      end
    end
    

    Tested!

    12 examples, 0 failures