lib/openagents/device_authorizations.ex

main at 58e6347eeb72 · 9 KB

defmodule OpenAgents.DeviceAuthorizations do
  @moduledoc """
  Short-lived, one-claim browser authorization for the OpenAgents CLI.

  A device authorization may *request* any scope the credential model admits,
  including the operator-only `deployments:promote`. Requesting is not
  holding: the approver sees the requested scopes on the approval page, and a
  request for a privileged scope is refused unless the approving account is a
  current operator. That is what lets an operator bootstrap a release CLI
  without issuing the credential from a settings page.
  """

  import Ecto.Query

  alias OpenAgents.Accounts
  alias OpenAgents.Accounts.User
  alias OpenAgents.ApiTokens
  alias OpenAgents.DeviceAuthorizations.DeviceAuthorization
  alias OpenAgents.Repo

  @ttl_seconds 600
  @interval_seconds 5
  @maximum_create_attempts 3
  @maximum_device_name_length 80

  # The alphabet `random_user_code/0` draws from. `I`, `O`, `0`, and `1` are
  # absent on purpose: a code is read off one screen and typed into another.
  @user_code_alphabet "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
  @user_code_pattern ~r/\A[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}\z/

  @doc """
  Casts a caller-supplied user code to the exact shape this module mints.

  `get_pending_by_user_code/1` can be handed anything and answers `nil`, which
  is the right answer for a lookup. It is the wrong answer for anything that
  puts the value back into a URL, a page, or a session, because "no such
  authorization" and "not a code at all" are then indistinguishable.

  This is that second question, and it is asked wherever a code the browser
  sent goes on to build something. The pattern is anchored with `\\A` and
  `\\z` rather than `^` and `$`, so a trailing newline cannot smuggle a second
  line past it, and it admits only the thirty-two characters and one hyphen
  above — never a path, a host, a scheme, a quote, or a tag.

  Trimming and upcasing come first, so a code retyped in lowercase is the same
  code. A letter this alphabet excludes is not silently corrected to one it
  admits: `i` upcases to `I`, which is not in the set, and is refused.
  """
  @spec cast_user_code(term()) :: {:ok, String.t()} | :error
  def cast_user_code(code) when is_binary(code) do
    normalized = normalize_user_code(code)

    if Regex.match?(@user_code_pattern, normalized), do: {:ok, normalized}, else: :error
  end

  def cast_user_code(_code), do: :error

  def create(scopes \\ ApiTokens.default_scopes(), device_name \\ nil)

  def create(scopes, device_name) when is_list(scopes),
    do: insert_authorization(scopes, normalize_device_name(device_name), @maximum_create_attempts)

  def create(_scopes, _device_name), do: {:error, :invalid_scopes}

  def get_pending_by_user_code(user_code) when is_binary(user_code) do
    now = DateTime.utc_now()

    Repo.one(
      from authorization in DeviceAuthorization,
        where:
          authorization.user_code_digest == ^digest(normalize_user_code(user_code)) and
            authorization.state == "pending" and authorization.expires_at > ^now
    )
  end

  def get_pending_by_user_code(_user_code), do: nil

  def approve(user_code, %User{status: "active", id: user_id} = user) do
    transition(user_code, "approved", user_id, user)
  end

  def approve(_user_code, %User{}), do: {:error, :access_denied}

  def deny(user_code, %User{status: "active", id: user_id} = user) do
    transition(user_code, "denied", user_id, user)
  end

  def deny(_user_code, %User{}), do: {:error, :access_denied}

  def poll(device_code) when is_binary(device_code) and byte_size(device_code) < 256 do
    Repo.transaction(fn -> poll_locked(device_code, DateTime.utc_now()) end)
    |> case do
      {:ok, result} -> result
      {:error, reason} -> {:error, reason}
    end
  end

  def poll(_device_code), do: {:error, :access_denied}

  defp insert_authorization(_scopes, _device_name, 0),
    do: {:error, :authorization_unavailable}

  defp insert_authorization(scopes, device_name, attempts_left) do
    device_code = random_url_token(32)
    user_code = random_user_code()
    expires_at = DateTime.add(DateTime.utc_now(), @ttl_seconds, :second)

    %DeviceAuthorization{}
    |> DeviceAuthorization.create_changeset(%{
      device_code_digest: digest(device_code),
      user_code_digest: digest(user_code),
      device_name: device_name,
      expires_at: expires_at,
      interval_seconds: @interval_seconds,
      scopes: scopes
    })
    |> Repo.insert()
    |> case do
      {:ok, authorization} ->
        {:ok, authorization, device_code, user_code}

      {:error, changeset} ->
        if Keyword.has_key?(changeset.errors, :device_code_digest) or
             Keyword.has_key?(changeset.errors, :user_code_digest),
           do: insert_authorization(scopes, device_name, attempts_left - 1),
           else: {:error, changeset}
    end
  end

  # A computer name is display metadata, not authority. Normalize it rather
  # than letting a hostname with a control character or excessive length stop
  # sign-in. HEEx escapes the remaining text when the approval page renders it.
  defp normalize_device_name(name) when is_binary(name) do
    name =
      name
      |> String.replace(~r/[\x00-\x1F\x7F]/u, " ")
      |> String.trim()
      |> String.slice(0, @maximum_device_name_length)

    if name == "", do: nil, else: name
  end

  defp normalize_device_name(_name), do: nil

  defp transition(user_code, next_state, user_id, user) do
    now = DateTime.utc_now()

    result =
      Repo.transaction(fn ->
        authorization =
          Repo.one(
            from authorization in DeviceAuthorization,
              where:
                authorization.user_code_digest == ^digest(normalize_user_code(user_code)) and
                  authorization.state == "pending" and authorization.expires_at > ^now,
              lock: "FOR UPDATE"
          )

        case authorization do
          %DeviceAuthorization{scopes: scopes} = authorization ->
            if next_state == "approved" and ApiTokens.privileged?(scopes) and
                 not Accounts.admin?(user) do
              Repo.rollback(:access_denied)
            end

            attrs =
              if next_state == "approved",
                do: [state: "approved", user_id: user_id, approved_at: now],
                else: [state: "denied", user_id: user_id, denied_at: now]

            authorization
            |> Ecto.Changeset.change(attrs)
            |> Repo.update!()

          nil ->
            Repo.rollback(:access_denied)
        end
      end)

    case result do
      {:ok, authorization} -> {:ok, authorization}
      {:error, _reason} -> {:error, :access_denied}
    end
  end

  defp poll_locked(device_code, now) do
    authorization =
      Repo.one(
        from authorization in DeviceAuthorization,
          where: authorization.device_code_digest == ^digest(device_code),
          lock: "FOR UPDATE"
      )

    case authorization do
      %DeviceAuthorization{expires_at: expires_at} = authorization ->
        if DateTime.compare(expires_at, now) != :gt,
          do: {:error, :access_denied},
          else: poll_by_state(authorization, now)

      _unavailable ->
        {:error, :access_denied}
    end
  end

  defp poll_by_state(%DeviceAuthorization{state: "pending"} = pending, now) do
    poll_pending(pending, now)
  end

  defp poll_by_state(%DeviceAuthorization{state: "approved", user_id: user_id} = approved, now) do
    claim(approved, Repo.get(User, user_id), now)
  end

  defp poll_by_state(_authorization, _now), do: {:error, :access_denied}

  defp poll_pending(%DeviceAuthorization{} = authorization, now) do
    elapsed =
      if authorization.last_polled_at,
        do: DateTime.diff(now, authorization.last_polled_at, :second),
        else: authorization.interval_seconds

    authorization
    |> Ecto.Changeset.change(
      last_polled_at: now,
      poll_count: authorization.poll_count + 1
    )
    |> Repo.update!()

    if elapsed < authorization.interval_seconds,
      do: {:error, :slow_down},
      else: {:error, :authorization_pending}
  end

  defp claim(
         %DeviceAuthorization{scopes: scopes} = authorization,
         %User{status: "active"} = user,
         now
       ) do
    case ApiTokens.create(user, %{
           name: "OpenAgents CLI",
           scopes: scopes,
           lifetime_days: min(30, ApiTokens.maximum_lifetime_days(scopes))
         }) do
      {:ok, api_token, plaintext} ->
        authorization
        |> Ecto.Changeset.change(
          state: "claimed",
          api_token_id: api_token.id,
          claimed_at: now
        )
        |> Repo.update!()

        {:ok, plaintext, api_token}

      {:error, _reason} ->
        Repo.rollback(:authorization_unavailable)
    end
  end

  defp claim(_authorization, _user, _now), do: {:error, :access_denied}

  defp normalize_user_code(code) do
    code
    |> String.trim()
    |> String.upcase()
  end

  defp random_user_code do
    alphabet = @user_code_alphabet

    8
    |> :crypto.strong_rand_bytes()
    |> :binary.bin_to_list()
    |> Enum.map_join(fn byte -> String.at(alphabet, rem(byte, byte_size(alphabet))) end)
    |> then(fn <<left::binary-size(4), right::binary-size(4)>> -> left <> "-" <> right end)
  end

  defp random_url_token(bytes) do
    bytes
    |> :crypto.strong_rand_bytes()
    |> Base.url_encode64(padding: false)
  end

  defp digest(value), do: :crypto.hash(:sha256, value)
end