Skip to content

Commit bf425a4

Browse files
authored
Merge pull request #17 from senid231/fix-after-sign-in-path
Derive every mounted path from ActiveAdmin's namespace
2 parents effba9e + 734488f commit bf425a4

36 files changed

Lines changed: 783 additions & 88 deletions

‎.github/workflows/ci.yml‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,14 @@ jobs:
2626
with:
2727
ruby-version: ${{ matrix.ruby }}
2828
bundler-cache: true
29-
- name: Run all spec suites (default + engine + isolated)
30-
run: bundle exec rake spec:all
29+
# One step per dummy app: each boots a different Rails app, so they
30+
# cannot share a process — and a separate step names which host
31+
# shape broke instead of burying it in a combined run.
32+
- name: Default suite (spec/dummy — ActiveAdmin at /admin)
33+
run: bundle exec rake spec
34+
- name: Engine-mounted Devise suite (spec/dummy_engine)
35+
run: bundle exec rake spec:engine
36+
- name: Isolated-engine suite (spec/dummy_isolated)
37+
run: bundle exec rake spec:isolated
38+
- name: Root-mounted suite (spec/dummy_root — ActiveAdmin at /)
39+
run: bundle exec rake spec:root

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121
/spec/dummy_engine/tmp/
2222
/spec/dummy_isolated/log/
2323
/spec/dummy_isolated/tmp/
24+
/spec/dummy_root/log/
25+
/spec/dummy_root/tmp/
2426

2527
# The dummy app's database.yml is checked in — override a global
2628
# ignore rule that excludes "database.yml" everywhere by default.
2729
!/spec/dummy/config/database.yml
2830
!/spec/dummy_engine/config/database.yml
2931
!/spec/dummy_isolated/config/database.yml
32+
!/spec/dummy_root/config/database.yml

‎.rspec‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
--color
44
# `bundle exec rspec` runs the default suite (spec/dummy). The engine-
55
# and isolated-engine suites each boot their own dummy Rails app and
6-
# must run in separate processes — invoke them via `rake spec:engine`,
7-
# `rake spec:isolated`, or all three with `rake spec:all`.
6+
# must run in separate processes — invoke them via `rake spec:engine`
7+
# and `rake spec:isolated`, or all three with `rake spec:all`.
88
--exclude-pattern "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*"

‎README.md‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,25 @@ OIDC is the only authentication mechanism — `:database_authenticatable`, `encr
5050

5151
If `devise_for :admin_users` lives inside a Rails engine (not the main app routes), set `Devise.router_name = :<engine_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 `<Engine>.routes.url_helpers.new_<scope>_session_path` resolves correctly.
5252

53-
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`:
53+
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`:
5454

5555
```ruby
5656
ActiveAdmin::Oidc.configure do |c|
57-
c.login_path = '/login'
58-
c.logout_path = '/logout'
57+
c.login_path = '/login'
58+
c.logout_path = '/logout'
59+
c.omniauth_route_prefix = '/auth'
5960
end
6061
```
6162

62-
Non-isolated engines don't need this override.
63+
`omniauth_route_prefix` is the same idea applied to the routes Devise draws for
64+
OmniAuth. It is separate from `omniauth_path_prefix` because the two are not the
65+
same string here: the OmniAuth middleware sits in the *application's* Rack stack
66+
and sees `/admin/auth/oidc` with the mount prefix still attached, while the route
67+
Devise declares for the callback is inside the engine and gets `/admin` prepended
68+
to it. Set only `omniauth_path_prefix` and the callback route lands on
69+
`/admin/admin/auth/oidc/callback`, so the redirect the middleware issues 404s.
70+
71+
Non-isolated engines mounted at `/` don't need any of these overrides.
6372

6473
### 3. `config/initializers/activeadmin_oidc.rb` (generated)
6574

@@ -73,7 +82,7 @@ The gem's Rails engine handles several things so host apps don't have to:
7382
* **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`.
7483
* **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.
7584
* **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.
76-
* **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.
85+
* **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.
7786
* **Parameter filtering** — `code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` are added to `Rails.application.config.filter_parameters`.
7887

7988
## Configuration
@@ -137,12 +146,41 @@ end
137146
| `identity_attribute` | `:email` | AdminUser column used for lookup/adoption |
138147
| `identity_claim` | `:email` | Claim key read from the id_token/userinfo |
139148
| `admin_user_class` | `"AdminUser"` | String or Class for the host's admin user model |
149+
| `login_path` | `/<namespace>/login` | SSO landing page path; derived from ActiveAdmin's namespace |
150+
| `logout_path` | `/<namespace>/logout` | Sign-out path; derived from ActiveAdmin's namespace |
151+
| `omniauth_path_prefix` | `/<namespace>/auth` | Browser-visible path the OmniAuth middleware listens on; derived from ActiveAdmin's namespace |
152+
| `omniauth_route_prefix` | `omniauth_path_prefix` | Prefix Devise declares its OmniAuth routes with; differs only for engine-mounted hosts |
140153
| `login_button_label` | `"Sign in with SSO"` | Label on the login-page button |
141154
| `access_denied_message` | generic | Flash shown on any denial |
142155
| `on_login` | — (required) | Authorization hook; see below |
143156

144157
`stub_dev_env_login!` is a method, not an option — see "Stub login" below.
145158

159+
## ActiveAdmin's namespace
160+
161+
Everything the gem mounts hangs off ActiveAdmin's namespace, and all of it is
162+
derived from `ActiveAdmin.application.default_namespace` rather than assumed to
163+
be `admin`. A host that renames it:
164+
165+
```ruby
166+
# config/initializers/active_admin.rb
167+
config.default_namespace = :backoffice
168+
```
169+
170+
gets `/backoffice/login`, `/backoffice/logout`, the OmniAuth middleware at
171+
`/backoffice/auth`, and a post-sign-in redirect to `/backoffice` — no gem
172+
configuration needed. ActiveAdmin's root namespace (`config.default_namespace =
173+
false`) mounts everything at the top level: `/login`, `/auth`, `/`.
174+
175+
Each is still overridable. Isolated engines *have* to override them, since the
176+
engine's mount prefix is prepended to every path declared inside it — see
177+
[Engine-mounted Devise](#engine-mounted-devise).
178+
179+
`omniauth_route_prefix` is what the gem assigns to `Devise.omniauth_path_prefix`,
180+
and it is skipped entirely if your app already assigned that in
181+
`config/initializers/devise.rb`. `omniauth_path_prefix` is passed to the OmniAuth
182+
strategy directly, so it stays correct regardless.
183+
146184
## The `on_login` hook
147185

148186
`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
247285

248286
* A login button is added to the ActiveAdmin sessions page via a prepended view override — no templates to edit.
249287
* 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.
250-
* After a successful callback the user is signed in and redirected to `/admin` (not the host app's `/`, which may not exist).
288+
* 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.
251289
* **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.
252290
* In development, `stub_dev_env_login!` repoints that same button at a local sign-in that never contacts the IdP — see "Stub login" below.
253291
* 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.

‎Rakefile‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ begin
77

88
# Default spec suite — boots spec/dummy/ (main-app OIDC-only setup).
99
RSpec::Core::RakeTask.new(:spec) do |t|
10-
t.exclude_pattern = "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*"
10+
t.exclude_pattern = "spec/{engine,isolated,root,dummy_engine,dummy_isolated,dummy_root}/**/*"
1111
end
1212

1313
namespace :spec do
@@ -24,8 +24,13 @@ begin
2424
sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/isolated spec/isolated"
2525
end
2626

27-
desc "Run every spec suite (default + engine + isolated)"
28-
task all: [:spec, :engine, :isolated]
27+
desc "Run root-mounted-ActiveAdmin specs (boots spec/dummy_root/)"
28+
task :root do
29+
sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/root spec/root"
30+
end
31+
32+
desc "Run every spec suite (default + engine + isolated + root)"
33+
task all: %i[spec engine isolated root]
2934
end
3035

3136
task default: :spec

‎app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb‎

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,28 +105,53 @@ def provision_and_sign_in(claims, kind: 'OIDC')
105105
# sign-in instead of Devise's default (host app root). Hosts
106106
# that don't define a `/` route would otherwise hit a routing
107107
# error immediately after login, and even when `/` does exist
108-
# it's rarely what an admin user wants to see. ActiveAdmin
109-
# always mounts at `/admin`, so we go there directly.
108+
# it's rarely what an admin user wants to see.
110109
def after_sign_in_path_for(resource)
111-
stored_location_for(resource) || '/admin'
110+
stored_location_for(resource) || active_admin_root_path
111+
end
112+
113+
# Resolved from ActiveAdmin's own route helper rather than
114+
# assumed to be '/admin'. Two things move it: a host can rename
115+
# the namespace (`config.default_namespace = :backoffice`), and
116+
# an engine-mounted ActiveAdmin prefixes every path it declares
117+
# with the engine's mount point -- so the real root can be
118+
# '/admin/admin' or anything else entirely. Routing a signed-in
119+
# admin to a 404 is a poor reward for a successful login.
120+
def active_admin_root_path
121+
send(devise_router_name(resource_name)).public_send(active_admin_root_helper)
122+
rescue NameError, ::ActionController::UrlGenerationError
123+
# The host has ActiveAdmin's routes somewhere we can't see (or
124+
# hasn't drawn them at all). Its namespace prefix is the best
125+
# remaining guess at where the admin panel lives -- except for
126+
# the root namespace, where that prefix is '' and would make an
127+
# empty, unredirectable Location.
128+
ActiveAdmin::Oidc.config.active_admin_namespace_prefix.presence || '/'
129+
end
130+
131+
# ActiveAdmin names its namespace root helper `<namespace>_root_path`,
132+
# except for the root namespace where it is plain `root_path`.
133+
def active_admin_root_helper
134+
namespace = ActiveAdmin::Oidc.config.active_admin_namespace
135+
namespace ? :"#{namespace}_root_path" : :root_path
112136
end
113137

114138
# Devise's `new_session_path(scope)` is only generated when
115139
# `:database_authenticatable` is in the mapping's `used_helpers`,
116140
# so an OIDC-only model never gets it. The engine mounts
117-
# `new_<scope>_session_path` itself, but the helper lives on
118-
# whichever route set Devise's URL helper dispatcher points at:
119-
# the per-mapping `router_name` (set by
120-
# `devise_for :scope, router_name: :engine`) when present,
121-
# otherwise the global `Devise.available_router_name`
122-
# (set by `Devise.router_name = :engine`), which defaults to
123-
# `:main_app`. Replicate that dispatcher here so the helper is
124-
# resolved on the right context (Rails.application proxy or
125-
# mounted engine proxy).
141+
# `new_<scope>_session_path` itself, but on whichever route set
142+
# `devise_router_name` resolves to.
126143
def after_omniauth_failure_path_for(scope)
127-
router_name = ::Devise.mappings[scope].router_name ||
128-
::Devise.available_router_name
129-
send(router_name).public_send(:"new_#{scope}_session_path")
144+
send(devise_router_name(scope)).public_send(:"new_#{scope}_session_path")
145+
end
146+
147+
# Devise's URL helpers live on the per-mapping `router_name` (set
148+
# by `devise_for :scope, router_name: :engine`) when present,
149+
# otherwise on the global `Devise.available_router_name` (set by
150+
# `Devise.router_name = :engine`), which defaults to `:main_app`.
151+
# Replicate that dispatcher here so helpers resolve on the right
152+
# context (Rails.application proxy or mounted engine proxy).
153+
def devise_router_name(scope)
154+
::Devise.mappings[scope]&.router_name || ::Devise.available_router_name
130155
end
131156
end
132157
end

‎gemfiles/activeadmin_3.5.gemfile‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,11 @@ gem "omniauth_openid_connect", "~> 0.6.0"
1414

1515
gem "sprockets-rails", ">= 3.4"
1616
gem "sassc-rails", ">= 2.1"
17+
18+
# json 3.0 dropped the `quirks_mode` keyword that ActiveSupport::JSON
19+
# still passes to JSON.parse/JSON.generate (activesupport 7.2 and 8.0,
20+
# lib/active_support/json/{decoding,encoding}.rb). CI has no lockfile, so
21+
# it picks up json 3.x and every request spec dies with
22+
# `ArgumentError: unknown keyword: quirks_mode`. Drop this once Rails
23+
# ships a version that no longer passes it.
24+
gem "json", "< 3"

‎gemfiles/activeadmin_4.0.gemfile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ gem "propshaft"
1717
gem "importmap-rails"
1818
gem "cssbundling-rails"
1919
gem "tailwindcss-rails", "~> 4.0"
20+
21+
# See the note in activeadmin_3.5.gemfile: json 3.0 removed the
22+
# `quirks_mode` keyword ActiveSupport::JSON still passes.
23+
gem "json", "< 3"

0 commit comments

Comments
 (0)