diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index 07cb1d41f8..ee9eefd458 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -107,6 +107,10 @@ jobs:
       PAM_ENABLED: true
       PAM_DEFAULT_SERVICE: pam_test
       PAM_CONTROLLED_SERVICE: pam_test_controlled
+      OIDC_ENABLED: true
+      OIDC_SCOPE: read
+      SAML_ENABLED: true
+      CAS_ENABLED: true
       BUNDLE_WITH: 'pam_authentication test'
       CI_JOBS: ${{ matrix.ci_job }}/4
 
diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb
index 9e0fb942aa..4723806b92 100644
--- a/app/controllers/auth/omniauth_callbacks_controller.rb
+++ b/app/controllers/auth/omniauth_callbacks_controller.rb
@@ -5,21 +5,13 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
 
   def self.provides_callback_for(provider)
     define_method provider do
+      @provider = provider
       @user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
 
       if @user.persisted?
-        LoginActivity.create(
-          user: @user,
-          success: true,
-          authentication_method: :omniauth,
-          provider: provider,
-          ip: request.remote_ip,
-          user_agent: request.user_agent
-        )
-
+        record_login_activity
         sign_in_and_redirect @user, event: :authentication
-        label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
-        set_flash_message(:notice, :success, kind: label) if is_navigational_format?
+        set_flash_message(:notice, :success, kind: label_for_provider) if is_navigational_format?
       else
         session["devise.#{provider}_data"] = request.env['omniauth.auth']
         redirect_to new_user_registration_url
@@ -38,4 +30,29 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
       auth_setup_path(missing_email: '1')
     end
   end
+
+  private
+
+  def record_login_activity
+    LoginActivity.create(
+      user: @user,
+      success: true,
+      authentication_method: :omniauth,
+      provider: @provider,
+      ip: request.remote_ip,
+      user_agent: request.user_agent
+    )
+  end
+
+  def label_for_provider
+    provider_display_name || configured_provider_name
+  end
+
+  def provider_display_name
+    Devise.omniauth_configs[@provider]&.strategy&.display_name.presence
+  end
+
+  def configured_provider_name
+    I18n.t("auth.providers.#{@provider}", default: @provider.to_s.chomp('_oauth2').capitalize)
+  end
 end
diff --git a/spec/requests/omniauth_callbacks_spec.rb b/spec/requests/omniauth_callbacks_spec.rb
new file mode 100644
index 0000000000..27aa5ec506
--- /dev/null
+++ b/spec/requests/omniauth_callbacks_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'OmniAuth callbacks' do
+  shared_examples 'omniauth provider callbacks' do |provider|
+    subject { post send "user_#{provider}_omniauth_callback_path" }
+
+    context 'with full information in response' do
+      before do
+        mock_omniauth(provider, {
+          provider: provider.to_s,
+          uid: '123',
+          info: {
+            verified: 'true',
+            email: 'user@host.example',
+          },
+        })
+      end
+
+      context 'without a matching user' do
+        it 'creates a user and an identity and redirects to root path' do
+          expect { subject }
+            .to change(User, :count)
+            .by(1)
+            .and change(Identity, :count)
+            .by(1)
+            .and change(LoginActivity, :count)
+            .by(1)
+
+          expect(User.last.email).to eq('user@host.example')
+          expect(Identity.find_by(user: User.last).uid).to eq('123')
+          expect(response).to redirect_to(root_path)
+        end
+      end
+
+      context 'with a matching user and no matching identity' do
+        before do
+          Fabricate(:user, email: 'user@host.example')
+        end
+
+        it 'matches the existing user, creates an identity, and redirects to root path' do
+          expect { subject }
+            .to not_change(User, :count)
+            .and change(Identity, :count)
+            .by(1)
+            .and change(LoginActivity, :count)
+            .by(1)
+
+          expect(Identity.find_by(user: User.last).uid).to eq('123')
+          expect(response).to redirect_to(root_path)
+        end
+      end
+
+      context 'with a matching user and a matching identity' do
+        before do
+          user = Fabricate(:user, email: 'user@host.example')
+          Fabricate(:identity, user: user, uid: '123', provider: provider)
+        end
+
+        it 'matches the existing records and redirects to root path' do
+          expect { subject }
+            .to not_change(User, :count)
+            .and not_change(Identity, :count)
+            .and change(LoginActivity, :count)
+            .by(1)
+
+          expect(response).to redirect_to(root_path)
+        end
+      end
+    end
+
+    context 'with a response missing email address' do
+      before do
+        mock_omniauth(provider, {
+          provider: provider.to_s,
+          uid: '123',
+          info: {
+            verified: 'true',
+          },
+        })
+      end
+
+      it 'redirects to the auth setup page' do
+        expect { subject }
+          .to change(User, :count)
+          .by(1)
+          .and change(Identity, :count)
+          .by(1)
+          .and change(LoginActivity, :count)
+          .by(1)
+
+        expect(response).to redirect_to(auth_setup_path(missing_email: '1'))
+      end
+    end
+
+    context 'when a user cannot be built' do
+      before do
+        allow(User).to receive(:find_for_oauth).and_return(User.new)
+      end
+
+      it 'redirects to the new user signup page' do
+        expect { subject }
+          .to not_change(User, :count)
+          .and not_change(Identity, :count)
+          .and not_change(LoginActivity, :count)
+
+        expect(response).to redirect_to(new_user_registration_url)
+      end
+    end
+  end
+
+  describe '#openid_connect', if: ENV['OIDC_ENABLED'] == 'true' && ENV['OIDC_SCOPE'].present? do
+    include_examples 'omniauth provider callbacks', :openid_connect
+  end
+
+  describe '#cas', if: ENV['CAS_ENABLED'] == 'true' do
+    include_examples 'omniauth provider callbacks', :cas
+  end
+
+  describe '#saml', if: ENV['SAML_ENABLED'] == 'true' do
+    include_examples 'omniauth provider callbacks', :saml
+  end
+end
diff --git a/spec/support/omniauth_mocks.rb b/spec/support/omniauth_mocks.rb
new file mode 100644
index 0000000000..9883adec7a
--- /dev/null
+++ b/spec/support/omniauth_mocks.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+OmniAuth.config.test_mode = true
+
+def mock_omniauth(provider, data)
+  OmniAuth.config.mock_auth[provider] = OmniAuth::AuthHash.new(data)
+end