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 thesub_prefixof 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 againstsubon 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
@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
@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 ]
@type token_response() :: %{ access_token: String.t(), expires_in: pos_integer(), scope: String.t(), token_type: String.t() }
@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
@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
@spec default_lifetime_seconds(Attesto.Config.t()) :: pos_integer()
The default token lifetime for config, in seconds.
@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- theclaim_valueof one of the configured principal kinds.:sub- the subject's public identifier; MUST begin with the kind'ssub_prefix.:scopes- the final, policy-resolved list of scope strings. Joined verbatim into thescopeclaim; Attesto applies no scope policy.:claims(optional) - extra principal claims (e.g.%{"client_id" => ...}). MUST satisfy the kind'srequired_claimsand MUST NOT collide with a reserved protocol claim.
Options:
:typ-"access"(default) or"refresh".:now-DateTimeor 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).
@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.
@spec signing_alg() :: String.t()
The JWS algorithm used to sign tokens. Pinned; verifiers reject anything else.
@spec typ_values() :: [String.t()]
The known typ values: "access" and "refresh".
@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:
- Signature. The compact JWS parses and its RS256 signature
verifies against a key the keystore trusts, selected by the JWS
header
kid. A token whosekidnames a key we do not hold, or whose headeralgis anything but RS256, fails as:invalid_signature(alg-confusion is impossible). A token whose protected header carries acritparameter (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. - Confirmation shape. If a
cnfis 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). issequals the configured issuer.audequals (or, in array form, contains) the configured audience.- Temporal.
expis strictly greater thannow(no skew leeway). Ifnbfis present it MUST be an integer no later thannow(RFC 7519 §4.1.5; a small clock-skew tolerance applies), else:not_yet_valid. Aniatmeaningfully in the future is also:not_yet_valid. - Required claims are present and well-typed:
sub/jtinon-empty strings,scopea string,iata non-negative integer, and both the principal-kind claim andtyppresent. - Principal. The principal-kind claim names a configured kind AND
subbegins with that kind'ssub_prefix; otherwise:invalid_principal. - Per-kind claims. The kind's
required_claimsare all present with the right shape; otherwise:invalid_claims. typis a known value AND equals the expected purpose (:expected_typ, default"access").- 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'sjkt(fromAttesto.DPoP.verify_proof/2). Required iff the token carriescnf.jkt.:mtls_cert_thumbprint- the presented certificate's thumbprint (fromAttesto.MTLS.compute_thumbprint/1). Required iff the token carriescnf.x5t#S256.
Returns {:ok, claims} (string-keyed payload) or {:error, reason}.