Skip to main content

lib/attesto_phoenix/store/ecto_client_id_metadata_cache.ex

defmodule AttestoPhoenix.ClientIdMetadata.Cache.Ecto do
  @moduledoc """
  Postgres-backed `AttestoPhoenix.ClientIdMetadata.Cache` for clustered
  deployments - CIMD (`draft-ietf-oauth-client-id-metadata-document-01`, IETF
  OAuth WG).

  CIMD lets a client identify itself with no prior registration by using an
  HTTPS URL as its `client_id`; the authorization server dereferences that URL
  and validates the returned document. Caching the validated document keeps
  every authorization request from reaching out to the network. The default
  per-node `AttestoPhoenix.ClientIdMetadata.Cache.ETS` would re-fetch on each
  node and offers no coherence; this store persists each entry so a document
  fetched on one node is served from every node and the outbound fetch fan-out
  is bounded under load. It is the cache default for exactly the same reason the
  code/refresh/nonce/replay/PAR stores default to Ecto.

  Only a *validated* document is ever written (the caller stores after
  `Attesto.ClientIdMetadata.validate_document/2` succeeds, with an `expires_at`
  derived from the response's freshness directives clamped to the configured
  bounds); the draft (ยง6) and RFC 9111 forbid caching errors or malformed
  documents, so this store never validates and never sees an unaccepted
  document.

  ## Behaviour callbacks

    * `get/1` resolves a live (unexpired) cached document WITHOUT consuming it.
      Expiry is re-checked on read (`expires_at > now`), so an unswept expired
      row is a `:miss`, never a stale hit.
    * `put/3` upserts the validated metadata and its expiry. A re-fetched
      document legitimately supersedes a stale one, so a conflicting `url`
      replaces the existing row's `metadata` and `expires_at` rather than
      failing - the freshest fetch wins.

  Expired rows are reclaimed by `AttestoPhoenix.Store.Sweeper`, but sweeping is
  housekeeping only: `get/1` already refuses an expired row.

  The repository module is supplied by the host application (`:repo` under the
  `:attesto_phoenix` app) and read at call time; a cache with no backing
  repository fails closed rather than silently no-opping.
  """

  @behaviour AttestoPhoenix.ClientIdMetadata.Cache

  import Ecto.Query, only: [from: 2]

  alias AttestoPhoenix.ClientIdMetadata.Cache
  alias AttestoPhoenix.Schema.ClientIdMetadata

  @app :attesto_phoenix

  @doc """
  Resolves a live cached document for a CIMD `client_id` URL.

  Returns `{:ok, metadata}` when a row exists and has not expired, or `:miss`
  when it is absent or expired. `metadata` is round-tripped through `jsonb`, so
  the caller reads back the same string-keyed map it stored. Freshness is
  enforced on read (`expires_at > now`); resolution does not consume the entry,
  so it serves every request until it expires or is replaced.
  """
  @impl Cache
  @spec get(String.t()) :: {:ok, map()} | :miss
  def get(url) when is_binary(url) do
    now = DateTime.utc_now() |> DateTime.truncate(:second)

    query =
      from c in ClientIdMetadata,
        where: c.url == ^url and c.expires_at > ^now,
        select: c.metadata

    case repo().one(query) do
      nil -> :miss
      metadata -> {:ok, metadata}
    end
  end

  @doc """
  Caches validated `metadata` for a CIMD `client_id` URL until `expires_at`.

  `metadata` is the validated, string-keyed map; it is round-tripped through
  `jsonb`. The `url` is the primary key, so a re-fetch upserts the single row:
  on conflict the stored `metadata` and `expires_at` are replaced with the
  freshly fetched values (the freshest accepted document wins), rather than
  raising or keeping a stale entry.
  """
  @impl Cache
  @spec put(String.t(), map(), DateTime.t()) :: :ok
  def put(url, metadata, %DateTime{} = expires_at) when is_binary(url) and is_map(metadata) do
    now = DateTime.utc_now() |> DateTime.truncate(:second)
    expires_at = DateTime.truncate(expires_at, :second)

    entry = %{url: url, metadata: metadata, expires_at: expires_at, inserted_at: now}

    %ClientIdMetadata{}
    |> ClientIdMetadata.put_changeset(entry)
    |> repo().insert!(
      on_conflict: [set: [metadata: metadata, expires_at: expires_at]],
      conflict_target: :url
    )

    :ok
  end

  defp repo do
    case Application.get_env(@app, :repo) do
      nil ->
        # Fail closed: a cache with no backing repository cannot share a
        # validated document across nodes, so refuse rather than silently no-op.
        raise ArgumentError,
              "AttestoPhoenix: no :repo configured. Set `config #{inspect(@app)}, repo: MyApp.Repo`"

      repo ->
        repo
    end
  end
end