Skip to content

Commit a87c359

Browse files
committed
Catch host on_login hook exceptions and render generic denial
The on_login hook is host-app code. Before this change, if the host's hook raised any non-ProvisioningError StandardError — a typo, a NoMethodError on the claims hash, a failed external API call inside the hook — the callback action crashed with a 500 instead of rendering the existing access-denied flash. UserProvisioner now wraps the hook invocation: gem-internal ActiveAdmin::Oidc::Error subclasses still propagate untouched (so a host can explicitly raise ProvisioningError with a custom message), but any other StandardError is logged at :error level with the original exception class and message, then re-raised as a generic ProvisioningError. The controller already handles that — the user sees the same clean denial flash as the 'hook returned false' path, and ops gets the stack-level detail in the log. Also introduces ActiveAdmin::Oidc.logger: a module-level logger accessor that defaults to Rails.logger when Rails is booted and falls back to a null logger otherwise. UserProvisioner uses it instead of touching Rails.logger directly, which keeps the class safe to call in unit-spec contexts that don't boot Rails and gives tests a clean injection point (allow(ActiveAdmin::Oidc).to receive(:logger)…). Flips the existing 'propagates unrelated exceptions from the hook' spec, which had calcified the old buggy behavior, and adds two new examples covering the log line and the pass-through for gem-internal errors.
1 parent db3fc78 commit a87c359

3 files changed

Lines changed: 64 additions & 3 deletions

File tree

lib/activeadmin-oidc.rb

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

3+
require "logger"
34
require "activeadmin/oidc/version"
45

56
# `omniauth-rails_csrf_protection` registers a Railtie that replaces
@@ -34,8 +35,30 @@ def configure
3435
config
3536
end
3637

38+
# Logger the gem uses for internal diagnostics (on_login hook
39+
# failures, omniauth failures, etc). Defaults to Rails.logger when
40+
# Rails is booted, falls back to a null logger otherwise so that
41+
# library code is safe to call in non-Rails contexts (unit specs,
42+
# scripts). Override by assigning directly — useful in tests.
43+
def logger
44+
@logger || default_logger
45+
end
46+
47+
attr_writer :logger
48+
3749
def reset!
3850
@config = Configuration.new
51+
@logger = nil
52+
end
53+
54+
private
55+
56+
def default_logger
57+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
58+
::Rails.logger
59+
else
60+
@null_logger ||= ::Logger.new(IO::NULL)
61+
end
3962
end
4063
end
4164
end

lib/activeadmin/oidc/user_provisioner.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def call
3737
admin_user = find_or_adopt_or_build
3838
assign_base_attributes(admin_user)
3939

40-
allowed = @config.on_login.call(admin_user, @claims)
40+
allowed = invoke_on_login(admin_user)
4141
raise ProvisioningError, denial_message unless allowed
4242

4343
save!(admin_user)
@@ -107,6 +107,25 @@ def save!(admin_user)
107107
raise ProvisioningError, e.message
108108
end
109109

110+
# The on_login hook is host-app code. If it raises a non-gem
111+
# exception, we do NOT want the callback action to blow up with
112+
# a 500 — the cleanest UX is the same generic denial flash the
113+
# "hook returned false" path produces. We still log the original
114+
# exception class + message at error level so ops can debug.
115+
# Gem-internal exceptions (ActiveAdmin::Oidc::Error subclasses)
116+
# are re-raised untouched so nested provisioning errors surface
117+
# with their original messages.
118+
def invoke_on_login(admin_user)
119+
@config.on_login.call(admin_user, @claims)
120+
rescue ActiveAdmin::Oidc::Error
121+
raise
122+
rescue StandardError => e
123+
ActiveAdmin::Oidc.logger.error(
124+
"[activeadmin-oidc] on_login hook raised #{e.class}: #{e.message}"
125+
)
126+
raise ProvisioningError, denial_message
127+
end
128+
110129
def denial_message
111130
@config.access_denied_message
112131
end

spec/unit/user_provisioner_spec.rb

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,28 @@
141141
expect(existing.reload.email).to eq("old@example.com")
142142
end
143143

144-
it "propagates unrelated exceptions from the hook" do
144+
it "converts unrelated exceptions from the hook into a ProvisioningError (generic denial)" do
145+
# The on_login hook is host-app code. If it raises anything, the
146+
# ideal UX is a clean denial flash (identical to the "false" case),
147+
# not a 500 crash on the callback action. The original exception
148+
# is logged for ops visibility; see next example.
145149
config.on_login = ->(_a, _c) { raise "boom" }
146-
expect { provisioner.call }.to raise_error(RuntimeError, "boom")
150+
expect { provisioner.call }.to raise_error(ActiveAdmin::Oidc::ProvisioningError)
151+
end
152+
153+
it "logs the original exception class and message when the hook raises" do
154+
config.on_login = ->(_a, _c) { raise ArgumentError, "bad claim shape" }
155+
fake_logger = instance_double(Logger, error: nil)
156+
allow(ActiveAdmin::Oidc).to receive(:logger).and_return(fake_logger)
157+
provisioner.call rescue nil
158+
expect(fake_logger).to have_received(:error).with(/on_login.*ArgumentError.*bad claim shape/)
159+
end
160+
161+
it "does not swallow ActiveAdmin::Oidc::Error subclasses raised by the hook" do
162+
# If host code explicitly raises a gem-internal error (e.g. from
163+
# a nested provisioner), don't double-wrap it — let it surface.
164+
config.on_login = ->(_a, _c) { raise ActiveAdmin::Oidc::ProvisioningError, "nested denial" }
165+
expect { provisioner.call }.to raise_error(ActiveAdmin::Oidc::ProvisioningError, "nested denial")
147166
end
148167
end
149168

0 commit comments

Comments
 (0)