diff --git a/README.md b/README.md index 9951fe3..eacf260 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ 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. A no-op outside the development environment. + # c.stub_dev_env_login! + # --- Authorization hook (REQUIRED) ------------------------------------ c.on_login = ->(admin_user, claims) { # ... see "The on_login hook" below @@ -137,6 +141,8 @@ end | `access_denied_message` | generic | Flash shown on any denial | | `on_login` | — (required) | Authorization hook; see below | +`stub_dev_env_login!` is a method, not an option — see "Stub login" below. + ## The `on_login` hook `on_login` is the **only** place authorization lives. The gem handles authentication (the user proved who they are via the IdP); deciding whether that user is allowed into the admin panel — and what they can see once they are in — is the host application's problem. The gem does not ship a role model. @@ -243,8 +249,101 @@ 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_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) + +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. 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| + 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 + } + + # 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 +``` + +`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. 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 + +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. + +Three consequences worth internalising: + +* **Your `on_login` hook must accept the stub claims.** If it reads + `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, so you render the button yourself. +Everything you need is on the config object — no helpers to mix in: + +```erb +<% 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 } %> +``` + +`login_submit_path` returns the stub route while stub login is on and the real +OmniAuth entry point otherwise. + ## 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..271b0cd 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,50 @@ 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. 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 + # `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_dev_env_login_enabled? + head :not_found + return + end + + 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, kind: 'stub login (no IdP)') + 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, kind: 'OIDC') admin_user = UserProvisioner.new( ActiveAdmin::Oidc.config, claims: claims, @@ -39,7 +83,7 @@ def oidc ).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 @@ -57,14 +101,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/views/active_admin/devise/sessions/new.html.erb b/app/views/active_admin/devise/sessions/new.html.erb index 700d875..efdb1f5 100644 --- a/app/views/active_admin/devise/sessions/new.html.erb +++ b/app/views/active_admin/devise/sessions/new.html.erb @@ -4,8 +4,14 @@ <%= site_title %> <%= set_page_title t('active_admin.devise.login.title') %> + <% if ActiveAdmin::Oidc.config.stub_dev_env_login_enabled? %> ++ Stub login is enabled: the button below signs in without contacting the identity provider. +
+ <% end %> + <%= button_to ActiveAdmin::Oidc.config.login_button_label, - "#{OmniAuth.config.path_prefix}/oidc", + ActiveAdmin::Oidc.config.login_submit_path, method: :post, class: "activeadmin-oidc-login-button w-full", form_class: 'formtastic', @@ -15,7 +21,13 @@+ Stub login is enabled: the button below signs in without contacting the identity provider. +
+ <% end %> + + <%= form_tag ActiveAdmin::Oidc.config.login_submit_path, method: :post, class: "activeadmin-oidc-login-form formtastic", data: { turbo: false } do %> diff --git a/lib/activeadmin/oidc/configuration.rb b/lib/activeadmin/oidc/configuration.rb index 5bdda41..eeac0f7 100644 --- a/lib/activeadmin/oidc/configuration.rb +++ b/lib/activeadmin/oidc/configuration.rb @@ -13,6 +13,10 @@ class Configuration 'Your account has no permission to access this admin panel.' DEFAULT_LOGIN_PATH = '/admin/login' DEFAULT_LOGOUT_PATH = '/admin/logout' + DEFAULT_STUB_DEV_ENV_LOGIN_CLAIMS = { + 'sub' => 'stub-uid', + 'email' => 'stub-dev@example.com' + }.freeze attr_accessor :issuer, :client_id, :client_secret, :scope, :redirect_uri, @@ -21,6 +25,12 @@ class Configuration :access_denied_message, :on_login, :admin_user_class, :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! end @@ -41,6 +51,8 @@ def reset! @logout_path = DEFAULT_LOGOUT_PATH @on_login = nil @pkce_override = nil + @stub_dev_env_login = false + @stub_dev_env_login_claims_block = nil self end @@ -54,6 +66,60 @@ def pkce=(value) @pkce_override = value 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? + + @stub_dev_env_login = true + @stub_dev_env_login_claims_block = block + true + end + + def stub_dev_env_login_enabled? + @stub_dev_env_login + end + + # 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 + + # 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? + + "#{::OmniAuth.config.path_prefix}/#{Engine::PROVIDER_NAME}" + end + def validate! raise ConfigurationError, 'issuer is required' if issuer.blank? raise ConfigurationError, 'client_id is required' if client_id.blank? diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index 98d880b..796d3be 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -145,9 +145,12 @@ def controllers app.config.after_initialize do next unless Engine.oidc_enabled? - cfg = ActiveAdmin::Oidc.config - login_path = cfg.login_path - logout_path = cfg.logout_path + # 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 @@ -174,6 +177,30 @@ 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. `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. + # 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 + # 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. + # `login_submit_path` is what the login button posts to, + # so drawing the route from it keeps the two in step. + post stub_cfg.login_submit_path, + 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..1f284b1 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) ------------------------------------ + # 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. + # Default claims: { "sub" => "stub-uid", "email" => "stub-dev@example.com" } + # + # c.stub_dev_env_login! + # + # Pass a block to patch or replace the claims -- needed when your + # `on_login` reads roles, groups or anything else: + # + # c.stub_dev_env_login! do |claims| + # claims.merge("groups" => ["admins"]) + # end + # + # 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 # 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..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,11 +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. %>+ Stub login is enabled: the button below signs in without contacting the identity provider. +
+ <% end %> + + <%= 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, + <%= submit_tag ActiveAdmin::Oidc.config.login_button_label, class: "activeadmin-oidc-login-button" %> - <%% end %> + <% end %>+ Stub login is enabled: the button below signs in without contacting the identity provider. +
+ <% 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 } %>