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

415 lines
12 KiB
Elixir

defmodule MvWeb.OidcE2EFlowTest do
@moduledoc """
End-to-end tests for OIDC authentication flows.
These tests simulate the complete user journey through OIDC authentication,
including account linking scenarios.
"""
use MvWeb.ConnCase, async: true
require Ash.Query
describe "E2E: New OIDC user registration" do
test "new user can register via OIDC", %{conn: _conn} do
# Simulate OIDC callback for brand new user
user_info = %{
"sub" => "new_oidc_user_123",
"preferred_username" => "newuser@example.com"
}
# Call register action
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
assert {:ok, new_user} = result
assert to_string(new_user.email) == "newuser@example.com"
assert new_user.oidc_id == "new_oidc_user_123"
assert is_nil(new_user.hashed_password)
# Verify user can be found by oidc_id
{:ok, [found_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
assert found_user.id == new_user.id
end
end
describe "E2E: Existing OIDC user sign-in" do
test "existing OIDC user can sign in and email updates", %{conn: _conn} do
# Create OIDC user
user =
create_test_user(%{
email: "oldmail@example.com",
oidc_id: "oidc_existing_999"
})
# User changed email at OIDC provider
updated_user_info = %{
"sub" => "oidc_existing_999",
"preferred_username" => "newmail@example.com"
}
# Register (upsert) with new email
{:ok, updated_user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: updated_user_info,
oauth_tokens: %{}
})
# Same user, updated email
assert updated_user.id == user.id
assert to_string(updated_user.email) == "newmail@example.com"
assert updated_user.oidc_id == "oidc_existing_999"
end
end
describe "E2E: OIDC with existing password account (Email Collision)" do
test "OIDC registration with password account email triggers PasswordVerificationRequired",
%{conn: _conn} do
# Step 1: Create a password-only user
password_user =
create_test_user(%{
email: "collision@example.com",
password: "mypassword123",
oidc_id: nil
})
# Step 2: Try to register via OIDC with same email
user_info = %{
"sub" => "oidc_new_777",
"preferred_username" => "collision@example.com"
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
# Step 3: Should fail with PasswordVerificationRequired
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
password_error =
Enum.find(errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
assert password_error != nil
assert password_error.user_id == password_user.id
assert password_error.oidc_user_info["sub"] == "oidc_new_777"
assert password_error.oidc_user_info["preferred_username"] == "collision@example.com"
end
test "full E2E flow: OIDC collision -> password verification -> account linked",
%{conn: _conn} do
# Step 1: Create password user
password_user =
create_test_user(%{
email: "full@example.com",
password: "testpass123",
oidc_id: nil
})
# Step 2: OIDC registration triggers error
user_info = %{
"sub" => "oidc_link_888",
"preferred_username" => "full@example.com"
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
# Extract the error
password_error =
Enum.find(errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
assert password_error != nil
# Step 3: User verifies password (this would happen in LiveView)
# Here we simulate successful password verification
# Step 4: Link OIDC account after verification
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_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 account is now linked
assert linked_user.id == password_user.id
assert linked_user.oidc_id == "oidc_link_888"
assert to_string(linked_user.email) == "full@example.com"
# Password should still exist
assert linked_user.hashed_password == password_user.hashed_password
# Step 5: User can now sign in via OIDC
{:ok, [signed_in_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
assert signed_in_user.id == password_user.id
assert signed_in_user.oidc_id == "oidc_link_888"
end
test "E2E: OIDC collision with different email at provider updates email after linking",
%{conn: _conn} do
# Password user with old email
password_user =
create_test_user(%{
email: "old@example.com",
password: "pass123",
oidc_id: nil
})
# OIDC provider has new email
user_info = %{
"sub" => "oidc_new_email_555",
"preferred_username" => "old@example.com"
}
# Collision detected
{:error, %Ash.Error.Invalid{}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
# After password verification, link with OIDC info that has NEW email
updated_user_info = %{
"sub" => "oidc_new_email_555",
"preferred_username" => "new@example.com"
}
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: updated_user_info["sub"],
oidc_user_info: updated_user_info
})
|> Ash.update()
# Email should be updated to match OIDC provider
assert to_string(linked_user.email) == "new@example.com"
assert linked_user.oidc_id == "oidc_new_email_555"
end
end
describe "E2E: OIDC with linked member" do
test "E2E: email sync to member when linking OIDC to password account", %{conn: _conn} do
# Create member
member =
Ash.Seed.seed!(Mv.Membership.Member, %{
email: "member@example.com",
first_name: "Test",
last_name: "User"
})
# Create password user linked to member
password_user =
Ash.Seed.seed!(Mv.Accounts.User, %{
email: "member@example.com",
hashed_password: "dummy_hash",
oidc_id: nil,
member_id: member.id
})
# OIDC registration with same email
user_info = %{
"sub" => "oidc_member_333",
"preferred_username" => "member@example.com"
}
# Collision detected
{:error, %Ash.Error.Invalid{}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
# After password verification, link OIDC with NEW email
updated_user_info = %{
"sub" => "oidc_member_333",
"preferred_username" => "newmember@example.com"
}
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: updated_user_info["sub"],
oidc_user_info: updated_user_info
})
|> Ash.update()
# User email updated
assert to_string(linked_user.email) == "newmember@example.com"
# Member email should be synced
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
assert to_string(updated_member.email) == "newmember@example.com"
end
end
describe "E2E: Security scenarios" do
test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: _conn} do
# Create password user
_password_user =
create_test_user(%{
email: "secure@example.com",
password: "securepass123",
oidc_id: nil
})
# Attacker tries to sign in via OIDC with same email
user_info = %{
"sub" => "attacker_oidc_666",
"preferred_username" => "secure@example.com"
}
# Sign-in should fail (no matching oidc_id)
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
case result do
{:ok, []} ->
:ok
{:error, %Ash.Error.Forbidden{}} ->
:ok
other ->
flunk("Expected no access, got: #{inspect(other)}")
end
# Registration should trigger password requirement
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
assert Enum.any?(errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
end
test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: _conn} do
# User linked to OIDC provider A
_user =
create_test_user(%{
email: "linked@example.com",
oidc_id: "provider_a_123"
})
# Attacker tries to register with OIDC provider B using same email
user_info = %{
"sub" => "provider_b_456",
"preferred_username" => "linked@example.com"
}
# Should trigger hard error (not PasswordVerificationRequired)
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
# Should have hard 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 "E2E: empty string oidc_id is treated as password-only account", %{conn: _conn} do
# User with empty oidc_id
_password_user =
create_test_user(%{
email: "empty@example.com",
password: "pass123",
oidc_id: ""
})
# Try OIDC registration
user_info = %{
"sub" => "oidc_new_222",
"preferred_username" => "empty@example.com"
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
# Should require password (empty string = no OIDC)
assert Enum.any?(errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
end
end
describe "E2E: Error scenarios" do
test "E2E: OIDC registration without oidc_id fails", %{conn: _conn} do
user_info = %{
"preferred_username" => "noid@example.com"
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
assert Enum.any?(errors, fn err ->
match?(%Ash.Error.Changes.InvalidChanges{}, err)
end)
end
test "E2E: OIDC registration without email fails", %{conn: _conn} do
user_info = %{
"sub" => "noemail_123"
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
assert Enum.any?(errors, fn err ->
match?(%Ash.Error.Changes.Required{field: :email}, err)
end)
end
end
end