From 4e14178b0958a778ce9e54397f708ff0125958f7 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Mon, 1 Jun 2026 13:30:37 +0200 Subject: [PATCH 1/2] README: drop mixed-mode block, add Testing section, minor accuracy fixes Mixed-mode setup (devise :database_authenticatable + :omniauthable on the same model) was carrying its weight as an option in the README, but the gem now mounts session routes unconditionally and the password-form flow it implied isn't something the gem adds value to. Drops the second model snippet and the explanatory paragraph; the gem is positioned as OIDC-only. Adds a Testing section documenting ActiveAdmin::Oidc::TestHelpers (stub_oidc_sign_in / stub_oidc_failure / reset_oidc_stubs) and the opt-in RSpecSupport.install! filter machinery (for hosts where OIDC is optional and want oidc_mode: tag + CI_RUN_OIDC env filtering). Minor wording fixes: 'Session routes' description in the engine-features list now notes scope is derived from admin_user_class, not hardcoded; mention that hosts mounting Devise inside an engine need to pass router_name: to devise_for too; updated Custom login view rationale to drop the password-form reference. --- README.md | 64 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 761ea7c..559d125 100644 --- a/README.md +++ b/README.md @@ -36,35 +36,19 @@ 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, - :rememberable, - :omniauthable, omniauth_providers: [:oidc] - - serialize :oidc_raw_info, coder: JSON + serialize :oidc_raw_info, coder: JSON # Postgres jsonb: drop this line 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. +OIDC is the only authentication mechanism — `:database_authenticatable`, `encrypted_password`, and password reset / lockable / confirmable flows are not used. The IdP owns identity, recovery, MFA, and lockout. The engine auto-mounts `GET /admin/login` (SSO landing page) and `DELETE /admin/logout` so ActiveAdmin's login link still resolves without Devise's session routes. ### Engine-mounted Devise -If `devise_for :admin_users` lives inside a Rails engine (not the main app routes), set `Devise.router_name = :` in `config/initializers/devise.rb`. The gem reads this and mounts its session routes inside that engine's route set so `.routes.url_helpers.new__session_path` resolves correctly. +If `devise_for :admin_users` lives inside a Rails engine (not the main app routes), set `Devise.router_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 `.routes.url_helpers.new__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`: @@ -88,7 +72,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`). +* **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. * **Parameter filtering** — `code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` are added to `Rails.application.config.filter_parameters`. @@ -263,7 +247,7 @@ AdminUser.last.oidc_raw_info ## Custom login view -The gem ships a minimal SSO-only login page (a single button, no email/password fields). If you need a different layout — for instance, a combined SSO + password form for a break-glass mode — drop your own template at: +The gem ships a minimal SSO-only login page (a single button, no email/password fields). If you need a different layout — for instance, different branding, an explanatory paragraph, or multiple OmniAuth strategies — drop your own template at: ``` app/views/active_admin/devise/sessions/new.html.erb @@ -299,6 +283,44 @@ The gem logs internal diagnostics (on_login exceptions, omniauth failures) via ` ActiveAdmin::Oidc.logger = MyStructuredLogger.new ``` +## Testing + +`require "activeadmin/oidc/test_helpers"` exposes `ActiveAdmin::Oidc::TestHelpers` with three methods for stubbing OmniAuth in specs: + +```ruby +stub_oidc_sign_in(sub: "alice-sub", claims: { "email" => "alice@example.com", "roles" => ["admin"] }) +stub_oidc_failure(:invalid_credentials) +reset_oidc_stubs # call in an after hook +``` + +Wire them up in `rails_helper.rb`: + +```ruby +require "activeadmin/oidc/test_helpers" + +RSpec.configure do |config| + config.include ActiveAdmin::Oidc::TestHelpers + config.after { reset_oidc_stubs } +end +``` + +### Optional `oidc_mode:` tag filter + +For host apps where OIDC is optional (some envs run without an IdP / `config/oidc.yml`), the gem ships an opt-in RSpec filter that: + +* Includes `TestHelpers` only on specs tagged `oidc_mode: true`. +* Skips those specs unless the AdminUser model has `:omniauthable` loaded. +* Runs only OIDC-tagged specs when `CI_RUN_OIDC=true` is set (and excludes them otherwise) — useful for a dedicated CI job. + +Install it explicitly: + +```ruby +require "activeadmin/oidc/test_helpers" +ActiveAdmin::Oidc::RSpecSupport.install! +``` + +If OIDC is mandatory for your host app, skip the `install!` call and just include `TestHelpers` directly (above). + ## License MIT — see [`LICENSE.txt`](LICENSE.txt). From 7ae1160a0cd9a908122dccb3b74a769ab0d2c8d7 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Mon, 1 Jun 2026 13:40:08 +0200 Subject: [PATCH 2/2] Drop unused RSpecSupport module; document explicit RSpec setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No host app uses ActiveAdmin::Oidc::RSpecSupport.install! — known consumers either inlined the equivalent four lines of RSpec.configure directly or never integrated TestHelpers at all. The CI_RUN_OIDC env var and automatic :omniauthable skip logic were carrying weight for a use case nobody has. Removes the RSpecSupport module from lib/activeadmin/oidc/test_helpers.rb. README's Testing section now shows the direct setup (include TestHelpers with :oidc_mode tag + after hook for reset_oidc_stubs). --- README.md | 26 ++++++--------- lib/activeadmin/oidc/test_helpers.rb | 47 ---------------------------- 2 files changed, 10 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 559d125..9951fe3 100644 --- a/README.md +++ b/README.md @@ -293,34 +293,28 @@ stub_oidc_failure(:invalid_credentials) reset_oidc_stubs # call in an after hook ``` -Wire them up in `rails_helper.rb`: +Wire them up in `rails_helper.rb`. The `oidc_mode: true` tag scopes the helpers and the cleanup hook to specs that actually need OIDC stubs: ```ruby require "activeadmin/oidc/test_helpers" RSpec.configure do |config| - config.include ActiveAdmin::Oidc::TestHelpers - config.after { reset_oidc_stubs } + config.include ActiveAdmin::Oidc::TestHelpers, oidc_mode: true + config.after(:each, :oidc_mode) { reset_oidc_stubs } end ``` -### Optional `oidc_mode:` tag filter - -For host apps where OIDC is optional (some envs run without an IdP / `config/oidc.yml`), the gem ships an opt-in RSpec filter that: - -* Includes `TestHelpers` only on specs tagged `oidc_mode: true`. -* Skips those specs unless the AdminUser model has `:omniauthable` loaded. -* Runs only OIDC-tagged specs when `CI_RUN_OIDC=true` is set (and excludes them otherwise) — useful for a dedicated CI job. - -Install it explicitly: +Then in your specs: ```ruby -require "activeadmin/oidc/test_helpers" -ActiveAdmin::Oidc::RSpecSupport.install! +RSpec.describe "OIDC sign-in", :oidc_mode do + it "signs in" do + stub_oidc_sign_in(claims: { "email" => "a@b.example" }) + # ... + end +end ``` -If OIDC is mandatory for your host app, skip the `install!` call and just include `TestHelpers` directly (above). - ## License MIT — see [`LICENSE.txt`](LICENSE.txt). diff --git a/lib/activeadmin/oidc/test_helpers.rb b/lib/activeadmin/oidc/test_helpers.rb index d82c256..4bf12bb 100644 --- a/lib/activeadmin/oidc/test_helpers.rb +++ b/lib/activeadmin/oidc/test_helpers.rb @@ -63,52 +63,5 @@ def reset_oidc_stubs OmniAuth.config.request_validation_phase = @_oidc_saved_request_validation_phase if defined?(@_oidc_saved_request_validation_phase) end end - - # 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 skip the helper entirely and just - # include the module directly: - # - # require "activeadmin/oidc/test_helpers" - # - # 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) - - RSpec.configure do |config| - config.include TestHelpers, oidc_mode: true - config.after(:each, :oidc_mode) { reset_oidc_stubs } - - config.before(:each, :oidc_mode) do - admin_class = ActiveAdmin::Oidc.config.admin_user_class - klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class - unless klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable) - skip 'requires OIDC mode (run with config/oidc.yml in place and CI_RUN_OIDC=true)' - end - end - - if ENV['CI_RUN_OIDC'].present? - config.filter_run_including oidc_mode: true - else - config.filter_run_excluding oidc_mode: true - end - end - end - end end end