activeadmin-oidc 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +79 -0
- data/app/views/active_admin/devise/sessions/new.html.erb +7 -0
- data/lib/activeadmin/oidc/configuration.rb +62 -0
- data/lib/activeadmin/oidc/engine.rb +106 -0
- data/lib/activeadmin/oidc/test_helpers.rb +103 -0
- data/lib/activeadmin/oidc/user_provisioner.rb +142 -0
- data/lib/activeadmin/oidc/version.rb +7 -0
- data/lib/activeadmin-oidc.rb +72 -0
- data/lib/generators/active_admin/oidc/install/install_generator.rb +148 -0
- data/lib/generators/active_admin/oidc/install/templates/initializer.rb.tt +48 -0
- data/lib/generators/active_admin/oidc/install/templates/migration.rb.tt +11 -0
- data/lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb +9 -0
- metadata +287 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5aaac0eb113550e4278c0ebd3dcae2898c148d2f439a87da8c119ed9a5129811
|
|
4
|
+
data.tar.gz: ee952618ae8ad48518d4f6a125b20a5e4ccaf3840233391e98964f11a708f222
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dbf926e94596cadb0d98a934ccd75dfa82f5ca3f2c97f9db665bfb0e3ef2bada39256620bc9078127e3cd36fc6dbf750dc0cbfc3de3f8d5cfc38ba2f40bb6cee
|
|
7
|
+
data.tar.gz: 45f36af5f4986bb57a539e93b9efbedd8e27e2402d5848b770891e1cac8e32d9013b57da143b9f30802b440a724e36e7839a4a395b14c46d2a0d150a3cc0646c
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor Fedoronchuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# activeadmin-oidc
|
|
2
|
+
|
|
3
|
+
[](https://github.com/activeadmin-plugins/activeadmin-oidc/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
OpenID Connect single sign-on for [ActiveAdmin](https://activeadmin.info/).
|
|
6
|
+
|
|
7
|
+
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).
|
|
8
|
+
|
|
9
|
+
Used in production by the authors against [Zitadel](https://zitadel.com/). Other compliant OIDC providers work via the standard omniauth_openid_connect options.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "activeadmin-oidc"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bundle install
|
|
20
|
+
bin/rails generate active_admin:oidc:install
|
|
21
|
+
bin/rails db:migrate
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Host-app setup checklist
|
|
25
|
+
|
|
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:
|
|
27
|
+
|
|
28
|
+
### 1. `config/initializers/active_admin.rb`
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
config.authentication_method = :authenticate_admin_user!
|
|
32
|
+
config.current_user_method = :current_admin_user
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Without these, `/admin` is public to anyone and the utility navigation (including the logout button) renders empty.
|
|
36
|
+
|
|
37
|
+
### 2. `app/models/admin_user.rb`
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
class AdminUser < ApplicationRecord
|
|
41
|
+
devise :database_authenticatable,
|
|
42
|
+
:rememberable,
|
|
43
|
+
:omniauthable, omniauth_providers: [:oidc]
|
|
44
|
+
|
|
45
|
+
serialize :oidc_raw_info, coder: JSON
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 3. `config/initializers/activeadmin_oidc.rb` (generated)
|
|
50
|
+
|
|
51
|
+
Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below.
|
|
52
|
+
|
|
53
|
+
## What the engine does automatically
|
|
54
|
+
|
|
55
|
+
The gem's Rails engine handles several things so host apps don't have to:
|
|
56
|
+
|
|
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`.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
ActiveAdmin::Oidc.configure do |c|
|
|
67
|
+
# --- Provider endpoints -----------------------------------------------
|
|
68
|
+
c.issuer = ENV.fetch("OIDC_ISSUER")
|
|
69
|
+
c.client_id = ENV.fetch("OIDC_CLIENT_ID")
|
|
70
|
+
c.client_secret = ENV.fetch("OIDC_CLIENT_SECRET", nil) # blank ⇒ PKCE public client
|
|
71
|
+
|
|
72
|
+
# --- OIDC scopes ------------------------------------------------------
|
|
73
|
+
# c.scope = "openid email profile"
|
|
74
|
+
|
|
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
|
+
|
|
80
|
+
# --- Identity lookup --------------------------------------------------
|
|
81
|
+
# Which AdminUser column to match existing rows against, and which
|
|
82
|
+
# claim on the id_token/userinfo to read for the lookup.
|
|
83
|
+
# c.identity_attribute = :email
|
|
84
|
+
# c.identity_claim = :email
|
|
85
|
+
|
|
86
|
+
# --- AdminUser model resolution ---------------------------------------
|
|
87
|
+
# Accepts a String (lazy constant lookup, recommended) or a Class.
|
|
88
|
+
# Use when your model is not literally ::AdminUser.
|
|
89
|
+
# c.admin_user_class = "Admin::User"
|
|
90
|
+
|
|
91
|
+
# --- UI copy ----------------------------------------------------------
|
|
92
|
+
# c.login_button_label = "Sign in with Corporate SSO"
|
|
93
|
+
# c.access_denied_message = "Your account has no permission to access this admin panel."
|
|
94
|
+
|
|
95
|
+
# --- PKCE override ----------------------------------------------------
|
|
96
|
+
# By default PKCE is enabled iff client_secret is blank. Override:
|
|
97
|
+
# c.pkce = true
|
|
98
|
+
|
|
99
|
+
# --- Authorization hook (REQUIRED) ------------------------------------
|
|
100
|
+
c.on_login = ->(admin_user, claims) {
|
|
101
|
+
# ... see "The on_login hook" below
|
|
102
|
+
true
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Option reference
|
|
108
|
+
|
|
109
|
+
| Option | Default | Purpose |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `issuer` | — (required) | OIDC discovery base URL |
|
|
112
|
+
| `client_id` | — (required) | IdP client identifier |
|
|
113
|
+
| `client_secret` | `nil` | Blank ⇒ PKCE public client |
|
|
114
|
+
| `scope` | `"openid email profile"` | Space-separated OIDC scopes |
|
|
115
|
+
| `pkce` | auto | `true` when `client_secret` is blank; overridable |
|
|
116
|
+
| `redirect_uri` | `nil` (auto) | Explicit callback URL; needed behind reverse proxies |
|
|
117
|
+
| `identity_attribute` | `:email` | AdminUser column used for lookup/adoption |
|
|
118
|
+
| `identity_claim` | `:email` | Claim key read from the id_token/userinfo |
|
|
119
|
+
| `admin_user_class` | `"AdminUser"` | String or Class for the host's admin user model |
|
|
120
|
+
| `login_button_label` | `"Sign in with SSO"` | Label on the login-page button |
|
|
121
|
+
| `access_denied_message` | generic | Flash shown on any denial |
|
|
122
|
+
| `on_login` | — (required) | Authorization hook; see below |
|
|
123
|
+
|
|
124
|
+
## The `on_login` hook
|
|
125
|
+
|
|
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.
|
|
127
|
+
|
|
128
|
+
### Signature
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
c.on_login = ->(admin_user, claims) {
|
|
132
|
+
# admin_user: an instance of the configured admin_user_class.
|
|
133
|
+
# Either a pre-existing row (matched by provider/uid or by
|
|
134
|
+
# identity_attribute) or an unsaved new record.
|
|
135
|
+
# claims: a Hash of String keys. Contains everything the IdP
|
|
136
|
+
# returned in the id_token/userinfo, plus the top-level
|
|
137
|
+
# `sub` (copied from the OmniAuth uid) and `email`
|
|
138
|
+
# (copied from info.email) for convenience.
|
|
139
|
+
# access_token / refresh_token / id_token are NEVER
|
|
140
|
+
# present — they are stripped before this hook runs.
|
|
141
|
+
#
|
|
142
|
+
# Return truthy to allow sign-in.
|
|
143
|
+
# Return falsy (false/nil) to deny: the user sees a generic denial
|
|
144
|
+
# flash and no AdminUser record is persisted or mutated.
|
|
145
|
+
#
|
|
146
|
+
# Any mutations you make to admin_user are persisted automatically
|
|
147
|
+
# after the hook returns truthy.
|
|
148
|
+
#
|
|
149
|
+
# Exceptions raised inside the hook are logged at :error via
|
|
150
|
+
# ActiveAdmin::Oidc.logger and surface to the user as the same
|
|
151
|
+
# generic denial flash — the callback action never 500s.
|
|
152
|
+
true
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Example A — Zitadel nested project roles claim
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
c.on_login = ->(admin_user, claims) {
|
|
162
|
+
roles = claims["urn:zitadel:iam:org:project:roles"]&.keys || []
|
|
163
|
+
return false if roles.empty?
|
|
164
|
+
|
|
165
|
+
admin_user.roles = roles
|
|
166
|
+
admin_user.name = claims["name"] if claims["name"].present?
|
|
167
|
+
true
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Example B — department-based gating
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
KNOWN_DEPARTMENTS = %w[ops eng support].freeze
|
|
175
|
+
|
|
176
|
+
c.on_login = ->(admin_user, claims) {
|
|
177
|
+
dept = claims["department"]
|
|
178
|
+
return false unless KNOWN_DEPARTMENTS.include?(dept)
|
|
179
|
+
|
|
180
|
+
admin_user.department = dept
|
|
181
|
+
true
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Example C — syncing from a standard `groups` claim (Keycloak-style)
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
ADMIN_GROUP = "admins"
|
|
189
|
+
|
|
190
|
+
c.on_login = ->(admin_user, claims) {
|
|
191
|
+
groups = Array(claims["groups"])
|
|
192
|
+
return false unless groups.include?(ADMIN_GROUP)
|
|
193
|
+
|
|
194
|
+
admin_user.super_admin = groups.include?("super-admins")
|
|
195
|
+
true
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Reading additional claims from the callback
|
|
200
|
+
|
|
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:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
c.on_login = ->(admin_user, claims) {
|
|
205
|
+
admin_user.employee_id = claims["employee_id"]
|
|
206
|
+
admin_user.given_name = claims["given_name"]
|
|
207
|
+
admin_user.family_name = claims["family_name"]
|
|
208
|
+
admin_user.locale = claims["locale"]
|
|
209
|
+
admin_user.email_verified = claims["email_verified"]
|
|
210
|
+
# Nested / structured claims come through as whatever the IdP sent.
|
|
211
|
+
# Zitadel metadata, for instance:
|
|
212
|
+
admin_user.tenant_id = claims.dig("urn:zitadel:iam:user:metadata", "tenant_id")
|
|
213
|
+
true
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
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:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
AdminUser.last.oidc_raw_info
|
|
221
|
+
# => { "sub" => "...", "email" => "...", "groups" => [...], ... }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Sign-in flow
|
|
225
|
+
|
|
226
|
+
* A login button is added to the ActiveAdmin sessions page via a prepended view override — no templates to edit.
|
|
227
|
+
* 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.
|
|
228
|
+
* After a successful callback the user is signed in and redirected to `/admin` (not the host app's `/`, which may not exist).
|
|
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
|
+
## Security notes
|
|
243
|
+
|
|
244
|
+
### Choice of `identity_attribute`
|
|
245
|
+
|
|
246
|
+
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.
|
|
247
|
+
|
|
248
|
+
### Unique index on the identity column
|
|
249
|
+
|
|
250
|
+
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:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
add_index :admin_users, :employee_id, unique: true
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The gem also adds a unique `(provider, uid)` partial index in its own install migration.
|
|
257
|
+
|
|
258
|
+
### What's filtered from logs
|
|
259
|
+
|
|
260
|
+
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.
|
|
261
|
+
|
|
262
|
+
## Logger
|
|
263
|
+
|
|
264
|
+
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:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
ActiveAdmin::Oidc.logger = MyStructuredLogger.new
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
MIT — see [`LICENSE.txt`](LICENSE.txt).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'devise'
|
|
4
|
+
|
|
5
|
+
module ActiveAdmin
|
|
6
|
+
module Oidc
|
|
7
|
+
module Devise
|
|
8
|
+
# Handles the OAuth callback from the IdP. Wiring:
|
|
9
|
+
#
|
|
10
|
+
# # config/routes.rb (or inside ActiveAdmin::Devise.config)
|
|
11
|
+
# devise_for :admin_users, ActiveAdmin::Devise.config.merge(
|
|
12
|
+
# controllers: {
|
|
13
|
+
# omniauth_callbacks: "active_admin/oidc/devise/omniauth_callbacks"
|
|
14
|
+
# }
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# The action name matches the provider name registered with Devise
|
|
18
|
+
# (`:oidc`, from ActiveAdmin::Oidc::Engine::PROVIDER_NAME).
|
|
19
|
+
class OmniauthCallbacksController < ::Devise::OmniauthCallbacksController
|
|
20
|
+
def oidc
|
|
21
|
+
auth = request.env['omniauth.auth'] || {}
|
|
22
|
+
info = auth['info'] || {}
|
|
23
|
+
# Defensive: an OIDC strategy is supposed to put a Hash at
|
|
24
|
+
# extra.raw_info, but a misbehaving/custom strategy could
|
|
25
|
+
# set something else (String, nil, Array). We only trust a
|
|
26
|
+
# Hash-shaped value; anything else collapses to {} and we
|
|
27
|
+
# rebuild `sub`/`email` from the top-level auth hash below.
|
|
28
|
+
extra = auth.dig('extra', 'raw_info')
|
|
29
|
+
extra = {} unless extra.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
claims = extra.to_h.transform_keys(&:to_s)
|
|
32
|
+
claims['sub'] = auth['uid'] if claims['sub'].blank? && auth['uid'].present?
|
|
33
|
+
claims['email'] = info['email'] if claims['email'].blank? && info['email'].present?
|
|
34
|
+
|
|
35
|
+
admin_user = UserProvisioner.new(
|
|
36
|
+
ActiveAdmin::Oidc.config,
|
|
37
|
+
claims: claims,
|
|
38
|
+
provider: ActiveAdmin::Oidc::Engine::PROVIDER_NAME.to_s
|
|
39
|
+
).call
|
|
40
|
+
|
|
41
|
+
# Devise checks active_for_authentication? on session
|
|
42
|
+
# deserialization but NOT on initial OmniAuth sign-in.
|
|
43
|
+
# Guard here so disabled/locked users are rejected immediately.
|
|
44
|
+
unless admin_user.active_for_authentication?
|
|
45
|
+
message = admin_user.inactive_message
|
|
46
|
+
flash[:alert] = I18n.t("devise.failure.#{message}", default: message.to_s)
|
|
47
|
+
redirect_to after_omniauth_failure_path_for(resource_name)
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sign_in_and_redirect admin_user, event: :authentication
|
|
52
|
+
set_flash_message(:notice, :success, kind: 'OIDC') if is_navigational_format?
|
|
53
|
+
rescue ActiveAdmin::Oidc::ProvisioningError => e
|
|
54
|
+
Rails.logger.warn("[activeadmin-oidc] denial: #{e.message}")
|
|
55
|
+
flash[:alert] = ActiveAdmin::Oidc.config.access_denied_message
|
|
56
|
+
redirect_to after_omniauth_failure_path_for(resource_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def failure
|
|
60
|
+
Rails.logger.warn("[activeadmin-oidc] omniauth failure: #{failure_message}")
|
|
61
|
+
flash[:alert] = ActiveAdmin::Oidc.config.access_denied_message
|
|
62
|
+
redirect_to after_omniauth_failure_path_for(resource_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Land on the ActiveAdmin namespace root after a successful SSO
|
|
68
|
+
# sign-in instead of Devise's default (host app root). Hosts
|
|
69
|
+
# that don't define a `/` route would otherwise hit a routing
|
|
70
|
+
# error immediately after login, and even when `/` does exist
|
|
71
|
+
# it's rarely what an admin user wants to see. ActiveAdmin
|
|
72
|
+
# always mounts at `/admin`, so we go there directly.
|
|
73
|
+
def after_sign_in_path_for(resource)
|
|
74
|
+
stored_location_for(resource) || '/admin'
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div id="login">
|
|
2
|
+
<h2><%= active_admin_application.site_title(self) %></h2>
|
|
3
|
+
|
|
4
|
+
<%= form_tag omniauth_authorize_path(resource_name, :oidc), method: :post, data: { turbo: false } do %>
|
|
5
|
+
<%= submit_tag ActiveAdmin::Oidc.config.login_button_label %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module Oidc
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_SCOPE = 'openid email profile'
|
|
7
|
+
DEFAULT_TIMEOUT = 5
|
|
8
|
+
DEFAULT_IDENTITY_ATTRIBUTE = :email
|
|
9
|
+
DEFAULT_IDENTITY_CLAIM = :email
|
|
10
|
+
DEFAULT_LOGIN_BUTTON_LABEL = 'Sign in with SSO'
|
|
11
|
+
DEFAULT_ADMIN_USER_CLASS = 'AdminUser'
|
|
12
|
+
DEFAULT_ACCESS_DENIED_MESSAGE =
|
|
13
|
+
'Your account has no permission to access this admin panel.'
|
|
14
|
+
|
|
15
|
+
attr_accessor :issuer, :client_id, :client_secret, :scope,
|
|
16
|
+
:redirect_uri,
|
|
17
|
+
:login_button_label, :timeout,
|
|
18
|
+
:identity_attribute, :identity_claim,
|
|
19
|
+
:access_denied_message, :on_login, :admin_user_class
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
reset!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset!
|
|
26
|
+
@issuer = nil
|
|
27
|
+
@client_id = nil
|
|
28
|
+
@client_secret = nil
|
|
29
|
+
@scope = DEFAULT_SCOPE
|
|
30
|
+
@redirect_uri = nil
|
|
31
|
+
@login_button_label = DEFAULT_LOGIN_BUTTON_LABEL
|
|
32
|
+
@timeout = DEFAULT_TIMEOUT
|
|
33
|
+
@identity_attribute = DEFAULT_IDENTITY_ATTRIBUTE
|
|
34
|
+
@identity_claim = DEFAULT_IDENTITY_CLAIM
|
|
35
|
+
@access_denied_message = DEFAULT_ACCESS_DENIED_MESSAGE
|
|
36
|
+
@admin_user_class = DEFAULT_ADMIN_USER_CLASS
|
|
37
|
+
@on_login = nil
|
|
38
|
+
@pkce_override = nil
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pkce
|
|
43
|
+
return @pkce_override unless @pkce_override.nil?
|
|
44
|
+
|
|
45
|
+
client_secret.nil? || client_secret.to_s.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def pkce=(value)
|
|
49
|
+
@pkce_override = value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate!
|
|
53
|
+
raise ConfigurationError, 'issuer is required' if issuer.blank?
|
|
54
|
+
raise ConfigurationError, 'client_id is required' if client_id.blank?
|
|
55
|
+
raise ConfigurationError, 'on_login is required' if on_login.nil?
|
|
56
|
+
raise ConfigurationError, 'on_login must be callable (respond to #call)' unless on_login.respond_to?(:call)
|
|
57
|
+
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/engine'
|
|
4
|
+
|
|
5
|
+
module ActiveAdmin
|
|
6
|
+
module Oidc
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
PROVIDER_NAME = :oidc
|
|
9
|
+
|
|
10
|
+
# True when the host's AdminUser model includes :omniauthable.
|
|
11
|
+
# Used to gate controller registration and view overrides so the
|
|
12
|
+
# gem is a no-op when OIDC is not enabled on the model.
|
|
13
|
+
def self.oidc_enabled?
|
|
14
|
+
admin_class = ActiveAdmin::Oidc.config.admin_user_class
|
|
15
|
+
klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class
|
|
16
|
+
klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ControllersPatch = Module.new do
|
|
20
|
+
def controllers
|
|
21
|
+
result = super
|
|
22
|
+
if Engine.oidc_enabled?
|
|
23
|
+
result = result.merge(
|
|
24
|
+
omniauth_callbacks: 'active_admin/oidc/devise/omniauth_callbacks'
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
initializer 'activeadmin_oidc.register_controllers' do |app|
|
|
32
|
+
app.config.to_prepare do
|
|
33
|
+
require 'active_admin/devise'
|
|
34
|
+
unless ::ActiveAdmin::Devise.singleton_class < ControllersPatch
|
|
35
|
+
::ActiveAdmin::Devise.singleton_class.prepend(ControllersPatch)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
initializer 'activeadmin_oidc.prepend_view_paths' do |app|
|
|
41
|
+
app.config.to_prepare do
|
|
42
|
+
if Engine.oidc_enabled?
|
|
43
|
+
require 'active_admin/devise'
|
|
44
|
+
# Only prepend the gem's SSO-only login view if the host app
|
|
45
|
+
# doesn't ship its own override. This avoids the need for hosts
|
|
46
|
+
# to re-prepend their views after the gem.
|
|
47
|
+
host_view = ::Rails.root.join(
|
|
48
|
+
'app/views/active_admin/devise/sessions/new.html.erb'
|
|
49
|
+
)
|
|
50
|
+
unless host_view.exist?
|
|
51
|
+
view_path = File.expand_path('../../../app/views', __dir__)
|
|
52
|
+
::ActiveAdmin::Devise::SessionsController.prepend_view_path(view_path)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Automatically register the OmniAuth :openid_connect strategy with
|
|
59
|
+
# Devise when the gem is configured, so host apps don't have to
|
|
60
|
+
# duplicate the config.omniauth boilerplate in devise.rb.
|
|
61
|
+
# Runs before Devise's own initializer so the strategy is available
|
|
62
|
+
# when the model calls `devise :omniauthable`.
|
|
63
|
+
initializer 'activeadmin_oidc.register_omniauth_strategy', before: 'devise.omniauth' do
|
|
64
|
+
cfg = ActiveAdmin::Oidc.config
|
|
65
|
+
next if cfg.issuer.blank? || cfg.client_id.blank?
|
|
66
|
+
|
|
67
|
+
require 'omniauth_openid_connect'
|
|
68
|
+
|
|
69
|
+
::Devise.setup do |devise|
|
|
70
|
+
# ActiveAdmin mounts Devise under /admin, so OmniAuth middleware
|
|
71
|
+
# must intercept /admin/auth/:provider.
|
|
72
|
+
devise.omniauth_path_prefix ||= '/admin/auth'
|
|
73
|
+
|
|
74
|
+
devise.omniauth :openid_connect,
|
|
75
|
+
name: PROVIDER_NAME,
|
|
76
|
+
scope: (cfg.scope || 'openid email profile').split,
|
|
77
|
+
response_type: :code,
|
|
78
|
+
issuer: cfg.issuer,
|
|
79
|
+
discovery: true,
|
|
80
|
+
pkce: cfg.pkce,
|
|
81
|
+
client_options: {
|
|
82
|
+
identifier: cfg.client_id,
|
|
83
|
+
secret: cfg.client_secret.presence,
|
|
84
|
+
redirect_uri: cfg.redirect_uri,
|
|
85
|
+
port: nil,
|
|
86
|
+
scheme: nil,
|
|
87
|
+
host: nil
|
|
88
|
+
}.compact
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Devise propagates omniauth_path_prefix to
|
|
92
|
+
# OmniAuth.config.path_prefix during route generation
|
|
93
|
+
# (set_omniauth_path_prefix!). On Rails 8 routes load lazily,
|
|
94
|
+
# so the OmniAuth middleware may process requests before routes
|
|
95
|
+
# are drawn and miss the prefix. Set it eagerly here.
|
|
96
|
+
# Must happen AFTER `devise.omniauth` because that call
|
|
97
|
+
# triggers autoload of devise/omniauth which nils the value.
|
|
98
|
+
::OmniAuth.config.path_prefix = ::Devise.omniauth_path_prefix
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
initializer 'activeadmin_oidc.filter_parameters' do |app|
|
|
102
|
+
app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'omniauth'
|
|
4
|
+
|
|
5
|
+
module ActiveAdmin
|
|
6
|
+
module Oidc
|
|
7
|
+
# Test helpers for host apps. Include in your RSpec config:
|
|
8
|
+
#
|
|
9
|
+
# require "activeadmin/oidc/test_helpers"
|
|
10
|
+
#
|
|
11
|
+
# RSpec.configure do |config|
|
|
12
|
+
# config.include ActiveAdmin::Oidc::TestHelpers, oidc_mode: true
|
|
13
|
+
# config.after(:each, :oidc_mode) { reset_oidc_stubs }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Then in specs tagged `oidc_mode: true`:
|
|
17
|
+
#
|
|
18
|
+
# before { stub_oidc_sign_in(sub: "alice-sub", claims: { "email" => "a@b" }) }
|
|
19
|
+
#
|
|
20
|
+
module TestHelpers
|
|
21
|
+
DEFAULT_CLAIMS = {
|
|
22
|
+
'preferred_username' => 'alice',
|
|
23
|
+
'email' => 'alice@test',
|
|
24
|
+
'roles' => ['admin']
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Stubs OmniAuth to return a successful OIDC auth hash.
|
|
28
|
+
# Merges the given claims with DEFAULT_CLAIMS.
|
|
29
|
+
def stub_oidc_sign_in(sub: 'alice-sub', claims: {})
|
|
30
|
+
merged = DEFAULT_CLAIMS.merge(claims.transform_keys(&:to_s))
|
|
31
|
+
OmniAuth.config.test_mode = true
|
|
32
|
+
# OmniAuth 2.x still runs request_validation_phase in test mode
|
|
33
|
+
# (mock_request_call, line 325 of strategy.rb). Disable it so
|
|
34
|
+
# the CSRF check from omniauth-rails_csrf_protection doesn't
|
|
35
|
+
# reject the mocked request.
|
|
36
|
+
@_oidc_saved_request_validation_phase = OmniAuth.config.request_validation_phase
|
|
37
|
+
OmniAuth.config.request_validation_phase = nil
|
|
38
|
+
OmniAuth.config.mock_auth[:oidc] = OmniAuth::AuthHash.new(
|
|
39
|
+
provider: 'oidc',
|
|
40
|
+
uid: sub,
|
|
41
|
+
info: {
|
|
42
|
+
email: merged['email'],
|
|
43
|
+
name: merged['name'],
|
|
44
|
+
nickname: merged['preferred_username']
|
|
45
|
+
},
|
|
46
|
+
credentials: {},
|
|
47
|
+
extra: { raw_info: merged.merge('sub' => sub) }
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stubs OmniAuth to simulate a strategy failure.
|
|
52
|
+
def stub_oidc_failure(message_key = :invalid_credentials)
|
|
53
|
+
OmniAuth.config.test_mode = true
|
|
54
|
+
@_oidc_saved_request_validation_phase = OmniAuth.config.request_validation_phase
|
|
55
|
+
OmniAuth.config.request_validation_phase = nil
|
|
56
|
+
OmniAuth.config.mock_auth[:oidc] = message_key
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resets OmniAuth test mode. Call in an `after` hook.
|
|
60
|
+
def reset_oidc_stubs
|
|
61
|
+
OmniAuth.config.mock_auth[:oidc] = nil
|
|
62
|
+
OmniAuth.config.test_mode = false
|
|
63
|
+
OmniAuth.config.request_validation_phase = @_oidc_saved_request_validation_phase if defined?(@_oidc_saved_request_validation_phase)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# RSpec support for oidc_mode tag filtering.
|
|
68
|
+
# Require this file in spec_helper or rails_helper to auto-configure:
|
|
69
|
+
#
|
|
70
|
+
# require "activeadmin/oidc/test_helpers"
|
|
71
|
+
#
|
|
72
|
+
# Specs tagged `oidc_mode: true` will be skipped unless the AdminUser
|
|
73
|
+
# model has :omniauthable loaded. Set CI_RUN_OIDC=true in your CI job
|
|
74
|
+
# to run only OIDC-tagged specs.
|
|
75
|
+
module RSpecSupport
|
|
76
|
+
def self.install!
|
|
77
|
+
return unless defined?(RSpec)
|
|
78
|
+
|
|
79
|
+
RSpec.configure do |config|
|
|
80
|
+
config.include TestHelpers, oidc_mode: true
|
|
81
|
+
config.after(:each, :oidc_mode) { reset_oidc_stubs }
|
|
82
|
+
|
|
83
|
+
config.before(:each, :oidc_mode) do
|
|
84
|
+
admin_class = ActiveAdmin::Oidc.config.admin_user_class
|
|
85
|
+
klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class
|
|
86
|
+
unless klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable)
|
|
87
|
+
skip 'requires OIDC mode (run with config/oidc.yml in place and CI_RUN_OIDC=true)'
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if ENV['CI_RUN_OIDC'].present?
|
|
92
|
+
config.filter_run_including oidc_mode: true
|
|
93
|
+
else
|
|
94
|
+
config.filter_run_excluding oidc_mode: true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Auto-install RSpec support when required during a test run.
|
|
103
|
+
ActiveAdmin::Oidc::RSpecSupport.install!
|