lib/immich/api/pkce.ex

defmodule Immich.API.PKCE do
  @moduledoc """
  Generates PKCE parameters for the Immich OAuth flow.
  """

  @enforce_keys [:code_verifier, :code_challenge, :state]
  defstruct @enforce_keys

  @type t :: %__MODULE__{
          code_verifier: String.t(),
          code_challenge: String.t(),
          state: String.t()
        }

  @alphabet ~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
  @alphabet_tuple List.to_tuple(@alphabet)
  @alphabet_length tuple_size(@alphabet_tuple)
  @default_verifier_length 64
  @default_state_length 32

  @doc """
  Generates a fresh PKCE triple.

  Options:
    * `:verifier_length` - target length for the code verifier (default: #{@default_verifier_length})
    * `:state_length` - target length for the state token (default: #{@default_state_length})
    * `:rand_fun` - function that receives a positive integer and returns that many random bytes
  """
  @spec new(keyword()) :: t()
  def new(opts \\ []) do
    rand_fun = Keyword.get(opts, :rand_fun, &:crypto.strong_rand_bytes/1)

    code_verifier =
      opts
      |> Keyword.get(:verifier_length, @default_verifier_length)
      |> random_string(rand_fun)

    state =
      opts
      |> Keyword.get(:state_length, @default_state_length)
      |> random_string(rand_fun)

    %__MODULE__{
      code_verifier: code_verifier,
      code_challenge: challenge(code_verifier),
      state: state
    }
  end

  @doc """
  Generates the PKCE code challenge by applying the `S256` transformation
  (`base64url(SHA256(verifier))`) recommended by RFC 7636 §4.2 for public
  clients. The same guidance is echoed by the OWASP OAuth 2.0 cheat sheet, so
  this helper always uses SHA-256 plus URL-safe base64 encoding.
  """
  @spec challenge(String.t()) :: String.t()
  def challenge(code_verifier) when is_binary(code_verifier) do
    :crypto.hash(:sha256, code_verifier)
    |> Base.url_encode64(padding: false)
  end

  defp random_string(length, _rand_fun) when length <= 0 do
    raise ArgumentError, "length must be positive"
  end

  defp random_string(length, rand_fun) do
    rand_fun.(length)
    |> :binary.bin_to_list()
    |> Enum.map(fn byte -> alphabet_char(rem(byte, @alphabet_length)) end)
    |> to_string()
  end

  defp alphabet_char(index), do: elem(@alphabet_tuple, index)
end