# Changelog
All notable changes to this project are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.10.0] - 2026-06-20
### Added
- **`GET /.well-known/oauth-protected-resource` endpoint
(`AttestoPhoenix.Controller.ProtectedResourceController`).** Serves the RFC 9728
protected-resource metadata document (`resource`, `authorization_servers`,
`scopes_supported`, `bearer_methods_supported`), derived from the same issuer,
audience, and scope configuration the RFC 8414 discovery document uses. Mounted
by `attesto_routes/1` at the host root (RFC 8615); it is the discovery target of
the `resource_metadata` `WWW-Authenticate` challenge the protected-resource
plugs emit, so a resource server is discovery-complete without the caller
hand-rolling the document.
- **`AttestoPhoenix.Config` `:resource_metadata`.** Absolute URL of this
resource's RFC 9728 protected-resource metadata document. When set,
`AttestoPhoenix.Plug.Authenticate` and the UserInfo endpoint advertise it as a
`resource_metadata` auth-param on every `WWW-Authenticate` challenge they
render (RFC 9728 §5.1), so a client refused with 401/403 can discover which
authorization server issues tokens for this resource. Configured once on the
Config; omitted from the challenge when unset.
- **`AttestoPhoenix.Config.new/1` now validates `:audience` at boot.** It must be a non-empty **absolute https URL** with a host and no fragment, not merely present. It
is the access-token `aud` (RFC 9068 §3), the audience the protected-resource
verifier requires (a mismatch is `:invalid_audience`), and the RFC 9728 resource
identifier served at `/.well-known/oauth-protected-resource` — so a nil, blank,
or non-URL value would either fail late (every token rejected `:invalid_audience`)
or 500 the protected-resource metadata endpoint. `new/1` now raises
`ArgumentError` instead. With RFC 8707 resource handling the minted `aud` may
differ per request, but `config.audience` remains the required default/fallback
and RS verification audience.
- **Identity Assertion JWT Authorization Grant (ID-JAG / `jwt-bearer`)** — the
resource server's half of
`draft-ietf-oauth-identity-assertion-authz-grant-04`, the grant behind MCP
Enterprise-Managed Authorization (EMA). A token request with
`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` and an `assertion`
(an ID-JAG signed by a trusted enterprise IdP) is exchanged for a normal
access token — no redirect, no consent.
- **Off by default**, gated by `jwt_bearer: [enabled: true, ...]`. When
enabled, `urn:ietf:params:oauth:grant-type:jwt-bearer` is advertised in
`grant_types_supported` (both discovery documents) and accepted at the token
endpoint; existing deployments are unaffected.
- **Trusted issuers** (`jwt_bearer: [issuers: %{...}]`): each issuer supplies
static `:jwks`, a cached `:jwks_uri` (fetched through the SSRF-guarded CIMD
fetcher + cache), or a custom `:jwks_resolver`; with `:allowed_algs` and an
optional `:audience` override. Assertions from unconfigured issuers are
denied.
- **Validation** via `Attesto.IdentityAssertion`: `typ=oauth-id-jag+jwt`,
signature against the issuer JWKS, `iss`/`aud`/`exp`/`iat` (with skew), the
required `client_id` binding to the authenticated client, and `jti` replay
(reusing the configured `:replay_check`). Every failure is RFC 6749 §5.2
`invalid_grant`; a missing `assertion` is `invalid_request`.
- **Subject resolution** via a new `:resolve_jwt_bearer_subject` callback
(also installable as `resolve_jwt_bearer_subject/1` on
`AttestoPhoenix.PrincipalStore`): maps the validated claims to a local
principal subject, or denies. Required at boot when the grant is enabled.
- The grant requires client authentication (confidential clients only) and
honours per-client `grant_types`. The assertion's `scope` claim is the
granted-scope ceiling; `:authorize_scope` narrows from there.
- **No refresh token is issued** for this grant: access is re-derived from a
fresh assertion on each request (RFC 7523 §4), so it cannot outlive the
enterprise IdP's policy/deprovisioning window.
- **RFC 8707 `resource` indicator → access-token `aud`** (via
`Attesto.Token.mint/3`'s `:audience` option, requiring `attesto ~> 0.8`): a
single valid resource becomes the minted `aud` (§2.2); an absent resource
falls back to `config.audience`. The resource is authorized fail-closed
(§2.2) — it must be `config.audience` or an explicitly configured
`jwt_bearer: [allowed_resources: [...]]` entry — so an authenticated client
cannot mint a token audienced to a resource the server does not serve. An
invalid (non-absolute-URI / fragment / bad percent-encoding), multiple, or
unauthorized resource is rejected `invalid_target` (§2.1).
- See [the ID-JAG guide](guides/identity_assertion_grant.md). Requires
`attesto ~> 0.8`.
### Changed
- Made OAuth error-code resolution (RFC 6749 §5.2) total by construction. The
`@error_*` codes in the token core, token controller, introspection
controller, and sender-constraint module are now compile-time atoms passed
straight to `OAuthError.new/3`, replacing a private `String.to_existing_atom/1`
round-trip that could raise `ArgumentError` and turn a clean §5.2 error body
into a 500 if a code string were ever emitted before its atom existed.
### Documentation
- **Documented and test-proved the DCR → `client_credentials` subject seam.**
Dynamic Client Registration (RFC 7591 §3.2.1) issues an *unprefixed*
`client_id`, while a minted principal's `sub` MUST carry its
`Attesto.PrincipalKind` `sub_prefix` (`:invalid_sub` otherwise). The host's
`:build_principal` callback is the sole seam that reconciles the two by
namespacing `:sub`; the `:build_principal` doc on `AttestoPhoenix.Config` and
the `c:AttestoPhoenix.PrincipalStore.build_principal/3` behaviour doc now state
this mandate and cite the prefix as mint-time defense-in-depth. A new
end-to-end test registers a confidential `client_credentials` client through
the registration endpoint, issues a token with the bare DCR id, and verifies
`sub == prefix <> client_id` and `client_id == client_id`, with a negative
control proving a non-prefixing `:build_principal` is rejected as the
RFC 6749 §5.2 `invalid_request`.
## [0.9.5] - 2026-06-16
### Fixed
- **Holder-of-key (DPoP) failures are now surfaced ahead of the client-auth
error (FAPI2 `ensure-holder-of-key-required`).** A token request redeeming a
sender-constrained (DPoP-bound) authorization code WITHOUT a DPoP proof is a
holder-of-key failure; FAPI2 expects it reported as
`invalid_request`/`invalid_grant`/`invalid_dpop_proof`. When such a request
ALSO lacked client authentication, the client-auth check masked it with
`invalid_client`. The token endpoint now reads the code (via the store's
non-consuming `c:Attesto.CodeStore.get/1`) and, when it is DPoP-bound and no
proof is presented, returns `invalid_request "DPoP proof required"` — even
before the client-auth failure. The code is NOT consumed, so a legitimate
retry is unaffected. Only DPoP-bound codes are affected; a plain (e.g. OIDC)
code still surfaces `invalid_client`. Requires attesto 0.7.2.
### Added
- **`AttestoPhoenix.Store.EctoCodeStore.get/1`** — the non-consuming read
(`c:Attesto.CodeStore.get/1`) for the Ecto-backed code store, a plain SELECT of
the live (unconsumed) row.
## [0.9.4] - 2026-06-14
### Security
Adversarial-review hardening of the token, authorization, and revocation
endpoints (all found by an internal multi-agent security review).
- **Public clients can no longer run confidential-only grants.** The token
endpoint gated grants only on the optional per-client `:client_grant_types`
callback (unset ⇒ all grants allowed), so a public (`none`) client that proved
possession of no credential could run `client_credentials` (RFC 6749 §4.4) or
RFC 8693 token-exchange. The resolved client-auth method is now threaded into
the request, and both grants reject the `:none` path with `invalid_client`,
independent of any host policy.
- **The revocation endpoint now enforces TLS.** `RevocationController` never
called `check_https`, so under the default `require_https: true` a plain-HTTP
`POST /oauth/revoke` carrying the client secret + refresh token was still
processed — leaking both over cleartext. It now gates on TLS first, like every
other credential-bearing endpoint.
- **DPoP proofs are replay-protected at the token endpoint (RFC 9449 §11.1).**
`SenderConstraint.bind_dpop` never wired `:replay_check`, so a captured
token-endpoint proof's `jti` was never recorded and the proof was replayable
within its acceptance window. The proof's `jti` is now recorded (via the same
default `Attesto.DPoP.ReplayCache` the PAR endpoint uses).
- **The direct (non-PAR) authorization endpoint honors a signed `dpop_jkt`.** It
read `dpop_jkt` from the raw outer query, ignoring the signed request object —
letting a front-channel attacker strip or substitute the code's DPoP key
binding. It now reads the value off the verified request
(`Attesto.AuthorizationRequest.dpop_jkt`, requires attesto 0.7.1), so a signed
request object's value is authoritative. The PAR-resolved path continues to use
the PAR-verified thumbprint stored at the top level (a pushed request object is
re-merged at `/authorize`, which would otherwise drop it).
- **The dynamic client registration endpoint now enforces TLS.**
`RegistrationController.create/2` returns a freshly minted plaintext
`client_secret` and `delete/2` reads a registration-access-token bearer
credential; neither gated on TLS, so under the default `require_https: true`
they served those credentials over cleartext. Both now refuse plain HTTP first,
like every other credential-bearing endpoint. (Found by adversarial
verification of the revocation fix — same class, uncovered sibling.)
- **The revocation endpoint equalizes client-auth timing.** A lookup failure
skipped `verify_client_secret`, leaving a timing oracle for client-id
enumeration. It now runs a dummy verify against an `:unknown_client` sentinel
so the unknown-client and wrong-secret paths match in observable timing,
matching the shared `AttestoPhoenix.ClientAuthentication` core.
- **CIMD SSRF guard covers Teredo and ORCHIDv2.** Added `2001:0000::/32` (Teredo)
and `2001:20::/28` (ORCHIDv2) to the RFC 6890 special-use IPv6 table the
fetcher screens against.
## [0.9.3] - 2026-06-14
### Security
- **Token exchange can no longer broaden scope (RFC 8693 §2.1).** The
token-exchange grant validated the requested `scope` only against the host's
`:authorize_scope` policy, which is never handed the subject token — so the
library could not, and did not, enforce that the issued token's scope stays
within the subject token's. A client registered for a broad scope set could
exchange a narrowly-scoped subject token for a broader one. The token endpoint
now rejects (`invalid_scope`) any requested scope not present in the subject
token's scope, before delegating to `:authorize_scope` — an exchange can only
preserve or narrow scope. (`:authorize_scope` may still narrow further.)
- **The token endpoint now enforces `grant_types_supported`.** Previously the
only grant gate was the optional per-client `:client_grant_types` callback
(unset ⇒ every grant allowed), while discovery advertised a hardcoded grant
superset including token-exchange — so a host that didn't lock every client
down had an advertised, working token-exchange grant it never opted into. The
token endpoint now rejects (`unsupported_grant_type`) any `grant_type` outside
the configured set, as a global backstop independent of per-client policy.
### Changed
- **`grant_types_supported` is now driven by host config, not a hardcoded list.**
Both discovery documents (RFC 8414 and OpenID configuration) and the new token
endpoint gate read `AttestoPhoenix.Config.grant_types_supported/1`, which
defaults to every implemented grant (so existing deployments are unchanged) and
is narrowed by configuring `:grant_types_supported` — dropping a grant (e.g.
token-exchange) now disables it across discovery, the token endpoint, and
dynamic registration at once, instead of only registration.
## [0.9.2] - 2026-06-14
### Fixed
- **A CIMD client no longer crashes a host `:authorize_scope` policy.** A Client
ID Metadata Document need not declare a `scope` member, so the metadata map
attesto hands the host policy callbacks carried no scope key at all. A scope
policy written for a registered client (reading `client.scopes`) raised
`KeyError` on it, 500-ing the token endpoint for an otherwise valid CIMD
authorization_code exchange (observed end-to-end against the ChatGPT MCP
connector). `host_client/1` now exposes the document's *declared* scopes — or
an empty set when the document omits `scope` — under the atom `:scopes` key, so
the callback reads an empty *declared* set instead of a missing key. The host
still owns what an empty set grants (typically the resource owner's consent).
### Added
- **`AttestoPhoenix.ClientIdMetadata.scopes/1`** — the public accessor for a CIMD
document's declared scopes (its space-delimited RFC 7591 §2 `scope` member as a
list; `[]` when absent), alongside the existing `client_id/1`, `redirect_uris/1`,
and `jwks/1` accessors.
## [0.9.1] - 2026-06-14
### Added
- **Boot-time discovery-document safety guard.** `AttestoPhoenix.Config.new/1`
now validates, at config-build time (alongside the existing required-key
checks), that the discovery documents it will serve are internally consistent —
so a "silent discovery mismatch" (a document that omits a required endpoint or
advertises one the router does not mount, served 200 with no error) can no
longer ship. It raises `ArgumentError` with an actionable message for two
classes of failure:
- **A required discovery endpoint that would be missing or non-absolute.** The
RFC 8414 §2 / OpenID Connect Discovery §3 endpoints the library derives —
`issuer`, `authorization_endpoint`, `token_endpoint`, and `jwks_uri` — must
each resolve to an absolute URL (scheme + host). The realistic trigger is an
`:issuer` that is not an absolute https URL (e.g. `"issuer.example"`), which
`URI.merge/2` turns into host-less, unresolvable endpoint URLs; this is the
same class of failure as the 0.9.1 regression where the RFC 8414 document
silently omitted `authorization_endpoint`.
- **An `:oauth_path_prefix` vs explicit per-endpoint override mismatch.** When a
host declares a non-default `:oauth_path_prefix` (committing every OAuth
endpoint to one mount tree) but then sets a per-endpoint override
(`:token_path` and friends) that escapes that prefix, discovery would
advertise that endpoint at a path the router — which mounts every OAuth
endpoint under one shared prefix — does not serve. That provable divergence
now fails fast. A per-endpoint override on the default prefix, or one that
stays under the declared prefix, remains a supported feature.
### Fixed
- **The RFC 8414 `/.well-known/oauth-authorization-server` document now advertises
`authorization_endpoint`.** It was omitted entirely: `Attesto.Discovery` derives
only `issuer`/`token_endpoint`, and the controller's host-member list never
supplied `authorization_endpoint`, so the OAuth metadata silently lacked a field
RFC 8414 §2 requires for the authorization-code flow. An OAuth client that reads
this document rather than OpenID Discovery (e.g. the ChatGPT MCP connector)
therefore concluded the server "does not implement OAuth." It is now derived via
`authorize_endpoint_url/1` — the same path resolution as `token_endpoint`,
so the two cannot diverge. (OpenID Discovery's `/.well-known/openid-configuration`
already advertised it.)
## [0.9.0] - 2026-06-14
Requires `attesto ~> 0.7.0`.
### Added
- **Client ID Metadata Documents (CIMD,
`draft-ietf-oauth-client-id-metadata-document-01`) — opt-in, default off.** A
client can identify itself with no prior registration by using an HTTPS URL as
its `client_id`; the authorization server dereferences that URL to a JSON
client metadata document and uses it as the client. Enable with
`client_id_metadata: [enabled: true, ...]` in the config.
- `AttestoPhoenix.ClientIdMetadata.Fetcher` (+ the default
`...Fetcher.Req`) — the SSRF-guarded outbound GET. It resolves the host,
rejects any A/AAAA address that is special-use (RFC 6890: loopback, private,
link-local, CGNAT, multicast, reserved, and every IPv6 form that embeds an
IPv4 — IPv4-mapped, NAT64 `64:ff9b::/96` and local-use `64:ff9b:1::/48`,
6to4, IPv4-compatible — unwrapped and re-checked), pins the connection to a
validated IP to close the DNS-rebinding window (TLS SNI/cert stay on the
original hostname), refuses redirects, requires `200` + JSON, and caps the
body at 5 KB. Requires the optional `:req` dependency, or a host-supplied
`:fetcher` (e.g. a CIMD proxy service).
- `AttestoPhoenix.ClientIdMetadata.Cache` (default Ecto, cluster-coherent;
ETS opt-out) — respects RFC 9111 cache headers clamped to bounds, never
caches errors/invalid documents, re-checks expiry on read. New table
`attesto_client_id_metadata` (`mix attesto_phoenix.gen.migration`), swept by
`AttestoPhoenix.Store.Sweeper`.
- `AttestoPhoenix.ClientIdMetadata.Resolver` + integration: a CIMD `client_id`
URL resolves via the document and is wired through the authorization, PAR,
and token endpoints as a `{:cimd, metadata}` client — PKCE forced, treated
as a public client (or `private_key_jwt` via the document `jwks`/`jwks_uri`),
`redirect_uri` exact-matched against the document's `redirect_uris` and (by
default) required to be same-origin with the `client_id` URL. Opaque
`client_id`s still resolve through `:load_client` unchanged.
- Discovery advertises `client_id_metadata_document_supported` when enabled.
- New optional dependency `{:req, "~> 0.5", optional: true}` for the default
CIMD fetcher (a host that never enables CIMD pays nothing).
## [0.8.0] - 2026-06-14
Requires `attesto ~> 0.6.16`.
### Added
- **`AttestoPhoenix.Store.EctoPARStore` — a Postgres-backed Pushed Authorization
Request store (RFC 9126), closing the last per-node gap to a fully clusterable
authorization server.** PAR was the only mutable OAuth store without an Ecto
implementation: the default `AttestoPhoenix.Store.PAR.ETS` keeps the
`request_uri` → params mapping in per-node memory, so a reference pushed to one
node could not be resolved when `/authorize` landed on another — and FAPI 2.0
*requires* PAR. The new store persists each pushed request so any node resolves
a `request_uri` issued by any other, matching the code/refresh/nonce/replay
Ecto stores. `fetch/1` is non-consuming (the authorization endpoint may
re-enter after a login/consent detour); `take/1` is an atomic single-use
`DELETE … RETURNING`.
- New `AttestoPhoenix.Schema.PushedAuthorizationRequest` (table
`attesto_pushed_authorization_requests`, keyed on the `request_uri` primary
key, `params` as `jsonb`).
- `mix attesto_phoenix.gen.migration` now creates the fifth table, and
`mix attesto_phoenix.install` wires `par_store: …EctoPARStore` by default, so
a by-the-docs install is cluster-safe out of the box.
- `AttestoPhoenix.Store.Sweeper` now also reclaims expired PAR references.
- **Atomic single-use of the PAR `request_uri` at completion.** The
authorization endpoint now claims the pushed reference with the store's atomic
`take/1` *before* issuing the code (it was previously consumed after issuance,
with the result ignored), so two concurrent completions — on one node or
across a cluster — can no longer each mint a code from one pushed request:
exactly one wins the claim; the loser is redirected `invalid_request_uri` and
issues nothing. Resolution still uses the non-consuming `fetch/1`, so a host
may establish login/consent and re-enter `/authorize` with the same reference.
### Changed
- README documents the clustering story end-to-end and the PAR caveat; the
`:par_store` config doc points at `EctoPARStore` for clustered/FAPI
deployments. The default `par_store` is unchanged (single-node ETS), so
existing single-node hosts are unaffected.
## [0.7.7] - 2026-06-13
Requires `attesto ~> 0.6.16`.
### Fixed
- **Token endpoint finalizes the authorization code only after the full
response is built.** The `authorization_code` grant now calls
`Attesto.AuthorizationCode.finalize/3` (new in attesto 0.6.16) once the access
token, optional refresh token, and id_token have all been minted and recorded
successfully. Previously the reuse marker was set the moment the code
validated, so any later failure in the same request (a refresh-store write
error, an id_token mint fault, a host `build_principal` callback returning the
subject under the wrong key) left the code spent AND flagged as a successful
redemption — turning a legitimate client retry into a false reuse attack that
revoked the whole refresh-token family. A redemption that validates but fails
downstream is now a clean `invalid_grant` on replay.
## [0.7.6] - 2026-06-12
Requires `attesto ~> 0.6.13`.
### Fixed
- The token endpoint no longer short-circuits a missing PKCE `code_verifier` as
`invalid_request`. PKCE enforcement is challenge-based:
`Token.fetch_code_verifier/3` passes the verifier through to
`Attesto.AuthorizationCode.redeem/4`, which requires a matching verifier for a
challenge-bound code and collapses a missing OR mismatched verifier to a single
`invalid_grant` (RFC 7636 §4.6). The authorization/PAR endpoint still requires a
`code_challenge` for clients that must use PKCE (`RequestPolicy.require_pkce?/2`),
so a challenge-bound code is always issued. Matches the FAPI
ensure-pkce-code-verifier-required test (it expects `invalid_grant`).
## [0.7.5] - 2026-06-10
Requires `attesto ~> 0.6.13`.
### Security
- **PAR `request_uri` is now single-use (RFC 9126 §2.2 / FAPI 2.0).** The
reference is consumed once an authorization code is issued (not on the
non-consuming `fetch` that lets the host establish login/consent and
re-enter), so a completed flow cannot be replayed within the remaining TTL.
An already-consumed reference is rejected as `invalid_request_uri`. (Flips the
conformance `PARAttemptReuseRequestUri` warning to a clean pass.)
- **UserInfo derives the DPoP `htu` via `RequestContext.canonical_url`**, like
every other endpoint — honouring a configured `:htu` but otherwise gating
`X-Forwarded-*`/Host on the trusted-proxy allowlist. Previously it fell back
to the raw request Host when `:htu` was unset (its default), the one endpoint
that bypassed the host-header trust boundary.
### Fixed
- **Sender-constrained (DPoP/mTLS) clients now require PKCE.** A FAPI 2.0 client
is sender-constrained, and FAPI 2.0 Security Profile §5.3.1.2 / RFC 9700
§2.1.1 mandate PKCE for it even though it authenticates confidentially (e.g.
`private_key_jwt`). `RequestPolicy.require_pkce?/2` now forces PKCE whenever
`client_requires_dpop?`/`client_requires_mtls?` is true, regardless of the
global `:require_pkce` flag, and the token endpoint enforces the matching
`code_verifier` through that same predicate (one source of truth, so the
authorization and token endpoints cannot drift). A plain confidential
Basic-profile client still follows the global flag. (Flips the conformance
`EnsurePKCERequired` test to a pass.)
## [0.7.4] - 2026-06-04
Requires `attesto ~> 0.6.13`.
### Security / FAPI 2.0 conformance
Closes four conformance gaps found by auditing the OpenID FAPI 2.0 test suite
source against the implementation:
- **PAR `request_uri` is bound to the client.** The authorization endpoint now
rejects a front-channel `client_id` that does not match the client the
`request_uri` was issued to (RFC 9126 §2.2 / `PAREnsureRequestUriIsBoundToClient`)
instead of silently using the stored client.
- **Unknown/expired PAR `request_uri` → `invalid_request_uri`.** A
`urn:ietf:params:oauth:request_uri:` reference not in the store now returns the
correct `invalid_request_uri` error rather than falling through to
`request_uri_not_supported`/`invalid_request` (RFC 9126 §2.2 /
`PARAttemptToUseExpiredRequestUri`). External (non-PAR) references still report
`request_uri_not_supported`.
- **PAR rejects a `request_uri` parameter.** The PAR endpoint rejects a request
carrying `request_uri` (RFC 9126 §2.1 step 2), checked on the raw parameters so
it cannot be masked by a `request` object replacing the set.
- **Client-assertion audience is issuer-only.** `private_key_jwt` assertions at
the token, PAR, and introspection endpoints must be audienced to the issuer
identifier (FAPI 2.0 §5.3.2.1); the concrete endpoint URL is no longer accepted
as `aud`, closing a confused-deputy gap (`PAREndpointAsAudienceFails`).
### Changed
- `:authorization_response_iss` now defaults to **`true`** (RFC 9207
authorization-server mix-up defense, mandated by FAPI 2.0). Set `false` to opt
out. Discovery advertises `authorization_response_iss_parameter_supported`
accordingly.
- Internal: `mix dialyzer` is clean again. `token.ex` resolves `:principal_kinds`
by reading the struct field directly (its type admits a list, unlike the
`callback() | nil` reader), and two fail-closed grant-pipeline clauses are
documented in `.dialyzer_ignore.exs`. No behaviour change.
## [0.7.3] - 2026-06-04
The FAPI 2.0 Message Signing endpoints on the Phoenix layer: signed
authorization responses (JARM), the RFC 7662 / RFC 9701 introspection endpoint,
and PAR/JAR hardening. Requires `attesto ~> 0.6.13`.
### Added
- `POST /oauth/introspect` — OAuth 2.0 Token Introspection (RFC 7662) with the
RFC 9701 signed-JWT response (FAPI 2.0 Message Signing §5.5). Authenticates
the caller through the shared `AttestoPhoenix.ClientAuthentication` core
(`client_secret_basic`/`client_secret_post`/`private_key_jwt`), introspects
via the conn-free `Attesto.Introspection`, and negotiates by `Accept` between
the plain JSON response and `application/token-introspection+jwt`.
- `:introspection_authorize` Config callback `(caller_client_id, response ->
boolean)` — authorizes the authenticated introspection caller against the
token (RFC 7662 §4 / RFC 9701 §5). Consulted only for an active response;
a non-`true` return (or a raise) downgrades the response to
`%{"active" => false}` so a caller not entitled to the token learns nothing
about it. Optional — when unset, every authenticated caller may introspect
any token (the single-trust-domain default).
- The authorization endpoint emits JARM (§5.4) responses for the JARM
`response_mode`s (`jwt`/`query.jwt`/`fragment.jwt`/`form_post.jwt`), and the
discovery documents advertise the supported `response_modes_supported`,
`authorization_signing_alg_values_supported`, the introspection endpoint, and
its signing-algorithm metadata.
### Changed
- The PAR endpoint now validates the pushed request as an authorization request
at push time (RFC 9126 §2.1 step 3): the request `redirect_uri` must exactly
match one of the client's registered URIs (RFC 6749 §3.1.2.3), and the
`response_type`/PKCE/`response_mode` must be valid, so an invalid request is
refused early rather than only when the `request_uri` is later resolved at
`/authorize`. The redirect-URI/PKCE/nonce policy is resolved by the new
conn-free `AttestoPhoenix.AuthorizationServer.RequestPolicy`, shared with the
authorization endpoint so both validate identically. **A host that mounts the
PAR endpoint must configure `:client_redirect_uris`** (the authorization
endpoint already required it).
- `AttestoPhoenix.ClientAuthentication.Result.client_id` falls back to the
presented credential identifier so the signed-introspection audience (and the
PAR/token client identity) resolves without a separate `:client_id` callback.
- OpenID Provider Metadata derives `request_parameter_supported` (and only then
advertises `request_object_signing_alg_values_supported`) from actual
request-object capability — whether the host can resolve a client's trusted
JWKS (a `:client_jwks` callback or an installed `:client_store`). An install
without that capability now advertises `request_parameter_supported: false`
instead of a JAR support it cannot honour.
- The OAuth 2.0 Authorization Server Metadata document (RFC 8414) now advertises
the signed-request-object metadata (`require_signed_request_object` and
`request_object_signing_alg_values_supported`, RFC 9101 §10.5), matching the
OpenID Provider Metadata document so a FAPI client reading either sees
identical JAR support. Both documents derive it from the new conn-free
`AttestoPhoenix.AuthorizationServer.RequestObjectMetadata` (no more split,
drift-prone assembly).
- `AttestoPhoenix.Config` now rejects at boot a `:request_object_policy` that
requires a signed request object (e.g. `Policy.fapi_message_signing/0`) when
no `:client_jwks` capability is configured. Such a config is unsatisfiable
(every authorization request would be rejected) and would otherwise advertise
the incoherent pair `request_parameter_supported: false` +
`require_signed_request_object: true`. Pair the policy with `:client_jwks`
(or an installed `:client_store`).
## [0.7.2] - 2026-06-03
### Added
- `:request_object_policy` Config key (an `Attesto.RequestObject.Policy`,
default `%Policy{}` = generic OpenID Connect §6.1). It is enforced at BOTH
the PAR endpoint and `/authorize`: a signed request object pushed to `/par`
is verified there (rejected with `invalid_request_object` if it fails the
policy), and re-verified at `/authorize` (RFC 9101). On success the PAR store
holds the VERIFIED request-object parameters, never the unsigned body values
beside them (RFC 9101 §6.3). A non-`%Attesto.RequestObject.Policy{}` value is
rejected at boot. Set
`Attesto.RequestObject.Policy.fapi_message_signing()` for the FAPI 2.0
Message Signing §5.3.1 profile (`nbf`/`exp` required and bounded to 60
minutes, `typ` = `"oauth-authz-req+jwt"`). Behaviour is unchanged unless a
host opts in. Requires `attesto ~> 0.6.12`.
## [0.7.1] - 2026-06-03
### Added
- `:client_auth_signing_algs` Config key — the JOSE algorithms accepted for
`private_key_jwt` client-assertion signatures, threaded into
`Attesto.ClientAssertion.verify/5` (via its `:accepted_algs` opt) and also
rendered as `token_endpoint_auth_signing_alg_values_supported` in discovery.
Defaults to `Attesto.SigningAlg.fapi_algs/0` (PS256, ES256, EdDSA), so
behaviour is unchanged unless a host overrides it. Verification and the
advertised metadata now read this one value and cannot drift. Requires
`attesto ~> 0.6.11`.
## [0.7.0] - 2026-06-03
A structural refactor of the token/PAR controllers into a reusable
authorization-server core, plus a behaviour-module install surface and several
correctness fixes. Pre-1.0 minor bump because it carries breaking changes to
the host-callback contract (see **BREAKING** below).
### Added
- Behaviour-module install for host callbacks. The Config keys `:client_store`,
`:principal_store`, `:consent_policy`, `:scope_policy`, `:event_sink`,
`:registration`, and `:claims_provider` each resolve their callbacks from a
single installed module. Precedence is fixed: an explicit flat callback key
wins; else the installed behaviour module if it exports the callback; else
`nil`. The required capabilities (`load_client`, `verify_client_secret`,
`load_principal`) are validated by *resolution* at boot, so a
behaviour-module-only install works. Boot-time conformance validation fails
fast on a typo'd or partial module.
- `AttestoPhoenix.ClaimsProvider` behaviour — the host UserInfo/ID-Token claim
source (`build_userinfo_claims/3`, `build_id_token_claims/4`).
- `AttestoPhoenix.Callback` — one callback dispatcher (function / `{m,f}` /
`{m,f,extra}`), replacing ~10 duplicated private `invoke/2` helpers.
- `AttestoPhoenix.ClientAuthentication` and
`AttestoPhoenix.AuthorizationServer.{SenderConstraint, Token, PAR}` — conn-free
core modules. The token and PAR controllers are now thin adapters that lift
conn facts into data, call the core, and render; the core returns data and
audit events rather than writing the conn or emitting events.
### Changed
- **BREAKING:** the ID-Token extra-claims source is now the separate
`:build_id_token_claims` callback (`(client, subject, granted_scopes,
requested_claims -> map)`, and it MUST NOT carry `sub`). Previously the
4-arity form of `:build_userinfo_claims` doubled as the ID-Token source;
`:build_userinfo_claims` is now the 3-arity UserInfo source only. Hosts that
wired a 4-arity `:build_userinfo_claims` must move it to
`:build_id_token_claims`.
- **BREAKING:** `AttestoPhoenix.ClaimsProvider` no longer declares
`build_principal/3`; principal building stays solely on
`AttestoPhoenix.PrincipalStore`. Claim sourcing and principal loading are
separate concerns.
- Client-assertion `aud` now accepts the issuer **or** the concrete token/PAR
endpoint URL (RFC 7523 / OIDC Core §9), widened from issuer-only. The endpoint
URL is derived from trusted Config (issuer + path), never the request Host.
Still FAPI 2 valid (the issuer remains accepted).
- Client authentication (RFC 6749 §2.3.1): a request-body `client_id` presented
alongside HTTP Basic is accepted as identification when it matches the Basic
userid, and rejected as `invalid_request` when it conflicts. Only a second
*credential* (body `client_secret` or `client_assertion`) is treated as a
competing authentication method. The token and PAR endpoints now share one
client-authentication implementation, so they no longer diverge.
- PAR stores the resolved authenticated `client_id`; when no `:client_id`
callback is configured it leaves the request's presented `client_id` intact
rather than clobbering it. The opaque-struct `client[:id]`/`client["id"]`
fallback is removed.
## [0.6.23] - 2026-06-02
### Changed
- Require the client-authentication assertion `aud` to be the issuer identifier
at both the token and PAR endpoints (FAPI 2). The endpoint URL is no longer
accepted as an audience. Requires `attesto ~> 0.6.10`.
## [0.6.22] - 2026-06-02
### Changed
- Advertise only the FAPI 2 client-authentication signing algorithms
(`PS256`, `ES256`, `EdDSA`) in `token_endpoint_auth_signing_alg_values_supported`,
matching the underlying enforcement in attesto 0.6.9 which rejects RS256
client assertions. Requires `attesto ~> 0.6.9`.
## [0.6.21] - 2026-06-02
### Fixed
- Return the standard OAuth token endpoint error `invalid_request` when a
client that requires DPoP omits the proof entirely. Presented-but-invalid
proofs still return `invalid_dpop_proof`; the omitted-proof case now matches
FAPI's expected token endpoint error classification.
## [0.6.20] - 2026-06-02
### Added
- Add `:refresh_token_rotation_grace_seconds` to `AttestoPhoenix.Config` and
pass it through to `Attesto.RefreshToken.rotate/3`. The default is now a
FAPI retry-compatible 60-second idempotency window for retrying a
just-rotated refresh token when the client did not receive or persist the
first rotation response; set `0` for strict immediate reuse revocation.
## [0.6.19] - 2026-06-02
### Fixed
- Bind refresh tokens to the DPoP proof key only for public clients, as
required by RFC 9449. Confidential clients keep refresh tokens bound to the
authenticated client, allowing a later refresh request to use a fresh DPoP
proof key while still minting the returned access token as DPoP-bound to that
current proof.
## [0.6.18] - 2026-06-02
### Added
- Add `:client_requires_dpop?` as a host callback so deployments can mark a
client as requiring DPoP-bound token issuance. When such a client calls the
token endpoint without a DPoP proof, the controller now rejects the request
with `invalid_dpop_proof` rather than silently issuing an unbound Bearer
token.
## [0.6.17] - 2026-06-02
### Fixed
- Treat a resolved PAR `request_uri` as the complete authorization request, so
front-channel parameters outside the pushed request object do not augment the
request. In particular, a `state` query parameter that was not included in the
pushed request is no longer echoed in the authorization response.
## [0.6.16] - 2026-06-02
### Fixed
- Allow PAR requests to carry an explicit `dpop_jkt` without also requiring a
DPoP proof on the PAR request itself. If a PAR DPoP proof is present, an
explicit `dpop_jkt` must still match that proof; otherwise the stored
thumbprint is later enforced when the authorization code is redeemed.
## [0.6.15] - 2026-06-02
### Fixed
- Carry the DPoP JWK thumbprint from a pushed authorization request into the
issued authorization code. A token request that redeems the code with a
different DPoP proof key is now rejected instead of minting a token bound to
the later key.
## [0.6.14] - 2026-06-01
### Fixed
- Verify DPoP proofs at the PAR endpoint and bind stored pushed
authorization requests to the verified proof key. If a PAR request includes
an explicit `dpop_jkt`, it must match the verified proof JWK thumbprint;
mismatches now return `invalid_dpop_proof` instead of issuing a
`request_uri`.
## [0.6.13] - 2026-06-01
### Fixed
- Accept `private_key_jwt` client assertions whose `aud` is the issuer at the
token endpoint and PAR endpoint, while continuing to accept endpoint-specific
audiences and reject unrelated audiences. This matches FAPI conformance suite
client-authentication behavior without relaxing signature, `iss`/`sub`, `jti`,
or replay checks.
## [0.6.12] - 2026-06-01
### Security
- Reject replayed `private_key_jwt` client assertions at the token endpoint and
PAR endpoint by recording assertion `jti` values through the configured
replay check.
- Enforce per-client registered grant types when a host provides
`:client_grant_types`, preventing a client registered for one grant from
minting tokens through another.
- Bind PAR `request_uri` authorization requests to the authenticated pushed
request client and store that authenticated client id, rather than trusting a
front-channel or body-supplied `client_id`.
### Fixed
- Preserve keystore-provided per-key `alg` metadata in the JWKS endpoint. This
keeps FAPI deployments that sign ID tokens with `PS256` from advertising the
same key as `RS256`.
- Add the zero-arity `issue/0` entrypoint to the Ecto DPoP nonce store so
server-issued DPoP nonces work when the store is configured directly as a
behaviour module.
- Decode form-encoded client id and secret values in revocation endpoint Basic
authentication, matching the token endpoint.
- Make the default ETS PAR store tolerate concurrent first-use table creation.
## [0.6.11] - 2026-06-01
### Fixed
- Resolve PAR `request_uri` references non-destructively at the authorization
endpoint, so host login or consent re-entry can complete without consuming the
pushed request before authorization-code issuance.
### Changed
- Add a `fetch` callback to `AttestoPhoenix.PARStore` for authorization-endpoint
resolution. Existing custom stores that only implement `take/1` still work
through a compatibility fallback, but new stores should implement `fetch/1`.
## [0.6.10] - 2026-06-01
### Fixed
- Treat an explicit `nil` `:par_store` config value as unset when applying the
default ETS PAR store. This prevents PAR from calling `nil.put/3` when hosts
enable pushed authorization requests without overriding the development PAR
store.
- Apply the same nil-aware defaulting to authorization-endpoint PAR resolution.
## [0.6.9] - 2026-06-01
### Added
- Advertise FAPI-required discovery metadata when configured:
`authorization_response_iss_parameter_supported: true` when RFC 9207
authorization-response `iss` is enabled, and
`token_endpoint_auth_signing_alg_values_supported` from Attesto's asymmetric
signing algorithm set for `private_key_jwt` clients.
## [0.6.8] - 2026-06-01
### Added
- Add host-configurable FAPI-oriented authorization-server controls:
`:require_pushed_authorization_requests` rejects direct front-channel
authorization requests unless they arrive through a PAR `request_uri`, and
`:authorization_response_iss` includes the RFC 9207 `iss` parameter on
successful and error authorization responses.
- Allow hosts to configure the advertised and accepted token endpoint client
authentication methods. The token endpoint and PAR endpoint now enforce
`:token_endpoint_auth_methods_supported` when set, so deployments can expose
stricter profiles such as `private_key_jwt` only.
- Advertise configured token endpoint authentication methods and PAR-required
policy in OAuth/OIDC metadata.
## [0.6.7] - 2026-06-01
### Added
- Mount `POST /oauth/authorize` alongside `GET /oauth/authorize`, matching
OpenID Connect Core's requirement that the Authorization Endpoint support both
methods.
- Extend the Ecto authorization-code store with successful-consumption markers
and issued-access-token tracking. When a successfully redeemed authorization
code is replayed, the token endpoint still returns `invalid_grant` and now
revokes the access token minted by the original code redemption when the Ecto
store is configured.
## [0.6.6] - 2026-06-01
### Fixed
- Dynamic client registration now preserves inline `jwks` metadata (RFC 7591
§2) and hands it to the host `:register_client` callback. Hosts can then
return those keys through `:client_jwks` for request-object and
`private_key_jwt` verification.
## [0.6.5] - 2026-06-01
### Fixed
- Return a clean `request_uri_not_supported` authorization response for
unsupported OIDC `request_uri` references when no PAR store is configured,
instead of calling a nil PAR store.
## [0.6.4] - 2026-05-31
### Changed
- Replace the direct `jason` dependency with Elixir's built-in `JSON` module.
### Added
- Add a test-only `req_dpop` compatibility check proving that
`AttestoPhoenix.Plug.Authenticate` accepts RFC 9449 DPoP proofs generated by
an external Req client plugin. `req_dpop` is not a runtime dependency.
- Document `req_dpop` as an optional Req client companion for tests and
internal tooling.
## [0.6.3] - 2026-05-31
### Added
- `mix attesto_phoenix.install`, an upgrade-aware Igniter installer. It is
idempotent and re-runnable: it adds the `AttestoPhoenix.Config` config skeleton
(issuer, keystore, repo, the Ecto-backed token stores, a chosen
`:oauth_path_prefix`, and neutral defaults) to the host config, mounts
`attesto_routes/1` at the chosen prefix into the host router, scaffolds host
callback modules implementing the recommended behaviours (`ClientStore`,
`PrincipalStore`, `ScopePolicy`, `ConsentPolicy`, `RegistrationStore`,
`EventSink`) with documented stub callbacks, and points the host at
`mix attesto_phoenix.gen.migration` for the Ecto tables. `igniter` is declared
as an optional dependency, so the runtime package never forces it on consumers;
the task is available to a host that opts into running it. Options:
`--oauth-path-prefix` and `--callbacks-module`.
- Configurable OAuth endpoint paths. `AttestoPhoenix.Config` now accepts an
`:oauth_path_prefix` (default `"/oauth"`, reproducing the historic surface)
plus explicit per-endpoint overrides (`:authorize_path`, `:token_path`,
`:par_path`, `:revocation_path`, `:registration_path`, `:userinfo_path`) that
win when set. Resolver helpers (`token_endpoint_url/1`, `par_endpoint_url/1`,
`revocation_endpoint_url/1`, `registration_endpoint_url/1`,
`userinfo_endpoint_url/1`, `authorize_endpoint_url/1`, `jwks_uri/1`,
`registration_client_uri/2`, and the `*_path/1` helpers) build absolute URLs
from the issuer and the resolved path. The discovery (RFC 8414),
OpenID-configuration (OpenID Connect Discovery), and registration (RFC 7591 /
RFC 7592) controllers read every advertised URL from these resolvers instead
of hardcoding `/oauth/*`, and `to_attesto_config/2` passes the resolved token
path to the core builder automatically so the DPoP `htu` follows the mount.
A host that mounts under `/mcp/oauth` now advertises correct URLs.
- Named host-contract behaviours documenting the full callback contract with
the governing RFC for each callback, as the recommended production shape:
`AttestoPhoenix.ClientStore`, `AttestoPhoenix.PrincipalStore`,
`AttestoPhoenix.ScopePolicy`, `AttestoPhoenix.ConsentPolicy`,
`AttestoPhoenix.RegistrationStore`, and `AttestoPhoenix.EventSink`. Wiring is
unchanged: pass an anonymous function, a `{module, function}` pair, or a
`{module, function, extra_args}` triple per `AttestoPhoenix.Config` key.
- Dynamic registration metadata passthrough (RFC 7591 §2). The registration
endpoint now validates and carries the known client-identity members
(`client_name`, `client_uri`, `logo_uri`, `contacts`, `policy_uri`,
`tos_uri`, and related software/JWKS members) through to `:register_client`
so consent screens keep the client's identity. Unknown members are dropped
and never promoted to trusted policy; known members are merged under the
validated protocol-critical members so they cannot override them.
- Actionable `AttestoPhoenix.Config.new/1` validation errors that name the
callback/store/path to add for each enabled feature, and absolute-path
validation for `:oauth_path_prefix` and the per-endpoint overrides.
- Operations guides wired into the published docs: `replay_nonce_production.md`,
`proxy_canonical_host.md`, `error_envelope.md`, `consumer_migration.md`, and
`examples.md`.
## [0.6.2]
- Advertise `response_modes_supported: ["query"]` from the RFC 8414 OAuth
Authorization Server Metadata endpoint, matching the authorization-code
redirect response mode already used by the Phoenix authorization endpoint.
## [0.6.1]
- Emit `:token_denied` audit/telemetry events for token endpoint failures,
including OAuth error, status, client/grant/scope context when available, and
sender-constraint presence.
- Normalize Phoenix callback specs before handing `:cert_der` to core Attesto
protected-resource verification, so function captures, `{Module, function}`,
and `{Module, function, extra_args}` all work consistently.
## [0.6.0]
Initial release: a Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer
over [attesto](https://hex.pm/packages/attesto).
### Added
- `AttestoPhoenix.Config`: centralized, validated configuration with neutral
host callbacks (`:load_client`, `:verify_client_secret`, `:load_principal`,
`:authorize_scope`, `:on_event`, and others), deriving the `Attesto.Config`
the protocol layer consumes.
- `AttestoPhoenix.Router`: the `attesto_routes/1` macro mounting the token,
revocation, discovery, JWKS, and optional dynamic-registration endpoints.
- Controllers for the token endpoint (`authorization_code`, `refresh_token`,
and `client_credentials` grants), revocation (RFC 7009), discovery
(RFC 8414), JWKS (RFC 7517), and optional dynamic client registration
(RFC 7591).
- `AttestoPhoenix.Plug.Authenticate` and `AttestoPhoenix.Plug.RequireScopes`
protected-resource plugs with DPoP and mTLS sender-constraint enforcement.
- Ecto-backed implementations of the attesto store behaviours: code store,
refresh store (rotation with reuse detection), DPoP nonce store, and DPoP
`jti` replay check, plus an optional TTL sweeper.
- `mix attesto_phoenix.gen.migration` to generate the operational tables.
- Pushed Authorization Requests (PAR, RFC 9126), `private_key_jwt` client
authentication, signed request object validation, token exchange, UserInfo,
registration management cleanup, and Phoenix resource-server plugs.