Skip to content

Commit ecfbc30

Browse files
authored
Code-review followups: restore PR #10 commit + harden inactive_message (#11)
* Code-review followups: generator templates, inactive_message hardening Follow-ups to PRs #9 and #10 surfaced by the code review. - Apply the same OmniAuth.config.path_prefix substitution to both install-generator templates. New installs were still shipping the hardcoded path PR #10 fixed in the live view. - Default the InactiveError key to :inactive when the model's inactive_message returns nil/blank. The empty key used to render an empty flash; now the user always sees a reason. - Stop leaking custom inactive_message symbols. If the host returns a symbol like :locked_by_admin with no devise.failure translation, the raw symbol used to land in a public flash. Fall back to devise.failure.inactive instead. The disabled-user request spec is consolidated via let helpers (auth_hash, disabling_hook, post_callback) and now uses new_admin_user_session_path / OmniAuth.config.path_prefix instead of hardcoded URLs. Adds a regression spec for the retry leg + an inactive winner row, documenting the expected redirect-and-no- warden-session behaviour. * Drop review-process references from disabled-user spec comments
1 parent e1bfa0f commit ecfbc30

5 files changed

Lines changed: 114 additions & 71 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,13 @@ def oidc
4242
set_flash_message(:notice, :success, kind: 'OIDC') if is_navigational_format?
4343
rescue ActiveAdmin::Oidc::InactiveError => e
4444
Rails.logger.warn("[activeadmin-oidc] inactive: #{e.inactive_message_key}")
45+
# Fall back to the standard `inactive` translation rather
46+
# than the raw symbol — custom keys like :locked_by_admin
47+
# would otherwise leak host-internal state into a flash
48+
# visible to unauthenticated visitors.
4549
flash[:alert] = I18n.t(
4650
"devise.failure.#{e.inactive_message_key}",
47-
default: e.inactive_message_key.to_s
51+
default: I18n.t("devise.failure.inactive")
4852
)
4953
redirect_to after_omniauth_failure_path_for(resource_name)
5054
rescue ActiveAdmin::Oidc::ProvisioningError => e

lib/activeadmin-oidc.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ class RetryProvisioning < Error; end
3535
class InactiveError < ProvisioningError
3636
attr_reader :inactive_message_key
3737

38+
# Devise's default inactive_message is :inactive. Hosts can
39+
# override the method and legitimately return nil on some
40+
# branches; fall back to :inactive so the controller never
41+
# ends up with an empty flash.
3842
def initialize(inactive_message_key)
39-
@inactive_message_key = inactive_message_key
40-
super(inactive_message_key.to_s)
43+
@inactive_message_key = inactive_message_key.presence || :inactive
44+
super(@inactive_message_key.to_s)
4145
end
4246
end
4347

lib/generators/active_admin/oidc/install/templates/sessions_new.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div id="login">
22
<h2><%%= active_admin_application.site_title(self) %></h2>
33

4-
<%%= form_tag "/admin/auth/oidc",
4+
<%%= form_tag "#{OmniAuth.config.path_prefix}/oidc",
55
method: :post,
66
class: "activeadmin-oidc-login-form formtastic",
77
data: { turbo: false } do %>

lib/generators/active_admin/oidc/install/templates/sessions_new_v4.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
</h2>
55

66
<%%= button_to ActiveAdmin::Oidc.config.login_button_label,
7-
"/admin/auth/oidc",
7+
"#{OmniAuth.config.path_prefix}/oidc",
88
method: :post,
99
class: "activeadmin-oidc-login-button w-full",
1010
form_class: 'formtastic',

spec/requests/disabled_user_persistence_spec.rb

Lines changed: 101 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,17 @@
22

33
require "rails_helper"
44

5-
# Failing spec for HIGH #2 — disabled user persistence.
5+
# Disabled-user provisioning behaviour.
66
#
7-
# UserProvisioner#save! runs BEFORE OmniauthCallbacksController#oidc
8-
# checks `active_for_authentication?`. If the on_login hook flips a
9-
# user's `enabled` flag (or any other Devise inactivity guard) and
10-
# returns truthy, the gem still persists the record — only then does
11-
# the controller reject the sign-in. Repeated hostile attempts leave
12-
# a growing pile of provisional AdminUser rows.
13-
#
14-
# Acceptance criterion: a user the host hook marks inactive must NOT
15-
# be persisted to the database.
7+
# UserProvisioner must enforce `active_for_authentication?` BEFORE
8+
# `save!`. If the on_login hook flips a user's inactivity flag (e.g.
9+
# `enabled = false`) and returns truthy, the gem must NOT persist
10+
# the record — otherwise repeated hostile attempts grow the table
11+
# with provisional rows that can never sign in.
1612
RSpec.describe "OIDC callback: disabled user not persisted", type: :request do
17-
before do
18-
OmniAuth.config.test_mode = true
19-
OmniAuth.config.mock_auth[:oidc] = nil
20-
21-
AdminUser.delete_all
22-
end
23-
24-
after { OmniAuth.config.mock_auth[:oidc] = nil }
25-
26-
def build_auth_hash(uid:, email:)
13+
let(:uid) { "mallory-sub" }
14+
let(:email) { "mallory@example.com" }
15+
let(:auth_hash) do
2716
OmniAuth::AuthHash.new(
2817
provider: "oidc",
2918
uid: uid,
@@ -32,72 +21,118 @@ def build_auth_hash(uid:, email:)
3221
)
3322
end
3423

35-
it "does NOT persist a row when on_login sets enabled=false and returns truthy" do
36-
ActiveAdmin::Oidc.configure do |c|
37-
c.issuer = "https://idp.example.com"
38-
c.client_id = "client-abc"
39-
c.on_login = lambda do |admin_user, _claims|
40-
admin_user.enabled = false
41-
true # truthy → current code persists the row
42-
end
24+
let(:disabling_hook) do
25+
lambda do |admin_user, _claims|
26+
admin_user.enabled = false
27+
true # truthy → pre-fix code persisted the row
4328
end
29+
end
4430

45-
OmniAuth.config.mock_auth[:oidc] =
46-
build_auth_hash(uid: "mallory-sub", email: "mallory@example.com")
31+
let(:noop_hook) { ->(*) { true } }
4732

48-
expect {
49-
post "/admin/auth/oidc"
50-
follow_redirect! if response.redirect?
51-
}.not_to change(AdminUser, :count),
52-
"disabled-by-hook user was persisted to AdminUser — repeated attempts grow the table"
53-
end
33+
before do
34+
OmniAuth.config.test_mode = true
35+
OmniAuth.config.mock_auth[:oidc] = nil
36+
AdminUser.delete_all
5437

55-
it "still redirects the disabled user to the login page" do
5638
ActiveAdmin::Oidc.configure do |c|
5739
c.issuer = "https://idp.example.com"
5840
c.client_id = "client-abc"
59-
c.on_login = lambda do |admin_user, _claims|
60-
admin_user.enabled = false
61-
true
62-
end
41+
c.on_login = disabling_hook
6342
end
6443

65-
OmniAuth.config.mock_auth[:oidc] =
66-
build_auth_hash(uid: "mallory-sub", email: "mallory@example.com")
44+
OmniAuth.config.mock_auth[:oidc] = auth_hash
45+
end
46+
47+
after { OmniAuth.config.mock_auth[:oidc] = nil }
6748

68-
post "/admin/auth/oidc"
49+
def post_callback
50+
post "#{OmniAuth.config.path_prefix}/oidc"
6951
follow_redirect! if response.redirect?
52+
end
7053

71-
expect(response).to redirect_to("/admin/login")
54+
it "does NOT persist a row when on_login sets enabled=false and returns truthy" do
55+
expect { post_callback }.not_to change(AdminUser, :count),
56+
"disabled-by-hook user was persisted to AdminUser — repeated attempts grow the table"
7257
end
7358

74-
# The HIGH #2 fix raised ProvisioningError when the hook flipped
75-
# the inactivity flag, but the controller's generic rescue replaced
76-
# the model's I18n-translated inactive_message with the generic
77-
# access_denied_message. The disabled user lost the specific reason
78-
# ("Your account has not been activated yet") and saw the catch-all
79-
# denial flash instead. Surface the original reason via a dedicated
80-
# error class.
81-
it "shows the model's I18n-translated inactive_message in the flash" do
82-
ActiveAdmin::Oidc.configure do |c|
83-
c.issuer = "https://idp.example.com"
84-
c.client_id = "client-abc"
85-
c.on_login = lambda do |admin_user, _claims|
86-
admin_user.enabled = false
87-
true
59+
it "still redirects the disabled user to the login page" do
60+
post_callback
61+
expect(response).to redirect_to(new_admin_user_session_path)
62+
end
63+
64+
# The retry short-circuit (`return admin_user if @retried`) skips
65+
# the `active_for_authentication?` guard in the provisioner. If a
66+
# host-side trigger flips the winner's row to inactive between the
67+
# concurrent insert and our retry read, the loser thread would
68+
# sign in silently — except Devise's after_set_user callback also
69+
# checks active_for_authentication? and intercepts. This spec
70+
# pins that safety net so a future refactor can't quietly remove
71+
# it.
72+
it "rejects an inactive winner row on the retry leg" do
73+
ActiveAdmin::Oidc.config.on_login = noop_hook
74+
75+
# First save! simulates a lost race: the "other thread" inserts
76+
# the row as ACTIVE, then a host-side trigger flips it inactive
77+
# before our retry read. RecordNotUnique sends us through the
78+
# retry path, where find_by(provider, uid) returns the now-
79+
# inactive row.
80+
raise_once = true
81+
allow_any_instance_of(AdminUser).to receive(:save!).and_wrap_original do |original, *args|
82+
if raise_once
83+
raise_once = false
84+
winner = AdminUser.create!(provider: "oidc", uid: uid, email: email)
85+
winner.update_column(:enabled, false)
86+
raise ActiveRecord::RecordNotUnique, "duplicate (provider, uid)"
87+
else
88+
original.call(*args)
8889
end
8990
end
9091

91-
OmniAuth.config.mock_auth[:oidc] =
92-
build_auth_hash(uid: "mallory-sub", email: "mallory@example.com")
93-
94-
expected = I18n.t("devise.failure.inactive")
92+
post_callback
9593

96-
post "/admin/auth/oidc"
97-
follow_redirect! if response.redirect? # OmniAuth → /callback → our controller
94+
expect(response).to redirect_to(new_admin_user_session_path)
95+
# Tighter than the redirect check: confirm the inactive winner
96+
# did NOT end up in the Warden session. If they did, subsequent
97+
# protected pages would honor the session until the next
98+
# active_for_authentication? check, and the user would briefly
99+
# appear signed-in.
100+
expect(session.to_h.keys.grep(/warden/i)).to be_empty,
101+
"retry leg signed in an inactive winner row — active_for_authentication? was skipped"
102+
end
98103

99-
expect(flash[:alert]).to eq(expected),
104+
# The flash for a disabled user must carry Devise's translated
105+
# inactive_message ("Your account is not activated yet."), not the
106+
# gem's generic access_denied_message — otherwise users lose the
107+
# specific reason for the rejection.
108+
it "shows the model's I18n-translated inactive_message in the flash" do
109+
post_callback
110+
expect(flash[:alert]).to eq(I18n.t("devise.failure.inactive")),
100111
"expected the disabled user to see Devise's translated inactive " \
101112
"message, but the controller used the generic denial flash"
102113
end
114+
115+
# A host's override may legitimately return nil from
116+
# inactive_message on some branches (Devise itself returns :inactive
117+
# from the base, but a subclass overriding for custom branches can
118+
# return nil). The flash must still carry a reason, not collapse to
119+
# I18n.t("devise.failure.") = "".
120+
it "defaults to :inactive when the model's inactive_message is blank" do
121+
allow_any_instance_of(AdminUser).to receive(:inactive_message).and_return(nil)
122+
post_callback
123+
expect(flash[:alert]).to eq(I18n.t("devise.failure.inactive")),
124+
"blank inactive_message produced an empty flash"
125+
end
126+
127+
# If the model returns a custom symbol with no translation (e.g.
128+
# :locked_by_admin without a devise.failure.locked_by_admin key),
129+
# the raw symbol name must NOT land in the flash visible to
130+
# unauthenticated visitors — it would leak host-internal state.
131+
it "hides custom inactive_message symbols when the translation is missing" do
132+
allow_any_instance_of(AdminUser).to receive(:inactive_message).and_return(:locked_by_admin)
133+
post_callback
134+
expect(flash[:alert]).not_to include("locked_by_admin"),
135+
"raw inactive_message symbol leaked into the public flash"
136+
expect(flash[:alert]).to eq(I18n.t("devise.failure.inactive"))
137+
end
103138
end

0 commit comments

Comments
 (0)