# `Attesto.RefreshToken`
[🔗](https://github.com/XukuLLC/attesto/blob/v2.0.1/lib/attesto/refresh_token.ex#L1)

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*. A short idempotency window
(10 seconds by default) lets the same client retry the just-consumed
parent after a lost response and receive the same successor. The parent
expiry remains authoritative: a cached successor is never returned after
the consumed parent expires, even when the persisted retry deadline is
later. Outside the intersection of those two fixed deadlines, or when the
retry does not match the original client, binding, and scope, a rotated
token 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 family-level `rotate/4` transaction on which reuse
detection depends (see that behaviour's moduledoc). Token records use
hashes, except that a
positive rotation-grace window necessarily retains the plaintext
successor until the window closes so the same credential can be returned
after a lost response. That retry state is credential-equivalent and MUST
be protected as described by
`c:Attesto.RefreshStore.rotate/4`.

## 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 - presenting a
proof for an unbound token is `:dpop_proof_unexpected`. That fail-closed
matrix mirrors `Attesto.Token`. It does NOT match
`Attesto.AuthorizationCode`, which permits an unbound code to be redeemed
alongside a token-request proof (the proof there binds the new access
token, not the code); rotation is deliberately the stricter of the two.

# `context`

```elixir
@type context() :: %{
  :subject =&gt; String.t(),
  optional(:scope) =&gt; [String.t()],
  optional(:resource) =&gt; [String.t()],
  optional(:acr) =&gt; String.t() | nil,
  optional(:auth_time) =&gt; non_neg_integer() | nil,
  optional(:client_id) =&gt; String.t(),
  optional(:dpop_jkt) =&gt; String.t() | nil,
  optional(:claims) =&gt; map()
}
```

Context for issuing an initial refresh token.

`:dpop_jkt` is optional because the host must classify the client before
issuing the token: RFC 9449 §5 requires DPoP-bound refresh tokens for public
clients and prohibits DPoP binding for confidential clients. When this
context is passed through
`Attesto.AuthorizationCode.issue_refresh_and_finalize/6` for a
DPoP-bound authorization grant, `nil` is the confidential-client choice and
the grant's exact JKT is the public-client choice; a different JKT is
rejected. Core does not determine the client class.

# `issue_error`

```elixir
@type issue_error() ::
  :invalid_subject
  | :invalid_scope
  | :invalid_resource
  | :invalid_client_id
  | :invalid_dpop_jkt
  | :invalid_claims
  | :invalid_acr
  | :invalid_auth_time
  | :family_revoked
```

# `issued`

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

# `rotate_error`

```elixir
@type rotate_error() ::
  :invalid_grant
  | :reuse_detected
  | :grant_revoked
  | :temporarily_unavailable
  | :expired
  | :client_required
  | :client_mismatch
  | :invalid_scope
  | :invalid_target
  | :dpop_proof_required
  | :dpop_proof_unexpected
  | :dpop_binding_mismatch
```

# `rotated`

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

# `issue`

```elixir
@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` (a lossless, string-keyed I-JSON object of host context; persisted
numbers are exact-range integers, not floats).

Options: `:ttl` (seconds, default 14 days) and `:now`. Public issuance
always starts a fresh family at generation 0; only `rotate/3` can create a
later generation, through the store's atomic rotation transaction.

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`. A store may very rarely return
`{:error, :family_revoked}` if a freshly generated family identifier
collides with one of its retained revocation markers.

# `rotate`

```elixir
@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, an immediate matching retry
returns the original successor within `:rotation_grace_seconds`;
otherwise the whole family is revoked and `{:error, :reuse_detected}` is
returned. Other failures include `:invalid_grant` (unknown token), `:expired`,
`:grant_revoked`, `:temporarily_unavailable`, `: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.
  * `:rotation_grace_seconds` - non-negative integer idempotency window for
    an immediate retry of the just-rotated token. Defaults to `10`; set `0`
    for strict reuse revocation. The window is fixed when the successor is
    issued: a later call may shorten it, but cannot extend it.

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. An already-consumed token is accepted only when its complete
successor state proves it is the same request inside the fixed retry window.

The parent claim, child insert, and retry-state persistence are one atomic
`c:Attesto.RefreshStore.rotate/4` operation. Simultaneous matching requests
therefore coalesce on the winner's complete committed successor instead of
observing a partially rotated family.

A store's documented `{:error, :invalid_rotation}` result means validation
rejected the proposed transition before committing any mutation. It is
reported as `{:error, :temporarily_unavailable}` with
`revocation: :not_attempted`; adapters MUST use that result only when they
can prove the transaction rolled back. Callback exceptions or ambiguous
unexpected returns still trigger family cleanup because they may have
committed.

A `get/1` exception or contract violation emits the operational
`[:attesto, :refresh_token, :rotation_state_failed]` event with
`operation: :lookup`. The original callback exception or constant contract
error is preserved. If a malformed returned record still binds the
presented token hash to a non-empty family ID, that family is revoked before
the contract error is raised; an untrusted or absent family is never used
for cleanup.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
