You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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/).
4
4
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).
6
6
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.
Then fill in `config/initializers/activeadmin_oidc.rb` with your issuer and client id.
29
-
30
22
## Host-app setup checklist
31
23
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:
|`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 |
42
117
43
-
2.**`app/models/admin_user.rb`** — the Devise call must include `:omniauthable` and declare the `oidc` provider:
118
+
## The `on_login` hook
44
119
45
-
```ruby
46
-
classAdminUser < 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.
50
121
51
-
serialize :oidc_raw_info, coder:JSON
52
-
end
53
-
```
122
+
### Signature
54
123
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.
admin_user.name = claims["name"] if claims["name"].present?
161
+
true
162
+
}
163
+
```
60
164
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
+
returnfalseunlessKNOWN_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)
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:
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:
* 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`
69
228
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.
71
230
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
+
```
73
238
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:
0 commit comments