Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .rspec
Original file line number Diff line number Diff line change
Expand Up @@ -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}/**/*"
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = :<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.

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)

Expand All @@ -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
Expand Down Expand Up @@ -133,10 +142,39 @@ 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` | `/<namespace>/login` | SSO landing page path; derived from ActiveAdmin's namespace |
| `logout_path` | `/<namespace>/logout` | Sign-out path; derived from ActiveAdmin's namespace |
| `omniauth_path_prefix` | `/<namespace>/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 |

## 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.
Expand Down Expand Up @@ -241,7 +279,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.
* 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.

Expand Down
2 changes: 1 addition & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,28 +69,53 @@ def failure
# 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 `<namespace>_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_<scope>_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_<scope>_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
Expand Down
4 changes: 2 additions & 2 deletions app/views/active_admin/devise/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</h2>

<%= button_to ActiveAdmin::Oidc.config.login_button_label,
"#{OmniAuth.config.path_prefix}/oidc",
"#{ActiveAdmin::Oidc.config.omniauth_path_prefix}/oidc",
method: :post,
class: "activeadmin-oidc-login-button w-full",
form_class: 'formtastic',
Expand All @@ -15,7 +15,7 @@
<div id="login">
<h2><%= active_admin_application.site_title(self) %></h2>

<%= form_tag "#{OmniAuth.config.path_prefix}/oidc",
<%= form_tag "#{ActiveAdmin::Oidc.config.omniauth_path_prefix}/oidc",
method: :post,
class: "activeadmin-oidc-login-form formtastic",
data: { turbo: false } do %>
Expand Down
82 changes: 76 additions & 6 deletions lib/activeadmin/oidc/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@ 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'
# 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

def initialize
reset!
Expand All @@ -37,13 +41,79 @@ 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
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?

Expand Down
Loading
Loading