defmodule MvWeb.OidcIntegrationTest do use MvWeb.ConnCase, async: true # Test OIDC callback scenarios by directly calling the actions # This simulates what happens during real OIDC authentication describe "OIDC sign-in scenarios" do test "existing OIDC user with unchanged email can sign in" do # Create user with OIDC ID user = create_test_user(%{ email: "existing@example.com", oidc_id: "existing_oidc_123" }) # Simulate OIDC callback data user_info = %{ "sub" => "existing_oidc_123", "preferred_username" => "existing@example.com" } # Test sign_in_with_rauthy action directly {:ok, [found_user]} = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert found_user.id == user.id assert to_string(found_user.email) == "existing@example.com" assert found_user.oidc_id == "existing_oidc_123" end test "new OIDC user gets created via register_with_rauthy" do # Simulate OIDC callback for completely new user user_info = %{ "sub" => "brand_new_oidc_456", "preferred_username" => "newuser@example.com" } # Test register_with_rauthy action case Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) do {:ok, new_user} -> assert to_string(new_user.email) == "newuser@example.com" assert new_user.oidc_id == "brand_new_oidc_456" assert is_nil(new_user.hashed_password) {:error, error} -> flunk("Should have created new user: #{inspect(error)}") end end end describe "OIDC sign-in security tests" do @tag :test_proposal test "sign_in_with_rauthy does NOT match user with only email (no oidc_id)" do # SECURITY TEST: Ensure password-only users cannot be accessed via OIDC # Create a password-only user (no oidc_id) _password_user = create_test_user(%{ email: "password.only@example.com", password: "securepassword123", oidc_id: nil }) # Try to sign in with OIDC using the same email user_info = %{ "sub" => "attacker_oidc_456", "preferred_username" => "password.only@example.com" } # Should NOT find any user (security requirement) result = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Either returns empty list OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok other -> flunk("Expected no user match, got: #{inspect(other)}") end end @tag :test_proposal test "sign_in_with_rauthy only matches when oidc_id matches" do # Create user with specific OIDC ID user = create_test_user(%{ email: "oidc.user@example.com", oidc_id: "correct_oidc_789" }) # Try with correct oidc_id correct_user_info = %{ "sub" => "correct_oidc_789", "preferred_username" => "oidc.user@example.com" } {:ok, [found_user]} = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: correct_user_info, oauth_tokens: %{} }) assert found_user.id == user.id # Try with wrong oidc_id but correct email wrong_user_info = %{ "sub" => "wrong_oidc_999", "preferred_username" => "oidc.user@example.com" } result = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: wrong_user_info, oauth_tokens: %{} }) # Either returns empty list OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok other -> flunk("Expected no user match when oidc_id differs, got: #{inspect(other)}") end end @tag :test_proposal test "sign_in_with_rauthy does not match user with empty string oidc_id" do # Edge case: empty string should be treated like nil _user = create_test_user(%{ email: "empty.oidc@example.com", oidc_id: "" }) user_info = %{ "sub" => "new_oidc_111", "preferred_username" => "empty.oidc@example.com" } result = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Either returns empty list OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok other -> flunk("Expected no user match with empty oidc_id, got: #{inspect(other)}") end end end describe "OIDC error and edge case scenarios" do test "OIDC registration with conflicting email and OIDC ID shows hard error" do # Create user with email and OIDC ID _existing_user = create_test_user(%{ email: "conflict@example.com", oidc_id: "oidc_conflict_1" }) # Try to register with same email but different OIDC ID user_info = %{ "sub" => "oidc_conflict_2", "preferred_username" => "conflict@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Should fail with hard error (not PasswordVerificationRequired) # This prevents someone with OIDC provider B from taking over an account # that's already linked to OIDC provider A assert {:error, %Ash.Error.Invalid{errors: errors}} = result # Should contain error about "already linked to a different OIDC account" assert Enum.any?(errors, fn %Ash.Error.Changes.InvalidAttribute{message: msg} -> String.contains?(msg, "already linked to a different OIDC account") _ -> false end) # Should NOT be PasswordVerificationRequired refute Enum.any?(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end test "OIDC registration with missing sub and id should fail" do user_info = %{ "preferred_username" => "nosub@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert {:error, %Ash.Error.Invalid{ errors: [%Ash.Error.Changes.InvalidChanges{vars: [user_info: msg]}] }} = result assert String.contains?(msg, "OIDC user_info must contain a non-empty 'sub' or 'id' field") end test "OIDC registration with missing preferred_username should fail" do user_info = %{ "sub" => "noemail_oidc_123" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert {:error, %Ash.Error.Invalid{errors: errors}} = result assert Enum.any?(errors, fn err -> match?(%Ash.Error.Changes.Required{field: :email}, err) end) end test "OIDC registration with existing OIDC ID and different email updates email" do existing_user = create_test_user(%{ email: "old@example.com", oidc_id: "oidc_update_email" }) user_info = %{ "sub" => "oidc_update_email", "preferred_username" => "new@example.com" } {:ok, user} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert user.id == existing_user.id assert to_string(user.email) == "new@example.com" assert user.oidc_id == "oidc_update_email" end test "OIDC registration with alternative OIDC ID field (id instead of sub)" do user_info = %{ "id" => "alt_oidc_id_123", "preferred_username" => "altid@example.com" } {:ok, user} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert user.oidc_id == "alt_oidc_id_123" assert to_string(user.email) == "altid@example.com" end end end