OpenID Connect single sign-on for ActiveAdmin.
Plugs OIDC into ActiveAdmin's existing Devise stack: JIT user provisioning, an on_login hook for host-owned authorization, a login-button view override, and a one-shot install generator. The OIDC protocol layer (discovery, JWKS, token verification, PKCE, nonce, state) is delegated to omniauth_openid_connect.
Used in production by the authors against Zitadel. Other compliant OIDC providers work via the standard omniauth_openid_connect options.
# Gemfile gem "activeadmin-oidc"
bundle install bin/rails generate active_admin:oidc:install bin/rails db:migrate
The generator creates the initializer and migration, but it cannot edit your active_admin.rb or admin_user.rb. Three things have to be in place:
config.authentication_method = :authenticate_admin_user! config.current_user_method = :current_admin_user
Without these, /admin is public to anyone and the utility navigation (including the logout button) renders empty.
class AdminUser < ApplicationRecord devise :omniauthable, omniauth_providers: [:oidc] serialize :oidc_raw_info, coder: JSON # Postgres jsonb: drop this line end
OIDC is the only authentication mechanism — :database_authenticatable, encrypted_password, and password reset / lockable / confirmable flows are not used. The IdP owns identity, recovery, MFA, and lockout. The engine auto-mounts GET /admin/login (SSO landing page) and DELETE /admin/logout so ActiveAdmin's login link still resolves without Devise's session routes.
If devise_for :admin_users lives inside a Rails engine (not the main app routes), set Devise.router_name = :<engine_name> in config/initializers/devise.rb and pass the same option to devise_for. The gem reads Devise.available_router_name and mounts its session routes inside that engine's route set, so <Engine>.routes.url_helpers.new_<scope>_session_path resolves correctly.
For isolated engines (isolate_namespace ...) mounted at a prefix (e.g. mount AdminPanel::Engine => '/admin'), the engine prepends its mount path to every internal route. The gem's default login_path = '/admin/login' would then become /admin/admin/login. Configure engine-relative paths in config/initializers/activeadmin_oidc.rb:
ActiveAdmin::Oidc.configure do |c| c.login_path = '/login' c.logout_path = '/logout' end
Non-isolated engines don't need this override.
Fill in at minimum issuer, client_id, and an on_login hook. Full reference below.
The gem's Rails engine handles several things so host apps don't have to:
- OmniAuth strategy registration — the engine registers the
:openid_connectstrategy with Devise automatically based on yourActiveAdmin::Oidcconfiguration. You do not need to addconfig.omniauthorconfig.omniauth_path_prefixtodevise.rb. - Callback controller — the engine patches
ActiveAdmin::Devise.controllersto route OmniAuth callbacks to the gem's controller. No manualcontrollers: { omniauth_callbacks: ... }needed inroutes.rb. - Login view override — the engine prepends an SSO-only login page (no email/password fields) to the sessions controller's view path. If your host app ships its own
app/views/active_admin/devise/sessions/new.html.erb, the gem detects it and backs off — your view wins. - Session routes — the engine mounts
GET /admin/login(renders the SSO landing page) andDELETE /admin/logoutunderdevise_scope, with the scope name derived fromconfig.admin_user_class. Devise normally generates session routes as a side effect of:database_authenticatable; without that module the route helpers would not exist and ActiveAdmin's login redirect would 404. - Path prefix — the engine sets
Devise.omniauth_path_prefixandOmniAuth.config.path_prefixto/admin/authso the middleware intercepts requests under ActiveAdmin's mount point. Compatible with Rails 7.2+ and Rails 8's lazy route loading. - Parameter filtering —
code,id_token,access_token,refresh_token,state, andnonceare added toRails.application.config.filter_parameters.
ActiveAdmin::Oidc.configure do |c| # --- Provider endpoints ----------------------------------------------- c.issuer = ENV.fetch("OIDC_ISSUER") c.client_id = ENV.fetch("OIDC_CLIENT_ID") c.client_secret = ENV.fetch("OIDC_CLIENT_SECRET", nil) # blank ⇒ PKCE public client # --- OIDC scopes ------------------------------------------------------ # c.scope = "openid email profile" # --- Redirect URI ----------------------------------------------------- # Normally auto-derived from the callback route. Set explicitly when # behind a reverse proxy, CDN, or when the IdP requires exact matching. # c.redirect_uri = "https://admin.example.com/admin/auth/oidc/callback" # --- Identity lookup -------------------------------------------------- # Which AdminUser column to match existing rows against, and which # claim on the id_token/userinfo to read for the lookup. # c.identity_attribute = :email # c.identity_claim = :email # --- AdminUser model resolution --------------------------------------- # Accepts a String (lazy constant lookup, recommended) or a Class. # Use when your model is not literally ::AdminUser. # c.admin_user_class = "Admin::User" # --- UI copy ---------------------------------------------------------- # c.login_button_label = "Sign in with Corporate SSO" # c.access_denied_message = "Your account has no permission to access this admin panel." # --- PKCE override ---------------------------------------------------- # By default PKCE is enabled iff client_secret is blank. Override: # c.pkce = true # --- Authorization hook (REQUIRED) ------------------------------------ c.on_login = ->(admin_user, claims) { # ... see "The on_login hook" below true } end
| Option | Default | Purpose |
|---|---|---|
issuer |
— (required) | OIDC discovery base URL |
client_id |
— (required) | IdP client identifier |
client_secret |
nil |
Blank ⇒ PKCE public client |
scope |
"openid email profile" |
Space-separated OIDC scopes |
pkce |
auto | true when client_secret is blank; overridable |
redirect_uri |
nil (auto) |
Explicit callback URL; needed behind reverse proxies |
identity_attribute |
:email |
AdminUser column used for lookup/adoption |
identity_claim |
:email |
Claim key read from the id_token/userinfo |
admin_user_class |
"AdminUser" |
String or Class for the host's admin user model |
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 |
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.
c.on_login = ->(admin_user, claims) { # admin_user: an instance of the configured admin_user_class. # Either a pre-existing row (matched by provider/uid or by # identity_attribute) or an unsaved new record. # claims: a Hash of String keys. Contains everything the IdP # returned in the id_token/userinfo, plus the top-level # `sub` (copied from the OmniAuth uid) and `email` # (copied from info.email) for convenience. # access_token / refresh_token / id_token are NEVER # present — they are stripped before this hook runs. # # Return truthy to allow sign-in. # Return falsy (false/nil) to deny: the user sees a generic denial # flash and no AdminUser record is persisted or mutated. # # Any mutations you make to admin_user are persisted automatically # after the hook returns truthy. # # Exceptions raised inside the hook are logged at :error via # ActiveAdmin::Oidc.logger and surface to the user as the same # generic denial flash — the callback action never 500s. true }
Zitadel emits roles under the custom claim urn:zitadel:iam:org:project:roles, shaped as { "role-name" => { "org-id" => "org-name" } }. Flatten the keys into a string array on the AdminUser.
c.on_login = ->(admin_user, claims) { roles = claims["urn:zitadel:iam:org:project:roles"]&.keys || [] return false if roles.empty? admin_user.roles = roles admin_user.name = claims["name"] if claims["name"].present? true }
KNOWN_DEPARTMENTS = %w[ops eng support].freeze c.on_login = ->(admin_user, claims) { dept = claims["department"] return false unless KNOWN_DEPARTMENTS.include?(dept) admin_user.department = dept true }
ADMIN_GROUP = "admins" 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 }
Every key the IdP returns in the id_token or userinfo is passed to on_login as part of claims. Custom claims work the same as standard ones — just read them by key:
c.on_login = ->(admin_user, claims) { admin_user.employee_id = claims["employee_id"] admin_user.given_name = claims["given_name"] admin_user.family_name = claims["family_name"] admin_user.locale = claims["locale"] admin_user.email_verified = claims["email_verified"] # Nested / structured claims come through as whatever the IdP sent. # Zitadel metadata, for instance: admin_user.tenant_id = claims.dig("urn:zitadel:iam:user:metadata", "tenant_id") true }
The full claim hash (minus access_token / refresh_token / id_token) is also stored on the admin user as oidc_raw_info — a JSON column created by the install generator. Read it later outside the hook for debugging or for showing the user's profile from the IdP:
AdminUser.last.oidc_raw_info # => { "sub" => "...", "email" => "...", "groups" => [...], ... }
- A login button is added to the ActiveAdmin sessions page via a prepended view override — no templates to edit.
- Clicking it POSTs to
/admin/auth/oidcwith a Rails CSRF token. The gem loadsomniauth-rails_csrf_protectionso OmniAuth 2.x delegates its authenticity check to Rails' forgery protection andbutton_tojust 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 anenabledflag or Devise's:lockablemodule), the guard fires on OIDC sign-in too — the user sees an appropriate flash and is redirected to the login page. - 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.
The gem ships a minimal SSO-only login page (a single button, no email/password fields). If you need a different layout — for instance, different branding, an explanatory paragraph, or multiple OmniAuth strategies — drop your own template at:
app/views/active_admin/devise/sessions/new.html.erb
The engine detects the host-app file and does not prepend its own view, so yours takes precedence with no extra configuration.
The identity_attribute column is used to adopt existing AdminUser rows on first SSO login — an existing user with that value in that column, and no provider/uid yet, gets linked to the IdP identity. Do not point this at a column the IdP can influence and that is also security-sensitive. Safe choices: :email, :username, :employee_id. Unsafe choices: :admin, :super_admin, :password_digest, :roles — anything whose value encodes a permission.
To prevent concurrent first-logins from creating two AdminUser rows for the same person, the column used as identity_attribute should have a database-level unique index. For the default :email case the standard Devise migration already adds this. If you pick a custom attribute, add the index yourself:
add_index :admin_users, :employee_id, unique: true
The gem also adds a unique (provider, uid) partial index in its own install migration.
The engine merges code, id_token, access_token, refresh_token, state, and nonce into Rails.application.config.filter_parameters so a mid-callback crash can't dump them into production logs. Your own filter_parameters entries are preserved.
The gem logs internal diagnostics (on_login exceptions, omniauth failures) via ActiveAdmin::Oidc.logger. It defaults to Rails.logger when Rails is booted, falling back to a null logger otherwise. Override by assigning directly:
ActiveAdmin::Oidc.logger = MyStructuredLogger.new
require "activeadmin/oidc/test_helpers" exposes ActiveAdmin::Oidc::TestHelpers with three methods for stubbing OmniAuth in specs:
stub_oidc_sign_in(sub: "alice-sub", claims: { "email" => "alice@example.com", "roles" => ["admin"] }) stub_oidc_failure(:invalid_credentials) reset_oidc_stubs # call in an after hook
Wire them up in rails_helper.rb. The oidc_mode: true tag scopes the helpers and the cleanup hook to specs that actually need OIDC stubs:
require "activeadmin/oidc/test_helpers" RSpec.configure do |config| config.include ActiveAdmin::Oidc::TestHelpers, oidc_mode: true config.after(:each, :oidc_mode) { reset_oidc_stubs } end
Then in your specs:
RSpec.describe "OIDC sign-in", :oidc_mode do it "signs in" do stub_oidc_sign_in(claims: { "email" => "a@b.example" }) # ... end end
MIT — see LICENSE.txt.