For upgrades to 3.5 or later, also read the 3.5 migration guide. It adds explicit single-issuer migration and diagnostic policies while retaining the strict defaults described here.
Version 3.4.1 coordinates protocol-boundary hardening with Attesto 2.2.2, closes an authentication downgrade, and bounds outbound CIMD work. Upgrade the core package first, then deploy Attesto Phoenix 3.4.1 to every authorization-server node. It adds no database migration beyond the existing 3.4 migration.
Hosts using the bundled Ecto stores that have not applied the 3.4 migration must still generate and run it before deploying the new writers:
mix attesto_phoenix.gen.migration --upgrade 3.4 --repo MyApp.Repo
mix ecto.migrate
Refresh families require reauthorization
New refresh families persist the configured authorization-server issuer and verify it before rotation, retry, introspection, or revocation. This prevents a second issuer that shares the same refresh store and client identifier from accepting or mutating the family.
Families issued before 3.4.1 do not contain that binding and fail closed. Retire those families through a trusted administrative process and have users authorize again. The new endpoints cannot introspect or revoke their unbound credentials. Do not run old and new token endpoint nodes together: an old node can still accept an unbound family. Drain or upgrade every writer and token endpoint as one rollout. Existing families are never bound to an issuer or Client Instance Key on first reuse.
Hosts that configure :build_refresh_principal receive the verified :issuer,
subject, session, authorization context, and family identity. Use this callback
to recheck locked or deactivated users, terminated sessions, and invalid tenant
context before issuing a refreshed access token. Its successful principal is
used without a second :build_principal lookup; protocol-owned claims and the
authenticated client_id are reconciled before minting. Returning
{:error, :invalid_grant} revokes the family and returns a generic OAuth
invalid_grant response. Immediate lost-response retries can invoke the
callback again, so keep it side-effect-free or idempotent. When the callback is
omitted, the existing :build_principal behavior remains in use.
Expose the registered authentication method
Return each client's trusted token_endpoint_auth_method through your client
store or a flat callback. Do not derive it from the incoming request, and do not
infer it from which credentials the client happens to present.
defmodule MyApp.OAuth.ClientStore do
@behaviour AttestoPhoenix.ClientStore
# Other ClientStore callbacks remain host-owned.
@impl true
def client_auth_method(client), do: client.token_endpoint_auth_method
endFor hosts using flat callbacks:
config :my_app, AttestoPhoenix,
client_auth_method: &MyApp.OAuth.ClientStore.client_auth_method/1The callback may return a known method string or atom, or {:ok, method}.
Unknown methods, errors, nil, and malformed results reject authentication.
The presented method must match exactly: client_secret_basic and
client_secret_post are separate registrations. This check applies to token,
PAR, introspection, revocation, device authorization, and CIBA.
When no method callback is installed, host-owned confidential clients fail
authentication, even if the server currently supports only one method. Narrowing
the server catalog cannot establish a previously registered client's method.
Explicitly public clients may use none; CIMD clients use their validated
metadata document's declared method. A configured callback never falls back
when it returns invalid data.
Registration now generates secrets only for client_secret_basic and
client_secret_post authentication.
Existing secrets issued to private-key JWT, mTLS, or attestation clients cannot
bypass their registered method. Hosts should remove unused stored secrets from
those registrations according to their own registry and retention policies.
CIMD outbound and cache limits
For CIMD-enabled deployments, invalidate persisted metadata and attached JWKS
caches during the upgrade, after stopping older writers. Old records do not
contain the response headers needed to correct their previous freshness, and
the new resolver discards origin-supplied internal cache annotations. Call the
built-in cache's delete_all/0 for each repository/schema-prefix context;
install that context for the Ecto cache as shown below. Use your own
invalidation mechanism for a custom backend. The built-in
ETS cache is empty on a fresh node; clear it explicitly for an in-place upgrade.
AttestoPhoenix.Config.with_request_config(config, fn ->
AttestoPhoenix.ClientIdMetadata.Cache.Ecto.delete_all()
end)CIMD remains disabled by default. When enabled, metadata and remote JWKS share node-wide outbound limits: 16 concurrent requests, 4 per host, 120 requests per minute, and 30 per host per minute. Concurrent identical resolutions share one request, with up to 32 followers. Failures receive a one-second backoff, and host buckets and failure markers are each bounded to 1,024 entries. Over-limit requests fail closed rather than fetching metadata without a bound.
The current host allow/block policy is checked before a remote JWKS cache lookup. A valid, unexpired key cache hit performs no DNS lookup, outbound request, or flow-control admission. Cache misses enter the outbound limits and recheck the cache after admission before fetching under the configured fetcher's DNS and IP screening, pinning, redirect, timeout, and size controls. Cached keys never outlive their metadata document. Caches without the optional atomic entry API fetch keys on each request. Basic and form-post client authentication do not resolve unused remote JWKS.
The built-in caches retain at most 1,024 records: node-wide for ETS and per repository/schema prefix for Ecto. Each serialized record is limited to 16 KiB, including attached JWKS. Expired records are pruned, then records with the earliest expiry are evicted as necessary. Custom caches must provide their own capacity and size controls. Application instances share Ecto capacity enforcement; outbound quotas are per node, so cluster operators should also limit aggregate traffic at their ingress or proxy.
All limits are positive integer members of :client_id_metadata; see
AttestoPhoenix.Config for their names and defaults. Keep allowed_hosts
narrow where possible. Hostnames are compared and resolved as lowercase IDNA
ASCII names after removing one terminal DNS root dot. IP destination screening
and pinning remain active.
HTTP cache freshness now subtracts response age. A max-age=3600 response
already aged 3,599 seconds has at most one second of remaining freshness.
These are shared caches: s-maxage takes precedence over max-age when
present, and private responses are not retained. Duplicate and malformed
freshness headers are handled conservatively, including quoted directive
values, response Age and Date, transport delay, and local residence time.
The first cache_ttl_bounds value is a fallback for responses without explicit
freshness and never increases an explicit publisher lifetime. no-cache and
no-store responses are not retained. Conditional 304 responses remain rejected
because the bundled fetcher does not send validators.
Registered assertion clients can now revoke using issuer-audience JWTs, and registered mTLS and attestation clients can use their own credentials. The revocation endpoint preserves attestation Challenge and fresh-attestation errors so wallets can retry without falling back to a secret.
Configure duplicate detection before body parsing
Configure the endpoint's existing Plug.Parsers entry with Attesto's body
reader before the router:
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library(),
body_reader: {AttestoPhoenix.DuplicateParameterGuard, :read_body, []}Query strings are checked automatically. The body reader lets protocol
controllers reject repeated scalar form fields and duplicate JSON object names
at every nesting level before Phoenix turns the body into a map. Repeated RFC
8707 resource values remain supported. If the endpoint already uses a custom
body reader, forward its MFA through AttestoPhoenix.DuplicateParameterGuard
as described in that module's documentation.
Public clients require an explicit consent decision
Authorization requests from public clients no longer inherit the historical
implicit allow behavior when no consent callback is configured. Provide
AttestoPhoenix.ConsentPolicy.consent/3 or the flat :consent callback for
every deployment that serves public clients.
Dynamic registration is stricter
The registration endpoint now requires registration_enabled: true, a
registration callback, a single JSON Content-Type header, and a JSON object
body. Mounting the route alone does not enable registration. The endpoint
bounds collections and text, rejects external plain-HTTP redirects, applies an
HTTPS-only logout callback profile, validates public JWK and RFC 8705 identity
metadata, and requires private-key JWT keys to satisfy the configured algorithm
policy.
An omitted grant_types member now receives RFC 7591's
["authorization_code"] default. A present JSON null value for
grant_types, redirect_uris, or scope is invalid rather than equivalent to
an omitted member.
An explicit token_endpoint_auth_methods_supported catalog is now validated
at boot against Attesto Phoenix's accepted catalog. Remove client_secret_jwt
and custom extension names before upgrading; those values now fail
configuration instead of being advertised or offered to registration. The
already documented attest_jwt_client_auth_dpop combined value remains
unsupported and is omitted from the effective catalog.
If the host dereferences a registered jwks_uri, the host callback must screen
DNS and connected addresses, reject redirects, pin the approved destination,
and bound response bytes, media type, and time. Registered display metadata is
still untrusted and must be escaped at every HTML rendering sink.
Encrypted presentation responses have stricter boundaries
OID4VP direct_post.jwt responses now reject oversized encrypted envelopes,
duplicate JSON object members, and non-canonical Base64URL encodings before
decryption. The controller also enforces direct ECDH key agreement and the
required AES-GCM IV and authentication-tag lengths. Wallets must produce the
standard compact JWE encoding; malformed encodings no longer reach the
cryptographic dependency.