Skip to main content

lib/break_glass/token_store.ex

defmodule BreakGlass.TokenStore do
  @moduledoc """
  GenServer that owns the `:break_glass_tokens` ETS table.

  Maintains at most one active break-glass session token in memory. A node
  restart automatically invalidates any active token — by design.

  ## ETS Table

  Table name: `:break_glass_tokens`
  Record format: `{:active_token, token :: binary(), inserted_at :: DateTime.t()}`
  Access: `:protected` with `read_concurrency: true`

  `lookup/1` reads ETS directly — no GenServer round-trip. All mutations go
  through `GenServer.call/2` so writes are serialised and race-free.

  ## Functions

  - `build_and_store/0` — generates a 32-byte cryptographically random token,
    stores it with a UTC timestamp, and returns the binary token
  - `lookup/1` — returns `{:ok, inserted_at}` if the token matches, or `:error`
  - `delete/1` — removes the token if it matches; returns `:ok` or `:error`
  - `clear/0` — unconditionally removes the active token record
  """

  use GenServer

  @table :break_glass_tokens

  # ---------------------------------------------------------------------------
  # Public API
  # ---------------------------------------------------------------------------

  @doc """
  Starts the TokenStore GenServer and registers it under `#{__MODULE__}`.
  """
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Generates a 32-byte cryptographically random token, stores it with a UTC
  insertion timestamp (replacing any previously stored token), and returns
  the raw binary token.
  """
  @spec build_and_store() :: binary()
  def build_and_store do
    GenServer.call(__MODULE__, :build_and_store)
  end

  @doc """
  Looks up `token` in the ETS table.

  Reads ETS directly — no GenServer round-trip.

  Returns `{:ok, inserted_at}` when `token` matches the stored token, or
  `:error` when there is no match or no token is stored.
  """
  @spec lookup(token :: binary()) :: {:ok, DateTime.t()} | :error
  def lookup(token) do
    case :ets.lookup(@table, :active_token) do
      [{:active_token, ^token, inserted_at}] -> {:ok, inserted_at}
      _ -> :error
    end
  end

  @doc """
  Deletes the stored token if it matches `token`.

  First checks the stored token against `token` via an ETS read, then
  delegates the actual removal to the GenServer to serialise the write.

  Returns `:ok` on a successful match-and-delete, or `:error` when the token
  does not match (or no token is stored).
  """
  @spec delete(token :: binary()) :: :ok | :error
  def delete(token) do
    case :ets.lookup(@table, :active_token) do
      [{:active_token, ^token, _inserted_at}] ->
        GenServer.call(__MODULE__, {:delete, token})

      _ ->
        :error
    end
  end

  @doc """
  Unconditionally removes the active token record from the ETS table.

  Returns `:ok`.
  """
  @spec clear() :: :ok
  def clear do
    GenServer.call(__MODULE__, :clear)
  end

  # ---------------------------------------------------------------------------
  # GenServer callbacks
  # ---------------------------------------------------------------------------

  @impl true
  def init(_opts) do
    :ets.new(@table, [:set, :protected, :named_table, {:read_concurrency, true}])
    {:ok, %{}}
  end

  @impl true
  def handle_call(:build_and_store, _from, state) do
    token = :crypto.strong_rand_bytes(32)
    inserted_at = DateTime.utc_now()
    :ets.insert(@table, {:active_token, token, inserted_at})
    {:reply, token, state}
  end

  @impl true
  def handle_call({:delete, token}, _from, state) do
    # Re-check under the GenServer lock to avoid TOCTOU
    result =
      case :ets.lookup(@table, :active_token) do
        [{:active_token, ^token, _}] ->
          :ets.delete(@table, :active_token)
          :ok

        _ ->
          :error
      end

    {:reply, result, state}
  end

  @impl true
  def handle_call(:clear, _from, state) do
    :ets.delete(@table, :active_token)
    {:reply, :ok, state}
  end
end