Skip to content

Failing spec: login view hardcodes /admin/auth/oidc form action#10

Merged
Fivell merged 1 commit into
high-risk-failing-testsfrom
medium-risk-failing-tests
Jun 2, 2026
Merged

Failing spec: login view hardcodes /admin/auth/oidc form action#10
Fivell merged 1 commit into
high-risk-failing-testsfrom
medium-risk-failing-tests

Conversation

@Fivell
Copy link
Copy Markdown
Member

@Fivell Fivell commented Jun 2, 2026

Stacked on #9 — please review/merge #9 first.

The SSO login view hardcoded /admin/auth/oidc. If a host customises Devise.omniauth_path_prefix (different mount path, different SSO sub-prefix), the button POSTs to a dead URL — OmniAuth listens at the configured prefix, not the literal one.

The fix

Both view branches (AA v3 form_tag, AA v4 button_to) now source the form action from OmniAuth.config.path_prefix — the Rack-level path where the OmniAuth middleware actually listens, which Devise keeps in sync with its own omniauth_path_prefix.

Why not omniauth_authorize_path(resource_name, :oidc)?

That was the first attempt. It works for the default setup but breaks when Devise lives inside a mounted engine: Devise's helper resolves through the engine's route set and the engine mount path gets prepended on top of the already-correct prefix, producing /admin/admin/auth/oidc. Sourcing the path from OmniAuth.config is plain Rack and avoids the engine routing dance.

Spec note

The regression spec stubs OmniAuth.config.path_prefix to a sentinel. It also force-loads routes in before — on Rails 8 with lazy route loading, the first get evaluates config/routes.rb while the stub is still active, and Devise's "path_prefix must match Devise.omniauth_path_prefix" guard raises. Compat shim covers Rails 7.x (reload_routes!) and 8.x (execute_unless_loaded).

Test plan

  • bundle exec rspec spec/requests/login_path_helper_spec.rb — GREEN
  • bundle exec rake spec:isolated — 5/0 (covers the engine case the first attempt broke)
  • bundle exec rake spec:engine — 7/0
  • bundle exec rspec — 103/0
  • CI matrix (Ruby 3.2/3.3/3.4 × ActiveAdmin 3.5/4.0) — 6/6 green

@Fivell Fivell force-pushed the medium-risk-failing-tests branch 2 times, most recently from ba46688 to ccf2acf Compare June 2, 2026 14:16
@Fivell Fivell changed the base branch from main to high-risk-failing-tests June 2, 2026 14:17
@Fivell Fivell force-pushed the high-risk-failing-tests branch from bd2234b to b40e24b Compare June 2, 2026 14:28
@Fivell Fivell force-pushed the medium-risk-failing-tests branch from ccf2acf to f548fdf Compare June 2, 2026 14:29
The login view hardcoded /admin/auth/oidc. Hosts that change
Devise.omniauth_path_prefix (mounting Devise at a different scope,
say) ended up with a button POSTing to a dead URL.

Source the path from OmniAuth.config.path_prefix instead - it's the
single Rack-level value of where OmniAuth actually listens, and
Devise keeps it in sync with its own omniauth_path_prefix. Both view
branches (AA v3 form_tag and AA v4 button_to) updated.

Why not omniauth_authorize_path? Tried that first - it breaks in the
isolated engine case: Devise's helper resolves through engine routes
and the engine mount path gets prepended, producing
/admin/admin/auth/oidc. OmniAuth.config.path_prefix is plain Rack
and sidesteps the issue.

Regression spec stubs OmniAuth.config.path_prefix to a sentinel and
asserts the form action follows it. The spec's before block force-
loads routes first - on Rails 8 with lazy route loading, the stub
would otherwise still be active when devise_for runs, tripping
Devise's "path_prefix must match Devise.omniauth_path_prefix" guard.
@Fivell Fivell force-pushed the medium-risk-failing-tests branch from f548fdf to 62831f1 Compare June 2, 2026 14:37
@Fivell Fivell merged commit 61b69f8 into high-risk-failing-tests Jun 2, 2026
6 checks passed
Fivell added a commit that referenced this pull request Jun 2, 2026
Follow-ups to PRs #9 and #10 surfaced by the code review.

- Apply the same OmniAuth.config.path_prefix substitution to both
  install-generator templates. New installs were still shipping the
  hardcoded path PR #10 fixed in the live view.
- Default the InactiveError key to :inactive when the model's
  inactive_message returns nil/blank. The empty key used to render
  an empty flash; now the user always sees a reason.
- Stop leaking custom inactive_message symbols. If the host returns
  a symbol like :locked_by_admin with no devise.failure translation,
  the raw symbol used to land in a public flash. Fall back to
  devise.failure.inactive instead.

The disabled-user request spec is consolidated via let helpers
(auth_hash, disabling_hook, post_callback) and now uses
new_admin_user_session_path / OmniAuth.config.path_prefix instead
of hardcoded URLs. Adds a regression spec for the retry leg + an
inactive winner row, documenting the expected redirect-and-no-
warden-session behaviour.
Fivell added a commit that referenced this pull request Jun 2, 2026
…nfig.path_prefix (#12)

* Add failing tests for three security risks in UserProvisioner

Each spec is RED today and documents one production bug. The next
commit implements the fixes and turns them green.

1. on_login runs twice when two first sign-ins race. The loser hits
   RecordNotUnique, the provisioner retries, and the host's hook
   double-fires (audit log, webhook, welcome email all duplicated).

2. A disabled-by-hook user still gets a row in admin_users. The
   active_for_authentication? check runs in the controller AFTER the
   provisioner has already persisted. Hostile attempts grow the table.

3. A pre-seeded admin row (provider/uid still nil) gets adopted by
   anyone the IdP says owns that email - even when the IdP marks the
   email as unverified. Attacker registers ceo@example.com at a lax
   IdP, signs in, walks off with the CEO account.

* Harden UserProvisioner against the three security risks

Fix the three bugs covered by the failing specs in the previous commit.

1. Don't re-run on_login on the RecordNotUnique retry path. The flag
   set by save!'s rescue is now read in #call to short-circuit after
   the row is found: return the winner's record without re-invoking
   the host hook. Side effects fire exactly once.

2. Check active_for_authentication? inside the provisioner, between
   on_login and save!. If the hook flipped an inactivity flag, raise
   ProvisioningError BEFORE writing the row.

3. Refuse to adopt a legacy admin row when email_verified is
   explicitly false. IdPs that don't ship the claim keep the old
   behaviour - many never emit it, so a strict "must be true" check
   would break legitimate hosts.

* Preserve the model's inactive_message in the disabled-user flash

The previous commit moved the active_for_authentication? guard into
the provisioner, but the controller's generic ProvisioningError
rescue replaced the model's I18n-translated reason ("Your account
is not activated yet.") with the catch-all access_denied_message.
Disabled users lost the specific feedback.

Add InactiveError (a ProvisioningError subclass that carries the
inactive_message symbol) and rescue it separately in the callbacks
controller, translating via I18n.t("devise.failure.<symbol>"). The
generic rescue stays in place for every other denial path.

Also drop the now-dead active_for_authentication? check in the
controller - the provisioner runs the same guard earlier and never
returns an inactive user.

* Use OmniAuth.config.path_prefix for the SSO login form action

The login view hardcoded /admin/auth/oidc. Hosts that change
Devise.omniauth_path_prefix (mounting Devise at a different scope,
say) ended up with a button POSTing to a dead URL.

Source the path from OmniAuth.config.path_prefix instead - it's the
single Rack-level value of where OmniAuth actually listens, and
Devise keeps it in sync with its own omniauth_path_prefix. Both view
branches (AA v3 form_tag and AA v4 button_to) updated.

Why not omniauth_authorize_path? Tried that first - it breaks in the
isolated engine case: Devise's helper resolves through engine routes
and the engine mount path gets prepended, producing
/admin/admin/auth/oidc. OmniAuth.config.path_prefix is plain Rack
and sidesteps the issue.

Regression spec stubs OmniAuth.config.path_prefix to a sentinel and
asserts the form action follows it. The spec's before block force-
loads routes first - on Rails 8 with lazy route loading, the stub
would otherwise still be active when devise_for runs, tripping
Devise's "path_prefix must match Devise.omniauth_path_prefix" guard.
Fivell added a commit that referenced this pull request Jun 2, 2026
#11)

* Code-review followups: generator templates, inactive_message hardening

Follow-ups to PRs #9 and #10 surfaced by the code review.

- Apply the same OmniAuth.config.path_prefix substitution to both
  install-generator templates. New installs were still shipping the
  hardcoded path PR #10 fixed in the live view.
- Default the InactiveError key to :inactive when the model's
  inactive_message returns nil/blank. The empty key used to render
  an empty flash; now the user always sees a reason.
- Stop leaking custom inactive_message symbols. If the host returns
  a symbol like :locked_by_admin with no devise.failure translation,
  the raw symbol used to land in a public flash. Fall back to
  devise.failure.inactive instead.

The disabled-user request spec is consolidated via let helpers
(auth_hash, disabling_hook, post_callback) and now uses
new_admin_user_session_path / OmniAuth.config.path_prefix instead
of hardcoded URLs. Adds a regression spec for the retry leg + an
inactive winner row, documenting the expected redirect-and-no-
warden-session behaviour.

* Drop review-process references from disabled-user spec comments
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.

1 participant