AttestoPhoenix.ClientIdMetadata.Cache.Ecto (AttestoPhoenix v2.4.0)

Copy Markdown View Source

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/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.

Summary

Functions

Evicts the cached document for url, if any.

Evicts every cached document, cluster-wide. See delete/1.

Resolves a live cached document for a CIMD client_id URL.

Caches validated metadata for a CIMD client_id URL until expires_at.

Functions

delete(url)

@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.

delete_all()

@spec delete_all() :: :ok

Evicts every cached document, cluster-wide. See delete/1.

get(url)

@spec get(String.t()) :: {:ok, map()} | :miss

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.

put(url, metadata, expires_at)

@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.