Skip to main content

lib/yahoo_finance_ex/session.ex

defmodule YahooFinanceEx.Session do
  @moduledoc """
  Holds Yahoo Finance auth state (cookie + CSRF crumb) for the running app.

  Yahoo's public API gates calls behind a per-session cookie and a crumb
  token that must be present on every request. This GenServer owns both,
  refreshes them when needed, and exposes a lookup function callers use
  before each HTTP call.

  Two strategies for obtaining a valid (cookie, crumb) pair are tried in
  order before giving up. Both rely on `Req` and are stubbable in tests
  via `Req.Test`.
  """

  use GenServer

  require Logger

  @session_ttl_seconds 60

  @user_agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " <>
                "(KHTML, like Gecko) Chrome/120.0.0.0"

  @cookie_url "https://fc.yahoo.com"
  @crumb_url_query1 "https://query1.finance.yahoo.com/v1/test/getcrumb"
  @crumb_url_query2 "https://query2.finance.yahoo.com/v1/test/getcrumb"

  defmodule Credentials do
    @moduledoc false
    defstruct [:cookie, :crumb, :base_url, :fetched_at]
  end

  ## Public API

  @doc """
  Starts the session GenServer. Started under the package's supervisor at
  app boot; not typically called directly.
  """
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
  end

  @doc """
  Returns `{:ok, %Credentials{}}` with a fresh-enough session, refreshing
  if the cached credentials are missing or expired.
  """
  @spec credentials(GenServer.server()) :: {:ok, Credentials.t()} | {:error, term()}
  def credentials(server \\ __MODULE__) do
    GenServer.call(server, :credentials, 15_000)
  end

  @doc """
  Marks the session as invalid so the next `credentials/0` call re-authenticates.
  Called after a 401 or auth-error response.
  """
  def invalidate(server \\ __MODULE__) do
    GenServer.cast(server, :invalidate)
  end

  ## GenServer

  @impl true
  def init(_opts) do
    {:ok, %{credentials: nil}}
  end

  @impl true
  def handle_call(:credentials, _from, state) do
    case state.credentials do
      %Credentials{} = creds ->
        if fresh?(creds) do
          {:reply, {:ok, creds}, state}
        else
          authenticate_and_reply(state)
        end

      nil ->
        authenticate_and_reply(state)
    end
  end

  @impl true
  def handle_cast(:invalidate, state) do
    {:noreply, %{state | credentials: nil}}
  end

  defp authenticate_and_reply(state) do
    case authenticate() do
      {:ok, %Credentials{} = creds} ->
        {:reply, {:ok, creds}, %{state | credentials: creds}}

      {:error, reason} = err ->
        {:reply, err, state}
        |> tap(fn _ -> Logger.warning("YahooFinanceEx auth failed: #{inspect(reason)}") end)
    end
  end

  ## Authentication

  defp authenticate do
    strategies = [
      {&fetch_fc_cookie/0, @crumb_url_query1, "https://query1.finance.yahoo.com"},
      {&fetch_fc_cookie/0, @crumb_url_query2, "https://query2.finance.yahoo.com"}
    ]

    Enum.reduce_while(strategies, {:error, :all_strategies_failed}, fn {get_cookie, crumb_url,
                                                                        base_url},
                                                                       acc ->
      case try_strategy(get_cookie, crumb_url, base_url) do
        {:ok, _creds} = ok -> {:halt, ok}
        {:error, _reason} -> {:cont, acc}
      end
    end)
  end

  defp try_strategy(get_cookie, crumb_url, base_url) do
    with {:ok, cookie} <- get_cookie.(),
         {:ok, crumb} <- fetch_crumb(cookie, crumb_url),
         true <- valid_crumb?(crumb) do
      {:ok,
       %Credentials{
         cookie: cookie,
         crumb: crumb,
         base_url: base_url,
         fetched_at: System.monotonic_time(:second)
       }}
    else
      false -> {:error, :invalid_crumb}
      {:error, _} = err -> err
    end
  end

  defp fetch_fc_cookie do
    case YahooFinanceEx.HTTP.get(@cookie_url, headers: request_headers(), receive_timeout: 10_000) do
      {:ok, %Req.Response{headers: headers}} ->
        case extract_cookie(headers) do
          nil -> {:error, :no_cookie}
          cookie -> {:ok, cookie}
        end

      {:error, reason} ->
        {:error, {:cookie_request_failed, reason}}
    end
  end

  defp fetch_crumb(cookie, crumb_url) do
    case YahooFinanceEx.HTTP.get(crumb_url,
           headers: [{"cookie", cookie} | request_headers()],
           receive_timeout: 10_000
         ) do
      {:ok, %Req.Response{status: 200, body: body}} when is_binary(body) ->
        {:ok, String.trim(body)}

      {:ok, %Req.Response{status: status}} ->
        {:error, {:crumb_request_status, status}}

      {:error, reason} ->
        {:error, {:crumb_request_failed, reason}}
    end
  end

  defp extract_cookie(headers) do
    headers
    |> Enum.find_value(fn
      {name, value} when is_binary(name) ->
        if String.downcase(name) == "set-cookie", do: List.wrap(value)

      _ ->
        nil
    end)
    |> case do
      nil -> nil
      [] -> nil
      values -> values |> List.flatten() |> Enum.join("; ")
    end
  end

  defp valid_crumb?(crumb) when is_binary(crumb) do
    crumb != "" and not String.contains?(crumb, "<") and
      not String.contains?(crumb, "Unauthorized")
  end

  defp valid_crumb?(_), do: false

  defp fresh?(%Credentials{fetched_at: at}) do
    System.monotonic_time(:second) - at < @session_ttl_seconds
  end

  defp request_headers do
    [
      {"user-agent", @user_agent},
      {"accept", "text/html,*/*;q=0.8"},
      {"accept-language", "en-US,en;q=0.5"}
    ]
  end

  @doc false
  def user_agent, do: @user_agent
end