No release in over 3 years
activeadmin-oidc plugs generic OpenID Connect single sign-on into ActiveAdmin. It builds on Devise + omniauth_openid_connect and adds JIT user provisioning, role mapping from provider claims via a host-owned on_login hook, and a single install generator that wires everything up.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 2.7
>= 13.0
>= 1.60
>= 1.7
>= 3.19

Runtime

 Project Readme

activeadmin-oidc

CI

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.

Installation

# Gemfile
gem "activeadmin-oidc"
bundle install
bin/rails generate active_admin:oidc:install
bin/rails db:migrate

Host-app setup checklist

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:

1. config/initializers/active_admin.rb

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.

2. app/models/admin_user.rb

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

  serialize :oidc_raw_info, coder: JSON
end

3. config/initializers/activeadmin_oidc.rb (generated)

Fill in at minimum issuer, client_id, and an on_login hook. Full reference below.

What the engine does automatically

The gem's Rails engine handles several things so host apps don't have to:

  • OmniAuth strategy registration — the engine registers the :openid_connect strategy with Devise automatically based on your ActiveAdmin::Oidc configuration. You do not need to add config.omniauth or config.omniauth_path_prefix to devise.rb.
  • Callback controller — the engine patches ActiveAdmin::Devise.controllers to route OmniAuth callbacks to the gem's controller. No manual controllers: { omniauth_callbacks: ... } needed in routes.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.
  • Path prefix — the engine sets Devise.omniauth_path_prefix and OmniAuth.config.path_prefix to /admin/auth so the middleware intercepts requests under ActiveAdmin's mount point. Compatible with Rails 7.2+ and Rails 8's lazy route loading.
  • Parameter filteringcode, id_token, access_token, refresh_token, state, and nonce are added to Rails.application.config.filter_parameters.

Configuration

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 reference

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

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.

Signature

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
}

Example A — Zitadel nested project roles claim

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
}

Example B — department-based gating

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
}

Example C — syncing from a standard groups claim (Keycloak-style)

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
}

Reading additional claims from the callback

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" => [...], ... }

Sign-in flow

  • 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/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.
  • 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.

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, a combined SSO + password form for a break-glass mode — 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.

Security notes

Choice of identity_attribute

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.

Unique index on the identity column

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.

What's filtered from logs

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.

Logger

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

License

MIT — see LICENSE.txt.