AttestoPhoenix

Hex.pmHexdocs.pmElixir CILicense: MITElixirOpenID Certified

An opinionated Phoenix/Ecto OAuth 2.0 / OIDC authorization server on top of attesto.

The Attesto family also includes:

OpenID Certified

An authorization server built from attesto + attesto_phoenix is OpenID Certified to FAPI 2.0 Security Profile Final — OP, FAPI 2.0 Message Signing Final — OP, FAPI-CIBA — OP, OpenID Connect Basic — OP and Config — OP, RP-Initiated, Back-Channel, and Front-Channel Logout — OP, and Session Management — OP — the first Elixir provider with FAPI 2.0 certification.

FAPI 2.0 CertifiedFAPI-CIBA CertifiedOpenID Connect CertifiedLogout Profiles CertifiedSession Management Certified

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:

Attesto owns the standards route catalog and protocol controllers; the host chooses route mounts and route pipeline classes declaratively. It deliberately does not own your client registry, principal store, secret hashing, scope catalog, resource-owner authentication, consent, or audit log. Those are application policy and are supplied through neutral configuration callbacks.

What you can build with it

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

Concernattesto (core)attesto_phoenix (this package)
JWT mint/verify, JWKS, DPoP, mTLS, PKCE, scopesyesreuses core
private_key_jwt, signed request objects, JARM, token exchange primitivesyeswires into endpoints
Grant orchestration primitivesyesreuses core
HTTP endpoints + router macronoyes
Protected-resource plugscore plug building blocksPhoenix-friendly wrappers
Ecto-backed token storesstore behaviours onlyEcto implementations
Client registry, principals, keys, auditnosupplied 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

Add attesto_phoenix to your dependencies:

def deps do
[
{:attesto_phoenix, "~> 3.0"}
]
end

The optional Igniter installer needs igniter available while you run it. It is not a runtime dependency of this package:

def deps do
[
{:attesto_phoenix, "~> 3.0"},
{:igniter, "~> 0.5", only: [:dev], runtime: false}
]
end

Upgrading from 2.x

Version 3.0 requires attesto 2.x. Upgrade both packages together, then review any custom store modules against the new core contracts:

Drain every 2.x token writer before starting 3.0, even when the old deployment used the default public schema. Version 3.0 adds durable refresh-family revocation tombstones that 2.x nodes do not read or write, so mixed 2.x/3.0 writers are unsupported. Apply the generation-index migration and create/backfill the tombstone table, then start 3.0 and re-enable traffic. A non-empty 2.x :table_prefix value does not identify one runtime layout: the old migration generator could create literal-prefixed tables in public, most runtime stores used canonical public tables, and only the CIBA store and sweeper treated the value as an Ecto schema prefix. Inventory the actual source for every table and complete the stopped procedure in the 3.0 schema-prefix upgrade guide; do not infer it from configuration.

Before deploying, apply a forward Ecto migration for the unique (family_id, generation) index on attesto_refresh_tokens if your existing 2.x database does not already have it. The exact Ecto operation is:

def up do
prefix = nil # Replace with your configured PostgreSQL schema name when non-default.
create unique_index(
:attesto_refresh_tokens,
[:family_id, :generation],
name: :attesto_refresh_tokens_family_id_generation_index,
prefix: prefix
)
end

Use the same prefix value as the runtime Ecto stores (nil for public, or the configured PostgreSQL schema name). The index name is intentionally bound to attesto_refresh_tokens_family_id_generation_index, including when a non-default prefix is used. If creation fails because duplicate family/generation rows already exist, stop the migration, reconcile the affected families, and revoke those families before retrying. Do not blindly delete duplicate rows: first determine which token lineage is authoritative and preserve the security audit trail. New installations can generate the complete migration with mix attesto_phoenix.gen.migration --repo MyApp.Repo; do not rerun that create-table migration against an existing database.

The generated migration also creates attesto_refresh_family_revocations, a durable tombstone table used by the bundled refresh store. If an existing database is upgrading to this release, apply a forward migration for that table before deploying the new code and backfill it from every existing attesto_refresh_tokens row where family_revoked = true (using the same schema_prefix as the refresh table):

def up do
prefix = "oauth" # Use nil for public; use one validated schema everywhere.
schema = prefix || "public"
create table(:attesto_refresh_family_revocations, primary_key: false, prefix: prefix) do
add :family_id, :string, primary_key: true, null: false
add :revoked_at, :utc_datetime, null: false
end
execute("""
INSERT INTO "#{schema}".attesto_refresh_family_revocations (family_id, revoked_at)
SELECT DISTINCT family_id, CURRENT_TIMESTAMP
FROM "#{schema}".attesto_refresh_tokens
WHERE family_revoked = true
ON CONFLICT (family_id) DO NOTHING
""")
end

The example qualifies both tables from the reviewed schema value; validate that value and keep it identical to schema_prefix. Do not rerun the complete create-table migration against an existing database.

Keep AttestoPhoenix.Store.Sweeper supervised after the host repo and set a positive :sweep_interval_ms. The sweeper removes expired rows and clears expired refresh-successor ciphertext; a custom cleanup job must provide both operations. The installer adds the child idempotently when rerun.

If the bundled AttestoPhoenix.Store.EctoRefreshStore retains the default positive retry grace, set one stable ATTESTO_REFRESH_SUCCESSOR_SECRET of at least 32 bytes in runtime configuration on every node before boot. Production configuration leaves this absent or short value for AttestoPhoenix.Config.new/1 to reject only when that bundled store and positive grace are selected. A custom refresh store or refresh_token_rotation_grace_seconds: 0 remains valid without a usable Ecto successor secret.

Quick 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

When the generated configuration keeps the bundled Ecto refresh store and its positive retry grace, provision one stable ATTESTO_REFRESH_SUCCESSOR_SECRET of at least 32 bytes before the first non-development boot. Config validation rejects that combination when the secret is absent or too short; custom refresh stores and strict zero-grace deployments do not need this Ecto-specific key. Every node serving the same refresh-token families must use the same value.

Use --oauth-path-prefix when the OAuth endpoints should not live under /oauth:

mix attesto_phoenix.install --oauth-path-prefix /mcp/oauth

The installer accepts /oauth or a prefix ending in /oauth only, with slash-separated literal segments containing only letters, digits, _, or -. The bundled router owns fixed /oauth/* endpoint tails. /mcp/oauth therefore generates attesto_routes(prefix: "/mcp") and advertises the routes it mounts; an arbitrary suffix such as /auth is rejected before any files change. If a deployment needs a different suffix or a per-endpoint path override, mount the matching routes manually and configure the advertised paths together.

For a fresh installation in a non-default PostgreSQL schema, pass the same validated schema to the installer and migration generator:

mix attesto_phoenix.install --schema-prefix oauth
mix attesto_phoenix.gen.migration --repo MyApp.Repo --schema-prefix 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/config.exs
# Points controllers and Ecto-backed stores at the host application.
config :attesto_phoenix,
otp_app: :my_app,
repo: MyApp.Repo
config :my_app, AttestoPhoenix.Config,
# --- required ---
issuer: "https://auth.example.com",
audience: "https://api.example.com",
keystore: MyApp.Keystore, # implements Attesto.Keystore
repo: MyApp.Repo, # Ecto.Repo for the token stores
principal_kinds: {MyApp.OAuth.PrincipalStore, :principal_kinds},
# 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(),
client_auth_enforce_fapi_alg_policy: true,
request_object_policy: Attesto.RequestObject.Policy.generic(),
# --- optional deployment + features ---
# Incoming request transport gate only; issuer and advertised endpoint URLs
# remain HTTPS-only regardless of this value.
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,
refresh_token_rotation_grace_seconds: 60,
schema_prefix: nil, # PostgreSQL schema; nil means `public`
sweep_interval_ms: 60_000,
authorization_code_ttl: 60,
authorization_grant_id_claim: "https://api.example.com/claims/oauth_grant_id",
dpop_enabled: true,
dpop_nonce_required: false,
mtls_enabled: false, # RFC 8705 certificate-bound tokens
# The terminator must overwrite, never append/forward, the client-cert header.
# This callback is invoked only from trusted_proxies:
forwarded_cert_der: &MyApp.TLS.forwarded_client_cert_der/1,
client_certificate_chain_validated?: &MyApp.TLS.chain_validated?/2,
token_endpoint_auth_methods_supported: ["private_key_jwt", "tls_client_auth"],
client_mtls_metadata: &MyApp.OAuth.Clients.mtls_metadata/1,
mtls_endpoint_aliases: %{
"token_endpoint" => "https://mtls.auth.example.com/oauth/token"
},
registration_enabled: false, # if true, also set registration callbacks
# RFC 8707 resource indicators (optional; see below)
resource_indicators: [
allowed_resources: ["https://api.example.com/a", "https://api.example.com/b"],
allowed_resources_for: {MyApp.OAuth, :resources_for} # optional per-client (client -> [uri])
]

The bundled Ecto refresh store encrypts the retry record used by a non-zero refresh-rotation grace period. Load its key from runtime configuration:

# config/runtime.exs
config :attesto_phoenix,
refresh_successor_secret:
System.fetch_env!("ATTESTO_REFRESH_SUCCESSOR_SECRET")

Use at least 32 bytes and keep the value identical across every node and deployment that serves the same refresh-token families. Changing or losing it prevents recovery of an in-flight rotation retry. AttestoPhoenix.Config refuses the bundled Ecto store with a non-zero grace period when this setting is missing or too short. Set refresh_token_rotation_grace_seconds: 0 only when you intentionally prefer strict immediate reuse handling and do not need retry recovery. The installer writes an idempotent runtime entry with this production fail-fast behavior and an explicitly development/test-only fallback.

Successor retry state written by this release uses an authenticated v2 envelope bound to the parent hash, family, parent generation, child hash, and fixed retry deadline. After a stopped cutover from 2.x to 3.0, the store also accepts the v1 envelope (the package-wide authenticated-data value used by older releases). Mixed 2.x/3.0 deployments are unsupported. That v1 compatibility path has a deliberately reduced binding: the ciphertext is authenticated but is not cryptographically tied to its parent or family, so the store additionally requires durable child lineage before it can expose a recovered successor. Plaintext successor maps are never accepted from the database. Complete the upgrade and allow the sweeper to redact v1 state; new writes are always v2-bound.

When wiring the Ecto stores by hand, supervise the sweeper after your repo. It removes expired rows and irreversibly redacts the encrypted successor on the first scheduled sweep after its short retry window has passed:

# lib/my_app/application.ex
children = [
MyApp.Repo,
{AttestoPhoenix.Store.Sweeper,
config: AttestoPhoenix.Config.from_otp_app(:my_app)}
]

mix attesto_phoenix.install adds this child and the 60-second sweep interval for you. A manual Ecto configuration with positive refresh retry grace must do both; ordinary expired-row pruning alone does not promptly remove successor ciphertext from a still-live refresh-token row. Supervise one sweeper per independent {repo, schema_prefix} pair when the host serves multiple request-scoped profiles; never share one sweeper across schemas or start duplicate sweepers for the same pair.

Build the validated struct wherever you need it:

config = AttestoPhoenix.Config.from_otp_app(:my_app)

Required keys are validated at build time so misconfiguration fails fast. AttestoPhoenix.Plug.PutConfig performs that resolution for mounted routes and places both the host config and its derived Attesto.Config in conn.private. Direct mTLS adapters expose the authenticated certificate through peer data; TLS terminators configure :forwarded_cert_der plus :trusted_proxies.

Authorization-grant identity

Set :authorization_grant_id_claim when a protected resource needs a stable signed correlation handle for access tokens descended from one authorization code:

config :my_app, AttestoPhoenix.Config,
authorization_grant_id_claim: "https://api.example.com/claims/oauth_grant_id"

The feature is disabled by default. When configured, Attesto Phoenix generates a 128-bit authorization family ID (22 unpadded Base64URL characters), stores it as the authorization code's family_id, and signs that exact value into the initial and refreshed access tokens. The refresh token itself has an independent internal family ID owned by Attesto 2.0; refresh rotation and lost-response retry preserve the authorization claim while every access token receives a fresh jti.

Within access tokens the library owns the value: host principal/code claims cannot replace it, and other grant types strip the configured claim instead of signing a fabricated or inherited value. That ownership is access-token specific — :build_id_token_claims and :build_userinfo_claims are trusted host callbacks whose output is not filtered, so a host that stamps the same claim name into an ID Token or UserInfo response owns that value itself. An authorization-code grant that does not issue a refresh token still receives the access-token claim but creates no refresh row.

A code issued without a family_id — possible only when a host mints codes itself rather than through the authorization endpoint — is permanently ineligible. Neither its initial nor any refreshed access token carries the claim, so claim presence never changes within one family. Redemption may still create an internal refresh family. Refresh-token rotation continues normally, and refresh-token reuse detection continues to revoke that family. When the configured code store supports reuse tracking, authorization-code replay also binds to the actual refresh family issued for that redemption, independently of whether the configured authorization claim exists. The claim is a correlation handle, not proof that a refresh family exists or remains active. No migration is required because issuance reuses the existing family_id fields. Use a private claim name under a namespace you control; do not use OIDC sid, which identifies an OP browser session with a different lifecycle.

Resource indicators (RFC 8707)

When one authorization server fronts more than one protected resource (say an admin API and an end-user API, or several MCP endpoints), a single fixed aud cannot separate a token meant for one from a token meant for another — only scope would, and scope is application policy, not a cryptographic boundary. RFC 8707 fixes that: a client names the resource it wants with a resource parameter, and the AS mints the token's aud to that identifier, so a token issued for resource A is structurally invalid at sibling resource B.

It works across every grant. A client sends resource on the authorization request (bound to the code) or the token request (client_credentials, token exchange, jwt-bearer); the token endpoint mints aud from it, refresh carries and may narrow it (subset-only), and token exchange cannot widen aud beyond the subject token's. One or more resources are allowed (a multi-resource grant mints a JWT aud array). A requested resource the server does not serve is rejected with invalid_target.

resource_indicators[:allowed_resources] lists the resource identifiers this server is willing to mint for (besides its own :audience, always served); :allowed_resources_for is an optional (client -> [uri]) callback for per-client scoping. With neither set and no resource requested, issuance keeps the single configured :audience — so single-resource deployments need no change. This is the issuer half of the RFC 9728 ↔ RFC 8707 chain: a resource advertises its identifier via protected-resource metadata, the client echoes it as resource, the AS mints that aud, and the resource server validates it. See attesto_mcp_server for the complete MCP integration or attesto_mcp for only the lower-level resource boundary.

Native apps (RFC 8252)

RFC 8252 (BCP 212) profiles OAuth for applications installed on the end user's own device. Most of it binds the client; the authorization server's obligations are narrow, and they are all keyed on one host-supplied fact — a :client_native? callback saying this client is an installed app:

config :my_app, AttestoPhoenix.Config,
client_native?: {MyApp.OAuth, :client_native?} # (client -> boolean), default false

That callback is the whole decision. A client it returns true for gets the RFC 8252 profile; everything else is untouched, and a deployment that never wires it has no native clients and so behaves exactly as before.

Marking a client native applies three rules to it:

Three things to know before marking a client native.

  1. Where no :client_public? callback is configured at all, a native client counts as public — so marking it native both refuses its secret and admits it on none + PKCE.
  2. A native public client cannot use PAR: that endpoint refuses secretless clients and §8.4 refuses this one a secret, so a deployment running require_pushed_authorization_requests: true cannot also serve native public clients.
  3. §7.3 is the only rule here that widens a check, and exact redirect-URI matching is assumed by the OpenID Connect and FAPI profiles. A deployment certifying against those normally has no native clients, so the answer is simply not to mark any — but native_apps: [loopback_redirect: false] forbids the exception server-wide if you need a hard switch.

Two compatibility/posture rules are genuinely opt-in, because unlike the three above they apply server-wide rather than expressing a per-client fact:

config :my_app, AttestoPhoenix.Config,
native_apps: [
loopback_include_localhost: true,
reject_embedded_user_agents: true
]

Serving the platform association files (apple-app-site-association, assetlinks.json) that claim HTTPS app links is app distribution, not OAuth, and is left to the host.

URL client metadata for native clients

Some clients identify themselves with an HTTPS Client ID Metadata Document URL instead of a row in the host client registry. Enable CIMD only when that client class is required:

config :my_app, AttestoPhoenix.Config,
client_id_metadata: [
enabled: true,
allowed_hosts: ["client.example"]
],
native_apps: [loopback_include_localhost: true]

The default CIMD fetcher requires the package's optional Req dependency. The resolver validates the HTTPS client ID, applies DNS/IP SSRF controls, bounds the document and timeout, validates redirect URIs and keys, and caches only the validated result. Keep allowed_hosts narrow when the clients are known in advance. The localhost option affects only redirect matching; it does not allow loopback client-metadata URLs or weaken the outbound fetch guard.

Host policy modules

The preferred install surface groups host-owned callbacks by concern:

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 :attesto_phoenix_config do
plug AttestoPhoenix.Plug.PutConfig, otp_app: :my_app
end
scope "/" do
attesto_routes(pipeline: :attesto_phoenix_config)
end
end

The macro's :prefix is the path before its fixed /oauth/* tails. For the usual /mcp/oauth/* mount, use attesto_routes(prefix: "/mcp", ...) and set oauth_path_prefix: "/mcp/oauth". Per-endpoint path overrides are supported for hosts that manually mount the corresponding route; they do not add or move routes in the bundled macro, so a custom advertised path must have a matching host route.

The installer writes this pipeline and repairs route output from older installer releases that mounted attesto_routes/1 without it. Add any shared transport-only plugs to the same pipeline; use :route_pipelines for browser session or content-negotiation differences.

When interactive routes need host session/resource-owner support that protocol clients must not inherit, classify the generated routes without hand-writing the route catalog:

attesto_routes(
pipeline: :oauth_common,
route_pipelines: [
interactive: [:oauth_interactive, :oauth_common]
],
registration: true
)

:metadata covers discovery, OpenID configuration, JWKS, and protected-resource metadata; :interactive covers authorization, device verification, end-session, and check-session; :protocol covers the remaining OAuth/OIDC endpoints. Each override is the complete ordered list for that class, while omitted classes use pipeline:. The host owns the actual session, resource-owner authentication, CSRF, and content-negotiation policy. In particular, do not place externally submitted OAuth POST endpoints behind generic browser CSRF or browser-only Accept handling. Write pipeline names as literal atoms/lists inside the Phoenix scope; module attributes are not available when Phoenix expands the nested route macro.

The OIDC-only local route mounts default on for compatibility. An OAuth authorization server that does not act as an OpenID Provider can retain authorization, token, PAR, revocation, introspection, JWKS, and RFC 8414 metadata while omitting both declarations:

attesto_routes(
userinfo: false,
openid_configuration: false
)

These flags are compile-time route-mount controls; metadata is built later from runtime AttestoPhoenix.Config. userinfo: false removes both local UserInfo verbs. openid_configuration: false removes only the OIDC Provider Metadata route; the RFC 8414 authorization-server document remains mounted and its contents are unchanged.

UserInfo metadata keeps explicit host intent separate from a mechanically derived local endpoint:

For example, a host can remount its own implementation at the canonical path without losing discovery:

scope "/" do
pipe_through :attesto_phoenix_config
attesto_routes(userinfo: false)
get "/oauth/userinfo", MyAppWeb.UserInfoController, :show
end
config :my_app, AttestoPhoenix.Config,
userinfo_endpoint: "https://issuer.example/oauth/userinfo"

The derived-path comparison models Phoenix/Plug dispatch rather than generic URI cleanup: adapters discard empty path segments, Phoenix decodes each request segment once, and ./.. segments remain significant. It therefore handles leading, repeated, and trailing slashes, percent-encoded request segments, static or dynamic surrounding scopes, non-default ports, and forwarded router mounts without conflating a distinct route with the removed one.

A dynamic macro :prefix is not available to the root Provider Metadata request. Consequently, userinfo: false with retained OpenID configuration rejects a dynamic :prefix at compile time instead of silently advertising a dead derived endpoint. Put the dynamic portion in a surrounding Phoenix scope, where the metadata request realizes the same scope, or also set openid_configuration: false. Static prefixes remain supported.

OIDC conformance for features such as CIBA, logout, and session management relies on Provider Metadata, so those deployments must keep OpenID configuration enabled unless the host serves equivalent metadata separately. A dynamically discovered and dynamically registered OpenID Provider that issues access tokens must still satisfy OIDC's Discovery and UserInfo requirements; these independent macro controls do not make every route combination an OIDC-conformant deployment. If OpenID configuration is disabled, any configured UserInfo endpoint is advertised nowhere unless the host publishes an equivalent Provider Metadata document.

The bundled well-known routes are the standards-derived forms for an origin-only issuer such as https://issuer.example. If the issuer contains a path, OIDC Discovery and RFC 8414 derive two different path-bearing well-known locations; mount those routes explicitly instead of using the macro's fixed root discovery routes. Because the macro always owns its RFC 8414 route, a path-bearing issuer requires a manually declared route catalog rather than adding duplicate discovery routes alongside attesto_routes/1. Derived endpoint URLs are likewise resolved against the issuer origin: the issuer's path is not prepended, so a path-bearing issuer must also set :oauth_path_prefix (or the per-endpoint path overrides) so the advertised endpoints sit under its path.

attesto_routes/1 mounts:

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 RFC 8705 tls_client_auth / self_signed_tls_client_auth when :client_mtls_metadata is wired and the method is included in :token_endpoint_auth_methods_supported (the self-signed method also uses :client_jwks). That callback returns nil only for a client without an mTLS authentication registration; lookup errors and malformed results fail client authentication closed. A forwarded certificate is read only through :forwarded_cert_der from an adapter-reported immediate socket peer in :trusted_proxies; public requests cannot make an XFCC-style header authoritative, even if middleware rewrites conn.remote_ip from a forwarded header. Standard scheme/host/port rewrites are likewise ignored for an untrusted socket peer when deriving HTTPS and DPoP htu. The deprecated :cert_der callback is subject to the same gate so older header-based deployments fail closed until they configure their proxy allowlist and migrate. PKI authentication also requires :client_certificate_chain_validated? to return true. The TLS terminator must delete any client-supplied certificate header and replace it only from a successful client-certificate handshake; the application listener should be network-isolated so only the configured trusted terminators can reach it. TLS passthrough/direct peer certificates avoid this header boundary and are preferred when the deployment permits them. The token endpoint 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 :client_auth_signing_algs is omitted, client assertions use Attesto's FAPI allowlist and enforce its key policy: RSA signatures require a modulus of at least 2048 bits, and legacy EdDSA is FAPI-compatible only over Ed25519. The default discovery metadata includes both legacy EdDSA and RFC 9864's exact Ed25519 identifier. Supplying an explicit algorithm list selects a non-FAPI policy for compatibility; pair a narrowed FAPI list with client_auth_enforce_fapi_alg_policy: true as in the example above. An enforced list must be a subset of Attesto.SigningAlg.fapi_algs/0; invalid or incoherent lists fail when the server configuration is built rather than being advertised and rejected only at request time.

:client_assertion_audiences controls which aud values a private_key_jwt assertion may carry at the token endpoint (RFC 7523 §3). It defaults to the issuer identifier and the token endpoint URL, because the profiles disagree: FAPI 2.0 Security Profile Final §5.3.2.1 requires the issuer, while FAPI-CIBA ID1 audiences a token-endpoint assertion to the token endpoint URL. A deployment certifying to only one of them can narrow it to [config.issuer]. The other endpoints (PAR, introspection, device authorization) are issuer-only already. Narrowing is a conformance choice rather than a security one: both values name this server, so accepting either does not admit an assertion minted for a different authorization server.

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. Request-object policies follow the same presence rule: an explicit accepted_algs list is non-FAPI unless enforce_fapi_alg_policy is true. The named FAPI policy sets it to true, so copying that policy and narrowing accepted_algs retains the RSA-strength and Edwards-curve gate.

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.

The route plumbing is profile-neutral. A permissive standards-compliant OAuth deployment can admit PKCE-bound public clients and select its supported grants. A FAPI 2.0 Security Profile deployment coordinates policy settings and callbacks that require PAR, PKCE, asymmetric confidential-client authentication, sender-constrained access tokens, and the applicable algorithm constraints. The optional Message Signing profile adds signed request-object enforcement and JARM; Attesto.RequestObject.Policy.fapi_message_signing/0 provides the request-object policy for that profile. These are coordinated settings rather than a single profile switch, and they use the same token, authorization, PAR, discovery, DPoP, and mTLS implementations.

OpenID for Verifiable Credentials (OID4VC / EU wallet)

attesto_phoenix mounts the HTTP surface for the OpenID4VCI issuer and OpenID4VP verifier roles behind an EUDI-wallet-facing service, targeting the HAIP profile. The protocol logic and cryptography live in attesto (SD-JWT VC + mdoc + jwt_vc_json issue/verify, DCQL, Token Status List, SIOPv2, OpenID Federation); these routes wire it to Phoenix. All paths derive from configurable Config tails (nothing hardcoded) and mount only behind their feature flag:

attesto_routes(
credential_issuance: true, # OID4VCI issuer
presentation: true, # OID4VP verifier
status_list: true, # Token Status List revocation
federation: true # OpenID Federation entity configuration
)

credential_issuance: true mounts the OID4VCI issuer endpoints:

Requires: :build_credential, :credential_configurations_supported, a :pre_authorized_code_store and :c_nonce_store, the :credential_offer_store (for by-reference offers), and the :keystore (issuer signing key).

A wallet may also authenticate to the token endpoint with a Client Attestation JWT + PoP (attest_jwt_client_auth, the OAuth-Client-Attestation / -PoP headers) when :trusted_wallet_provider_jwks is configured — advertised in token_endpoint_auth_methods_supported.

presentation: true mounts the OID4VP verifier endpoints:

The host drives it through AttestoPhoenix.Verifier: create_presentation_request/2 builds + signs the request object (optionally with an x509_san_dns client-id + x5c), and presentation_result/2 polls the verified claims. Requires a :presentation_session_store, :verifier_client_id (or the x509 config), and the :keystore. Request-object signatures keep using that main keystore and derive their alg from its configured key. Encrypted direct_post.jwt responses additionally require a dedicated EC P-256 :verifier_encryption_keystore; there is deliberately no fallback to the main signing key.

status_list: true mounts GET /oauth/statuslist/:id, serving a signed statuslist+jwt built from the :status_list_store — the revocation target referenced by issued credentials.

federation: true mounts GET /.well-known/openid-federation, serving the signed OpenID Federation Entity Configuration (application/entity-statement+jwt) built from :federation_authority_hints and :federation_entity_metadata — the trust-chain anchor a federation resolver starts from.

Backchannel authentication (CIBA)

For decoupled authentication — where the device consuming the API is not the device the user approves on, such as a call-center agent's console, a POS terminal, or an AI agent acting on a user's behalf — mount CIBA with attesto_routes(ciba: true) and enable it in AttestoPhoenix.Config (ciba: [enabled: true]). With an explicit grant_types_supported list, also include urn:openid:params:grant-type:ciba; only an unset catalog adds it automatically. The client calls POST /oauth/bc-authorize to start a flow the user approves out of band on their own phone, then collects the tokens at the token endpoint: in poll mode the client polls until the user approves, and in ping mode the AS calls the client's notification endpoint when the tokens are ready. Signed authentication requests follow the FAPI-CIBA profile. The default CIBA request algorithm list (PS256 and ES256) retains the same FAPI key-strength checks. An explicit ciba: [request_signing_algs: ...] list is treated as non-FAPI unless paired with enforce_fapi_alg_policy: true; this lets a generic deployment opt into additional algorithms without weakening a narrowed FAPI policy accidentally. An enforced FAPI-CIBA list is limited to PS256 and ES256, as required by the profile. Because signed CIBA request JWTs carry replay-sensitive jti values, enabling the default signed-request policy also requires an explicit atomic :replay_check; the installer configures the cluster-safe Ecto implementation. The stored replay identity is a fixed-length digest scoped to the authenticated client, not the raw jti. An intentionally unsigned generic profile may opt out with ciba: [enabled: true, require_signed_request: false]; if that profile accepts an optional signed request, the request is still rejected unless a replay callback is configured.

Device Authorization Grant (RFC 8628)

For sign-in on input-constrained devices — a smart TV, a CLI, an IoT box with no browser or keyboard — mount the device grant with attesto_routes(device: true) and enable device_authorization: [enabled: true]. With an explicit grant_types_supported list, also include urn:ietf:params:oauth:grant-type:device_code; only an unset catalog adds it automatically. POST /oauth/device_authorization returns a device_code and a short human-typable user_code; the user enters that code on a second device at the verification page (/oauth/device_verification), while the device polls the token endpoint with the device_code until the user approves.

Logout and session management

Single-logout across relying parties and browser-session change detection. An ID Token minted for a session records the RPs to notify, and the end-session flow fans out to them:

Protecting resources

pipeline :api_protected do
plug AttestoPhoenix.Plug.Authenticate
end
pipeline :reports_read do
plug AttestoPhoenix.Plug.RequireScopes, "read:reports"
end
scope "/api", MyAppWeb do
pipe_through [:api, :api_protected]
scope "/reports" do
pipe_through :reports_read
get "/", ReportController, :index
end
end

AttestoPhoenix.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.

When conn.private[:attesto_phoenix_config] is present, that validated request config is authoritative; plug :config/:otp_app options are used only when the request-private value is absent. A malformed request-private value fails closed rather than falling back to global application configuration. Mount AttestoPhoenix.Plug.PutConfig in the pipeline when the plug should use a request-specific profile.

The plug assigns:

Bearer credentials default to the Authorization header only, matching bearer_methods_supported: ["header"] in protected-resource metadata. Configure bearer_methods_supported: ["header", "body"] only for resource servers that intentionally accept RFC 6750 form-body access_token credentials.

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 that static RFC 9728 resource_metadata pointer, preserving the single-resource default. A host serving several protected resources can instead select the correct pointer per request, or return nil when that surface has no applicable metadata declaration:

resource_metadata: "https://api.example/.well-known/oauth-protected-resource",
resource_metadata_resolver: {MyAppWeb.ResourceMetadata, :for_request}
def for_request(%Plug.Conn{request_path: "/alpha"}) do
"https://api.example/.well-known/oauth-protected-resource/alpha"
end
def for_request(%Plug.Conn{request_path: "/beta"}) do
"https://api.example/.well-known/oauth-protected-resource/beta"
end
def for_request(_conn), do: nil

The resolver is authoritative when present; it does not fall back to the static URL when it returns nil. An invalid runtime return is safely omitted rather than turned into a challenge or a request-time exception. A static Config value is validated by AttestoPhoenix.Config.new/1; a non-nil per-plug value is validated when AttestoPhoenix.Plug.Authenticate is initialized (at compile time under Phoenix's default Plug initialization mode). Explicit per-plug nil remains a valid, authoritative omission. Function callbacks must accept one argument, and MFA tuples must export the effective arity (the request plus any extra arguments, which are appended after it). The explicit per-plug option wins on core verification, TLS, revocation, and principal failures and skips the resolver.

The resolver is trusted configuration. Return pinned or allowlisted HTTPS URLs; do not construct a metadata authority from untrusted Host, forwarded, query, or arbitrary header values. The returned URL is never fetched or used as a redirect, and it is validated with the same HTTPS/host/no-fragment rules as the static value before it can enter a quoted challenge. The resolver runs once per protected-resource request — including requests that authenticate successfully, since the pointer must be selected before verification renders any challenge — so keep it fast and total. Resolver exceptions are not rescued: a callback that raises propagates the exception and fails the request (successful ones included) instead of rendering an authentication challenge.

The protected-resource integration still owns the actual RFC 9728 declarations. Publish one document per exact resource identifier, with the path-inserted well-known URI and matching resource member; do not collapse multiple identifiers into a root document. When no resource owns the origin root, use protected_resource_root: false and let the per-resource integration mount only the documents it owns.

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.

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 generated migration owns the operational tables backing the attesto store behaviours: attesto_authorization_codes, attesto_refresh_tokens, dpop_nonces, dpop_replays, and attesto_pushed_authorization_requests, plus two feature tables — attesto_client_id_metadata (the CIMD client-metadata cache) and attesto_consent_grants (the single-use, request-bound consent-grant primitive) — and the attesto_refresh_family_revocations table that retains refresh-family revocation tombstones after token-row cleanup. 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

When the host configures a non-default schema_prefix, the generator picks it up automatically from config :attesto_phoenix, otp_app: ... (or the current Mix project's app), so the command above remains aligned with runtime Ecto queries. Pass --schema-prefix to override it explicitly. This option selects one PostgreSQL schema through Ecto's prefix: option; it does not alter the canonical table names.

The 2.x host :table_prefix key, separate package-level config :attesto_phoenix, :table_prefix setting, and --table-prefix generator flag are rejected in 3.0. In v2.14.2 the generator could prepend a value to table names in public, while most runtime stores queried canonical public tables and only the CIBA store and sweeper used it as an Ecto schema prefix. The setting therefore cannot be silently translated into one 3.0 schema. Remove every old setting, inventory the actual source tables, and follow the 3.0 schema-prefix upgrade guide before deploying. Stop if more than one candidate for a logical table is non-empty.

The generated migration passes that prefix to both the table and every index; it does not prepend the prefix to the table or index name. In particular, its refresh-generation constraint is the named index attesto_refresh_tokens_family_id_generation_index shown above.

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, and a family-serialized refresh transaction). Refresh rotation locks the family and parent, consumes the parent, inserts exactly one child, and persists the authenticated-encrypted retry state in one database transaction. Matching concurrent retries therefore receive the committed successor, while reuse and revocation remain serialized against new family members; no partially rotated family is observable.

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.EctoPARStore

The Ecto refresh store also needs the stable ATTESTO_REFRESH_SUCCESSOR_SECRET runtime setting shown in Configuration whenever refresh_token_rotation_grace_seconds is greater than zero (the default).

Single-node deployments may instead use the in-memory ETS implementations for nonces, replay, and PAR; the Ecto variants exist for clustered correctness. Signed CIBA still requires an explicit :replay_check, so a single-node host using ETS must supervise Attesto.DPoP.ReplayCache and point the callback at &Attesto.DPoP.ReplayCache.check_and_record/2. 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. When require_pushed_authorization_requests: true, any custom PAR store must also implement atomic take/1; configuration rejects fetch-only stores.

Local HTTPS for development

attesto requires an https issuer (RFC 8414 §2), so a plain http://localhost dev server can't drive the OAuth / MCP flow — and there is deliberately no "disable https" switch. Instead, serve a locally-trusted mkcert certificate so https://localhost works with no tunnel and no downgrade.

Generate the certificate once:

mix attesto_phoenix.gen.dev_https

Then wire it into config/dev.exs in one line:

config :my_app, MyAppWeb.Endpoint,
https: AttestoPhoenix.DevTLS.https_opts(port: 4443)

Point your issuer at https://localhost:4443 and discovery, DPoP, and the RFC 8707 resource identifiers all line up. AttestoPhoenix.DevTLS.https_opts/1 raises (pointing back at the generator) if the certificate is missing — it never falls back to http. See the Local HTTPS guide for the full walkthrough and the tunnel-vs-mkcert tradeoff.

Guides and examples

Development

mix deps.get
mix precommit
mix test --include ecto # requires Postgres

License

MIT. See LICENSE.