Skip to content

Commit 3e90441

Browse files
committed
auto-register OmniAuth strategy, active_for_authentication guard, test helpers
- Engine auto-registers config.omniauth :openid_connect from gem config, eliminating ~20 lines of boilerplate from host app's devise.rb - Engine auto-sets omniauth_path_prefix to /admin/auth - Callback controller checks active_for_authentication? after provisioning, so disabled/locked users are rejected on initial OmniAuth sign-in (Devise only checks on session deserialization, not initial sign-in) - Engine only prepends gem views if host app has no own login view override - Add redirect_uri to Configuration for custom callback URLs - Ship ActiveAdmin::Oidc::TestHelpers and RSpecSupport for host apps (stub_oidc_sign_in, stub_oidc_failure, oidc_mode tag filtering)
1 parent d90c44e commit 3e90441

File tree

7 files changed

+197
-29
lines changed

7 files changed

+197
-29
lines changed

app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
require "devise"
3+
require 'devise'
44

55
module ActiveAdmin
66
module Oidc
@@ -18,28 +18,38 @@ module Devise
1818
# (`:oidc`, from ActiveAdmin::Oidc::Engine::PROVIDER_NAME).
1919
class OmniauthCallbacksController < ::Devise::OmniauthCallbacksController
2020
def oidc
21-
auth = request.env["omniauth.auth"] || {}
22-
info = auth["info"] || {}
21+
auth = request.env['omniauth.auth'] || {}
22+
info = auth['info'] || {}
2323
# Defensive: an OIDC strategy is supposed to put a Hash at
2424
# extra.raw_info, but a misbehaving/custom strategy could
2525
# set something else (String, nil, Array). We only trust a
2626
# Hash-shaped value; anything else collapses to {} and we
2727
# rebuild `sub`/`email` from the top-level auth hash below.
28-
extra = auth.dig("extra", "raw_info")
28+
extra = auth.dig('extra', 'raw_info')
2929
extra = {} unless extra.is_a?(Hash)
3030

3131
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?
32+
claims['sub'] = auth['uid'] if claims['sub'].blank? && auth['uid'].present?
33+
claims['email'] = info['email'] if claims['email'].blank? && info['email'].present?
3434

3535
admin_user = UserProvisioner.new(
3636
ActiveAdmin::Oidc.config,
37-
claims: claims,
37+
claims: claims,
3838
provider: ActiveAdmin::Oidc::Engine::PROVIDER_NAME.to_s
3939
).call
4040

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+
4151
sign_in_and_redirect admin_user, event: :authentication
42-
set_flash_message(:notice, :success, kind: "OIDC") if is_navigational_format?
52+
set_flash_message(:notice, :success, kind: 'OIDC') if is_navigational_format?
4353
rescue ActiveAdmin::Oidc::ProvisioningError => e
4454
Rails.logger.warn("[activeadmin-oidc] denial: #{e.message}")
4555
flash[:alert] = ActiveAdmin::Oidc.config.access_denied_message
@@ -61,7 +71,7 @@ def failure
6171
# it's rarely what an admin user wants to see. ActiveAdmin
6272
# always mounts at `/admin`, so we go there directly.
6373
def after_sign_in_path_for(resource)
64-
stored_location_for(resource) || "/admin"
74+
stored_location_for(resource) || '/admin'
6575
end
6676
end
6777
end

lib/activeadmin/oidc/configuration.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
module ActiveAdmin
44
module Oidc
55
class Configuration
6-
DEFAULT_SCOPE = "openid email profile"
6+
DEFAULT_SCOPE = 'openid email profile'
77
DEFAULT_TIMEOUT = 5
88
DEFAULT_IDENTITY_ATTRIBUTE = :email
99
DEFAULT_IDENTITY_CLAIM = :email
10-
DEFAULT_LOGIN_BUTTON_LABEL = "Sign in with SSO"
11-
DEFAULT_ADMIN_USER_CLASS = "AdminUser"
10+
DEFAULT_LOGIN_BUTTON_LABEL = 'Sign in with SSO'
11+
DEFAULT_ADMIN_USER_CLASS = 'AdminUser'
1212
DEFAULT_ACCESS_DENIED_MESSAGE =
13-
"Your account has no permission to access this admin panel."
13+
'Your account has no permission to access this admin panel.'
1414

1515
attr_accessor :issuer, :client_id, :client_secret, :scope,
16+
:redirect_uri,
1617
:login_button_label, :timeout,
1718
:identity_attribute, :identity_claim,
1819
:access_denied_message, :on_login, :admin_user_class
@@ -26,6 +27,7 @@ def reset!
2627
@client_id = nil
2728
@client_secret = nil
2829
@scope = DEFAULT_SCOPE
30+
@redirect_uri = nil
2931
@login_button_label = DEFAULT_LOGIN_BUTTON_LABEL
3032
@timeout = DEFAULT_TIMEOUT
3133
@identity_attribute = DEFAULT_IDENTITY_ATTRIBUTE
@@ -48,12 +50,10 @@ def pkce=(value)
4850
end
4951

5052
def validate!
51-
raise ConfigurationError, "issuer is required" if issuer.blank?
52-
raise ConfigurationError, "client_id is required" if client_id.blank?
53-
raise ConfigurationError, "on_login is required" if on_login.nil?
54-
unless on_login.respond_to?(:call)
55-
raise ConfigurationError, "on_login must be callable (respond to #call)"
56-
end
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)
5757

5858
true
5959
end

lib/activeadmin/oidc/engine.rb

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
require "rails/engine"
3+
require 'rails/engine'
44

55
module ActiveAdmin
66
module Oidc
@@ -13,41 +13,83 @@ class Engine < ::Rails::Engine
1313
def self.oidc_enabled?
1414
admin_class = ActiveAdmin::Oidc.config.admin_user_class
1515
klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class
16-
klass&.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable)
16+
klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable)
1717
end
1818

1919
ControllersPatch = Module.new do
2020
def controllers
2121
result = super
2222
if Engine.oidc_enabled?
2323
result = result.merge(
24-
omniauth_callbacks: "active_admin/oidc/devise/omniauth_callbacks"
24+
omniauth_callbacks: 'active_admin/oidc/devise/omniauth_callbacks'
2525
)
2626
end
2727
result
2828
end
2929
end
3030

31-
initializer "activeadmin_oidc.register_controllers" do |app|
31+
initializer 'activeadmin_oidc.register_controllers' do |app|
3232
app.config.to_prepare do
33-
require "active_admin/devise"
33+
require 'active_admin/devise'
3434
unless ::ActiveAdmin::Devise.singleton_class < ControllersPatch
3535
::ActiveAdmin::Devise.singleton_class.prepend(ControllersPatch)
3636
end
3737
end
3838
end
3939

40-
initializer "activeadmin_oidc.prepend_view_paths" do |app|
40+
initializer 'activeadmin_oidc.prepend_view_paths' do |app|
4141
app.config.to_prepare do
4242
if Engine.oidc_enabled?
43-
require "active_admin/devise"
44-
view_path = File.expand_path("../../../app/views", __dir__)
45-
::ActiveAdmin::Devise::SessionsController.prepend_view_path(view_path)
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
4654
end
4755
end
4856
end
4957

50-
initializer "activeadmin_oidc.filter_parameters" do |app|
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+
end
91+
92+
initializer 'activeadmin_oidc.filter_parameters' do |app|
5193
app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce]
5294
end
5395
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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.config.mock_auth[:oidc] = OmniAuth::AuthHash.new(
33+
provider: 'oidc',
34+
uid: sub,
35+
info: {
36+
email: merged['email'],
37+
name: merged['name'],
38+
nickname: merged['preferred_username']
39+
},
40+
credentials: {},
41+
extra: { raw_info: merged.merge('sub' => sub) }
42+
)
43+
end
44+
45+
# Stubs OmniAuth to simulate a strategy failure.
46+
def stub_oidc_failure(message_key = :invalid_credentials)
47+
OmniAuth.config.test_mode = true
48+
OmniAuth.config.mock_auth[:oidc] = message_key
49+
end
50+
51+
# Resets OmniAuth test mode. Call in an `after` hook.
52+
def reset_oidc_stubs
53+
OmniAuth.config.mock_auth[:oidc] = nil
54+
OmniAuth.config.test_mode = false
55+
end
56+
end
57+
58+
# RSpec support for oidc_mode tag filtering.
59+
# Require this file in spec_helper or rails_helper to auto-configure:
60+
#
61+
# require "activeadmin/oidc/test_helpers"
62+
#
63+
# Specs tagged `oidc_mode: true` will be skipped unless the AdminUser
64+
# model has :omniauthable loaded. Set CI_RUN_OIDC=true in your CI job
65+
# to run only OIDC-tagged specs.
66+
module RSpecSupport
67+
def self.install!
68+
return unless defined?(RSpec)
69+
70+
RSpec.configure do |config|
71+
config.include TestHelpers, oidc_mode: true
72+
config.after(:each, :oidc_mode) { reset_oidc_stubs }
73+
74+
config.before(:each, :oidc_mode) do
75+
admin_class = ActiveAdmin::Oidc.config.admin_user_class
76+
klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class
77+
unless klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable)
78+
skip 'requires OIDC mode (run with config/oidc.yml in place and CI_RUN_OIDC=true)'
79+
end
80+
end
81+
82+
if ENV['CI_RUN_OIDC'].present?
83+
config.filter_run_including oidc_mode: true
84+
else
85+
config.filter_run_excluding oidc_mode: true
86+
end
87+
end
88+
end
89+
end
90+
end
91+
end
92+
93+
# Auto-install RSpec support when required during a test run.
94+
ActiveAdmin::Oidc::RSpecSupport.install!

spec/dummy/app/models/admin_user.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ class AdminUser < ApplicationRecord
77
validates :email, presence: true
88

99
serialize :oidc_raw_info, coder: JSON
10+
11+
def active_for_authentication?
12+
super && enabled?
13+
end
1014
end

spec/dummy/db/schema.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
t.string :uid
1010
t.text :oidc_raw_info
1111
t.string :department
12+
t.boolean :enabled, default: true
1213
t.timestamps
1314
end
1415

spec/requests/omniauth_callback_spec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,23 @@ def trigger_callback
149149
end
150150
end
151151

152+
context "disabled user (active_for_authentication? returns false)" do
153+
it "does not sign in the user and redirects to login" do
154+
AdminUser.create!(
155+
email: "disabled@example.com",
156+
provider: "oidc",
157+
uid: "sub-disabled",
158+
enabled: false
159+
)
160+
161+
OmniAuth.config.mock_auth[:oidc] =
162+
build_auth_hash(uid: "sub-disabled", email: "disabled@example.com")
163+
164+
trigger_callback
165+
expect(response).to redirect_to("/admin/login")
166+
end
167+
end
168+
152169
context "OmniAuth strategy failure (e.g. invalid_credentials)" do
153170
before do
154171
OmniAuth.config.mock_auth[:oidc] = :invalid_credentials

0 commit comments

Comments
 (0)