lib/ash_authentication/token_resource/actions.ex

defmodule AshAuthentication.TokenResource.Actions do
  @moduledoc """
  The code interface for interacting with the token resource.
  """

  alias Ash.{Changeset, DataLayer, Query, Resource}
  alias AshAuthentication.{TokenResource, TokenResource.Info}

  import AshAuthentication.Utils

  @doc false
  @spec read_expired(Resource.t(), keyword) :: {:ok, [Resource.record()]} | {:error, any}
  def read_expired(resource, opts \\ []) do
    with :ok <- assert_resource_has_extension(resource, TokenResource),
         {:ok, api} <- Info.token_api(resource),
         {:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource) do
      resource
      |> Query.for_read(read_expired_action_name, opts)
      |> api.read()
    end
  end

  @doc """
  Remove all expired records.
  """
  @spec expunge_expired(Resource.t(), keyword) :: :ok | {:error, any}
  def expunge_expired(resource, opts \\ []) do
    case Info.token_expunge_expired_action_name(resource) do
      {:ok, expunge_expired_action_name} ->
        resource
        |> DataLayer.transaction(
          fn -> expunge_inside_transaction(resource, expunge_expired_action_name, opts) end,
          nil,
          %{
            type: :bulk_destroy,
            metadata: %{
              metadata: %{
                resource: resource,
                action: expunge_expired_action_name
              }
            }
          }
        )
        |> case do
          {:ok, :ok} -> :ok
          {:error, reason} -> {:error, reason}
        end

      :error ->
        {:error, "No configured expunge_expired_action_name"}
    end
  end

  @doc """
  Has the token been revoked?

  Similar to `jti_revoked?/2..3` except that it extracts the JTI from the token,
  rather than relying on it to be passed in.
  """
  @spec token_revoked?(Resource.t(), String.t(), keyword) :: boolean
  def token_revoked?(resource, token, opts \\ []) do
    with :ok <- assert_resource_has_extension(resource, TokenResource),
         {:ok, api} <- Info.token_api(resource),
         {:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do
      resource
      |> Query.for_read(is_revoked_action_name, %{"token" => token}, opts)
      |> api.read()
      |> case do
        {:ok, []} -> false
        {:ok, _} -> true
        _ -> false
      end
    end
  end

  @doc """
  Has the token been revoked?

  Similar to `token-revoked?/2..3` except that rather than extracting the JTI
  from the token, assumes that it's being passed in directly.
  """
  @spec jti_revoked?(Resource.t(), String.t(), keyword) :: boolean
  def jti_revoked?(resource, jti, opts \\ []) do
    with :ok <- assert_resource_has_extension(resource, TokenResource),
         {:ok, api} <- Info.token_api(resource),
         {:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do
      resource
      |> Query.for_read(is_revoked_action_name, %{"jti" => jti}, opts)
      |> api.read()
      |> case do
        {:ok, []} -> false
        {:ok, _} -> true
        _ -> false
      end
    end
  end

  @doc false
  @spec valid_jti?(Resource.t(), String.t(), keyword) :: boolean
  def valid_jti?(resource, jti, opts \\ []), do: !jti_revoked?(resource, jti, opts)

  @doc """
  Revoke a token.

  Extracts the JTI from the provided token and uses it to generate a revocationr
  record.
  """
  @spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
  def revoke(resource, token, opts \\ []) do
    with :ok <- assert_resource_has_extension(resource, TokenResource),
         {:ok, api} <- Info.token_api(resource),
         {:ok, revoke_token_action_name} <-
           Info.token_revocation_revoke_token_action_name(resource) do
      resource
      |> Changeset.for_create(
        revoke_token_action_name,
        %{"token" => token},
        Keyword.merge(opts, upsert?: true)
      )
      |> api.create()
      |> case do
        {:ok, _} -> :ok
        {:error, reason} -> {:error, reason}
      end
    end
  end

  @doc """
  Store a token.

  Stores a token for any purpose.
  """
  @spec store_token(Resource.t(), map, keyword) :: :ok | {:error, any}
  def store_token(resource, params, opts \\ []) do
    with :ok <- assert_resource_has_extension(resource, TokenResource),
         {:ok, api} <- Info.token_api(resource),
         {:ok, store_token_action_name} <- Info.token_store_token_action_name(resource) do
      resource
      |> Changeset.for_create(
        store_token_action_name,
        params,
        Keyword.merge(opts, upsert?: true)
      )
      |> api.create()
      |> case do
        {:ok, _} -> :ok
        {:error, reason} -> {:error, reason}
      end
    end
  end

  defp expunge_inside_transaction(resource, expunge_expired_action_name, opts) do
    with :ok <- assert_resource_has_extension(resource, TokenResource),
         {:ok, api} <- Info.token_api(resource),
         {:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource),
         query <- Query.for_read(resource, read_expired_action_name, opts),
         {:ok, expired} <- api.read(query) do
      Enum.reduce_while(expired, :ok, fn record, :ok ->
        record
        |> Changeset.for_destroy(expunge_expired_action_name, opts)
        |> api.destroy()
        |> case do
          :ok -> {:cont, :ok}
          {:error, reason} -> {:halt, {:error, reason}}
        end
      end)
    end
  end
end