mitgliederverwaltung/test/mv_web/controllers/oidc_password_linking_test.exs
2025-11-13 16:33:29 +01:00

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