Verify JWT access tokens issued by a remote OAuth authorization server.
A resource server starts one supervised process per trusted issuer. The process discovers the issuer's JWKS, retains a bounded key set, coordinates refreshes, and permits a configured stale-key interval when a transient upstream failure prevents refresh. Signature and claim verification happens in the caller process; the server process only owns cache state and refresh coordination.
Security properties
- discovery metadata must report the configured issuer exactly;
- metadata and JWKS are fetched only over HTTPS with redirects disabled;
- signing algorithms are an explicit allowlist and are never learned from the presented token;
- a
kidselects exactly one eligible verification key, and RSA keys below 2048 bits are rejected; - RFC 9068
typ, issuer, audience, time, identity, client, JWT ID, and scope claims are checked; - an unknown
kidcauses at most one coordinated refresh per cache generation and refresh interval, preventing request fan-out and randomkidrefresh storms; - stale keys are used only within the configured stale interval and only after transient transport, 429, or 5xx refresh failures;
- DPoP and mTLS confirmation claims fail closed unless the matching verified proof-key or certificate thumbprint is supplied.
The module authenticates tokens and can require exact OAuth scope tokens. It does not decide which subjects may perform an application action, map claims to local users, or retain sessions; those remain application policy.
Example
children = [
{AttestoClient.ResourceServer,
name: MyApp.RemoteIssuer,
issuer: "https://issuer.example",
audience: "https://api.example",
accepted_algs: ["PS256", "ES256"],
fresh_ttl: :timer.minutes(5),
stale_ttl: :timer.hours(1),
refresh_retry_interval: :timer.seconds(5),
max_response_bytes: 512 * 1024}
]
:ok = AttestoClient.ResourceServer.warm(MyApp.RemoteIssuer)
{:ok, claims} =
AttestoClient.ResourceServer.verify(MyApp.RemoteIssuer, access_token,
required_scopes: ["documents.read"]
)fresh_ttl and stale_ttl are milliseconds. stale_ttl starts when the
fresh interval ends. refresh_retry_interval avoids retrying a failed
upstream refresh on every request while a stale snapshot remains usable.
Summary
Functions
Returns a specification to start this module under a supervisor.
Return whether the verifier retains a currently usable key snapshot.
Force a metadata/JWKS refresh.
Start a supervised remote-issuer verifier.
Verify a remote issuer's JWT access token.
Warm the verifier by completing metadata/JWKS retrieval.
Types
@type error() :: :invalid_token | :invalid_signature | :ambiguous_key | :weak_key | :unsupported_alg | :unsupported_critical_header | :unexpected_typ | :invalid_issuer | :invalid_audience | :expired | :not_yet_valid | :invalid_claims | :invalid_scope | :insufficient_scope | :subject_not_allowed | :client_not_allowed | :token_too_old | :token_lifetime_exceeded | :invalid_policy | :unsupported_confirmation | :dpop_proof_required | :dpop_proof_unexpected | :dpop_key_mismatch | :mtls_certificate_required | :mtls_certificate_mismatch | {:jwks_refresh_failed, term()}
@type server() :: GenServer.server()
@type start_opt() :: {:name, GenServer.name()} | {:issuer, String.t()} | {:audience, String.t() | [String.t()]} | {:accepted_algs, [Attesto.SigningAlg.alg()]} | {:well_known, AttestoClient.Discovery.well_known()} | {:metadata, map()} | {:jwks_uri, String.t()} | {:req_options, keyword()} | {:fresh_ttl, non_neg_integer()} | {:stale_ttl, non_neg_integer()} | {:unknown_kid_refresh_interval, non_neg_integer()} | {:refresh_retry_interval, non_neg_integer()} | {:refresh_timeout, pos_integer()} | {:clock_skew_seconds, non_neg_integer()} | {:max_jwks_keys, pos_integer()} | {:max_response_bytes, pos_integer()}
@type verify_opt() :: {:required_scopes, [String.t()]} | {:allowed_subjects, [String.t()]} | {:allowed_client_ids, [String.t()]} | {:max_token_age_seconds, non_neg_integer()} | {:max_token_lifetime_seconds, pos_integer()} | {:now, integer() | DateTime.t()} | {:dpop_jkt, String.t()} | {:mtls_cert_thumbprint, String.t()}
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
Return whether the verifier retains a currently usable key snapshot.
Force a metadata/JWKS refresh.
@spec start_link([start_opt()]) :: GenServer.on_start()
Start a supervised remote-issuer verifier.
@spec verify(server(), String.t(), [verify_opt()]) :: {:ok, map()} | {:error, error()}
Verify a remote issuer's JWT access token.
:required_scopes is an optional list of exact RFC 6749 scope tokens. Every
listed scope must occur in the token's space-delimited scope claim.
:allowed_subjects, :allowed_client_ids, :max_token_age_seconds, and
:max_token_lifetime_seconds provide optional authentication policy bounds.
Sender-constrained tokens additionally require the verified :dpop_jkt or
:mtls_cert_thumbprint from the current request.
Warm the verifier by completing metadata/JWKS retrieval.