Skip to main content

lib/ash_authentication/oauth2_server/expunger.ex

# SPDX-FileCopyrightText: 2026 ash_authentication_oauth2_server contributors <https://github.com/ash-project/ash_authentication_oauth2_server/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshAuthentication.Oauth2Server.Expunger do
  @moduledoc """
  A `GenServer` which periodically removes expired OAuth2 authorization
  codes and refresh tokens.

  Scans all resources extended with either
  `AshAuthentication.Oauth2Server.AuthorizationCodeResource` or
  `AshAuthentication.Oauth2Server.RefreshTokenResource` and, on each
  resource's configured `expunge_interval` (hours), runs that
  resource's `:expunge_expired` destroy action.

  ## Multitenancy

  For resources using `strategy :context` multitenancy (and not
  `global? true`), Ash refuses tenant-less destroys, and a single
  tenant-less call could not reach rows that live in separate tenant
  schemas anyway. Pass `:list_tenants` (via the `Supervisor`) so the
  expunger fans out one destroy per tenant per resource:

      {AshAuthentication.Oauth2Server.Supervisor,
        otp_app: :my_app,
        list_tenants: {MyApp.Repo, :all_tenants, []}}

  `list_tenants` accepts a static list, a 0-arity function, or an
  `{module, function, args}` tuple. Default: `[nil]` — a single
  tenant-less pass, which is correct for apps with no multitenancy or
  with `strategy :attribute, global? true`.

  Started for you by `AshAuthentication.Oauth2Server.Supervisor` —
  you should not need to start this yourself.
  """

  use GenServer

  alias AshAuthentication.Oauth2Server.AuthorizationCodeResource
  alias AshAuthentication.Oauth2Server.RefreshTokenResource

  require Logger

  @doc false
  @spec start_link(keyword) :: GenServer.on_start()
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  @impl true
  def init(opts) do
    otp_app = Keyword.fetch!(opts, :otp_app)
    list_tenants = Keyword.get(opts, :list_tenants, [nil])

    resource_states =
      otp_app
      |> Spark.sparks(Ash.Resource)
      |> Stream.flat_map(&extension_for/1)
      |> Enum.reduce(%{}, fn {resource, extension}, acc ->
        state = schedule_timer(%{interval: nil, timer: nil}, resource, extension)
        Map.put(acc, resource, {extension, state})
      end)

    {:ok, %{otp_app: otp_app, resources: resource_states, list_tenants: list_tenants}}
  end

  @impl true
  def handle_info({:expunge, resource}, state) do
    {extension, resource_state} = Map.fetch!(state.resources, resource)

    for tenant <- resolve_tenants(state.list_tenants) do
      case extension.expunge_expired(resource, tenant: tenant) do
        :ok ->
          :ok

        {:error, reason} ->
          Logger.warning(
            "Oauth2Server.Expunger: failed to expunge #{inspect(resource)} " <>
              "(tenant=#{inspect(tenant)}): #{inspect(reason)}"
          )
      end
    end

    resource_state = schedule_timer(resource_state, resource, extension)

    {:noreply,
     %{state | resources: Map.put(state.resources, resource, {extension, resource_state})}}
  end

  def handle_info(_, state), do: {:noreply, state}

  defp resolve_tenants(list) when is_list(list), do: list
  defp resolve_tenants(fun) when is_function(fun, 0), do: fun.()
  defp resolve_tenants({mod, fun, args}), do: apply(mod, fun, args)

  # A resource may carry only one of the two extensions; produce the
  # matching tuple so we know which `expunge_expired/1` helper to call.
  defp extension_for(resource) do
    extensions = Spark.extensions(resource)

    cond do
      AuthorizationCodeResource in extensions ->
        [{resource, AuthorizationCodeResource}]

      RefreshTokenResource in extensions ->
        [{resource, RefreshTokenResource}]

      true ->
        []
    end
  end

  defp schedule_timer(state, resource, extension) do
    new_interval = interval_for(resource, extension)

    cond do
      state.interval == new_interval and not is_nil(state.timer) ->
        state

      is_nil(state.timer) ->
        {:ok, timer} = :timer.send_interval(new_interval, {:expunge, resource})
        %{state | interval: new_interval, timer: timer}

      true ->
        :timer.cancel(state.timer)
        {:ok, timer} = :timer.send_interval(new_interval, {:expunge, resource})
        %{state | interval: new_interval, timer: timer}
    end
  end

  # Interval is configured in hours; convert to milliseconds.
  defp interval_for(resource, extension) do
    info_module = Module.concat(extension, Info)
    info_module.oauth2_server_expunge_interval!(resource) * 60 * 60 * 1000
  end
end