Skip to content

Commit ab182ef

Browse files
committed
Rewrite README and formalize admin_user_class config option
Strip the README down to usage docs: remove the why-this-gem-exists narrative, the "first-class Zitadel" framing (replaced with a neutral "used in production by the authors against Zitadel"), the roadmap, and the "what this gem does NOT reimplement" section. Add the documentation that was missing: - Host-app setup checklist (active_admin.rb, admin_user.rb, devise.rb, the generated initializer) with the exact lines each file needs. - Full Configuration reference: commented example block plus a table of every option, its default, and its purpose. - Detailed on_login hook section: signature, argument semantics, how denial is surfaced, how exceptions are handled, and three worked examples (Zitadel nested roles, department gating, Keycloak groups). - Reading-additional-claims section showing how to pull arbitrary claims off the hash and how oidc_raw_info is stored. - Security notes: guidance on safe vs unsafe identity_attribute choices and the unique-index requirement. - Logger section documenting ActiveAdmin::Oidc.logger. Formalize admin_user_class as a real Configuration option: - Add :admin_user_class accessor with default "AdminUser" - UserProvisioner now resolves via config.admin_user_class (String or Class), dropping the old respond_to?-based hack that silently never fired because Configuration did not expose the reader. - Add specs for both code paths (String + Class).
1 parent 635b2df commit ab182ef

5 files changed

Lines changed: 243 additions & 53 deletions

File tree

README.md

Lines changed: 216 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
# activeadmin-oidc
22

3-
OpenID Connect single sign-on for [ActiveAdmin](https://activeadmin.info/), with a first-class [Zitadel](https://zitadel.com/) preset.
3+
OpenID Connect single sign-on for [ActiveAdmin](https://activeadmin.info/).
44

5-
This gem plugs generic OIDC SSO into ActiveAdmin's existing Devise stack. It builds on [`omniauth_openid_connect`](https://github.com/omniauth/omniauth_openid_connect) for the OIDC protocol and adds the wiring ActiveAdmin apps actually need: JIT user provisioning, role mapping from provider claims, a Zitadel preset for the nested `urn:zitadel:iam:org:project:roles` claim, a login-view override, and a single install generator.
5+
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`](https://github.com/omniauth/omniauth_openid_connect).
66

7-
## Why not just follow the ActiveAdmin wiki?
8-
9-
The [ActiveAdmin OAuth wiki recipe](https://github.com/activeadmin/activeadmin/wiki/Log-in-through-OAuth-providers) is a 200-line copy-paste that only covers Google and doesn't handle role mapping, account linking, or Zitadel's claim shape. This gem packages the wiring once so you can `rails g active_admin:oidc:install` and be done.
10-
11-
## What this gem does NOT reimplement
12-
13-
The OIDC protocol layer — discovery, JWKS, token verification, PKCE, nonce, state — is delegated to the maintained upstream [`omniauth_openid_connect`](https://github.com/omniauth/omniauth_openid_connect) gem. This gem is a convention-over-configuration wrapper, not a new OIDC client.
7+
Used in production by the authors against [Zitadel](https://zitadel.com/); the `:zitadel` preset below covers that wiring. Other compliant OIDC providers work via the standard omniauth_openid_connect options.
148

159
## Installation
1610

@@ -25,60 +19,236 @@ bin/rails generate active_admin:oidc:install
2519
bin/rails db:migrate
2620
```
2721

28-
Then fill in `config/initializers/activeadmin_oidc.rb` with your issuer and client id.
29-
3022
## Host-app setup checklist
3123

32-
The generator can't modify your `active_admin.rb` or `admin_user.rb` for you. Make sure these are in place — the install generator will print a "next steps" reminder, but you may as well do them up front:
24+
The generator creates the initializer and migration, but it cannot edit your `active_admin.rb` or `admin_user.rb`. Four things have to be in place:
25+
26+
### 1. `config/initializers/active_admin.rb`
27+
28+
```ruby
29+
config.authentication_method = :authenticate_admin_user!
30+
config.current_user_method = :current_admin_user
31+
```
32+
33+
Without these, `/admin` is public to anyone and the utility navigation (including the logout button) renders empty.
34+
35+
### 2. `app/models/admin_user.rb`
36+
37+
```ruby
38+
class AdminUser < ApplicationRecord
39+
devise :database_authenticatable,
40+
:rememberable,
41+
:omniauthable, omniauth_providers: [:oidc]
42+
43+
serialize :oidc_raw_info, coder: JSON
44+
end
45+
```
46+
47+
### 3. `config/initializers/devise.rb`
48+
49+
ActiveAdmin mounts Devise under `/admin`, so OmniAuth's path prefix has to match:
3350

34-
1. **`config/initializers/active_admin.rb`** — uncomment both of these:
51+
```ruby
52+
config.omniauth_path_prefix = "/admin/auth"
53+
```
54+
55+
### 4. `config/initializers/activeadmin_oidc.rb` (generated)
56+
57+
Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below.
58+
59+
## Configuration
60+
61+
```ruby
62+
ActiveAdmin::Oidc.configure do |c|
63+
# --- Provider endpoints -----------------------------------------------
64+
c.issuer = ENV.fetch("OIDC_ISSUER")
65+
c.client_id = ENV.fetch("OIDC_CLIENT_ID")
66+
c.client_secret = ENV.fetch("OIDC_CLIENT_SECRET", nil) # blank ⇒ PKCE public client
67+
68+
# --- Optional transport-layer preset ----------------------------------
69+
# Sets scope and enables PKCE automatically when no client_secret is set.
70+
# c.preset :zitadel
71+
72+
# --- OIDC scopes ------------------------------------------------------
73+
# c.scope = "openid email profile"
74+
75+
# --- Identity lookup --------------------------------------------------
76+
# Which AdminUser column to match existing rows against, and which
77+
# claim on the id_token/userinfo to read for the lookup.
78+
# c.identity_attribute = :email
79+
# c.identity_claim = :email
80+
81+
# --- AdminUser model resolution ---------------------------------------
82+
# Accepts a String (lazy constant lookup, recommended) or a Class.
83+
# Use when your model is not literally ::AdminUser.
84+
# c.admin_user_class = "Admin::User"
85+
86+
# --- UI copy ----------------------------------------------------------
87+
# c.login_button_label = "Sign in with Corporate SSO"
88+
# c.access_denied_message = "Your account has no permission to access this admin panel."
89+
90+
# --- PKCE override ----------------------------------------------------
91+
# By default PKCE is enabled iff client_secret is blank. Override:
92+
# c.pkce = true
93+
94+
# --- Authorization hook (REQUIRED) ------------------------------------
95+
c.on_login = ->(admin_user, claims) {
96+
# ... see "The on_login hook" below
97+
true
98+
}
99+
end
100+
```
35101

36-
```ruby
37-
config.authentication_method = :authenticate_admin_user!
38-
config.current_user_method = :current_admin_user
39-
```
102+
### Option reference
40103

41-
Without these, `/admin` is public to anyone and the utility navigation (including the logout button) renders empty.
104+
| Option | Default | Purpose |
105+
|---|---|---|
106+
| `issuer` | — (required) | OIDC discovery base URL |
107+
| `client_id` | — (required) | IdP client identifier |
108+
| `client_secret` | `nil` | Blank ⇒ PKCE public client |
109+
| `scope` | `"openid email profile"` | Space-separated OIDC scopes |
110+
| `pkce` | auto | `true` when `client_secret` is blank; overridable |
111+
| `identity_attribute` | `:email` | AdminUser column used for lookup/adoption |
112+
| `identity_claim` | `:email` | Claim key read from the id_token/userinfo |
113+
| `admin_user_class` | `"AdminUser"` | String or Class for the host's admin user model |
114+
| `login_button_label` | `"Sign in with SSO"` | Label on the login-page button |
115+
| `access_denied_message` | generic | Flash shown on any denial |
116+
| `on_login` | — (required) | Authorization hook; see below |
42117

43-
2. **`app/models/admin_user.rb`** — the Devise call must include `:omniauthable` and declare the `oidc` provider:
118+
## The `on_login` hook
44119

45-
```ruby
46-
class AdminUser < ApplicationRecord
47-
devise :database_authenticatable,
48-
:rememberable,
49-
:omniauthable, omniauth_providers: [:oidc]
120+
`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.
50121

51-
serialize :oidc_raw_info, coder: JSON
52-
end
53-
```
122+
### Signature
54123

55-
3. **`config/initializers/devise.rb`** — ActiveAdmin mounts Devise under `/admin`, so OmniAuth's path prefix has to match:
124+
```ruby
125+
c.on_login = ->(admin_user, claims) {
126+
# admin_user: an instance of the configured admin_user_class.
127+
# Either a pre-existing row (matched by provider/uid or by
128+
# identity_attribute) or an unsaved new record.
129+
# claims: a Hash of String keys. Contains everything the IdP
130+
# returned in the id_token/userinfo, plus the top-level
131+
# `sub` (copied from the OmniAuth uid) and `email`
132+
# (copied from info.email) for convenience.
133+
# access_token / refresh_token / id_token are NEVER
134+
# present — they are stripped before this hook runs.
135+
#
136+
# Return truthy to allow sign-in.
137+
# Return falsy (false/nil) to deny: the user sees a generic denial
138+
# flash and no AdminUser record is persisted or mutated.
139+
#
140+
# Any mutations you make to admin_user are persisted automatically
141+
# after the hook returns truthy.
142+
#
143+
# Exceptions raised inside the hook are logged at :error via
144+
# ActiveAdmin::Oidc.logger and surface to the user as the same
145+
# generic denial flash — the callback action never 500s.
146+
true
147+
}
148+
```
149+
150+
### Example A — Zitadel nested project roles claim
151+
152+
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.
56153

57-
```ruby
58-
config.omniauth_path_prefix = "/admin/auth"
59-
```
154+
```ruby
155+
c.on_login = ->(admin_user, claims) {
156+
roles = claims["urn:zitadel:iam:org:project:roles"]&.keys || []
157+
return false if roles.empty?
158+
159+
admin_user.roles = roles
160+
admin_user.name = claims["name"] if claims["name"].present?
161+
true
162+
}
163+
```
60164

61-
4. **`config/initializers/activeadmin_oidc.rb`** (generated) — set at minimum `c.issuer`, `c.client_id`, and an `c.on_login` hook that decides whether a given user is allowed in.
165+
### Example B — department-based gating
166+
167+
```ruby
168+
KNOWN_DEPARTMENTS = %w[ops eng support].freeze
169+
170+
c.on_login = ->(admin_user, claims) {
171+
dept = claims["department"]
172+
return false unless KNOWN_DEPARTMENTS.include?(dept)
173+
174+
admin_user.department = dept
175+
true
176+
}
177+
```
178+
179+
### Example C — syncing from a standard `groups` claim (Keycloak-style)
180+
181+
```ruby
182+
ADMIN_GROUP = "admins"
183+
184+
c.on_login = ->(admin_user, claims) {
185+
groups = Array(claims["groups"])
186+
return false unless groups.include?(ADMIN_GROUP)
187+
188+
admin_user.super_admin = groups.include?("super-admins")
189+
true
190+
}
191+
```
192+
193+
## Reading additional claims from the callback
194+
195+
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:
196+
197+
```ruby
198+
c.on_login = ->(admin_user, claims) {
199+
admin_user.employee_id = claims["employee_id"]
200+
admin_user.given_name = claims["given_name"]
201+
admin_user.family_name = claims["family_name"]
202+
admin_user.locale = claims["locale"]
203+
admin_user.email_verified = claims["email_verified"]
204+
# Nested / structured claims come through as whatever the IdP sent.
205+
# Zitadel metadata, for instance:
206+
admin_user.tenant_id = claims.dig("urn:zitadel:iam:user:metadata", "tenant_id")
207+
true
208+
}
209+
```
210+
211+
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:
212+
213+
```ruby
214+
AdminUser.last.oidc_raw_info
215+
# => { "sub" => "...", "email" => "...", "groups" => [...], ... }
216+
```
62217

63218
## Sign-in flow
64219

65-
* A login button is added to the ActiveAdmin sessions page via a prepended view override — you don't need to edit any templates.
66-
* Clicking it POSTs to `/admin/auth/oidc` with a Rails CSRF token (the gem forces OmniAuth 2.x's authenticity check to delegate to Rails' forgery protection, so `button_to` just works).
67-
* After a successful callback the user is signed in and redirected directly to `/admin` — the gem doesn't assume the host has a `root` route.
68-
* Logout goes through Devise's stock session destroy; no `end_session_endpoint` ping to the IdP. If you want RP-initiated single-logout, override the destroy action in your host app.
220+
* A login button is added to the ActiveAdmin sessions page via a prepended view override — no templates to edit.
221+
* 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.
222+
* After a successful callback the user is signed in and redirected to `/admin` (not the host app's `/`, which may not exist).
223+
* 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.
224+
225+
## Security notes
226+
227+
### Choice of `identity_attribute`
69228

70-
## Status and roadmap
229+
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.
71230

72-
Design and test plan are locked. See [`docs/TEST_PLAN.md`](docs/TEST_PLAN.md) for the full TDD/BDD roadmap. Implementation follows red-green-refactor in this order:
231+
### Unique index on the identity column
232+
233+
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:
234+
235+
```ruby
236+
add_index :admin_users, :employee_id, unique: true
237+
```
73238

74-
1. `Configuration`
75-
2. `Discovery` wrapper over omniauth_openid_connect discovery
76-
3. `RoleResolver`
77-
4. `UserProvisioner`
78-
5. `Presets::Zitadel`
79-
6. Rails engine, routes, `SessionsController`
80-
7. Install generator
81-
8. Security hardening pass
239+
The gem also adds a unique `(provider, uid)` partial index in its own install migration.
240+
241+
### What's filtered from logs
242+
243+
The initializer 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.
244+
245+
## Logger
246+
247+
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:
248+
249+
```ruby
250+
ActiveAdmin::Oidc.logger = MyStructuredLogger.new
251+
```
82252

83253
## License
84254

lib/activeadmin/oidc/configuration.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ class Configuration
88
DEFAULT_IDENTITY_ATTRIBUTE = :email
99
DEFAULT_IDENTITY_CLAIM = :email
1010
DEFAULT_LOGIN_BUTTON_LABEL = "Sign in with SSO"
11+
DEFAULT_ADMIN_USER_CLASS = "AdminUser"
1112
DEFAULT_ACCESS_DENIED_MESSAGE =
1213
"Your account has no permission to access this admin panel."
1314

1415
attr_accessor :issuer, :client_id, :client_secret, :scope,
1516
:login_button_label, :timeout,
1617
:identity_attribute, :identity_claim,
17-
:access_denied_message, :on_login
18+
:access_denied_message, :on_login, :admin_user_class
1819

1920
def initialize
2021
reset!
@@ -30,6 +31,7 @@ def reset!
3031
@identity_attribute = DEFAULT_IDENTITY_ATTRIBUTE
3132
@identity_claim = DEFAULT_IDENTITY_CLAIM
3233
@access_denied_message = DEFAULT_ACCESS_DENIED_MESSAGE
34+
@admin_user_class = DEFAULT_ADMIN_USER_CLASS
3335
@on_login = nil
3436
@pkce_override = nil
3537
self

lib/activeadmin/oidc/user_provisioner.rb

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,12 @@ def call
4747
private
4848

4949
def model
50-
@model ||= admin_user_class
50+
@model ||= resolve_admin_user_class
5151
end
5252

53-
def admin_user_class
54-
klass_name = @config.respond_to?(:admin_user_class) && @config.admin_user_class
55-
return klass_name if klass_name.is_a?(Class)
56-
57-
Object.const_get(klass_name || "AdminUser")
53+
def resolve_admin_user_class
54+
value = @config.admin_user_class
55+
value.is_a?(Class) ? value : Object.const_get(value)
5856
end
5957

6058
def validate_claims!

spec/unit/configuration_spec.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
expect(config.access_denied_message).to match(/permission/i)
3333
end
3434

35+
it "defaults admin_user_class to the string 'AdminUser'" do
36+
expect(config.admin_user_class).to eq("AdminUser")
37+
end
38+
3539
it "has no issuer by default" do
3640
expect(config.issuer).to be_nil
3741
end

spec/unit/user_provisioner_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,22 @@
188188
end
189189
end
190190

191+
describe "admin_user_class configuration" do
192+
it "defaults to looking up the AdminUser constant" do
193+
expect { provisioner.call }.to change(AdminUser, :count).by(1)
194+
end
195+
196+
it "accepts a Class reference directly" do
197+
config.admin_user_class = AdminUser
198+
expect { provisioner.call }.to change(AdminUser, :count).by(1)
199+
end
200+
201+
it "accepts a String class name for lazy resolution" do
202+
config.admin_user_class = "AdminUser"
203+
expect { provisioner.call }.to change(AdminUser, :count).by(1)
204+
end
205+
end
206+
191207
describe "concurrent first-login race" do
192208
it "results in exactly one AdminUser for a given (provider, uid)" do
193209
# Simulate two callbacks racing: both see no existing row, both try to

0 commit comments

Comments
 (0)