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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ jobs:
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run tests
run: bundle exec rspec spec
- name: Run all spec suites (default + engine + isolated)
run: bundle exec rake spec:all
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@
# 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
5 changes: 5 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
--require spec_helper
--format documentation
--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`.
--exclude-pattern "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*"
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ Without these, `/admin` is public to anyone and the utility navigation (includin

### 2. `app/models/admin_user.rb`

For SSO-only setups (recommended — no local password login at all):

```ruby
class AdminUser < ApplicationRecord
devise :omniauthable, omniauth_providers: [:oidc]

serialize :oidc_raw_info, coder: JSON
end
```

The engine auto-mounts `/admin/login` (SSO landing page) and `/admin/logout` so ActiveAdmin's login link still resolves, even though Devise's `:database_authenticatable` isn't installed. The `encrypted_password` column is **not** required.

If you also want password login alongside SSO, add the usual modules and column:

```ruby
class AdminUser < ApplicationRecord
devise :database_authenticatable,
Expand All @@ -46,6 +60,23 @@ class AdminUser < ApplicationRecord
end
```

In mixed mode Devise's own `devise_for` already maps `GET /admin/login` to its session controller. Because host routes are drawn before the gem's `after_initialize` append, Devise wins recognition for that path and renders its default sessions view (with both password form and OmniAuth links). The gem's auto-mount is a silent no-op — keep your custom `app/views/active_admin/devise/sessions/new.html.erb` if you want a specific landing UI.

### Engine-mounted Devise (yeti-web / pbx-api pattern)

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`. The gem reads this 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`:

```ruby
ActiveAdmin::Oidc.configure do |c|
c.login_path = '/login'
c.logout_path = '/logout'
end
```

Non-isolated engines don't need this override.

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

Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below.
Expand All @@ -57,6 +88,7 @@ The gem's Rails engine handles several things so host apps don't have to:
* **OmniAuth strategy registration** — the engine registers the `:openid_connect` strategy with Devise automatically based on your `ActiveAdmin::Oidc` configuration. You do **not** need to add `config.omniauth` or `config.omniauth_path_prefix` to `devise.rb`.
* **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 the existing `devise_scope :admin_user`. Without these, hosts that omit `:database_authenticatable` would 404 on ActiveAdmin's redirect to `new_admin_user_session_path` (Devise only generates those routes as a side effect of `:database_authenticatable`).
* **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.
* **Parameter filtering** — `code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` are added to `Rails.application.config.filter_parameters`.

Expand Down
25 changes: 24 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,30 @@ require "bundler/gem_tasks"

begin
require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)

# 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}/**/*"
end

namespace :spec do
# Each variant below boots a different dummy Rails app and therefore
# must run in its own process. CI invokes them as separate steps;
# locally `rake spec:all` runs them sequentially via `sh`.
desc "Run engine-mounted-Devise specs (boots spec/dummy_engine/)"
task :engine do
sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/engine spec/engine"
end

desc "Run isolated-engine specs (boots spec/dummy_isolated/)"
task :isolated do
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: [:spec, :engine, :isolated]
end

task default: :spec
rescue LoadError
# rspec not available
Expand Down
1 change: 1 addition & 0 deletions activeadmin-oidc.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Gem::Specification.new do |spec|
spec.add_dependency "rails", ">= 7.2"

spec.add_development_dependency "rspec-rails", ">= 6.0"
spec.add_development_dependency "capybara", ">= 3.40"
spec.add_development_dependency "webmock", ">= 3.19"
spec.add_development_dependency "jwt", ">= 2.7"
spec.add_development_dependency "sqlite3", ">= 1.7"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ def failure
def after_sign_in_path_for(resource)
stored_location_for(resource) || '/admin'
end

# Devise's default `after_omniauth_failure_path_for` calls
# `new_session_path(scope)`, a URL helper Devise only generates
# when :database_authenticatable mounts session routes. The
# engine mounts `new_<scope>_session_path` itself (see
# `mount_oidc_sessions_routes` initializer) regardless of which
# modules are loaded, so we always route through that helper.
def after_omniauth_failure_path_for(scope)
public_send(:"new_#{scope}_session_path")
end
end
end
end
Expand Down
7 changes: 6 additions & 1 deletion lib/activeadmin/oidc/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ 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'

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
:access_denied_message, :on_login, :admin_user_class,
:login_path, :logout_path

def initialize
reset!
Expand All @@ -34,6 +37,8 @@ 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
@on_login = nil
@pkce_override = nil
self
Expand Down
61 changes: 59 additions & 2 deletions lib/activeadmin/oidc/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,30 @@ class Engine < ::Rails::Engine
# Used to gate controller registration and view overrides so the
# gem is a no-op when OIDC is not enabled on the model.
def self.oidc_enabled?
admin_class = ActiveAdmin::Oidc.config.admin_user_class
klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class
klass = admin_user_class
klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable)
end

def self.admin_user_class
admin_class = ActiveAdmin::Oidc.config.admin_user_class
admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class
end

# Returns the route set our session routes should be appended to.
# Follows `Devise.available_router_name` (set by the host via
# `Devise.router_name = :foo`) so that engine-mounted Devise
# setups see the helpers on the right engine's url_helpers.
def self.session_routes_target(app)
router_name = ::Devise.available_router_name
return app.routes if router_name.blank? || router_name.to_sym == :main_app

engine_class = ::Rails::Engine.subclasses.find do |klass|
klass.engine_name.to_sym == router_name.to_sym
end

engine_class ? engine_class.routes : app.routes
end

ControllersPatch = Module.new do
def controllers
result = super
Expand Down Expand Up @@ -101,6 +120,44 @@ def controllers
initializer 'activeadmin_oidc.filter_parameters' do |app|
app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce]
end

# The gem is OIDC-first: mount our SSO landing page at /admin/login
# and a warden-based /admin/logout under the existing devise scope.
# Without these, hosts that omit :database_authenticatable have no
# session routes from Devise at all, so ActiveAdmin's redirect to
# `new_admin_user_session_path` 404s. Hosts that DO keep
# :database_authenticatable get our SSO landing on GET; Devise's
# POST /admin/login (password sign-in) is unaffected because it
# lives on the same path with a different verb.
#
# Mount target follows `Devise.available_router_name`: hosts that
# mount Devise inside an engine (pbx-api / yeti-web pattern) set
# `Devise.router_name = :admin_panel`, which pins Devise URL
# helpers to `AdminPanel::Engine.routes`. We mount in the same
# route set so `<Engine>.routes.url_helpers.new_admin_user_session_path`
# resolves. Defaults to `Rails.application.routes` when unset.
initializer 'activeadmin_oidc.mount_oidc_sessions_routes' do |app|
# after_initialize fires once at boot. `RouteSet#clear!` deliberately
# preserves the append/prepend queues across reloads, so re-running
# this hook (as `to_prepare` would in dev) accumulates duplicate
# append callbacks and crashes the second draw with
# "Invalid route name, already in use: 'new_admin_user_session'".
app.config.after_initialize do
next unless Engine.oidc_enabled?

cfg = ActiveAdmin::Oidc.config
login_path = cfg.login_path
logout_path = cfg.logout_path
scope_name = Engine.admin_user_class.model_name.singular.to_sym

Engine.session_routes_target(app).append do
devise_scope scope_name do
get login_path, to: 'active_admin/devise/sessions#new', as: :"new_#{scope_name}_session"
delete logout_path, to: 'active_admin/devise/sessions#destroy', as: :"destroy_#{scope_name}_session"
end
end
end
end
end
end
end
27 changes: 19 additions & 8 deletions lib/activeadmin/oidc/test_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,28 @@ def reset_oidc_stubs
end
end

# RSpec support for oidc_mode tag filtering.
# Require this file in spec_helper or rails_helper to auto-configure:
# Opt-in RSpec support for the `oidc_mode` tag.
#
# Hosts where OIDC is OPTIONAL (some envs run without an IdP) call:
#
# require "activeadmin/oidc/test_helpers"
# ActiveAdmin::Oidc::RSpecSupport.install!
#
# That installs auto-include + skips `oidc_mode: true` specs when
# `:omniauthable` isn't loaded, and toggles `CI_RUN_OIDC=true` to
# run only OIDC specs in a focused CI job.
#
# Hosts where OIDC is MANDATORY (yeti-web / pbx-api / didww-rs)
# skip the helper entirely and just include the module directly:
#
# require "activeadmin/oidc/test_helpers"
#
# Specs tagged `oidc_mode: true` will be skipped unless the AdminUser
# model has :omniauthable loaded. Set CI_RUN_OIDC=true in your CI job
# to run only OIDC-tagged specs.
# RSpec.configure do |c|
# c.include ActiveAdmin::Oidc::TestHelpers
# c.after { reset_oidc_stubs }
# end
#
# No filter, no skip logic, no CI env var to remember.
module RSpecSupport
def self.install!
return unless defined?(RSpec)
Expand All @@ -98,6 +112,3 @@ def self.install!
end
end
end

# Auto-install RSpec support when required during a test run.
ActiveAdmin::Oidc::RSpecSupport.install!
2 changes: 1 addition & 1 deletion spec/dummy/app/models/admin_user.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

class AdminUser < ApplicationRecord
devise :database_authenticatable, :omniauthable,
devise :omniauthable,
omniauth_providers: [:oidc]

validates :email, presence: true
Expand Down
1 change: 0 additions & 1 deletion spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
create_table :admin_users, force: :cascade do |t|
t.string :email
t.string :username
t.string :encrypted_password, default: "", null: false
t.string :provider
t.string :uid
t.text :oidc_raw_info
Expand Down
4 changes: 4 additions & 0 deletions spec/dummy_engine/Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

require_relative "config/application"
Rails.application.load_tasks
9 changes: 9 additions & 0 deletions spec/dummy_engine/app/admin/dashboard.rb
Original file line number Diff line number Diff line change
@@ -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 "Engine dummy dashboard."
end
end
3 changes: 3 additions & 0 deletions spec/dummy_engine/app/assets/config/manifest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_directory ../javascripts .js
1 change: 1 addition & 0 deletions spec/dummy_engine/app/assets/javascripts/active_admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// No-op JS for dummy app
1 change: 1 addition & 0 deletions spec/dummy_engine/app/assets/stylesheets/active_admin.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* No-op stylesheet for dummy app */
4 changes: 4 additions & 0 deletions spec/dummy_engine/app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class ApplicationController < ActionController::Base
end
10 changes: 10 additions & 0 deletions spec/dummy_engine/app/models/admin_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

class AdminUser < ApplicationRecord
devise :omniauthable,
omniauth_providers: [:oidc]

validates :email, presence: true

serialize :oidc_raw_info, coder: JSON
end
5 changes: 5 additions & 0 deletions spec/dummy_engine/app/models/application_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
5 changes: 5 additions & 0 deletions spec/dummy_engine/config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

require_relative "config/environment"
run Rails.application
Rails.application.load_server
47 changes: 47 additions & 0 deletions spec/dummy_engine/config/application.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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
end

Bundler.require(*Rails.groups)

require "devise"
require "activeadmin"
require "activeadmin-oidc"

# Mimic pbx-api / yeti-web pattern: an in-tree engine (non-isolated)
# that mounts devise_for inside its own routes and pins Devise's URL
# helpers to itself via `router_name: :admin_panel`.
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "admin_panel"

module Dummy
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_engine_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
5 changes: 5 additions & 0 deletions spec/dummy_engine/config/boot.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__)

require "bundler/setup"
Loading
Loading