# Attesto
[](https://hex.pm/packages/attesto)
[](https://hexdocs.pm/attesto)
[](https://hex.pm/packages/attesto)
[](https://github.com/XukuLLC/attesto/actions/workflows/elixir.yml)
[](https://github.com/XukuLLC/attesto/blob/main/LICENSE)
[](https://elixir-lang.org)
A vendor-neutral [OAuth 2.0](https://oauth.net/2/) / [OpenID Connect](https://openid.net/developers/how-connect-works/) engine for Elixir APIs that need modern token security, with first-class support for sender-constrained access tokens: [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) and mutual TLS. It also provides the conn-free protocol pieces for JAR, JARM, token introspection, and FAPI 2.0 Message Signing.
## Where it fits
Most Elixir authentication libraries focus on the application session: signing
in with an external provider, managing user accounts, or creating Phoenix
session cookies. Attesto sits on the token side of the boundary: short-lived,
scoped, locally-verifiable OAuth/OIDC tokens for APIs and machine clients.
That matters for everyday APIs as much as specialized high-assurance systems:
as exploit discovery gets cheaper and faster, stolen bearer tokens and
long-lived credentials become weaker defaults.
Use it when you need to:
1. Verify standards-based API tokens in a resource server. Attesto verifies
JWT access tokens locally by signature, audience, issuer, and optional
sender constraint. A stolen sender-constrained token is not enough to call
the API without the holder's DPoP key or client certificate, and no token
database or introspection call is required for the normal access-token path.
2. Issue tokens from your own authorization server. Attesto provides the
protocol pieces: JWT access tokens, ID tokens, JWKS/key handling, DPoP,
mutual-TLS binding, authorization-code helpers, refresh-token rotation,
signed authorization requests, JARM response JWTs, token introspection,
scope algebra, and OAuth error/challenge helpers. Machine-to-machine access
can use OAuth client credentials with short-lived scoped tokens instead of
long-lived API keys. Transport and persistence remain separate;
`attesto_phoenix` supplies the Phoenix/Ecto layer.
This is different from session-oriented libraries such as Ueberauth, Assent,
Pow, AshAuthentication, or `mix phx.gen.auth`: those help your application
authenticate users. Attesto helps your application issue or verify OAuth/OIDC
tokens.
Attesto is the engine, not the framework. It mints and verifies JWTs, binds
them to a sender, and validates proofs and scopes. You bring the principals,
the keys, and the policy. It carries no opinion about your identity provider,
your web layer, or your persistence.
If you want a batteries-included Phoenix authorization server, use
[`attesto_phoenix`](https://github.com/XukuLLC/attesto_phoenix) on top of
this package: endpoints, router helpers, and Ecto-backed stores wired together.
## Contents
- [Where it fits](#where-it-fits)
- [Why this library](#why-this-library)
- [Installation](#installation)
- [Usage](#usage)
- [Configure once](#configure-once)
- [Mint and verify a token](#mint-and-verify-a-token)
- [Sender-constrain a token to a DPoP key](#sender-constrain-a-token-to-a-dpop-key)
- [Authorization request and response JWTs](#authorization-request-and-response-jwts)
- [Token introspection](#token-introspection)
- [Match scopes](#match-scopes)
- [What you supply / what's in the box](#what-you-supply--whats-in-the-box)
- [RFC coverage](#rfc-coverage)
- [Plug integration (optional)](#plug-integration-optional)
- [Cluster safety](#cluster-safety)
- [Status](#status)
- [Development](#development)
- [License](#license)
## Why this library
- **Vendor-neutral.** No coupling to Auth0, Okta, Cognito, or any particular IdP. The token shape is yours, and the same issuer can serve several kinds of principal (a machine client, a human session) from one signing key and one verifier.
- **Sender-constrained by design.** DPoP (RFC 9449) and certificate-bound tokens (RFC 8705) are part of the core, with the `cnf` binding matrix enforced on both issue and verify.
- **Short-lived and locally verifiable.** Access tokens are signed JWTs that resource servers can verify without a shared token database. Refresh-token rotation, reuse detection, and revocation hooks cover the stateful parts that should stay stateful.
- **Protocol, not policy.** Attesto selects keys by key ID ([`kid`](https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.4)), verifies the configured signing algorithms, canonicalises thumbprints, compares in constant time, and rejects replay. Whether a given principal may hold a given scope stays in your application.
- **Pluggable keys.** Use the bundled static keystore (which derives the public half from the private key so the two can never drift), or implement the `Attesto.Keystore` behaviour against your own KMS or rotation story.
- **Cross-language parity.** The test suite verifies Attesto-issued tokens and proofs against a reference implementation in another language, so the wire format is exactly what other ecosystems expect.
## Installation
```elixir
def deps do
[
{:attesto, "~> 0.6"}
]
end
```
## Usage
### Configure once
Declare the principal kinds your issuer serves, point Attesto at a keystore, and name your issuer and audience.
```elixir
config =
Attesto.Config.new(
issuer: "https://api.example.com/",
audience: "https://api.example.com/",
keystore: Attesto.Keystore.Static,
principal_kinds: [
Attesto.PrincipalKind.new("client", "oc_",
required_claims: [{"client_id", :non_empty_string}]
),
Attesto.PrincipalKind.new("user", "usr_",
required_claims: [
{"act", :non_empty_string},
{"sid", :non_empty_string},
{"token_version", :non_neg_integer}
]
)
]
)
```
The static keystore reads its signing key from application config:
```elixir
config :attesto, Attesto.Keystore.Static,
signing_pem: System.fetch_env!("OAUTH_SIGNING_PRIVATE_KEY_PEM")
```
### Mint and verify a token
```elixir
{:ok, token} =
Attesto.Token.mint(config, %{
kind: "client",
sub: "oc_live_4f2a",
scopes: ["documents.read", "documents.write"],
claims: %{"client_id" => "oc_live_4f2a"}
})
# token.access_token -> the compact JWS
# token.token_type -> "Bearer"
# token.expires_in -> 900
# token.scope -> "documents.read documents.write"
{:ok, claims} = Attesto.Token.verify(config, token.access_token)
# claims["sub"] -> "oc_live_4f2a"
# claims["scope"] -> "documents.read documents.write"
```
### Sender-constrain a token to a DPoP key
Pass a JWK thumbprint at issue time, then verify the proof and the binding together on each request.
```elixir
{:ok, token} =
Attesto.Token.mint(config, principal, dpop_jkt: proof_key_thumbprint)
# token.token_type -> "DPoP"
{:ok, proof} =
Attesto.DPoP.verify_proof(dpop_proof_jwt,
http_method: "POST",
http_uri: "https://api.example.com/documents",
access_token: token.access_token,
replay_check: &Attesto.DPoP.ReplayCache.check_and_record/2
)
{:ok, _claims} =
Attesto.Token.verify(config, token.access_token, dpop_jkt: proof.jkt)
```
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.
### Authorization request and response JWTs
Attesto verifies signed authorization request objects (JAR / RFC 9101) and can
build signed authorization responses (JARM). Profile policy is explicit data:
the generic defaults stay broadly OpenID-compatible, while
`Attesto.RequestObject.Policy.fapi_message_signing/0` applies the FAPI 2.0
Message Signing request-object rules.
```elixir
policy = Attesto.RequestObject.Policy.fapi_message_signing()
{:ok, request_claims} =
Attesto.RequestObject.verify(request_jwt, client_jwks,
[issuer: client_id, audience: config.issuer] ++
Attesto.RequestObject.Policy.to_verify_opts(policy)
)
{:ok, response_jwt} =
Attesto.JARM.response_jwt(config, client_id, %{
"code" => code,
"state" => state
})
```
`Attesto.AuthorizationRequest.validate/2` accepts `:request_object_policy`,
`:request_object_jwks`, and `:request_object_audience` options so a transport
layer can enforce request-object policy while keeping controller code thin.
### Token introspection
`Attesto.Introspection` implements the RFC 7662 active-token decision without
owning an HTTP endpoint. Access tokens are introspected with the same verifier
used by resource servers, except the sender-binding proof match is skipped so
the response can echo `cnf` for the resource server to enforce. Refresh tokens
are active only while present, unconsumed, and unexpired in the configured
`Attesto.RefreshStore`.
```elixir
response =
Attesto.Introspection.introspect(config, token,
refresh_store: MyApp.RefreshStore,
token_type_hint: "access_token"
)
{:ok, signed_response} =
Attesto.SignedIntrospection.response_jwt(config, resource_server_id, response)
```
The signed response helper emits the RFC 9701
`application/token-introspection+jwt` payload; the HTTP endpoint and content
negotiation belong to the host or integration layer.
### Match scopes
```elixir
catalog = Attesto.Scope.new_catalog(~w(documents.read documents.write reports.read))
Attesto.Scope.grants?(catalog, ["documents.*"], "documents.write")
# => true
Attesto.Scope.grants_all?(catalog, ["documents.read"], ["documents.write"])
# => false
```
## What you supply / what's in the box
| What you supply | What's in the box |
| --- | --- |
| Principal definitions (`Attesto.PrincipalKind`) | Token issue and verify (`Attesto.Token`) |
| Signing / verification keys, rotation (`Attesto.Keystore`) | JWS signing, `kid` selection, claim validation |
| Authorization policy ("may this principal do X?") | DPoP proof verification + replay protection (`Attesto.DPoP`) |
| HTTP layer, routing, plugs | mTLS certificate-binding checks (`Attesto.MTLS`) |
| Persistence, sessions, IdP integration | Scope grant-form matching (`Attesto.Scope`) |
| Issuer / audience values (`Attesto.Config`) | JAR, JARM, and introspection primitives |
| Client stores, PAR stores, endpoint rendering | Canonical SHA-256 thumbprints (`Attesto.Thumbprint`) |
If a decision depends on your business rules, it is yours. If it is a wire-format or cryptographic check defined by an RFC, it is Attesto's.
## RFC coverage
| RFC | Title | Status |
| --- | --- | --- |
| RFC 7519 | JSON Web Token (JWT) | Supported |
| RFC 7515 | JSON Web Signature (JWS) | Supported |
| RFC 7517 | JSON Web Key (JWK) | Supported |
| RFC 7638 | JWK Thumbprint | Supported |
| RFC 7800 | Proof-of-Possession Key Semantics (`cnf`) | Supported |
| RFC 8705 | Mutual-TLS / Certificate-Bound Access Tokens | Supported |
| RFC 9449 | Demonstrating Proof of Possession (DPoP) | Supported |
| RFC 6749 §4.1 | Authorization-code grant (single-use, PKCE-mandatory) | Supported |
| RFC 6749 §6 / §10.4 | Refresh-token rotation + reuse detection | Supported |
| RFC 6749 §3.3 | Access-token scope | Supported |
| RFC 7636 | Proof Key for Code Exchange (PKCE) | Supported (S256) |
| RFC 8414 | Authorization Server Metadata (discovery) | Supported |
| RFC 7517 | JSON Web Key Set publication (JWKS endpoint) | Supported |
| RFC 7009 | Token Revocation (refresh-token family) | Supported |
| RFC 9449 §8 | DPoP server-issued nonce | Supported |
| RFC 9068 | JWT access-token `typ: "at+jwt"` header | Supported |
| RFC 9101 | JWT Secured Authorization Request (JAR) | Supported |
| JARM | JWT Secured Authorization Response Mode | Supported |
| RFC 7662 | OAuth 2.0 Token Introspection | Core primitive |
| RFC 9701 | JWT Response for OAuth Token Introspection | Core primitive |
| FAPI 2.0 Message Signing | JAR/JARM/signed introspection primitives | Core primitives |
## Plug integration (optional)
The core is plain functions, but a thin optional Plug layer wires them to
a Phoenix/Plug pipeline so you don't hand-roll header parsing, `htu`
construction, replay enforcement, the mTLS thumbprint handoff, or the
standard error responses:
```elixir
plug Attesto.Plug.Authenticate,
config: &MyApp.Attesto.config/0,
replay_check: &MyApp.DPoPReplay.check_and_record/2,
cert_der: &MyApp.TLS.client_cert_der/1
plug Attesto.Plug.RequireScopes, ["documents.read"]
```
`Authenticate` parses `Authorization: Bearer …` / `DPoP …`, verifies the
DPoP proof and the access token (and the mTLS binding when `:cert_der`
returns a certificate), and assigns the verified claims.
`Attesto.Plug.OAuthError` renders the RFC 6750 / RFC 9449 responses
(`WWW-Authenticate`, `DPoP-Nonce`, `invalid_token`, `invalid_dpop_proof`,
`insufficient_scope`, `use_dpop_nonce`). `Plug` is an optional dependency:
add it only if you use this layer. The token-endpoint grant logic stays
yours - client auth, policy, and store wiring are too host-specific for a
fixed plug.
## Cluster safety
The engine is pure and stateless, so it is **cluster-safe by
construction**: the same token/proof verifies to the same result on any
node. All *state* (authorization codes, refresh-token families, seen DPoP
`jti` values, DPoP nonces) lives behind storage behaviours whose contracts
mandate the atomic primitives (atomic `take`, atomic compare-and-set
`consume`, sticky family revocation). Implement those behaviours over a
shared store (Postgres, Redis) and the whole system is cluster-safe.
The bundled ETS reference stores are deliberately **single-node** - a
captured credential would otherwise be replayable once per node. Rather
than fail silently, every ETS store (`CodeStore.ETS`, `RefreshStore.ETS`,
`DPoP.ReplayCache`, `DPoP.NonceStore.ETS`) **refuses to boot on a clustered
BEAM** unless you pass `multi_node_acknowledged?: true`, which forces the
choice: wire a shared store, or explicitly accept the single-node
constraint.
## Status
A `0.x` release: still pre-1.0, so the API may change between minor versions (read the CHANGELOG before upgrading). Implemented and tested: token issue/verify, DPoP, mTLS, scope, keystore, PKCE validation, JWKS publication, OIDC discovery, the authorization-code grant (single-use, optionally DPoP-bound), refresh-token rotation with reuse detection, token revocation (RFC 7009, refresh-token family), signed request-object policy, JARM response signing, token introspection, and signed introspection response JWTs. The stateful grants run against the `Attesto.CodeStore` / `Attesto.RefreshStore` behaviours, with ETS reference implementations included; a production host implements those over its own database (the atomic-`take` and atomic-`consume` contracts are documented). Cross-language parity tests check Attesto-issued artifacts against a reference implementation in another language. Pin to `~> 0.6`.
## Development
```sh
mix deps.get
mix test
mix precommit # format --check-formatted, compile --warnings-as-errors, credo --strict, test
```
The cross-language parity tests drive a reference `joserfc` / `cryptography`
stack in-process via `erlang_python` and run as part of `mix test` (they
self-skip when that Python stack is not installed). Install it with
`pip install joserfc cryptography` against the interpreter `erlang_python`
loads.
## License
MIT, Copyright (c) Neil Berkman. See [LICENSE](LICENSE).