From b967a6593376d0da087f9cc44c8815d374b0312c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 21:34:41 +0200 Subject: [PATCH 01/12] Support OIDC-only mode without :database_authenticatable When the host AdminUser model uses devise :omniauthable alone (no :database_authenticatable), Devise doesn't mount session routes, so ActiveAdmin's redirect to new_admin_user_session_path 404s. Engine now detects this configuration via Engine.oidc_only? and mounts GET /admin/login + DELETE /admin/logout under the existing devise scope. /admin/login renders the same SSO landing view; /admin/logout signs out via warden. Backwards compatible: when :database_authenticatable is present, the auto-mount is skipped and Devise's own routes win. Dummy app now exercises OIDC-only mode (no encrypted_password column), proving the new path end-to-end. README updated with both setup options. --- README.md | 17 ++++++++ .../devise/omniauth_callbacks_controller.rb | 14 +++++++ .../oidc/devise/sessions_controller.rb | 31 ++++++++++++++ lib/activeadmin/oidc/engine.rb | 38 ++++++++++++++++- spec/dummy/app/models/admin_user.rb | 2 +- spec/dummy/db/schema.rb | 1 - spec/unit/engine_spec.rb | 42 +++++++++++++++++++ 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 app/controllers/active_admin/oidc/devise/sessions_controller.rb create mode 100644 spec/unit/engine_spec.rb diff --git a/README.md b/README.md index 351eae6..409924d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ Without these, `/admin` is public to anyone and the utility navigation (includin ### 2. `app/models/admin_user.rb` +For SSO-only setups (recommended — no local password login at all): + +```ruby +class AdminUser < ApplicationRecord + devise :omniauthable, omniauth_providers: [:oidc] + + serialize :oidc_raw_info, coder: JSON +end +``` + +The engine auto-mounts `/admin/login` (SSO landing page) and `/admin/logout` so ActiveAdmin's login link still resolves, even though Devise's `:database_authenticatable` isn't installed. The `encrypted_password` column is **not** required. + +If you also want password login alongside SSO, add the usual modules and column: + ```ruby class AdminUser < ApplicationRecord devise :database_authenticatable, @@ -46,6 +60,8 @@ class AdminUser < ApplicationRecord end ``` +Devise then mounts the session routes itself and the engine's auto-mount becomes a no-op. + ### 3. `config/initializers/activeadmin_oidc.rb` (generated) Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below. @@ -57,6 +73,7 @@ The gem's Rails engine handles several things so host apps don't have to: * **OmniAuth strategy registration** — the engine registers the `:openid_connect` strategy with Devise automatically based on your `ActiveAdmin::Oidc` configuration. You do **not** need to add `config.omniauth` or `config.omniauth_path_prefix` to `devise.rb`. * **Callback controller** — the engine patches `ActiveAdmin::Devise.controllers` to route OmniAuth callbacks to the gem's controller. No manual `controllers: { omniauth_callbacks: ... }` needed in `routes.rb`. * **Login view override** — the engine prepends an SSO-only login page (no email/password fields) to the sessions controller's view path. If your host app ships its own `app/views/active_admin/devise/sessions/new.html.erb`, the gem detects it and backs off — your view wins. +* **OIDC-only session routes** — when the host's AdminUser model omits `:database_authenticatable`, the engine mounts its own `GET /admin/login` (renders the SSO landing page) and `DELETE /admin/logout` under the existing `devise_scope :admin_user`. Without this, ActiveAdmin's redirect to `new_admin_user_session_path` would 404 because Devise only generates those routes from `:database_authenticatable`. * **Path prefix** — the engine sets `Devise.omniauth_path_prefix` and `OmniAuth.config.path_prefix` to `/admin/auth` so the middleware intercepts requests under ActiveAdmin's mount point. Compatible with Rails 7.2+ and Rails 8's lazy route loading. * **Parameter filtering** — `code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` are added to `Rails.application.config.filter_parameters`. diff --git a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb index f848a6a..89d4984 100644 --- a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +++ b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb @@ -73,6 +73,20 @@ def failure def after_sign_in_path_for(resource) stored_location_for(resource) || '/admin' end + + # Devise's default `after_omniauth_failure_path_for` calls + # `new_session_path(scope)`, a URL helper that only gets + # generated when :database_authenticatable mounts session + # routes. In OIDC-only mode the engine mounts the equivalent + # `new_admin_user_session_path` manually; use it if defined, + # fall back to the conventional ActiveAdmin login URL. + def after_omniauth_failure_path_for(scope) + if respond_to?(:new_admin_user_session_path) + new_admin_user_session_path + else + super + end + end end end end diff --git a/app/controllers/active_admin/oidc/devise/sessions_controller.rb b/app/controllers/active_admin/oidc/devise/sessions_controller.rb new file mode 100644 index 0000000..741e09b --- /dev/null +++ b/app/controllers/active_admin/oidc/devise/sessions_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ActiveAdmin + module Oidc + module Devise + # Mounted by the engine only when the host's AdminUser model + # omits :database_authenticatable. Provides the GET /admin/login + # landing page (the SSO button) and DELETE /admin/logout, which + # Devise normally mounts as a side effect of the database + # authentication module. + # + # POST /admin/login (password sign-in) is intentionally not + # provided — without :database_authenticatable there is no + # password to verify. + class SessionsController < ::ActiveAdmin::Devise::SessionsController + # Devise's `new` builds `resource_class.new(sign_in_params)` + # and calls `clean_up_passwords` — both rely on + # :database_authenticatable being mixed in. We just need to + # render the SSO landing view, so skip the parent logic. + def new + self.resource = resource_class.new + render template: 'active_admin/devise/sessions/new' + end + + # Inherit destroy from Devise::SessionsController — it signs + # out via warden, which works regardless of authentication + # modules. + end + end + end +end diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index d3aeeaa..51794e6 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -11,11 +11,27 @@ class Engine < ::Rails::Engine # Used to gate controller registration and view overrides so the # gem is a no-op when OIDC is not enabled on the model. def self.oidc_enabled? - admin_class = ActiveAdmin::Oidc.config.admin_user_class - klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class + klass = admin_user_class klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable) end + # True when OIDC is the *only* authentication mechanism: model + # includes :omniauthable but not :database_authenticatable. In + # that case Devise does not mount session routes (no password to + # log in with), so we mount our own GET /admin/login + DELETE + # /admin/logout so ActiveAdmin's login redirect still resolves. + def self.oidc_only? + return false unless oidc_enabled? + + modules = admin_user_class.devise_modules + !modules.include?(:database_authenticatable) + end + + def self.admin_user_class + admin_class = ActiveAdmin::Oidc.config.admin_user_class + admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class + end + ControllersPatch = Module.new do def controllers result = super @@ -101,6 +117,24 @@ def controllers initializer 'activeadmin_oidc.filter_parameters' do |app| app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce] end + + # When the host's AdminUser model omits :database_authenticatable + # there are no session routes from Devise, so ActiveAdmin's login + # redirect to `new_admin_user_session_path` 404s. Mount our own + # /admin/login + /admin/logout in the same devise scope so the + # helpers and routes exist and render the SSO landing page. + initializer 'activeadmin_oidc.mount_oidc_only_routes' do |app| + app.config.to_prepare do + next unless Engine.oidc_only? + + app.routes.append do + devise_scope :admin_user do + get '/admin/login', to: 'active_admin/oidc/devise/sessions#new', as: :new_admin_user_session + delete '/admin/logout', to: 'active_admin/oidc/devise/sessions#destroy', as: :destroy_admin_user_session + end + end + end + end end end end diff --git a/spec/dummy/app/models/admin_user.rb b/spec/dummy/app/models/admin_user.rb index dd6ebaf..09e66eb 100644 --- a/spec/dummy/app/models/admin_user.rb +++ b/spec/dummy/app/models/admin_user.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AdminUser < ApplicationRecord - devise :database_authenticatable, :omniauthable, + devise :omniauthable, omniauth_providers: [:oidc] validates :email, presence: true diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index be519a3..0cab485 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -4,7 +4,6 @@ create_table :admin_users, force: :cascade do |t| t.string :email t.string :username - t.string :encrypted_password, default: "", null: false t.string :provider t.string :uid t.text :oidc_raw_info diff --git a/spec/unit/engine_spec.rb b/spec/unit/engine_spec.rb new file mode 100644 index 0000000..b3f387a --- /dev/null +++ b/spec/unit/engine_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Unit coverage for `ActiveAdmin::Oidc::Engine.{oidc_enabled?, oidc_only?}`. +# These predicates gate controller registration, view overrides, and the +# fallback `/admin/login` route mount, so getting them wrong silently +# disables (or doubly-mounts) parts of the integration. +RSpec.describe ActiveAdmin::Oidc::Engine do + before { allow(ActiveAdmin::Oidc.config).to receive(:admin_user_class).and_return(double_klass) } + + let(:double_klass) { double("AdminUser", devise_modules: modules).tap { |d| allow(d).to receive(:respond_to?).with(:devise_modules).and_return(true) } } + + describe ".oidc_enabled?" do + context "model includes :omniauthable" do + let(:modules) { %i[database_authenticatable omniauthable] } + it { expect(described_class.oidc_enabled?).to be true } + end + + context "model lacks :omniauthable" do + let(:modules) { %i[database_authenticatable] } + it { expect(described_class.oidc_enabled?).to be false } + end + end + + describe ".oidc_only?" do + context "model has :omniauthable only" do + let(:modules) { %i[omniauthable] } + it { expect(described_class.oidc_only?).to be true } + end + + context "model has both :omniauthable and :database_authenticatable" do + let(:modules) { %i[database_authenticatable omniauthable trackable] } + it { expect(described_class.oidc_only?).to be false } + end + + context "model lacks :omniauthable entirely" do + let(:modules) { %i[database_authenticatable] } + it { expect(described_class.oidc_only?).to be false } + end + end +end From ea085224fd7a8b223d1f14c582fa43933c7562c1 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 22:09:10 +0200 Subject: [PATCH 02/12] Mount /admin/login unconditionally; drop oidc_only? gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate existed to avoid clashing with Devise's session routes when the host kept :database_authenticatable. In practice OIDC consumers either use :omniauthable alone (route mount needed) or want SSO-only UX anyway. Mixed mode (SSO + password on same login page) is exotic — recoverable/lockable/confirmable belong to the IdP under SSO, migration periods just disable :database_authenticatable while keeping the column, and a host that genuinely needs both forms is already customising the view itself. Now the engine always mounts GET /admin/login and DELETE /admin/logout. POST /admin/login (Devise password sign-in) is unaffected when :database_authenticatable is loaded — same path, different verb. --- README.md | 2 +- lib/activeadmin/oidc/engine.rb | 29 ++++++++++------------------- spec/unit/engine_spec.rb | 32 ++++++++++++-------------------- 3 files changed, 23 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 409924d..1ff039d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The gem's Rails engine handles several things so host apps don't have to: * **OmniAuth strategy registration** — the engine registers the `:openid_connect` strategy with Devise automatically based on your `ActiveAdmin::Oidc` configuration. You do **not** need to add `config.omniauth` or `config.omniauth_path_prefix` to `devise.rb`. * **Callback controller** — the engine patches `ActiveAdmin::Devise.controllers` to route OmniAuth callbacks to the gem's controller. No manual `controllers: { omniauth_callbacks: ... }` needed in `routes.rb`. * **Login view override** — the engine prepends an SSO-only login page (no email/password fields) to the sessions controller's view path. If your host app ships its own `app/views/active_admin/devise/sessions/new.html.erb`, the gem detects it and backs off — your view wins. -* **OIDC-only session routes** — when the host's AdminUser model omits `:database_authenticatable`, the engine mounts its own `GET /admin/login` (renders the SSO landing page) and `DELETE /admin/logout` under the existing `devise_scope :admin_user`. Without this, ActiveAdmin's redirect to `new_admin_user_session_path` would 404 because Devise only generates those routes from `:database_authenticatable`. +* **Session routes** — the engine mounts `GET /admin/login` (renders the SSO landing page) and `DELETE /admin/logout` under the existing `devise_scope :admin_user`. Without these, hosts that omit `:database_authenticatable` would 404 on ActiveAdmin's redirect to `new_admin_user_session_path` (Devise only generates those routes as a side effect of `:database_authenticatable`). * **Path prefix** — the engine sets `Devise.omniauth_path_prefix` and `OmniAuth.config.path_prefix` to `/admin/auth` so the middleware intercepts requests under ActiveAdmin's mount point. Compatible with Rails 7.2+ and Rails 8's lazy route loading. * **Parameter filtering** — `code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` are added to `Rails.application.config.filter_parameters`. diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 51794e6..7187353 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -15,18 +15,6 @@ def self.oidc_enabled? klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable) end - # True when OIDC is the *only* authentication mechanism: model - # includes :omniauthable but not :database_authenticatable. In - # that case Devise does not mount session routes (no password to - # log in with), so we mount our own GET /admin/login + DELETE - # /admin/logout so ActiveAdmin's login redirect still resolves. - def self.oidc_only? - return false unless oidc_enabled? - - modules = admin_user_class.devise_modules - !modules.include?(:database_authenticatable) - end - def self.admin_user_class admin_class = ActiveAdmin::Oidc.config.admin_user_class admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class @@ -118,14 +106,17 @@ def controllers app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce] end - # When the host's AdminUser model omits :database_authenticatable - # there are no session routes from Devise, so ActiveAdmin's login - # redirect to `new_admin_user_session_path` 404s. Mount our own - # /admin/login + /admin/logout in the same devise scope so the - # helpers and routes exist and render the SSO landing page. - initializer 'activeadmin_oidc.mount_oidc_only_routes' do |app| + # The gem is OIDC-first: mount our SSO landing page at /admin/login + # and a warden-based /admin/logout under the existing devise scope. + # Without these, hosts that omit :database_authenticatable have no + # session routes from Devise at all, so ActiveAdmin's redirect to + # `new_admin_user_session_path` 404s. Hosts that DO keep + # :database_authenticatable get our SSO landing on GET; Devise's + # POST /admin/login (password sign-in) is unaffected because it + # lives on the same path with a different verb. + initializer 'activeadmin_oidc.mount_oidc_sessions_routes' do |app| app.config.to_prepare do - next unless Engine.oidc_only? + next unless Engine.oidc_enabled? app.routes.append do devise_scope :admin_user do diff --git a/spec/unit/engine_spec.rb b/spec/unit/engine_spec.rb index b3f387a..fc278d5 100644 --- a/spec/unit/engine_spec.rb +++ b/spec/unit/engine_spec.rb @@ -2,14 +2,18 @@ require "rails_helper" -# Unit coverage for `ActiveAdmin::Oidc::Engine.{oidc_enabled?, oidc_only?}`. -# These predicates gate controller registration, view overrides, and the -# fallback `/admin/login` route mount, so getting them wrong silently -# disables (or doubly-mounts) parts of the integration. +# Unit coverage for `ActiveAdmin::Oidc::Engine.oidc_enabled?`. The +# predicate gates controller registration, view overrides, and the +# fallback `/admin/login` route mount, so getting it wrong silently +# disables parts of the integration. RSpec.describe ActiveAdmin::Oidc::Engine do before { allow(ActiveAdmin::Oidc.config).to receive(:admin_user_class).and_return(double_klass) } - let(:double_klass) { double("AdminUser", devise_modules: modules).tap { |d| allow(d).to receive(:respond_to?).with(:devise_modules).and_return(true) } } + let(:double_klass) do + double("AdminUser", devise_modules: modules).tap do |d| + allow(d).to receive(:respond_to?).with(:devise_modules).and_return(true) + end + end describe ".oidc_enabled?" do context "model includes :omniauthable" do @@ -17,26 +21,14 @@ it { expect(described_class.oidc_enabled?).to be true } end - context "model lacks :omniauthable" do - let(:modules) { %i[database_authenticatable] } - it { expect(described_class.oidc_enabled?).to be false } - end - end - - describe ".oidc_only?" do context "model has :omniauthable only" do let(:modules) { %i[omniauthable] } - it { expect(described_class.oidc_only?).to be true } - end - - context "model has both :omniauthable and :database_authenticatable" do - let(:modules) { %i[database_authenticatable omniauthable trackable] } - it { expect(described_class.oidc_only?).to be false } + it { expect(described_class.oidc_enabled?).to be true } end - context "model lacks :omniauthable entirely" do + context "model lacks :omniauthable" do let(:modules) { %i[database_authenticatable] } - it { expect(described_class.oidc_only?).to be false } + it { expect(described_class.oidc_enabled?).to be false } end end end From 1f7a532d220506b2d1992233700fbc7885614f87 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 23:45:17 +0200 Subject: [PATCH 03/12] Add failing Capybara spec for engine-mounted Devise scenario Reproduces the pbx-api / yeti-web pattern: AdminPanel::Engine (non-isolated) hosts devise_for :admin_users + ActiveAdmin inside its own routes, with Devise.router_name = :admin_panel pinning URL helpers to the engine. Without fixing the gem, AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path is undefined and Capybara visit to /admin/login fails because the gem currently mounts on Rails.application.routes, not the engine. Adds spec/dummy_engine/ (full second dummy app) and spec/engine/ (separate spec dir loaded by engine_rails_helper.rb). Default rspec run is unaffected via .rspec --exclude-pattern. --- .rspec | 1 + activeadmin-oidc.gemspec | 1 + spec/dummy_engine/Rakefile | 4 ++ spec/dummy_engine/app/admin/dashboard.rb | 9 ++++ .../app/assets/config/manifest.js | 3 ++ .../app/assets/javascripts/active_admin.js | 1 + .../app/assets/stylesheets/active_admin.css | 1 + .../app/controllers/application_controller.rb | 4 ++ spec/dummy_engine/app/models/admin_user.rb | 10 ++++ .../app/models/application_record.rb | 5 ++ spec/dummy_engine/config.ru | 5 ++ spec/dummy_engine/config/application.rb | 47 +++++++++++++++++++ spec/dummy_engine/config/boot.rb | 5 ++ spec/dummy_engine/config/environment.rb | 4 ++ spec/dummy_engine/config/environments/test.rb | 17 +++++++ .../config/initializers/active_admin.rb | 13 +++++ .../config/initializers/activeadmin_oidc.rb | 7 +++ .../config/initializers/devise.rb | 22 +++++++++ spec/dummy_engine/config/routes.rb | 9 ++++ spec/dummy_engine/db/schema.rb | 14 ++++++ spec/dummy_engine/lib/admin_panel.rb | 3 ++ .../lib/admin_panel/config/routes.rb | 8 ++++ spec/dummy_engine/lib/admin_panel/engine.rb | 13 +++++ spec/engine/engine_rails_helper.rb | 27 +++++++++++ .../features/engine_mounted_login_spec.rb | 32 +++++++++++++ 25 files changed, 265 insertions(+) create mode 100644 spec/dummy_engine/Rakefile create mode 100644 spec/dummy_engine/app/admin/dashboard.rb create mode 100644 spec/dummy_engine/app/assets/config/manifest.js create mode 100644 spec/dummy_engine/app/assets/javascripts/active_admin.js create mode 100644 spec/dummy_engine/app/assets/stylesheets/active_admin.css create mode 100644 spec/dummy_engine/app/controllers/application_controller.rb create mode 100644 spec/dummy_engine/app/models/admin_user.rb create mode 100644 spec/dummy_engine/app/models/application_record.rb create mode 100644 spec/dummy_engine/config.ru create mode 100644 spec/dummy_engine/config/application.rb create mode 100644 spec/dummy_engine/config/boot.rb create mode 100644 spec/dummy_engine/config/environment.rb create mode 100644 spec/dummy_engine/config/environments/test.rb create mode 100644 spec/dummy_engine/config/initializers/active_admin.rb create mode 100644 spec/dummy_engine/config/initializers/activeadmin_oidc.rb create mode 100644 spec/dummy_engine/config/initializers/devise.rb create mode 100644 spec/dummy_engine/config/routes.rb create mode 100644 spec/dummy_engine/db/schema.rb create mode 100644 spec/dummy_engine/lib/admin_panel.rb create mode 100644 spec/dummy_engine/lib/admin_panel/config/routes.rb create mode 100644 spec/dummy_engine/lib/admin_panel/engine.rb create mode 100644 spec/engine/engine_rails_helper.rb create mode 100644 spec/engine/features/engine_mounted_login_spec.rb diff --git a/.rspec b/.rspec index 7a2cc1a..dc02d90 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,4 @@ --require spec_helper --format documentation --color +--exclude-pattern "spec/engine/**/*_spec.rb,spec/isolated/**/*_spec.rb" diff --git a/activeadmin-oidc.gemspec b/activeadmin-oidc.gemspec index 917ccaa..5921b54 100644 --- a/activeadmin-oidc.gemspec +++ b/activeadmin-oidc.gemspec @@ -42,6 +42,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/dummy_engine/Rakefile b/spec/dummy_engine/Rakefile new file mode 100644 index 0000000..91e7ad7 --- /dev/null +++ b/spec/dummy_engine/Rakefile @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "config/application" +Rails.application.load_tasks diff --git a/spec/dummy_engine/app/admin/dashboard.rb b/spec/dummy_engine/app/admin/dashboard.rb new file mode 100644 index 0000000..15eb814 --- /dev/null +++ b/spec/dummy_engine/app/admin/dashboard.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +ActiveAdmin.register_page "Dashboard" do + menu priority: 1, label: proc { I18n.t("active_admin.dashboard") } + + content title: proc { I18n.t("active_admin.dashboard") } do + para "Engine dummy dashboard." + end +end diff --git a/spec/dummy_engine/app/assets/config/manifest.js b/spec/dummy_engine/app/assets/config/manifest.js new file mode 100644 index 0000000..7d52bcf --- /dev/null +++ b/spec/dummy_engine/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_directory ../javascripts .js diff --git a/spec/dummy_engine/app/assets/javascripts/active_admin.js b/spec/dummy_engine/app/assets/javascripts/active_admin.js new file mode 100644 index 0000000..32eb1ca --- /dev/null +++ b/spec/dummy_engine/app/assets/javascripts/active_admin.js @@ -0,0 +1 @@ +// No-op JS for dummy app diff --git a/spec/dummy_engine/app/assets/stylesheets/active_admin.css b/spec/dummy_engine/app/assets/stylesheets/active_admin.css new file mode 100644 index 0000000..f4a799f --- /dev/null +++ b/spec/dummy_engine/app/assets/stylesheets/active_admin.css @@ -0,0 +1 @@ +/* No-op stylesheet for dummy app */ diff --git a/spec/dummy_engine/app/controllers/application_controller.rb b/spec/dummy_engine/app/controllers/application_controller.rb new file mode 100644 index 0000000..7944f9f --- /dev/null +++ b/spec/dummy_engine/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base +end diff --git a/spec/dummy_engine/app/models/admin_user.rb b/spec/dummy_engine/app/models/admin_user.rb new file mode 100644 index 0000000..90f62a5 --- /dev/null +++ b/spec/dummy_engine/app/models/admin_user.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AdminUser < ApplicationRecord + devise :omniauthable, + omniauth_providers: [:oidc] + + validates :email, presence: true + + serialize :oidc_raw_info, coder: JSON +end diff --git a/spec/dummy_engine/app/models/application_record.rb b/spec/dummy_engine/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/spec/dummy_engine/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/spec/dummy_engine/config.ru b/spec/dummy_engine/config.ru new file mode 100644 index 0000000..345dbef --- /dev/null +++ b/spec/dummy_engine/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" +run Rails.application +Rails.application.load_server diff --git a/spec/dummy_engine/config/application.rb b/spec/dummy_engine/config/application.rb new file mode 100644 index 0000000..5b2bef9 --- /dev/null +++ b/spec/dummy_engine/config/application.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "boot" + +require "rails" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "action_mailer/railtie" +begin + require "sprockets/railtie" +rescue LoadError +end + +Bundler.require(*Rails.groups) + +require "devise" +require "activeadmin" +require "activeadmin-oidc" + +# Mimic pbx-api / yeti-web pattern: an in-tree engine (non-isolated) +# that mounts devise_for inside its own routes and pins Devise's URL +# helpers to itself via `router_name: :admin_panel`. +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "admin_panel" + +module Dummy + class Application < Rails::Application + rails_gem_version = Gem::Version.new(Rails.version) + config.load_defaults(rails_gem_version >= Gem::Version.new("8.0") ? 8.0 : 7.2) + config.eager_load = false + config.root = File.expand_path("..", __dir__) + + config.secret_key_base = "test-secret-key-base-#{"x" * 64}" + config.hosts.clear + + config.action_controller.allow_forgery_protection = false + config.session_store :cookie_store, key: "_dummy_engine_session" + + config.action_dispatch.show_exceptions = :none + config.consider_all_requests_local = true + config.active_support.to_time_preserves_timezone = :zone if config.active_support.respond_to?(:to_time_preserves_timezone=) + + config.logger = Logger.new($stdout) + config.log_level = ENV.fetch("DUMMY_LOG_LEVEL", "fatal").to_sym + end +end diff --git a/spec/dummy_engine/config/boot.rb b/spec/dummy_engine/config/boot.rb new file mode 100644 index 0000000..7865da2 --- /dev/null +++ b/spec/dummy_engine/config/boot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) + +require "bundler/setup" diff --git a/spec/dummy_engine/config/environment.rb b/spec/dummy_engine/config/environment.rb new file mode 100644 index 0000000..b3dafa1 --- /dev/null +++ b/spec/dummy_engine/config/environment.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "application" +Dummy::Application.initialize! diff --git a/spec/dummy_engine/config/environments/test.rb b/spec/dummy_engine/config/environments/test.rb new file mode 100644 index 0000000..ca39b0e --- /dev/null +++ b/spec/dummy_engine/config/environments/test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.cache_classes = true + config.eager_load = false + config.public_file_server.enabled = true + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = :none + config.action_controller.allow_forgery_protection = false + config.active_support.deprecation = :stderr + config.active_support.disallowed_deprecation = :raise + config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = { host: "www.example.com" } + config.i18n.raise_on_missing_translations = false + config.log_level = :fatal +end diff --git a/spec/dummy_engine/config/initializers/active_admin.rb b/spec/dummy_engine/config/initializers/active_admin.rb new file mode 100644 index 0000000..40ca057 --- /dev/null +++ b/spec/dummy_engine/config/initializers/active_admin.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +ActiveAdmin.setup do |config| + config.site_title = "Dummy Engine" + config.authentication_method = :authenticate_admin_user! + config.current_user_method = :current_admin_user + config.logout_link_path = :destroy_admin_user_session_path + + config.root_to = "dashboard#index" + config.comments = false + + config.default_namespace = :admin +end diff --git a/spec/dummy_engine/config/initializers/activeadmin_oidc.rb b/spec/dummy_engine/config/initializers/activeadmin_oidc.rb new file mode 100644 index 0000000..81345e0 --- /dev/null +++ b/spec/dummy_engine/config/initializers/activeadmin_oidc.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + c.on_login = ->(_admin_user, _claims) { true } +end diff --git a/spec/dummy_engine/config/initializers/devise.rb b/spec/dummy_engine/config/initializers/devise.rb new file mode 100644 index 0000000..34a3924 --- /dev/null +++ b/spec/dummy_engine/config/initializers/devise.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +Devise.setup do |config| + config.mailer_sender = 'please-change-me@example.com' + require 'devise/orm/active_record' + + config.case_insensitive_keys = [:email] + config.strip_whitespace_keys = [:email] + config.skip_session_storage = [:http_auth] + config.stretches = 1 + config.reconfirmable = true + config.expire_all_remember_me_on_sign_out = true + config.password_length = 6..128 + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + config.reset_password_within = 6.hours + config.sign_out_via = :delete + + # router_name pins Devise's URL helpers to AdminPanel::Engine instead + # of main_app. Without this, *_session_path helpers would be looked + # up on Rails.application.routes.url_helpers, not the engine's. + config.router_name = :admin_panel +end diff --git a/spec/dummy_engine/config/routes.rb b/spec/dummy_engine/config/routes.rb new file mode 100644 index 0000000..f2cee6a --- /dev/null +++ b/spec/dummy_engine/config/routes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Non-isolated AdminPanel::Engine: its routes (devise_for + + # ActiveAdmin) get drawn into AdminPanel::Engine.routes, which is + # itself a route set discovered by Rails::Engine railtie. No + # explicit `mount` needed — that would conflict because the engine + # registers itself once already. +end diff --git a/spec/dummy_engine/db/schema.rb b/spec/dummy_engine/db/schema.rb new file mode 100644 index 0000000..c65b726 --- /dev/null +++ b/spec/dummy_engine/db/schema.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.2].define(version: 0) do + create_table :admin_users, force: :cascade do |t| + t.string :email + t.string :username + t.string :provider + t.string :uid + t.text :oidc_raw_info + t.timestamps + end + + add_index :admin_users, :email, unique: true +end diff --git a/spec/dummy_engine/lib/admin_panel.rb b/spec/dummy_engine/lib/admin_panel.rb new file mode 100644 index 0000000..6102e75 --- /dev/null +++ b/spec/dummy_engine/lib/admin_panel.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "admin_panel/engine" diff --git a/spec/dummy_engine/lib/admin_panel/config/routes.rb b/spec/dummy_engine/lib/admin_panel/config/routes.rb new file mode 100644 index 0000000..5b46ac6 --- /dev/null +++ b/spec/dummy_engine/lib/admin_panel/config/routes.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Mirrors the pbx-api / yeti-web pattern: devise_for + ActiveAdmin +# routes live inside the engine, pinned to it via `router_name`. +AdminPanel::Engine.routes.draw do + devise_for :admin_users, ActiveAdmin::Devise.config.merge(router_name: :admin_panel) + ActiveAdmin.routes(self) +end diff --git a/spec/dummy_engine/lib/admin_panel/engine.rb b/spec/dummy_engine/lib/admin_panel/engine.rb new file mode 100644 index 0000000..dc59f65 --- /dev/null +++ b/spec/dummy_engine/lib/admin_panel/engine.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module AdminPanel + class Engine < ::Rails::Engine + # Intentionally NOT calling `isolate_namespace AdminPanel`: this + # mirrors the pbx-api / yeti-web setup where the admin engine + # shares route helpers and the main app's url table. + # + # Routes are auto-loaded from `/config/routes.rb`, + # i.e. `spec/dummy_engine/lib/admin_panel/config/routes.rb`. + engine_name "admin_panel" + end +end diff --git a/spec/engine/engine_rails_helper.rb b/spec/engine/engine_rails_helper.rb new file mode 100644 index 0000000..a28eb00 --- /dev/null +++ b/spec/engine/engine_rails_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" + +require "spec_helper" +require_relative "../dummy_engine/config/environment" + +abort("Rails not in test mode") if Rails.env.production? + +require "rspec/rails" +require "webmock/rspec" +require "capybara/rspec" + +ActiveRecord::Schema.verbose = false +load File.expand_path("../dummy_engine/db/schema.rb", __dir__) + +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! +end + +OmniAuth.config.test_mode = true +OmniAuth.config.logger = Logger.new(File::NULL) +OmniAuth.config.request_validation_phase = ->(_env) { } +OmniAuth.config.allowed_request_methods = %i[get post] +OmniAuth.config.silence_get_warning = true if OmniAuth.config.respond_to?(:silence_get_warning=) diff --git a/spec/engine/features/engine_mounted_login_spec.rb b/spec/engine/features/engine_mounted_login_spec.rb new file mode 100644 index 0000000..09e3ea0 --- /dev/null +++ b/spec/engine/features/engine_mounted_login_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "engine_rails_helper" + +# Engine-mounted Devise + OIDC scenario (pbx-api / yeti-web pattern): +# +# - AdminPanel::Engine is a non-isolated Rails engine +# - `devise_for :admin_users, ..., router_name: :admin_panel` lives +# inside the engine's routes +# - `Devise.router_name = :admin_panel` pins Devise's URL helpers to +# `AdminPanel::Engine.routes` +# +# With AdminUser also OIDC-only (`devise :omniauthable`, no +# `:database_authenticatable`), Devise generates no session routes. +# activeadmin-oidc has to mount `/admin/login` and `/admin/logout` +# *inside the engine's route set*, otherwise +# `AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path` +# stays undefined and host-side failure apps (Devise::FailureApp +# subclasses that redirect via the engine's helpers) blow up. +RSpec.feature "Engine-mounted OIDC login", type: :feature do + it "AdminPanel::Engine.routes.url_helpers exposes new_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path + }.not_to raise_error + end + + scenario "GET /admin/login renders the SSO landing page" do + visit "/admin/login" + expect(page.status_code).to eq(200) + expect(page.body).to include(ActiveAdmin::Oidc.config.login_button_label) + end +end From ae2d52a240f0e7a059b3cce7d7ae65a2519be9cd Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 23:49:46 +0200 Subject: [PATCH 04/12] Mount session routes on the engine pointed at by Devise.router_name Hosts that mount Devise inside a Rails engine (pbx-api / yeti-web) set Devise.router_name = :admin_panel so Devise looks up URL helpers on AdminPanel::Engine.routes instead of Rails.application.routes. Previously the gem mounted /admin/login + /admin/logout unconditionally on Rails.application.routes, so AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path stayed undefined and any host-side Devise::FailureApp subclass that redirected via the engine's helpers (e.g. pbx-api's PbxDevise#redirect_url) raised NoMethodError. New Engine.session_routes_target(app) resolves the right route set by walking Rails::Engine.subclasses for the one whose engine_name matches Devise.available_router_name; falls back to Rails.application.routes when router_name is unset or :main_app. --- lib/activeadmin/oidc/engine.rb | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 7187353..75c3975 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -20,6 +20,21 @@ def self.admin_user_class admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class end + # Returns the route set our session routes should be appended to. + # Follows `Devise.available_router_name` (set by the host via + # `Devise.router_name = :foo`) so that engine-mounted Devise + # setups see the helpers on the right engine's url_helpers. + def self.session_routes_target(app) + router_name = ::Devise.available_router_name + return app.routes if router_name.blank? || router_name.to_sym == :main_app + + engine_class = ::Rails::Engine.subclasses.find do |klass| + klass.engine_name.to_sym == router_name.to_sym + end + + engine_class ? engine_class.routes : app.routes + end + ControllersPatch = Module.new do def controllers result = super @@ -114,11 +129,18 @@ def controllers # :database_authenticatable get our SSO landing on GET; Devise's # POST /admin/login (password sign-in) is unaffected because it # lives on the same path with a different verb. + # + # Mount target follows `Devise.available_router_name`: hosts that + # mount Devise inside an engine (pbx-api / yeti-web pattern) set + # `Devise.router_name = :admin_panel`, which pins Devise URL + # helpers to `AdminPanel::Engine.routes`. We mount in the same + # route set so `.routes.url_helpers.new_admin_user_session_path` + # resolves. Defaults to `Rails.application.routes` when unset. initializer 'activeadmin_oidc.mount_oidc_sessions_routes' do |app| app.config.to_prepare do next unless Engine.oidc_enabled? - app.routes.append do + Engine.session_routes_target(app).append do devise_scope :admin_user do get '/admin/login', to: 'active_admin/oidc/devise/sessions#new', as: :new_admin_user_session delete '/admin/logout', to: 'active_admin/oidc/devise/sessions#destroy', as: :destroy_admin_user_session From f2e6312f7ee7069722207ca7840df844261089b6 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 27 May 2026 23:51:05 +0200 Subject: [PATCH 05/12] CI: run engine-mounted-Devise specs in a separate step spec/engine/ uses spec/dummy_engine/ via a uniquely-named engine_rails_helper.rb. Default rspec excludes it; CI runs it as its own step so the second dummy app boots in a fresh process. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48e5177..540e1b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,7 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Run tests + - name: Run default specs (dummy app) run: bundle exec rspec spec + - name: Run engine-mounted-Devise specs (dummy_engine app) + run: bundle exec rspec --options /dev/null --require spec_helper -I spec/engine spec/engine From 6d297f27a7499dae19b7d935327ba701f671260c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Thu, 28 May 2026 08:43:03 +0200 Subject: [PATCH 06/12] Support isolated-engine mount via configurable login_path/logout_path Adds config.login_path (default /admin/login) and config.logout_path (default /admin/logout). The engine reads them when mounting session routes, so hosts whose Devise lives inside an isolated Rails engine can configure engine-relative paths (e.g. /login when the engine is mounted at /admin) and avoid mount-prefix duplication that would otherwise turn /admin/login into /admin/admin/login. Adds spec/dummy_isolated/ (third dummy app exercising isolate_namespace AdminPanel mounted at /admin) and spec/isolated/ specs verifying the engine's url helpers and route table see the correct paths. CI runs the isolated suite in its own step. --- .github/workflows/ci.yml | 2 + .rspec | 2 +- lib/activeadmin/oidc/configuration.rb | 7 ++- lib/activeadmin/oidc/engine.rb | 8 +++- spec/dummy_isolated/Rakefile | 4 ++ spec/dummy_isolated/app/admin/dashboard.rb | 9 ++++ .../app/assets/config/manifest.js | 3 ++ .../app/assets/javascripts/active_admin.js | 1 + .../app/assets/stylesheets/active_admin.css | 1 + .../app/controllers/application_controller.rb | 4 ++ spec/dummy_isolated/app/models/admin_user.rb | 10 ++++ .../app/models/application_record.rb | 5 ++ spec/dummy_isolated/config.ru | 5 ++ spec/dummy_isolated/config/application.rb | 47 +++++++++++++++++++ spec/dummy_isolated/config/boot.rb | 5 ++ spec/dummy_isolated/config/environment.rb | 4 ++ .../config/environments/test.rb | 17 +++++++ .../config/initializers/active_admin.rb | 13 +++++ .../config/initializers/activeadmin_oidc.rb | 13 +++++ .../config/initializers/devise.rb | 22 +++++++++ spec/dummy_isolated/config/routes.rb | 9 ++++ spec/dummy_isolated/db/schema.rb | 14 ++++++ spec/dummy_isolated/lib/admin_panel.rb | 3 ++ .../lib/admin_panel/config/routes.rb | 9 ++++ spec/dummy_isolated/lib/admin_panel/engine.rb | 18 +++++++ .../features/engine_mounted_login_spec.rb | 24 +++++++--- .../features/isolated_engine_login_spec.rb | 44 +++++++++++++++++ spec/isolated/isolated_rails_helper.rb | 27 +++++++++++ 28 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 spec/dummy_isolated/Rakefile create mode 100644 spec/dummy_isolated/app/admin/dashboard.rb create mode 100644 spec/dummy_isolated/app/assets/config/manifest.js create mode 100644 spec/dummy_isolated/app/assets/javascripts/active_admin.js create mode 100644 spec/dummy_isolated/app/assets/stylesheets/active_admin.css create mode 100644 spec/dummy_isolated/app/controllers/application_controller.rb create mode 100644 spec/dummy_isolated/app/models/admin_user.rb create mode 100644 spec/dummy_isolated/app/models/application_record.rb create mode 100644 spec/dummy_isolated/config.ru create mode 100644 spec/dummy_isolated/config/application.rb create mode 100644 spec/dummy_isolated/config/boot.rb create mode 100644 spec/dummy_isolated/config/environment.rb create mode 100644 spec/dummy_isolated/config/environments/test.rb create mode 100644 spec/dummy_isolated/config/initializers/active_admin.rb create mode 100644 spec/dummy_isolated/config/initializers/activeadmin_oidc.rb create mode 100644 spec/dummy_isolated/config/initializers/devise.rb create mode 100644 spec/dummy_isolated/config/routes.rb create mode 100644 spec/dummy_isolated/db/schema.rb create mode 100644 spec/dummy_isolated/lib/admin_panel.rb create mode 100644 spec/dummy_isolated/lib/admin_panel/config/routes.rb create mode 100644 spec/dummy_isolated/lib/admin_panel/engine.rb create mode 100644 spec/isolated/features/isolated_engine_login_spec.rb create mode 100644 spec/isolated/isolated_rails_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 540e1b4..3da6900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,5 @@ jobs: run: bundle exec rspec spec - name: Run engine-mounted-Devise specs (dummy_engine app) run: bundle exec rspec --options /dev/null --require spec_helper -I spec/engine spec/engine + - name: Run isolated-engine specs (dummy_isolated app) + run: bundle exec rspec --options /dev/null --require spec_helper -I spec/isolated spec/isolated diff --git a/.rspec b/.rspec index dc02d90..a3acb71 100644 --- a/.rspec +++ b/.rspec @@ -1,4 +1,4 @@ --require spec_helper --format documentation --color ---exclude-pattern "spec/engine/**/*_spec.rb,spec/isolated/**/*_spec.rb" +--exclude-pattern "spec/engine/**/*_spec.rb,spec/isolated/**/*_spec.rb,spec/dummy_engine/**/*,spec/dummy_isolated/**/*" diff --git a/lib/activeadmin/oidc/configuration.rb b/lib/activeadmin/oidc/configuration.rb index 8533be4..5bdda41 100644 --- a/lib/activeadmin/oidc/configuration.rb +++ b/lib/activeadmin/oidc/configuration.rb @@ -11,12 +11,15 @@ class Configuration DEFAULT_ADMIN_USER_CLASS = 'AdminUser' DEFAULT_ACCESS_DENIED_MESSAGE = 'Your account has no permission to access this admin panel.' + DEFAULT_LOGIN_PATH = '/admin/login' + DEFAULT_LOGOUT_PATH = '/admin/logout' attr_accessor :issuer, :client_id, :client_secret, :scope, :redirect_uri, :login_button_label, :timeout, :identity_attribute, :identity_claim, - :access_denied_message, :on_login, :admin_user_class + :access_denied_message, :on_login, :admin_user_class, + :login_path, :logout_path def initialize reset! @@ -34,6 +37,8 @@ def reset! @identity_claim = DEFAULT_IDENTITY_CLAIM @access_denied_message = DEFAULT_ACCESS_DENIED_MESSAGE @admin_user_class = DEFAULT_ADMIN_USER_CLASS + @login_path = DEFAULT_LOGIN_PATH + @logout_path = DEFAULT_LOGOUT_PATH @on_login = nil @pkce_override = nil self diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 75c3975..632eb3e 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -140,10 +140,14 @@ def controllers app.config.to_prepare do next unless Engine.oidc_enabled? + cfg = ActiveAdmin::Oidc.config + login_path = cfg.login_path + logout_path = cfg.logout_path + Engine.session_routes_target(app).append do devise_scope :admin_user do - get '/admin/login', to: 'active_admin/oidc/devise/sessions#new', as: :new_admin_user_session - delete '/admin/logout', to: 'active_admin/oidc/devise/sessions#destroy', as: :destroy_admin_user_session + get login_path, to: 'active_admin/oidc/devise/sessions#new', as: :new_admin_user_session + delete logout_path, to: 'active_admin/oidc/devise/sessions#destroy', as: :destroy_admin_user_session end end end diff --git a/spec/dummy_isolated/Rakefile b/spec/dummy_isolated/Rakefile new file mode 100644 index 0000000..91e7ad7 --- /dev/null +++ b/spec/dummy_isolated/Rakefile @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "config/application" +Rails.application.load_tasks diff --git a/spec/dummy_isolated/app/admin/dashboard.rb b/spec/dummy_isolated/app/admin/dashboard.rb new file mode 100644 index 0000000..15eb814 --- /dev/null +++ b/spec/dummy_isolated/app/admin/dashboard.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +ActiveAdmin.register_page "Dashboard" do + menu priority: 1, label: proc { I18n.t("active_admin.dashboard") } + + content title: proc { I18n.t("active_admin.dashboard") } do + para "Engine dummy dashboard." + end +end diff --git a/spec/dummy_isolated/app/assets/config/manifest.js b/spec/dummy_isolated/app/assets/config/manifest.js new file mode 100644 index 0000000..7d52bcf --- /dev/null +++ b/spec/dummy_isolated/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_directory ../javascripts .js diff --git a/spec/dummy_isolated/app/assets/javascripts/active_admin.js b/spec/dummy_isolated/app/assets/javascripts/active_admin.js new file mode 100644 index 0000000..32eb1ca --- /dev/null +++ b/spec/dummy_isolated/app/assets/javascripts/active_admin.js @@ -0,0 +1 @@ +// No-op JS for dummy app diff --git a/spec/dummy_isolated/app/assets/stylesheets/active_admin.css b/spec/dummy_isolated/app/assets/stylesheets/active_admin.css new file mode 100644 index 0000000..f4a799f --- /dev/null +++ b/spec/dummy_isolated/app/assets/stylesheets/active_admin.css @@ -0,0 +1 @@ +/* No-op stylesheet for dummy app */ diff --git a/spec/dummy_isolated/app/controllers/application_controller.rb b/spec/dummy_isolated/app/controllers/application_controller.rb new file mode 100644 index 0000000..7944f9f --- /dev/null +++ b/spec/dummy_isolated/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base +end diff --git a/spec/dummy_isolated/app/models/admin_user.rb b/spec/dummy_isolated/app/models/admin_user.rb new file mode 100644 index 0000000..90f62a5 --- /dev/null +++ b/spec/dummy_isolated/app/models/admin_user.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AdminUser < ApplicationRecord + devise :omniauthable, + omniauth_providers: [:oidc] + + validates :email, presence: true + + serialize :oidc_raw_info, coder: JSON +end diff --git a/spec/dummy_isolated/app/models/application_record.rb b/spec/dummy_isolated/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/spec/dummy_isolated/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/spec/dummy_isolated/config.ru b/spec/dummy_isolated/config.ru new file mode 100644 index 0000000..345dbef --- /dev/null +++ b/spec/dummy_isolated/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" +run Rails.application +Rails.application.load_server diff --git a/spec/dummy_isolated/config/application.rb b/spec/dummy_isolated/config/application.rb new file mode 100644 index 0000000..5b2bef9 --- /dev/null +++ b/spec/dummy_isolated/config/application.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "boot" + +require "rails" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "action_mailer/railtie" +begin + require "sprockets/railtie" +rescue LoadError +end + +Bundler.require(*Rails.groups) + +require "devise" +require "activeadmin" +require "activeadmin-oidc" + +# Mimic pbx-api / yeti-web pattern: an in-tree engine (non-isolated) +# that mounts devise_for inside its own routes and pins Devise's URL +# helpers to itself via `router_name: :admin_panel`. +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "admin_panel" + +module Dummy + class Application < Rails::Application + rails_gem_version = Gem::Version.new(Rails.version) + config.load_defaults(rails_gem_version >= Gem::Version.new("8.0") ? 8.0 : 7.2) + config.eager_load = false + config.root = File.expand_path("..", __dir__) + + config.secret_key_base = "test-secret-key-base-#{"x" * 64}" + config.hosts.clear + + config.action_controller.allow_forgery_protection = false + config.session_store :cookie_store, key: "_dummy_engine_session" + + config.action_dispatch.show_exceptions = :none + config.consider_all_requests_local = true + config.active_support.to_time_preserves_timezone = :zone if config.active_support.respond_to?(:to_time_preserves_timezone=) + + config.logger = Logger.new($stdout) + config.log_level = ENV.fetch("DUMMY_LOG_LEVEL", "fatal").to_sym + end +end diff --git a/spec/dummy_isolated/config/boot.rb b/spec/dummy_isolated/config/boot.rb new file mode 100644 index 0000000..7865da2 --- /dev/null +++ b/spec/dummy_isolated/config/boot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) + +require "bundler/setup" diff --git a/spec/dummy_isolated/config/environment.rb b/spec/dummy_isolated/config/environment.rb new file mode 100644 index 0000000..b3dafa1 --- /dev/null +++ b/spec/dummy_isolated/config/environment.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "application" +Dummy::Application.initialize! diff --git a/spec/dummy_isolated/config/environments/test.rb b/spec/dummy_isolated/config/environments/test.rb new file mode 100644 index 0000000..ca39b0e --- /dev/null +++ b/spec/dummy_isolated/config/environments/test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.cache_classes = true + config.eager_load = false + config.public_file_server.enabled = true + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = :none + config.action_controller.allow_forgery_protection = false + config.active_support.deprecation = :stderr + config.active_support.disallowed_deprecation = :raise + config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = { host: "www.example.com" } + config.i18n.raise_on_missing_translations = false + config.log_level = :fatal +end diff --git a/spec/dummy_isolated/config/initializers/active_admin.rb b/spec/dummy_isolated/config/initializers/active_admin.rb new file mode 100644 index 0000000..40ca057 --- /dev/null +++ b/spec/dummy_isolated/config/initializers/active_admin.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +ActiveAdmin.setup do |config| + config.site_title = "Dummy Engine" + config.authentication_method = :authenticate_admin_user! + config.current_user_method = :current_admin_user + config.logout_link_path = :destroy_admin_user_session_path + + config.root_to = "dashboard#index" + config.comments = false + + config.default_namespace = :admin +end diff --git a/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb b/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb new file mode 100644 index 0000000..819f45c --- /dev/null +++ b/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + c.on_login = ->(_admin_user, _claims) { true } + + # Isolated engine mounted at /admin: paths inside the engine route + # set are prefixed by the mount path, so the default `/admin/login` + # would become `/admin/admin/login`. Use relative paths instead. + c.login_path = "/login" + c.logout_path = "/logout" +end diff --git a/spec/dummy_isolated/config/initializers/devise.rb b/spec/dummy_isolated/config/initializers/devise.rb new file mode 100644 index 0000000..34a3924 --- /dev/null +++ b/spec/dummy_isolated/config/initializers/devise.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +Devise.setup do |config| + config.mailer_sender = 'please-change-me@example.com' + require 'devise/orm/active_record' + + config.case_insensitive_keys = [:email] + config.strip_whitespace_keys = [:email] + config.skip_session_storage = [:http_auth] + config.stretches = 1 + config.reconfirmable = true + config.expire_all_remember_me_on_sign_out = true + config.password_length = 6..128 + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + config.reset_password_within = 6.hours + config.sign_out_via = :delete + + # router_name pins Devise's URL helpers to AdminPanel::Engine instead + # of main_app. Without this, *_session_path helpers would be looked + # up on Rails.application.routes.url_helpers, not the engine's. + config.router_name = :admin_panel +end diff --git a/spec/dummy_isolated/config/routes.rb b/spec/dummy_isolated/config/routes.rb new file mode 100644 index 0000000..460f474 --- /dev/null +++ b/spec/dummy_isolated/config/routes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Routes are re-drawn during boot for isolated engines; guard the + # mount so the named helper isn't re-registered on the second pass. + unless Rails.application.routes.named_routes.key?(:admin_panel) + mount AdminPanel::Engine => "/admin" + end +end diff --git a/spec/dummy_isolated/db/schema.rb b/spec/dummy_isolated/db/schema.rb new file mode 100644 index 0000000..c65b726 --- /dev/null +++ b/spec/dummy_isolated/db/schema.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.2].define(version: 0) do + create_table :admin_users, force: :cascade do |t| + t.string :email + t.string :username + t.string :provider + t.string :uid + t.text :oidc_raw_info + t.timestamps + end + + add_index :admin_users, :email, unique: true +end diff --git a/spec/dummy_isolated/lib/admin_panel.rb b/spec/dummy_isolated/lib/admin_panel.rb new file mode 100644 index 0000000..6102e75 --- /dev/null +++ b/spec/dummy_isolated/lib/admin_panel.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "admin_panel/engine" diff --git a/spec/dummy_isolated/lib/admin_panel/config/routes.rb b/spec/dummy_isolated/lib/admin_panel/config/routes.rb new file mode 100644 index 0000000..678291b --- /dev/null +++ b/spec/dummy_isolated/lib/admin_panel/config/routes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Isolated engine variant: devise_for + ActiveAdmin live inside the +# engine. Mount-path prefixing applies — paths declared here become +# `/admin/` after `mount AdminPanel::Engine => '/admin'` in the +# main app routes. +AdminPanel::Engine.routes.draw do + devise_for :admin_users, ActiveAdmin::Devise.config.merge(router_name: :admin_panel) +end diff --git a/spec/dummy_isolated/lib/admin_panel/engine.rb b/spec/dummy_isolated/lib/admin_panel/engine.rb new file mode 100644 index 0000000..7c97c95 --- /dev/null +++ b/spec/dummy_isolated/lib/admin_panel/engine.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module AdminPanel + class Engine < ::Rails::Engine + # Isolated: URL helpers are namespaced under the engine + # (`admin_panel.foo_path`), engine routes do NOT merge into + # main_app helpers, and the engine's mount path PREFIXES every + # path declared inside. + # + # Hosts using this layout must set ActiveAdmin::Oidc.config.login_path + # relative to the engine mount (e.g. `'/login'` when the engine + # is mounted at `/admin` — otherwise the default `/admin/login` + # becomes `/admin/admin/login` after mount prefixing). + isolate_namespace AdminPanel + + engine_name "admin_panel" + end +end diff --git a/spec/engine/features/engine_mounted_login_spec.rb b/spec/engine/features/engine_mounted_login_spec.rb index 09e3ea0..01a58bb 100644 --- a/spec/engine/features/engine_mounted_login_spec.rb +++ b/spec/engine/features/engine_mounted_login_spec.rb @@ -16,17 +16,29 @@ # *inside the engine's route set*, otherwise # `AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path` # stays undefined and host-side failure apps (Devise::FailureApp -# subclasses that redirect via the engine's helpers) blow up. -RSpec.feature "Engine-mounted OIDC login", type: :feature do +# subclasses that redirect via the engine's helpers, e.g. pbx-api's +# `PbxDevise#redirect_url`) blow up with NoMethodError. +RSpec.describe "Engine-mounted OIDC sessions" do it "AdminPanel::Engine.routes.url_helpers exposes new_admin_user_session_path" do expect { AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path }.not_to raise_error end - scenario "GET /admin/login renders the SSO landing page" do - visit "/admin/login" - expect(page.status_code).to eq(200) - expect(page.body).to include(ActiveAdmin::Oidc.config.login_button_label) + it "AdminPanel::Engine.routes.url_helpers exposes destroy_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.destroy_admin_user_session_path + }.not_to raise_error + end + + it "the engine's route table contains GET /admin/login" do + paths = AdminPanel::Engine.routes.routes.map { |r| [r.verb, r.path.spec.to_s] } + expect(paths).to include(["GET", "/admin/login(.:format)"]) + end + + it "Rails.application.routes does NOT define the session helpers (they live on the engine)" do + # Sanity check: if the gem mounted on the wrong route set the + # helper would appear here instead of on the engine. + expect(Rails.application.routes.url_helpers).not_to respond_to(:new_admin_user_session_path) end end diff --git a/spec/isolated/features/isolated_engine_login_spec.rb b/spec/isolated/features/isolated_engine_login_spec.rb new file mode 100644 index 0000000..3d9f837 --- /dev/null +++ b/spec/isolated/features/isolated_engine_login_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "isolated_rails_helper" + +# Isolated-engine variant of the engine-mounted Devise scenario. +# +# AdminPanel::Engine uses `isolate_namespace AdminPanel`, mounted at +# `/admin` in main app routes. Inside the engine, devise_for registers +# `:admin_users` pinned to the engine via `router_name: :admin_panel`. +# +# Key difference from non-isolated: paths declared inside the engine +# get prefixed by the mount path, so a route declared as `/admin/login` +# inside the engine would become `/admin/admin/login` after mounting. +# Hosts in this layout must therefore configure +# `ActiveAdmin::Oidc.config.login_path = '/login'` (engine-relative); +# the gem then mounts at the correct effective path. +RSpec.describe "Isolated engine OIDC sessions" do + it "AdminPanel::Engine.routes.url_helpers exposes new_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path + }.not_to raise_error + end + + it "AdminPanel::Engine.routes.url_helpers exposes destroy_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.destroy_admin_user_session_path + }.not_to raise_error + end + + it "the engine's route table contains GET /login (relative — mount prefix added later)" do + paths = AdminPanel::Engine.routes.routes.map { |r| [r.verb, r.path.spec.to_s] } + expect(paths).to include(["GET", "/login(.:format)"]) + end + + it "from the main app, GET /admin/login resolves through the mount prefix" do + main_paths = Rails.application.routes.routes.map { |r| r.path.spec.to_s } + expect(main_paths.any? { |p| p.include?("/admin") }).to be(true) + end + + it "main app routes resolve GET /admin/login through the mounted engine" do + request = ActionDispatch::Request.new("PATH_INFO" => "/admin/login", "REQUEST_METHOD" => "GET") + expect { Rails.application.routes.router.recognize(request) {} }.not_to raise_error + end +end diff --git a/spec/isolated/isolated_rails_helper.rb b/spec/isolated/isolated_rails_helper.rb new file mode 100644 index 0000000..f543376 --- /dev/null +++ b/spec/isolated/isolated_rails_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" + +require "spec_helper" +require_relative "../dummy_isolated/config/environment" + +abort("Rails not in test mode") if Rails.env.production? + +require "rspec/rails" +require "webmock/rspec" +require "capybara/rspec" + +ActiveRecord::Schema.verbose = false +load File.expand_path("../dummy_isolated/db/schema.rb", __dir__) + +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! +end + +OmniAuth.config.test_mode = true +OmniAuth.config.logger = Logger.new(File::NULL) +OmniAuth.config.request_validation_phase = ->(_env) { } +OmniAuth.config.allowed_request_methods = %i[get post] +OmniAuth.config.silence_get_warning = true if OmniAuth.config.respond_to?(:silence_get_warning=) From c55bc8dc2e1d7e165f741627c73c9c4ef287dcc8 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Thu, 28 May 2026 10:11:43 +0200 Subject: [PATCH 07/12] Ship database.yml for the dummy_engine + dummy_isolated apps Global gitignore excludes database.yml; the existing dummy app un-ignored its own copy via !/spec/dummy/config/database.yml. Add the same un-ignore for the two new dummies so CI can boot them. --- .gitignore | 2 ++ spec/dummy_engine/config/database.yml | 5 +++++ spec/dummy_isolated/config/database.yml | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 spec/dummy_engine/config/database.yml create mode 100644 spec/dummy_isolated/config/database.yml diff --git a/.gitignore b/.gitignore index 64a1e10..970778d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ # The dummy app's database.yml is checked in — override a global # ignore rule that excludes "database.yml" everywhere by default. !/spec/dummy/config/database.yml +!/spec/dummy_engine/config/database.yml +!/spec/dummy_isolated/config/database.yml diff --git a/spec/dummy_engine/config/database.yml b/spec/dummy_engine/config/database.yml new file mode 100644 index 0000000..85b7ead --- /dev/null +++ b/spec/dummy_engine/config/database.yml @@ -0,0 +1,5 @@ +test: + adapter: sqlite3 + database: ":memory:" + pool: 5 + timeout: 5000 diff --git a/spec/dummy_isolated/config/database.yml b/spec/dummy_isolated/config/database.yml new file mode 100644 index 0000000..85b7ead --- /dev/null +++ b/spec/dummy_isolated/config/database.yml @@ -0,0 +1,5 @@ +test: + adapter: sqlite3 + database: ":memory:" + pool: 5 + timeout: 5000 From 9fcd88fc651223d43ca52adc23aad6b3fcb02202 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 29 May 2026 21:25:06 +0200 Subject: [PATCH 08/12] Fix dev-reload route accumulation; drop redundant SessionsController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three intertwined routing fixes: (1) Move the session-route mount from to_prepare to after_initialize. RouteSet#clear! deliberately preserves the @append queue across reloads (railties 8.0.5 routes_reloader.rb:23-30 + actionpack route_set.rb:466-498), so to_prepare re-running in dev was enqueueing duplicate append blocks and crashing the second draw with 'Invalid route name, already in use: new_admin_user_session'. after_initialize fires once at boot, registering the single append callback exactly once. (2) Remove ActiveAdmin::Oidc::Devise::SessionsController subclass — Devise's own SessionsController#new is safe for omniauth-only models: sign_in_params returns {} when no params submitted, and clean_up_passwords in DeviseController is guarded by respond_to? which an omniauth-only model never satisfies. The route now points at ActiveAdmin::Devise::SessionsController#new directly. (3) Derive scope name and route helper names from config.admin_user_class.model_name.singular instead of hardcoded :admin_user, so hosts with User/Staff/etc. admin models work without patching. Drops the now-dead respond_to? guard in OmniauthCallbacksController#after_omniauth_failure_path_for since the helper is always defined after the mount fix. --- .../devise/omniauth_callbacks_controller.rb | 16 ++++------ .../oidc/devise/sessions_controller.rb | 31 ------------------- lib/activeadmin/oidc/engine.rb | 16 +++++++--- 3 files changed, 17 insertions(+), 46 deletions(-) delete mode 100644 app/controllers/active_admin/oidc/devise/sessions_controller.rb diff --git a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb index 89d4984..8de1062 100644 --- a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +++ b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb @@ -75,17 +75,13 @@ def after_sign_in_path_for(resource) end # Devise's default `after_omniauth_failure_path_for` calls - # `new_session_path(scope)`, a URL helper that only gets - # generated when :database_authenticatable mounts session - # routes. In OIDC-only mode the engine mounts the equivalent - # `new_admin_user_session_path` manually; use it if defined, - # fall back to the conventional ActiveAdmin login URL. + # `new_session_path(scope)`, a URL helper Devise only generates + # when :database_authenticatable mounts session routes. The + # engine mounts `new__session_path` itself (see + # `mount_oidc_sessions_routes` initializer) regardless of which + # modules are loaded, so we always route through that helper. def after_omniauth_failure_path_for(scope) - if respond_to?(:new_admin_user_session_path) - new_admin_user_session_path - else - super - end + public_send(:"new_#{scope}_session_path") end end end diff --git a/app/controllers/active_admin/oidc/devise/sessions_controller.rb b/app/controllers/active_admin/oidc/devise/sessions_controller.rb deleted file mode 100644 index 741e09b..0000000 --- a/app/controllers/active_admin/oidc/devise/sessions_controller.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module ActiveAdmin - module Oidc - module Devise - # Mounted by the engine only when the host's AdminUser model - # omits :database_authenticatable. Provides the GET /admin/login - # landing page (the SSO button) and DELETE /admin/logout, which - # Devise normally mounts as a side effect of the database - # authentication module. - # - # POST /admin/login (password sign-in) is intentionally not - # provided — without :database_authenticatable there is no - # password to verify. - class SessionsController < ::ActiveAdmin::Devise::SessionsController - # Devise's `new` builds `resource_class.new(sign_in_params)` - # and calls `clean_up_passwords` — both rely on - # :database_authenticatable being mixed in. We just need to - # render the SSO landing view, so skip the parent logic. - def new - self.resource = resource_class.new - render template: 'active_admin/devise/sessions/new' - end - - # Inherit destroy from Devise::SessionsController — it signs - # out via warden, which works regardless of authentication - # modules. - end - end - end -end diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 632eb3e..6c13e07 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -137,17 +137,23 @@ def controllers # route set so `.routes.url_helpers.new_admin_user_session_path` # resolves. Defaults to `Rails.application.routes` when unset. initializer 'activeadmin_oidc.mount_oidc_sessions_routes' do |app| - app.config.to_prepare do + # after_initialize fires once at boot. `RouteSet#clear!` deliberately + # preserves the append/prepend queues across reloads, so re-running + # this hook (as `to_prepare` would in dev) accumulates duplicate + # append callbacks and crashes the second draw with + # "Invalid route name, already in use: 'new_admin_user_session'". + app.config.after_initialize do next unless Engine.oidc_enabled? - cfg = ActiveAdmin::Oidc.config + cfg = ActiveAdmin::Oidc.config login_path = cfg.login_path logout_path = cfg.logout_path + scope_name = Engine.admin_user_class.model_name.singular.to_sym Engine.session_routes_target(app).append do - devise_scope :admin_user do - get login_path, to: 'active_admin/oidc/devise/sessions#new', as: :new_admin_user_session - delete logout_path, to: 'active_admin/oidc/devise/sessions#destroy', as: :destroy_admin_user_session + devise_scope scope_name do + get login_path, to: 'active_admin/devise/sessions#new', as: :"new_#{scope_name}_session" + delete logout_path, to: 'active_admin/devise/sessions#destroy', as: :"destroy_#{scope_name}_session" end end end From 1231c42a233bee0d5a899cc1b79bc492ec2dcfa1 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 29 May 2026 21:25:20 +0200 Subject: [PATCH 09/12] Drop spec/unit/engine_spec.rb tautology It was a mock-heavy unit test for a one-line Array#include? predicate (Engine.oidc_enabled? checks klass.devise_modules.include?(:omniauthable)). The dummy-app integration suites already exercise the predicate end-to-end via the auto-mount path. --- spec/unit/engine_spec.rb | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 spec/unit/engine_spec.rb diff --git a/spec/unit/engine_spec.rb b/spec/unit/engine_spec.rb deleted file mode 100644 index fc278d5..0000000 --- a/spec/unit/engine_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -# Unit coverage for `ActiveAdmin::Oidc::Engine.oidc_enabled?`. The -# predicate gates controller registration, view overrides, and the -# fallback `/admin/login` route mount, so getting it wrong silently -# disables parts of the integration. -RSpec.describe ActiveAdmin::Oidc::Engine do - before { allow(ActiveAdmin::Oidc.config).to receive(:admin_user_class).and_return(double_klass) } - - let(:double_klass) do - double("AdminUser", devise_modules: modules).tap do |d| - allow(d).to receive(:respond_to?).with(:devise_modules).and_return(true) - end - end - - describe ".oidc_enabled?" do - context "model includes :omniauthable" do - let(:modules) { %i[database_authenticatable omniauthable] } - it { expect(described_class.oidc_enabled?).to be true } - end - - context "model has :omniauthable only" do - let(:modules) { %i[omniauthable] } - it { expect(described_class.oidc_enabled?).to be true } - end - - context "model lacks :omniauthable" do - let(:modules) { %i[database_authenticatable] } - it { expect(described_class.oidc_enabled?).to be false } - end - end -end From 3f436b3da9165d8d7c7497ee0ca2df4163b51189 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 29 May 2026 21:25:35 +0200 Subject: [PATCH 10/12] Make RSpec auto-install opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts where OIDC is mandatory (yeti-web / pbx-api / didww-rs) were silently getting filter_run_excluding oidc_mode: true installed on every require of activeadmin/oidc/test_helpers, forcing them to either set CI_RUN_OIDC=true or strip the filter from @filter_manager — both are reverse-engineering of an undocumented side effect. test_helpers.rb now only defines the modules; hosts that want the filter machinery call ActiveAdmin::Oidc::RSpecSupport.install! explicitly. The doc block on RSpecSupport spells out both setups. --- lib/activeadmin/oidc/test_helpers.rb | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/activeadmin/oidc/test_helpers.rb b/lib/activeadmin/oidc/test_helpers.rb index a086122..8b0b0f4 100644 --- a/lib/activeadmin/oidc/test_helpers.rb +++ b/lib/activeadmin/oidc/test_helpers.rb @@ -64,14 +64,28 @@ def reset_oidc_stubs end end - # RSpec support for oidc_mode tag filtering. - # Require this file in spec_helper or rails_helper to auto-configure: + # Opt-in RSpec support for the `oidc_mode` tag. + # + # Hosts where OIDC is OPTIONAL (some envs run without an IdP) call: + # + # require "activeadmin/oidc/test_helpers" + # ActiveAdmin::Oidc::RSpecSupport.install! + # + # That installs auto-include + skips `oidc_mode: true` specs when + # `:omniauthable` isn't loaded, and toggles `CI_RUN_OIDC=true` to + # run only OIDC specs in a focused CI job. + # + # Hosts where OIDC is MANDATORY (yeti-web / pbx-api / didww-rs) + # skip the helper entirely and just include the module directly: # # require "activeadmin/oidc/test_helpers" # - # Specs tagged `oidc_mode: true` will be skipped unless the AdminUser - # model has :omniauthable loaded. Set CI_RUN_OIDC=true in your CI job - # to run only OIDC-tagged specs. + # RSpec.configure do |c| + # c.include ActiveAdmin::Oidc::TestHelpers + # c.after { reset_oidc_stubs } + # end + # + # No filter, no skip logic, no CI env var to remember. module RSpecSupport def self.install! return unless defined?(RSpec) @@ -98,6 +112,3 @@ def self.install! end end end - -# Auto-install RSpec support when required during a test run. -ActiveAdmin::Oidc::RSpecSupport.install! From 4fce328a552748089b055d92b39ce449a44ed1eb Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 29 May 2026 21:25:50 +0200 Subject: [PATCH 11/12] Document mixed-mode caveat and engine-mounted Devise setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mixed mode (:database_authenticatable + :omniauthable on the same model): clarify that Devise's GET /admin/login wins recognition because host routes draw before the gem's after_initialize append — the gem's SSO landing is a silent no-op for these hosts. Direct them to customise the sessions/new view if they want a specific landing UI. Engine-mounted Devise (yeti-web / pbx-api pattern): document Devise.router_name pinning and the isolated-engine login_path/logout_path workaround for hosts using isolate_namespace + mount Engine => '/admin'. --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ff039d..5b6bc4b 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,22 @@ class AdminUser < ApplicationRecord end ``` -Devise then mounts the session routes itself and the engine's auto-mount becomes a no-op. +In mixed mode Devise's own `devise_for` already maps `GET /admin/login` to its session controller. Because host routes are drawn before the gem's `after_initialize` append, Devise wins recognition for that path and renders its default sessions view (with both password form and OmniAuth links). The gem's auto-mount is a silent no-op — keep your custom `app/views/active_admin/devise/sessions/new.html.erb` if you want a specific landing UI. + +### Engine-mounted Devise (yeti-web / pbx-api pattern) + +If `devise_for :admin_users` lives inside a Rails engine (not the main app routes), set `Devise.router_name = :` in `config/initializers/devise.rb`. The gem reads this and mounts its session routes inside that engine's route set so `.routes.url_helpers.new__session_path` resolves correctly. + +For **isolated** engines (`isolate_namespace ...`) mounted at a prefix (e.g. `mount AdminPanel::Engine => '/admin'`), the engine prepends its mount path to every internal route. The gem's default `login_path = '/admin/login'` would then become `/admin/admin/login`. Configure engine-relative paths in `config/initializers/activeadmin_oidc.rb`: + +```ruby +ActiveAdmin::Oidc.configure do |c| + c.login_path = '/login' + c.logout_path = '/logout' +end +``` + +Non-isolated engines don't need this override. ### 3. `config/initializers/activeadmin_oidc.rb` (generated) From 7e217d69a11a1033b5fd57fece06f53dae8afa7c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 29 May 2026 21:26:04 +0200 Subject: [PATCH 12/12] Consolidate three spec suites under rake spec:all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rakefile gains spec:engine, spec:isolated and spec:all tasks. Each suite boots its own dummy Rails app and therefore must run in a separate process — spec:all shells out three times sequentially. CI replaces three hand-rolled rspec invocations with a single rake spec:all step. .rspec keeps the exclude-pattern for the default rspec invocation (so bundle exec rspec runs the dummy suite alone without booting the other two dummies) and adds an inline comment pointing developers at the rake tasks for the full run. --- .github/workflows/ci.yml | 8 ++------ .rspec | 6 +++++- Rakefile | 25 ++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3da6900..ed6c919 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,5 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Run default specs (dummy app) - run: bundle exec rspec spec - - name: Run engine-mounted-Devise specs (dummy_engine app) - run: bundle exec rspec --options /dev/null --require spec_helper -I spec/engine spec/engine - - name: Run isolated-engine specs (dummy_isolated app) - run: bundle exec rspec --options /dev/null --require spec_helper -I spec/isolated spec/isolated + - name: Run all spec suites (default + engine + isolated) + run: bundle exec rake spec:all diff --git a/.rspec b/.rspec index a3acb71..28e9c03 100644 --- a/.rspec +++ b/.rspec @@ -1,4 +1,8 @@ --require spec_helper --format documentation --color ---exclude-pattern "spec/engine/**/*_spec.rb,spec/isolated/**/*_spec.rb,spec/dummy_engine/**/*,spec/dummy_isolated/**/*" +# `bundle exec rspec` runs the default suite (spec/dummy). The engine- +# and isolated-engine suites each boot their own dummy Rails app and +# must run in separate processes — invoke them via `rake spec:engine`, +# `rake spec:isolated`, or all three with `rake spec:all`. +--exclude-pattern "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*" diff --git a/Rakefile b/Rakefile index 180946b..64ca094 100644 --- a/Rakefile +++ b/Rakefile @@ -4,7 +4,30 @@ require "bundler/gem_tasks" begin require "rspec/core/rake_task" - RSpec::Core::RakeTask.new(:spec) + + # Default spec suite — boots spec/dummy/ (main-app OIDC-only setup). + RSpec::Core::RakeTask.new(:spec) do |t| + t.exclude_pattern = "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*" + end + + namespace :spec do + # Each variant below boots a different dummy Rails app and therefore + # must run in its own process. CI invokes them as separate steps; + # locally `rake spec:all` runs them sequentially via `sh`. + desc "Run engine-mounted-Devise specs (boots spec/dummy_engine/)" + task :engine do + sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/engine spec/engine" + end + + desc "Run isolated-engine specs (boots spec/dummy_isolated/)" + task :isolated do + sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/isolated spec/isolated" + end + + desc "Run every spec suite (default + engine + isolated)" + task all: [:spec, :engine, :isolated] + end + task default: :spec rescue LoadError # rspec not available