# SPDX-FileCopyrightText: 2026 ash_authentication_oauth2_server 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