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, JARM response JWTs, token introspection
primitives, and the token-lifecycle building blocks.
attesto_phoenix wires those primitives into a running server:
- HTTP endpoints (authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource metadata, optional dynamic registration) mounted into your router with one macro. The authorization endpoint supports the default query response mode and the JARM JWT response modes.
- Protected-resource plugs that verify Bearer JWTs and enforce DPoP / mTLS sender-constraint binding.
- Ecto-backed implementations of every mutable store the OAuth/OIDC flows need
— authorization codes, refresh tokens, DPoP nonces, DPoP proof
jtireplay records, and Pushed Authorization Request (PAR) references — so a clustered or load-balanced deployment keeps no OAuth state per node.
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.
What you can build with it
- An API that AI assistants can connect to. Assistant connectors — ChatGPT,
Claude — authorize through OAuth: PKCE, dynamic client registration, pushed
authorization requests, sender-constrained tokens, and protected-resource
discovery.
attesto_phoenixmounts that whole surface with one router macro, so your app can expose tools and data to an assistant without hand-rolling an OAuth server. - Your own authorization server. Issue short-lived, scoped JWT access tokens and OIDC ID tokens for first-party apps and machine clients, instead of outsourcing to a hosted identity provider.
- A resource server that resists stolen tokens. Verify access tokens locally — signature, issuer, audience, and DPoP / mTLS sender-constraint — with no token database or introspection call on the hot path, so a leaked bearer token alone can't call the API.
The standards each use case rests on are catalogued below and in
the attesto core README;
you don't need to track them to use the library.
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, JARM, 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-included Phoenix
authorization server, use attesto_phoenix.
Contents
- Installation
- Quick start
- Configuration
- Mounting the routes
- Protecting resources
- Database migration
- Guides and examples
- Development
- License
Installation
Add attesto_phoenix to your dependencies:
def deps do
[
{:attesto_phoenix, "~> 0.10"}
]
endThe optional Igniter installer needs igniter available while you run it. It is
not a runtime dependency of this package:
def deps do
[
{:attesto_phoenix, "~> 0.10"},
{:igniter, "~> 0.5", only: [:dev], runtime: false}
]
endQuick start
For a new Phoenix app, start with the installer. It is idempotent and writes the host-owned callback modules as stubs rather than guessing your client registry, principal model, or authorization policy.
mix deps.get
mix attesto_phoenix.install
mix attesto_phoenix.gen.migration --repo MyApp.Repo
mix ecto.migrate
Use --oauth-path-prefix when the OAuth endpoints should not live under
/oauth:
mix attesto_phoenix.install --oauth-path-prefix /mcp/oauth
After the installer runs, fill in the generated callback modules and configure a keystore. The rest of this README shows the same pieces explicitly so you can review what the installer generated or wire them by hand.
Configuration
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
# host policy modules (preferred install surface)
client_store: MyApp.OAuth.ClientStore,
principal_store: MyApp.OAuth.PrincipalStore,
scope_policy: MyApp.OAuth.ScopePolicy,
consent_policy: MyApp.OAuth.ConsentPolicy,
claims_provider: MyApp.OIDC.ClaimsProvider,
event_sink: MyApp.OAuth.Events,
# --- optional policy ---
scopes_supported: ["profile", "email", "read:*", "write:*"],
send_error: &MyApp.OAuthErrors.render/3,
# (conn, status, body_map -> conn), optional custom OAuth error envelope
client_auth_signing_algs: Attesto.SigningAlg.fapi_algs(),
request_object_policy: Attesto.RequestObject.Policy.generic(),
# --- 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 registration callbacksBuild 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.
Host policy modules
The preferred install surface groups host-owned callbacks by concern:
- client registry ->
:client_store(load_client,verify_client_secret,client_jwks, client metadata) - principals ->
:principal_store(load_principal,build_principal, principal kinds) - scope policy ->
:scope_policy(authorize_scope, supported scopes) - login / consent ->
:consent_policy(authenticate_resource_owner,consent) - claims ->
:claims_provider(build_userinfo_claims/3,build_id_token_claims/4) - audit / telemetry ->
:event_sink(on_event) - dynamic registration ->
:registration(only with registration)
Flat callback keys such as :load_client, :verify_client_secret,
:client_jwks, :load_principal, and :authorize_scope are still accepted and
take precedence when present. Use them for small installs or targeted overrides;
use behaviour modules for production wiring.
Other deployment callbacks remain flat because they are endpoint mechanics, not
domain policy: :send_error, :www_authenticate, :no_store, :cert_der,
:require_https, and :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 /.well-known/oauth-protected-resource(RFC 9728 metadata)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, OAuth
token-exchange, and JWT-assertion (jwt-bearer) 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.
When :request_object_policy is configured, signed request objects are verified
at PAR submission and re-verified at /authorize; verified request-object
parameters are authoritative over unsigned request body/query values. Set
Attesto.RequestObject.Policy.fapi_message_signing/0 to enforce the FAPI 2.0
Message Signing JAR profile.
The authorization endpoint also emits JARM responses when the validated request
uses response_mode=jwt, query.jwt, fragment.jwt, or form_post.jwt.
Discovery advertises the supported response modes and the server signing
algorithms used for authorization response JWTs.
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.
When :resource_metadata is set on the config, a 401 challenge carries the
RFC 9728 resource_metadata pointer to the /.well-known/oauth-protected-resource
document (mounted by attesto_routes/1), so a client refused without a valid
token can discover which authorization server issues tokens for the resource.
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/1The 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.
Req DPoP clients
attesto_phoenix is the server-side Phoenix layer. If you also use
Req for OAuth clients in tests or internal
tooling, req_dpop generates RFC 9449 DPoP
proofs that interoperate with AttestoPhoenix.Plug.Authenticate. It is not a
runtime dependency of this package; attesto_phoenix uses it only in tests as
an external client compatibility check.
Database migration
The library owns five operational tables backing the attesto store behaviours:
attesto_authorization_codes, attesto_refresh_tokens, dpop_nonces,
dpop_replays, and attesto_pushed_authorization_requests. It does not own
a clients table (that is yours, behind :load_client).
Generate the migration into your app:
mix attesto_phoenix.gen.migration --repo MyApp.Repo
Then run it:
mix ecto.migrate
Clustering
Every mutable OAuth store has a Postgres-backed implementation, so a clustered
or load-balanced deployment holds no OAuth state per node — a request can bounce
across machines mid-flow. Access tokens are stateless signed JWTs (any node
validates any token against the shared keystore); everything else lives in
Postgres with atomic single-use enforcement (DELETE … RETURNING for codes and
PAR references, conditional UPDATE for nonces, INSERT … ON CONFLICT for the
replay cache, transactional refresh rotation/family revocation).
To be fully clusterable, wire the Ecto stores (the mix attesto_phoenix.install
config block does this by default):
code_store: AttestoPhoenix.Store.EctoCodeStore,
refresh_store: AttestoPhoenix.Store.EctoRefreshStore,
nonce_store: AttestoPhoenix.Store.EctoNonceStore,
replay_check: {AttestoPhoenix.Store.EctoReplayCheck, :check_and_record},
par_store: AttestoPhoenix.Store.EctoPARStoreSingle-node deployments may instead leave the defaults (in-memory ETS for
nonces, replay, and PAR); the Ecto variants exist for clustered correctness.
PAR is the one to watch: its default is single-node ETS, but FAPI 2.0
requires PAR, so a clustered FAPI deployment must set
par_store: AttestoPhoenix.Store.EctoPARStore or a pushed request_uri will not
resolve on the node that later handles /authorize.
Guides and examples
- Example configurations - confidential and public-client configuration sketches.
- Consumer migration - moving from a custom or legacy OAuth route surface while keeping historical migrations compiling.
- Proxy and canonical host - issuer, forwarded header, and HTTPS behavior behind proxies/CDNs.
- Replay and nonce production notes - shared-store requirements for clustered DPoP replay and nonce handling.
- Error envelope hooks - using
:send_errorand related callbacks to keep a host application's API error format. - Identity Assertion grant (ID-JAG / MCP EMA) -
enabling the
jwt-bearergrant, configuring trusted issuers, and wiring the subject-resolution callback. - Livebook demo - a self-contained
Phoenix/Bandit resource-server demo using
Req+req_dpop.
Development
mix deps.get
mix precommit
mix test --include ecto # requires Postgres
License
MIT. See LICENSE.