lib/ueberauth/strategy/workos.ex

defmodule Ueberauth.Strategy.WorkOS do
  @moduledoc """
  Implementation of an Ueberauth Strategy for WorkOS Single Sign-On

  ## Configuration

  This provider supports the following configuration:

    * `api_key`: (**Required**) WorkOS API key, which also acts as the OAuth client secret. This key
      is environment-specific and may be supplied using runtime configuration.

    * `client_id`: (**Required**) OAuth client ID obtained from WorkOS. This ID is
      environment-specific and may be supplied using runtime configuration.

    * `callback_url`: Redirect URI to send users for the callback phase. This URL
      must be allowed in the WorkOS configuration for the environment matching the Client ID.
      Defaults to a callback URL calculated using the endpoint host and provider name.

  Example configuration:

      config :ueberauth, Ueberauth,
        providers: [
          workos: {Ueberauth.Strategy.WorkOS, [
            api_key: System.fetch_env!("WORKOS_API_KEY"),
            client_id: System.fetch_env!("WORKOS_CLIENT_ID")
          ]}
        ]

  Alternatively, you may configure the strategy module directly:

      config :ueberauth, Ueberauth.Strategy.WorkOS,
        api_key: System.fetch_env!("WORKOS_API_KEY"),
        client_id: System.fetch_env!("WORKOS_CLIENT_ID")

  ## Connection Selector

  In addition to the configuration mentioned above, the request phase also accepts several params
  allowing the client to specify details of the login process. One of these is the **Connection
  Selector**. The WorkOS documentation states:

  > To indicate the connection to use for authentication, use one of the following connection
  > selectors: connection, organization, or provider.
  >
  > These connection selectors are mutually exclusive, and exactly one must be provided.

  Therefore, the request phase must include exactly one of `connection`, `organization`, or
  `provider` in the incoming params. These may be provided directly by the client, or inserted
  before Ueberauth runs (before `plug Ueberauth`) by a custom plug. If absent, the request will
  fail immediately.

  ## Additional Params

  WorkOS also provides the ability to give "hints" about the domain or login. These hints may also
  be provided by the client or another plug using connection params:

    * `domain_hint`: According to WorkOS: _Can be used to pre-fill the domain field when initiating
      authentication with Microsoft OAuth, or with a `GoogleSAML` connection type._

    * `login_hint`: According to WorkOS: _Can be used to pre-fill the username/email address field
      of the IdP sign-in page for the user, if you know their username ahead of time._

  If you use an email address to determine the connection selector, then it is advisable to use the
  same email address as the `login_hint`.
  """
  use Ueberauth.Strategy,
    ignores_csrf_attack: true

  alias Ueberauth.Auth.Credentials
  alias Ueberauth.Auth.Extra
  alias Ueberauth.Auth.Info
  alias Ueberauth.Strategy.WorkOS.OAuth

  #
  # Plug Callbacks
  #

  @doc false
  @impl Ueberauth.Strategy
  def handle_request!(conn) do
    params =
      []
      |> with_connection_selector(conn)
      |> with_param(:domain_hint, conn)
      |> with_param(:login_hint, conn)
      |> with_state_param(conn)

    opts = oauth_client_options_from_conn(conn)
    redirect!(conn, OAuth.authorize_url!(params, opts))
  end

  @doc false
  @impl Ueberauth.Strategy
  def handle_callback!(%Plug.Conn{params: %{"code" => code}} = conn) do
    params = [code: code]

    conn
    |> oauth_client_options_from_conn()
    |> OAuth.client()
    |> OAuth2.Client.get_token(params)
    |> case do
      {:ok,
       %OAuth2.Client{token: %OAuth2.AccessToken{other_params: %{"profile" => profile}} = token}} ->
        conn
        |> put_private(:workos_profile, profile)
        |> put_private(:workos_token, token)

      {:error, %OAuth2.Response{body: %{"error" => error, "error_description" => description}}} ->
        {:error, {error, description}}

      {:error, %OAuth2.Error{reason: reason}} ->
        {:error, {"error", to_string(reason)}}
    end
  end

  def handle_callback!(%Plug.Conn{params: %{"error" => error}} = conn) do
    set_errors!(conn, [error("auth_failed", error)])
  end

  def handle_callback!(conn) do
    set_errors!(conn, [error("missing_code", "No code received")])
  end

  @doc false
  @impl Ueberauth.Strategy
  def handle_cleanup!(conn) do
    conn
    |> put_private(:workos_profile, nil)
    |> put_private(:workos_token, nil)
  end

  #
  # Data Processing Callbacks
  #

  @doc false
  @impl Ueberauth.Strategy
  def uid(conn), do: conn.private[:workos_profile]["id"]

  @doc false
  @impl Ueberauth.Strategy
  def credentials(conn) do
    expiration = DateTime.utc_now() |> DateTime.add(10 * 60, :second) |> DateTime.to_unix()

    %Credentials{
      expires: true,
      expires_at: expiration,
      token: conn.private[:workos_token].access_token,
      token_type: "access_token"
    }
  end

  @doc false
  @impl Ueberauth.Strategy
  def extra(conn) do
    %Extra{
      raw_info: conn.private[:workos_profile]
    }
  end

  @doc false
  @impl Ueberauth.Strategy
  def info(conn) do
    %Info{
      email: conn.private[:workos_profile]["email"],
      first_name: conn.private[:workos_profile]["first_name"],
      last_name: conn.private[:workos_profile]["last_name"]
    }
  end

  #
  # Helpers
  #

  @spec with_connection_selector(keyword, Plug.Conn.t()) :: keyword
  defp with_connection_selector(params, conn) do
    case conn.params do
      %{"connection" => connection_id} -> Keyword.put(params, :connection, connection_id)
      %{"organization" => org_id} -> Keyword.put(params, :organization, org_id)
      %{"provider" => provider} -> Keyword.put(params, :provider, provider)
      _else -> raise "Missing WorkOS connection, organization, or provider"
    end
  end

  @spec with_param(keyword, atom, Plug.Conn.t()) :: keyword
  defp with_param(params, key, conn) do
    if value = conn.params[to_string(key)], do: Keyword.put(params, key, value), else: params
  end

  @spec oauth_client_options_from_conn(Plug.Conn.t()) :: keyword
  defp oauth_client_options_from_conn(conn) do
    base_options = [redirect_uri: callback_url(conn)]
    request_options = conn.private[:ueberauth_request_options].options

    case {request_options[:client_id], request_options[:api_key]} do
      {nil, _} -> base_options
      {_, nil} -> base_options
      {id, secret} -> [client_id: id, api_key: secret] ++ base_options
    end
  end
end