496 lines
16 KiB
Elixir
496 lines
16 KiB
Elixir
defmodule MvWeb.OidcPasswordLinkingTest do
|
|
@moduledoc """
|
|
Tests for OIDC account linking when email collision occurs.
|
|
|
|
This test suite verifies the security flow when an OIDC login attempts
|
|
to use an email that already exists in the system with a password account.
|
|
"""
|
|
use MvWeb.ConnCase, async: true
|
|
require Ash.Query
|
|
|
|
describe "OIDC login with existing email (no oidc_id) - Email Collision" do
|
|
@tag :test_proposal
|
|
test "OIDC register with existing password user email fails with PasswordVerificationRequired" do
|
|
# Create password-only user
|
|
existing_user =
|
|
create_test_user(%{
|
|
email: "existing@example.com",
|
|
password: "securepassword123",
|
|
oidc_id: nil
|
|
})
|
|
|
|
# Try OIDC registration with same email
|
|
user_info = %{
|
|
"sub" => "new_oidc_12345",
|
|
"preferred_username" => "existing@example.com"
|
|
}
|
|
|
|
result =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{}
|
|
})
|
|
|
|
# Should fail with PasswordVerificationRequired error
|
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
|
|
|
# Check that the error is our custom PasswordVerificationRequired
|
|
password_verification_error =
|
|
Enum.find(errors, fn err ->
|
|
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
|
end)
|
|
|
|
assert password_verification_error != nil,
|
|
"Should contain PasswordVerificationRequired error"
|
|
|
|
assert password_verification_error.user_id == existing_user.id
|
|
end
|
|
|
|
@tag :test_proposal
|
|
test "PasswordVerificationRequired error contains necessary context" do
|
|
existing_user =
|
|
create_test_user(%{
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
oidc_id: nil
|
|
})
|
|
|
|
user_info = %{
|
|
"sub" => "oidc_99999",
|
|
"preferred_username" => "test@example.com"
|
|
}
|
|
|
|
{:error, %Ash.Error.Invalid{errors: errors}} =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{}
|
|
})
|
|
|
|
password_error =
|
|
Enum.find(errors, fn err ->
|
|
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
|
end)
|
|
|
|
# Verify error contains all necessary context
|
|
assert password_error.user_id == existing_user.id
|
|
assert password_error.oidc_user_info["sub"] == "oidc_99999"
|
|
assert password_error.oidc_user_info["preferred_username"] == "test@example.com"
|
|
end
|
|
|
|
@tag :test_proposal
|
|
test "after successful password verification, oidc_id can be set" do
|
|
# Create password user
|
|
user =
|
|
create_test_user(%{
|
|
email: "link@example.com",
|
|
password: "mypassword123",
|
|
oidc_id: nil
|
|
})
|
|
|
|
# Simulate password verification passed, now link OIDC
|
|
user_info = %{
|
|
"sub" => "linked_oidc_555",
|
|
"preferred_username" => "link@example.com"
|
|
}
|
|
|
|
# Use the link_oidc_id action
|
|
{:ok, updated_user} =
|
|
Mv.Accounts.User
|
|
|> Ash.Query.filter(id == ^user.id)
|
|
|> Ash.read_one!()
|
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|
oidc_id: user_info["sub"],
|
|
oidc_user_info: user_info
|
|
})
|
|
|> Ash.update()
|
|
|
|
assert updated_user.id == user.id
|
|
assert updated_user.oidc_id == "linked_oidc_555"
|
|
assert to_string(updated_user.email) == "link@example.com"
|
|
# Password should still exist
|
|
assert updated_user.hashed_password == user.hashed_password
|
|
end
|
|
|
|
@tag :test_proposal
|
|
test "password verification with wrong password keeps oidc_id as nil" do
|
|
# This test verifies that if password verification fails,
|
|
# the oidc_id should NOT be set
|
|
|
|
user =
|
|
create_test_user(%{
|
|
email: "secure@example.com",
|
|
password: "correctpassword",
|
|
oidc_id: nil
|
|
})
|
|
|
|
# This test verifies the CONCEPT that wrong password should prevent linking
|
|
# In practice, the password verification happens BEFORE calling link_oidc_id
|
|
# So we just verify that the user still has no oidc_id
|
|
|
|
# Attempt to verify with wrong password would fail in the controller/LiveView
|
|
# before link_oidc_id is called, so here we just verify the user state
|
|
|
|
# User should still have no oidc_id (no linking happened)
|
|
{:ok, unchanged_user} = Ash.get(Mv.Accounts.User, user.id)
|
|
assert is_nil(unchanged_user.oidc_id)
|
|
assert unchanged_user.hashed_password == user.hashed_password
|
|
end
|
|
end
|
|
|
|
describe "OIDC login with email of user having different oidc_id - Account Conflict" do
|
|
@tag :test_proposal
|
|
test "OIDC register with email of user having different oidc_id fails" do
|
|
# User already linked to OIDC provider A
|
|
_existing_user =
|
|
create_test_user(%{
|
|
email: "linked@example.com",
|
|
oidc_id: "oidc_provider_a_123"
|
|
})
|
|
|
|
# Someone tries to register with OIDC provider B using same email
|
|
user_info = %{
|
|
# Different OIDC ID!
|
|
"sub" => "oidc_provider_b_456",
|
|
"preferred_username" => "linked@example.com"
|
|
}
|
|
|
|
result =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{}
|
|
})
|
|
|
|
# Should fail - cannot link different OIDC account to same email
|
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
|
|
|
# The error should indicate email is already taken
|
|
assert Enum.any?(errors, fn err ->
|
|
(err.__struct__ == Ash.Error.Changes.InvalidAttribute and err.field == :email) or
|
|
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
|
end)
|
|
end
|
|
|
|
@tag :test_proposal
|
|
test "existing OIDC user email remains unchanged when oidc_id matches" do
|
|
user =
|
|
create_test_user(%{
|
|
email: "oidc@example.com",
|
|
oidc_id: "oidc_stable_789"
|
|
})
|
|
|
|
# Same OIDC ID, same email - should just sign in
|
|
user_info = %{
|
|
"sub" => "oidc_stable_789",
|
|
"preferred_username" => "oidc@example.com"
|
|
}
|
|
|
|
# This should work via upsert
|
|
{:ok, updated_user} =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{}
|
|
})
|
|
|
|
assert updated_user.id == user.id
|
|
assert updated_user.oidc_id == "oidc_stable_789"
|
|
assert to_string(updated_user.email) == "oidc@example.com"
|
|
end
|
|
end
|
|
|
|
describe "Email update during OIDC linking" do
|
|
@tag :test_proposal
|
|
test "linking OIDC to password account updates email if different in OIDC" do
|
|
# Password user with old email
|
|
user =
|
|
create_test_user(%{
|
|
email: "oldemail@example.com",
|
|
password: "password123",
|
|
oidc_id: nil
|
|
})
|
|
|
|
# OIDC provider returns new email (user changed it there)
|
|
user_info = %{
|
|
"sub" => "oidc_link_999",
|
|
"preferred_username" => "newemail@example.com"
|
|
}
|
|
|
|
# After password verification, link and update email
|
|
{:ok, updated_user} =
|
|
Mv.Accounts.User
|
|
|> Ash.Query.filter(id == ^user.id)
|
|
|> Ash.read_one!()
|
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|
oidc_id: user_info["sub"],
|
|
oidc_user_info: user_info
|
|
})
|
|
|> Ash.update()
|
|
|
|
assert updated_user.oidc_id == "oidc_link_999"
|
|
assert to_string(updated_user.email) == "newemail@example.com"
|
|
end
|
|
|
|
@tag :test_proposal
|
|
test "email change during linking triggers member email sync" do
|
|
# Create member
|
|
member =
|
|
Ash.Seed.seed!(Mv.Membership.Member, %{
|
|
email: "member@example.com",
|
|
first_name: "Test",
|
|
last_name: "User"
|
|
})
|
|
|
|
# Create user linked to member
|
|
user =
|
|
Ash.Seed.seed!(Mv.Accounts.User, %{
|
|
email: "member@example.com",
|
|
hashed_password: "dummy_hash",
|
|
oidc_id: nil,
|
|
member_id: member.id
|
|
})
|
|
|
|
# Link OIDC with new email
|
|
user_info = %{
|
|
"sub" => "oidc_sync_777",
|
|
"preferred_username" => "newemail@example.com"
|
|
}
|
|
|
|
{:ok, updated_user} =
|
|
Mv.Accounts.User
|
|
|> Ash.Query.filter(id == ^user.id)
|
|
|> Ash.read_one!()
|
|
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
|
oidc_id: user_info["sub"],
|
|
oidc_user_info: user_info
|
|
})
|
|
|> Ash.update()
|
|
|
|
# Verify user email changed
|
|
assert to_string(updated_user.email) == "newemail@example.com"
|
|
|
|
# Verify member email was synced
|
|
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
|
|
assert to_string(updated_member.email) == "newemail@example.com"
|
|
end
|
|
end
|
|
|
|
describe "Edge cases" do
|
|
@tag :test_proposal
|
|
test "user with empty string oidc_id is treated as password-only user" do
|
|
_user =
|
|
create_test_user(%{
|
|
email: "empty@example.com",
|
|
password: "password123",
|
|
oidc_id: ""
|
|
})
|
|
|
|
# Try OIDC registration with same email
|
|
user_info = %{
|
|
"sub" => "oidc_new_111",
|
|
"preferred_username" => "empty@example.com"
|
|
}
|
|
|
|
result =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{}
|
|
})
|
|
|
|
# Should trigger PasswordVerificationRequired (empty string = no OIDC)
|
|
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
|
|
|
password_error =
|
|
Enum.find(errors, fn err ->
|
|
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
|
end)
|
|
|
|
assert password_error != nil
|
|
end
|
|
|
|
@tag :test_proposal
|
|
test "cannot link same oidc_id to multiple users" do
|
|
# User 1 with OIDC
|
|
_user1 =
|
|
create_test_user(%{
|
|
email: "user1@example.com",
|
|
oidc_id: "shared_oidc_333"
|
|
})
|
|
|
|
# Try to create user 2 with same OIDC ID using raw Ash.Changeset
|
|
# (create_test_user uses Ash.Seed which does upsert)
|
|
result =
|
|
Mv.Accounts.User
|
|
|> Ash.Changeset.for_create(:create_user, %{
|
|
email: "user2@example.com"
|
|
})
|
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333")
|
|
|> Ash.create()
|
|
|
|
# Should fail due to unique constraint on oidc_id
|
|
assert match?({:error, %Ash.Error.Invalid{}}, result)
|
|
|
|
{:error, error} = result
|
|
# Verify the error is about oidc_id uniqueness
|
|
assert Enum.any?(error.errors, fn err ->
|
|
match?(%Ash.Error.Changes.InvalidAttribute{field: :oidc_id}, err)
|
|
end)
|
|
end
|
|
end
|
|
|
|
describe "OIDC login with passwordless user - Requires Linking Flow" do
|
|
test "user without password and without oidc_id triggers PasswordVerificationRequired" do
|
|
# Create user without password (e.g., invited user)
|
|
{:ok, existing_user} =
|
|
Mv.Accounts.User
|
|
|> Ash.Changeset.for_create(:create_user, %{
|
|
email: "invited@example.com"
|
|
})
|
|
|> Ash.create()
|
|
|
|
# Verify user has no password and no oidc_id
|
|
assert is_nil(existing_user.hashed_password)
|
|
assert is_nil(existing_user.oidc_id)
|
|
|
|
# OIDC registration should trigger linking flow (not automatic)
|
|
user_info = %{
|
|
"sub" => "auto_link_oidc_123",
|
|
"preferred_username" => "invited@example.com"
|
|
}
|
|
|
|
result =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{"access_token" => "test_token"}
|
|
})
|
|
|
|
# Should fail with PasswordVerificationRequired
|
|
# The LinkOidcAccountLive will auto-link without password prompt
|
|
assert {:error, %Ash.Error.Invalid{}} = result
|
|
{:error, error} = result
|
|
|
|
assert Enum.any?(error.errors, fn err ->
|
|
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
|
end)
|
|
end
|
|
|
|
test "user without password but WITH password later requires verification" do
|
|
# Create user without password first
|
|
{:ok, user} =
|
|
Mv.Accounts.User
|
|
|> Ash.Changeset.for_create(:create_user, %{
|
|
email: "added-password@example.com"
|
|
})
|
|
|> Ash.create()
|
|
|
|
# User sets password later (using admin action)
|
|
{:ok, user_with_password} =
|
|
user
|
|
|> Ash.Changeset.for_update(:admin_set_password, %{
|
|
password: "newpassword123"
|
|
})
|
|
|> Ash.update()
|
|
|
|
assert not is_nil(user_with_password.hashed_password)
|
|
|
|
# Now OIDC login should require password verification
|
|
user_info = %{
|
|
"sub" => "needs_verification",
|
|
"preferred_username" => "added-password@example.com"
|
|
}
|
|
|
|
result =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{"access_token" => "test_token"}
|
|
})
|
|
|
|
# Should fail with PasswordVerificationRequired
|
|
assert {:error, %Ash.Error.Invalid{}} = result
|
|
{:error, error} = result
|
|
|
|
assert Enum.any?(error.errors, fn err ->
|
|
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
|
end)
|
|
end
|
|
end
|
|
|
|
describe "OIDC login with different oidc_id - Hard Error" do
|
|
test "user with different oidc_id cannot be linked (hard error)" do
|
|
# Create user with existing OIDC ID
|
|
{:ok, existing_user} =
|
|
Mv.Accounts.User
|
|
|> Ash.Changeset.for_create(:create_user, %{
|
|
email: "already-linked@example.com"
|
|
})
|
|
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|
|
|> Ash.create()
|
|
|
|
assert existing_user.oidc_id == "original_oidc_999"
|
|
|
|
# Try to register with same email but different OIDC ID
|
|
user_info = %{
|
|
"sub" => "different_oidc_888",
|
|
"preferred_username" => "already-linked@example.com"
|
|
}
|
|
|
|
result =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{"access_token" => "test_token"}
|
|
})
|
|
|
|
# Should fail with hard error (not PasswordVerificationRequired)
|
|
assert {:error, %Ash.Error.Invalid{}} = result
|
|
{:error, error} = result
|
|
|
|
# Should NOT be PasswordVerificationRequired
|
|
refute Enum.any?(error.errors, fn err ->
|
|
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
|
end)
|
|
|
|
# Should be a validation error about email already linked
|
|
assert Enum.any?(error.errors, fn err ->
|
|
case err do
|
|
%Ash.Error.Changes.InvalidAttribute{message: msg} ->
|
|
String.contains?(msg, "already linked to a different OIDC account")
|
|
|
|
_ ->
|
|
false
|
|
end
|
|
end)
|
|
end
|
|
|
|
test "cannot link different oidc_id even with password verification" do
|
|
# Create user with password AND existing OIDC ID
|
|
existing_user =
|
|
create_test_user(%{
|
|
email: "password-and-oidc@example.com",
|
|
password: "mypassword123",
|
|
oidc_id: "first_oidc_111"
|
|
})
|
|
|
|
assert existing_user.oidc_id == "first_oidc_111"
|
|
assert not is_nil(existing_user.hashed_password)
|
|
|
|
# Try to register with different OIDC ID
|
|
user_info = %{
|
|
"sub" => "second_oidc_222",
|
|
"preferred_username" => "password-and-oidc@example.com"
|
|
}
|
|
|
|
result =
|
|
Mv.Accounts.create_register_with_rauthy(%{
|
|
user_info: user_info,
|
|
oauth_tokens: %{"access_token" => "test_token"}
|
|
})
|
|
|
|
# Should fail - cannot link different OIDC ID
|
|
assert {:error, %Ash.Error.Invalid{}} = result
|
|
{:error, error} = result
|
|
|
|
# Should be a hard error, not password verification
|
|
refute Enum.any?(error.errors, fn err ->
|
|
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
|
end)
|
|
end
|
|
end
|
|
end
|