Attesto.RefreshStore behaviour (Attesto v0.5.0)

Copy Markdown View Source

Storage seam for refresh tokens, with the atomic primitive that makes reuse detection possible.

Attesto.RefreshToken is pure rotation logic; this behaviour is where refresh tokens live and how they are consumed. Attesto.RefreshStore.ETS is a ready single-node implementation; a production host implements it over its database.

The consume/1 contract (load-bearing)

Refresh-token rotation (RFC 6749 §10.4, OAuth 2.0 Security BCP) requires detecting when an already-rotated (consumed) token is presented again: that means the token was captured, and the whole token family must be revoked. Detecting it reliably needs an atomic compare-and-set: consume/1 MUST, in one indivisible step, check whether the token is unconsumed and, if so, mark it consumed.

It returns:

  • {:ok, record} - the token existed and was unconsumed; it is now consumed. The caller issues the next token in the family.
  • {:reuse, record} - the token existed but was already consumed. The caller MUST revoke_family/1. The record carries the family_id to revoke.
  • :error - no such token.

A SQL implementation is UPDATE refresh_tokens SET consumed = true WHERE token_hash = $1 AND consumed = false RETURNING ...: zero rows updated with a row present means reuse. A non-atomic get-then-update would let two concurrent rotations both see "unconsumed" and both succeed, defeating detection.

Record shape

  • :token_hash - Attesto.Secret.hash/1 of the token (the key).
  • :family_id - groups all tokens descended from one authorization; revoked together on reuse.
  • :generation - 0 for the first token in a family, incremented each rotation. Diagnostic.
  • :data - the opaque context the host round-trips (subject, scope, client, optional DPoP thumbprint, host claims).
  • :expires_at - absolute expiry, unix seconds.
  • :consumed - whether the token has been rotated already.

Summary

Callbacks

Atomically mark the token consumed if it was not already. See the moduledoc for the required semantics and the three return values. This is the claim step, run only once a rotation has otherwise validated; it also closes the read-then-claim race (a concurrent rotation that claimed the token first surfaces here as {:reuse, record}).

Non-consuming read of the record for token_hash, or :error if absent. Used to validate a rotation (expiry, DPoP binding) and to detect a replayed already-consumed token BEFORE the atomic consume/1 claims it, so a recoverable validation failure does not burn the token.

Persist a new (unconsumed) refresh-token record.

Revoke a token family: remove every token in family_id AND mark the family revoked so a subsequent insert/1 for it is refused (sticky revocation; see insert/1). Idempotent - revoking an already-revoked or unknown family is a no-op :ok.

Types

entry()

@type entry() :: %{
  token_hash: token_hash(),
  family_id: family_id(),
  generation: non_neg_integer(),
  data: map(),
  expires_at: integer(),
  consumed: boolean()
}

family_id()

@type family_id() :: String.t()

token_hash()

@type token_hash() :: String.t()

Callbacks

consume(token_hash)

@callback consume(token_hash()) :: {:ok, entry()} | {:reuse, entry()} | :error

Atomically mark the token consumed if it was not already. See the moduledoc for the required semantics and the three return values. This is the claim step, run only once a rotation has otherwise validated; it also closes the read-then-claim race (a concurrent rotation that claimed the token first surfaces here as {:reuse, record}).

get(token_hash)

@callback get(token_hash()) :: {:ok, entry()} | :error

Non-consuming read of the record for token_hash, or :error if absent. Used to validate a rotation (expiry, DPoP binding) and to detect a replayed already-consumed token BEFORE the atomic consume/1 claims it, so a recoverable validation failure does not burn the token.

insert(entry)

@callback insert(entry()) :: :ok | {:error, :family_revoked}

Persist a new (unconsumed) refresh-token record.

Returns {:error, :family_revoked} if the record's family_id has been revoked (see revoke_family/1); the row MUST NOT be stored in that case. This closes a concurrency race: a rotation that wins the atomic consume/1 but whose successor insert/1 lands after a concurrent reuse revoked the family would otherwise leave a live successor in a revoked family. Revocation is therefore sticky - it rejects later inserts, not just the rows present at revoke time.

revoke_family(family_id)

@callback revoke_family(family_id()) :: :ok

Revoke a token family: remove every token in family_id AND mark the family revoked so a subsequent insert/1 for it is refused (sticky revocation; see insert/1). Idempotent - revoking an already-revoked or unknown family is a no-op :ok.