diff --git a/README.md b/README.md index 761ea7c..9951fe3 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 + serialize :oidc_raw_info, coder: JSON # Postgres jsonb: drop this line 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 -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,38 @@ 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`. 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, oidc_mode: true + config.after(:each, :oidc_mode) { reset_oidc_stubs } +end +``` + +Then in your specs: + +```ruby +RSpec.describe "OIDC sign-in", :oidc_mode do + it "signs in" do + stub_oidc_sign_in(claims: { "email" => "a@b.example" }) + # ... + end +end +``` + ## 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