Skip to content

Commit 02788a9

Browse files
committed
TDD step 7: OIDC callback request specs
Drives the full OmniAuth handshake through the dummy Rails app to cover the five real-world callback outcomes: new user, existing provider/uid match, legacy identity-attribute migration, hook denial, and strategy failure. Adds `Devise.omniauth_path_prefix = "/admin/auth"` and the matching strategy path_prefix so OmniAuth middleware intercepts ActiveAdmin-scoped auth URLs.
1 parent 89788ab commit 02788a9

6 files changed

Lines changed: 148 additions & 3 deletions

File tree

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
// no assets needed for the dummy app
1+
//= link active_admin.css
2+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* stub for specs — ActiveAdmin layout references this file */

spec/dummy/config/application.rb

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ class Application < Rails::Application
3232

3333
config.action_controller.allow_forgery_protection = false
3434
config.session_store :cookie_store, key: "_dummy_session"
35-
config.middleware.use ActionDispatch::Cookies
36-
config.middleware.use config.session_store, config.session_options
3735

3836
config.action_dispatch.show_exceptions = :none
3937
config.consider_all_requests_local = true

spec/dummy/config/initializers/devise.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
config.mailer_sender = "please-change-me@example.com"
55
require "devise/orm/active_record"
66

7+
# ActiveAdmin mounts Devise under /admin, so OmniAuth middleware must
8+
# intercept /admin/auth/:provider instead of the default per-model prefix.
9+
config.omniauth_path_prefix = "/admin/auth"
10+
711
config.case_insensitive_keys = [:email]
812
config.strip_whitespace_keys = [:email]
913
config.skip_session_storage = [:http_auth]
@@ -23,6 +27,7 @@
2327

2428
config.omniauth :openid_connect,
2529
name: :oidc,
30+
path_prefix: "/admin/auth",
2631
issuer: "https://idp.example.com",
2732
discovery: false,
2833
scope: %i[openid email profile],

spec/rails_helper.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,10 @@
2222

2323
OmniAuth.config.test_mode = true
2424
OmniAuth.config.logger = Logger.new(File::NULL)
25+
# Skip OmniAuth 2.x's POST CSRF validation in request specs so we can drive
26+
# the callback flow without generating a real authenticity token.
27+
OmniAuth.config.request_validation_phase = ->(_env) { }
28+
# Let the request phase short-circuit via test mode mock_auth without
29+
# trying to hit an actual IdP for discovery.
30+
OmniAuth.config.allowed_request_methods = %i[get post]
31+
OmniAuth.config.silence_get_warning = true if OmniAuth.config.respond_to?(:silence_get_warning=)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
# BDD-style request spec for the OIDC callback flow. Exercises the real
6+
# Devise + OmniAuth + ActiveAdmin + engine stack via the dummy Rails app.
7+
#
8+
# OmniAuth test mode short-circuits the IdP roundtrip: POST /admin/auth/oidc
9+
# immediately invokes the callback action with the mocked auth hash.
10+
RSpec.describe "OIDC callback", type: :request do
11+
before do
12+
OmniAuth.config.test_mode = true
13+
OmniAuth.config.mock_auth[:oidc] = nil
14+
15+
ActiveAdmin::Oidc.configure do |c|
16+
c.issuer = "https://idp.example.com"
17+
c.client_id = "client-abc"
18+
c.on_login = ->(admin_user, _claims) { true }
19+
end
20+
21+
AdminUser.delete_all
22+
end
23+
24+
after do
25+
OmniAuth.config.mock_auth[:oidc] = nil
26+
end
27+
28+
def build_auth_hash(uid:, email:, extra: {})
29+
OmniAuth::AuthHash.new(
30+
provider: "oidc",
31+
uid: uid,
32+
info: { "email" => email, "name" => "Alice" },
33+
extra: { "raw_info" => { "sub" => uid, "email" => email }.merge(extra) }
34+
)
35+
end
36+
37+
# Drive the full OmniAuth handshake: POST /admin/auth/oidc → 302 to
38+
# /admin/auth/oidc/callback → the gem's callbacks controller runs.
39+
# Leaves `response` pointed at the controller's own redirect (to the
40+
# signed-in landing page or the login page on failure).
41+
def trigger_callback
42+
post "/admin/auth/oidc"
43+
follow_redirect! if response.redirect?
44+
end
45+
46+
context "new user, on_login hook accepts" do
47+
it "creates the AdminUser, signs it in, and stores the raw OIDC info" do
48+
OmniAuth.config.mock_auth[:oidc] =
49+
build_auth_hash(uid: "sub-new", email: "new@example.com")
50+
51+
expect {
52+
trigger_callback
53+
}.to change(AdminUser, :count).by(1)
54+
55+
user = AdminUser.find_by(provider: "oidc", uid: "sub-new")
56+
expect(user).not_to be_nil
57+
expect(user.email).to eq("new@example.com")
58+
expect(user.oidc_raw_info).to include("sub" => "sub-new", "email" => "new@example.com")
59+
60+
# Devise's default is the app root; the dummy app redirects `/` to /admin.
61+
expect(response).to be_redirect
62+
expect(response.location).to match(%r{\Ahttps?://[^/]+/(\z|admin)})
63+
end
64+
end
65+
66+
context "existing user matched by (provider, uid)" do
67+
it "signs in without creating a new record" do
68+
AdminUser.create!(
69+
email: "alice@example.com",
70+
provider: "oidc",
71+
uid: "sub-existing"
72+
)
73+
74+
OmniAuth.config.mock_auth[:oidc] =
75+
build_auth_hash(uid: "sub-existing", email: "alice@example.com")
76+
77+
expect {
78+
trigger_callback
79+
}.not_to change(AdminUser, :count)
80+
81+
expect(response).to be_redirect
82+
end
83+
end
84+
85+
context "existing user matched by identity_attribute (email) with no provider/uid yet" do
86+
it "migrates the record in place and signs it in" do
87+
legacy = AdminUser.create!(email: "legacy@example.com")
88+
89+
OmniAuth.config.mock_auth[:oidc] =
90+
build_auth_hash(uid: "sub-legacy", email: "legacy@example.com")
91+
92+
expect {
93+
trigger_callback
94+
}.not_to change(AdminUser, :count)
95+
96+
legacy.reload
97+
expect(legacy.provider).to eq("oidc")
98+
expect(legacy.uid).to eq("sub-legacy")
99+
expect(response).to be_redirect
100+
end
101+
end
102+
103+
context "on_login hook denies the user" do
104+
before do
105+
ActiveAdmin::Oidc.config.on_login = ->(_admin_user, _claims) { false }
106+
end
107+
108+
it "does not persist the user, redirects to the login page, and sets the access-denied flash" do
109+
OmniAuth.config.mock_auth[:oidc] =
110+
build_auth_hash(uid: "sub-denied", email: "denied@example.com")
111+
112+
expect {
113+
trigger_callback
114+
}.not_to change(AdminUser, :count)
115+
116+
expect(response).to redirect_to("/admin/login")
117+
expect(flash[:alert]).to eq(ActiveAdmin::Oidc.config.access_denied_message)
118+
end
119+
end
120+
121+
context "OmniAuth strategy failure (e.g. invalid_credentials)" do
122+
before do
123+
OmniAuth.config.mock_auth[:oidc] = :invalid_credentials
124+
end
125+
126+
it "lands on the failure action, redirects to login, and shows the access-denied flash" do
127+
trigger_callback
128+
129+
expect(response).to redirect_to("/admin/login")
130+
expect(flash[:alert]).to eq(ActiveAdmin::Oidc.config.access_denied_message)
131+
end
132+
end
133+
end

0 commit comments

Comments
 (0)