Skip to main content

CHANGELOG.md

# 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).

## [Unreleased]

## [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.