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.6.15] - 2026-06-12
Fixed
Attesto.RequestObjectcompares the JOSEtypheader CASE-INSENSITIVELY (RFC 7515 §4.1.9typis 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 asinvalid_typ, failing the Message-Signing happy-flow / user-rejects tests at the PAR endpoint. A wrong type is still rejected; an absenttypis still governed byaccepted_typ.
[0.6.14] - 2026-06-12
Fixed
Attesto.RequestObject.Policy.fapi_message_signing/0no longer requires the JOSEtypheader on signed request objects - it now accepts an absenttyp(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 maketypRECOMMENDED, not mandatory, and the OpenID FAPI conformance suite signs its request objects with notypheader - so the previous strict pinning rejected every conformant pushed request object and failed the FAPI2 Message Signing certification at the PAR endpoint.typis still validated for the RFC 9101 §10.8 explicit-typing defence when a client does send it.
Security
Attesto.DPoPnow 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 carryingiss/aud/exp/iat, using the keystore signing key (algorithm pinned, nevernone).Attesto.Introspection— OAuth 2.0 Token Introspection (RFC 7662). Access tokens are introspected statelessly with the fullAttesto.Tokenverifier except the sender-binding proof match (thecnfis echoed for the resource server); refresh tokens are checked against anAttesto.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 withiss/aud/iatand atoken_introspectionclaim, JOSE headertyp="token-introspection+jwt").Attesto.RequestObject.Policygainsrequire_request_object(false ingeneric/0, true infapi_message_signing/0) andrequire_request_object?/1.Attesto.AuthorizationRequest.validate/2rejects a request that carries no signed request object when the policy requires one (redirectableinvalid_request; non-redirectable when the client is untrusted, OIDC Core §3.1.2.6).Attesto.AuthorizationRequestparses and validatesresponse_mode(the RFC 6749queryplus the JARM modesjwt/query.jwt/fragment.jwt/form_post.jwt);supported_response_modes/0exposes the accepted set. Trusted redirectable errors carry the requestedresponse_modeand theclient_idso the transport can return the error as a JARM JWT.Attesto.Discoveryallowlists the RFC 9101 §10.5 metadata membersrequire_signed_request_objectandrequest_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/3acceptsrequire_confirmation_binding: falseto verify a token's signature/claims while skipping only the sender-binding proof match (used by introspection); thecnfshape is still validated.Attesto.Introspection.introspect/3accepts an:authorizepredicate(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-truereturn — 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.Introspectionsurfaces the RFC 7662sub/scope/client_id/cnfmembers for an active refresh token from the stored record's own data contract (Attesto.RefreshTokenbuild context), when present, so a resource server — and an:authorizepolicy — can decide per refresh token rather than allow/deny every refresh token wholesale. A store that does not populate them yields the minimalactive+expresponse.
Security
Attesto.AuthorizationRequest.validate/2now judges the OIDCopenid-scope gate for therequire_noncepolicy on the EFFECTIVE (post-merge) request, so a direct JAR carryingscope=openidonly inside the signed request object can no longer bypass the host's nonce requirement. A plain OAuth request (noopenidscope) remains un-nonce-constrained.Attesto.RequestObject.verify/3rejects a signed request object whoseaudis 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/3rejects a request object that itself carries arequestorrequest_uriclaim (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/0is the OpenID Connect §6.1 baseline (the default:nbf/exp/typnot required);fapi_message_signing/0is the FAPI 2.0 Message Signing §5.3.1 profile (nbfrequired ≤60 min past,exprequired ≤60 min afternbf, JOSE headertyp="oauth-authz-req+jwt").Attesto.AuthorizationRequest.validate/2accepts a:request_object_policyoption (default%Policy{}, generic) and threads it intoAttesto.RequestObject.verify/3. Anaudthat 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_algsoption onAttesto.ClientAssertion.verify/5andAttesto.RequestObject.verify/3(defaultAttesto.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/0as a named helper for the default client-presented signature verification policy.- Strict JAR policy options on
Attesto.RequestObject.verify/3for 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_expdemand a non-negative integer NumericDate (a missing or malformed value fails);:max_lifetime_secondsrequires bothnbfandexpanchors. These default to the prior lenient behaviour, so callers opt into strictness with explicit policy.
Fixed
Attesto.RequestObject.verify/3now honoursnbfas a not-before claim (RFC 7519 §4.1.5): a request object withnbfin the future is rejected as:not_yet_valideven in lenient mode (clock skew tolerated).
[0.6.10] - 2026-06-02
Changed
- Require a single-valued string
audin client-authentication assertions (FAPI 2). An arrayaudis 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.SigningAlgexposes the permitted set viafapi_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
htuURI 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
iatvalues up to 60 seconds ahead of the server clock, matching Attesto's JWT verifier clock-skew policy. Proofs remain short-lived throughmax_age_seconds, and replay-cache TTLs now cover the full acceptance window.
[0.6.6] - 2026-06-01
Fixed
- Sign
PS256JWTs 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
requestJWT is present, unsigned query parameters no longer supplement missing signed parameters such as PKCE inputs. - Require signed request objects to carry
iss, matchingclient_id, and a configuredaud, 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
typheader is intentionally disabled.
[0.6.5] - 2026-06-01
Fixed
- Allow an authorization code that was not pre-bound with
dpop_jktto be redeemed at the token endpoint with a DPoP proof. Codes explicitly bound withdpop_jktstill 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, andsigning_alg/0. Cold modules now advertise and use their configured per-key algorithms deterministically instead of briefly falling back to inferred RSARS256metadata.
[0.6.3] - 2026-06-01
Added
- Allow OAuth authorization-server metadata (RFC 8414) hosts to advertise
authorization_response_iss_parameter_supportedandtoken_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 (
requestJWTs withalg: "none") are now rejected with the redirectablerequest_not_supportederror instead ofinvalid_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 toAttesto.Test.DPoP. From a plain request description (method,url,headers) it verifies the presented DPoP proof and, whenverify_token: true, the access token, returning{:ok, verified}or an{:error, challenge}map carrying the HTTP status, theWWW-Authenticatechallenge, and an optionalDPoP-Nonce. It does not reimplement RFC 9449: it delegates every decision to the production verifiersAttesto.DPoP.verify_proof/2andAttesto.Token.verify/3, and mirrors the resource server's scheme handling (a DPoP-bound token presented as Bearer surfaces aDPoPchallenge, RFC 9449 §7.1; a missing required nonce surfacesuse_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 underlib/so a consumer can call it from itstest/tree without depending on Attesto's own test support.generate_key/1mints a proof key (EC P-256 /ES256by default);mint_access_token/4mints a DPoP-sender-constrained access token bound to that key viacnf.jkt(RFC 7800);proof/4builds a valid proof JWT for a(htm, htu)pair, optionally carryingath(RFC 9449 §4.3) and a servernonce(§8);invalid_proof/5builds 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 againstAttesto.DPoP.verify_proof/2and stays in step with it.
[0.6.0]
Added
Attesto.IDToken.mint/3rounds out the OpenID Connect Core §2 ID Token claim set:auth_time(REQUIRED when the request asked for it or carriedmax_age),acr,amr, andazpare accepted as optional inputs and omitted when absent. Arbitrary additional claims requested through the OIDC Core §5.5claimsparameter 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_nonceoption (defaultfalse). Whentrue, a request with nononceis rejected with a redirectableinvalid_requesterror (OIDC Core §3.1.2.1); whenfalse,noncestays OPTIONAL and is carried through unenforced (RFC 6749 keeps thecodeflow 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/3accepts an optional:family_idthat links a code to the refresh-token family it spawns; it rides onto the redeemedAttesto.AuthorizationCode.Grant(new:family_idfield).Attesto.CodeStoregains an OPTIONAL reuse-tracking pair: amark_consumed/2callback and a thirdtake/1return value{:error, :consumed, meta}. When a store implements them,redeem/4records the spent code'sfamily_id/subjectand 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} | :errortake/1contract and a re-presented code stays{:error, :invalid_grant}, with single-use atomicity unchanged. - Refresh-token rotation grace for honest retries.
Attesto.RefreshToken.rotate/3now 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(default10). Outside that window, or on any mismatch, reuse still revokes the whole family.Attesto.RefreshStoreentries now carry:consumed_atand:successor, and stores may implementremember_successor/3to support the idempotent retry path. Attesto.Plug.Authenticateaccepts a:credential_from_connfallback hook for host-owned credential channels such as first-party cookies. TheAuthorizationheader remains authoritative when present; the callback is consulted only when no usable header credential exists.Attesto.Plug.OAuthErrorsupports 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-prompttokens are now validated against the fixed OIDC set{none, login, consent, select_account}; an unknown token is a redirectableinvalid_requesterror (OIDC Core §3.1.2.1). The parsed list is still exposed for the controller, which enforces semantics such asprompt=none(the OP MUST NOT show UI).Attesto.RefreshStore.consume/2receives 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/3andAttesto.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 throughBase.url_decode64/2andBase.url_encode64/2byte-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 analg:nonetoken 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), includingat_hash/c_hashgeneration,nonce, and the client-id audience and genericJWTtypthat distinguish an ID Token from an RFC 9068 access token. Shares the keystore/kid/RS256 path withAttesto.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-matchredirect_uri, scope/openiddetection, 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 ofAttesto.Discovery.mix checkalias running formatting,--warnings-as-errorscompile, 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/2performed a non-atomic lookup-then-insert, so at the exact TTL boundary two concurrent callers could both re-admit a just-expiredjtiand a proof could be replayed more than once. Re-admission is now a single atomic compare-and-delete (:ets.select_delete/2guarded on expiry) followed byinsert_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/3andAttesto.IDToken.verify/3reject 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.CodeStoretake/1callback with the correct callback reference, clearing a docs-build warning.