require Logger defmodule MvWeb.AuthController do @moduledoc """ Handles authentication callbacks for password and OIDC authentication. This controller manages: - Successful authentication (password, OIDC, password reset, email confirmation) - Authentication failures with appropriate error handling - OIDC account linking flow when email collision occurs - Sign out functionality """ use MvWeb, :controller use AshAuthentication.Phoenix.Controller alias Mv.Accounts.User.Errors.PasswordVerificationRequired def success(conn, activity, user, _token) do return_to = get_session(conn, :return_to) || ~p"/" message = case activity do {:confirm_new_user, :confirm} -> gettext("Your email address has now been confirmed") {:password, :reset} -> gettext("Your password has successfully been reset") _ -> gettext("You are now signed in") end conn |> delete_session(:return_to) |> store_in_session(user) # If your resource has a different name, update the assign name here (i.e :current_admin) |> assign(:current_user, user) |> put_flash(:info, message) |> redirect(to: return_to) end @doc """ Handles authentication failures and routes to appropriate error handling. Manages: - OIDC email collisions (triggers password verification flow) - Generic OIDC authentication failures - Unconfirmed account errors - Generic authentication failures """ def failure(conn, activity, reason) do Logger.warning( "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" ) case {activity, reason} do {{:rauthy, _action}, reason} -> handle_rauthy_failure(conn, reason) {_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} -> handle_authentication_failed(conn, caused_by) _ -> redirect_with_error(conn, gettext("Incorrect email or password")) end end # Handle all Rauthy (OIDC) authentication failures defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do handle_oidc_email_collision(conn, errors) end defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{ caused_by: caused_by }) do case caused_by do %Ash.Error.Invalid{errors: errors} -> handle_oidc_email_collision(conn, errors) _ -> redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) end end # Handle generic AuthenticationFailed errors defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do message = gettext(""" You have already signed in another way, but have not confirmed your account. You can confirm your account using the link we sent to you, or by resetting your password. """) redirect_with_error(conn, message) else redirect_with_error(conn, gettext("Authentication failed. Please try again.")) end end defp handle_authentication_failed(conn, _other) do redirect_with_error(conn, gettext("Authentication failed. Please try again.")) end # Handle OIDC email collision - user needs to verify password to link accounts defp handle_oidc_email_collision(conn, errors) do case find_password_verification_error(errors) do %PasswordVerificationRequired{user_id: user_id, oidc_user_info: oidc_user_info} -> redirect_to_account_linking(conn, user_id, oidc_user_info) nil -> # Check if it's a "different OIDC account" error or email uniqueness error error_message = extract_meaningful_error_message(errors) redirect_with_error(conn, error_message) end end # Extract meaningful error message from Ash errors defp extract_meaningful_error_message(errors) do # Look for specific error messages in InvalidAttribute errors meaningful_error = Enum.find_value(errors, fn %Ash.Error.Changes.InvalidAttribute{message: message, field: :email} when is_binary(message) -> cond do # Email update conflict during OIDC login String.contains?(message, "Cannot update email to") and String.contains?(message, "already registered to another account") -> gettext( "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." ) # Different OIDC account error String.contains?(message, "already linked to a different OIDC account") -> gettext( "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." ) true -> nil end %Ash.Error.Changes.InvalidAttribute{message: message} when is_binary(message) -> # Return any other meaningful message if String.length(message) > 20 and not String.contains?(message, "has already been taken") do message else nil end _ -> nil end) meaningful_error || gettext("Unable to sign in. Please try again.") end # Find PasswordVerificationRequired error in error list defp find_password_verification_error(errors) do Enum.find(errors, &match?(%PasswordVerificationRequired{}, &1)) end # Redirect to account linking page with OIDC info stored in session defp redirect_to_account_linking(conn, user_id, oidc_user_info) do conn |> put_session(:oidc_linking_user_id, user_id) |> put_session(:oidc_linking_user_info, oidc_user_info) |> put_flash( :info, gettext( "An account with this email already exists. Please verify your password to link your OIDC account." ) ) |> redirect(to: ~p"/auth/link-oidc-account") end # Generic error redirect helper defp redirect_with_error(conn, message) do conn |> put_flash(:error, message) |> redirect(to: ~p"/sign-in") end def sign_out(conn, _params) do return_to = get_session(conn, :return_to) || ~p"/" conn |> clear_session(:mv) |> put_flash(:info, gettext("You are now signed out")) |> redirect(to: return_to) end end