Attesto.Telemetry (Attesto v2.0.1)

Copy Markdown View Source

:telemetry events Attesto emits for security-relevant refusals.

These events exist for one reason: some refusals are not routine. A refresh token presented twice outside the safe retry conditions, or a DPoP proof whose jti has already been seen, is consistent with a stolen credential. A return value tells the calling function; it does not tell whoever is on call. Attaching a handler is how that signal reaches a pager, a SIEM, or an audit log without a host wrapping every call site.

Ordinary failures are deliberately NOT events. An expired token, an unknown client, a wrong scope - these happen constantly in healthy traffic, and emitting them would bury the useful signals below in noise. Attesto also emits operational events when refresh rotation cannot commit, recover, or read trustworthy state, and another when refresh introspection cannot read its backing store reliably. Neither implies theft.

These are indicators, not verdicts

None of them proves theft, and two are cheap for a client to produce deliberately:

  • A client can present its own sender-bound token under a second key as often as it likes. Verification fails before the proof's jti is claimed, so the same proof works repeatedly - one token and one proof can ring sender_constraint_mismatch indefinitely.
  • A mid-rotation key or certificate rotation, or a client with a stale cached key, produces the same mismatch for entirely benign reasons.

So treat them as inputs to a decision rather than the decision: rate-limit and deduplicate by client_id before alerting, correlate across events and requests, and page on a pattern rather than on a single occurrence. refresh_token.reuse_detected is the one worth escalating fastest, because family revocation is attempted first; inspect the event's :revocation metadata to determine whether containment succeeded.

Events

Every event carries %{system_time: System.system_time()} as its measurements and is emitted at the moment the refusal is decided.

[:attesto, :refresh_token, :reuse_detected]

A refresh token was presented after it had already been rotated, outside the idempotency window or without matching the original client, binding, and scope (RFC 6749 §10.4, RFC 9700 §4.14). Family revocation is attempted before this fires. The event is emitted even when that cleanup fails, because losing the highest-value security signal along with the revocation would hide both the replay and its incomplete containment.

This is the highest-value event here. Treat it as an alert, not a log line.

Metadata:

  • :family_id - the revoked family, for correlating the sessions this terminated.
  • :client_id - the client the family was issued to, when the token carried one.
  • :subject - the resource owner whose session was revoked.
  • :generation - the generation of the presented token.
  • :revocation - :succeeded or :failed for the family cleanup attempt.

[:attesto, :refresh_token, :rotation_state_failed]

Attesto could not durably store or safely recover the credential-equivalent state required for refresh rotation. The rotation is always denied, but the containment action depends on what happened: a pre-commit protection failure, exhausted random token collision, or documented :invalid_rotation rollback leaves the parent untouched and needs no revocation; malformed or unreadable committed state triggers a family revocation attempt; a sibling-generation integrity failure is atomically revoked by the store. A callback exception during rotation has ambiguous commit status, so Attesto attempts revocation before propagating the original failure. An initial get/1 exception has no trustworthy family identity and is reported without containment; a malformed record is revoked only when its presented hash and family ID still bind it to the credential. This is an operational or configuration fault, not refresh-token reuse.

Metadata:

  • :operation - the failed rotation boundary: :lookup, :rotate_successor, or :recover_successor.
  • :reason - a bounded Attesto-defined atom identifying the failed invariant, including :store_raised, :store_threw, or :store_exited for a callback contract violation; it never contains the adapter's return value, exception, or throw/exit value.
  • :revocation - :succeeded or :failed for containment, or :not_attempted when no mutation committed by contract or no trustworthy family identity was available.
  • :family_id, :client_id, :subject, and :generation - the same correlation fields as the reuse event.

[:attesto, :introspection, :refresh_store_failed]

Refresh-token introspection could not reliably read its configured Attesto.RefreshStore. The RFC 7662 response remains %{"active" => false} so the failure cannot become a token-existence oracle, while this operational event makes the backing-store fault visible to the host.

Metadata:

  • :operation - currently always :get.
  • :reason - one of :store_contract_violation, :store_raised, :store_threw, or :store_exited. It never contains the adapter's return value, exception, or throw/exit value.

[:attesto, :dpop, :replay_detected]

A DPoP proof carried a jti the replay store had already recorded (RFC 9449 §11.1) - the proof was captured and replayed, or a client is reusing identifiers it must not.

Metadata:

  • :jti - the replayed identifier, emitted unchanged because it is the only handle for correlating repeats. Chosen by the CLIENT, so see "What metadata contains" below before writing it anywhere.

[:attesto, :token, :sender_constraint_mismatch]

A token bound to a sender was presented with the WRONG proof of possession: a DPoP-bound token under a mismatched key (RFC 9449 §7.1), or an mTLS-bound token with a mismatched certificate (RFC 8705 §3).

This is the weakest refusal indicator. A token separated from its holder produces it - but so does a key rotation, a stale cached key, and a client that simply chooses to send the wrong one, repeatedly and for free. Correlate before concluding anything; see "indicators, not verdicts" above.

A missing proof (:dpop_proof_required, :mtls_cert_required) does not emit. A client that has not implemented DPoP yet produces those constantly, and they say nothing about where the token is.

Metadata:

  • :binding - :dpop or :mtls, the constraint that failed.
  • :reason - the specific refusal (:dpop_binding_mismatch or :mtls_binding_mismatch).
  • :client_id - the client_id claim of the presented token, when it carries one.

What metadata contains, and what it does not

Attesto never copies a credential, or a digest of one, into an event: no access token, refresh token, authorization code, client secret, assertion, or DPoP proof appears in metadata, and nothing emitted can be presented to obtain anything.

It does emit selected FIELDS taken from credentials - jti is read out of the DPoP proof, client_id out of the presented token's claims - and those fields carry whatever their author put in them:

  • jti is chosen by the client. RFC 9449 constrains it only to be a unique string; this verifier additionally caps it at 256 bytes. A client may put anything there, including something that looks like - or is - one of its own credentials, and it is emitted unchanged so repeats can be correlated. A handler that writes metadata to a log is writing a remote party's chosen bytes to that log.
  • client_id, subject, and family_id come from the host. They are whatever the host's own identifiers are. subject in particular is usually personal data and falls under whatever retention policy covers your logs.

So treat metadata as untrusted, attacker-influencable input on its way to wherever the handler sends it: escape it, bound it, and do not interpolate it into anything that parses. A client that puts its own live secret in jti will have that secret written wherever the handler writes.

Handlers run synchronously

:telemetry invokes handlers on the calling process, so a handler that blocks blocks the refusal that produced the event, and there is no timeout. Hand work to a queue, a task, or a GenServer and return; do not do I/O inline. A handler that raises or exits is caught and detached by :telemetry itself and cannot change the outcome - one that hangs can.

Attaching

:telemetry.attach_many(
  "attesto-security",
  [
    [:attesto, :refresh_token, :reuse_detected],
    [:attesto, :refresh_token, :rotation_state_failed],
    [:attesto, :introspection, :refresh_store_failed],
    [:attesto, :dpop, :replay_detected],
    [:attesto, :token, :sender_constraint_mismatch]
  ],
  &MyApp.Security.handle_event/4,
  nil
)

Stability

These names and metadata keys are public API and follow this package's version policy: keys may be added, but an existing event will not be renamed or have a documented key removed without a major version.

Summary

Functions

Every event this library emits, for :telemetry.attach_many/4.

Functions

events()

@spec events() :: [[atom()]]

Every event this library emits, for :telemetry.attach_many/4.