Packages

phoenix_kit

2.52.2
2.63.0 2.62.2 2.62.1 2.62.0 2.61.0 2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web users oauth.ex
Raw

lib/phoenix_kit_web/users/oauth.ex

if Code.ensure_loaded?(Ueberauth) do
  defmodule PhoenixKitWeb.Users.OAuth do
    @moduledoc """
    OAuth authentication controller using Ueberauth with dynamic provider configuration.

    This controller uses `Ueberauth.run_request/4` and `Ueberauth.run_callback/4` for
    dynamic OAuth invocation, eliminating compile-time configuration requirements.
    OAuth credentials are loaded from database at runtime.

    This controller requires the following optional dependencies to be installed:
    - ueberauth
    - ueberauth_google (for Google Sign-In)
    - ueberauth_github (for GitHub Sign-In)
    - ueberauth_facebook (for Facebook Sign-In)

    If these dependencies are not installed, a fallback controller will be used instead.

    ## Add-account via OAuth

    When the request action receives `add_account=1` as a query parameter it sets a
    short-lived session key `:oauth_add_account_intent` to `"add_account"`. The callback
    reads and immediately clears this key. When it is present AND there is a valid root
    user AND `MultiSession.gate_allowed?/1` is true, the callback adds the OAuth-
    authenticated user to the multi-session stack via `MultiSession.add_authenticated_user/2`
    instead of performing a full login.

    The marker is distinct from `:oauth_return_to` and `:oauth_referral_code` and does NOT
    interact with the existing settings-page provider-link flow (which has no session marker
    at all and is handled by a separate LiveView).
    """

    use PhoenixKitWeb, :controller

    plug PhoenixKitWeb.Plugs.EnsureOAuthScheme
    plug PhoenixKitWeb.Plugs.EnsureOAuthConfig
    # NOTE: No `plug Ueberauth` - we call Ueberauth.run_request/4 and run_callback/4 dynamically

    alias PhoenixKit.Config
    alias PhoenixKit.Settings
    alias PhoenixKit.Users.Auth
    alias PhoenixKit.Users.Auth.Scope
    alias PhoenixKit.Users.OAuth
    alias PhoenixKit.Utils.IpAddress
    alias PhoenixKit.Utils.Routes
    alias PhoenixKitWeb.Users.Auth, as: UserAuth
    alias PhoenixKitWeb.Users.MultiSession

    require Logger

    # Session key used to signal that an OAuth flow was started from the
    # "Add account" modal. Distinct from :oauth_return_to and the settings
    # provider-link flow. Value is "add_account" when set.
    @add_account_intent_key :oauth_add_account_intent

    # Map provider names (strings) to strategy modules
    @provider_strategies %{
      "google" => Ueberauth.Strategy.Google,
      "github" => Ueberauth.Strategy.Github,
      "facebook" => Ueberauth.Strategy.Facebook
    }

    @doc """
    Initiates OAuth authentication flow.

    Uses `Ueberauth.run_request/4` for dynamic OAuth invocation,
    reading credentials from database at runtime.
    """
    def request(conn, %{"provider" => provider} = params) do
      Logger.debug("PhoenixKit OAuth request for provider: #{provider}")

      # Check if OAuth is enabled in settings
      if oauth_enabled_in_settings?() do
        # Check if provider is supported and enabled
        case validate_provider(provider) do
          {:ok, strategy_module} ->
            handle_oauth_request(conn, provider, strategy_module, params)

          {:error, :unknown_provider} ->
            Logger.warning("PhoenixKit OAuth: Unknown provider '#{provider}'")

            conn
            |> put_flash(
              :error,
              gettext("Unknown OAuth provider: %{provider}", provider: provider)
            )
            |> redirect(to: Routes.path("/users/log-in"))

          {:error, :provider_disabled} ->
            Logger.warning("PhoenixKit OAuth: Provider '#{provider}' is disabled in settings")

            conn
            |> put_flash(
              :error,
              gettext("OAuth provider '%{provider}' is currently disabled.", provider: provider)
            )
            |> redirect(to: Routes.path("/users/log-in"))

          {:error, :no_credentials} ->
            Logger.warning(
              "PhoenixKit OAuth: No credentials configured for provider '#{provider}'"
            )

            conn
            |> put_flash(
              :error,
              gettext(
                "OAuth provider '%{provider}' is not configured. Please contact your administrator.",
                provider: provider
              )
            )
            |> redirect(to: Routes.path("/users/log-in"))
        end
      else
        Logger.warning("PhoenixKit OAuth: OAuth is disabled in settings")

        conn
        |> put_flash(
          :error,
          gettext(
            "OAuth authentication is currently disabled. Please contact your administrator to enable it."
          )
        )
        |> redirect(to: Routes.path("/users/log-in"))
      end
    end

    defp handle_oauth_request(conn, provider, strategy_module, params) do
      # Store referral_code, return_to, and add-account intent in session.
      # The add-account intent is set when the request comes from the "Add account"
      # modal (add_account=1 query param). It is consumed and cleared in the callback.
      conn =
        conn
        |> maybe_put_session(:oauth_referral_code, params["referral_code"])
        |> maybe_put_session(:oauth_return_to, params["return_to"])
        |> maybe_set_add_account_intent(params)

      # Build provider config for dynamic Ueberauth call
      base_path = Config.UeberAuth.get_base_path()

      provider_config =
        {strategy_module,
         [
           request_path: "#{base_path}/#{provider}",
           callback_path: "#{base_path}/#{provider}/callback"
         ]}

      # Dynamic Ueberauth call - reads credentials from Application env
      # (configured by EnsureOAuthConfig plug)
      Ueberauth.run_request(conn, provider, provider_config)
    end

    defp validate_provider(provider) do
      case Map.get(@provider_strategies, provider) do
        nil ->
          {:error, :unknown_provider}

        strategy_module ->
          # Check if provider is enabled in settings
          if Settings.get_boolean_setting("oauth_#{provider}_enabled", false) do
            # Check if credentials exist
            if Settings.has_oauth_credentials_direct?(String.to_existing_atom(provider)) do
              {:ok, strategy_module}
            else
              {:error, :no_credentials}
            end
          else
            {:error, :provider_disabled}
          end
      end
    end

    # Set-or-CLEAR, never leave the previous attempt's value behind. A no-op on
    # nil meant an abandoned OAuth attempt handed its stale return_to or
    # referral attribution to whatever login came next.
    defp maybe_put_session(conn, key, nil), do: delete_session(conn, key)
    defp maybe_put_session(conn, key, value), do: put_session(conn, key, value)

    defp maybe_set_add_account_intent(conn, %{"add_account" => "1"}) do
      put_session(conn, @add_account_intent_key, "add_account")
    end

    defp maybe_set_add_account_intent(conn, _params),
      do: delete_session(conn, @add_account_intent_key)

    defp clear_oauth_session_keys(conn) do
      conn
      |> delete_session(:oauth_referral_code)
      |> delete_session(:oauth_return_to)
      |> delete_session(@add_account_intent_key)
    end

    defp oauth_enabled_in_settings? do
      Settings.get_boolean_setting("oauth_enabled", false)
    end

    @doc """
    Handles OAuth callback from provider.

    Uses `Ueberauth.run_callback/4` for dynamic OAuth invocation,
    then processes the result from conn.assigns.
    """
    def callback(conn, %{"provider" => provider} = _params) do
      Logger.debug("PhoenixKit OAuth callback for provider: #{provider}")

      case Map.get(@provider_strategies, provider) do
        nil ->
          Logger.error("PhoenixKit OAuth: Unknown provider in callback: #{provider}")

          conn
          |> put_flash(:error, gettext("Unknown OAuth provider: %{provider}", provider: provider))
          |> redirect(to: Routes.path("/users/log-in"))

        strategy_module ->
          # Build provider config for dynamic Ueberauth call
          base_path = Config.UeberAuth.get_base_path()

          provider_config =
            {strategy_module,
             [
               request_path: "#{base_path}/#{provider}",
               callback_path: "#{base_path}/#{provider}/callback"
             ]}

          # Dynamic Ueberauth callback - processes OAuth response
          conn = Ueberauth.run_callback(conn, provider, provider_config)

          # Handle the result based on assigns set by Ueberauth
          handle_callback_result(conn)
      end
    end

    # Handle successful OAuth authentication
    defp handle_callback_result(%{assigns: %{ueberauth_auth: auth}} = conn) do
      track_geolocation = Settings.get_boolean_setting("track_registration_geolocation", false)
      ip_address = IpAddress.extract_from_conn(conn)
      referral_code = get_session(conn, :oauth_referral_code)
      return_to = get_session(conn, :oauth_return_to)
      # Consume the add-account marker immediately — regardless of outcome below.
      add_account_intent = get_session(conn, @add_account_intent_key)

      conn =
        conn
        |> delete_session(:oauth_referral_code)
        |> delete_session(:oauth_return_to)
        |> delete_session(@add_account_intent_key)

      opts = [
        track_geolocation: track_geolocation,
        ip_address: ip_address,
        referral_code: referral_code
      ]

      case OAuth.handle_oauth_callback(auth, opts) do
        {:ok, user} ->
          Logger.info(
            "PhoenixKit: User #{user.uuid} (#{user.email}) authenticated via OAuth (#{auth.provider})"
          )

          # When the add-account intent is present AND the root session is still valid AND
          # the multi-session gate allows it, add the OAuth user to the stack instead of
          # performing a full login. This mirrors the password add_account/3 path.
          cond do
            add_account_intent == "add_account" and
                MultiSession.gate_allowed?(get_session(conn)) ->
              handle_oauth_add_account(conn, user, return_to)

            # Intent was set but the gate is now closed (setting toggled off, or the
            # root token expired). Don't silently replace the primary session with a
            # full login — surface it and return home.
            add_account_intent == "add_account" ->
              conn
              |> put_flash(:error, gettext("Account switching is currently disabled."))
              |> redirect(to: Routes.safe_destination(conn, scope: conn_scope(conn)))

            true ->
              flash_message =
                gettext("Successfully signed in with %{provider}!",
                  provider: format_provider_name(auth.provider)
                )

              # No checkbox in an OAuth round-trip, so persistence follows the
              # site-wide policy setting.
              login_params =
                Map.put(UserAuth.remember_me_params(), "return_to", return_to)

              conn
              |> put_flash(:info, flash_message)
              |> UserAuth.log_in_user(user, login_params)
          end

        {:error, %Ecto.Changeset{} = changeset} ->
          errors = format_changeset_errors(changeset)

          Logger.warning(
            "PhoenixKit: OAuth authentication failed for #{auth.info.email}: #{inspect(errors)}"
          )

          conn
          |> put_flash(:error, gettext("Authentication failed: %{errors}", errors: errors))
          |> redirect(to: Routes.path("/users/log-in"))

        {:error, :provider_email_unverified} ->
          # An account with this address already exists locally and the provider
          # did not assert that it verified the address, so attaching would be a
          # takeover. Say what happened — a legitimate owner needs to know that
          # signing in with a password is the way through, and that nothing is
          # broken on their side.
          Logger.warning(
            "PhoenixKit: OAuth link refused for #{auth.info.email} via #{auth.provider} — " <>
              "provider did not assert the address is verified and a local account already exists"
          )

          conn
          |> put_flash(
            :error,
            gettext(
              "An account already exists for that email address. Sign in with your password first, then connect this provider from your account settings."
            )
          )
          |> redirect(to: Routes.path("/users/log-in"))

        {:error, :registration_disabled} ->
          # No new accounts anywhere while `allow_registration` is off. Said
          # plainly, because "authentication failed" would send someone to
          # retry a provider that is working perfectly well.
          Logger.info(
            "PhoenixKit: OAuth registration refused for #{auth.info.email} via " <>
              "#{auth.provider} — registration is disabled"
          )

          conn
          |> put_flash(
            :error,
            gettext("Registration is currently disabled. Contact an administrator for access.")
          )
          |> redirect(to: Routes.path("/users/log-in"))

        {:error, reason} ->
          Logger.error("PhoenixKit: OAuth authentication error: #{inspect(reason)}")

          conn
          |> put_flash(
            :error,
            gettext("Authentication failed. Please try again or use a different sign-in method.")
          )
          |> redirect(to: Routes.path("/users/log-in"))
      end
    end

    # Handle OAuth authentication failure
    defp handle_callback_result(%{assigns: %{ueberauth_failure: failure}} = conn) do
      error_message = format_ueberauth_failure(failure)
      Logger.warning("PhoenixKit: OAuth authentication failure: #{inspect(failure)}")

      conn
      # Clear every transient OAuth key so a later normal sign-in can't inherit
      # this abandoned attempt's redirect target or referral attribution.
      |> clear_oauth_session_keys()
      |> put_flash(:error, error_message)
      |> redirect(to: Routes.path("/users/log-in"))
    end

    # Handle unexpected callback without auth or failure
    defp handle_callback_result(conn) do
      Logger.error("PhoenixKit: Unexpected OAuth callback without auth or failure")

      conn
      # Clear every transient OAuth key so a later normal sign-in can't inherit
      # this abandoned attempt's redirect target or referral attribution.
      |> clear_oauth_session_keys()
      |> put_flash(:error, gettext("Authentication failed. Please try again."))
      |> redirect(to: Routes.path("/users/log-in"))
    end

    # Adds an OAuth-authenticated user to the multi-session stack.
    # Mirrors the error handling of Session.add_account/2.
    defp handle_oauth_add_account(conn, user, return_to) do
      case MultiSession.add_authenticated_user(conn, user) do
        {:ok, conn} ->
          conn
          |> put_flash(:info, gettext("Account added."))
          |> redirect_back(return_to)

        {:error, :stack_full} ->
          conn
          |> put_flash(:error, gettext("Maximum number of accounts reached."))
          |> redirect_back(return_to)

        {:error, :already_in_stack} ->
          conn
          |> put_flash(:error, gettext("That account is already in your session."))
          |> redirect_back(return_to)

        {:error, :inactive} ->
          conn
          |> put_flash(:error, gettext("That account is inactive."))
          |> redirect_back(return_to)
      end
    end

    defp redirect_back(conn, return_to) do
      redirect(conn,
        to: Routes.safe_destination(conn, scope: conn_scope(conn), return_to: return_to)
      )
    end

    # Read from the SESSION, not from assigns: `handle_oauth_add_account/3` has
    # already activated the newly added account by the time `redirect_back/2`
    # runs, while `conn.assigns[:phoenix_kit_current_user]` still describes the
    # account that started the OAuth round-trip. (This controller also runs on
    # the `phoenix_kit_auto_setup` pipeline, which never assigns a scope at
    # all.) `Scope.for_user(nil)` is anonymous — the right answer when the
    # session carries no valid token.
    defp conn_scope(conn) do
      conn
      |> get_session(:user_token)
      |> case do
        token when is_binary(token) -> Auth.get_user_by_session_token(token)
        _ -> nil
      end
      # Same reason as the twin in `PhoenixKitWeb.Users.Session`: a deactivated
      # account resolves to anonymous, not to itself.
      |> Auth.ensure_active_user()
      |> Scope.for_user()
    end

    # Private helper functions

    defp format_provider_name(provider) when is_atom(provider) do
      provider |> to_string() |> format_provider_name()
    end

    defp format_provider_name("google"), do: "Google"
    defp format_provider_name("github"), do: "GitHub"
    defp format_provider_name("facebook"), do: "Facebook"
    defp format_provider_name("twitter"), do: "Twitter"
    defp format_provider_name("microsoft"), do: "Microsoft"
    defp format_provider_name(provider), do: String.capitalize(provider)

    defp format_changeset_errors(changeset) do
      Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
        Enum.reduce(opts, msg, fn {key, value}, acc ->
          String.replace(acc, "%{#{key}}", to_string(value))
        end)
      end)
      |> Enum.map_join("; ", fn {field, errors} ->
        "#{field}: #{Enum.join(errors, ", ")}"
      end)
    end

    defp format_ueberauth_failure(%Ueberauth.Failure{errors: errors}) do
      case errors do
        [] ->
          gettext("Authentication failed. Please try again.")

        [%{message: message} | _] when is_binary(message) ->
          gettext("Authentication failed: %{message}", message: message)

        _ ->
          gettext("Authentication failed. Please try again.")
      end
    end
  end
else
  # Fallback controller when Ueberauth is not loaded
  defmodule PhoenixKitWeb.Users.OAuth do
    @moduledoc """
    Fallback OAuth controller when Ueberauth dependencies are not installed.

    This controller provides user-friendly error messages when OAuth authentication
    is attempted but the required dependencies are not installed.

    To enable OAuth authentication, add the following dependencies to your mix.exs:

        {:ueberauth, "~> 0.10"},
        {:ueberauth_google, "~> 0.12"}   # For Google Sign-In

    Then configure the providers in your config.exs as described in the PhoenixKit documentation.
    """

    use PhoenixKitWeb, :controller

    alias PhoenixKit.Utils.Routes

    require Logger

    @doc """
    Handles OAuth request when Ueberauth is not available.
    """
    def request(conn, %{"provider" => provider}) do
      Logger.warning(
        "PhoenixKit OAuth: Attempted to use #{provider} authentication but Ueberauth dependencies are not installed"
      )

      conn
      |> put_flash(
        :error,
        gettext(
          "OAuth authentication is not available. Please install the required dependencies (ueberauth, ueberauth_%{provider}) and configure your application. See PhoenixKit documentation for details.",
          provider: provider
        )
      )
      |> redirect(to: Routes.path("/users/log-in"))
    end

    @doc """
    Handles OAuth callback when Ueberauth is not available.
    """
    def callback(conn, _params) do
      Logger.error(
        "PhoenixKit OAuth: Received OAuth callback but Ueberauth dependencies are not installed"
      )

      conn
      |> put_flash(
        :error,
        gettext("OAuth authentication is not configured. Please contact your administrator.")
      )
      |> redirect(to: Routes.path("/users/log-in"))
    end
  end
end