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(:success, 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 log_failure_safely(activity, reason) case {activity, reason} do {{:oidc, _action}, reason} -> handle_oidc_failure(conn, reason) {_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} -> handle_authentication_failed(conn, caused_by) _ -> conn |> put_flash(:error, gettext("Incorrect email or password")) |> redirect(to: ~p"/sign-in") end end # Log authentication failures safely, avoiding sensitive data for {:oidc, _} activities defp log_failure_safely({:oidc, _action} = activity, reason) do # For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params case reason do %Assent.ServerUnreachableError{} = err -> meta = safe_assent_meta(err) message = format_safe_log_message("Authentication failure", activity, meta) Logger.warning(message) %Assent.InvalidResponseError{} = err -> meta = safe_assent_meta(err) message = format_safe_log_message("Authentication failure", activity, meta) Logger.warning(message) _ -> # For other OIDC errors, log only error type, not full details error_type = get_error_type(reason) Logger.warning( "Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}" ) end end defp log_failure_safely(activity, reason) do # For non-OIDC activities, safe to log full reason Logger.warning( "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" ) end # Extract safe error type identifier without sensitive data defp get_error_type(%struct{}), do: "#{struct}" defp get_error_type(atom) when is_atom(atom), do: inspect(atom) defp get_error_type(_other), do: "[redacted]" # Format safe log message with metadata included in the message string defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do activity_str = "Activity: #{inspect(activity)}" meta_str = format_meta_string(meta) "#{base_message} - #{activity_str}#{meta_str}" end defp format_meta_string([]), do: "" defp format_meta_string(meta) when is_list(meta) do parts = Enum.map(meta, fn {:request_url, url} -> "Request URL: #{url}" {:status, status} -> "Status: #{status}" {:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}" _ -> nil end) |> Enum.filter(&(&1 != nil)) if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ") end # Handle all OIDC authentication failures defp handle_oidc_failure(conn, %Ash.Error.Invalid{errors: errors}) do handle_oidc_email_collision(conn, errors) end defp handle_oidc_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) _ -> conn |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) |> redirect(to: ~p"/sign-in") end end # Handle Assent server unreachable errors (network/connectivity issues) defp handle_oidc_failure(conn, %Assent.ServerUnreachableError{} = _err) do # Logging already done safely in failure/3 via log_failure_safely/2 # No need to log again here to avoid duplicate logs conn |> put_flash( :error, gettext("The authentication server is currently unavailable. Please try again later.") ) |> redirect(to: ~p"/sign-in") end # Handle Assent invalid response errors (configuration or malformed responses) defp handle_oidc_failure(conn, %Assent.InvalidResponseError{} = _err) do # Logging already done safely in failure/3 via log_failure_safely/2 # No need to log again here to avoid duplicate logs conn |> put_flash( :error, gettext("Authentication configuration error. Please contact the administrator.") ) |> redirect(to: ~p"/sign-in") end # Catch-all clause for any other error types defp handle_oidc_failure(conn, _reason) do # Logging already done safely in failure/3 via log_failure_safely/2 # No need to log again here to avoid duplicate logs conn |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) |> redirect(to: ~p"/sign-in") 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. """) conn |> put_flash(:error, message) |> redirect(to: ~p"/sign-in") else conn |> put_flash(:error, gettext("Authentication failed. Please try again.")) |> redirect(to: ~p"/sign-in") end end defp handle_authentication_failed(conn, _other) do conn |> put_flash(:error, gettext("Authentication failed. Please try again.")) |> redirect(to: ~p"/sign-in") 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) conn |> put_flash(:error, error_message) |> redirect(to: ~p"/sign-in") 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 # Extract safe metadata from Assent errors for logging # Never logs sensitive data: no tokens, secrets, or full request URLs # Returns keyword list for Logger.warning/2 defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do [ request_url: redact_url(url), http_adapter: Map.get(err, :http_adapter) ] |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end # Handle InvalidResponseError which has :response field (HTTPResponse struct) defp safe_assent_meta(%{response: %{status: status} = response} = err) do [ status: status, http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter) ] |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end defp safe_assent_meta(err) do # Only extract safe, simple fields [ http_adapter: Map.get(err, :http_adapter) ] |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end # Redact URL to only show scheme and host, hiding path, query, and fragments defp redact_url(url) when is_binary(url) do case URI.parse(url) do %URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) -> "#{scheme}://#{host}" _ -> "[redacted]" end end defp redact_url(_), do: "[redacted]" def sign_out(conn, _params) do return_to = get_session(conn, :return_to) || ~p"/" conn |> clear_session(:mv) |> put_flash(:success, gettext("You are now signed out")) |> redirect(to: return_to) end end