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:migrateThe 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_userWithout 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
endOIDC 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'
endNon-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: trueThe 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.newrequire "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 hookWire 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 }
endThen 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
endMIT — see LICENSE.txt.