Attesto.RefreshToken (Attesto v0.5.0)

Copy Markdown View Source

Refresh-token issuance and rotation with reuse detection (RFC 6749 §6 / §10.4, OAuth 2.0 Security BCP).

Each refresh token is single-use: presenting it (rotate/3) consumes it and mints a successor in the same family. If a token that has already been rotated is presented again, that is a captured-token signal, and the entire family is revoked so neither the attacker nor the victim can continue, forcing a fresh authorization.

This module is pure logic over a Attesto.RefreshStore; the store provides the atomic consume/1 on which reuse detection depends (see that behaviour's moduledoc). Only the hash of each token is stored.

DPoP binding

A refresh token can be bound to a DPoP key (its issuing context carries a :dpop_jkt). Rotation then requires the caller to present the matching :dpop_jkt (the thumbprint of the key in the token-request's DPoP proof); an unbound token must be rotated without one. The binding matrix mirrors Attesto.Token and Attesto.AuthorizationCode.

Summary

Functions

Issue a refresh token for context and persist it via store.

Rotate a presented refresh token: consume it and mint its successor.

Types

context()

@type context() :: %{
  :subject => String.t(),
  optional(:scope) => [String.t()],
  optional(:client_id) => String.t(),
  optional(:dpop_jkt) => String.t() | nil,
  optional(:claims) => map()
}

issue_error()

@type issue_error() ::
  :invalid_subject
  | :invalid_scope
  | :invalid_dpop_jkt
  | :invalid_claims
  | :family_revoked

issued()

@type issued() :: %{
  token: String.t(),
  family_id: String.t(),
  generation: non_neg_integer()
}

rotate_error()

@type rotate_error() ::
  :invalid_grant
  | :reuse_detected
  | :expired
  | :client_required
  | :client_mismatch
  | :invalid_scope
  | :dpop_proof_required
  | :dpop_proof_unexpected
  | :dpop_binding_mismatch

rotated()

@type rotated() :: %{
  token: String.t(),
  family_id: String.t(),
  generation: non_neg_integer(),
  context: map()
}

Functions

issue(store, context, opts \\ [])

@spec issue(module(), context(), keyword()) ::
  {:ok, issued()} | {:error, issue_error()}

Issue a refresh token for context and persist it via store.

context MUST carry :subject; optional :scope (list, default []), :client_id, :dpop_jkt (binds the token to a DPoP key), and :claims (opaque host context).

Options: :ttl (seconds, default 14 days), :now, and - when continuing a family during rotation - :family_id and :generation (callers issuing a first token omit both: a fresh family is started at generation 0).

Returns {:ok, %{token, family_id, generation}} with the plaintext token to hand the client (only its hash is stored), or {:error, reason} on malformed context. Returns {:error, :family_revoked} only when continuing an explicit :family_id that has been revoked (a fresh first issue starts a new family and never hits this).

rotate(store, presented_token, opts \\ [])

@spec rotate(module(), String.t(), keyword()) ::
  {:ok, rotated()} | {:error, rotate_error()}

Rotate a presented refresh token: consume it and mint its successor.

On success returns {:ok, %{token, family_id, generation, context}} where token is the new refresh token, generation is the successor's generation, and context is the grant context to mint the next access token from.

If the presented token was already rotated, the whole family is revoked and {:error, :reuse_detected} is returned. Other failures: :invalid_grant (unknown token), :expired, :client_mismatch, :invalid_scope, and the DPoP binding errors.

Options:

  • :now - clock override.
  • :dpop_jkt - the presented proof's thumbprint (for DPoP-bound tokens).
  • :client_id - the authenticated presenting client. When the token was issued with a client_id, rotation is fail-closed: it MUST present a matching one (:client_required if absent, :client_mismatch if wrong), closing token substitution across clients (RFC 6749 §6 / §10.4). Pass allow_missing_client_id?: true to opt out. A token issued without a client binding skips the check.
  • :scope - a requested scope list. MUST be a subset of the token's granted scope; the successor then carries the narrowed scope. A request for any scope not granted is :invalid_scope (no escalation). Omitted, the successor carries the full granted scope.
  • :ttl - lifetime for the successor.

Recoverable failures (:client_mismatch, :invalid_scope, :expired, the DPoP binding errors) are checked on a non-consuming read before the token is claimed, so they do NOT burn the token: a client that, say, retries with a corrected DPoP proof succeeds rather than tripping reuse detection. Only a genuine replay of an already-consumed token (or a concurrent double-claim) revokes the family.