294 lines
8.6 KiB
Elixir
294 lines
8.6 KiB
Elixir
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
|