All notable changes to this project are documented here. The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
[Unreleased]
[0.7.5] - 2026-06-10
Requires attesto ~> 0.6.13.
Security
- PAR
request_uriis now single-use (RFC 9126 §2.2 / FAPI 2.0). The reference is consumed once an authorization code is issued (not on the non-consumingfetchthat 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 asinvalid_request_uri. (Flips the conformancePARAttemptReuseRequestUriwarning to a clean pass.) - UserInfo derives the DPoP
htuviaRequestContext.canonical_url, like every other endpoint — honouring a configured:htubut otherwise gatingX-Forwarded-*/Host on the trusted-proxy allowlist. Previously it fell back to the raw request Host when:htuwas 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?/2now forces PKCE wheneverclient_requires_dpop?/client_requires_mtls?is true, regardless of the global:require_pkceflag, and the token endpoint enforces the matchingcode_verifierthrough 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 conformanceEnsurePKCERequiredtest 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_uriis bound to the client. The authorization endpoint now rejects a front-channelclient_idthat does not match the client therequest_uriwas issued to (RFC 9126 §2.2 /PAREnsureRequestUriIsBoundToClient) instead of silently using the stored client. - Unknown/expired PAR
request_uri→invalid_request_uri. Aurn:ietf:params:oauth:request_uri:reference not in the store now returns the correctinvalid_request_urierror rather than falling through torequest_uri_not_supported/invalid_request(RFC 9126 §2.2 /PARAttemptToUseExpiredRequestUri). External (non-PAR) references still reportrequest_uri_not_supported. - PAR rejects a
request_uriparameter. The PAR endpoint rejects a request carryingrequest_uri(RFC 9126 §2.1 step 2), checked on the raw parameters so it cannot be masked by arequestobject replacing the set. - Client-assertion audience is issuer-only.
private_key_jwtassertions 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 asaud, closing a confused-deputy gap (PAREndpointAsAudienceFails).
Changed
:authorization_response_issnow defaults totrue(RFC 9207 authorization-server mix-up defense, mandated by FAPI 2.0). Setfalseto opt out. Discovery advertisesauthorization_response_iss_parameter_supportedaccordingly.- Internal:
mix dialyzeris clean again.token.exresolves:principal_kindsby reading the struct field directly (its type admits a list, unlike thecallback() | nilreader), 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 sharedAttestoPhoenix.ClientAuthenticationcore (client_secret_basic/client_secret_post/private_key_jwt), introspects via the conn-freeAttesto.Introspection, and negotiates byAcceptbetween the plain JSON response andapplication/token-introspection+jwt.:introspection_authorizeConfig 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-truereturn (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_modes (jwt/query.jwt/fragment.jwt/form_post.jwt), and the discovery documents advertise the supportedresponse_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_urimust exactly match one of the client's registered URIs (RFC 6749 §3.1.2.3), and theresponse_type/PKCE/response_modemust be valid, so an invalid request is refused early rather than only when therequest_uriis later resolved at/authorize. The redirect-URI/PKCE/nonce policy is resolved by the new conn-freeAttestoPhoenix.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_idfalls back to the presented credential identifier so the signed-introspection audience (and the PAR/token client identity) resolves without a separate:client_idcallback.- OpenID Provider Metadata derives
request_parameter_supported(and only then advertisesrequest_object_signing_alg_values_supported) from actual request-object capability — whether the host can resolve a client's trusted JWKS (a:client_jwkscallback or an installed:client_store). An install without that capability now advertisesrequest_parameter_supported: falseinstead 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_objectandrequest_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-freeAttestoPhoenix.AuthorizationServer.RequestObjectMetadata(no more split, drift-prone assembly). AttestoPhoenix.Confignow rejects at boot a:request_object_policythat requires a signed request object (e.g.Policy.fapi_message_signing/0) when no:client_jwkscapability is configured. Such a config is unsatisfiable (every authorization request would be rejected) and would otherwise advertise the incoherent pairrequest_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_policyConfig key (anAttesto.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/paris verified there (rejected withinvalid_request_objectif 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. SetAttesto.RequestObject.Policy.fapi_message_signing()for the FAPI 2.0 Message Signing §5.3.1 profile (nbf/exprequired and bounded to 60 minutes,typ="oauth-authz-req+jwt"). Behaviour is unchanged unless a host opts in. Requiresattesto ~> 0.6.12.
[0.7.1] - 2026-06-03
Added
:client_auth_signing_algsConfig key — the JOSE algorithms accepted forprivate_key_jwtclient-assertion signatures, threaded intoAttesto.ClientAssertion.verify/5(via its:accepted_algsopt) and also rendered astoken_endpoint_auth_signing_alg_values_supportedin discovery. Defaults toAttesto.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. Requiresattesto ~> 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_providereach 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; elsenil. 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.ClaimsProviderbehaviour — 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 privateinvoke/2helpers.AttestoPhoenix.ClientAuthenticationandAttestoPhoenix.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_claimscallback ((client, subject, granted_scopes, requested_claims -> map), and it MUST NOT carrysub). Previously the 4-arity form of:build_userinfo_claimsdoubled as the ID-Token source;:build_userinfo_claimsis now the 3-arity UserInfo source only. Hosts that wired a 4-arity:build_userinfo_claimsmust move it to:build_id_token_claims. - BREAKING:
AttestoPhoenix.ClaimsProviderno longer declaresbuild_principal/3; principal building stays solely onAttestoPhoenix.PrincipalStore. Claim sourcing and principal loading are separate concerns. - Client-assertion
audnow 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_idpresented alongside HTTP Basic is accepted as identification when it matches the Basic userid, and rejected asinvalid_requestwhen it conflicts. Only a second credential (bodyclient_secretorclient_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_idcallback is configured it leaves the request's presentedclient_idintact rather than clobbering it. The opaque-structclient[:id]/client["id"]fallback is removed.
[0.6.23] - 2026-06-02
Changed
- Require the client-authentication assertion
audto be the issuer identifier at both the token and PAR endpoints (FAPI 2). The endpoint URL is no longer accepted as an audience. Requiresattesto ~> 0.6.10.
[0.6.22] - 2026-06-02
Changed
- Advertise only the FAPI 2 client-authentication signing algorithms
(
PS256,ES256,EdDSA) intoken_endpoint_auth_signing_alg_values_supported, matching the underlying enforcement in attesto 0.6.9 which rejects RS256 client assertions. Requiresattesto ~> 0.6.9.
[0.6.21] - 2026-06-02
Fixed
- Return the standard OAuth token endpoint error
invalid_requestwhen a client that requires DPoP omits the proof entirely. Presented-but-invalid proofs still returninvalid_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_secondstoAttestoPhoenix.Configand pass it through toAttesto.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; set0for 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 withinvalid_dpop_proofrather than silently issuing an unbound Bearer token.
[0.6.17] - 2026-06-02
Fixed
- Treat a resolved PAR
request_urias the complete authorization request, so front-channel parameters outside the pushed request object do not augment the request. In particular, astatequery 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_jktwithout also requiring a DPoP proof on the PAR request itself. If a PAR DPoP proof is present, an explicitdpop_jktmust 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 returninvalid_dpop_proofinstead of issuing arequest_uri.
[0.6.13] - 2026-06-01
Fixed
- Accept
private_key_jwtclient assertions whoseaudis 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_jwtclient assertions at the token endpoint and PAR endpoint by recording assertionjtivalues 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_uriauthorization requests to the authenticated pushed request client and store that authenticated client id, rather than trusting a front-channel or body-suppliedclient_id.
Fixed
- Preserve keystore-provided per-key
algmetadata in the JWKS endpoint. This keeps FAPI deployments that sign ID tokens withPS256from advertising the same key asRS256. - Add the zero-arity
issue/0entrypoint 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_urireferences 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
fetchcallback toAttestoPhoenix.PARStorefor authorization-endpoint resolution. Existing custom stores that only implementtake/1still work through a compatibility fallback, but new stores should implementfetch/1.
[0.6.10] - 2026-06-01
Fixed
- Treat an explicit
nil:par_storeconfig value as unset when applying the default ETS PAR store. This prevents PAR from callingnil.put/3when 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: truewhen RFC 9207 authorization-responseissis enabled, andtoken_endpoint_auth_signing_alg_values_supportedfrom Attesto's asymmetric signing algorithm set forprivate_key_jwtclients.
[0.6.8] - 2026-06-01
Added
- Add host-configurable FAPI-oriented authorization-server controls:
:require_pushed_authorization_requestsrejects direct front-channel authorization requests unless they arrive through a PARrequest_uri, and:authorization_response_issincludes the RFC 9207issparameter 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_supportedwhen set, so deployments can expose stricter profiles such asprivate_key_jwtonly. - Advertise configured token endpoint authentication methods and PAR-required policy in OAuth/OIDC metadata.
[0.6.7] - 2026-06-01
Added
- Mount
POST /oauth/authorizealongsideGET /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_grantand 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
jwksmetadata (RFC 7591 §2) and hands it to the host:register_clientcallback. Hosts can then return those keys through:client_jwksfor request-object andprivate_key_jwtverification.
[0.6.5] - 2026-06-01
Fixed
- Return a clean
request_uri_not_supportedauthorization response for unsupported OIDCrequest_urireferences when no PAR store is configured, instead of calling a nil PAR store.
[0.6.4] - 2026-05-31
Changed
- Replace the direct
jasondependency with Elixir's built-inJSONmodule.
Added
- Add a test-only
req_dpopcompatibility check proving thatAttestoPhoenix.Plug.Authenticateaccepts RFC 9449 DPoP proofs generated by an external Req client plugin.req_dpopis not a runtime dependency. - Document
req_dpopas 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 theAttestoPhoenix.Configconfig skeleton (issuer, keystore, repo, the Ecto-backed token stores, a chosen:oauth_path_prefix, and neutral defaults) to the host config, mountsattesto_routes/1at 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 atmix attesto_phoenix.gen.migrationfor the Ecto tables.igniteris 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-prefixand--callbacks-module.Configurable OAuth endpoint paths.
AttestoPhoenix.Confignow 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/1helpers) 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/*, andto_attesto_config/2passes the resolved token path to the core builder automatically so the DPoPhtufollows the mount. A host that mounts under/mcp/oauthnow 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, andAttestoPhoenix.EventSink. Wiring is unchanged: pass an anonymous function, a{module, function}pair, or a{module, function, extra_args}triple perAttestoPhoenix.Configkey.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_clientso 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/1validation errors that name the callback/store/path to add for each enabled feature, and absolute-path validation for:oauth_path_prefixand 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, andexamples.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_deniedaudit/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_derto 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.
Added
AttestoPhoenix.Config: centralized, validated configuration with neutral host callbacks (:load_client,:verify_client_secret,:load_principal,:authorize_scope,:on_event, and others), deriving theAttesto.Configthe protocol layer consumes.AttestoPhoenix.Router: theattesto_routes/1macro mounting the token, revocation, discovery, JWKS, and optional dynamic-registration endpoints.- Controllers for the token endpoint (
authorization_code,refresh_token, andclient_credentialsgrants), revocation (RFC 7009), discovery (RFC 8414), JWKS (RFC 7517), and optional dynamic client registration (RFC 7591). AttestoPhoenix.Plug.AuthenticateandAttestoPhoenix.Plug.RequireScopesprotected-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
jtireplay check, plus an optional TTL sweeper. mix attesto_phoenix.gen.migrationto generate the operational tables.- Pushed Authorization Requests (PAR, RFC 9126),
private_key_jwtclient authentication, signed request object validation, token exchange, UserInfo, registration management cleanup, and Phoenix resource-server plugs.