409 lines
12 KiB
Elixir
409 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 password requirement (different oidc_id)
|
|
{: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 ->
|
|
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
|
end)
|
|
|
|
assert password_error != nil
|
|
assert password_error.user_id == user.id
|
|
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
|