From 54c09bf57b95172c7031d3828f4ced56ed931302 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Tue, 25 Aug 2026 14:02:54 +0300 Subject: [PATCH 1/3] Derive every mounted path from ActiveAdmin's namespace The gem hardcoded `/admin` for the post-sign-in landing page, the SSO login/logout routes and the OmniAuth prefix. A host that renames the namespace (`config.default_namespace = :backoffice`) has no /admin anywhere, so each of those either 404'd or raised. Derive them from `ActiveAdmin.application.default_namespace` instead, and resolve the post-sign-in path through ActiveAdmin's own root helper on whichever route set holds the host's Devise mapping. Engine-mounted hosts need one more distinction. Devise reuses a single setting, `Devise.omniauth_path_prefix`, both to declare its OmniAuth routes and to tell the middleware where to listen; for an engine mounted at a prefix those are different strings, so one value put the callback route at `/admin/admin/auth/oidc/callback` while the middleware redirected to `/admin/auth/oidc/callback`. Drive them separately: `path_prefix:` goes to the strategy directly (honoured over `OmniAuth.config.path_prefix`, which Devise owns), and `omniauth_route_prefix` -- defaulting to `omniauth_path_prefix`, so main-app hosts see no change -- feeds Devise's global. Registering the strategy after the host's config/initializers makes the namespace readable there, so no `after_initialize` hook or Rails 8 lazy-route workaround is needed. Covered by unit specs for the namespace derivation and a full OmniAuth round trip against spec/dummy_isolated. Co-Authored-By: Clanker --- .gitignore | 3 + .rspec | 4 +- README.md | 50 +++++++++-- Rakefile | 2 +- .../devise/omniauth_callbacks_controller.rb | 55 ++++++++---- lib/activeadmin/oidc/configuration.rb | 87 +++++++++++++++++-- lib/activeadmin/oidc/engine.rb | 46 ++++++---- .../config/initializers/activeadmin_oidc.rb | 6 ++ .../requests/isolated_engine_callback_spec.rb | 60 +++++++++++++ spec/requests/after_sign_in_path_spec.rb | 69 +++++++++++++++ .../disabled_user_persistence_spec.rb | 2 +- spec/requests/login_path_helper_spec.rb | 52 ++++------- spec/unit/configuration_spec.rb | 74 ++++++++++++++++ 13 files changed, 426 insertions(+), 84 deletions(-) create mode 100644 spec/isolated/requests/isolated_engine_callback_spec.rb create mode 100644 spec/requests/after_sign_in_path_spec.rb diff --git a/.gitignore b/.gitignore index 34bd2a2..02b2a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,9 +21,12 @@ /spec/dummy_engine/tmp/ /spec/dummy_isolated/log/ /spec/dummy_isolated/tmp/ +/spec/dummy_namespaced/log/ +/spec/dummy_namespaced/tmp/ # 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 +!/spec/dummy_namespaced/config/database.yml diff --git a/.rspec b/.rspec index 28e9c03..db77bd0 100644 --- a/.rspec +++ b/.rspec @@ -3,6 +3,6 @@ --color # `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`. +# must run in separate processes — invoke them via `rake spec:engine` +# and `rake spec:isolated`, or all three with `rake spec:all`. --exclude-pattern "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*" diff --git a/README.md b/README.md index eacf260..019f924 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,25 @@ OIDC is the only authentication mechanism — `:database_authenticatable`, `encr If `devise_for :admin_users` lives inside a Rails engine (not the main app routes), set `Devise.router_name = :` in `config/initializers/devise.rb` and pass the same option to `devise_for`. The gem reads `Devise.available_router_name` 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`: +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, so the derived `login_path` of `/admin/login` would 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' + c.login_path = '/login' + c.logout_path = '/logout' + c.omniauth_route_prefix = '/auth' end ``` -Non-isolated engines don't need this override. +`omniauth_route_prefix` is the same idea applied to the routes Devise draws for +OmniAuth. It is separate from `omniauth_path_prefix` because the two are not the +same string here: the OmniAuth middleware sits in the *application's* Rack stack +and sees `/admin/auth/oidc` with the mount prefix still attached, while the route +Devise declares for the callback is inside the engine and gets `/admin` prepended +to it. Set only `omniauth_path_prefix` and the callback route lands on +`/admin/admin/auth/oidc/callback`, so the redirect the middleware issues 404s. + +Non-isolated engines mounted at `/` don't need any of these overrides. ### 3. `config/initializers/activeadmin_oidc.rb` (generated) @@ -73,7 +82,7 @@ The gem's Rails engine handles several things so host apps don't have to: * **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. * **Session routes** — the engine mounts `GET /admin/login` (renders the SSO landing page) and `DELETE /admin/logout` under `devise_scope`, with the scope name derived from `config.admin_user_class`. Devise normally generates session routes as a side effect of `:database_authenticatable`; without that module the route helpers would not exist and ActiveAdmin's login redirect would 404. -* **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. +* **Path prefix** — the engine registers the strategy with `path_prefix: '/admin/auth'` so the middleware intercepts requests under ActiveAdmin's mount point, and sets `Devise.omniauth_path_prefix` to the prefix Devise declares its routes with. 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`. ## Configuration @@ -137,12 +146,41 @@ end | `identity_attribute` | `:email` | AdminUser column used for lookup/adoption | | `identity_claim` | `:email` | Claim key read from the id_token/userinfo | | `admin_user_class` | `"AdminUser"` | String or Class for the host's admin user model | +| `login_path` | `//login` | SSO landing page path; derived from ActiveAdmin's namespace | +| `logout_path` | `//logout` | Sign-out path; derived from ActiveAdmin's namespace | +| `omniauth_path_prefix` | `//auth` | Browser-visible path the OmniAuth middleware listens on; derived from ActiveAdmin's namespace | +| `omniauth_route_prefix` | `omniauth_path_prefix` | Prefix Devise declares its OmniAuth routes with; differs only for engine-mounted hosts | | `login_button_label` | `"Sign in with SSO"` | Label on the login-page button | | `access_denied_message` | generic | Flash shown on any denial | | `on_login` | — (required) | Authorization hook; see below | `stub_dev_env_login!` is a method, not an option — see "Stub login" below. +## ActiveAdmin's namespace + +Everything the gem mounts hangs off ActiveAdmin's namespace, and all of it is +derived from `ActiveAdmin.application.default_namespace` rather than assumed to +be `admin`. A host that renames it: + +```ruby +# config/initializers/active_admin.rb +config.default_namespace = :backoffice +``` + +gets `/backoffice/login`, `/backoffice/logout`, the OmniAuth middleware at +`/backoffice/auth`, and a post-sign-in redirect to `/backoffice` — no gem +configuration needed. ActiveAdmin's root namespace (`config.default_namespace = +false`) mounts everything at the top level: `/login`, `/auth`, `/`. + +Each is still overridable. Isolated engines *have* to override them, since the +engine's mount prefix is prepended to every path declared inside it — see +[Engine-mounted Devise](#engine-mounted-devise). + +`omniauth_route_prefix` is what the gem assigns to `Devise.omniauth_path_prefix`, +and it is skipped entirely if your app already assigned that in +`config/initializers/devise.rb`. `omniauth_path_prefix` is passed to the OmniAuth +strategy directly, so it stays correct regardless. + ## The `on_login` hook `on_login` is the **only** place authorization lives. The gem handles authentication (the user proved who they are via the IdP); deciding whether that user is allowed into the admin panel — and what they can see once they are in — is the host application's problem. The gem does not ship a role model. @@ -247,7 +285,7 @@ AdminUser.last.oidc_raw_info * A login button is added to the ActiveAdmin sessions page via a prepended view override — no templates to edit. * Clicking it POSTs to `/admin/auth/oidc` with a Rails CSRF token. The gem loads `omniauth-rails_csrf_protection` so OmniAuth 2.x delegates its authenticity check to Rails' forgery protection and `button_to` just works. -* After a successful callback the user is signed in and redirected to `/admin` (not the host app's `/`, which may not exist). +* After a successful callback the user is signed in and redirected to ActiveAdmin's namespace root (not the host app's `/`, which may not exist). The path comes from ActiveAdmin's own route helper, so a renamed `config.default_namespace` or an engine-mounted ActiveAdmin lands correctly; `/admin` is only the fallback when that helper cannot be resolved. * **Disabled/locked users are rejected.** Devise's `active_for_authentication?` is checked after provisioning but before sign-in. If your model overrides this method (e.g. to check an `enabled` flag or Devise's `:lockable` module), the guard fires on OIDC sign-in too — the user sees an appropriate flash and is redirected to the login page. * In development, `stub_dev_env_login!` repoints that same button at a local sign-in that never contacts the IdP — see "Stub login" below. * Logout goes through Devise's stock session destroy. No RP-initiated single-logout ping to the IdP — override the destroy action in your host app if you need that. diff --git a/Rakefile b/Rakefile index 64ca094..67501d3 100644 --- a/Rakefile +++ b/Rakefile @@ -25,7 +25,7 @@ begin end desc "Run every spec suite (default + engine + isolated)" - task all: [:spec, :engine, :isolated] + task all: %i[spec engine isolated] end task default: :spec 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 271b0cd..f212b47 100644 --- a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +++ b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb @@ -105,28 +105,53 @@ def provision_and_sign_in(claims, kind: 'OIDC') # sign-in instead of Devise's default (host app root). Hosts # that don't define a `/` route would otherwise hit a routing # error immediately after login, and even when `/` does exist - # it's rarely what an admin user wants to see. ActiveAdmin - # always mounts at `/admin`, so we go there directly. + # it's rarely what an admin user wants to see. def after_sign_in_path_for(resource) - stored_location_for(resource) || '/admin' + stored_location_for(resource) || active_admin_root_path + end + + # Resolved from ActiveAdmin's own route helper rather than + # assumed to be '/admin'. Two things move it: a host can rename + # the namespace (`config.default_namespace = :backoffice`), and + # an engine-mounted ActiveAdmin prefixes every path it declares + # with the engine's mount point -- so the real root can be + # '/admin/admin' or anything else entirely. Routing a signed-in + # admin to a 404 is a poor reward for a successful login. + def active_admin_root_path + send(devise_router_name(resource_name)).public_send(active_admin_root_helper) + rescue NameError, ::ActionController::UrlGenerationError + # The host has ActiveAdmin's routes somewhere we can't see (or + # hasn't drawn them at all). Its namespace prefix is the best + # remaining guess at where the admin panel lives -- except for + # the root namespace, where that prefix is '' and would make an + # empty, unredirectable Location. + ActiveAdmin::Oidc.config.active_admin_namespace_prefix.presence || '/' + end + + # ActiveAdmin names its namespace root helper `_root_path`, + # except for the root namespace where it is plain `root_path`. + def active_admin_root_helper + namespace = ActiveAdmin::Oidc.config.active_admin_namespace + namespace ? :"#{namespace}_root_path" : :root_path end # Devise's `new_session_path(scope)` is only generated when # `:database_authenticatable` is in the mapping's `used_helpers`, # so an OIDC-only model never gets it. The engine mounts - # `new__session_path` itself, but the helper lives on - # whichever route set Devise's URL helper dispatcher points at: - # the per-mapping `router_name` (set by - # `devise_for :scope, router_name: :engine`) when present, - # otherwise the global `Devise.available_router_name` - # (set by `Devise.router_name = :engine`), which defaults to - # `:main_app`. Replicate that dispatcher here so the helper is - # resolved on the right context (Rails.application proxy or - # mounted engine proxy). + # `new__session_path` itself, but on whichever route set + # `devise_router_name` resolves to. def after_omniauth_failure_path_for(scope) - router_name = ::Devise.mappings[scope].router_name || - ::Devise.available_router_name - send(router_name).public_send(:"new_#{scope}_session_path") + send(devise_router_name(scope)).public_send(:"new_#{scope}_session_path") + end + + # Devise's URL helpers live on the per-mapping `router_name` (set + # by `devise_for :scope, router_name: :engine`) when present, + # otherwise on the global `Devise.available_router_name` (set by + # `Devise.router_name = :engine`), which defaults to `:main_app`. + # Replicate that dispatcher here so helpers resolve on the right + # context (Rails.application proxy or mounted engine proxy). + def devise_router_name(scope) + ::Devise.mappings[scope]&.router_name || ::Devise.available_router_name end end end diff --git a/lib/activeadmin/oidc/configuration.rb b/lib/activeadmin/oidc/configuration.rb index eeac0f7..c491e4a 100644 --- a/lib/activeadmin/oidc/configuration.rb +++ b/lib/activeadmin/oidc/configuration.rb @@ -11,19 +11,23 @@ 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' DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS = { 'sub' => 'stub-uid', 'email' => 'stub-dev@example.com' }.freeze + # Stands in for ActiveAdmin's namespace when ActiveAdmin is not + # loaded (plain unit specs, scripts) and it therefore cannot be + # read. Everything else derives from + # `ActiveAdmin.application.default_namespace`. + FALLBACK_NAMESPACE = :admin 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, - :login_path, :logout_path + :access_denied_message, :on_login, :admin_user_class + attr_writer :login_path, :logout_path, + :omniauth_path_prefix, :omniauth_route_prefix # Readers, not writers: stub login is turned on through # `stub_dev_env_login!` so the environment check cannot be skipped. @@ -47,8 +51,10 @@ 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 + @login_path = nil + @logout_path = nil + @omniauth_path_prefix = nil + @omniauth_route_prefix = nil @on_login = nil @pkce_override = nil @stub_dev_env_login = false @@ -56,6 +62,70 @@ def reset! self end + # The paths below all hang off ActiveAdmin's namespace, which the + # host can rename (`config.default_namespace = :backoffice`) -- in + # which case there is no /admin anywhere in the app and every + # hardcoded one would 404. They are computed on read rather than in + # `reset!` because the gem's own initializer may run before the + # host's `ActiveAdmin.setup` block. + # + # `login_path` and `logout_path` are declared inside whichever route + # set holds the host's Devise mapping, so an engine-mounted host has + # to override them engine-relative -- the mount prefix is prepended + # on top of whatever is written here. + def login_path + @login_path || "#{active_admin_namespace_prefix}/login" + end + + def logout_path + @logout_path || "#{active_admin_namespace_prefix}/logout" + end + + # Where the OmniAuth middleware listens. This one is a real, + # browser-visible path: the middleware sits in the application's + # Rack stack and sees the URL before any engine mount prefix has + # been stripped. + def omniauth_path_prefix + @omniauth_path_prefix || "#{active_admin_namespace_prefix}/auth" + end + + # What Devise declares its OmniAuth request/callback routes with. + # Devise reuses a single setting for both jobs, and the two differ + # by exactly the mount prefix when `devise_for` lives inside a + # mounted engine -- so an engine-mounted host sets this + # engine-relative ('/auth'), the same way it does `login_path`. + def omniauth_route_prefix + @omniauth_route_prefix || omniauth_path_prefix + end + + # ActiveAdmin's namespace as a Symbol, or nil for the root + # namespace (`default_namespace = false`), which mounts everything + # at the top level. + def active_admin_namespace + namespace = active_admin_default_namespace + return nil if namespace.blank? || namespace.to_sym == :root + + namespace.to_sym + end + + # Narrow on purpose: `NoMethodError` is what "ActiveAdmin is not + # loaded, or not set up yet" surfaces as. Anything else -- a host + # initializer blowing up inside its own `default_namespace` + # override, say -- is a real misconfiguration and must not be + # quietly turned into a wrong path. + def active_admin_default_namespace + return FALLBACK_NAMESPACE unless defined?(::ActiveAdmin) && ::ActiveAdmin.respond_to?(:application) + + ::ActiveAdmin.application.default_namespace + rescue NoMethodError + FALLBACK_NAMESPACE + end + + def active_admin_namespace_prefix + namespace = active_admin_namespace + namespace ? "/#{namespace}" : '' + end + def pkce return @pkce_override unless @pkce_override.nil? @@ -117,7 +187,10 @@ def stub_dev_env_login_claims def login_submit_path return "#{login_path}/stub" if stub_dev_env_login_enabled? - "#{::OmniAuth.config.path_prefix}/#{Engine::PROVIDER_NAME}" + # `omniauth_path_prefix`, not `OmniAuth.config.path_prefix`: + # under an engine mount those two differ by the mount prefix, + # and this one is the browser-visible path the form posts to. + "#{omniauth_path_prefix}/#{Engine::PROVIDER_NAME}" end def validate! diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 796d3be..22f871d 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -7,6 +7,12 @@ module Oidc class Engine < ::Rails::Engine PROVIDER_NAME = :oidc + # True once the host has supplied the minimum OIDC credentials. + def self.configured? + cfg = ActiveAdmin::Oidc.config + cfg.issuer.present? && cfg.client_id.present? + end + # True when the host's AdminUser model includes :omniauthable. # Used to gate controller registration and view overrides so the # gem is a no-op when OIDC is not enabled on the model. @@ -77,21 +83,36 @@ def controllers # Automatically register the OmniAuth :openid_connect strategy with # Devise when the gem is configured, so host apps don't have to # duplicate the config.omniauth boilerplate in devise.rb. - # Runs before Devise's own initializer so the strategy is available - # when the model calls `devise :omniauthable`. - initializer 'activeadmin_oidc.register_omniauth_strategy', before: 'devise.omniauth' do + # + # Ordered after the host's config/initializers (where + # `ActiveAdmin.setup` and `Devise.setup` live, so the namespace and + # any explicit host overrides are readable) and before + # `devise.omniauth`, which both needs the strategy registered and + # builds the middleware from these args. + # + # `path_prefix` is passed per-strategy rather than left to + # `OmniAuth.config.path_prefix`, because Devise overwrites that + # global at route-draw time with `Devise.omniauth_path_prefix` -- + # the value it *declares its routes* with, which for a mounted + # engine is the same path minus the mount prefix. Setting the two + # independently through the global is impossible: Devise raises + # "Wrong OmniAuth configuration" whenever they disagree. + initializer 'activeadmin_oidc.register_omniauth_strategy', + after: :load_config_initializers, before: 'devise.omniauth' do cfg = ActiveAdmin::Oidc.config - next if cfg.issuer.blank? || cfg.client_id.blank? + next unless Engine.configured? require 'omniauth_openid_connect' - ::Devise.setup do |devise| - # ActiveAdmin mounts Devise under /admin, so OmniAuth middleware - # must intercept /admin/auth/:provider. - devise.omniauth_path_prefix ||= '/admin/auth' + # `||=` semantics preserved: a host that set the prefix in its + # own devise.rb keeps it. Read by Devise when the routes are + # drawn, which happens later still. + ::Devise.omniauth_path_prefix ||= cfg.omniauth_route_prefix + ::Devise.setup do |devise| devise.omniauth :openid_connect, name: PROVIDER_NAME, + path_prefix: cfg.omniauth_path_prefix, scope: (cfg.scope || 'openid email profile').split, response_type: :code, issuer: cfg.issuer, @@ -106,15 +127,6 @@ def controllers host: nil }.compact end - - # Devise propagates omniauth_path_prefix to - # OmniAuth.config.path_prefix during route generation - # (set_omniauth_path_prefix!). On Rails 8 routes load lazily, - # so the OmniAuth middleware may process requests before routes - # are drawn and miss the prefix. Set it eagerly here. - # Must happen AFTER `devise.omniauth` because that call - # triggers autoload of devise/omniauth which nils the value. - ::OmniAuth.config.path_prefix = ::Devise.omniauth_path_prefix end initializer 'activeadmin_oidc.filter_parameters' do |app| diff --git a/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb b/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb index 819f45c..e76d11b 100644 --- a/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb +++ b/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb @@ -10,4 +10,10 @@ # would become `/admin/admin/login`. Use relative paths instead. c.login_path = "/login" c.logout_path = "/logout" + + # Same reason, for the routes Devise draws for OmniAuth. The + # middleware still listens on the browser-visible `/admin/auth` + # (the derived `omniauth_path_prefix`); only the route declaration + # is engine-relative. + c.omniauth_route_prefix = "/auth" end diff --git a/spec/isolated/requests/isolated_engine_callback_spec.rb b/spec/isolated/requests/isolated_engine_callback_spec.rb new file mode 100644 index 0000000..fca4387 --- /dev/null +++ b/spec/isolated/requests/isolated_engine_callback_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "isolated_rails_helper" + +# The OmniAuth round trip for an isolated engine mounted at a prefix. +# +# Devise reuses one setting -- `Devise.omniauth_path_prefix` -- both to +# declare its OmniAuth routes and to tell the OmniAuth middleware where +# to listen. Those two are not the same string here: routes declared +# inside AdminPanel::Engine get `/admin` prepended by the mount, while +# the middleware sits in the application's Rack stack and sees the URL +# with `/admin` still on it. Feeding the browser-visible `/admin/auth` +# to both puts the callback route at `/admin/admin/auth/oidc/callback`, +# so the redirect the middleware issues 404s. +# +# The gem therefore drives them separately: `omniauth_route_prefix` +# ('/auth' here) for the route declaration, `omniauth_path_prefix` +# ('/admin/auth', derived) as a per-strategy middleware option. +RSpec.describe "Isolated engine OIDC callback", type: :request do + before do + # spec_helper resets the singleton config before every example, so + # restore the parts the callback action reads at request time. + ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + c.on_login = ->(_admin_user, _claims) { true } + end + + OmniAuth.config.mock_auth[:oidc] = OmniAuth::AuthHash.new( + provider: "oidc", + uid: "sub-isolated", + info: { "email" => "isolated@example.com" }, + extra: { "raw_info" => { "sub" => "sub-isolated", "email" => "isolated@example.com" } } + ) + AdminUser.delete_all + end + + after { OmniAuth.config.mock_auth[:oidc] = nil } + + it "declares the callback route engine-relative so the mount prefix lands it on /admin/auth" do + paths = AdminPanel::Engine.routes.routes.map { |r| r.path.spec.to_s } + expect(paths).to include("/auth/oidc/callback(.:format)") + end + + it "keeps the middleware listening on the browser-visible /admin/auth" do + post "/admin/auth/oidc" + + expect(response).to redirect_to("http://www.example.com/admin/auth/oidc/callback") + end + + it "completes the round trip and signs the admin user in" do + post "/admin/auth/oidc" + follow_redirect! + + expect(AdminUser.find_by(uid: "sub-isolated")).to be_present + # Not bounced back to the SSO landing page, which is where every + # failure path ends up. + expect(response.headers["Location"]).not_to include("/admin/login") + end +end diff --git a/spec/requests/after_sign_in_path_spec.rb b/spec/requests/after_sign_in_path_spec.rb new file mode 100644 index 0000000..4c9163d --- /dev/null +++ b/spec/requests/after_sign_in_path_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Where a successful callback lands. The path used to be the literal +# '/admin', which is wrong for any host whose ActiveAdmin is not mounted +# exactly there -- a renamed `config.default_namespace`, or ActiveAdmin +# drawn inside a mounted engine, whose mount prefix is prepended to +# every path it declares. It is now read from ActiveAdmin's own route +# helper. +# +# This dummy app uses the default :admin namespace in the main app, so +# the resolved path and the old hardcoded one coincide; what these specs +# pin down is that the value is *derived* rather than written down. +RSpec.describe "Post-sign-in landing path" do + let(:admin_user) do + AdminUser.create!(email: "alice@example.com", provider: "oidc", uid: "sub-123") + end + + let(:controller) do + ActiveAdmin::Oidc::Devise::OmniauthCallbacksController.new.tap do |c| + request = ActionDispatch::TestRequest.create + request.env["devise.mapping"] = Devise.mappings[:admin_user] + request.env["rack.session"] = ActionController::TestSession.new + c.set_request!(request) + c.set_response!(ActionDispatch::TestResponse.create) + end + end + + before { AdminUser.delete_all } + + it "uses ActiveAdmin's namespace root helper" do + expect(controller.send(:after_sign_in_path_for, admin_user)) + .to eq(Rails.application.routes.url_helpers.admin_root_path) + end + + it "builds the helper name from ActiveAdmin's configured namespace" do + allow(ActiveAdmin.application).to receive(:default_namespace).and_return(:backoffice) + + # No :backoffice routes exist here, so stand in for the url-helper + # proxy the controller resolves through: this pins down that the + # helper NAME follows the setting, not that the route exists. + allow(controller).to receive(:main_app) + .and_return(double("main_app", backoffice_root_path: "/backoffice")) + + expect(controller.send(:after_sign_in_path_for, admin_user)).to eq("/backoffice") + end + + it "falls back to the namespace prefix when the helper cannot be resolved" do + allow(ActiveAdmin.application).to receive(:default_namespace).and_return(:nowhere) + + expect(controller.send(:after_sign_in_path_for, admin_user)).to eq("/nowhere") + end + + # ActiveAdmin's root namespace has no prefix at all, so the fallback + # has to name a real path rather than the empty string, which would + # render an unredirectable Location header. + it "falls back to / for ActiveAdmin's root namespace" do + allow(ActiveAdmin.application).to receive(:default_namespace).and_return(false) + + expect(controller.send(:after_sign_in_path_for, admin_user)).to eq("/") + end + + it "still honours a stored location" do + controller.session["admin_user_return_to"] = "/admin/admin_users" + + expect(controller.send(:after_sign_in_path_for, admin_user)).to eq("/admin/admin_users") + end +end diff --git a/spec/requests/disabled_user_persistence_spec.rb b/spec/requests/disabled_user_persistence_spec.rb index f47ce68..d0aa21f 100644 --- a/spec/requests/disabled_user_persistence_spec.rb +++ b/spec/requests/disabled_user_persistence_spec.rb @@ -47,7 +47,7 @@ after { OmniAuth.config.mock_auth[:oidc] = nil } def post_callback - post "#{OmniAuth.config.path_prefix}/oidc" + post "#{ActiveAdmin::Oidc.config.omniauth_path_prefix}/oidc" follow_redirect! if response.redirect? end diff --git a/spec/requests/login_path_helper_spec.rb b/spec/requests/login_path_helper_spec.rb index 09e5d35..964b6a7 100644 --- a/spec/requests/login_path_helper_spec.rb +++ b/spec/requests/login_path_helper_spec.rb @@ -2,22 +2,17 @@ require "rails_helper" -# Regression spec for MEDIUM #4 — login view must derive the OmniAuth -# callback path from `OmniAuth.config.path_prefix`, not hardcode it. +# Regression spec for MEDIUM #4 — the login view must derive the OmniAuth +# request path from configuration, not hardcode `/admin/auth/oidc`. Hosts +# that rename ActiveAdmin's namespace, or set `omniauth_path_prefix` +# explicitly, otherwise get a button POSTing to a dead URL. # -# `app/views/active_admin/devise/sessions/new.html.erb` used to hardcode -# `"/admin/auth/oidc"` in the form action. Hosts that customise -# `Devise.omniauth_path_prefix` (mount Devise at a non-`/admin` path, -# or use a different sub-prefix for SSO) ended up with a button POSTing -# to a dead URL — the gem's strategy is registered at the configured -# prefix, not the hardcoded one. -# -# We can't use Devise's `omniauth_authorize_path` helper here because -# the OmniAuth middleware lives at the Rack level (global path prefix), -# while Devise route helpers resolve through the engine and get -# re-prefixed by the engine mount — producing e.g. `/admin/admin/auth/oidc` -# when Devise is engine-mounted. `OmniAuth.config.path_prefix` is the -# single source of truth for where the middleware actually listens. +# The source of truth is `ActiveAdmin::Oidc.config.omniauth_path_prefix`: +# the browser-visible path the OmniAuth middleware listens on. Neither +# `Devise.omniauth_path_prefix` nor `OmniAuth.config.path_prefix` works +# here — Devise sets both to the prefix it *declares its routes* with, +# which for an engine-mounted host is the same path minus the engine's +# mount prefix. RSpec.describe "Login view OmniAuth path", type: :request do before do ActiveAdmin::Oidc.configure do |c| @@ -25,32 +20,19 @@ c.client_id = "client-abc" c.on_login = ->(*) { true } end - - # Force routes to load NOW. Otherwise Rails 8 lazy loading defers - # `devise_for` until the first request — at which point the stub - # below is active, Devise's "OmniAuth.config.path_prefix matches - # Devise.omniauth_path_prefix" guard sees the sentinel, and raises. - # `execute_unless_loaded` is Rails 8+; fall back for 7.x. - reloader = Rails.application.routes_reloader - if reloader.respond_to?(:execute_unless_loaded) - reloader.execute_unless_loaded - else - Rails.application.reload_routes! - end end - it "renders the form action from OmniAuth.config.path_prefix (no hardcoded literal)" do - # Stub the OmniAuth path prefix to a sentinel value the hardcoded - # string could never match. If the view actually reads the prefix, - # the rendered form action will be `/oidc`; if it - # hardcodes the path, the literal "/admin/auth/oidc" stays. + it "renders the form action from the configured prefix (no hardcoded literal)" do + # A sentinel the hardcoded string could never match: if the view + # reads the config, the action is `/oidc`; if it hardcodes + # the path, the literal "/admin/auth/oidc" stays. sentinel = "/sentinel-omniauth-prefix" - allow(OmniAuth.config).to receive(:path_prefix).and_return(sentinel) + ActiveAdmin::Oidc.config.omniauth_path_prefix = sentinel get "/admin/login" expect(response.body).to include(%(action="#{sentinel}/oidc")), - "form action ignores Devise.omniauth_path_prefix — hosts that " \ - "customise the prefix get a button POSTing to a dead URL" + "form action ignores ActiveAdmin::Oidc.config.omniauth_path_prefix — " \ + "hosts that customise the prefix get a button POSTing to a dead URL" end end diff --git a/spec/unit/configuration_spec.rb b/spec/unit/configuration_spec.rb index 6153312..06f2ebf 100644 --- a/spec/unit/configuration_spec.rb +++ b/spec/unit/configuration_spec.rb @@ -213,6 +213,80 @@ end end + describe "paths derived from ActiveAdmin's namespace" do + # `default_namespace` is one of ActiveAdmin's dynamically defined + # settings, so a verifying double refuses it. + def with_namespace(namespace) + without_partial_double_verification do + allow(ActiveAdmin).to receive(:application) + .and_return(double("ActiveAdmin::Application", default_namespace: namespace)) + yield + end + end + + it "follows a renamed default_namespace" do + with_namespace(:backoffice) do + expect(config.login_path).to eq("/backoffice/login") + expect(config.logout_path).to eq("/backoffice/logout") + expect(config.omniauth_path_prefix).to eq("/backoffice/auth") + end + end + + it "mounts at the top level for ActiveAdmin's root namespace" do + with_namespace(:root) do + expect(config.login_path).to eq("/login") + expect(config.omniauth_path_prefix).to eq("/auth") + end + end + + it "treats a blank namespace as the root namespace" do + with_namespace(false) { expect(config.login_path).to eq("/login") } + end + + it "keeps an explicit override, which isolated engines depend on" do + config.login_path = "/login" + config.logout_path = "/logout" + + with_namespace(:backoffice) do + expect(config.login_path).to eq("/login") + expect(config.logout_path).to eq("/logout") + # ...without dragging the derived prefix along with it. + expect(config.omniauth_path_prefix).to eq("/backoffice/auth") + end + end + + it "defaults omniauth_route_prefix to omniauth_path_prefix" do + with_namespace(:backoffice) do + expect(config.omniauth_route_prefix).to eq("/backoffice/auth") + end + end + + # Engine-mounted hosts split the two: the middleware sees the + # browser-visible path, Devise declares its routes engine-relative. + it "lets omniauth_route_prefix be overridden independently" do + config.omniauth_route_prefix = "/auth" + + with_namespace(:admin) do + expect(config.omniauth_path_prefix).to eq("/admin/auth") + expect(config.omniauth_route_prefix).to eq("/auth") + end + end + + # Library code has to stay callable with no ActiveAdmin around at + # all (unit specs, scripts). Driven by raising rather than by the + # absence of the constant, so it holds whether or not another spec + # in the same process has booted the dummy app. + it "falls back to /admin when ActiveAdmin cannot be consulted" do + without_partial_double_verification do + allow(ActiveAdmin).to receive(:application).and_raise(NoMethodError) + end + + expect(config.login_path).to eq("/admin/login") + expect(config.logout_path).to eq("/admin/logout") + expect(config.omniauth_path_prefix).to eq("/admin/auth") + end + end + describe "#pkce" do it "defaults to true when client_secret is blank" do config.client_secret = nil From d175e823d13b0591b878b0f06d1a5b14aaf5f90f Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 30 Sep 2026 13:37:21 +0200 Subject: [PATCH 2/3] Cover a host that mounts ActiveAdmin at / with a booting dummy app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The namespace derivation above is exercised by unit specs that stub `ActiveAdmin.application.default_namespace`. This adds the other half: a host that really boots with `config.default_namespace = false`, so the derived login path, OmniAuth prefix and post-sign-in redirect are asserted against a live route table rather than a double. spec/dummy_root/ is spec/dummy/ with that one config line flipped, and no host `root` route — anything the gem redirects to outside the ActiveAdmin route table 404s instead of quietly resolving. The suite pins down exactly what broke in production on such a host: /login (not /admin/login) serves the SSO landing page, /auth is where the middleware listens, and a callback with no stored location lands on / instead of a 404 at /admin. CI now runs one step per dummy app rather than a single combined `spec:all`, so a failure names the host shape that broke. --- .github/workflows/ci.yml | 13 ++- .gitignore | 6 +- Rakefile | 11 ++- spec/dummy_root/Rakefile | 4 + spec/dummy_root/app/admin/dashboard.rb | 9 ++ spec/dummy_root/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_root/app/models/admin_user.rb | 14 +++ .../app/models/application_record.rb | 5 + spec/dummy_root/config/application.rb | 48 +++++++++ spec/dummy_root/config/boot.rb | 5 + spec/dummy_root/config/database.yml | 11 +++ spec/dummy_root/config/environment.rb | 4 + spec/dummy_root/config/environments/test.rb | 17 ++++ .../config/initializers/active_admin.rb | 15 +++ .../config/initializers/activeadmin_oidc.rb | 12 +++ spec/dummy_root/config/initializers/devise.rb | 21 ++++ spec/dummy_root/config/routes.rb | 9 ++ spec/dummy_root/db/schema.rb | 19 ++++ .../requests/root_mounted_redirect_spec.rb | 99 +++++++++++++++++++ spec/root/root_rails_helper.rb | 26 +++++ 23 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 spec/dummy_root/Rakefile create mode 100644 spec/dummy_root/app/admin/dashboard.rb create mode 100644 spec/dummy_root/app/assets/config/manifest.js create mode 100644 spec/dummy_root/app/assets/javascripts/active_admin.js create mode 100644 spec/dummy_root/app/assets/stylesheets/active_admin.css create mode 100644 spec/dummy_root/app/controllers/application_controller.rb create mode 100644 spec/dummy_root/app/models/admin_user.rb create mode 100644 spec/dummy_root/app/models/application_record.rb create mode 100644 spec/dummy_root/config/application.rb create mode 100644 spec/dummy_root/config/boot.rb create mode 100644 spec/dummy_root/config/database.yml create mode 100644 spec/dummy_root/config/environment.rb create mode 100644 spec/dummy_root/config/environments/test.rb create mode 100644 spec/dummy_root/config/initializers/active_admin.rb create mode 100644 spec/dummy_root/config/initializers/activeadmin_oidc.rb create mode 100644 spec/dummy_root/config/initializers/devise.rb create mode 100644 spec/dummy_root/config/routes.rb create mode 100644 spec/dummy_root/db/schema.rb create mode 100644 spec/root/requests/root_mounted_redirect_spec.rb create mode 100644 spec/root/root_rails_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61a81a9..e545741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,5 +26,14 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Run all spec suites (default + engine + isolated) - run: bundle exec rake spec:all + # One step per dummy app: each boots a different Rails app, so they + # cannot share a process — and a separate step names which host + # shape broke instead of burying it in a combined run. + - name: Default suite (spec/dummy — ActiveAdmin at /admin) + run: bundle exec rake spec + - name: Engine-mounted Devise suite (spec/dummy_engine) + run: bundle exec rake spec:engine + - name: Isolated-engine suite (spec/dummy_isolated) + run: bundle exec rake spec:isolated + - name: Root-mounted suite (spec/dummy_root — ActiveAdmin at /) + run: bundle exec rake spec:root diff --git a/.gitignore b/.gitignore index 02b2a7a..92d4eea 100644 --- a/.gitignore +++ b/.gitignore @@ -21,12 +21,12 @@ /spec/dummy_engine/tmp/ /spec/dummy_isolated/log/ /spec/dummy_isolated/tmp/ -/spec/dummy_namespaced/log/ -/spec/dummy_namespaced/tmp/ +/spec/dummy_root/log/ +/spec/dummy_root/tmp/ # 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 -!/spec/dummy_namespaced/config/database.yml +!/spec/dummy_root/config/database.yml diff --git a/Rakefile b/Rakefile index 67501d3..33a4428 100644 --- a/Rakefile +++ b/Rakefile @@ -7,7 +7,7 @@ begin # 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}/**/*" + t.exclude_pattern = "spec/{engine,isolated,root,dummy_engine,dummy_isolated,dummy_root}/**/*" end namespace :spec do @@ -24,8 +24,13 @@ begin 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: %i[spec engine isolated] + desc "Run root-mounted-ActiveAdmin specs (boots spec/dummy_root/)" + task :root do + sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/root spec/root" + end + + desc "Run every spec suite (default + engine + isolated + root)" + task all: %i[spec engine isolated root] end task default: :spec diff --git a/spec/dummy_root/Rakefile b/spec/dummy_root/Rakefile new file mode 100644 index 0000000..91e7ad7 --- /dev/null +++ b/spec/dummy_root/Rakefile @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "config/application" +Rails.application.load_tasks diff --git a/spec/dummy_root/app/admin/dashboard.rb b/spec/dummy_root/app/admin/dashboard.rb new file mode 100644 index 0000000..06b6306 --- /dev/null +++ b/spec/dummy_root/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 "Dummy dashboard." + end +end diff --git a/spec/dummy_root/app/assets/config/manifest.js b/spec/dummy_root/app/assets/config/manifest.js new file mode 100644 index 0000000..aebeee2 --- /dev/null +++ b/spec/dummy_root/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link active_admin.css +//= link active_admin.js + diff --git a/spec/dummy_root/app/assets/javascripts/active_admin.js b/spec/dummy_root/app/assets/javascripts/active_admin.js new file mode 100644 index 0000000..a7f3bdd --- /dev/null +++ b/spec/dummy_root/app/assets/javascripts/active_admin.js @@ -0,0 +1 @@ +// stub for specs — ActiveAdmin layout references this file diff --git a/spec/dummy_root/app/assets/stylesheets/active_admin.css b/spec/dummy_root/app/assets/stylesheets/active_admin.css new file mode 100644 index 0000000..7ba4ac9 --- /dev/null +++ b/spec/dummy_root/app/assets/stylesheets/active_admin.css @@ -0,0 +1 @@ +/* stub for specs — ActiveAdmin layout references this file */ diff --git a/spec/dummy_root/app/controllers/application_controller.rb b/spec/dummy_root/app/controllers/application_controller.rb new file mode 100644 index 0000000..7944f9f --- /dev/null +++ b/spec/dummy_root/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base +end diff --git a/spec/dummy_root/app/models/admin_user.rb b/spec/dummy_root/app/models/admin_user.rb new file mode 100644 index 0000000..09e66eb --- /dev/null +++ b/spec/dummy_root/app/models/admin_user.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AdminUser < ApplicationRecord + devise :omniauthable, + omniauth_providers: [:oidc] + + validates :email, presence: true + + serialize :oidc_raw_info, coder: JSON + + def active_for_authentication? + super && enabled? + end +end diff --git a/spec/dummy_root/app/models/application_record.rb b/spec/dummy_root/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/spec/dummy_root/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_root/config/application.rb b/spec/dummy_root/config/application.rb new file mode 100644 index 0000000..703d870 --- /dev/null +++ b/spec/dummy_root/config/application.rb @@ -0,0 +1,48 @@ +# 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 + # sprockets-rails is optional; dummy app uses ActiveAdmin's cssbundling/importmap defaults +end + +Bundler.require(*Rails.groups) + +require "devise" +require "activeadmin" + +# Load the gem under test. +require "activeadmin-oidc" + +# Host app that mounts ActiveAdmin at `/` instead of `/admin` +# (`config.default_namespace = false`). Identical to spec/dummy in every +# other respect — see config/initializers/active_admin.rb for the one +# line that differs. +module DummyRoot + 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_root_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_root/config/boot.rb b/spec/dummy_root/config/boot.rb new file mode 100644 index 0000000..7865da2 --- /dev/null +++ b/spec/dummy_root/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_root/config/database.yml b/spec/dummy_root/config/database.yml new file mode 100644 index 0000000..57ed80b --- /dev/null +++ b/spec/dummy_root/config/database.yml @@ -0,0 +1,11 @@ +test: + adapter: sqlite3 + database: ":memory:" + pool: 5 + timeout: 5000 + +development: + adapter: sqlite3 + database: db/development.sqlite3 + pool: 5 + timeout: 5000 diff --git a/spec/dummy_root/config/environment.rb b/spec/dummy_root/config/environment.rb new file mode 100644 index 0000000..b3a30d6 --- /dev/null +++ b/spec/dummy_root/config/environment.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "application" +DummyRoot::Application.initialize! diff --git a/spec/dummy_root/config/environments/test.rb b/spec/dummy_root/config/environments/test.rb new file mode 100644 index 0000000..ca39b0e --- /dev/null +++ b/spec/dummy_root/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_root/config/initializers/active_admin.rb b/spec/dummy_root/config/initializers/active_admin.rb new file mode 100644 index 0000000..71b4a80 --- /dev/null +++ b/spec/dummy_root/config/initializers/active_admin.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +ActiveAdmin.setup do |config| + config.site_title = "Dummy Root" + 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 + + # The whole point of this dummy app: ActiveAdmin mounted at `/`, not + # `/admin`. Real hosts do this when the admin panel *is* the app. + config.default_namespace = false +end diff --git a/spec/dummy_root/config/initializers/activeadmin_oidc.rb b/spec/dummy_root/config/initializers/activeadmin_oidc.rb new file mode 100644 index 0000000..d764381 --- /dev/null +++ b/spec/dummy_root/config/initializers/activeadmin_oidc.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + # client_secret intentionally blank so PKCE auto-enables in specs. + c.on_login = ->(admin_user, claims) { + # Default dummy on_login: accept, set department if provided. + admin_user.department = claims["department"] if claims.key?("department") + true + } +end diff --git a/spec/dummy_root/config/initializers/devise.rb b/spec/dummy_root/config/initializers/devise.rb new file mode 100644 index 0000000..6948f9f --- /dev/null +++ b/spec/dummy_root/config/initializers/devise.rb @@ -0,0 +1,21 @@ +# 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 = Rails.env.test? ? 1 : 11 + 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 + + # OmniAuth strategy registration and path prefix are handled automatically + # by the gem's engine (see lib/activeadmin/oidc/engine.rb) based on the + # ActiveAdmin::Oidc configuration in config/initializers/activeadmin_oidc.rb. +end diff --git a/spec/dummy_root/config/routes.rb b/spec/dummy_root/config/routes.rb new file mode 100644 index 0000000..a2e66cc --- /dev/null +++ b/spec/dummy_root/config/routes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + devise_for :admin_users, ActiveAdmin::Devise.config + ActiveAdmin.routes(self) + # No host-defined `root` route: ActiveAdmin's own `root_to` supplies + # `/`. Anything the gem redirects to outside the ActiveAdmin route + # table therefore 404s, which is what these specs are here to catch. +end diff --git a/spec/dummy_root/db/schema.rb b/spec/dummy_root/db/schema.rb new file mode 100644 index 0000000..0cab485 --- /dev/null +++ b/spec/dummy_root/db/schema.rb @@ -0,0 +1,19 @@ +# 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.string :department + t.boolean :enabled, default: true + t.timestamps + end + + add_index :admin_users, :email, unique: true + add_index :admin_users, %i[provider uid], unique: true, + where: "provider IS NOT NULL AND uid IS NOT NULL", + name: "index_admin_users_on_provider_and_uid" +end diff --git a/spec/root/requests/root_mounted_redirect_spec.rb b/spec/root/requests/root_mounted_redirect_spec.rb new file mode 100644 index 0000000..586afc9 --- /dev/null +++ b/spec/root/requests/root_mounted_redirect_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "root_rails_helper" + +# Regression suite for hosts that mount ActiveAdmin at `/` via +# `config.default_namespace = false` (see spec/dummy_root/). +# +# After a successful SSO round-trip the gem has to land the user on the +# ActiveAdmin namespace root. When Devise has a stored location that is +# used, and the bug is invisible. When it does not — every sign-in that +# did not start from a protected page: a bookmarked login page, a session +# cookie rotated during the IdP round-trip — the fallback fires, and a +# fallback that assumes `/admin` sends these hosts to a path that does +# not exist. +RSpec.describe "SSO sign-in on a host mounted at /", type: :request do + before do + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:oidc] = OmniAuth::AuthHash.new( + provider: "oidc", + uid: "sub-root", + info: { "email" => "root@example.com" }, + extra: { "raw_info" => { "sub" => "sub-root", "email" => "root@example.com" } } + ) + + ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + c.on_login = ->(_admin_user, _claims) { true } + end + + AdminUser.delete_all + end + + after { OmniAuth.config.mock_auth[:oidc] = nil } + + # POST the OmniAuth request phase and follow it into the callback, so + # `response` ends up on whatever the gem's controller redirected to. + # The prefix is read from config rather than written out: on this host + # it is `/auth`, and hardcoding `/admin/auth` is the very assumption + # these specs exist to catch. + def sign_in_via_sso + post "#{ActiveAdmin::Oidc.config.omniauth_path_prefix}/oidc" + follow_redirect! + end + + describe "the dummy host itself" do + it "mounts ActiveAdmin at / and has no /admin" do + expect(ActiveAdmin.application.default_namespace).to be(false) + expect(Rails.application.routes.recognize_path("/")).to include(action: "index") + expect { + Rails.application.routes.recognize_path("/admin") + }.to raise_error(ActionController::RoutingError) + end + + # The production symptom that started this: a host mounted at / was + # sent to /admin/login, which does not exist, while the Devise route + # actually lived at /login. + it "derives every mounted path from the empty namespace" do + expect(ActiveAdmin::Oidc.config.login_path).to eq("/login") + expect(ActiveAdmin::Oidc.config.logout_path).to eq("/logout") + expect(ActiveAdmin::Oidc.config.omniauth_path_prefix).to eq("/auth") + end + + it "serves the SSO landing page at the derived login path" do + get ActiveAdmin::Oidc.config.login_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include(ActiveAdmin::Oidc.config.login_button_label) + end + end + + context "with no stored location (sign-in did not start from a protected page)" do + it "redirects to /" do + sign_in_via_sso + + expect(response).to be_redirect + expect(URI(response.location).path).to eq("/") + end + + it "lands on a page that actually exists" do + sign_in_via_sso + follow_redirect! + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Dashboard") + end + end + + context "with a stored location (bounced off a protected page)" do + it "returns to where the user was headed" do + get "/" # unauthenticated → Devise stores admin_user_return_to + expect(response).to be_redirect + + sign_in_via_sso + + expect(URI(response.location).path).to eq("/") + end + end +end diff --git a/spec/root/root_rails_helper.rb b/spec/root/root_rails_helper.rb new file mode 100644 index 0000000..f3ddb0d --- /dev/null +++ b/spec/root/root_rails_helper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" + +require "spec_helper" +require_relative "../dummy_root/config/environment" + +abort("Rails not in test mode") if Rails.env.production? + +require "rspec/rails" +require "webmock/rspec" + +ActiveRecord::Schema.verbose = false +load File.expand_path("../dummy_root/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 734488ff05c8ddabca504b080a248568e0a6e6b0 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 30 Sep 2026 13:37:21 +0200 Subject: [PATCH 3/3] Pin json < 3 so CI can resolve a working stack json 3.0.2 removed the `quirks_mode` keyword that ActiveSupport::JSON.decode/encode still passes to JSON.parse and JSON.generate (activesupport 7.2 and 8.0). CI resolves without a lockfile, so it picked json 3.x up as soon as it was released and every request spec fails with `ArgumentError: unknown keyword: quirks_mode`, regardless of what a branch changes. --- gemfiles/activeadmin_3.5.gemfile | 8 ++++++++ gemfiles/activeadmin_4.0.gemfile | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/gemfiles/activeadmin_3.5.gemfile b/gemfiles/activeadmin_3.5.gemfile index a81b26f..2fdf8c4 100644 --- a/gemfiles/activeadmin_3.5.gemfile +++ b/gemfiles/activeadmin_3.5.gemfile @@ -14,3 +14,11 @@ gem "omniauth_openid_connect", "~> 0.6.0" gem "sprockets-rails", ">= 3.4" gem "sassc-rails", ">= 2.1" + +# json 3.0 dropped the `quirks_mode` keyword that ActiveSupport::JSON +# still passes to JSON.parse/JSON.generate (activesupport 7.2 and 8.0, +# lib/active_support/json/{decoding,encoding}.rb). CI has no lockfile, so +# it picks up json 3.x and every request spec dies with +# `ArgumentError: unknown keyword: quirks_mode`. Drop this once Rails +# ships a version that no longer passes it. +gem "json", "< 3" diff --git a/gemfiles/activeadmin_4.0.gemfile b/gemfiles/activeadmin_4.0.gemfile index dc65df7..538c275 100644 --- a/gemfiles/activeadmin_4.0.gemfile +++ b/gemfiles/activeadmin_4.0.gemfile @@ -17,3 +17,7 @@ gem "propshaft" gem "importmap-rails" gem "cssbundling-rails" gem "tailwindcss-rails", "~> 4.0" + +# See the note in activeadmin_3.5.gemfile: json 3.0 removed the +# `quirks_mode` keyword ActiveSupport::JSON still passes. +gem "json", "< 3"