defmodule Mv.Accounts.User.Validations.OidcEmailCollision do @moduledoc """ Validation that checks for email collisions during OIDC registration. This validation prevents unauthorized account takeovers and enforces proper account linking flows based on user state. ## Scenarios: 1. **User exists with matching oidc_id**: - Allow (upsert will update the existing user) 2. **User exists with different oidc_id**: - Hard error: Cannot link multiple OIDC providers to same account - No linking possible - user must use original OIDC provider 3. **User exists without oidc_id** (password-protected OR passwordless): - Raise PasswordVerificationRequired error - User is redirected to LinkOidcAccountLive which will: - Show password form if user has password - Auto-link immediately if user is passwordless 4. **No user exists with this email**: - Allow (new user will be created) """ use Ash.Resource.Validation require Logger 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 if a user with this oidc_id already exists # If yes, this will be an upsert (email update), not a new registration existing_oidc_user = case Mv.Accounts.User |> Ash.Query.filter(oidc_id == ^to_string(oidc_id)) |> Ash.read_one() do {:ok, user} -> user _ -> nil end check_email_collision(email, oidc_id, user_info, existing_oidc_user) else :ok end end defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) 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, user_with_email} -> # User exists with this email - check if it's an upsert or registration is_upsert = not is_nil(existing_oidc_user) handle_existing_user( user_with_email, new_oidc_id, user_info, is_upsert, existing_oidc_user ) {:error, error} -> # Database error - log for debugging but don't expose internals to user Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}") {:error, field: :email, message: "Could not verify email uniqueness. Please try again."} end end defp handle_existing_user( user_with_email, new_oidc_id, user_info, is_upsert, existing_oidc_user ) do if is_upsert do handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) else handle_create_scenario(user_with_email, new_oidc_id, user_info) end end # Handle email update for existing OIDC user defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do cond do # Same user updating their own record not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id -> :ok # Different user exists with target email not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id -> handle_email_conflict(user_with_email, user_info) # Should not reach here true -> {:error, field: :email, message: "Unexpected error during email update"} end end # Handle email conflict during upsert defp handle_email_conflict(user_with_email, user_info) do email = Map.get(user_info, "preferred_username", "unknown") email_user_oidc_id = user_with_email.oidc_id # Check if target email belongs to another OIDC user if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do different_oidc_error(email) else email_taken_error(email) end end # Handle new OIDC user registration scenarios defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do email_user_oidc_id = user_with_email.oidc_id cond do # Same oidc_id (should not happen in practice, but allow for safety) email_user_oidc_id == new_oidc_id -> :ok # Different oidc_id exists (hard error) not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and email_user_oidc_id != new_oidc_id -> email = Map.get(user_info, "preferred_username", "unknown") different_oidc_error(email) # No oidc_id (require account linking) is_nil(email_user_oidc_id) or email_user_oidc_id == "" -> {:error, PasswordVerificationRequired.exception( user_id: user_with_email.id, oidc_user_info: user_info )} # Should not reach here true -> {:error, field: :email, message: "Unexpected error during OIDC registration"} end end # Generate error for different OIDC account conflict defp different_oidc_error(email) do {:error, field: :email, message: "Email '#{email}' is already linked to a different OIDC account. " <> "Cannot link multiple OIDC providers to the same account."} end # Generate error for email already taken defp email_taken_error(email) do {:error, field: :email, message: "Cannot update email to '#{email}': This email is already registered to another account. " <> "Please change your email in the identity provider."} end @impl true def atomic?(), do: false @impl true def describe(_opts) do [ message: "OIDC email collision detected", vars: [] ] end end