'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 |
