Skip to content

Support OIDC-only mode without :database_authenticatable#3

Open
Fivell wants to merge 7 commits into
mainfrom
oidc-only-without-database-authenticatable
Open

Support OIDC-only mode without :database_authenticatable#3
Fivell wants to merge 7 commits into
mainfrom
oidc-only-without-database-authenticatable

Conversation

@Fivell
Copy link
Copy Markdown
Member

@Fivell Fivell commented May 27, 2026

Summary

Host apps can now use SSO-only without keeping :database_authenticatable as dead code:

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

— and the encrypted_password column is no longer required.

Why

Previously the README told hosts to include :database_authenticatable even when the app only used SSO. The reason was not password login — it was that Devise only generates session routes (new_admin_user_session_path, destroy_admin_user_session_path) as a side effect of :database_authenticatable. Without those, ActiveAdmin redirect to /admin/login 404s.

Host apps then carried defensive scaffolding to neutralise the unused module: encrypted_password column, valid_password? override returning false, password_required? override returning false. That is busywork to load a module the app does not want.

How

  • New initializer activeadmin_oidc.mount_oidc_sessions_routes — when the gem detects :omniauthable on the AdminUser model, it appends to the host route set inside devise_scope :admin_user:
    get    '/admin/login',  to: 'active_admin/oidc/devise/sessions#new',     as: :new_admin_user_session
    delete '/admin/logout', to: 'active_admin/oidc/devise/sessions#destroy', as: :destroy_admin_user_session
  • New controller ActiveAdmin::Oidc::Devise::SessionsController < ActiveAdmin::Devise::SessionsControllernew renders the existing SSO landing view; destroy is inherited from Devise (warden logout, independent of auth modules).
  • OmniauthCallbacksController#after_omniauth_failure_path_for overridden to use new_admin_user_session_path (always defined now) instead of Devise new_session_path proxy, which only exists when :database_authenticatable is loaded.

Mixed mode

The mount is unconditional — there is no oidc_only? gate. Hosts that DO keep :database_authenticatable get our SSO landing on GET /admin/login. Devise's POST /admin/login (password sign-in) is unaffected because it lives on the same path with a different verb. If someone genuinely wants both SSO button and password form on one page, they override the view (app/views/active_admin/devise/sessions/new.html.erb) themselves — that is a presentation concern, not routing.

Why no gate:

  • :recoverable / :lockable / :confirmable belong to the IdP under SSO — recovery flows there, not in the app.
  • Migration period (cutover from password to SSO) is typically handled by disabling :database_authenticatable while keeping the column for old records; the module is already off.
  • Mixed UI is rare and host-customisable.

Tests

  • 96 existing specs now pass against an OIDC-only dummy app (model: devise :omniauthable, schema: no encrypted_password).
  • Verified locally that flipping the dummy back to :database_authenticatable + :omniauthable also passes — backwards compatibility preserved.
  • 3 new unit specs in spec/unit/engine_spec.rb cover the oidc_enabled? predicate.

Test plan

  • CI green across the full matrix.
  • Smoke-test on a host app where AdminUser model has devise :omniauthable only (pbx-api).

Fivell added 7 commits May 27, 2026 21:34
When the host AdminUser model uses devise :omniauthable alone (no :database_authenticatable), Devise doesn't mount session routes, so ActiveAdmin's redirect to new_admin_user_session_path 404s.

Engine now detects this configuration via Engine.oidc_only? and mounts GET /admin/login + DELETE /admin/logout under the existing devise scope. /admin/login renders the same SSO landing view; /admin/logout signs out via warden.

Backwards compatible: when :database_authenticatable is present, the auto-mount is skipped and Devise's own routes win.

Dummy app now exercises OIDC-only mode (no encrypted_password column), proving the new path end-to-end. README updated with both setup options.
The gate existed to avoid clashing with Devise's session routes when the host kept :database_authenticatable. In practice OIDC consumers either use :omniauthable alone (route mount needed) or want SSO-only UX anyway.

Mixed mode (SSO + password on same login page) is exotic — recoverable/lockable/confirmable belong to the IdP under SSO, migration periods just disable :database_authenticatable while keeping the column, and a host that genuinely needs both forms is already customising the view itself.

Now the engine always mounts GET /admin/login and DELETE /admin/logout. POST /admin/login (Devise password sign-in) is unaffected when :database_authenticatable is loaded — same path, different verb.
Reproduces the pbx-api / yeti-web pattern: AdminPanel::Engine (non-isolated) hosts devise_for :admin_users + ActiveAdmin inside its own routes, with Devise.router_name = :admin_panel pinning URL helpers to the engine.

Without fixing the gem, AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path is undefined and Capybara visit to /admin/login fails because the gem currently mounts on Rails.application.routes, not the engine.

Adds spec/dummy_engine/ (full second dummy app) and spec/engine/ (separate spec dir loaded by engine_rails_helper.rb). Default rspec run is unaffected via .rspec --exclude-pattern.
Hosts that mount Devise inside a Rails engine (pbx-api / yeti-web) set Devise.router_name = :admin_panel so Devise looks up URL helpers on AdminPanel::Engine.routes instead of Rails.application.routes.

Previously the gem mounted /admin/login + /admin/logout unconditionally on Rails.application.routes, so AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path stayed undefined and any host-side Devise::FailureApp subclass that redirected via the engine's helpers (e.g. pbx-api's PbxDevise#redirect_url) raised NoMethodError.

New Engine.session_routes_target(app) resolves the right route set by walking Rails::Engine.subclasses for the one whose engine_name matches Devise.available_router_name; falls back to Rails.application.routes when router_name is unset or :main_app.
spec/engine/ uses spec/dummy_engine/ via a uniquely-named engine_rails_helper.rb. Default rspec excludes it; CI runs it as its own step so the second dummy app boots in a fresh process.
Adds config.login_path (default /admin/login) and config.logout_path (default /admin/logout). The engine reads them when mounting session routes, so hosts whose Devise lives inside an isolated Rails engine can configure engine-relative paths (e.g. /login when the engine is mounted at /admin) and avoid mount-prefix duplication that would otherwise turn /admin/login into /admin/admin/login.

Adds spec/dummy_isolated/ (third dummy app exercising isolate_namespace AdminPanel mounted at /admin) and spec/isolated/ specs verifying the engine's url helpers and route table see the correct paths.

CI runs the isolated suite in its own step.
Global gitignore excludes database.yml; the existing dummy app un-ignored its own copy via !/spec/dummy/config/database.yml. Add the same un-ignore for the two new dummies so CI can boot them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant