AttestoPhoenix
An opinionated Phoenix/Ecto OAuth 2.0 / OIDC authorization server on top of attesto.
attesto brings the protocol, attesto_phoenix brings transport + persistence; you bring principals, keys, and policy.
attesto is a transport-agnostic library of OAuth/OIDC primitives: JWT access
tokens, JWKS/key handling, DPoP, mTLS, PKCE, scope algebra, private-key client
assertions, signed request objects, and the token-lifecycle building blocks.
attesto_phoenix wires those primitives into a running server:
- HTTP endpoints (authorization, token, PAR, revocation, discovery, JWKS, UserInfo, optional dynamic registration) mounted into your router with one macro.
- Protected-resource plugs that verify Bearer JWTs and enforce DPoP / mTLS sender-constraint binding.
-
Ecto-backed implementations of the attesto store behaviours for authorization
codes, refresh tokens, and (for clustered deployments) DPoP nonces and proof
jtireplay records.
It deliberately does not own your client registry, principal store, secret hashing, scope catalog, or audit log. Those are application policy and are supplied through a small set of neutral configuration callbacks.
Positioning vs. attesto core
| Concern | attesto (core) | attesto_phoenix (this package) |
|---|---|---|
| JWT mint/verify, JWKS, DPoP, mTLS, PKCE, scopes | yes | reuses core |
private_key_jwt, signed request objects, token exchange primitives | yes | wires into endpoints |
| Grant orchestration primitives | yes | reuses core |
| HTTP endpoints + router macro | no | yes |
| Protected-resource plugs | core plug building blocks | Phoenix-friendly wrappers |
| Ecto-backed token stores | store behaviours only | Ecto implementations |
| Client registry, principals, keys, audit | no | supplied via callbacks |
If you only need the protocol primitives and want to build your own transport,
depend on attesto directly. If you want a batteries-mostly-included Phoenix
server, use attesto_phoenix.
Installation
Add attesto_phoenix to your dependencies:
def deps do
[
{:attesto_phoenix, "~> 0.6"}
]
endConfiguration
All behavior is centralized in AttestoPhoenix.Config. Anything that is
inherently application policy is a neutral callback rather than a baked-in
assumption.
config :my_app, AttestoPhoenix.Config,
# --- required ---
issuer: "https://auth.example.com",
keystore: MyApp.Keystore, # implements Attesto.Keystore
repo: MyApp.Repo, # Ecto.Repo for the token stores
# client lookup + secret verification (you own the client registry)
load_client: &MyApp.Clients.fetch/1,
# (client_id -> {:ok, client} | {:error, :not_found} | {:error, :revoked})
verify_client_secret: &MyApp.Clients.verify_secret/2,
# (client, presented_secret -> boolean) -- constant time
client_jwks: &MyApp.Clients.jwks/1,
# (client -> {:ok, jwks} | jwks), for private_key_jwt and request objects
# subject/principal resolution for protected-resource auth
load_principal: &MyApp.Principals.fetch/1,
# (subject_id -> {:ok, principal} | {:error, :not_found})
# --- optional policy ---
scopes_supported: ["profile", "email", "read:*", "write:*"],
authorize_scope: &MyApp.Scopes.authorize/2,
# (client, requested_scope -> {:ok, granted} | {:error, :invalid_scope})
on_event: &MyApp.Audit.record/1, # (%AttestoPhoenix.Event{} -> any)
send_error: &MyApp.OAuthErrors.render/3,
# (conn, status, body_map -> conn), optional custom OAuth error envelope
# --- optional deployment + features ---
require_https: true,
trusted_proxies: ["10.0.0.0/8"], # honor X-Forwarded-* only from these
access_token_ttl: 900,
refresh_token_ttl: 1_209_600,
authorization_code_ttl: 60,
dpop_enabled: true,
dpop_nonce_required: false,
mtls_enabled: false, # if true, also set :cert_der
registration_enabled: false # if true, also set :register_clientBuild the validated struct wherever you need it:
config = AttestoPhoenix.Config.from_otp_app(:my_app)
Required keys are validated at build time; a missing key (or a missing
dependency such as :cert_der when mTLS is enabled) raises immediately so
misconfiguration fails fast.
The callbacks, in OAuth terms
- client lookup ->
:load_client - client secret verification ->
:verify_client_secret - client public keys ->
:client_jwks - subject/principal resolution ->
:load_principal - scope catalog / narrowing ->
:scopes_supportedand/or:authorize_scope - audit / telemetry ->
:on_event(optional, no-op by default) - error envelope / transport rendering ->
:send_error,:www_authenticate,:no_store(optional) - dynamic client persistence ->
:register_client(only with registration) - mTLS certificate extraction ->
:cert_der(only with mTLS) - HTTPS / proxy trust ->
:require_https+:trusted_proxies
Mounting the routes
Use the router macro to mount the server endpoints under a scope you choose:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use AttestoPhoenix.Router
pipeline :oauth do
plug :accepts, ["json"]
end
scope "/" do
pipe_through :oauth
attesto_routes()
end
endattesto_routes/1 mounts:
GET /.well-known/oauth-authorization-server(RFC 8414 metadata)GET /.well-known/openid-configuration(OIDC Discovery metadata)GET /.well-known/jwks.json(RFC 7517 JWK Set)GET /oauth/authorizePOST /oauth/tokenPOST /oauth/par(RFC 9126)POST /oauth/revoke(RFC 7009)POST /oauth/register(RFC 7591, only whenregistration_enabled: true)DELETE /oauth/register/:client_id(RFC 7592, with registration)GET /oauth/userinfoPOST /oauth/userinfo
Discovery and JWKS are public; the token and revocation endpoints authenticate
the client via your :load_client / :verify_client_secret callbacks.
The token endpoint also accepts private_key_jwt when :client_jwks is wired,
and supports authorization-code, refresh-token, client-credentials, and OAuth
token-exchange grants. The PAR endpoint accepts the same confidential-client
secret methods plus private_key_jwt, then stores the authorization request
behind a one-time request_uri.
Protecting resources
pipeline :api_protected do
plug AttestoPhoenix.Plug.Authenticate
end
scope "/api", MyAppWeb do
pipe_through [:api, :api_protected]
scope "/reports" do
plug AttestoPhoenix.Plug.RequireScopes, "read:reports"
get "/", ReportController, :index
end
endAttestoPhoenix.Plug.Authenticate verifies the Bearer JWT, enforces DPoP and
mTLS binding when enabled, resolves the subject via :load_principal, emits
neutral :auth_succeeded / :auth_denied events through :on_event, and
assigns:
conn.assigns.attesto_claims- the verified JWT claimsconn.assigns.attesto_principal- the host principal returned by:load_principalconn.assigns.attesto_context- a neutral%{subject, client_id, scope, claims, cnf, principal}map
AttestoPhoenix.Plug.RequireScopes enforces route-level scope authorization
using Attesto.Scope grant-form algebra. It accepts either a single scope
string or a list of required scopes.
For first-party web flows, keep cookie semantics in your app and pass a generic credential extractor to the plug:
plug AttestoPhoenix.Plug.Authenticate,
credential_from_conn: &MyAppWeb.Auth.access_token_from_cookie/1
The extractor returns {:ok, :bearer, token}, {:ok, :dpop, token}, or
:missing. Attesto still verifies the token through the same JWT/DPoP/mTLS
path; the cookie format and CSRF policy remain host concerns.
Database migration
The library owns four operational tables backing the attesto store behaviours:
authorizations, refresh_tokens, dpop_nonces, and dpop_replays. It does
not own a clients table (that is yours, behind :load_client). The default
PAR store is single-node ETS; clustered deployments should provide a
AttestoPhoenix.PARStore backed by shared storage.
Generate the migration into your app:
mix attesto_phoenix.gen.migration --repo MyApp.RepoThen run it:
mix ecto.migrate
Single-node deployments may skip the Ecto nonce/replay tables and wire
attesto's in-memory ETS implementations via :nonce_store and :replay_check;
the Ecto variants exist for clustered correctness.
License
MIT. See LICENSE.