Attesto.Token (Attesto v0.5.0)

Copy Markdown View Source

Mint and verify RS256 JWT access tokens.

This is the heart of the engine: a single mint point and a single verifier that one issuer uses for every kind of principal. The two operations are pure - they read no database, no process state, and no application config beyond the Attesto.Config you pass in. Effects that surround issuance (auditing, persisting refresh state, looking up revocation) belong to the host application, which wraps these functions.

Claims

Every minted token carries:

  • iss - the configured issuer.
  • aud - the configured audience.
  • sub - the subject's public identifier, which MUST begin with the sub_prefix of its principal kind.
  • exp / iat - expiry and issued-at, unix seconds.
  • jti - a 128-bit crypto-random identifier, base64url-no-pad (RFC 7519 §4.1.7), so a resource server can reject replay.
  • scope - the space-separated granted scope list (resolved by the host's policy and passed in; Attesto does not decide who gets what).
  • typ - the token purpose, "access" or "refresh".
  • the configured principal-kind claim - the kind's claim_value, cross-checked against sub on verify.
  • any per-kind required claims (e.g. client_id).
  • cnf - present iff the token is sender-constrained (DPoP or mTLS).

Tokens are signed RS256 with the key the configured Attesto.Keystore provides; the JWS header carries the key's kid (its RFC 7638 thumbprint). The algorithm is pinned: verify/3 rejects anything but RS256, so none/HS256 alg-confusion is impossible by construction.

Sender constraints

mint/3 accepts at most one of :dpop_jkt (RFC 9449) or :mtls_cert_thumbprint (RFC 8705); supplying both is :conflicting_confirmation. The chosen binding becomes a cnf claim (RFC 7800), and verify/3 enforces it: a DPoP- or mTLS-bound token presented without (or with a mismatched) proof is rejected, and a proof presented against a token that is not bound that way is rejected too. See verify/3 for the full binding matrix.

What this module does NOT do

Scope policy (which scopes a principal may hold, downscoping rules) is the host's; pass the already-resolved scope list to mint/3. Revocation lookup, jti replay rejection of the access token, and audit are the resource server's. Keeping them out is what lets the verifier stay pure and reusable (token introspection, multiple surfaces).

Summary

Functions

The default token lifetime for config, in seconds.

Mint a token for principal under config.

Return a token's claims iff its RS256 signature verifies against a keystore key. Skips every other check (iss, aud, exp, claim shape, binding).

The JWS algorithm used to sign tokens. Pinned; verifiers reject anything else.

The known typ values: "access" and "refresh".

Verify and decode a token previously minted by mint/3 under the same config.

Types

claims()

@type claims() :: %{optional(String.t()) => term()}

mint_error()

@type mint_error() ::
  :unknown_principal_kind
  | :invalid_sub
  | :invalid_claims
  | :reserved_claim_conflict
  | :invalid_scopes
  | :invalid_typ
  | :invalid_dpop_jkt
  | :invalid_mtls_thumbprint
  | :conflicting_confirmation

mint_opts()

@type mint_opts() :: [
  now: DateTime.t() | non_neg_integer(),
  lifetime: pos_integer(),
  typ: String.t(),
  dpop_jkt: String.t() | nil,
  mtls_cert_thumbprint: String.t() | nil
]

principal()

@type principal() :: %{
  :kind => String.t(),
  :sub => String.t(),
  :scopes => [String.t()],
  optional(:claims) => %{optional(String.t()) => term()}
}

token_response()

@type token_response() :: %{
  access_token: String.t(),
  expires_in: pos_integer(),
  scope: String.t(),
  token_type: String.t()
}

verify_error()

@type verify_error() ::
  :invalid_token
  | :invalid_signature
  | :invalid_issuer
  | :invalid_audience
  | :expired
  | :not_yet_valid
  | :invalid_claims
  | :invalid_principal
  | :invalid_typ
  | :unexpected_typ
  | :unsupported_critical_header
  | :unsupported_confirmation
  | :dpop_proof_required
  | :dpop_binding_mismatch
  | :dpop_proof_unexpected
  | :mtls_cert_required
  | :mtls_binding_mismatch
  | :mtls_cert_unexpected

verify_opts()

@type verify_opts() :: [
  now: DateTime.t() | non_neg_integer(),
  expected_typ: String.t(),
  dpop_jkt: String.t() | nil,
  mtls_cert_thumbprint: String.t() | nil
]

Functions

default_lifetime_seconds(config)

@spec default_lifetime_seconds(Attesto.Config.t()) :: pos_integer()

The default token lifetime for config, in seconds.

mint(config, principal, opts \\ [])

@spec mint(Attesto.Config.t(), principal(), mint_opts()) ::
  {:ok, token_response()} | {:error, mint_error()}

Mint a token for principal under config.

principal is a map with:

  • :kind - the claim_value of one of the configured principal kinds.
  • :sub - the subject's public identifier; MUST begin with the kind's sub_prefix.
  • :scopes - the final, policy-resolved list of scope strings. Joined verbatim into the scope claim; Attesto applies no scope policy.
  • :claims (optional) - extra principal claims (e.g. %{"client_id" => ...}). MUST satisfy the kind's required_claims and MUST NOT collide with a reserved protocol claim.

Options:

  • :typ - "access" (default) or "refresh".
  • :now - DateTime or unix-seconds clock override. Defaults to now.
  • :lifetime - positive seconds; may only shorten the configured default (a larger value is capped to the default, so a miswired caller cannot mint a long-lived token).
  • :dpop_jkt - RFC 7638 JWK thumbprint to bind the token to a DPoP key (cnf.jkt). Must be a canonical 43-char base64url thumbprint or :invalid_dpop_jkt.
  • :mtls_cert_thumbprint - RFC 8705 certificate thumbprint to bind the token to a client certificate (cnf.x5t#S256). Same shape rule or :invalid_mtls_thumbprint.

:dpop_jkt and :mtls_cert_thumbprint are mutually exclusive (:conflicting_confirmation).

Returns {:ok, %{access_token, token_type, expires_in, scope}}. token_type is "DPoP" for a DPoP-bound token (RFC 9449 §5) and "Bearer" otherwise (mTLS binding does not change the type per RFC 8705 §3).

peek_signed_claims(config, jwt)

@spec peek_signed_claims(Attesto.Config.t(), String.t()) ::
  {:ok, claims()} | {:error, :invalid_signature | :invalid_token}

Return a token's claims iff its RS256 signature verifies against a keystore key. Skips every other check (iss, aud, exp, claim shape, binding).

This is NOT an authentication primitive - the token may be expired, replayed, wrongly scoped, or bound to a key the request did not present. Its sole legitimate use is denial-audit attribution: after verify/3 fails, a caller may read the claims to identify the credential being abused so the audit row names a real actor rather than :unknown. A forged-signature token still surfaces as an error.

signing_alg()

@spec signing_alg() :: String.t()

The JWS algorithm used to sign tokens. Pinned; verifiers reject anything else.

typ_values()

@spec typ_values() :: [String.t()]

The known typ values: "access" and "refresh".

verify(config, jwt, opts \\ [])

@spec verify(Attesto.Config.t(), String.t(), verify_opts()) ::
  {:ok, claims()} | {:error, verify_error()}

Verify and decode a token previously minted by mint/3 under the same config.

Runs, in order:

  1. Signature. The compact JWS parses and its RS256 signature verifies against a key the keystore trusts, selected by the JWS header kid. A token whose kid names a key we do not hold, or whose header alg is anything but RS256, fails as :invalid_signature (alg-confusion is impossible). A token whose protected header carries a crit parameter (RFC 7515 §4.1.11) is rejected with :unsupported_critical_header - Attesto implements no JWS extensions, so it must not honour a token that demands one.
  2. Confirmation shape. If a cnf is present it MUST be exactly %{"jkt" => <thumbprint>} (DPoP) or %{"x5t#S256" => <thumbprint>} (mTLS), with a canonical thumbprint and no other members; anything else is :unsupported_confirmation (accepting it as bearer would silently strip the binding).
  3. iss equals the configured issuer.
  4. aud equals (or, in array form, contains) the configured audience.
  5. Temporal. exp is strictly greater than now (no skew leeway). If nbf is present it MUST be an integer no later than now (RFC 7519 §4.1.5; a small clock-skew tolerance applies), else :not_yet_valid. An iat meaningfully in the future is also :not_yet_valid.
  6. Required claims are present and well-typed: sub/jti non-empty strings, scope a string, iat a non-negative integer, and both the principal-kind claim and typ present.
  7. Principal. The principal-kind claim names a configured kind AND sub begins with that kind's sub_prefix; otherwise :invalid_principal.
  8. Per-kind claims. The kind's required_claims are all present with the right shape; otherwise :invalid_claims.
  9. typ is a known value AND equals the expected purpose (:expected_typ, default "access").
  10. Binding. A DPoP-bound token requires a matching :dpop_jkt; an mTLS-bound token a matching :mtls_cert_thumbprint; an unbound token requires neither. The cross-scheme option MUST be absent. See the error list for the precise outcomes.

Options

  • :now - clock override.
  • :expected_typ - "access" (default) or "refresh".
  • :dpop_jkt - the verified DPoP proof's jkt (from Attesto.DPoP.verify_proof/2). Required iff the token carries cnf.jkt.
  • :mtls_cert_thumbprint - the presented certificate's thumbprint (from Attesto.MTLS.compute_thumbprint/1). Required iff the token carries cnf.x5t#S256.

Returns {:ok, claims} (string-keyed payload) or {:error, reason}.