lib/immich/api/oauth.ex

defmodule Immich.API.OAuth do
  @moduledoc """
  OAuth authentication flow for the Immich API.

  This module provides the two-step authorization code flow used by Immich:

  1. `authorize/2` starts the flow and returns a remote login URL.
  2. `callback/3` completes the flow after the browser redirect and builds
     an `Immich.API.Session`.

  The module is intentionally stateless. Callers are responsible for storing
  the returned OAuth context between steps.

  ## Shared options

  Most functions accept `shared_opts`:

  - `:base_url` (required) - Immich server URL (for example
    `"https://demo.immich.app"`)
  - `:client` (optional) - HTTP client module implementing
    `Immich.API.Client.Behaviour` (defaults to `Immich.API.Client`)

  ## Usage

  ```elixir
  alias Immich.Api.OAuth

  opts = [base_url: "https://demo.immich.app"]

  # Step 1: Start flow
  {:ok, login_url, oauth_context} =
    OAuth.authorize("http://localhost:55555/oauth-callback", opts)

  # Open login_url in browser, then receive callback URL in your app.
  callback_url = "http://localhost:55555/oauth-callback?code=...&state=..."

  # Step 2: Complete flow
  {:ok, session} = OAuth.callback(callback_url, oauth_context, opts)

  # session.access_token can now be used for authenticated API calls.
  ```

  ## Error handling

  Functions return `{:error, reason}` tuples for API-level failures, including
  transport/authentication errors from the configured client and unexpected
  successful payloads (`{:unexpected_response, payload}`).
  """

  alias Immich.API.Client
  alias Immich.API.PKCE
  alias Immich.API.Session

  @typedoc "Immich server base URL without the trailing /api (for example, https://demo.immich.app)"
  @type base_url :: String.t()

  @typedoc "Redirect URI sent to the authorize endpoint."
  @type redirect_uri :: String.t()

  @typedoc "Callback URI received after OAuth login."
  @type callback_uri :: String.t()

  @typedoc "Login URL returned by authorize."
  @type login_url :: String.t()

  @typedoc "Error tuple returned by OAuth functions."
  @type oauth_error :: Client.api_error() | {:unexpected_response, term()}

  @typedoc "Shared options for OAuth requests."
  @type shared_opts :: [
          base_url: base_url(),
          client: Client.t()
        ]

  @type oauth_context :: %{
          pkce: PKCE.t()
        }

  @doc """
  Starts OAuth login

  This function starts the OAuth login flow and returns the remote login URL
  authorization context that needs to be passed to callback/3.
  """
  @spec authorize(redirect_uri(), shared_opts()) ::
          {:ok, login_url(), oauth_context()} | {:error, oauth_error()}
  def authorize(redirect_uri, opts) do
    pkce = PKCE.new()

    request = %{
      "redirectUri" => redirect_uri,
      "codeChallenge" => pkce.code_challenge,
      "state" => pkce.state
    }

    case api_post("/api/oauth/authorize", request, opts) do
      {:ok, %{"url" => url}} ->
        {:ok, url, %{pkce: pkce}}

      {:ok, payload} ->
        {:error, {:unexpected_response, payload}}

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

  @doc """
  Completes OAuth callback exchange and returns an authenticated session.

  This function should be called after the user has been redirected back to
  your application from the OAuth provider.
  """
  @spec callback(callback_uri(), oauth_context(), shared_opts()) ::
          {:ok, Session.t()} | {:error, oauth_error()}
  def callback(callback_url, oauth_context, opts) do
    pkce = oauth_context.pkce

    request = %{
      "url" => callback_url,
      "state" => pkce.state,
      "codeVerifier" => pkce.code_verifier
    }

    case api_post("/api/oauth/callback", request, opts) do
      {:ok, %{"accessToken" => access_token}} ->
        session = build_session(access_token, opts)
        {:ok, session}

      {:ok, payload} ->
        {:error, {:unexpected_response, payload}}

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

  defp api_post(path, request, opts) do
    client = client(opts)

    path
    |> build_url(opts)
    |> client.post(request, [])
  end

  defp client(opts) do
    Keyword.get(opts, :client, Immich.API.Client)
  end

  defp build_url(path, opts) do
    opts
    |> Access.fetch!(:base_url)
    |> URI.merge(path)
  end

  defp build_session(access_token, opts) do
    base_url = Access.fetch!(opts, :base_url)
    %Session{base_url: base_url, access_token: access_token}
  end
end