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.0] - 2026-06-14

Added

  • Attesto.ClientIdMetadata — the pure core of Client ID Metadata Documents (CIMD, draft-ietf-oauth-client-id-metadata-document-01). CIMD lets a client 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. This module is the conn-free, HTTP-free half: client_id_url?/1 and validate_client_id/1 enforce the draft §2 URL grammar (https, path required, no fragment/userinfo/dot-segments); validate_document/2 validates a fetched document (the client_id must equal the URL, no shared symmetric secret / client_secret_* auth method, a non-empty redirect_uris) and normalizes it into the same client shape the RFC 7591 registration path produces. The network half (SSRF-guarded fetch, caching) lives in attesto_phoenix; this module touches no socket and adds no dependency.
  • Attesto.Discovery advertises the client_id_metadata_document_supported authorization-server metadata field when the host enables it.

[0.6.16] - 2026-06-13

Fixed

  • Authorization-code redemption is now atomic. Attesto.AuthorizationCode.redeem/4 no longer records the reuse marker (consumed_success) itself; that moved to the new finalize/3, which the caller runs ONLY after the full token response is successfully built. So a code whose redemption validated but whose downstream issuance then failed (a mint or refresh-store fault, a host build_principal callback returning the subject under the wrong key) is left single-use-spent but NOT reuse-flagged: a replay is a clean invalid_grant instead of a false reuse attack that revokes the family, and a legitimate retry of a transient failure is not mistaken for an attack. Previously any post-validation failure permanently bricked the code AND marked it a successful redemption.

    Caller change: after a successful token response, call AuthorizationCode.finalize/3 to record the reuse marker. The bundled attesto_phoenix token endpoint (>= 0.7.7) does this. Stores that do not implement the optional mark_consumed/2 are unaffected.

[0.6.15] - 2026-06-12

Fixed

  • Attesto.RequestObject compares the JOSE typ header CASE-INSENSITIVELY (RFC 7515 §4.1.9 typ is a media type; RFC 2045 §5.1 media types are case-insensitive). The FAPI 2.0 Message Signing conformance suite signs request objects with a randomly-cased typ (e.g. OautH-auThZ-REQ+jWt) to exercise this; the previous exact-match rejected them as invalid_typ, failing the Message-Signing happy-flow / user-rejects tests at the PAR endpoint. A wrong type is still rejected; an absent typ is still governed by accepted_typ.

[0.6.14] - 2026-06-12

Fixed

  • Attesto.RequestObject.Policy.fapi_message_signing/0 no longer requires the JOSE typ header on signed request objects - it now accepts an absent typ (accepted_typ: ["oauth-authz-req+jwt", nil]) while still rejecting a wrong one. FAPI 2.0 Message Signing §5.3.1 ("shall accept that typ") and RFC 9101 §4 make typ RECOMMENDED, not mandatory, and the OpenID FAPI conformance suite signs its request objects with no typ header - so the previous strict pinning rejected every conformant pushed request object and failed the FAPI2 Message Signing certification at the PAR endpoint. typ is still validated for the RFC 9101 §10.8 explicit-typing defence when a client does send it.

Security

  • Attesto.DPoP now applies the strict canonical-base64url check to the proof's JOSE header (no padding, no non-significant trailing bits) that the Token/IDToken/ClientAssertion/RequestObject verifiers already apply, so a DPoP proof header cannot be presented in a non-canonical/aliased encoding. Defense-in-depth (the signature is verified over the real bytes regardless).

[0.6.13] - 2026-06-04

The FAPI 2.0 Message Signing surface: signed request objects (JAR, §5.3), signed authorization responses (JARM, §5.4), and token introspection with signed responses (§5.5). All additions are backward-compatible; behaviour is unchanged unless a caller opts into the new policy/options.

Added

  • Attesto.JARM — JWT Secured Authorization Response Mode (§5.4). Signs an authorization response (success: code/state; error: error/error_description/state) into a JWT carrying iss/aud/exp/ iat, using the keystore signing key (algorithm pinned, never none).
  • Attesto.Introspection — OAuth 2.0 Token Introspection (RFC 7662). Access tokens are introspected statelessly with the full Attesto.Token verifier except the sender-binding proof match (the cnf is echoed for the resource server); refresh tokens are checked against an Attesto.RefreshStore (active only while unconsumed and unexpired). Never an error — an invalid, expired, revoked, or unknown token is reported inactive (no existence oracle).
  • Attesto.SignedIntrospection — the RFC 9701 signed introspection response (a JWT with iss/aud/iat and a token_introspection claim, JOSE header typ = "token-introspection+jwt").
  • Attesto.RequestObject.Policy gains require_request_object (false in generic/0, true in fapi_message_signing/0) and require_request_object?/1. Attesto.AuthorizationRequest.validate/2 rejects a request that carries no signed request object when the policy requires one (redirectable invalid_request; non-redirectable when the client is untrusted, OIDC Core §3.1.2.6).
  • Attesto.AuthorizationRequest parses and validates response_mode (the RFC 6749 query plus the JARM modes jwt/query.jwt/fragment.jwt/ form_post.jwt); supported_response_modes/0 exposes the accepted set. Trusted redirectable errors carry the requested response_mode and the client_id so the transport can return the error as a JARM JWT.
  • Attesto.Discovery allowlists the RFC 9101 §10.5 metadata members require_signed_request_object and request_object_signing_alg_values_supported.
  • Attesto.SigningAlg.keystore_algs/1 — the unique signing algorithms across a keystore's verification keys (shared by the ID Token / JARM / introspection signing-algorithm metadata).
  • Attesto.Token.verify/3 accepts require_confirmation_binding: false to verify a token's signature/claims while skipping only the sender-binding proof match (used by introspection); the cnf shape is still validated.
  • Attesto.Introspection.introspect/3 accepts an :authorize predicate (response -> boolean) consulted with the active response before it is returned (RFC 7662 §4 / RFC 9701 §5: the AS MAY restrict which tokens a caller may introspect). A non-true return — or a raise — downgrades the response to %{"active" => false} so a caller not authorized for the token learns nothing about it. When omitted, every authenticated caller may introspect any token (the single-trust-domain default).
  • Attesto.Introspection surfaces the RFC 7662 sub/scope/client_id/cnf members for an active refresh token from the stored record's own data contract (Attesto.RefreshToken build context), when present, so a resource server — and an :authorize policy — can decide per refresh token rather than allow/deny every refresh token wholesale. A store that does not populate them yields the minimal active+exp response.

Security

  • Attesto.AuthorizationRequest.validate/2 now judges the OIDC openid-scope gate for the require_nonce policy on the EFFECTIVE (post-merge) request, so a direct JAR carrying scope=openid only inside the signed request object can no longer bypass the host's nonce requirement. A plain OAuth request (no openid scope) remains un-nonce-constrained.
  • Attesto.RequestObject.verify/3 rejects a signed request object whose aud is an array containing any non-string member (RFC 7519 §4.1.3), rather than accepting it on a single matching member — matching the hardened Token/IDToken/JARM audience handling.
  • Attesto.RequestObject.verify/3 rejects a request object that itself carries a request or request_uri claim (RFC 9101 §4 forbids them) instead of silently dropping them, so a nested-request smuggle fails closed at the verifier.

[0.6.12] - 2026-06-03

Added

  • Attesto.RequestObject.Policy — a data-only JAR verification policy for signed authorization request objects (RFC 9101). generic/0 is the OpenID Connect §6.1 baseline (the default: nbf/exp/typ not required); fapi_message_signing/0 is the FAPI 2.0 Message Signing §5.3.1 profile (nbf required ≤60 min past, exp required ≤60 min after nbf, JOSE header typ = "oauth-authz-req+jwt"). Attesto.AuthorizationRequest.validate/2 accepts a :request_object_policy option (default %Policy{}, generic) and threads it into Attesto.RequestObject.verify/3. An aud that is an array containing the issuer is already accepted. Behaviour is unchanged unless a caller opts into the FAPI profile.

[0.6.11] - 2026-06-03

Added

  • :accepted_algs option on Attesto.ClientAssertion.verify/5 and Attesto.RequestObject.verify/3 (default Attesto.SigningAlg.fapi_algs/0), so the accepted client-authentication / request-object signature algorithms are caller-supplied policy rather than a hardcoded constant. The default preserves current behaviour.
  • Attesto.SigningAlg.default_client_algs/0 as a named helper for the default client-presented signature verification policy.
  • Strict JAR policy options on Attesto.RequestObject.verify/3 for the FAPI Message Signing 2.0 (§5.3.1) / RFC 9101 profile: :require_nbf, :max_nbf_age_seconds, :require_exp, :max_lifetime_seconds, and :accepted_typ (e.g. "oauth-authz-req+jwt"). :require_nbf/:require_exp demand a non-negative integer NumericDate (a missing or malformed value fails); :max_lifetime_seconds requires both nbf and exp anchors. These default to the prior lenient behaviour, so callers opt into strictness with explicit policy.

Fixed

  • Attesto.RequestObject.verify/3 now honours nbf as a not-before claim (RFC 7519 §4.1.5): a request object with nbf in the future is rejected as :not_yet_valid even in lenient mode (clock skew tolerated).

[0.6.10] - 2026-06-02

Changed

  • Require a single-valued string aud in client-authentication assertions (FAPI 2). An array aud is now rejected even when it contains an accepted value, and the string must match an expected audience exactly.

[0.6.9] - 2026-06-02

Changed

  • Restrict client-authentication assertions (private_key_jwt) and request objects to the FAPI 2 signing algorithms PS256, ES256, and EdDSA. Assertions or request objects signed with RS256 are now rejected. Attesto.SigningAlg exposes the permitted set via fapi_algs/0. The provider's own token signing (allowed/0) is unaffected and still admits RS256.

[0.6.8] - 2026-06-02

Fixed

  • Canonicalize DPoP htu URI comparison by ignoring query/fragment, normalizing scheme and host case, and treating an explicit HTTPS default port as equivalent to an omitted port. Non-HTTPS URIs, host/path mismatches, and non-default port mismatches remain rejected.

[0.6.7] - 2026-06-01

Fixed

  • Accept DPoP proof iat values up to 60 seconds ahead of the server clock, matching Attesto's JWT verifier clock-skew policy. Proofs remain short-lived through max_age_seconds, and replay-cache TTLs now cover the full acceptance window.

[0.6.6] - 2026-06-01

Fixed

  • Sign PS256 JWTs with the RFC 7518 salt length (32 bytes for SHA-256) instead of JOSE/OpenSSL's maximum salt length. This makes PS256 access tokens and ID Tokens verifiable by strict FAPI/OIDF validators while keeping Attesto's key-derived algorithm policy unchanged.
  • Treat signed authorization request object parameters as authoritative (RFC 9101 §6.3). When a request JWT is present, unsigned query parameters no longer supplement missing signed parameters such as PKCE inputs.
  • Require signed request objects to carry iss, matching client_id, and a configured aud, preventing cross-client or cross-issuer replay of otherwise valid request objects.
  • Reject access-token-shaped payloads during ID Token verification even when the access token JOSE typ header is intentionally disabled.

[0.6.5] - 2026-06-01

Fixed

  • Allow an authorization code that was not pre-bound with dpop_jkt to be redeemed at the token endpoint with a DPoP proof. Codes explicitly bound with dpop_jkt still require the exact same proof key at redemption. This matches FAPI-style DPoP flows where the authorization request does not pre-bind the code, but the token endpoint proof sender-constrains the access token being minted.

[0.6.4] - 2026-06-01

Fixed

  • Load keystore modules before checking optional callbacks such as verification_pems/0, key_algs/0, and signing_alg/0. Cold modules now advertise and use their configured per-key algorithms deterministically instead of briefly falling back to inferred RSA RS256 metadata.

[0.6.3] - 2026-06-01

Added

  • Allow OAuth authorization-server metadata (RFC 8414) hosts to advertise authorization_response_iss_parameter_supported and token_endpoint_auth_signing_alg_values_supported. These are host capability declarations; Attesto still drops nil values and ignores unlisted metadata keys.

[0.6.2] - 2026-06-01

Fixed

  • Unsigned OpenID Connect request objects (request JWTs with alg: "none") are now rejected with the redirectable request_not_supported error instead of invalid_request_object. Attesto still deliberately does not accept unsigned request objects; this change makes the unsupported-feature signal match OIDC Core §3.1.2.6 and the OpenID conformance suite.

[0.6.1] - 2026-05-31

Added

  • Attesto.Test.DPoPVerifier - a server-side DPoP verification harness for host application suites, the counterpart to Attesto.Test.DPoP. From a plain request description (method, url, headers) it verifies the presented DPoP proof and, when verify_token: true, the access token, returning {:ok, verified} or an {:error, challenge} map carrying the HTTP status, the WWW-Authenticate challenge, and an optional DPoP-Nonce. It does not reimplement RFC 9449: it delegates every decision to the production verifiers Attesto.DPoP.verify_proof/2 and Attesto.Token.verify/3, and mirrors the resource server's scheme handling (a DPoP-bound token presented as Bearer surfaces a DPoP challenge, RFC 9449 §7.1; a missing required nonce surfaces use_dpop_nonce, §8). It depends on neither Plug, Phoenix, nor any HTTP client, so it runs from any ExUnit suite.

  • Attesto.Test.DPoP - DPoP test fixtures for host application suites (RFC 9449). Ships under lib/ so a consumer can call it from its test/ tree without depending on Attesto's own test support. generate_key/1 mints a proof key (EC P-256 / ES256 by default); mint_access_token/4 mints a DPoP-sender-constrained access token bound to that key via cnf.jkt (RFC 7800); proof/4 builds a valid proof JWT for a (htm, htu) pair, optionally carrying ath (RFC 9449 §4.3) and a server nonce (§8); invalid_proof/5 builds a proof with a single deliberate defect (:wrong_htm, :wrong_htu, :missing_ath, :expired) for negative tests. Every fixture is built through the same primitives the production code uses (Attesto.Token.mint/3, Attesto.DPoP.compute_jkt/1, Attesto.DPoP.compute_ath/1, Attesto.SigningAlg.infer/1, JOSE.JWS), and embeds only the proof key's public half (RFC 9449 §4.2), so a fixture is correct by construction against Attesto.DPoP.verify_proof/2 and stays in step with it.

[0.6.0]

Added

  • Attesto.IDToken.mint/3 rounds out the OpenID Connect Core §2 ID Token claim set: auth_time (REQUIRED when the request asked for it or carried max_age), acr, amr, and azp are accepted as optional inputs and omitted when absent. Arbitrary additional claims requested through the OIDC Core §5.5 claims parameter or a host userinfo mapping are supplied via :extra_claims, a string-keyed map merged after the protocol claims. The merge is non-overriding: a key colliding with a reserved protocol claim (iss, sub, aud, exp, iat, nonce, azp, auth_time, acr, amr, at_hash, c_hash) is rejected with :reserved_claim_conflict, and a non-map or non-string-keyed value with :invalid_extra_claims. at_hash/c_hash (OIDC Core §3.1.3.6, §3.3.2.11) were already present.
  • Attesto.AuthorizationRequest.validate/2 - :require_nonce option (default false). When true, a request with no nonce is rejected with a redirectable invalid_request error (OIDC Core §3.1.2.1); when false, nonce stays OPTIONAL and is carried through unenforced (RFC 6749 keeps the code flow at SHOULD). The OP policy is the host's, signalled per call.
  • Authorization-code reuse detection (OAuth 2.0 Security BCP §4.13 / RFC 6749 §4.1.2). Attesto.AuthorizationCode.issue/3 accepts an optional :family_id that links a code to the refresh-token family it spawns; it rides onto the redeemed Attesto.AuthorizationCode.Grant (new :family_id field). Attesto.CodeStore gains an OPTIONAL reuse-tracking pair: a mark_consumed/2 callback and a third take/1 return value {:error, :consumed, meta}. When a store implements them, redeem/4 records the spent code's family_id/subject and surfaces a later replay of that code as {:error, {:reuse, meta}} so the caller can revoke the descendant family. The addition is purely additive and fail-safe: a store that does not implement the pair keeps the {:ok, entry} | :error take/1 contract and a re-presented code stays {:error, :invalid_grant}, with single-use atomicity unchanged.
  • Refresh-token rotation grace for honest retries. Attesto.RefreshToken.rotate/3 now returns the same successor when the just-consumed parent is immediately retried by the same client, DPoP binding, and narrowed scope within :rotation_grace_seconds (default 10). Outside that window, or on any mismatch, reuse still revokes the whole family. Attesto.RefreshStore entries now carry :consumed_at and :successor, and stores may implement remember_successor/3 to support the idempotent retry path.
  • Attesto.Plug.Authenticate accepts a :credential_from_conn fallback hook for host-owned credential channels such as first-party cookies. The Authorization header remains authoritative when present; the callback is consulted only when no usable header credential exists.
  • Attesto.Plug.OAuthError supports transport hooks (:send_error, :www_authenticate, :no_store) so hosts can preserve their API error envelope while Attesto owns the OAuth status/challenge semantics.

Changed

  • Attesto.AuthorizationRequest.validate/2 - prompt tokens are now validated against the fixed OIDC set {none, login, consent, select_account}; an unknown token is a redirectable invalid_request error (OIDC Core §3.1.2.1). The parsed list is still exposed for the controller, which enforces semantics such as prompt=none (the OP MUST NOT show UI).
  • Attesto.RefreshStore.consume/2 receives rotation options such as the claim timestamp and returns consumed records with enough metadata for retry/reuse decisions. This is the intentional 0.6 store-contract change.

Security

  • Closed a JWS signature-malleability gap in the compact-form boundary of both Attesto.Token.verify/3 and Attesto.IDToken.verify/3. The boundary previously checked each segment against the base64url alphabet only (RFC 4648 §5), which accepts a non-canonical final character: the 342-byte RS256 signature segment is a partial quantum (342 rem 4 == 2) whose last character carries four unused low-order bits, so several distinct characters decode to the same signature bytes (RFC 4648 §3.5). JOSE's liberal decoder normalises such a variant and verifies it, so a tampered serialization that is not byte-identical to the issuer's token was accepted. The boundary now requires each segment to round-trip through Base.url_decode64/2 and Base.url_encode64/2 byte-identically, rejecting padding, non-alphabet bytes, and non-zero unused trailing bits in one check, before the token reaches JOSE. Canonical unpadded base64url tokens are unaffected; the empty signature segment of an alg:none token still round-trips and is classified :invalid_signature.

[0.5.1]

Added

  • Attesto.IDToken - mint and verify OpenID Connect ID Tokens (OIDC Core 1.0 §2), including at_hash/c_hash generation, nonce, and the client-id audience and generic JWT typ that distinguish an ID Token from an RFC 9068 access token. Shares the keystore/kid/RS256 path with Attesto.Token.
  • Attesto.AuthorizationRequest - protocol-shape validation for the authorization endpoint (RFC 6749 §4.1.1, OIDC Core §3.1.2.1, PKCE §4.3): response_type, client_id, exact-match redirect_uri, scope/openid detection, and the PKCE parameters.
  • Attesto.OpenIDDiscovery - the OpenID Provider Metadata document (OIDC Discovery 1.0 §3) served from /.well-known/openid-configuration, built on top of Attesto.Discovery.
  • mix check alias running formatting, --warnings-as-errors compile, property tests, and Credo strict in one command.

Security

  • DPoP replay cache: closed a race in the expired-entry re-admission path. Attesto.DPoP.ReplayCache.check_and_record/2 performed a non-atomic lookup-then-insert, so at the exact TTL boundary two concurrent callers could both re-admit a just-expired jti and a proof could be replayed more than once. Re-admission is now a single atomic compare-and-delete (:ets.select_delete/2 guarded on expiry) followed by insert_new/2, so exactly one caller wins and the losers see :replay.
  • Token verification now enforces canonical compact-JWS form at its own boundary. Attesto.Token.verify/3 and Attesto.IDToken.verify/3 reject any = padding or non-base64url byte in a compact segment before the token reaches JOSE, refusing to verify a serialization the issuer never emitted (JOSE's decoder would otherwise tolerantly normalize trailing padding). Unpadded base64url tokens are unaffected.

Fixed

  • Documentation: the authorization-code single-use note now links the Attesto.CodeStore take/1 callback with the correct callback reference, clearing a docs-build warning.