defmodule Mv.Accounts.User.Validations.OidcEmailCollision do @moduledoc """ Validation that checks for email collisions during OIDC registration. This validation prevents OIDC accounts from automatically taking over existing password-only accounts. Instead, it requires password verification. ## Scenarios: 1. **User exists with matching oidc_id**: - Allow (upsert will update the existing user) 2. **User exists with email but NO oidc_id (or empty string)**: - Raise PasswordVerificationRequired error - User must verify password before linking 3. **User exists with email AND different oidc_id**: - Raise PasswordVerificationRequired error - This prevents linking different OIDC providers to same account 4. **No user exists with this email**: - Allow (new user will be created) """ use Ash.Resource.Validation alias Mv.Accounts.User.Errors.PasswordVerificationRequired @impl true def init(opts), do: {:ok, opts} @impl true def validate(changeset, _opts, _context) do # Get the email and oidc_id from the changeset email = Ash.Changeset.get_attribute(changeset, :email) oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id) user_info = Ash.Changeset.get_argument(changeset, :user_info) # Only validate if we have both email and oidc_id (from OIDC registration) if email && oidc_id && user_info do check_email_collision(email, oidc_id, user_info) else :ok end end defp check_email_collision(email, new_oidc_id, user_info) do # Find existing user with this email case Mv.Accounts.User |> Ash.Query.filter(email == ^to_string(email)) |> Ash.read_one() do {:ok, nil} -> # No user exists with this email - OK to create new user :ok {:ok, existing_user} -> # User exists - check oidc_id handle_existing_user(existing_user, new_oidc_id, user_info) {:error, error} -> # Database error {:error, field: :email, message: "Could not verify email uniqueness: #{inspect(error)}"} end end defp handle_existing_user(existing_user, new_oidc_id, user_info) do existing_oidc_id = existing_user.oidc_id cond do # Case 1: Same oidc_id - this is an upsert, allow it existing_oidc_id == new_oidc_id -> :ok # Case 2: No oidc_id set (nil or empty string) - password-only user is_nil(existing_oidc_id) or existing_oidc_id == "" -> {:error, PasswordVerificationRequired.exception( user_id: existing_user.id, oidc_user_info: user_info )} # Case 3: Different oidc_id - account conflict true -> {:error, PasswordVerificationRequired.exception( user_id: existing_user.id, oidc_user_info: user_info )} end end @impl true def atomic?(), do: false @impl true def describe(_opts) do [ message: "OIDC email collision detected", vars: [] ] end end