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 per-node
AttestoPhoenix.ClientIdMetadata.Cache.ETS opt-out 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/1resolves 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/3upserts the validated metadata and its expiry. A re-fetched document legitimately supersedes a stale one, so a conflictingurlreplaces the existing row'smetadataandexpires_atrather 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.
Summary
Functions
@spec delete(String.t()) :: :ok
Evicts the cached document for url, if any.
Cluster-wide by construction: the row is the shared cache, so deleting it
evicts on every node at once. That is the point of having this on the DEFAULT
backend rather than only on the per-node ETS one — a rotated or compromised
CIMD document is otherwise honored until expires_at, up to 24 hours under
the default :cache_ttl_bounds, on every node independently.
@spec delete_all() :: :ok
Evicts every cached document, cluster-wide. See delete/1.
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.
@spec put(String.t(), map(), DateTime.t()) :: :ok
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.