Skip to content

Follow a host-set Devise.omniauth_path_prefix in the strategy too - #21

Merged
senid231 merged 4 commits into
mainfrom
fix/omniauth-prefix-split
Oct 1, 2026
Merged

senid231 merged 4 commits into
mainfrom
fix/omniauth-prefix-split

Conversation

@Fivell

@Fivell Fivell commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #17, found by an adversarial review of it.

The bug

Devise's omniauth_path_prefix decides where the OmniAuth routes are drawn. The strategy's own path_prefix decides where the middleware listens. lib/activeadmin/oidc/engine.rb honoured a host-set value for the first and ignored it for the second:

::Devise.omniauth_path_prefix ||= cfg.omniauth_route_prefix   # host's value wins
devise.omniauth :openid_connect, path_prefix: cfg.omniauth_path_prefix   # always derived

Trigger: config/initializers/devise.rb contains config.omniauth_path_prefix = '/sso/auth' — the line Devise's own generator template suggests — with the ActiveAdmin namespace left at :admin. Booting spec/dummy with that value pre-set, before this change:

Devise.omniauth_path_prefix : "/sso/auth"      <- routes drawn here
cfg.omniauth_path_prefix    : "/admin/auth"    <- middleware listens here
route /sso/auth/oidc/callback   -> omniauth_callbacks#oidc
route /admin/auth/oidc/callback -> ActionController::RoutingError

POST /admin/auth/oidc          -> 302 /admin/auth/oidc/callback
GET  /admin/auth/oidc/callback -> No route matches [GET] "/admin/auth/oidc/callback"

Every SSO sign-in on such a host 404s after the IdP round trip, and the passthru route Devise drew is equally dead because the middleware never intercepts it. README.md documents this configuration as supported and says omniauth_path_prefix "stays correct regardless" — it is the one thing that is not.

Nothing raises. Devise's set_omniauth_path_prefix! only warns when OmniAuth.config.path_prefix is already set, and devise/omniauth.rb nils that global on load, so the split is silent.

The fix

A host that pinned only Devise's setting meant one prefix, not two, so the engine writes that value back to cfg.omniauth_path_prefix. The strategy and the login form's login_submit_path both read it, so the button, the middleware and the routes agree. An explicit c.omniauth_path_prefix still wins — that is what engine-mounted hosts need, since there the two genuinely differ by the mount prefix.

Same setup after the change, through the login page:

GET  /admin/login     -> form action="/sso/auth/oidc"
POST /sso/auth/oidc   -> 302 /sso/auth/oidc/callback

spec/requests/host_devise_prefix_spec.rb covers this end to end. It boots spec/dummy in a child process with the prefix preset, because the suite's own app is already booted without it.

Also in this PR

active_admin_namespace now applies .underscore. ActiveAdmin::Namespace#initialize does name.to_s.underscore, so config.default_namespace = :"admin-panel" routes everything under /admin_panel. The gem derived from the raw value:

input=:"admin-panel"   AA route_prefix="admin_panel"   gem prefix="/admin-panel"
input=:AdminPanel      AA route_prefix="admin_panel"   gem prefix="/AdminPanel"

so the login page mounted somewhere ActiveAdmin never routed, and public_send(:"admin-panel_root_path") raised into the fallback. Covered by two new unit examples.

#login_submit_path spec had no discriminating power. It asserted against OmniAuth.config.path_prefix — a global that tracks the same value, so it passed under the pre-#17 implementation too and could not have caught the regression it guards. It also only passed at all when another spec file had booted a dummy app first; run standalone it failed. Now a literal, plus a second example for the derived default.

The suite no longer depends on the random seed. Rails 8 draws routes lazily, so the first example to touch them drew them under its own stubs. spec/rails_helper.rb now draws them once before any example runs. Details in the comment below.

Suites

spec           167 examples, 0 failures, 1 pending (8 pending on activeadmin_4.0)
spec:engine      7 examples, 0 failures
spec:isolated    8 examples, 0 failures
spec:root        6 examples, 0 failures

Devise's `omniauth_path_prefix` decides where the OmniAuth routes are
DRAWN; the strategy's own `path_prefix` decides where the middleware
LISTENS. The engine honoured a host-set value for the first and ignored
it for the second:

    ::Devise.omniauth_path_prefix ||= cfg.omniauth_route_prefix   # host wins
    devise.omniauth :openid_connect, path_prefix: cfg.omniauth_path_prefix

A host with `config.omniauth_path_prefix = '/sso/auth'` in its own
devise.rb — the line Devise's own generator template suggests — ends up
with the callback route at /sso/auth/oidc/callback and the middleware
listening on /admin/auth. Every SSO sign-in 404s after the IdP round
trip, and README.md documents that configuration as supported.

A host that pinned only Devise's setting meant one prefix, so follow it
for both. An explicit `c.omniauth_path_prefix` still wins, which is
what engine-mounted hosts need — there the two genuinely differ, by the
mount prefix.

Also here:

  * `active_admin_namespace` now applies `.underscore`, matching
    `ActiveAdmin::Namespace#initialize`. With
    `default_namespace = :"admin-panel"` ActiveAdmin routes under
    /admin_panel while the gem mounted /admin-panel/login and raised
    looking up `:"admin-panel_root_path"`.

  * The `#login_submit_path` example asserted against
    `OmniAuth.config.path_prefix`, a global that tracks the same value
    — it passed under the old implementation too, so it could not have
    caught the regression it guards, and it only passed at all when
    another spec had booted a dummy app first. Now a literal, plus a
    second example for the derived default.
@Fivell
Fivell requested a review from senid231 September 30, 2026 13:18
Rails 8 loads routes lazily in the test environment, so the draw happened
whenever the first example touched the route set -- and it inherited that
example's stubs. `spec/requests/after_sign_in_path_spec.rb` stubs
`ActiveAdmin.application.default_namespace` and then reads
`Devise.mappings`, which calls `reload_routes_unless_loaded` internally;
when that example ran first, the whole app got drawn under the stubbed
namespace and the bad route set leaked into every example after it.

That is what made the suite order-dependent. Two different symptoms, one
cause:

  RuntimeError: Could not find a valid mapping for path
                "/admin/auth/oidc/callback"
      -- spec/requests/omniauth_callback_spec.rb, seed 18410

  ActionController::RoutingError: No route matches
                [POST] "/admin/login/stub"
      -- spec/requests/stub_login_spec.rb:224, seed 21181

`rspec --bisect` on seed 18410 reduced it to two examples:

  rspec ./spec/requests/after_sign_in_path_spec.rb[1:2] \
        ./spec/requests/omniauth_callback_spec.rb[1:7:1] --seed 18410

Note `reload_routes!` does not work here. On a not-yet-loaded app it
draws the routes and then resets the loaded flag back to false:

  def reload_routes!
    if routes_reloader.execute_unless_loaded
      routes_reloader.loaded = false
    else
      routes_reloader.reload!
    end
  end

so the next `Devise.mappings` call redraws them anyway, which leaves the
original problem in place. `reload_routes_unless_loaded` draws them and
leaves them loaded. `try` keeps this a no-op on Rails 7.2, which has no
lazy route loading and no such method -- the same call Devise makes.

Seeds 6, 6233, 18410, 21181, 35323 and 62645 all pass after this.
@Fivell

Fivell commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

Pushed one commit on top of this branch: a766de0 — "Draw the routes once before the suite runs", a single line in spec/rails_helper.rb plus a comment explaining it.

What it fixes

This branch already removed the first half of the order-dependence by replacing the mutable OmniAuth.config.path_prefix lookups in spec/unit/configuration_spec.rb with literals. What remained were two symptoms that looked unrelated but turned out to be one root cause:

RuntimeError: Could not find a valid mapping for path "/admin/auth/oidc/callback"
    spec/requests/omniauth_callback_spec.rb        (seed 18410, 4 failures)

ActionController::RoutingError: No route matches [POST] "/admin/login/stub"
    spec/requests/stub_login_spec.rb:224           (seed 21181, 1 failure)

Rails 8 loads routes lazily in test, so the draw happened whenever the first example touched the route set — inheriting that example's stubs. rspec --bisect on seed 18410 reduced it to two examples:

rspec ./spec/requests/after_sign_in_path_spec.rb[1:2] \
      ./spec/requests/omniauth_callback_spec.rb[1:7:1] --seed 18410

after_sign_in_path_spec.rb[1:2] stubs ActiveAdmin.application.default_namespace to :backoffice and then reads Devise.mappings — which calls reload_routes_unless_loaded internally. When that example ran first, the entire app got drawn under the stubbed namespace, and that route set leaked into every example after it. Hence no Devise mapping for /admin/auth/....

One gotcha worth recording

Rails.application.reload_routes! does not work here. On a not-yet-loaded app it draws the routes and then resets the loaded flag straight back to false:

def reload_routes!
  if routes_reloader.execute_unless_loaded
    routes_reloader.loaded = false
  else
    routes_reloader.reload!
  end
end

…so the next Devise.mappings call redraws them anyway and the problem survives. I tried that first and it only took seed 18410 from 4 failures to 1. reload_routes_unless_loaded draws them and leaves them loaded. It is wrapped in try so it stays a no-op on Rails 7.2, which has no lazy route loading and no such method — the same call Devise itself makes.

Seed grid

Default suite, lockfiles deleted first, CI=true. Seeds 1–10 plus the four that previously failed and one from a red main run:

ruby gemfile rails seeds 1–10 6233 18410 21181 35323 62645
3.4.10 activeadmin_4.0 8.0.5.1 all 0 fail 0 0 0 0 0
3.4.10 activeadmin_3.5 7.2.3 all 0 fail 0 0 0 0 0
3.3.12 activeadmin_4.0 8.0.5.1 — 0 0 0 — 0
3.3.12 activeadmin_3.5 7.2.3 — 0 0 0 — 0
4.0.6 activeadmin_4.0 8.0.5.1 — 0 0 0 — 0
4.0.6 activeadmin_3.5 7.2.3 — 0 0 0 — 0

166 examples each (8 pending on AA 4.0, 1 on AA 3.5). The engine-, isolated- and root-mounted suites were also run at seeds 6 / 18410 / 21181 / 62645 on both gemfiles — 7 / 8 / 6 examples, 0 failures throughout. They have their own helpers and never exhibited the problem, so they are left alone.

Proof the commit is load-bearing

Reverting just spec/rails_helper.rb to its pre-commit state brings the failures straight back, same Ruby and bundle:

seed 21181 seed 18410
without the commit 1 failure 4 failures
with the commit 0 0

One more check worth calling out. CI here builds the merge commit, so its activeadmin_3.5 leg runs main's Rails 8.0 pin, not this branch's ~> 7.2.0 — and Rails 7.2 has no lazy route loading, so the branch-local 3.5 leg cannot exercise this at all. Repeating the experiment with the 3.5 gemfile temporarily pinned to Rails 8.0 (post-merge reality):

seed 21181 seed 18410
without the commit 1 failure 9 failures
with the commit 0 0

Those 9 are exactly the cluster that has been showing up on main, so this one line accounts for the whole thing.

CI on the merge commit: 6/6 legs pass (Ruby 3.3 / 3.4 / 4.0 × both gemfiles).

Notes

  • No config.order = :defined, no seed pinning, no skip/pending, and no assertion was weakened — the suite still runs fully random-ordered.
  • Nothing this branch already changed was touched, and none of your commits were amended or rebased; this is a plain fast-forward push (bf83584..a766de0).
  • The two previous CI runs on this branch were green, but on lucky seeds — the branch was flaky before this, as seeds 18410 and 21181 show.
  • Separately and not addressed here: main itself is still flaky at these seeds, since this fix lives on this branch. Worth merging reasonably soon, or cherry-picking the one line, so trunk CI stops failing at random.

@senid231 senid231 self-assigned this Oct 1, 2026
@Fivell
Fivell requested a balanced review from Copilot October 1, 2026 14:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The login form still submits to the derived prefix when the middleware adopts Devise’s host-configured prefix.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Updates OIDC path handling to align Devise routes, middleware, and ActiveAdmin namespaces.

Changes:

  • Honors host-configured Devise OmniAuth prefixes.
  • Normalizes ActiveAdmin namespaces with underscore.
  • Improves path tests and stabilizes Rails 8 route loading.
File Description
lib/​activeadmin/​oidc/​engine.rb Selects the middleware prefix.
lib/​activeadmin/​oidc/​configuration.rb Tracks explicit prefixes and normalizes namespaces.
spec/​unit/​configuration_spec.rb Adds namespace and path-prefix coverage.
spec/​rails_helper.rb Loads routes before examples run.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/activeadmin/oidc/engine.rb Outdated
The engine moved the middleware to a host-set Devise prefix but kept
the value local, so `login_submit_path` still built /admin/auth/oidc
and the login button hit a dead route. Writing it back to the config
gives the view and the middleware one value.

Co-Authored-By: Clanker
@senid231
senid231 merged commit b3ce66c into main Oct 1, 2026
6 checks passed
@senid231
senid231 deleted the fix/omniauth-prefix-split branch October 1, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants