'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 1:[1]

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: "[email protected]..., 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: "[email protected]..., 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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

Solution 2:[2]

Based on your error message. It seems to be root is not set for active_admin.

After signing in a user, confirming the account or updating the password, Devise will look for a scoped root path to redirect to. For instance, when using a :user resource, the user_root_path will be used if it exists; otherwise, the default root_path will be used. This means that you need to set the root inside your routes:

root to: 'home#index'

You can also override after_sign_in_path_for and after_sign_out_path_for to customize your redirect hooks.

Took a reference from here.

Solution 3:[3]

I had a similar issue. In my case, it was linked to the gem responders. Here is my issue about it, still unsolved.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 rna
Solution 3 brcebn