Skip to content

Commit 3b017f9

Browse files
committed
Update README: document auto-registration, test helpers, active_for_authentication guard, redirect_uri, custom views
- Remove outdated devise.rb manual setup step (engine handles it) - Add 'What the engine does automatically' section - Add redirect_uri to config example and option table - Document active_for_authentication? guard in sign-in flow - Add 'Custom login view' section (host views take precedence) - Add 'Testing' section with test helpers, CI job pattern - Reduce setup checklist from 4 items to 3
1 parent 4730250 commit 3b017f9

1 file changed

Lines changed: 109 additions & 25 deletions

File tree

README.md

Lines changed: 109 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ bin/rails db:migrate
2323

2424
## Host-app setup checklist
2525

26-
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:
26+
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:
2727

2828
### 1. `config/initializers/active_admin.rb`
2929

@@ -46,17 +46,19 @@ class AdminUser < ApplicationRecord
4646
end
4747
```
4848

49-
### 3. `config/initializers/devise.rb`
49+
### 3. `config/initializers/activeadmin_oidc.rb` (generated)
5050

51-
ActiveAdmin mounts Devise under `/admin`, so OmniAuth's path prefix has to match:
51+
Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below.
5252

53-
```ruby
54-
config.omniauth_path_prefix = "/admin/auth"
55-
```
53+
## What the engine does automatically
5654

57-
### 4. `config/initializers/activeadmin_oidc.rb` (generated)
55+
The gem's Rails engine handles several things so host apps don't have to:
5856

59-
Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below.
57+
* **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`.
58+
* **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`.
59+
* **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.
60+
* **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.
61+
* **Parameter filtering**`code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` are added to `Rails.application.config.filter_parameters`.
6062

6163
## Configuration
6264

@@ -70,6 +72,11 @@ ActiveAdmin::Oidc.configure do |c|
7072
# --- OIDC scopes ------------------------------------------------------
7173
# c.scope = "openid email profile"
7274

75+
# --- Redirect URI -----------------------------------------------------
76+
# Normally auto-derived from the callback route. Set explicitly when
77+
# behind a reverse proxy, CDN, or when the IdP requires exact matching.
78+
# c.redirect_uri = "https://admin.example.com/admin/auth/oidc/callback"
79+
7380
# --- Identity lookup --------------------------------------------------
7481
# Which AdminUser column to match existing rows against, and which
7582
# claim on the id_token/userinfo to read for the lookup.
@@ -101,21 +108,22 @@ end
101108

102109
| Option | Default | Purpose |
103110
|---|---|---|
104-
| `issuer` | (required) | OIDC discovery base URL |
105-
| `client_id` | (required) | IdP client identifier |
106-
| `client_secret` | `nil` | Blank PKCE public client |
111+
| `issuer` | -- (required) | OIDC discovery base URL |
112+
| `client_id` | -- (required) | IdP client identifier |
113+
| `client_secret` | `nil` | Blank => PKCE public client |
107114
| `scope` | `"openid email profile"` | Space-separated OIDC scopes |
108115
| `pkce` | auto | `true` when `client_secret` is blank; overridable |
116+
| `redirect_uri` | `nil` (auto) | Explicit callback URL; needed behind reverse proxies |
109117
| `identity_attribute` | `:email` | AdminUser column used for lookup/adoption |
110118
| `identity_claim` | `:email` | Claim key read from the id_token/userinfo |
111119
| `admin_user_class` | `"AdminUser"` | String or Class for the host's admin user model |
112120
| `login_button_label` | `"Sign in with SSO"` | Label on the login-page button |
113121
| `access_denied_message` | generic | Flash shown on any denial |
114-
| `on_login` | (required) | Authorization hook; see below |
122+
| `on_login` | -- (required) | Authorization hook; see below |
115123

116124
## The `on_login` hook
117125

118-
`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.
126+
`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.
119127

120128
### Signature
121129

@@ -129,7 +137,7 @@ c.on_login = ->(admin_user, claims) {
129137
# `sub` (copied from the OmniAuth uid) and `email`
130138
# (copied from info.email) for convenience.
131139
# access_token / refresh_token / id_token are NEVER
132-
# present they are stripped before this hook runs.
140+
# present -- they are stripped before this hook runs.
133141
#
134142
# Return truthy to allow sign-in.
135143
# Return falsy (false/nil) to deny: the user sees a generic denial
@@ -140,12 +148,12 @@ c.on_login = ->(admin_user, claims) {
140148
#
141149
# Exceptions raised inside the hook are logged at :error via
142150
# ActiveAdmin::Oidc.logger and surface to the user as the same
143-
# generic denial flash the callback action never 500s.
151+
# generic denial flash -- the callback action never 500s.
144152
true
145153
}
146154
```
147155

148-
### Example A Zitadel nested project roles claim
156+
### Example A -- Zitadel nested project roles claim
149157

150158
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.
151159

@@ -160,7 +168,7 @@ c.on_login = ->(admin_user, claims) {
160168
}
161169
```
162170

163-
### Example B department-based gating
171+
### Example B -- department-based gating
164172

165173
```ruby
166174
KNOWN_DEPARTMENTS = %w[ops eng support].freeze
@@ -174,7 +182,7 @@ c.on_login = ->(admin_user, claims) {
174182
}
175183
```
176184

177-
### Example C syncing from a standard `groups` claim (Keycloak-style)
185+
### Example C -- syncing from a standard `groups` claim (Keycloak-style)
178186

179187
```ruby
180188
ADMIN_GROUP = "admins"
@@ -190,7 +198,7 @@ c.on_login = ->(admin_user, claims) {
190198

191199
## Reading additional claims from the callback
192200

193-
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:
201+
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:
194202

195203
```ruby
196204
c.on_login = ->(admin_user, claims) {
@@ -206,7 +214,7 @@ c.on_login = ->(admin_user, claims) {
206214
}
207215
```
208216

209-
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:
217+
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:
210218

211219
```ruby
212220
AdminUser.last.oidc_raw_info
@@ -215,16 +223,92 @@ AdminUser.last.oidc_raw_info
215223

216224
## Sign-in flow
217225

218-
* A login button is added to the ActiveAdmin sessions page via a prepended view override no templates to edit.
226+
* A login button is added to the ActiveAdmin sessions page via a prepended view override -- no templates to edit.
219227
* 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.
220228
* After a successful callback the user is signed in and redirected to `/admin` (not the host app's `/`, which may not exist).
221-
* 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.
229+
* **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.
230+
* 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.
231+
232+
## Custom login view
233+
234+
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:
235+
236+
```
237+
app/views/active_admin/devise/sessions/new.html.erb
238+
```
239+
240+
The engine detects the host-app file and does not prepend its own view, so yours takes precedence with no extra configuration.
241+
242+
## Testing
243+
244+
The gem ships test helpers for host apps that need to exercise OIDC sign-in flows in their own specs.
245+
246+
### Quick setup
247+
248+
```ruby
249+
# spec/rails_helper.rb (or spec/support/oidc.rb)
250+
require "activeadmin/oidc/test_helpers"
251+
```
252+
253+
This single `require` auto-configures RSpec:
254+
255+
* Includes `ActiveAdmin::Oidc::TestHelpers` in specs tagged `oidc_mode: true`
256+
* Resets OmniAuth mocks after each `oidc_mode` example
257+
* Skips `oidc_mode` specs when the AdminUser model doesn't have `:omniauthable` loaded (i.e. when OIDC is not the active auth backend)
258+
* When `CI_RUN_OIDC=true` is set, runs **only** `oidc_mode` specs (useful for a dedicated CI job)
259+
260+
### Available helpers
261+
262+
```ruby
263+
RSpec.describe "OIDC sign-in", type: :feature, oidc_mode: true do
264+
it "provisions a new user" do
265+
# Stubs OmniAuth to return a successful OIDC auth hash.
266+
# Merges given claims with sensible defaults (preferred_username, email, roles).
267+
stub_oidc_sign_in(sub: "alice-sub", claims: { "email" => "alice@example.com" })
268+
269+
visit new_admin_user_session_path
270+
click_button "Sign in with SSO"
271+
272+
expect(page).to have_current_path("/admin")
273+
end
274+
275+
it "handles IdP errors" do
276+
# Stubs OmniAuth to simulate a strategy failure.
277+
stub_oidc_failure(:invalid_credentials)
278+
279+
visit new_admin_user_session_path
280+
click_button "Sign in with SSO"
281+
282+
expect(page).to have_content("no permission")
283+
end
284+
end
285+
```
286+
287+
| Helper | Purpose |
288+
|---|---|
289+
| `stub_oidc_sign_in(sub:, claims: {})` | Mock a successful OIDC callback with the given `sub` and claims |
290+
| `stub_oidc_failure(message_key)` | Mock an OmniAuth strategy failure (e.g. `:invalid_credentials`) |
291+
| `reset_oidc_stubs` | Clear mocks and disable test mode (called automatically in `after`) |
292+
293+
### CI job pattern
294+
295+
For apps that support multiple auth backends (DB, LDAP, OIDC) selected at boot time, run OIDC specs in a separate CI job:
296+
297+
```yaml
298+
oidc-specs:
299+
steps:
300+
- run: cp config/oidc.yml.distr config/oidc.yml
301+
- run: bundle exec rails db:create db:migrate
302+
- run: CI_RUN_OIDC=true bundle exec rspec --tag oidc_mode
303+
```
304+
305+
The default job should exclude them: `bundle exec rspec --tag ~oidc_mode`.
222306

223307
## Security notes
224308

225309
### Choice of `identity_attribute`
226310

227-
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.
311+
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.
228312

229313
### Unique index on the identity column
230314

@@ -238,7 +322,7 @@ The gem also adds a unique `(provider, uid)` partial index in its own install mi
238322

239323
### What's filtered from logs
240324

241-
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.
325+
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.
242326

243327
## Logger
244328

@@ -250,4 +334,4 @@ ActiveAdmin::Oidc.logger = MyStructuredLogger.new
250334

251335
## License
252336

253-
MIT see [`LICENSE.txt`](LICENSE.txt).
337+
MIT -- see [`LICENSE.txt`](LICENSE.txt).

0 commit comments

Comments
 (0)