From eb20844bf5e8729baee05009423bfe7913e9def3 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Tue, 25 Aug 2026 12:25:51 +0300 Subject: [PATCH 1/4] Add stub login for development OIDC-only admin panels are painful to develop against: the IdP has to know your local redirect URI, which breaks the moment you run on a different port or two apps share one client. Projects work around it with per-repo bypass controllers that skip the gem's whole provisioning path, so authorization is never exercised locally. Adds `stub_login_enabled` / `stub_login_claims`, which put a second button on the normal login page. Nothing is automatic: the page still renders and the user still clicks. The button POSTs to the gem's own callbacks controller, which runs the fabricated claims through the same UserProvisioner a real callback uses -- identity lookup, takeover guard, the host's on_login hook, oidc_raw_info and active_for_authentication? all still apply. The provider stays "oidc" rather than a separate stub value, so a stub row is still matched by a later real SSO login instead of tripping the takeover guard. Guarded three ways: boot raises when enabled under the production env, the route is not drawn when disabled, and the action re-checks both. Enabling it logs a warning at boot and renders a banner naming the identity the button signs in as. Also fixes the published login view templates, which used generator escaped `<%%=` while being copied verbatim -- a host that published the view got literal ERB tags rendered as page text. --- README.md | 125 +++++++++++ .../devise/omniauth_callbacks_controller.rb | 57 ++++- app/helpers/active_admin/oidc/view_helpers.rb | 58 +++++ .../active_admin/devise/sessions/new.html.erb | 63 ++++-- lib/activeadmin/oidc/configuration.rb | 60 +++++- lib/activeadmin/oidc/engine.rb | 63 ++++++ lib/activeadmin/oidc/version.rb | 2 +- .../oidc/install/templates/initializer.rb.tt | 27 +++ .../install/templates/sessions_new.html.erb | 39 +++- .../templates/sessions_new_v4.html.erb | 36 +++- spec/generators/install_generator_spec.rb | 23 ++ .../features/isolated_stub_login_spec.rb | 50 +++++ spec/requests/stub_login_spec.rb | 202 ++++++++++++++++++ spec/unit/configuration_spec.rb | 78 +++++++ 14 files changed, 844 insertions(+), 39 deletions(-) create mode 100644 app/helpers/active_admin/oidc/view_helpers.rb create mode 100644 spec/isolated/features/isolated_stub_login_spec.rb create mode 100644 spec/requests/stub_login_spec.rb diff --git a/README.md b/README.md index 9951fe3..b0f74b3 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ ActiveAdmin::Oidc.configure do |c| # By default PKCE is enabled iff client_secret is blank. Override: # c.pkce = true + # --- Stub login (development only) ------------------------------------ + # See "Stub login" below. Boot fails if enabled in production. + # c.stub_login_enabled = Rails.env.development? + # c.stub_login_claims = { "sub" => "stub-uid", "email" => "stub@example.com" } + # --- Authorization hook (REQUIRED) ------------------------------------ c.on_login = ->(admin_user, claims) { # ... see "The on_login hook" below @@ -136,6 +141,9 @@ end | `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 | +| `stub_login_enabled` | `false` | Development sign-in without the IdP; see below | +| `stub_login_claims` | `{"sub" => "stub-uid", "email" => "stub@example.com"}` | Claims the stub button signs in with (Hash or callable) | +| `stub_login_button_label` | `"Sign in with stub login (no IdP)"` | Label on the stub button | ## The `on_login` hook @@ -243,8 +251,125 @@ AdminUser.last.oidc_raw_info * 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). * **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. +* In development, `stub_login_enabled` adds a second button that signs in without contacting the IdP — see "Stub login" below. * 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. +## Stub login (development) + +When OIDC is the only way in, local development gets harder than the feature +you were trying to build. Some providers accept a `localhost:3000` redirect +URI — until you need a different port, or a second app that also speaks OIDC on +the same machine. Registering and juggling those redirect URIs is work that has +nothing to do with the change you are making. + +Stub login is the escape hatch. Enable it and the login page renders exactly as +it always does, with a second button next to the SSO one: + +```ruby +ActiveAdmin::Oidc.configure do |c| + # Adds a login-page button that signs in with locally fabricated claims + # instead of redirecting to the IdP. Nothing happens automatically — the + # login page still renders and the user still clicks. + c.stub_login_enabled = Rails.env.development? + + # The claims that button signs in with — the same hash `on_login` + # receives. Must contain "sub" and your configured `identity_claim`. + c.stub_login_claims = { + "sub" => "stub-uid", + "email" => "stub@example.com" + } + + # c.stub_login_button_label = "Sign in as a developer" +end +``` + +With it on, `issuer` and `client_id` are no longer required, so a machine with +no IdP credentials at all still boots and signs in. When they *are* configured, +both buttons render and you can exercise the real flow whenever you want. + +### It is not a separate code path + +The stub button POSTs to the gem's own callbacks controller, which builds the +claims hash and hands it to the **same `UserProvisioner` a real callback uses**. +That means identity lookup by `(provider, uid)`, the account-takeover guard, +your `on_login` hook, `oidc_raw_info`, and Devise's `active_for_authentication?` +check all still run. Authorization you develop against the stub is the +authorization you get against the IdP. + +Two consequences worth internalising: + +* **Your `on_login` hook must accept the stub claims.** If it reads + `claims["groups"]` or Zitadel's nested roles claim, put those in + `stub_login_claims` — otherwise the stub is denied, correctly. +* **`provider` is always `"oidc"`,** never a separate `"stub"`/`"test"` value. + A row stamped with a different provider could never be matched by a later + real SSO login and would trip the takeover guard, permanently stranding the + record. + +### Switching identities without editing the initializer + +`stub_login_claims` also accepts any callable, evaluated per request: + +```ruby +c.stub_login_claims = lambda { + username = ENV.fetch("DEV_USER", "stub") + { + "sub" => "stub-#{username}", + "username" => username, + "email" => "#{username}@example.com", + "roles" => ENV.fetch("DEV_ROLES", "admin").split(",") + } +} +``` + +```sh +DEV_USER=alice bin/rails server +``` + +### Safety + +Stub login fabricates an authenticated admin session. It is guarded three ways: + +1. **Boot fails** with a `ConfigurationError` if `stub_login_enabled` is true + and `Rails.env.production?`. There is no override flag — a production-like + environment that genuinely needs the bypass must run under its own Rails env. +2. **The route is not drawn** at all unless stub login is enabled, so there is + no URL to probe when it is off. +3. **The action itself re-checks** the flag and the environment, covering a + config flipped at runtime. + +Enabling it also logs a warning at boot, and the login page renders a red banner +naming the identity the button will sign in as. + +### Hosts with a custom login view + +If your app ships its own `app/views/active_admin/devise/sessions/new.html.erb`, +the engine backs off and your view wins — including for the stub button, which +you then have to render yourself. These helpers are mixed into the sessions +controller either way: + +| Helper | Returns | +|---|---| +| `activeadmin_oidc_sso_configured?` | Whether a real flow can be started (`issuer` + `client_id` present) | +| `activeadmin_oidc_sso_login_path` | POST target for the real SSO button | +| `activeadmin_oidc_stub_login_enabled?` | Whether to render the stub button at all | +| `activeadmin_oidc_stub_login_path` | POST target for the stub button | +| `activeadmin_oidc_stub_login_identity` | The identity the stub will sign in as, for your warning banner | + +```erb +<% if activeadmin_oidc_stub_login_enabled? %> +

Signs in as <%= activeadmin_oidc_stub_login_identity %> without the IdP.

+ <%= button_to ActiveAdmin::Oidc.config.stub_login_button_label, + activeadmin_oidc_stub_login_path, + method: :post, data: { turbo: false } %> +<% end %> +``` + +The stub route is derived from `login_path`, so the engine-relative +`login_path = '/login'` an isolated engine needs produces +`/admin/login/stub` after the mount prefix — the helper always resolves it +correctly, a hardcoded path would not. + ## 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, different branding, an explanatory paragraph, or multiple OmniAuth strategies — drop your own template at: diff --git a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb index a43bf23..1d953c3 100644 --- a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +++ b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb @@ -32,6 +32,55 @@ def oidc claims['sub'] = auth['uid'] if claims['sub'].blank? && auth['uid'].present? claims['email'] = info['email'] if claims['email'].blank? && info['email'].present? + provision_and_sign_in(claims) + end + + # Development stub login: sign in with locally fabricated claims + # instead of a real authorization code flow, for machines whose + # redirect URI the IdP does not know. Deliberately runs the SAME + # provisioning path as `#oidc` -- identity lookup, the takeover + # guard, the host's `on_login` hook, oidc_raw_info, and the + # active_for_authentication? check all still apply, so what works + # locally is what works against the real IdP. + # + # The route only exists when stub login is enabled and the env is + # not production (see Engine), and boot fails outright if it is + # enabled in production. The check here is the last of those three + # layers, covering a flag flipped at runtime. + def stub + cfg = ActiveAdmin::Oidc.config + unless cfg.stub_login_enabled? && !::Rails.env.production? + head :not_found + return + end + + claims = cfg.resolved_stub_login_claims + cfg.validate_stub_login!(claims) + + ActiveAdmin::Oidc.logger.warn( + "[activeadmin-oidc] STUB LOGIN used for sub=#{claims['sub'].inspect} " \ + '-- no identity provider was contacted.' + ) + + provision_and_sign_in(claims) + rescue ActiveAdmin::Oidc::ConfigurationError => e + # Only reachable from a non-production env behind an enabled + # flag, so the real message is safe -- and necessary -- here. + flash[:alert] = "[activeadmin-oidc] stub login misconfigured: #{e.message}" + redirect_to after_omniauth_failure_path_for(resource_name) + end + + def failure + Rails.logger.warn("[activeadmin-oidc] omniauth failure: #{failure_message}") + flash[:alert] = ActiveAdmin::Oidc.config.access_denied_message + redirect_to after_omniauth_failure_path_for(resource_name) + end + + private + + # Shared tail of every sign-in path: turn a claims hash into a + # persisted, signed-in admin user, or into a denial flash. + def provision_and_sign_in(claims) admin_user = UserProvisioner.new( ActiveAdmin::Oidc.config, claims: claims, @@ -57,14 +106,6 @@ def oidc redirect_to after_omniauth_failure_path_for(resource_name) end - def failure - Rails.logger.warn("[activeadmin-oidc] omniauth failure: #{failure_message}") - flash[:alert] = ActiveAdmin::Oidc.config.access_denied_message - redirect_to after_omniauth_failure_path_for(resource_name) - end - - private - # Land on the ActiveAdmin namespace root after a successful SSO # sign-in instead of Devise's default (host app root). Hosts # that don't define a `/` route would otherwise hit a routing diff --git a/app/helpers/active_admin/oidc/view_helpers.rb b/app/helpers/active_admin/oidc/view_helpers.rb new file mode 100644 index 0000000..0312a04 --- /dev/null +++ b/app/helpers/active_admin/oidc/view_helpers.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module ActiveAdmin + module Oidc + # View helpers for the login page. Mixed into + # `ActiveAdmin::Devise::SessionsController` by the engine, so they are + # available both to the gem's own login view and to a host app that + # ships its own `app/views/active_admin/devise/sessions/new.html.erb` + # (in which case the engine backs off and the host must render the + # buttons itself). + module ViewHelpers + def activeadmin_oidc_config + ActiveAdmin::Oidc.config + end + + # True when a real authorization code flow can be started. False on + # a stub-login-only setup with no IdP credentials at all, where a + # rendered SSO button would just 404 (no OmniAuth strategy, hence + # no middleware listening on /admin/auth/oidc). + def activeadmin_oidc_sso_configured? + activeadmin_oidc_config.sso_configured? + end + + def activeadmin_oidc_sso_login_path + "#{OmniAuth.config.path_prefix}/#{ActiveAdmin::Oidc::Engine::PROVIDER_NAME}" + end + + # True when the stub login button should be rendered. Mirrors the + # conditions under which the engine draws the route, so the button + # is never shown without a target. + def activeadmin_oidc_stub_login_enabled? + activeadmin_oidc_config.stub_login_enabled? && + !::Rails.env.production? && + activeadmin_oidc_stub_login_path.present? + end + + # Resolved through the same router the Devise mapping uses, so it + # is correct when Devise (and therefore our routes) live inside a + # mounted engine. Returns nil when the route is not drawn. + def activeadmin_oidc_stub_login_path + scope = ActiveAdmin::Oidc::Engine.admin_user_class.model_name.singular + router = ::Devise.mappings[scope.to_sym]&.router_name || + ::Devise.available_router_name + send(router).public_send(:"#{scope}_stub_login_path") + rescue NoMethodError + nil + end + + # The identity the stub button will sign in as, for the warning + # banner. Nil when the claims are unusable, so the banner can say + # so instead of raising inside a view. + def activeadmin_oidc_stub_login_identity + claims = activeadmin_oidc_config.resolved_stub_login_claims + claims[activeadmin_oidc_config.identity_claim.to_s].presence + end + end + end +end diff --git a/app/views/active_admin/devise/sessions/new.html.erb b/app/views/active_admin/devise/sessions/new.html.erb index 700d875..1692aef 100644 --- a/app/views/active_admin/devise/sessions/new.html.erb +++ b/app/views/active_admin/devise/sessions/new.html.erb @@ -4,23 +4,62 @@ <%= site_title %> <%= set_page_title t('active_admin.devise.login.title') %> - <%= button_to ActiveAdmin::Oidc.config.login_button_label, - "#{OmniAuth.config.path_prefix}/oidc", - method: :post, - class: "activeadmin-oidc-login-button w-full", - form_class: 'formtastic', - data: { turbo: false } %> + <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> + <%= button_to ActiveAdmin::Oidc.config.login_button_label, + activeadmin_oidc_sso_login_path, + method: :post, + class: "activeadmin-oidc-login-button w-full", + form_class: 'formtastic', + data: { turbo: false } %> + <% end %> + + <% if activeadmin_oidc_stub_login_enabled? %> +
+

Stub login is enabled

+

+ The button below signs in as + <%= activeadmin_oidc_stub_login_identity || '(unconfigured identity)' %> + without contacting the identity provider. Never enable this outside development. +

+ <%= button_to ActiveAdmin::Oidc.config.stub_login_button_label, + activeadmin_oidc_stub_login_path, + method: :post, + class: "activeadmin-oidc-stub-login-button w-full", + form_class: 'formtastic', + data: { turbo: false } %> +
+ <% end %> <% else %>

<%= active_admin_application.site_title(self) %>

- <%= form_tag "#{OmniAuth.config.path_prefix}/oidc", - method: :post, - class: "activeadmin-oidc-login-form formtastic", - data: { turbo: false } do %> - <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, - class: "activeadmin-oidc-login-button" %> + <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> + <%= form_tag activeadmin_oidc_sso_login_path, + method: :post, + class: "activeadmin-oidc-login-form formtastic", + data: { turbo: false } do %> + <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, + class: "activeadmin-oidc-login-button" %> + <% end %> + <% end %> + + <% if activeadmin_oidc_stub_login_enabled? %> + <% end %>
<% end %> diff --git a/lib/activeadmin/oidc/configuration.rb b/lib/activeadmin/oidc/configuration.rb index 5bdda41..ceeaf4d 100644 --- a/lib/activeadmin/oidc/configuration.rb +++ b/lib/activeadmin/oidc/configuration.rb @@ -13,13 +13,20 @@ class Configuration 'Your account has no permission to access this admin panel.' DEFAULT_LOGIN_PATH = '/admin/login' DEFAULT_LOGOUT_PATH = '/admin/logout' + DEFAULT_STUB_LOGIN_BUTTON_LABEL = 'Sign in with stub login (no IdP)' + DEFAULT_STUB_LOGIN_CLAIMS = { + 'sub' => 'stub-uid', + 'email' => 'stub@example.com' + }.freeze 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 + :login_path, :logout_path, + :stub_login_enabled, :stub_login_claims, + :stub_login_button_label def initialize reset! @@ -41,6 +48,9 @@ def reset! @logout_path = DEFAULT_LOGOUT_PATH @on_login = nil @pkce_override = nil + @stub_login_enabled = false + @stub_login_claims = DEFAULT_STUB_LOGIN_CLAIMS + @stub_login_button_label = DEFAULT_STUB_LOGIN_BUTTON_LABEL self end @@ -54,9 +64,53 @@ def pkce=(value) @pkce_override = value end + def stub_login_enabled? + !!@stub_login_enabled + end + + # True when there is enough configuration to start a real + # authorization code flow. Stub-login-only setups (a dev machine + # with no IdP credentials at all) deliberately leave these blank, + # and the login view hides the SSO button rather than rendering + # one that 404s. + def sso_configured? + issuer.present? && client_id.present? + end + + # `stub_login_claims` may be a Hash or any callable returning one + # (so hosts can read ENV per request and switch dev identities + # without editing the initializer). Always returns String keys, + # the same shape `on_login` receives. + def resolved_stub_login_claims + raw = stub_login_claims + raw = raw.call if raw.respond_to?(:call) + (raw || {}).to_h.transform_keys(&:to_s) + end + + # The stub claims go through the same UserProvisioner as a real + # callback, which needs `sub` and the configured identity claim. + # Checking here (at boot for Hash claims, per request for + # callables) turns a misconfiguration into a readable message + # instead of the provisioner's generic access-denied flash. + def validate_stub_login!(claims = resolved_stub_login_claims) + if claims['sub'].blank? + raise ConfigurationError, 'stub_login_claims must contain a "sub" value' + end + + key = identity_claim.to_s + if claims[key].blank? + raise ConfigurationError, + "stub_login_claims must contain #{key.inspect} (the configured identity_claim)" + end + + true + end + def validate! - raise ConfigurationError, 'issuer is required' if issuer.blank? - raise ConfigurationError, 'client_id is required' if client_id.blank? + unless stub_login_enabled? + raise ConfigurationError, 'issuer is required' if issuer.blank? + raise ConfigurationError, 'client_id is required' if client_id.blank? + end raise ConfigurationError, 'on_login is required' if on_login.nil? raise ConfigurationError, 'on_login must be callable (respond to #call)' unless on_login.respond_to?(:call) diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 98d880b..b5e7899 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -70,6 +70,13 @@ def controllers view_path = File.expand_path('../../../app/views', __dir__) ::ActiveAdmin::Devise::SessionsController.prepend_view_path(view_path) end + + # Registered unconditionally, unlike the view path above: a + # host that ships its own login view still needs the helpers + # to render the SSO and stub-login buttons. + ::ActiveAdmin::Devise::SessionsController.helper( + ::ActiveAdmin::Oidc::ViewHelpers + ) end end end @@ -121,6 +128,43 @@ def controllers app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce] end + # Stub login fabricates an authenticated admin session without ever + # talking to the IdP. That is a complete authentication bypass, so + # it is refused outright in the production environment — there is no + # override flag. Hosts running a production-like environment that + # genuinely needs the bypass must run it under its own Rails env. + # + # Registered as an after_initialize (not a plain engine initializer) + # because engine initializers run *before* the host's + # config/initializers, where `stub_login_enabled` is set. + initializer 'activeadmin_oidc.stub_login_guard' do |app| + app.config.after_initialize { Engine.enforce_stub_login_policy! } + end + + # Extracted from the initializer above so it can be exercised + # directly in specs without booting a second application. + def self.enforce_stub_login_policy! + cfg = ActiveAdmin::Oidc.config + return false unless cfg.stub_login_enabled? + + if ::Rails.env.production? + raise ConfigurationError, + 'ActiveAdmin::Oidc stub_login_enabled is true in the production ' \ + 'environment. Stub login signs users in without contacting the ' \ + 'identity provider and must never be reachable in production.' + end + + # Eager validation only for a literal Hash; callables are + # per-request by design and are validated in the controller. + cfg.validate_stub_login! unless cfg.stub_login_claims.respond_to?(:call) + + ActiveAdmin::Oidc.logger.warn( + '[activeadmin-oidc] STUB LOGIN IS ENABLED. The login page offers a ' \ + 'button that signs in without contacting the identity provider.' + ) + true + 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 @@ -174,6 +218,25 @@ def controllers logout_via = [*::Devise.sign_out_via, aa_method].compact.uniq match logout_path, to: ::ActiveAdmin::Devise::SessionsController.action(:destroy), as: :"destroy_#{scope_name}_session", via: logout_via + + # Development stub login. Read the flag here (draw time) + # rather than in the enclosing after_initialize so + # `Rails.application.reload_routes!` reflects config + # changes — and so the route simply does not exist when + # the feature is off. The production check duplicates the + # boot guard above on purpose: this is an auth bypass, and + # a route that is never drawn cannot be probed. + if ActiveAdmin::Oidc.config.stub_login_enabled? && !::Rails.env.production? + # Resolve the controller per request instead of capturing + # `.action(:stub)` at draw time: the class lives in the + # engine's app/ and is reloadable, and stub login runs in + # development where a captured constant goes stale on the + # first reload. A lambda also sidesteps the module scoping + # an isolated engine would apply to a string target. + post "#{login_path}/stub", + to: ->(env) { ::ActiveAdmin::Oidc::Devise::OmniauthCallbacksController.action(:stub).call(env) }, + as: :"#{scope_name}_stub_login" + end end end end diff --git a/lib/activeadmin/oidc/version.rb b/lib/activeadmin/oidc/version.rb index d3de8a5..458ffdb 100644 --- a/lib/activeadmin/oidc/version.rb +++ b/lib/activeadmin/oidc/version.rb @@ -2,6 +2,6 @@ module ActiveAdmin module Oidc - VERSION = "2.1.3" + VERSION = "2.2.0" end end diff --git a/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt b/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt index a6e40a2..630ad86 100644 --- a/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt +++ b/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt @@ -21,6 +21,33 @@ ActiveAdmin::Oidc.configure do |c| # --- Login button label ---------------------------------------------- # c.login_button_label = "Sign in with Corporate SSO" + # --- Stub login (development only) ------------------------------------ + # Adds a second button to the login page that signs in with locally + # fabricated claims instead of redirecting to the IdP. Nothing is + # automatic: the login page still renders and you still click. Useful + # when the IdP does not know your local redirect URI -- a non-default + # port, or two apps sharing one OIDC client. + # + # The claims run through the normal pipeline, `on_login` included, so + # authorization behaves exactly as it will against the real IdP. + # Boot fails if this is enabled in the production environment. + # + # c.stub_login_enabled = Rails.env.development? + # + # Claims the stub button signs in with -- the same hash `on_login` + # receives. Must contain "sub" and your `identity_claim`. Add whatever + # else your `on_login` hook reads (roles, groups, department...). + # Accepts a Hash, or a callable evaluated per request: + # + # c.stub_login_claims = -> { { "sub" => ENV.fetch("DEV_USER"), ... } } + # + # c.stub_login_claims = { + # "sub" => "stub-uid", + # "email" => "stub@example.com" + # } + # + # c.stub_login_button_label = "Sign in as a developer" + # --- on_login hook --------------------------------------------------- # Called with (admin_user, claims) after identity lookup and before # save. Mutate admin_user in place, return truthy to allow sign-in, diff --git a/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb b/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb index c7b277b..0cf748a 100644 --- a/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb +++ b/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb @@ -1,11 +1,34 @@
-

<%%= active_admin_application.site_title(self) %>

+

<%= active_admin_application.site_title(self) %>

- <%%= form_tag "#{OmniAuth.config.path_prefix}/oidc", - method: :post, - class: "activeadmin-oidc-login-form formtastic", - data: { turbo: false } do %> - <%%= submit_tag ActiveAdmin::Oidc.config.login_button_label, - class: "activeadmin-oidc-login-button" %> - <%% end %> + <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> + <%= form_tag activeadmin_oidc_sso_login_path, + method: :post, + class: "activeadmin-oidc-login-form formtastic", + data: { turbo: false } do %> + <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, + class: "activeadmin-oidc-login-button" %> + <% end %> + <% end %> + + <%# Development stub login. Renders only when + ActiveAdmin::Oidc.config.stub_login_enabled is true and the env is + not production. Delete this block if you never want the button. %> + <% if activeadmin_oidc_stub_login_enabled? %> + + <% end %>
diff --git a/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb b/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb index 875f972..ea69189 100644 --- a/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb +++ b/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb @@ -1,12 +1,34 @@

- <%%= site_title %> <%%= set_page_title t('active_admin.devise.login.title') %> + <%= site_title %> <%= set_page_title t('active_admin.devise.login.title') %>

- <%%= button_to ActiveAdmin::Oidc.config.login_button_label, - "#{OmniAuth.config.path_prefix}/oidc", - method: :post, - class: "activeadmin-oidc-login-button w-full", - form_class: 'formtastic', - data: { turbo: false } %> + <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> + <%= button_to ActiveAdmin::Oidc.config.login_button_label, + activeadmin_oidc_sso_login_path, + method: :post, + class: "activeadmin-oidc-login-button w-full", + form_class: 'formtastic', + data: { turbo: false } %> + <% end %> + + <%# Development stub login. Renders only when + ActiveAdmin::Oidc.config.stub_login_enabled is true and the env is + not production. Delete this block if you never want the button. %> + <% if activeadmin_oidc_stub_login_enabled? %> + + <% end %>
diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index f5ac3d2..4f4560d 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -134,11 +134,34 @@ def run_generator(args = []) describe "login view override" do before { run_generator } + let(:published_view) do + File.read( + File.join(destination_root, "app/views/active_admin/devise/sessions/new.html.erb") + ) + end + it "publishes app/views/active_admin/devise/sessions/new.html.erb" do expect(File).to exist( File.join(destination_root, "app/views/active_admin/devise/sessions/new.html.erb") ) end + + # The template is copied verbatim (copy_file, not template), so an + # escaped `<%%=` would survive into the host app and render as + # literal `<%= ... %>` text on the login page. + it "publishes real ERB tags, not generator-escaped ones" do + expect(published_view).not_to include("<%%") + expect(published_view).to include("<%=") + end + + it "includes the stub login block so the dev button still renders" do + expect(published_view).to include("activeadmin_oidc_stub_login_enabled?") + expect(published_view).to include("activeadmin_oidc_stub_login_path") + end + + it "posts the SSO button through the helper rather than a hardcoded path" do + expect(published_view).to include("activeadmin_oidc_sso_login_path") + end end describe "idempotency" do diff --git a/spec/isolated/features/isolated_stub_login_spec.rb b/spec/isolated/features/isolated_stub_login_spec.rb new file mode 100644 index 0000000..b8b56c3 --- /dev/null +++ b/spec/isolated/features/isolated_stub_login_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "isolated_rails_helper" + +# Stub login inside an isolated engine. The route is declared as +# "#{config.login_path}/stub", so with the engine-relative +# `login_path = '/login'` this host must configure, the effective URL +# after the `mount AdminPanel::Engine => '/admin'` prefix is +# `/admin/login/stub`. A hardcoded '/admin/login/stub' in the gem would +# become '/admin/admin/login/stub' here. +RSpec.feature "Isolated engine stub login", type: :feature do + before do + # spec_helper resets the gem config before every example, which + # wipes the engine-relative login_path the dummy app's initializer + # set. Restore it before redrawing routes. + ActiveAdmin::Oidc.configure do |c| + c.login_path = "/login" + c.logout_path = "/logout" + c.on_login = ->(*) { true } + c.stub_login_enabled = true + c.stub_login_claims = { "sub" => "stub-iso", "email" => "iso@example.com" } + end + Rails.application.reload_routes! + AdminUser.delete_all + end + + after do + ActiveAdmin::Oidc.reset! + Rails.application.reload_routes! + end + + it "declares the stub route engine-relative" do + paths = AdminPanel::Engine.routes.routes.map { |r| [r.verb, r.path.spec.to_s] } + expect(paths).to include(["POST", "/login/stub(.:format)"]) + end + + scenario "the login page's stub button signs in through the mount prefix" do + visit "/admin/login" + expect(page.body).to include(%(action="/admin/login/stub")) + expect(page.body).to include(ActiveAdmin::Oidc.config.stub_login_button_label) + + # Driven without following the redirect: on success the gem sends the + # user to a hardcoded /admin, which this dummy app (ActiveAdmin lives + # inside the mounted engine) does not route. + page.driver.browser.process(:post, "/admin/login/stub") + + expect(page.driver.browser.last_response.status).to eq(302) + expect(AdminUser.find_by(email: "iso@example.com")).not_to be_nil + end +end diff --git a/spec/requests/stub_login_spec.rb b/spec/requests/stub_login_spec.rb new file mode 100644 index 0000000..1f15395 --- /dev/null +++ b/spec/requests/stub_login_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Stub login is the development escape hatch for machines whose redirect +# URI the IdP does not know: the login page renders as usual, and a +# second button signs in with locally fabricated claims. The point of +# these specs is that the stub goes through the SAME provisioning path +# as a real callback -- on_login, the identity lookup, oidc_raw_info and +# the active_for_authentication? guard all still run. +RSpec.describe "Stub login", type: :request do + # The route is drawn conditionally on the config flag, so flipping the + # flag at runtime requires a redraw. The global config reset in + # spec_helper runs before each example, so undo the redraw afterwards + # or the route leaks into unrelated specs. + def enable_stub_login!(claims: nil) + ActiveAdmin::Oidc.config.stub_login_enabled = true + ActiveAdmin::Oidc.config.stub_login_claims = claims unless claims.nil? + Rails.application.reload_routes! + end + + before do + ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + c.on_login = ->(*) { true } + end + + AdminUser.delete_all + end + + after do + ActiveAdmin::Oidc.reset! + Rails.application.reload_routes! + end + + describe "the login page" do + it "renders no stub button or warning when stub login is off" do + get "/admin/login" + + expect(response.body).not_to include("activeadmin-oidc-stub-login") + expect(response.body).not_to include("Stub login is enabled") + end + + it "renders the stub button, the identity it signs in as, and a warning" do + enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) + + get "/admin/login" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Stub login is enabled") + expect(response.body).to include("dev@example.com") + expect(response.body).to include( + ActiveAdmin::Oidc::Configuration::DEFAULT_STUB_LOGIN_BUTTON_LABEL + ) + expect(response.body).to include(%(action="/admin/login/stub")) + end + + it "still renders the real SSO button alongside the stub one" do + enable_stub_login! + + get "/admin/login" + + expect(response.body).to include(%(action="/admin/auth/oidc")) + expect(response.body).to include(%(action="/admin/login/stub")) + end + + it "hides the SSO button when no IdP is configured at all" do + ActiveAdmin::Oidc.config.issuer = nil + ActiveAdmin::Oidc.config.client_id = nil + enable_stub_login! + + get "/admin/login" + + expect(response.body).not_to include(%(action="/admin/auth/oidc")) + expect(response.body).to include(%(action="/admin/login/stub")) + end + end + + describe "POST /admin/login/stub" do + it "is not routable when stub login is disabled" do + expect { post "/admin/login/stub" } + .to raise_error(ActionController::RoutingError) + end + + it "provisions the admin user from the configured claims and signs in" do + enable_stub_login!( + claims: { "sub" => "stub-1", "email" => "dev@example.com", "department" => "ops" } + ) + + expect { post "/admin/login/stub" }.to change(AdminUser, :count).by(1) + + user = AdminUser.find_by(email: "dev@example.com") + expect(user).not_to be_nil + # provider is the gem's own, never a separate "stub"/"test" value: + # a row stamped with a different provider could never be matched by + # a later real SSO login, and would trip the takeover guard. + expect(user.provider).to eq("oidc") + expect(user.uid).to eq("stub-1") + expect(user.oidc_raw_info).to include("sub" => "stub-1", "department" => "ops") + + expect(response).to be_redirect + expect(URI(response.location).path).to eq("/admin") + end + + it "reuses the existing row on a second stub login" do + enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) + + post "/admin/login/stub" + expect { post "/admin/login/stub" }.not_to change(AdminUser, :count) + end + + it "runs the host's on_login hook and honours a denial" do + called_with = nil + ActiveAdmin::Oidc.config.on_login = lambda { |_admin_user, claims| + called_with = claims + false + } + enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) + + expect { post "/admin/login/stub" }.not_to change(AdminUser, :count) + + expect(called_with).to include("sub" => "stub-1", "email" => "dev@example.com") + expect(response).to redirect_to("/admin/login") + follow_redirect! + expect(response.body).to include(ActiveAdmin::Oidc.config.access_denied_message) + end + + it "honours active_for_authentication? just like a real callback" do + ActiveAdmin::Oidc.config.on_login = lambda { |admin_user, _claims| + admin_user.enabled = false + true + } + enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) + + expect { post "/admin/login/stub" }.not_to change(AdminUser, :count) + expect(response).to redirect_to("/admin/login") + end + + it "accepts a callable so the identity can come from ENV per request" do + identity = "first@example.com" + enable_stub_login!(claims: -> { { "sub" => "stub-#{identity}", "email" => identity } }) + + post "/admin/login/stub" + expect(AdminUser.find_by(email: "first@example.com")).not_to be_nil + + identity = "second@example.com" + post "/admin/login/stub" + expect(AdminUser.find_by(email: "second@example.com")).not_to be_nil + end + + it "reports a misconfigured claims hash instead of a generic denial" do + enable_stub_login!(claims: { "sub" => "stub-1" }) # no identity claim + + post "/admin/login/stub" + + follow_redirect! + expect(response.body).to include("stub login misconfigured") + expect(response.body).to include("email") + end + + it "404s when the flag is flipped off after the route was drawn" do + enable_stub_login! + ActiveAdmin::Oidc.config.stub_login_enabled = false + + post "/admin/login/stub" + + expect(response).to have_http_status(:not_found) + end + end + + describe "the production guard" do + it "refuses to boot when stub login is enabled in production" do + ActiveAdmin::Oidc.config.stub_login_enabled = true + allow(Rails).to receive(:env).and_return( + ActiveSupport::StringInquirer.new("production") + ) + + expect { ActiveAdmin::Oidc::Engine.enforce_stub_login_policy! } + .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /production/) + end + + it "raises at boot when a literal claims hash is missing the identity claim" do + ActiveAdmin::Oidc.config.stub_login_enabled = true + ActiveAdmin::Oidc.config.stub_login_claims = { "sub" => "stub-1" } + + expect { ActiveAdmin::Oidc::Engine.enforce_stub_login_policy! } + .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /identity_claim/) + end + + it "does not evaluate a callable at boot" do + ActiveAdmin::Oidc.config.stub_login_enabled = true + ActiveAdmin::Oidc.config.stub_login_claims = -> { raise "must not be called at boot" } + + expect { ActiveAdmin::Oidc::Engine.enforce_stub_login_policy! }.not_to raise_error + end + + it "is a no-op when stub login is off" do + expect(ActiveAdmin::Oidc::Engine.enforce_stub_login_policy!).to be(false) + end + end +end diff --git a/spec/unit/configuration_spec.rb b/spec/unit/configuration_spec.rb index 5b4e7ee..46a97b7 100644 --- a/spec/unit/configuration_spec.rb +++ b/spec/unit/configuration_spec.rb @@ -113,6 +113,84 @@ config.on_login = "not a proc" expect { config.validate! }.to raise_error(ActiveAdmin::Oidc::ConfigurationError, /on_login/) end + + context "with stub login enabled" do + # A developer machine using only stub login has no IdP credentials + # to give, so requiring them would force fake values into the + # initializer just to boot. + it "stops requiring issuer and client_id" do + config.stub_login_enabled = true + config.issuer = nil + config.client_id = nil + + expect { config.validate! }.not_to raise_error + end + + it "still requires on_login, which stub sign-ins also run through" do + config.stub_login_enabled = true + config.on_login = nil + + expect { config.validate! }.to raise_error(ActiveAdmin::Oidc::ConfigurationError, /on_login/) + end + end + end + + describe "stub login configuration" do + it "is disabled by default" do + expect(config.stub_login_enabled?).to be(false) + end + + it "defaults to claims that satisfy the default identity_claim" do + expect { config.validate_stub_login! }.not_to raise_error + end + + it "stringifies symbol keys so the claims match what on_login receives" do + config.stub_login_claims = { sub: "s-1", email: "dev@example.com" } + + expect(config.resolved_stub_login_claims) + .to eq("sub" => "s-1", "email" => "dev@example.com") + end + + it "calls a callable on every read, so the identity can come from ENV" do + calls = 0 + config.stub_login_claims = lambda { + calls += 1 + { "sub" => "s-#{calls}", "email" => "dev#{calls}@example.com" } + } + + expect(config.resolved_stub_login_claims["sub"]).to eq("s-1") + expect(config.resolved_stub_login_claims["sub"]).to eq("s-2") + end + + it "rejects claims without a sub" do + expect { config.validate_stub_login!("email" => "dev@example.com") } + .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /sub/) + end + + it "rejects claims missing the configured identity_claim" do + config.identity_claim = :username + + expect { config.validate_stub_login!("sub" => "s-1", "email" => "dev@example.com") } + .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /username/) + end + + it "accepts claims carrying a non-default identity_claim" do + config.identity_claim = :username + + expect { config.validate_stub_login!("sub" => "s-1", "username" => "dev") } + .not_to raise_error + end + end + + describe "#sso_configured?" do + it "is true only when both issuer and client_id are present" do + config.issuer = "https://example.com" + config.client_id = "abc" + expect(config.sso_configured?).to be(true) + + config.client_id = nil + expect(config.sso_configured?).to be(false) + end end describe "#pkce" do From 0ee18588f86582751c89d6d3b593dbb1efa00dd9 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 27 Aug 2026 17:36:35 +0300 Subject: [PATCH 2/4] Simplify stub login to a single dev-only config method Replace the three stub_login_* config accessors with one method: c.stub_dev_env_login! do |claims| claims.merge("groups" => [ADMIN_GROUP]) end The method is a no-op outside the development environment, so the boot guard, the production checks and the validators it needed all go away. The login page keeps its single button; config.login_submit_path points it at the stub route while stub login is on. Removed: * app/helpers/active_admin/oidc/view_helpers.rb -- the views read the config directly, as they did before stub login existed. This also fixes a 500 on the generator-published view in hosts whose AdminUser lacks :omniauthable, where the helpers were never registered. * Engine.enforce_stub_login_policy! and its after_initialize. * Configuration#validate_stub_login! and #sso_configured?. * The validate! patch, restoring main's behaviour. * Isolated-engine support for the stub URL, now a documented limit. Fixed: * Claims are stringified all the way down, on a copy, so a nested symbol key no longer breaks stub/real parity and a mutating block cannot write back into the defaults. * The claims block may return a Hash or mutate the one it is given. * Stub sign-in gets its own flash instead of claiming OIDC succeeded. * The route reads login_path at draw time, and draws from login_submit_path so the button and the route cannot drift apart. Co-Authored-By: Clanker --- README.md | 155 ++++++------- .../devise/omniauth_callbacks_controller.rb | 27 +-- app/helpers/active_admin/oidc/view_helpers.rb | 58 ----- .../active_admin/devise/sessions/new.html.erb | 67 ++---- lib/activeadmin/oidc/configuration.rb | 102 +++++---- lib/activeadmin/oidc/engine.rb | 74 ++----- .../oidc/install/templates/initializer.rb.tt | 36 +-- .../install/templates/sessions_new.html.erb | 40 ++-- .../templates/sessions_new_v4.html.erb | 39 ++-- spec/generators/install_generator_spec.rb | 8 +- .../features/isolated_stub_login_spec.rb | 50 ----- spec/requests/stub_login_spec.rb | 205 +++++++++--------- spec/unit/configuration_spec.rb | 132 ++++++----- 13 files changed, 400 insertions(+), 593 deletions(-) delete mode 100644 app/helpers/active_admin/oidc/view_helpers.rb delete mode 100644 spec/isolated/features/isolated_stub_login_spec.rb diff --git a/README.md b/README.md index b0f74b3..bc20d5f 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,8 @@ ActiveAdmin::Oidc.configure do |c| # c.pkce = true # --- Stub login (development only) ------------------------------------ - # See "Stub login" below. Boot fails if enabled in production. - # c.stub_login_enabled = Rails.env.development? - # c.stub_login_claims = { "sub" => "stub-uid", "email" => "stub@example.com" } + # See "Stub login" below. A no-op outside the development environment. + # c.stub_dev_env_login! # --- Authorization hook (REQUIRED) ------------------------------------ c.on_login = ->(admin_user, claims) { @@ -141,9 +140,8 @@ end | `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 | -| `stub_login_enabled` | `false` | Development sign-in without the IdP; see below | -| `stub_login_claims` | `{"sub" => "stub-uid", "email" => "stub@example.com"}` | Claims the stub button signs in with (Hash or callable) | -| `stub_login_button_label` | `"Sign in with stub login (no IdP)"` | Label on the stub button | + +`stub_dev_env_login!` is a method, not an option — see "Stub login" below. ## The `on_login` hook @@ -251,7 +249,7 @@ AdminUser.last.oidc_raw_info * 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). * **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. -* In development, `stub_login_enabled` adds a second button that signs in without contacting the IdP — see "Stub login" below. +* In development, `stub_dev_env_login!` repoints that same button at a local sign-in that never contacts the IdP — see "Stub login" below. * 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. ## Stub login (development) @@ -262,113 +260,88 @@ URI — until you need a different port, or a second app that also speaks OIDC o the same machine. Registering and juggling those redirect URIs is work that has nothing to do with the change you are making. -Stub login is the escape hatch. Enable it and the login page renders exactly as -it always does, with a second button next to the SSO one: +Stub login is the escape hatch. Turn it on and the login page renders exactly as +it always does — same button, same label — but the button signs in with locally +fabricated claims instead of redirecting to the IdP. A red warning sits above +it while it is on. ```ruby ActiveAdmin::Oidc.configure do |c| - # Adds a login-page button that signs in with locally fabricated claims - # instead of redirecting to the IdP. Nothing happens automatically — the - # login page still renders and the user still clicks. - c.stub_login_enabled = Rails.env.development? - - # The claims that button signs in with — the same hash `on_login` - # receives. Must contain "sub" and your configured `identity_claim`. - c.stub_login_claims = { - "sub" => "stub-uid", - "email" => "stub@example.com" + c.on_login = ->(admin_user, claims) { + groups = Array(claims["groups"]) + return false unless groups.include?(ADMIN_GROUP) + + admin_user.super_admin = groups.include?("super-admins") + true } - # c.stub_login_button_label = "Sign in as a developer" + # Default claims: { "sub" => "stub-uid", "email" => "stub-dev@example.com" } + # The optional block patches or replaces them. + c.stub_dev_env_login! do |claims| + claims.merge("groups" => [ADMIN_GROUP]) + end end ``` -With it on, `issuer` and `client_id` are no longer required, so a machine with -no IdP credentials at all still boots and signs in. When they *are* configured, -both buttons render and you can exercise the real flow whenever you want. +`stub_dev_env_login!` is a **no-op outside the development environment** and +returns `false` there. That is the whole safety story: nothing to flip off +before a deploy, no boot guard to trip, and no route drawn anywhere else. +Leave the call uncommented in the initializer if you want. + +The block runs once per sign-in, not once at boot, so it can return a different +identity each time. ### It is not a separate code path -The stub button POSTs to the gem's own callbacks controller, which builds the -claims hash and hands it to the **same `UserProvisioner` a real callback uses**. -That means identity lookup by `(provider, uid)`, the account-takeover guard, -your `on_login` hook, `oidc_raw_info`, and Devise's `active_for_authentication?` -check all still run. Authorization you develop against the stub is the -authorization you get against the IdP. +The button POSTs to the gem's own callbacks controller, which hands the claims +to the **same `UserProvisioner` a real callback uses**. That means identity +lookup by `(provider, uid)`, the account-takeover guard, your `on_login` hook, +`oidc_raw_info`, and Devise's `active_for_authentication?` check all still run. +Authorization you develop against the stub is the authorization you get against +the IdP. -Two consequences worth internalising: +Three consequences worth internalising: * **Your `on_login` hook must accept the stub claims.** If it reads - `claims["groups"]` or Zitadel's nested roles claim, put those in - `stub_login_claims` — otherwise the stub is denied, correctly. -* **`provider` is always `"oidc"`,** never a separate `"stub"`/`"test"` value. - A row stamped with a different provider could never be matched by a later - real SSO login and would trip the takeover guard, permanently stranding the - record. - -### Switching identities without editing the initializer - -`stub_login_claims` also accepts any callable, evaluated per request: - -```ruby -c.stub_login_claims = lambda { - username = ENV.fetch("DEV_USER", "stub") - { - "sub" => "stub-#{username}", - "username" => username, - "email" => "#{username}@example.com", - "roles" => ENV.fetch("DEV_ROLES", "admin").split(",") - } -} -``` - -```sh -DEV_USER=alice bin/rails server -``` - -### Safety - -Stub login fabricates an authenticated admin session. It is guarded three ways: - -1. **Boot fails** with a `ConfigurationError` if `stub_login_enabled` is true - and `Rails.env.production?`. There is no override flag — a production-like - environment that genuinely needs the bypass must run under its own Rails env. -2. **The route is not drawn** at all unless stub login is enabled, so there is - no URL to probe when it is off. -3. **The action itself re-checks** the flag and the environment, covering a - config flipped at runtime. - -Enabling it also logs a warning at boot, and the login page renders a red banner -naming the identity the button will sign in as. + `claims["groups"]` or Zitadel's nested roles claim, add them in the block — + otherwise the stub is denied, correctly. You will find out the first time you + click the button, and fix it once. +* **Use a made-up email, never your real one.** A stub sign-in writes the fake + `sub` onto that row. Your real sign-in later brings the real `sub`, they do not + match, and the takeover guard locks you out of that account until someone + clears the columns by hand. +* **`provider` is always `"oidc"`,** never a separate `"stub"`/`"test"` value, + so the row a stub sign-in creates is an ordinary OIDC row. + +### Limits + +* **Development only.** Nothing else enables it. +* **Devise mounted inside an isolated engine is not supported.** The stub URL is + built as `"#{login_path}/stub"`, which does not account for a mount prefix. + The real SSO flow is unaffected. +* **CSRF protection is your app's.** The stub POST does not go through the + OmniAuth stack, so it relies on whatever your `ApplicationController` does. If + you have set `allow_forgery_protection = false` in `development.rb`, any web + page you visit can silently sign you into your local admin panel. ### Hosts with a custom login view If your app ships its own `app/views/active_admin/devise/sessions/new.html.erb`, -the engine backs off and your view wins — including for the stub button, which -you then have to render yourself. These helpers are mixed into the sessions -controller either way: - -| Helper | Returns | -|---|---| -| `activeadmin_oidc_sso_configured?` | Whether a real flow can be started (`issuer` + `client_id` present) | -| `activeadmin_oidc_sso_login_path` | POST target for the real SSO button | -| `activeadmin_oidc_stub_login_enabled?` | Whether to render the stub button at all | -| `activeadmin_oidc_stub_login_path` | POST target for the stub button | -| `activeadmin_oidc_stub_login_identity` | The identity the stub will sign in as, for your warning banner | +the engine backs off and your view wins, so you render the button yourself. +Everything you need is on the config object — no helpers to mix in: ```erb -<% if activeadmin_oidc_stub_login_enabled? %> -

Signs in as <%= activeadmin_oidc_stub_login_identity %> without the IdP.

- <%= button_to ActiveAdmin::Oidc.config.stub_login_button_label, - activeadmin_oidc_stub_login_path, - method: :post, data: { turbo: false } %> +<% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %> +

Stub login is enabled: this button never contacts the identity provider.

<% end %> + +<%= button_to ActiveAdmin::Oidc.config.login_button_label, + ActiveAdmin::Oidc.config.login_submit_path, + method: :post, data: { turbo: false } %> ``` -The stub route is derived from `login_path`, so the engine-relative -`login_path = '/login'` an isolated engine needs produces -`/admin/login/stub` after the mount prefix — the helper always resolves it -correctly, a hardcoded path would not. +`login_submit_path` returns the stub route while stub login is on and the real +OmniAuth entry point otherwise. ## Custom login view diff --git a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb index 1d953c3..271b0cd 100644 --- a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +++ b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb @@ -41,33 +41,28 @@ def oidc # provisioning path as `#oidc` -- identity lookup, the takeover # guard, the host's `on_login` hook, oidc_raw_info, and the # active_for_authentication? check all still apply, so what works - # locally is what works against the real IdP. + # locally is what works against the real IdP. In particular, a + # claims block that does not satisfy `on_login` is denied here + # exactly as the real IdP would deny it. # - # The route only exists when stub login is enabled and the env is - # not production (see Engine), and boot fails outright if it is - # enabled in production. The check here is the last of those three - # layers, covering a flag flipped at runtime. + # The route only exists when stub login is enabled, and + # `stub_dev_env_login!` only enables it in the development + # environment. The check here covers a flag flipped at runtime. def stub cfg = ActiveAdmin::Oidc.config - unless cfg.stub_login_enabled? && !::Rails.env.production? + unless cfg.stub_dev_env_login_enabled? head :not_found return end - claims = cfg.resolved_stub_login_claims - cfg.validate_stub_login!(claims) + claims = cfg.stub_dev_env_login_claims ActiveAdmin::Oidc.logger.warn( "[activeadmin-oidc] STUB LOGIN used for sub=#{claims['sub'].inspect} " \ '-- no identity provider was contacted.' ) - provision_and_sign_in(claims) - rescue ActiveAdmin::Oidc::ConfigurationError => e - # Only reachable from a non-production env behind an enabled - # flag, so the real message is safe -- and necessary -- here. - flash[:alert] = "[activeadmin-oidc] stub login misconfigured: #{e.message}" - redirect_to after_omniauth_failure_path_for(resource_name) + provision_and_sign_in(claims, kind: 'stub login (no IdP)') end def failure @@ -80,7 +75,7 @@ def failure # Shared tail of every sign-in path: turn a claims hash into a # persisted, signed-in admin user, or into a denial flash. - def provision_and_sign_in(claims) + def provision_and_sign_in(claims, kind: 'OIDC') admin_user = UserProvisioner.new( ActiveAdmin::Oidc.config, claims: claims, @@ -88,7 +83,7 @@ def provision_and_sign_in(claims) ).call sign_in_and_redirect admin_user, event: :authentication - set_flash_message(:notice, :success, kind: 'OIDC') if is_navigational_format? + set_flash_message(:notice, :success, kind: kind) if is_navigational_format? rescue ActiveAdmin::Oidc::InactiveError => e Rails.logger.warn("[activeadmin-oidc] inactive: #{e.inactive_message_key}") # Fall back to the standard `inactive` translation rather diff --git a/app/helpers/active_admin/oidc/view_helpers.rb b/app/helpers/active_admin/oidc/view_helpers.rb deleted file mode 100644 index 0312a04..0000000 --- a/app/helpers/active_admin/oidc/view_helpers.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module ActiveAdmin - module Oidc - # View helpers for the login page. Mixed into - # `ActiveAdmin::Devise::SessionsController` by the engine, so they are - # available both to the gem's own login view and to a host app that - # ships its own `app/views/active_admin/devise/sessions/new.html.erb` - # (in which case the engine backs off and the host must render the - # buttons itself). - module ViewHelpers - def activeadmin_oidc_config - ActiveAdmin::Oidc.config - end - - # True when a real authorization code flow can be started. False on - # a stub-login-only setup with no IdP credentials at all, where a - # rendered SSO button would just 404 (no OmniAuth strategy, hence - # no middleware listening on /admin/auth/oidc). - def activeadmin_oidc_sso_configured? - activeadmin_oidc_config.sso_configured? - end - - def activeadmin_oidc_sso_login_path - "#{OmniAuth.config.path_prefix}/#{ActiveAdmin::Oidc::Engine::PROVIDER_NAME}" - end - - # True when the stub login button should be rendered. Mirrors the - # conditions under which the engine draws the route, so the button - # is never shown without a target. - def activeadmin_oidc_stub_login_enabled? - activeadmin_oidc_config.stub_login_enabled? && - !::Rails.env.production? && - activeadmin_oidc_stub_login_path.present? - end - - # Resolved through the same router the Devise mapping uses, so it - # is correct when Devise (and therefore our routes) live inside a - # mounted engine. Returns nil when the route is not drawn. - def activeadmin_oidc_stub_login_path - scope = ActiveAdmin::Oidc::Engine.admin_user_class.model_name.singular - router = ::Devise.mappings[scope.to_sym]&.router_name || - ::Devise.available_router_name - send(router).public_send(:"#{scope}_stub_login_path") - rescue NoMethodError - nil - end - - # The identity the stub button will sign in as, for the warning - # banner. Nil when the claims are unusable, so the banner can say - # so instead of raising inside a view. - def activeadmin_oidc_stub_login_identity - claims = activeadmin_oidc_config.resolved_stub_login_claims - claims[activeadmin_oidc_config.identity_claim.to_s].presence - end - end - end -end diff --git a/app/views/active_admin/devise/sessions/new.html.erb b/app/views/active_admin/devise/sessions/new.html.erb index 1692aef..efdb1f5 100644 --- a/app/views/active_admin/devise/sessions/new.html.erb +++ b/app/views/active_admin/devise/sessions/new.html.erb @@ -4,62 +4,35 @@ <%= site_title %> <%= set_page_title t('active_admin.devise.login.title') %> - <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> - <%= button_to ActiveAdmin::Oidc.config.login_button_label, - activeadmin_oidc_sso_login_path, - method: :post, - class: "activeadmin-oidc-login-button w-full", - form_class: 'formtastic', - data: { turbo: false } %> + <% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %> + <% end %> - <% if activeadmin_oidc_stub_login_enabled? %> - - <% end %> + <%= button_to ActiveAdmin::Oidc.config.login_button_label, + ActiveAdmin::Oidc.config.login_submit_path, + method: :post, + class: "activeadmin-oidc-login-button w-full", + form_class: 'formtastic', + data: { turbo: false } %> <% else %>

<%= active_admin_application.site_title(self) %>

- <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> - <%= form_tag activeadmin_oidc_sso_login_path, - method: :post, - class: "activeadmin-oidc-login-form formtastic", - data: { turbo: false } do %> - <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, - class: "activeadmin-oidc-login-button" %> - <% end %> + <% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %> + <% end %> - <% if activeadmin_oidc_stub_login_enabled? %> - + <%= form_tag ActiveAdmin::Oidc.config.login_submit_path, + method: :post, + class: "activeadmin-oidc-login-form formtastic", + data: { turbo: false } do %> + <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, + class: "activeadmin-oidc-login-button" %> <% end %>
<% end %> diff --git a/lib/activeadmin/oidc/configuration.rb b/lib/activeadmin/oidc/configuration.rb index ceeaf4d..eeac0f7 100644 --- a/lib/activeadmin/oidc/configuration.rb +++ b/lib/activeadmin/oidc/configuration.rb @@ -13,10 +13,9 @@ class Configuration 'Your account has no permission to access this admin panel.' DEFAULT_LOGIN_PATH = '/admin/login' DEFAULT_LOGOUT_PATH = '/admin/logout' - DEFAULT_STUB_LOGIN_BUTTON_LABEL = 'Sign in with stub login (no IdP)' - DEFAULT_STUB_LOGIN_CLAIMS = { + DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS = { 'sub' => 'stub-uid', - 'email' => 'stub@example.com' + 'email' => 'stub-dev@example.com' }.freeze attr_accessor :issuer, :client_id, :client_secret, :scope, @@ -24,9 +23,13 @@ class Configuration :login_button_label, :timeout, :identity_attribute, :identity_claim, :access_denied_message, :on_login, :admin_user_class, - :login_path, :logout_path, - :stub_login_enabled, :stub_login_claims, - :stub_login_button_label + :login_path, :logout_path + + # Readers, not writers: stub login is turned on through + # `stub_dev_env_login!` so the environment check cannot be skipped. + # Specs (this gem's own included) stub these two instead of + # pretending to run in the development environment. + attr_reader :stub_dev_env_login_claims_block def initialize reset! @@ -48,9 +51,8 @@ def reset! @logout_path = DEFAULT_LOGOUT_PATH @on_login = nil @pkce_override = nil - @stub_login_enabled = false - @stub_login_claims = DEFAULT_STUB_LOGIN_CLAIMS - @stub_login_button_label = DEFAULT_STUB_LOGIN_BUTTON_LABEL + @stub_dev_env_login = false + @stub_dev_env_login_claims_block = nil self end @@ -64,53 +66,63 @@ def pkce=(value) @pkce_override = value end - def stub_login_enabled? - !!@stub_login_enabled - end + # Turns on the development stub login: the login page's button + # signs in with locally fabricated claims instead of redirecting to + # the IdP. For machines whose redirect URI the IdP does not know -- + # a non-default port, or two apps sharing one OIDC client. + # + # A no-op outside the development environment, so there is nothing + # to guard at boot and nothing to flip off before a deploy. + # + # The optional block receives the default claims and returns the + # claims to sign in with, so a host whose `on_login` reads roles or + # groups can satisfy it: + # + # c.stub_dev_env_login! { |claims| claims.merge('groups' => ADMIN_GROUP) } + # + # The claims go through the same UserProvisioner as a real + # callback, so a block that does not satisfy `on_login` is denied + # exactly as the real IdP would deny it. + def stub_dev_env_login!(&block) + return false unless ::Rails.env.development? - # True when there is enough configuration to start a real - # authorization code flow. Stub-login-only setups (a dev machine - # with no IdP credentials at all) deliberately leave these blank, - # and the login view hides the SSO button rather than rendering - # one that 404s. - def sso_configured? - issuer.present? && client_id.present? + @stub_dev_env_login = true + @stub_dev_env_login_claims_block = block + true end - # `stub_login_claims` may be a Hash or any callable returning one - # (so hosts can read ENV per request and switch dev identities - # without editing the initializer). Always returns String keys, - # the same shape `on_login` receives. - def resolved_stub_login_claims - raw = stub_login_claims - raw = raw.call if raw.respond_to?(:call) - (raw || {}).to_h.transform_keys(&:to_s) + def stub_dev_env_login_enabled? + @stub_dev_env_login end - # The stub claims go through the same UserProvisioner as a real - # callback, which needs `sub` and the configured identity claim. - # Checking here (at boot for Hash claims, per request for - # callables) turns a misconfiguration into a readable message - # instead of the provisioner's generic access-denied flash. - def validate_stub_login!(claims = resolved_stub_login_claims) - if claims['sub'].blank? - raise ConfigurationError, 'stub_login_claims must contain a "sub" value' + # Evaluated once per stub sign-in, in the controller. String keys + # all the way down, the same shape `on_login` receives from a real + # callback. + # + # The block may either return a Hash or mutate the one it is given + # -- `claims["groups"] = [...]` as a last line returns the assigned + # value, not the Hash, and that should not 500 the dev's login. + def stub_dev_env_login_claims + claims = DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS.dup + block = stub_dev_env_login_claims_block + if block + returned = block.call(claims) + claims = returned if returned.is_a?(Hash) end + claims.deep_transform_keys(&:to_s) + end - key = identity_claim.to_s - if claims[key].blank? - raise ConfigurationError, - "stub_login_claims must contain #{key.inspect} (the configured identity_claim)" - end + # Where the login page's single button POSTs to: the stub route + # while stub login is on, the real OmniAuth entry point otherwise. + def login_submit_path + return "#{login_path}/stub" if stub_dev_env_login_enabled? - true + "#{::OmniAuth.config.path_prefix}/#{Engine::PROVIDER_NAME}" end def validate! - unless stub_login_enabled? - raise ConfigurationError, 'issuer is required' if issuer.blank? - raise ConfigurationError, 'client_id is required' if client_id.blank? - end + raise ConfigurationError, 'issuer is required' if issuer.blank? + raise ConfigurationError, 'client_id is required' if client_id.blank? raise ConfigurationError, 'on_login is required' if on_login.nil? raise ConfigurationError, 'on_login must be callable (respond to #call)' unless on_login.respond_to?(:call) diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index b5e7899..c3c04c6 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -70,13 +70,6 @@ def controllers view_path = File.expand_path('../../../app/views', __dir__) ::ActiveAdmin::Devise::SessionsController.prepend_view_path(view_path) end - - # Registered unconditionally, unlike the view path above: a - # host that ships its own login view still needs the helpers - # to render the SSO and stub-login buttons. - ::ActiveAdmin::Devise::SessionsController.helper( - ::ActiveAdmin::Oidc::ViewHelpers - ) end end end @@ -128,43 +121,6 @@ def controllers app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce] end - # Stub login fabricates an authenticated admin session without ever - # talking to the IdP. That is a complete authentication bypass, so - # it is refused outright in the production environment — there is no - # override flag. Hosts running a production-like environment that - # genuinely needs the bypass must run it under its own Rails env. - # - # Registered as an after_initialize (not a plain engine initializer) - # because engine initializers run *before* the host's - # config/initializers, where `stub_login_enabled` is set. - initializer 'activeadmin_oidc.stub_login_guard' do |app| - app.config.after_initialize { Engine.enforce_stub_login_policy! } - end - - # Extracted from the initializer above so it can be exercised - # directly in specs without booting a second application. - def self.enforce_stub_login_policy! - cfg = ActiveAdmin::Oidc.config - return false unless cfg.stub_login_enabled? - - if ::Rails.env.production? - raise ConfigurationError, - 'ActiveAdmin::Oidc stub_login_enabled is true in the production ' \ - 'environment. Stub login signs users in without contacting the ' \ - 'identity provider and must never be reachable in production.' - end - - # Eager validation only for a literal Hash; callables are - # per-request by design and are validated in the controller. - cfg.validate_stub_login! unless cfg.stub_login_claims.respond_to?(:call) - - ActiveAdmin::Oidc.logger.warn( - '[activeadmin-oidc] STUB LOGIN IS ENABLED. The login page offers a ' \ - 'button that signs in without contacting the identity provider.' - ) - true - 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 @@ -189,12 +145,15 @@ def self.enforce_stub_login_policy! 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 + # Read at draw time (not in the enclosing after_initialize) + # so `Rails.application.reload_routes!` picks up host changes + # to any of them. + 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 + devise_scope scope_name do # Use the controller class directly via `.action(...)` so # isolated engines don't try to resolve the controller as @@ -219,21 +178,20 @@ def self.enforce_stub_login_policy! match logout_path, to: ::ActiveAdmin::Devise::SessionsController.action(:destroy), as: :"destroy_#{scope_name}_session", via: logout_via - # Development stub login. Read the flag here (draw time) - # rather than in the enclosing after_initialize so - # `Rails.application.reload_routes!` reflects config - # changes — and so the route simply does not exist when - # the feature is off. The production check duplicates the - # boot guard above on purpose: this is an auth bypass, and - # a route that is never drawn cannot be probed. - if ActiveAdmin::Oidc.config.stub_login_enabled? && !::Rails.env.production? + # Development stub login. `stub_dev_env_login!` only ever + # sets the flag in the development environment, so the + # route simply does not exist anywhere else -- and a route + # that is never drawn cannot be probed. + if cfg.stub_dev_env_login_enabled? # Resolve the controller per request instead of capturing # `.action(:stub)` at draw time: the class lives in the # engine's app/ and is reloadable, and stub login runs in # development where a captured constant goes stale on the # first reload. A lambda also sidesteps the module scoping # an isolated engine would apply to a string target. - post "#{login_path}/stub", + # `login_submit_path` is what the login button posts to, + # so drawing the route from it keeps the two in step. + post cfg.login_submit_path, to: ->(env) { ::ActiveAdmin::Oidc::Devise::OmniauthCallbacksController.action(:stub).call(env) }, as: :"#{scope_name}_stub_login" end diff --git a/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt b/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt index 630ad86..1f284b1 100644 --- a/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt +++ b/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt @@ -22,31 +22,31 @@ ActiveAdmin::Oidc.configure do |c| # c.login_button_label = "Sign in with Corporate SSO" # --- Stub login (development only) ------------------------------------ - # Adds a second button to the login page that signs in with locally - # fabricated claims instead of redirecting to the IdP. Nothing is - # automatic: the login page still renders and you still click. Useful - # when the IdP does not know your local redirect URI -- a non-default - # port, or two apps sharing one OIDC client. + # Makes the login button sign in with locally fabricated claims instead + # of redirecting to the IdP. Nothing is automatic: the login page still + # renders and you still click. Useful when the IdP does not know your + # local redirect URI -- a non-default port, or two apps sharing one + # OIDC client. + # + # A no-op outside the development environment, so it is safe to leave + # uncommented. A red warning sits above the button while it is on. # # The claims run through the normal pipeline, `on_login` included, so # authorization behaves exactly as it will against the real IdP. - # Boot fails if this is enabled in the production environment. - # - # c.stub_login_enabled = Rails.env.development? + # Default claims: { "sub" => "stub-uid", "email" => "stub-dev@example.com" } # - # Claims the stub button signs in with -- the same hash `on_login` - # receives. Must contain "sub" and your `identity_claim`. Add whatever - # else your `on_login` hook reads (roles, groups, department...). - # Accepts a Hash, or a callable evaluated per request: + # c.stub_dev_env_login! # - # c.stub_login_claims = -> { { "sub" => ENV.fetch("DEV_USER"), ... } } + # Pass a block to patch or replace the claims -- needed when your + # `on_login` reads roles, groups or anything else: # - # c.stub_login_claims = { - # "sub" => "stub-uid", - # "email" => "stub@example.com" - # } + # c.stub_dev_env_login! do |claims| + # claims.merge("groups" => ["admins"]) + # end # - # c.stub_login_button_label = "Sign in as a developer" + # Use a made-up email, not your real SSO one. A stub sign-in stamps the + # row with uid "stub-uid", and a later real sign-in for the same email + # is then refused by the takeover guard. # --- on_login hook --------------------------------------------------- # Called with (admin_user, claims) after identity lookup and before diff --git a/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb b/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb index 0cf748a..ce2d8a9 100644 --- a/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb +++ b/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb @@ -1,34 +1,20 @@ +<%# Development stub login. The submit path switches to the stub + route while ActiveAdmin::Oidc.config.stub_dev_env_login! is on, + which only ever happens in the development environment. %>

<%= active_admin_application.site_title(self) %>

- <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> - <%= form_tag activeadmin_oidc_sso_login_path, - method: :post, - class: "activeadmin-oidc-login-form formtastic", - data: { turbo: false } do %> - <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, - class: "activeadmin-oidc-login-button" %> - <% end %> + <% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %> + <% end %> - <%# Development stub login. Renders only when - ActiveAdmin::Oidc.config.stub_login_enabled is true and the env is - not production. Delete this block if you never want the button. %> - <% if activeadmin_oidc_stub_login_enabled? %> - + <%= form_tag ActiveAdmin::Oidc.config.login_submit_path, + method: :post, + class: "activeadmin-oidc-login-form formtastic", + data: { turbo: false } do %> + <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, + class: "activeadmin-oidc-login-button" %> <% end %>
diff --git a/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb b/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb index ea69189..1d7832d 100644 --- a/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb +++ b/lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb @@ -1,34 +1,21 @@ +<%# Development stub login. The submit path switches to the stub + route while ActiveAdmin::Oidc.config.stub_dev_env_login! is on, + which only ever happens in the development environment. %>

<%= site_title %> <%= set_page_title t('active_admin.devise.login.title') %>

- <% if activeadmin_oidc_sso_configured? || !activeadmin_oidc_stub_login_enabled? %> - <%= button_to ActiveAdmin::Oidc.config.login_button_label, - activeadmin_oidc_sso_login_path, - method: :post, - class: "activeadmin-oidc-login-button w-full", - form_class: 'formtastic', - data: { turbo: false } %> + <% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %> + <% end %> - <%# Development stub login. Renders only when - ActiveAdmin::Oidc.config.stub_login_enabled is true and the env is - not production. Delete this block if you never want the button. %> - <% if activeadmin_oidc_stub_login_enabled? %> - - <% end %> + <%= button_to ActiveAdmin::Oidc.config.login_button_label, + ActiveAdmin::Oidc.config.login_submit_path, + method: :post, + class: "activeadmin-oidc-login-button w-full", + form_class: 'formtastic', + data: { turbo: false } %>
diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 4f4560d..6212d68 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -155,12 +155,12 @@ def run_generator(args = []) end it "includes the stub login block so the dev button still renders" do - expect(published_view).to include("activeadmin_oidc_stub_login_enabled?") - expect(published_view).to include("activeadmin_oidc_stub_login_path") + expect(published_view).to include("stub_dev_env_login_enabled?") + expect(published_view).to include("login_submit_path") end - it "posts the SSO button through the helper rather than a hardcoded path" do - expect(published_view).to include("activeadmin_oidc_sso_login_path") + it "posts through the config rather than a hardcoded path" do + expect(published_view).to include("ActiveAdmin::Oidc.config.login_submit_path") end end diff --git a/spec/isolated/features/isolated_stub_login_spec.rb b/spec/isolated/features/isolated_stub_login_spec.rb deleted file mode 100644 index b8b56c3..0000000 --- a/spec/isolated/features/isolated_stub_login_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require "isolated_rails_helper" - -# Stub login inside an isolated engine. The route is declared as -# "#{config.login_path}/stub", so with the engine-relative -# `login_path = '/login'` this host must configure, the effective URL -# after the `mount AdminPanel::Engine => '/admin'` prefix is -# `/admin/login/stub`. A hardcoded '/admin/login/stub' in the gem would -# become '/admin/admin/login/stub' here. -RSpec.feature "Isolated engine stub login", type: :feature do - before do - # spec_helper resets the gem config before every example, which - # wipes the engine-relative login_path the dummy app's initializer - # set. Restore it before redrawing routes. - ActiveAdmin::Oidc.configure do |c| - c.login_path = "/login" - c.logout_path = "/logout" - c.on_login = ->(*) { true } - c.stub_login_enabled = true - c.stub_login_claims = { "sub" => "stub-iso", "email" => "iso@example.com" } - end - Rails.application.reload_routes! - AdminUser.delete_all - end - - after do - ActiveAdmin::Oidc.reset! - Rails.application.reload_routes! - end - - it "declares the stub route engine-relative" do - paths = AdminPanel::Engine.routes.routes.map { |r| [r.verb, r.path.spec.to_s] } - expect(paths).to include(["POST", "/login/stub(.:format)"]) - end - - scenario "the login page's stub button signs in through the mount prefix" do - visit "/admin/login" - expect(page.body).to include(%(action="/admin/login/stub")) - expect(page.body).to include(ActiveAdmin::Oidc.config.stub_login_button_label) - - # Driven without following the redirect: on success the gem sends the - # user to a hardcoded /admin, which this dummy app (ActiveAdmin lives - # inside the mounted engine) does not route. - page.driver.browser.process(:post, "/admin/login/stub") - - expect(page.driver.browser.last_response.status).to eq(302) - expect(AdminUser.find_by(email: "iso@example.com")).not_to be_nil - end -end diff --git a/spec/requests/stub_login_spec.rb b/spec/requests/stub_login_spec.rb index 1f15395..a77038f 100644 --- a/spec/requests/stub_login_spec.rb +++ b/spec/requests/stub_login_spec.rb @@ -3,19 +3,24 @@ require "rails_helper" # Stub login is the development escape hatch for machines whose redirect -# URI the IdP does not know: the login page renders as usual, and a -# second button signs in with locally fabricated claims. The point of -# these specs is that the stub goes through the SAME provisioning path -# as a real callback -- on_login, the identity lookup, oidc_raw_info and -# the active_for_authentication? guard all still run. +# URI the IdP does not know: the login page renders as usual, and its +# button signs in with locally fabricated claims. The point of these +# specs is that the stub goes through the SAME provisioning path as a +# real callback -- on_login, the identity lookup, oidc_raw_info and the +# active_for_authentication? guard all still run. RSpec.describe "Stub login", type: :request do - # The route is drawn conditionally on the config flag, so flipping the - # flag at runtime requires a redraw. The global config reset in - # spec_helper runs before each example, so undo the redraw afterwards - # or the route leaks into unrelated specs. - def enable_stub_login!(claims: nil) - ActiveAdmin::Oidc.config.stub_login_enabled = true - ActiveAdmin::Oidc.config.stub_login_claims = claims unless claims.nil? + # `stub_dev_env_login!` is a no-op outside the development environment, + # and these specs run in the test environment. Stub the two readers it + # would have set instead of pretending to be development. + # + # The route is drawn conditionally on the flag, so turning it on at + # runtime requires a redraw. Undo the redraw afterwards or the route + # leaks into unrelated specs. + def enable_stub_login!(&claims_block) + allow(ActiveAdmin::Oidc.config) + .to receive(:stub_dev_env_login_enabled?).and_return(true) + allow(ActiveAdmin::Oidc.config) + .to receive(:stub_dev_env_login_claims_block).and_return(claims_block) Rails.application.reload_routes! end @@ -34,46 +39,42 @@ def enable_stub_login!(claims: nil) Rails.application.reload_routes! end - describe "the login page" do - it "renders no stub button or warning when stub login is off" do - get "/admin/login" - - expect(response.body).not_to include("activeadmin-oidc-stub-login") - expect(response.body).not_to include("Stub login is enabled") + describe "the config API" do + it "refuses to enable outside the development environment" do + expect(ActiveAdmin::Oidc.config.stub_dev_env_login!).to be(false) + expect(ActiveAdmin::Oidc.config.stub_dev_env_login_enabled?).to be(false) end - it "renders the stub button, the identity it signs in as, and a warning" do - enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) - - get "/admin/login" - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Stub login is enabled") - expect(response.body).to include("dev@example.com") - expect(response.body).to include( - ActiveAdmin::Oidc::Configuration::DEFAULT_STUB_LOGIN_BUTTON_LABEL + it "enables and stores the claims block in the development environment" do + allow(Rails).to receive(:env).and_return( + ActiveSupport::StringInquirer.new("development") ) - expect(response.body).to include(%(action="/admin/login/stub")) - end + block = ->(claims) { claims } - it "still renders the real SSO button alongside the stub one" do - enable_stub_login! + expect(ActiveAdmin::Oidc.config.stub_dev_env_login!(&block)).to be(true) + expect(ActiveAdmin::Oidc.config.stub_dev_env_login_enabled?).to be(true) + expect(ActiveAdmin::Oidc.config.stub_dev_env_login_claims_block).to be(block) + end + end + describe "the login page" do + it "posts to the real OmniAuth entry point when stub login is off" do get "/admin/login" expect(response.body).to include(%(action="/admin/auth/oidc")) - expect(response.body).to include(%(action="/admin/login/stub")) + expect(response.body).not_to include("Stub login is enabled") end - it "hides the SSO button when no IdP is configured at all" do - ActiveAdmin::Oidc.config.issuer = nil - ActiveAdmin::Oidc.config.client_id = nil + it "posts to the stub route and warns when stub login is on" do enable_stub_login! get "/admin/login" - expect(response.body).not_to include(%(action="/admin/auth/oidc")) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Stub login is enabled") expect(response.body).to include(%(action="/admin/login/stub")) + expect(response.body).not_to include(%(action="/admin/auth/oidc")) + expect(response.body).to include(ActiveAdmin::Oidc.config.login_button_label) end end @@ -83,44 +84,97 @@ def enable_stub_login!(claims: nil) .to raise_error(ActionController::RoutingError) end - it "provisions the admin user from the configured claims and signs in" do - enable_stub_login!( - claims: { "sub" => "stub-1", "email" => "dev@example.com", "department" => "ops" } - ) + it "provisions the admin user from the default claims and signs in" do + enable_stub_login! expect { post "/admin/login/stub" }.to change(AdminUser, :count).by(1) - user = AdminUser.find_by(email: "dev@example.com") + user = AdminUser.find_by(email: "stub-dev@example.com") expect(user).not_to be_nil # provider is the gem's own, never a separate "stub"/"test" value: # a row stamped with a different provider could never be matched by # a later real SSO login, and would trip the takeover guard. expect(user.provider).to eq("oidc") - expect(user.uid).to eq("stub-1") - expect(user.oidc_raw_info).to include("sub" => "stub-1", "department" => "ops") + expect(user.uid).to eq("stub-uid") + expect(user.oidc_raw_info).to include("sub" => "stub-uid") expect(response).to be_redirect expect(URI(response.location).path).to eq("/admin") end + it "signs in with the claims the block returns" do + ActiveAdmin::Oidc.config.on_login = lambda { |admin_user, claims| + admin_user.department = claims["department"] + true + } + enable_stub_login! { |claims| claims.merge("email" => "dev@example.com", "department" => "ops") } + + post "/admin/login/stub" + + user = AdminUser.find_by(email: "dev@example.com") + expect(user).not_to be_nil + expect(user.department).to eq("ops") + end + + it "accepts a block that mutates the claims instead of returning them" do + enable_stub_login! { |claims| claims["email"] = "mutated@example.com" } + + expect { post "/admin/login/stub" }.to change(AdminUser, :count).by(1) + expect(AdminUser.find_by(email: "mutated@example.com")).not_to be_nil + end + + it "stringifies nested keys the block returns" do + enable_stub_login! { |claims| claims.merge(roles: { admin: true }) } + seen = nil + ActiveAdmin::Oidc.config.on_login = lambda { |_admin_user, claims| + seen = claims + true + } + + post "/admin/login/stub" + + expect(seen.dig("roles", "admin")).to be(true) + end + + it "never lets the block mutate the default claims" do + enable_stub_login! { |claims| claims["email"] = "first@example.com"; claims } + + post "/admin/login/stub" + + expect(ActiveAdmin::Oidc::Configuration::DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS) + .to eq("sub" => "stub-uid", "email" => "stub-dev@example.com") + end + it "reuses the existing row on a second stub login" do - enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) + enable_stub_login! post "/admin/login/stub" expect { post "/admin/login/stub" }.not_to change(AdminUser, :count) end + it "re-evaluates the block on every sign-in" do + identity = "first@example.com" + enable_stub_login! { |claims| claims.merge("email" => identity) } + + post "/admin/login/stub" + expect(AdminUser.find_by(email: "first@example.com")).not_to be_nil + + identity = "second@example.com" + post "/admin/login/stub" + expect(AdminUser.find_by(email: "second@example.com")).not_to be_nil + end + it "runs the host's on_login hook and honours a denial" do called_with = nil ActiveAdmin::Oidc.config.on_login = lambda { |_admin_user, claims| called_with = claims false } - enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) + enable_stub_login! expect { post "/admin/login/stub" }.not_to change(AdminUser, :count) - expect(called_with).to include("sub" => "stub-1", "email" => "dev@example.com") + expect(called_with).to include("sub" => "stub-uid", "email" => "stub-dev@example.com") expect(response).to redirect_to("/admin/login") follow_redirect! expect(response.body).to include(ActiveAdmin::Oidc.config.access_denied_message) @@ -131,72 +185,29 @@ def enable_stub_login!(claims: nil) admin_user.enabled = false true } - enable_stub_login!(claims: { "sub" => "stub-1", "email" => "dev@example.com" }) + enable_stub_login! expect { post "/admin/login/stub" }.not_to change(AdminUser, :count) expect(response).to redirect_to("/admin/login") end - it "accepts a callable so the identity can come from ENV per request" do - identity = "first@example.com" - enable_stub_login!(claims: -> { { "sub" => "stub-#{identity}", "email" => identity } }) + it "denies claims the provisioner cannot use, like the real IdP would" do + enable_stub_login! { |_claims| { "sub" => "stub-uid" } } # no identity claim - post "/admin/login/stub" - expect(AdminUser.find_by(email: "first@example.com")).not_to be_nil - - identity = "second@example.com" - post "/admin/login/stub" - expect(AdminUser.find_by(email: "second@example.com")).not_to be_nil - end - - it "reports a misconfigured claims hash instead of a generic denial" do - enable_stub_login!(claims: { "sub" => "stub-1" }) # no identity claim - - post "/admin/login/stub" + expect { post "/admin/login/stub" }.not_to change(AdminUser, :count) follow_redirect! - expect(response.body).to include("stub login misconfigured") - expect(response.body).to include("email") + expect(response.body).to include(ActiveAdmin::Oidc.config.access_denied_message) end it "404s when the flag is flipped off after the route was drawn" do enable_stub_login! - ActiveAdmin::Oidc.config.stub_login_enabled = false + allow(ActiveAdmin::Oidc.config) + .to receive(:stub_dev_env_login_enabled?).and_return(false) post "/admin/login/stub" expect(response).to have_http_status(:not_found) end end - - describe "the production guard" do - it "refuses to boot when stub login is enabled in production" do - ActiveAdmin::Oidc.config.stub_login_enabled = true - allow(Rails).to receive(:env).and_return( - ActiveSupport::StringInquirer.new("production") - ) - - expect { ActiveAdmin::Oidc::Engine.enforce_stub_login_policy! } - .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /production/) - end - - it "raises at boot when a literal claims hash is missing the identity claim" do - ActiveAdmin::Oidc.config.stub_login_enabled = true - ActiveAdmin::Oidc.config.stub_login_claims = { "sub" => "stub-1" } - - expect { ActiveAdmin::Oidc::Engine.enforce_stub_login_policy! } - .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /identity_claim/) - end - - it "does not evaluate a callable at boot" do - ActiveAdmin::Oidc.config.stub_login_enabled = true - ActiveAdmin::Oidc.config.stub_login_claims = -> { raise "must not be called at boot" } - - expect { ActiveAdmin::Oidc::Engine.enforce_stub_login_policy! }.not_to raise_error - end - - it "is a no-op when stub login is off" do - expect(ActiveAdmin::Oidc::Engine.enforce_stub_login_policy!).to be(false) - end - end end diff --git a/spec/unit/configuration_spec.rb b/spec/unit/configuration_spec.rb index 46a97b7..6153312 100644 --- a/spec/unit/configuration_spec.rb +++ b/spec/unit/configuration_spec.rb @@ -114,82 +114,102 @@ expect { config.validate! }.to raise_error(ActiveAdmin::Oidc::ConfigurationError, /on_login/) end - context "with stub login enabled" do - # A developer machine using only stub login has no IdP credentials - # to give, so requiring them would force fake values into the - # initializer just to boot. - it "stops requiring issuer and client_id" do - config.stub_login_enabled = true - config.issuer = nil - config.client_id = nil - - expect { config.validate! }.not_to raise_error - end - - it "still requires on_login, which stub sign-ins also run through" do - config.stub_login_enabled = true - config.on_login = nil - - expect { config.validate! }.to raise_error(ActiveAdmin::Oidc::ConfigurationError, /on_login/) - end - end end describe "stub login configuration" do it "is disabled by default" do - expect(config.stub_login_enabled?).to be(false) + expect(config.stub_dev_env_login_enabled?).to be(false) end - it "defaults to claims that satisfy the default identity_claim" do - expect { config.validate_stub_login! }.not_to raise_error + it "refuses to enable outside the development environment" do + expect(config.stub_dev_env_login!).to be(false) + expect(config.stub_dev_env_login_enabled?).to be(false) end - it "stringifies symbol keys so the claims match what on_login receives" do - config.stub_login_claims = { sub: "s-1", email: "dev@example.com" } + context "in the development environment" do + before do + allow(Rails).to receive(:env).and_return( + ActiveSupport::StringInquirer.new("development") + ) + end - expect(config.resolved_stub_login_claims) - .to eq("sub" => "s-1", "email" => "dev@example.com") - end + it "enables without a block" do + expect(config.stub_dev_env_login!).to be(true) + expect(config.stub_dev_env_login_enabled?).to be(true) + expect(config.stub_dev_env_login_claims_block).to be_nil + end - it "calls a callable on every read, so the identity can come from ENV" do - calls = 0 - config.stub_login_claims = lambda { - calls += 1 - { "sub" => "s-#{calls}", "email" => "dev#{calls}@example.com" } - } + it "defaults to claims that satisfy the default identity_claim" do + config.stub_dev_env_login! - expect(config.resolved_stub_login_claims["sub"]).to eq("s-1") - expect(config.resolved_stub_login_claims["sub"]).to eq("s-2") - end + expect(config.stub_dev_env_login_claims) + .to eq("sub" => "stub-uid", "email" => "stub-dev@example.com") + end - it "rejects claims without a sub" do - expect { config.validate_stub_login!("email" => "dev@example.com") } - .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /sub/) - end + it "passes the default claims to the block and uses what it returns" do + config.stub_dev_env_login! { |claims| claims.merge("groups" => ["admins"]) } - it "rejects claims missing the configured identity_claim" do - config.identity_claim = :username + expect(config.stub_dev_env_login_claims).to eq( + "sub" => "stub-uid", "email" => "stub-dev@example.com", "groups" => ["admins"] + ) + end - expect { config.validate_stub_login!("sub" => "s-1", "email" => "dev@example.com") } - .to raise_error(ActiveAdmin::Oidc::ConfigurationError, /username/) - end + it "accepts a block that mutates the claims instead of returning them" do + # `claims["groups"] = [...]` as a last line returns the assigned + # value, not the Hash. + config.stub_dev_env_login! { |claims| claims["groups"] = ["admins"] } + + expect(config.stub_dev_env_login_claims).to eq( + "sub" => "stub-uid", "email" => "stub-dev@example.com", "groups" => ["admins"] + ) + end - it "accepts claims carrying a non-default identity_claim" do - config.identity_claim = :username + it "stringifies nested symbol keys so the claims match what on_login receives" do + config.stub_dev_env_login! { |claims| claims.merge(roles: { admin: true }) } - expect { config.validate_stub_login!("sub" => "s-1", "username" => "dev") } - .not_to raise_error + expect(config.stub_dev_env_login_claims["roles"]).to eq("admin" => true) + end + + it "never lets the block mutate the defaults" do + config.stub_dev_env_login! { |claims| claims["email"] = "dev@example.com"; claims } + config.stub_dev_env_login_claims + + expect(described_class::DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS) + .to eq("sub" => "stub-uid", "email" => "stub-dev@example.com") + end + + it "calls the block on every read, so the identity can come from ENV" do + calls = 0 + config.stub_dev_env_login! do |claims| + calls += 1 + claims.merge("email" => "dev#{calls}@example.com") + end + + expect(config.stub_dev_env_login_claims["email"]).to eq("dev1@example.com") + expect(config.stub_dev_env_login_claims["email"]).to eq("dev2@example.com") + end + + it "is cleared by reset!" do + config.stub_dev_env_login! + config.reset! + + expect(config.stub_dev_env_login_enabled?).to be(false) + expect(config.stub_dev_env_login_claims_block).to be_nil + end end end - describe "#sso_configured?" do - it "is true only when both issuer and client_id are present" do - config.issuer = "https://example.com" - config.client_id = "abc" - expect(config.sso_configured?).to be(true) + describe "#login_submit_path" do + it "points at the OmniAuth entry point when stub login is off" do + expect(config.login_submit_path) + .to eq("#{OmniAuth.config.path_prefix}/oidc") + end + + it "points at the stub route, derived from login_path, when stub login is on" do + allow(config).to receive(:stub_dev_env_login_enabled?).and_return(true) + config.login_path = "/admin/login" - config.client_id = nil - expect(config.sso_configured?).to be(false) + expect(config.login_submit_path).to eq("/admin/login/stub") end end From 859c21eba4fc8b6887246a519b051281c39d1141 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 27 Aug 2026 18:05:23 +0300 Subject: [PATCH 3/4] Fix login route path on ActiveAdmin 4 lazy route draw `login_path` and `logout_path` were read inside the route append block. On ActiveAdmin 4 routes are drawn lazily on the first request, so the read happened long after the host initializer that set them -- and any later `ActiveAdmin::Oidc.reset!` (the spec suite does this per example) replaced the Configuration, so the block saw the defaults. The isolated engine, which sets an engine-relative `login_path = "/login"`, drew `/admin/login` instead and 404ed. Capture both paths at after_initialize time, as before stub login. The stub flag and `login_submit_path` stay a draw-time read, but through `ActiveAdmin::Oidc.config` rather than a captured instance, because `reset!` swaps the whole object and a redraw must see the current one. Co-Authored-By: Clanker --- lib/activeadmin/oidc/engine.rb | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index c3c04c6..796d3be 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -145,15 +145,15 @@ def controllers app.config.after_initialize do next unless Engine.oidc_enabled? - Engine.session_routes_target(app).append do - # Read at draw time (not in the enclosing after_initialize) - # so `Rails.application.reload_routes!` picks up host changes - # to any of them. - 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 + # Captured here, not inside the append block: on ActiveAdmin 4 + # routes are drawn lazily on the first request, long after the + # host initializer that set these ran, so a draw-time read can + # observe a config that something else has since replaced. + login_path = ActiveAdmin::Oidc.config.login_path + logout_path = ActiveAdmin::Oidc.config.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 # Use the controller class directly via `.action(...)` so # isolated engines don't try to resolve the controller as @@ -182,7 +182,13 @@ def controllers # sets the flag in the development environment, so the # route simply does not exist anywhere else -- and a route # that is never drawn cannot be probed. - if cfg.stub_dev_env_login_enabled? + # Read through `ActiveAdmin::Oidc.config` at draw time rather + # than from a captured object: `reset!` swaps the whole + # Configuration instance, and a redraw must see the current + # one. + stub_cfg = ActiveAdmin::Oidc.config + + if stub_cfg.stub_dev_env_login_enabled? # Resolve the controller per request instead of capturing # `.action(:stub)` at draw time: the class lives in the # engine's app/ and is reloadable, and stub login runs in @@ -191,7 +197,7 @@ def controllers # an isolated engine would apply to a string target. # `login_submit_path` is what the login button posts to, # so drawing the route from it keeps the two in step. - post cfg.login_submit_path, + post stub_cfg.login_submit_path, to: ->(env) { ::ActiveAdmin::Oidc::Devise::OmniauthCallbacksController.action(:stub).call(env) }, as: :"#{scope_name}_stub_login" end From 4c597634e9baff9a67e2998e59b248e1ec503529 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Fri, 28 Aug 2026 13:39:08 +0300 Subject: [PATCH 4/4] improve README about stub dev login Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc20d5f..eacf260 100644 --- a/README.md +++ b/README.md @@ -288,8 +288,9 @@ returns `false` there. That is the whole safety story: nothing to flip off before a deploy, no boot guard to trip, and no route drawn anywhere else. Leave the call uncommented in the initializer if you want. -The block runs once per sign-in, not once at boot, so it can return a different -identity each time. +The block runs once per sign-in, not once at boot. To switch between distinct +users, vary both `sub` and the configured identity claim; changing only the +email retains `stub-uid` and updates the same `(provider, uid)` row. ### It is not a separate code path