From 3571a8c45ce74ea0b0563d08a5aabeba61da8377 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 09:12:57 +0200 Subject: [PATCH 1/7] Relax omniauth_openid_connect floor to 0.6 for faraday-1 host apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omniauth_openid_connect 0.7+ pulls in openid_connect 2.x which requires faraday ~> 2.0. Host apps still on faraday 1.x (large legacy Gemfiles with pinned transitives like azure-storage-common, faraday_middleware, pipedrive forks) cannot adopt the gem at all. omniauth_openid_connect 0.6.x pairs with openid_connect 1.x, which uses httpclient internally and has no faraday dependency. activeadmin-oidc itself only touches the standard OmniAuth strategy registration API (devise.omniauth :openid_connect, ...) — identical across both lines — so the floor can safely move down to 0.6 with no runtime changes. --- activeadmin-oidc.gemspec | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/activeadmin-oidc.gemspec b/activeadmin-oidc.gemspec index d9027b4..1d8f71d 100644 --- a/activeadmin-oidc.gemspec +++ b/activeadmin-oidc.gemspec @@ -31,7 +31,11 @@ Gem::Specification.new do |spec| spec.add_dependency "devise", ">= 4.9" spec.add_dependency "omniauth", ">= 2.1" spec.add_dependency "omniauth-rails_csrf_protection", ">= 1.0" - spec.add_dependency "omniauth_openid_connect", ">= 0.7" + # 0.6.x → openid_connect 1.x (httpclient-based, no faraday dep). + # 0.7.x+ → openid_connect 2.x (faraday 2.x). Host apps still on faraday 1.x + # need 0.6.x. activeadmin-oidc only uses the standard OmniAuth strategy + # registration API, which is identical across both lines. + spec.add_dependency "omniauth_openid_connect", ">= 0.6" spec.add_dependency "rails", ">= 7.2" spec.add_development_dependency "rspec-rails", ">= 6.0" From 61150c93a0760b9dbadce51a59b5ab56ae0fcb19 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 17:32:27 +0200 Subject: [PATCH 2/7] Force route load after_initialize so OmniAuth failure handler sees Devise mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmniAuth.config.on_failure walks Devise.mappings.find_by_path! to resolve the resource scope from the request path. On Rails 8 routes load lazily — first OmniAuth callback can fire before devise_for runs, which leaves Devise.mappings empty and raises 'Could not find a valid mapping for path /admin/auth/oidc' that masks the real underlying error (CSRF check, bad id_token signature, etc). Move the workaround into the engine so every host app gets it for free instead of every consumer copy-pasting Rails.application.routes_reloader. execute_unless_loaded into their activeadmin-oidc initializer. --- lib/activeadmin/oidc/engine.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index d3aeeaa..63bd1dd 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -101,6 +101,16 @@ def controllers initializer 'activeadmin_oidc.filter_parameters' do |app| app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce] end + + # Rails 8 lazy-loads routes; OmniAuth's failure handler walks + # `Devise.mappings.find_by_path!` which sees an empty mapping + # registry and raises "Could not find a valid mapping for path + # /admin/auth/oidc", masking the real underlying error (CSRF, + # mis-issued id_token, etc). Forcing route load at after_initialize + # populates the registry before the first OmniAuth callback hits. + config.after_initialize do + Rails.application.routes_reloader.execute_unless_loaded + end end end end From 23d17cc2df8f1b7e6d53dfea937c6232f3fb7c3b Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 18:17:40 +0200 Subject: [PATCH 3/7] Guard execute_unless_loaded for Rails 7.x and add regression spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute_unless_loaded is a Rails 8 API; on Rails 7.x the engine crashed at boot. Guarded with respond_to? — Rails 7.x loads routes eagerly so the hook is a no-op there. Added a high-level spec that simulates the empty-Devise.mappings state and verifies the fix. --- lib/activeadmin/oidc/engine.rb | 5 +- spec/requests/rails8_lazy_routes_spec.rb | 71 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 spec/requests/rails8_lazy_routes_spec.rb diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 63bd1dd..a7782e2 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -108,8 +108,11 @@ def controllers # /admin/auth/oidc", masking the real underlying error (CSRF, # mis-issued id_token, etc). Forcing route load at after_initialize # populates the registry before the first OmniAuth callback hits. + # `execute_unless_loaded` is a Rails 8 API; on Rails 7.x routes + # load eagerly so this is already populated. config.after_initialize do - Rails.application.routes_reloader.execute_unless_loaded + reloader = Rails.application.routes_reloader + reloader.execute_unless_loaded if reloader.respond_to?(:execute_unless_loaded) end end end diff --git a/spec/requests/rails8_lazy_routes_spec.rb b/spec/requests/rails8_lazy_routes_spec.rb new file mode 100644 index 0000000..fa54173 --- /dev/null +++ b/spec/requests/rails8_lazy_routes_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Regression spec for the Rails 8 lazy-route-loading bug. +# +# On Rails 8 routes are not drawn at boot — they load lazily on the +# first request. `Devise.mappings` is populated as a side effect of +# drawing routes (every `devise_for :admin_users` call registers a +# mapping). OmniAuth's failure handler walks +# `Devise.mappings.find_by_path!('/admin/auth/oidc', :path)` to figure +# out which scope failed; if the registry is still empty (no request +# has yet caused routes to load) the handler raises: +# +# Could not find a valid mapping for path "/admin/auth/oidc" +# +# masking the real underlying failure (CSRF, mis-issued id_token, etc). +# +# The engine fixes this with: +# +# config.after_initialize do +# Rails.application.routes_reloader.execute_unless_loaded +# end +# +# (Rails 8 API; on Rails 7.x routes load eagerly so the hook is a no-op.) +RSpec.describe "Rails 8 lazy-route-loading regression" do + before do + # Snapshot Devise.mappings so we can restore after simulating the + # fresh-boot state; clearing it permanently would break other specs. + @saved_mappings = Devise.mappings.dup + end + + after do + Devise.mappings.clear + @saved_mappings.each { |k, v| Devise.mappings[k] = v } + Rails.application.routes_reloader.instance_variable_set(:@loaded, true) + end + + def simulate_fresh_boot_no_routes_drawn! + Devise.mappings.clear + Rails.application.routes_reloader.instance_variable_set(:@loaded, false) + end + + it "the symptom: empty Devise.mappings cannot resolve the OmniAuth callback path" do + simulate_fresh_boot_no_routes_drawn! + + expect(Devise.mappings).to be_empty + expect { + Devise::Mapping.find_by_path!("/admin/auth/oidc", :path) + }.to raise_error(/Could not find a valid mapping/) + end + + it "the cure: forcing route load repopulates Devise.mappings before OmniAuth needs it" do + simulate_fresh_boot_no_routes_drawn! + + # Mirrors the engine's after_initialize hook. `execute_unless_loaded` + # is Rails 8 only; fall back to `execute` on Rails 7.x so this spec + # runs on the full CI matrix. + reloader = Rails.application.routes_reloader + if reloader.respond_to?(:execute_unless_loaded) + reloader.execute_unless_loaded + else + reloader.execute + end + + expect(Devise.mappings.keys).to include(:admin_user) + expect { + Devise::Mapping.find_by_path!("/admin/auth/oidc", :path) + }.not_to raise_error + end +end From 34e2bf1aa285b03289c1527dfbdacedcf13ca811 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 18:29:12 +0200 Subject: [PATCH 4/7] Replace internal-state spec with Rails 8 Capybara feature spec The previous spec poked at routes_reloader and Devise.mappings internals to demonstrate the fix. Replaced with a feature-level Capybara spec that exercises the OIDC failure flow end-to-end on Rails 8 only (skipped on 7.x where the bug doesnt manifest). Adds capybara as a dev dependency. --- activeadmin-oidc.gemspec | 1 + spec/features/rails8_oidc_failure_spec.rb | 37 ++++++++++++ spec/requests/rails8_lazy_routes_spec.rb | 71 ----------------------- 3 files changed, 38 insertions(+), 71 deletions(-) create mode 100644 spec/features/rails8_oidc_failure_spec.rb delete mode 100644 spec/requests/rails8_lazy_routes_spec.rb diff --git a/activeadmin-oidc.gemspec b/activeadmin-oidc.gemspec index 1d8f71d..0a2a412 100644 --- a/activeadmin-oidc.gemspec +++ b/activeadmin-oidc.gemspec @@ -39,6 +39,7 @@ Gem::Specification.new do |spec| spec.add_dependency "rails", ">= 7.2" spec.add_development_dependency "rspec-rails", ">= 6.0" + spec.add_development_dependency "capybara", ">= 3.40" spec.add_development_dependency "webmock", ">= 3.19" spec.add_development_dependency "jwt", ">= 2.7" spec.add_development_dependency "sqlite3", ">= 1.7" diff --git a/spec/features/rails8_oidc_failure_spec.rb b/spec/features/rails8_oidc_failure_spec.rb new file mode 100644 index 0000000..604c74d --- /dev/null +++ b/spec/features/rails8_oidc_failure_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" +require "capybara/rspec" + +# End-to-end Capybara spec for the Rails 8 OIDC failure flow. +# +# Regression context: on Rails 8 routes load lazily, and OmniAuth's +# failure handler walks `Devise.mappings.find_by_path!` which is only +# fully populated (with path-aware metadata) once `devise_for` has +# run as part of drawing routes. If a host app's model isn't yet +# autoloaded and routes haven't been drawn, the very first failed +# OIDC callback raises "Could not find a valid mapping for path +# /admin/auth/oidc", masking the real underlying error. +# +# The engine fixes this with +# `config.after_initialize { routes_reloader.execute_unless_loaded }`. +# This spec exercises the failure flow end-to-end on Rails 8 to guard +# against regressions to that path. +# +# Skipped on Rails 7.x where routes load eagerly and +# `execute_unless_loaded` doesn't exist. +RSpec.feature "Rails 8 OIDC failure callback", type: :feature do + before do + skip "Rails 8+ only" if Rails::VERSION::MAJOR < 8 + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:oidc] = :invalid_credentials + end + + after { OmniAuth.config.mock_auth[:oidc] = nil } + + scenario "an OIDC strategy failure redirects to /admin/login" do + visit "/admin/auth/oidc" + expect(page).to have_current_path("/admin/login") + end +end diff --git a/spec/requests/rails8_lazy_routes_spec.rb b/spec/requests/rails8_lazy_routes_spec.rb deleted file mode 100644 index fa54173..0000000 --- a/spec/requests/rails8_lazy_routes_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -# Regression spec for the Rails 8 lazy-route-loading bug. -# -# On Rails 8 routes are not drawn at boot — they load lazily on the -# first request. `Devise.mappings` is populated as a side effect of -# drawing routes (every `devise_for :admin_users` call registers a -# mapping). OmniAuth's failure handler walks -# `Devise.mappings.find_by_path!('/admin/auth/oidc', :path)` to figure -# out which scope failed; if the registry is still empty (no request -# has yet caused routes to load) the handler raises: -# -# Could not find a valid mapping for path "/admin/auth/oidc" -# -# masking the real underlying failure (CSRF, mis-issued id_token, etc). -# -# The engine fixes this with: -# -# config.after_initialize do -# Rails.application.routes_reloader.execute_unless_loaded -# end -# -# (Rails 8 API; on Rails 7.x routes load eagerly so the hook is a no-op.) -RSpec.describe "Rails 8 lazy-route-loading regression" do - before do - # Snapshot Devise.mappings so we can restore after simulating the - # fresh-boot state; clearing it permanently would break other specs. - @saved_mappings = Devise.mappings.dup - end - - after do - Devise.mappings.clear - @saved_mappings.each { |k, v| Devise.mappings[k] = v } - Rails.application.routes_reloader.instance_variable_set(:@loaded, true) - end - - def simulate_fresh_boot_no_routes_drawn! - Devise.mappings.clear - Rails.application.routes_reloader.instance_variable_set(:@loaded, false) - end - - it "the symptom: empty Devise.mappings cannot resolve the OmniAuth callback path" do - simulate_fresh_boot_no_routes_drawn! - - expect(Devise.mappings).to be_empty - expect { - Devise::Mapping.find_by_path!("/admin/auth/oidc", :path) - }.to raise_error(/Could not find a valid mapping/) - end - - it "the cure: forcing route load repopulates Devise.mappings before OmniAuth needs it" do - simulate_fresh_boot_no_routes_drawn! - - # Mirrors the engine's after_initialize hook. `execute_unless_loaded` - # is Rails 8 only; fall back to `execute` on Rails 7.x so this spec - # runs on the full CI matrix. - reloader = Rails.application.routes_reloader - if reloader.respond_to?(:execute_unless_loaded) - reloader.execute_unless_loaded - else - reloader.execute - end - - expect(Devise.mappings.keys).to include(:admin_user) - expect { - Devise::Mapping.find_by_path!("/admin/auth/oidc", :path) - }.not_to raise_error - end -end From e64b7c5a1e5c015231a88a661cd5ca45604c55a0 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 18:30:25 +0200 Subject: [PATCH 5/7] Add omniauth_openid_connect axis (0.6 and 0.7) to CI matrix 0.6.x uses openid_connect 1.x (httpclient, no faraday dep) so host apps still on faraday 1.x can stay on this line. 0.7.x uses openid_connect 2.x (faraday 2.x). Both must keep working since the gemspec floor is >= 0.6. OOIDC env var overrides the omniauth_openid_connect version in the Gemfile; CI exercises both lines across the Ruby/Rails/AA matrix. --- .github/workflows/ci.yml | 6 +++++- Gemfile | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c7a525..eaf1c8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ permissions: jobs: test: - name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / AA ${{ matrix.activeadmin }} + name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / AA ${{ matrix.activeadmin }} / OOIDC ${{ matrix.omniauth_openid_connect }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -17,12 +17,16 @@ jobs: ruby: ['3.2', '3.3', '3.4'] rails: ['7.2.0', '8.0.0'] activeadmin: ['3.5.0'] + # 0.6.x → openid_connect 1.x (httpclient, no faraday dep). + # 0.7.x → openid_connect 2.x (faraday 2.x). + omniauth_openid_connect: ['0.6.0', '0.7.0'] # AA 3.4.0 is excluded: it pins devise < 5, which is # incompatible with the devise >= 4.9 floor this gem needs. # Support starts at ActiveAdmin 3.5, which lifted that cap. env: RAILS: ${{ matrix.rails }} AA: ${{ matrix.activeadmin }} + OOIDC: ${{ matrix.omniauth_openid_connect }} steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 diff --git a/Gemfile b/Gemfile index 9d727e5..34184fc 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,11 @@ default_activeadmin_version = "3.5.0" rails_version = ENV.fetch("RAILS", default_rails_version) activeadmin_version = ENV.fetch("AA", default_activeadmin_version) +# 0.6.x uses openid_connect 1.x (httpclient, no faraday) — required for +# host apps still on faraday 1.x. 0.7.x uses openid_connect 2.x (faraday 2.x). +ooidc_version = ENV["OOIDC"] gem "rails", "~> #{rails_version}" gem "activerecord", "~> #{rails_version}" gem "activeadmin", "~> #{activeadmin_version}" +gem "omniauth_openid_connect", "~> #{ooidc_version}" if ooidc_version From 85b3df368d7c72a9664959feb4eb397cd1ed0537 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 19:59:55 +0200 Subject: [PATCH 6/7] Include 0.8.0 in omniauth_openid_connect matrix --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaf1c8a..48e5177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: rails: ['7.2.0', '8.0.0'] activeadmin: ['3.5.0'] # 0.6.x → openid_connect 1.x (httpclient, no faraday dep). - # 0.7.x → openid_connect 2.x (faraday 2.x). - omniauth_openid_connect: ['0.6.0', '0.7.0'] + # 0.7.x / 0.8.x → openid_connect 2.x (faraday 2.x). + omniauth_openid_connect: ['0.6.0', '0.7.0', '0.8.0'] # AA 3.4.0 is excluded: it pins devise < 5, which is # incompatible with the devise >= 4.9 floor this gem needs. # Support starts at ActiveAdmin 3.5, which lifted that cap. From 81e4443588c266b554d131256279fa35a15e48ca Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 20:22:32 +0200 Subject: [PATCH 7/7] Bump devise floor to 5.0 and drop Rails 8 route-loading workaround Devise 5.0 wraps Devise.mappings with reload_routes_unless_loaded (heartcombo/devise#5728) so OmniAuth's failure handler resolves the scope correctly under Rails 8 lazy route loading without engine-side workarounds. Removes: engine after_initialize hook, capybara dev dep, the Rails 8 sanity spec that existed only to cover the bug Devise now handles upstream. --- activeadmin-oidc.gemspec | 6 ++-- lib/activeadmin/oidc/engine.rb | 13 -------- spec/features/rails8_oidc_failure_spec.rb | 37 ----------------------- 3 files changed, 4 insertions(+), 52 deletions(-) delete mode 100644 spec/features/rails8_oidc_failure_spec.rb diff --git a/activeadmin-oidc.gemspec b/activeadmin-oidc.gemspec index 0a2a412..4326fe5 100644 --- a/activeadmin-oidc.gemspec +++ b/activeadmin-oidc.gemspec @@ -28,7 +28,10 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "activeadmin", ">= 3.5", "< 4" - spec.add_dependency "devise", ">= 4.9" + # Devise 5.0 wraps `Devise.mappings` with `reload_routes_unless_loaded` + # (heartcombo/devise#5728) so OmniAuth's failure handler works under + # Rails 8 lazy route loading without an engine-side workaround. + spec.add_dependency "devise", ">= 5.0" spec.add_dependency "omniauth", ">= 2.1" spec.add_dependency "omniauth-rails_csrf_protection", ">= 1.0" # 0.6.x → openid_connect 1.x (httpclient-based, no faraday dep). @@ -39,7 +42,6 @@ Gem::Specification.new do |spec| spec.add_dependency "rails", ">= 7.2" spec.add_development_dependency "rspec-rails", ">= 6.0" - spec.add_development_dependency "capybara", ">= 3.40" spec.add_development_dependency "webmock", ">= 3.19" spec.add_development_dependency "jwt", ">= 2.7" spec.add_development_dependency "sqlite3", ">= 1.7" diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index a7782e2..d3aeeaa 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -101,19 +101,6 @@ def controllers initializer 'activeadmin_oidc.filter_parameters' do |app| app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce] end - - # Rails 8 lazy-loads routes; OmniAuth's failure handler walks - # `Devise.mappings.find_by_path!` which sees an empty mapping - # registry and raises "Could not find a valid mapping for path - # /admin/auth/oidc", masking the real underlying error (CSRF, - # mis-issued id_token, etc). Forcing route load at after_initialize - # populates the registry before the first OmniAuth callback hits. - # `execute_unless_loaded` is a Rails 8 API; on Rails 7.x routes - # load eagerly so this is already populated. - config.after_initialize do - reloader = Rails.application.routes_reloader - reloader.execute_unless_loaded if reloader.respond_to?(:execute_unless_loaded) - end end end end diff --git a/spec/features/rails8_oidc_failure_spec.rb b/spec/features/rails8_oidc_failure_spec.rb deleted file mode 100644 index 604c74d..0000000 --- a/spec/features/rails8_oidc_failure_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require "capybara/rspec" - -# End-to-end Capybara spec for the Rails 8 OIDC failure flow. -# -# Regression context: on Rails 8 routes load lazily, and OmniAuth's -# failure handler walks `Devise.mappings.find_by_path!` which is only -# fully populated (with path-aware metadata) once `devise_for` has -# run as part of drawing routes. If a host app's model isn't yet -# autoloaded and routes haven't been drawn, the very first failed -# OIDC callback raises "Could not find a valid mapping for path -# /admin/auth/oidc", masking the real underlying error. -# -# The engine fixes this with -# `config.after_initialize { routes_reloader.execute_unless_loaded }`. -# This spec exercises the failure flow end-to-end on Rails 8 to guard -# against regressions to that path. -# -# Skipped on Rails 7.x where routes load eagerly and -# `execute_unless_loaded` doesn't exist. -RSpec.feature "Rails 8 OIDC failure callback", type: :feature do - before do - skip "Rails 8+ only" if Rails::VERSION::MAJOR < 8 - - OmniAuth.config.test_mode = true - OmniAuth.config.mock_auth[:oidc] = :invalid_credentials - end - - after { OmniAuth.config.mock_auth[:oidc] = nil } - - scenario "an OIDC strategy failure redirects to /admin/login" do - visit "/admin/auth/oidc" - expect(page).to have_current_path("/admin/login") - end -end