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 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
+ [![CI](https://github.com/activeadmin-plugins/activeadmin-oidc/actions/workflows/ci.yml/badge.svg)](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!