All notable changes to this project are documented here. The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
[1.14.0] - 2026-08-12
SIOPv2 DID Subject Syntax support for connection-free DID methods. Self-issued
ID Tokens can now bind their subject and signature directly to a did:jwk or
did:key verification method without an issuer JWKS or network resolution.
Added
Attesto.Siop.verify/2now supports SIOPv2 Decentralized Identifier Subject Syntax for connection-freedid:jwkanddid:keysubjects. Verification requires the method-defined protectedkid, rejects a simultaneoussub_jwk, resolves the holder public key locally, enforces the JWK's signature usage and algorithm metadata, verifies the signature, and requiresiss == sub. Network-backed and unsupported DID methods continue to fail closed.
[1.13.0] - 2026-08-11
Hardening of the ID-JAG (urn:ietf:params:oauth:grant-type:jwt-bearer)
assertion verifier. Pairs with attesto_phoenix 2.12.0, which enforces the
signed resource and cnf.jkt constraints at the token endpoint; that
package now requires this release (>= 1.13.0).
Security
- ID-JAG verification now validates a present
scope,resource, andcnfinstead of allowing a malformed optional authorization constraint to be treated as absent. A signed constraint that could not be parsed previously degraded to "absent", erasing the IdP's ceiling; it now fails closed. An emptyscopeis rejected rather than read as an unbounded one. - Mixed confirmation methods are rejected. A
cnfcarryingjktalongside any other member (for example anx5t#S256certificate thumbprint) previously matched onjktalone, silently dropping the second constraint — an IdP that required both DPoP and mTLS possession got an assertion enforced as DPoP-only. Only a single-membercnf.jktis accepted. jtiis bounded (256 bytes) before it can reach a host replay store, so unbounded IdP-controlled input never becomes a store key.
Changed
- Potentially breaking: an ID-JAG carrying
authorization_details(RFC 9396) is now rejected with:invalid_claims. This verifier has no policy engine for their typed constraints, and ignoring a signed constraint would silently promote a narrowly-scoped assertion to broader scope-only authority. Fail closed until the constraints can be enforced end to end. Trusted IdPs that mint ID-JAGs includingauthorization_detailsmust stop doing so, or pinattesto ~> 1.12.2until they can. - An RFC 7800 confirmation claim using a method other than DPoP
jktis likewise rejected rather than treated as an unbound (bearer) assertion.
[1.12.2] - 2026-08-11
Second-round hardening from a review-of-the-fixes pass, a dedicated sweep of the shared crypto/infra primitives (never a prior review front), and fuzzing of the SD-JWT / mdoc / status-list parsers. mdoc and status-list parsing came through fuzzing fully clean.
Security
Attesto.Diddid:jwk DoS:resolve/2now rejects an oversized presenter-controlled RSA key on the raw map (viaSigningAlg.rsa_params_ok?) BEFOREJOSE.JWK.from_map/1bignum-decodes it — a ~256 KB modulus previously pinned a scheduler for ~3s per unauthenticated request. (did:jwk resolution is not yet wired to an HTTP endpoint, so this was latent.)Attesto.SdJwtVc.issue/2registered-claim integrity: subject claims are string-keyed before merging the registered claims, so an atom-keyed collision (e.g.claims: %{cnf: ...}) can no longer emit a duplicate JSON member and let a last-wins verifier bind to the wrong holder key. MatchesAttesto.JwtVc.
Fixed
Attesto.SdJwt.verify/3andAttesto.CredentialProof.verify_jwt/2now fail closed ({:error, _}) on a malformed host-suppliedjwks/accepted_algs(nil, non-list) instead of raising, honoring their documented contract (found by fuzzing).Attesto.Store.ETS:directreset now clears every table the store owns, not just the primary — a store addingextra_tables:on the default reset mode would previously leave those rows intact.
Documentation
Attesto.Diddid:web resolver: explicit SSRF warning (the fetch URL is presenter-controlled — allow-list before dereferencing).Attesto.Mdoc: clarified that document-type binding requires:expected_doc_type(threaded viaAttesto.VpToken's:query_constraints).
[1.12.1] - 2026-08-10
Fixed
Attesto.PreAuthorizedCodeStore.ETS.reset/0(test-facing) now routes through the owner process (reset: :server), so it works on the:protectedtable introduced in 1.12.0. In 1.12.0 the macro-provided direct:ets.delete_all_objects/1raisedinsufficient access rightswhen a downstream test suite calledreset/0. Production is unaffected (reset is test-only).
[1.12.0] - 2026-08-10
Wallet-surface security-hardening release from a 6-front adversarial sweep of the EU-wallet code (SD-JWT/SD-JWT VC, mdoc/COSE, OID4VP verifier, OID4VCI issuer, OpenID Federation/DID/SIOPv2/attestation) plus an ecosystem-CVE and per-spec security-considerations cross-check. The cryptographic surface was found solid; the fixes close policy-enforcement gaps.
Security
- Status List Token freshness:
Attesto.StatusList.verify/3now rejects an expired token (exppresent and past, 60s clock-skew leeway).expremains optional per draft-ietf-oauth-status-list §11.5, so a no-exp token does not expire. Previously a stale/expired list verified indefinitely, letting a revoked credential read VALID. - SD-JWT VC registered claims:
Attesto.SdJwtVc.issue/2never makes registered claims (iss/vct/cnf/status/iat/exp/nbf/sub) selectively disclosable, closing a holder-binding / revocation-reference strip where a holder could removecnfand present as a bearer credential. - SD-JWT disclosure DoS:
Attesto.SdJwtcaps presentation byte-length and disclosure count before any base64/JSON decode work. - Pre-authorized code store isolation: the reference ETS store now uses a
:protectedtable (owner-only writes, single-usetakepreserved), matching the credential-offer store, so a co-resident BEAM process cannot forge a redeemable grant. - OpenID Federation trust marks: added
Attesto.Federation.TrustMark.verify/3for real signature/iss/sub/typ/expverification;validate_trust_marks/1is documented as structural-only.
Fixed
Attesto.PresentationSessioncreate_attrstype now lists:query_constraints(a latent dialyzer contract mismatch forattesto_phoenix).
[1.11.0] - 2026-08-10
Second security-hardening release, from two further adversarial audit rounds (under-covered flows + implementation-level classes) and three cross-model code reviews. Focused on denial-of-service resource-exhaustion classes and OID4VP verification correctness.
Security
- RSA verification-key parameter bounds (DoS).
Attesto.SigningAlg.rsa_params_ok?/1rejects an RSA verification key whose modulus exceeds 8192 bits or whose public exponent is not an odd integer in3..65537, evaluated on the RAW base64urln/ebefore any bignum decode. Without it, an attacker-supplied key with a multi-hundred-KB exponent pins a scheduler inmodexpfor seconds. Wired at every gate that admits an untrusted key:Attesto.Key.verification_jwk/2,Attesto.JWS.verification_candidates/2(viamap_candidate!), the SD-JWT holder Key Binding path (Attesto.SdJwt), andAttesto.JwtVc'scnfparse. - OID4VP format binding + query-ID requirement.
Attesto.VpToken.verify/2now (a) binds each presentation to the DCQL-requested format — a validly signeddc+sd-jwtcredential can no longer satisfy anmso_mdocquery (:format_mismatch) — and (b) treats every query id that carries a constraint as required, so a wallet cannot return a credential under a different id to dodge the type/claim binding.constraints_from_dcql/1accepts atom- or string-keyed queries. (Completes the DCQL binding introduced in 1.10.0.) - Token Status List decompression-bomb bound.
Attesto.StatusListinflates the status bitstring through a capped:zlib.safeInflateloop (16 MiB ceiling,:status_list_too_large) instead of:zlib.uncompress/1, so a signed zlib bomb from a compromised status issuer cannot expand to gigabytes. - CBOR/mdoc parse-amplification bound.
Attesto.MdocandAttesto.Cosereject CBOR input over 1 MiB before decoding, bounding the deep-nesting amplification the decoder would otherwise turn into heap ahead of any signature check. - Nonce single-use + store ceilings.
Attesto.CNonceStoregains a required atomicconsume/1(single-use OID4VCI c_nonces; see attesto_phoenix). TheAttesto.CNonceStore.ETSandAttesto.DPoP.NonceStore.ETSstores bound table growth from their unauthenticated endpoints with an O(1) best-effort ceiling.
Changed (breaking)
Attesto.CNonceStorerequires theconsume/1callback. The bundled ETS store implements it; custom stores must add it (a store that cannot single-use a nonce must not be used for issuance).Attesto.VpToken.verify/2: a query id present in:query_constraintsis now required in the response even if absent from:expected_query_ids.
[1.10.0] - 2026-08-10
Security-hardening release from an adversarial audit against four public
vulnerability classes (broken tenant/authorization isolation, alg:none /
decrypt-then-verify signature bypass, DPoP session theft, and unbounded-batch
DoS). The audit found the JWT-verification and DPoP surfaces already
fail-closed; the fixes below close authorization-isolation seams and add
defense-in-depth.
Security
- Credential-offer id is now generated by the library.
Attesto.CredentialOffer.store_by_reference/3mints the by-reference offer id with a 256-bit CSPRNG (Attesto.Secret.generate/0) and stores it, so a host can no longer supply a guessable id. The by-reference offer endpoint is unauthenticated and a pre-authorized offer embeds a redeemablepre-authorized_code, so a weak id let an attacker enumerate the endpoint and redeem a victim's code. Use this instead of calling the store'sput/1with a self-chosen id.:ttlis validated (a positive integer <= 3600 s). - OID4VP verified results are single-use.
Attesto.PresentationSession.result/2now reads the completed result through the store'stake/1and clears it, and the reference store'sget/1no longer returns the result payload at all. The OID4VPresponse_codehanded to the browser is the session id and transits the address bar, history,Referer, and logs; reading results once means a capturedresponse_codecan no longer be replayed to re-read the presented claims. - Credential Request
proofsare bounded.Attesto.CredentialRequest.parse/2caps the total proofs per request (default 50, override with:max_proofs; validated to a non-negative integer) and rejects an over-cap request with{:error, :too_many_proofs}before any signature is verified, closing an authenticated proof-verification amplification-DoS. - Key-attestation signer strength parity.
Attesto.KeyAttestation.verify/2now applies the FAPI RSA-modulus / Edwards-curve strength policy to the attestation signer (via:enforce_fapi_alg_policy), matchingAttesto.ClientAssertionandAttesto.WalletAttestation. - Reference stores hardened to
:protectedETS. The presentation-session and credential-offer ETS tables (which hold plaintext session data / redeemable codes) drop:public, so a co-resident BEAM process can no longer directly:ets.insert/2to overwrite a verified result, bypass the atomicpending -> completedguard, or inject a forged offer. - Introspection multi-tenant guidance. Documented that any deployment where
more than one client can authenticate to introspection MUST set
:introspection_authorize(without it, one client can introspect another's token). No behavior change.
Changed (breaking)
Attesto.PresentationSession.result/2is now single-use: it consumes the completed session, and a second read (or a read of a still-pending/expired session) returns:error. Hosts that read the result more than once must read it once and cache their own outcome.Attesto.PresentationSessionStorerequires thetake/1callback (moved out of@optional_callbacks), andget/1MUST NOT return a completed session'sdata.result. Custom store implementations must providetake/1; the bundledAttesto.PresentationSessionStore.ETSis unaffected.
[1.9.0] - 2026-08-05
Added
A third redirect-URI matching mode,
:exact_allow_loopback_port_including_localhost, which applies the RFC 8252 §7.3 port allowance to the bare hostnamelocalhostas well as the127.0.0.1/[::1]literals.Native clients exist that register a portless
http://localhost/callbackin their client-id metadata document and then bind an ephemeral port. Under:exact_allow_loopback_portevery such request failsredirect_uri_not_registered: exact comparison fails on the port, and the §7.3 exception does not cover the name. Claude Code is one such client, so no Attesto deployment could serve it over the loopback flow.The literal-IP-only reading remains correct and remains the behavior of
:exact_allow_loopback_port— §7.3's MUST is scoped to "loopback IP redirect URIs", and 1.4.1's reconciliation of RFC 9700's wider wording still stands. What this adds is the observation that nothing forbids a server allowing the name, and that §8.3's case againstlocalhostis stated entirely in terms of what the client does (which interface it binds, its firewall, its host-name resolution) — none of which a server changes by refusing the request. The residual risk is the combination of two allowances the module already makes separately: a registeredlocalhostURI is already reachable by exact match, and §7.3 port flexibility is already mandated for the IP literals. Permitting only that combination is why this is a separate opt-in rather than a change to the existing mode.No behavior change unless selected.
:exactand:exact_allow_loopback_portare byte-for-byte as before —localhoststill gets no port flexibility under either, and the existing test asserting that is unchanged.localhostis a distinct host identity from the IP literals and never cross-matches them, and every other constraint is preserved: byte-exacthttp://scheme, anchored authority (localhost.evil.example,sub.localhost,evil-localhost,localhost.and userinfo all stay outside), no fragment, exact path and query, and the asymmetric request / registered port rule.
[1.8.1] - 2026-08-03
Fixed
- Clean compile for consumers without the optional
:cbordependency.Attesto.VpToken's mdoc verification path pattern-matched onAttesto.Mdoc, which compiles to a raising stub when:cboris absent. Under Elixir 1.19+'s set-theoretic type checker the stub types asnone(), so those clauses were reported as unreachable — a warning that surfaced for any downstream app (e.g. a LivebookMix.install) that didn't pull:cbor. The mdoc verification cluster is now gated behindCode.ensure_loaded?(CBOR): with:cborpresent the behavior is unchanged; without it, an mdoc presentation fails closed with{:error, {id, :mdoc_unsupported}}and SD-JWT VC /jwt_vc_jsonverification is unaffected. A dedicated CI job now compiles the library with no optional deps present and fails on any such warning.
[1.8.0] - 2026-08-03
Added
- OpenID for Verifiable Credentials (OID4VC / EU digital identity) — the
conn-free core of the issuer and verifier roles, targeting the HAIP profile.
Issue and verify IETF SD-JWT VC (
Attesto.SdJwt/Attesto.SdJwtVc), ISO 18013-5 mdoc (Attesto.Mdoc, CBOR + COSE_Sign1), and W3Cjwt_vc_json(Attesto.JwtVc), all with holder key binding. OID4VCI issuance primitives (Attesto.CredentialOffer,CredentialRequest,CredentialResponse,CredentialProof,CredentialIssuerMetadata,WalletAttestation,KeyAttestation) and OID4VP presentation primitives (Attesto.PresentationRequest,VpToken,PresentationSession+ store) including DCQL,direct_post/ encrypteddirect_post.jwtwith a per-request ephemeral response-encryption key, and the x509client_idschemes. PlusAttesto.StatusList(IETF Token Status List),Attesto.Siop(SIOPv2 Self-Issued ID Token verification for the RP role, withsub_jwkself-signature, RFC 7638 subject binding, and fail-closed nonce/exp/iat/nbfchecks), and OpenID Federation support. - Signed Credential Issuer Metadata (
CredentialIssuerMetadata.signed/2), thex5cheader anddc+sd-jwttyp for SD-JWT VC issuance, and the response- encryption-key binding threaded into mdocdirect_post.jwtverification — the interoperability details exercised while driving the OIDF OID4VCI/OID4VP conformance suites green.
[1.7.0] - 2026-08-02
Security
DPoP replay identities are now namespaced by the proof key.
jtiuniqueness is only guaranteed per key (RFC 9449 §4.2), so recording the rawjtilet two keys sharing ajticollide in the replay store — a cross-client false replay and a targeted DoS in which an authenticated attacker pre-burns a victim'sjtiunder the attacker's own key.Attesto.DPoP.verify_proof/2now returnsreplay_key, a fixed-length digest ofjkt:jti, and every replay path (the plug, the token endpoint, PAR, device authorization) records THAT.Upgrade note: the recorded identity's format changed (raw
jti→ digest). During a rolling upgrade, old and new nodes write different formats to a shared replay store, so a single proof could be accepted once on each side within its acceptance window (max_age + skew, default ~120s). Drain old nodes and wait out one proof lifetime, or briefly stop the world, to close that window. A fresh deployment is unaffected.A batch of fail-closed / correctness fixes found by a full-surface adversarial sweep and two rounds of external review:
Attesto.DPoPreplay TTL now retains thejtione second past the inclusive freshness boundary, closing a sub-second edge-of-life replay gap.Attesto.IdentityAssertionrejects a present non-integernbfinstead of treating it as absent (:invalid_claims).Attesto.RequestObjectparameter coercion is total: a request-object claim that is a list with a non-string member is dropped rather than raising.- CIBA signed authentication requests reject an authorization-endpoint
request object (
typ: oauth-authz-req+jwt);typcomparison follows RFC 7515 §4.1.9 for theapplication/prefix. Attesto.JARMreserved claims (iss/aud/iat/exp) can no longer be shadowed by a caller's atom- or charlist-keyed duplicate.Attesto.SessionStaterejects an OP browser-state secret under 32 bytes and asession_statesalt containing a space or., and serializes the browser origin closer to the WHATWG form (lowercase scheme/host, bracketed IPv6).
Changed
The
:replay_checkcallback andverify_proof/2'sreplay_keycarry an OPAQUE replay identity (a digest), not the rawjti; do not parse it. The[:attesto, :dpop, :replay_detected]telemetry still carries the raw clientjtifor correlation.Attesto.Scope.grants_all?/3is now linear in the requested-scope count. It previously rescanned the granted set and re-split resource wildcards for every (required, granted) pair, so a large caller-suppliedscopevalue - on which RFC 6749 §3.3 places no bound - was a denial-of-service lever: 500,000 scope tokens took ~20 seconds. Classifying the granted set once and testing each required scope against that index in O(1) drops the same input to ~40ms. For every proper-list input (the typespec's contract) the result is identical to the naive form, pinned by a generated property test; an improper list now raises rather than short-circuiting, a louder failure on a value the contract already forbids. Surfaced by mining the class behind Keycloak CVE-2026-4634.
[1.6.0] - 2026-08-01
Security
Claim a DPoP proof's
jtionly after the access token has verified.Attesto.Plug.Authenticateran:replay_checkduring proof validation, two steps beforeverify_token. That callback is check-AND-record — the default claims the identifier with:ets.insert_new/2in the same step that tests it — so an unauthenticated caller wrote a row on every request. A DPoP proof is signed by a key the caller generated, so anyone can mint a valid one, pair it with any string in theAuthorizationheader, and grow an unbounded ETS table for the cost of a signature.The guarantee is unchanged: the claim still happens before the request is served, it is still the same atomic check-and-record, and a replayed
jtion an otherwise-valid request is still refused (RFC 9449 §11.1).The authorization-server path needed the same fix, in
attesto_phoenix— an earlier draft of this entry claimed it did not, on the grounds that%Request{}carries an authenticated client. That is false for a public client (RFC 6749 §2.1), which presents aclient_idand no credential, so the same unauthenticated write was reachable at the token endpoint. See that package's changelog.Attesto.SecureCompare.equal?/2no longer short-circuits on a length mismatch. Both operands are hashed to 32 bytes and those digests are compared, so the comparison no longer separates "wrong length" from "right length, wrong bytes" — the distinction an attacker probes with. A matching pair does one extra byte comparison to rule out a digest collision; that branch separates right from wrong, which the answer already reveals.Attesto.PKCE.verify/3was never exposed - it gates onAttesto.Thumbprint.valid?/1, which requires an exact byte size - butAttesto.DPoP'sathcomparison takes an arbitrary-length value straight from the presented proof, so it was, and it is the caller this most benefits.Note what the change does and does not buy: the comparison no longer reveals HOW the operands differ, but hashing reads every byte, so its duration still depends on their total size.
Added
Attesto.Telemetry—:telemetryevents for the refusals that mean someone holds a credential they should not:Event Fires when [:attesto, :refresh_token, :reuse_detected]a rotated token is presented again; the family has been revoked [:attesto, :dpop, :replay_detected]a proof carries an already-recorded jti[:attesto, :token, :sender_constraint_mismatch]a sender-bound token is presented with the wrong proof of possession Previously these returned an atom and nothing else, so a host that wanted to alert had to wrap every call site. Metadata carries correlation handles (
family_id,client_id,subject,jti,binding,reason). No credential or digest of one is ever copied into an event, but some handles are read out of credentials -jtifrom the proof,client_idfrom the token - andjtiis the client's to choose, so a handler should treat metadata as untrusted input. The events are indicators to correlate, not proof of theft. Ordinary failures — expired, unknown client, wrong scope — are deliberately not events. Event names and metadata keys are public API; see the module docs.Adds
:telemetryas a dependency.Attesto.DPoP.verify_proof/2now reports thereplay_ttlit derived, so a caller claiming thejtiitself uses that verification's own acceptance window rather than re-deriving the formula.
Documentation
Attesto.DPoP.ReplayCachenow namesAttestoPhoenix.Store.EctoReplayCheckas the shared-store implementation to use on a multi-node deployment. It previously described one in the abstract ("e.g. a Postgres-backed cache"), which read as an instruction to go and build something the family already ships.
[1.5.0] - 2026-07-28
Security
Reject a Client ID Metadata Document whose
redirect_uriscontain a URI that RFC 3986 and the WHATWG URL Standard read differently ({:error, :invalid_redirect_uris}).A CIMD document is fetched from a URL the client itself chose, so its
redirect_urisare supplied by the client in a way a host-registered set is not. Elixir'sURIfollows RFC 3986 while the browser that receives theLocationfollows WHATWG, and the two disagree about some authorities: inhttps://evil.example\@client.example/cb, RFC 3986 readsevil.example\as userinfo andclient.exampleas the host, while WHATWG treats the backslash as a path separator and navigates toevil.example.Any check phrased in terms of a host or an origin — the CIMD draft's same-origin tightening, a deployment's origin allow-list — is decided with the first parser and enforced by the second, so such a URI could pass the check and still send the authorization response off-origin. Refusing the document keeps the URI out of the registered set those checks ever run against.
Byte-exact matching (RFC 6749 §3.1.2.3) was never affected: it compares strings, not origins. The RFC 8252 §7.3 loopback exception was never affected either, because it anchors on the whole authority rather than the parsed host —
http://evil.example\@127.0.0.1/cbwas already refused.
Added
Attesto.RedirectURI.unambiguous?/1, the predicate behind the above: whether every URL parser agrees which origin a URI names. It refuses backslashes, userinfo, and C0 controls/whitespace anywhere in the URI. Exposed so a host applying its own origin-level policy to a redirect URI can gate on the same rule.This governs what may be registered; it is not a matching mode and does not change what
registered?/3accepts.
Changed
- The WHATWG parity suite now pins a second invariant alongside the loopback
one: for every URI
unambiguous?/1admits, the host RFC 3986 reads and the host WHATWG reads are the same string.
[1.4.1] - 2026-07-28
Documentation
Reconcile RFC 9700's wording of the loopback exception with the rule
Attesto.RedirectURIactually implements. RFC 9700 §2.1 and §4.1.3 phrase it as applying to "localhostredirection URIs of native apps", which reads wider than this module's literal-IP-only rule and could be mistaken for a conformance gap. Both texts define the exception by reference — "as described in Section 7.3 of [RFC8252]" — and §7.3 constructs the URI from the loopback IP literal, not the name, solocalhostthere is shorthand for "the loopback interface" and §7.3's construction is the normative content.No behavior change: 1.4.0 already implements exactly this. A
localhostredirect URI still matches exactly; it just gets no port flexibility.State in the README's RFC table where the §7.3 opt-in lives — the core defaults to exact RFC 6749 §3.1.2.3 matching and the caller selects
:exact_allow_loopback_portper request — rather than the vaguer "opt-in, off by default".
[1.4.0] - 2026-07-28
Added
RFC 8252 §7.3 loopback interface redirection, as an opt-in redirect-URI matching mode.
Attesto.AuthorizationRequest.validate/2accepts:redirect_uri_matching, which is:exact(the RFC 6749 §3.1.2.3 simple string comparison) unless a host selects:exact_allow_loopback_port. Under the exception a native app's loopback redirect URI matches on any port, so an ephemeral port bound at runtime need not be registered ahead of time, while scheme, host, path, and query still compare exactly. The relaxation is scoped tohttp://127.0.0.1/...andhttp://[::1]/...; RFC 8252 §8.3 makeshttp://localhost/...unacceptable, andhttps, private-use schemes, and every remote host stay exact-match. An unmatched redirect URI is still classified{:direct, :redirect_uri_not_registered}and is never used as a redirect target. The new matching logic lives inAttesto.RedirectURI.The two sides of the comparison differ in one respect: a port on the request URI must be decimal
1..65535or absent, since it names an endpoint a browser is about to be redirected to, while the registered URI's port is discarded and so may be anything — including the conventional:0placeholder.Defaults are unchanged: without the option, redirect matching is byte-identical to previous releases. Enabling the exception is incompatible with profiles that mandate exact redirect-URI matching, so it must be a deliberate deployment decision. Redemption-time
redirect_uricomparison inAttesto.AuthorizationCodeis unaffected — the code is bound to the URI the client actually presented, ephemeral port included, and still matches exactly.
[1.3.0] - 2026-07-25
Changed
- Admit compatible JOSE releases through the 1.x line while retaining 1.11.12 as the minimum security- and runtime-compatible version. This lets hosts use newer native cryptographic adapters without another Attesto release.
Fixed
- Derive OIDC
at_hashandc_hashclaims for Ed25519-signed ID Tokens with SHA-512 and Ed448-signed ID Tokens with SHAKE256, matching their signature primitives and interoperable OIDC validators. Previously EdDSA used SHA-256 unconditionally, contrary to OIDC's generic hash rule when applied to RFC- ID Token and logout-token minting now snapshot the signing PEM once so
algorithm, curve-specific hash,
kid, and signature cannot straddle a keystore rotation. Algorithm selection remains bound to trusted keystore metadata and key material; Ed448 uses JOSE's configured Curve448 and SHA3 backends. The ambiguous keylesshash_alg/1andhash_half_bytes/1helpers remain Ed25519-compatible but are deprecated in favor of key-awareoidc_hash_profile/2andoidc_hash/3. A missing SHAKE256 backend now raises a direct configuration error.
- ID Token and logout-token minting now snapshot the signing PEM once so
algorithm, curve-specific hash,
- Support RFC 9864's exact
Ed25519andEd448JOSE identifiers in trusted key metadata, signing, verification, ID Token hash profiles, and DPoP while retaining legacyEdDSAinference for wire compatibility. DPoP discovery advertises the new identifiers only when the configured JOSE backend reports them available. Default FAPI client-assertion, signed-request-object, and CIBA policies accept EdDSA only over a trusted Ed25519 key and accept exact Ed25519, never Ed448, and require PS256 RSA moduli to be at least 2048 bits; an explicit non-FAPI allowlist can opt into Ed448 or a weaker RSA key, while named FAPI policies retain the key gate when their algorithm list is narrowed. DPoP rejects RSA proof keys with moduli below 2048 bits for every accepted RSASSA and RSA-PSS proof algorithm. FAPI server keystores must also provision RSA keys of at least 2048 bits; generic keystore resolution remains profile-neutral for backward compatibility.
Documentation
- Correct the token, ID Token, logout-token, and keystore documentation to describe Attesto's existing multi-algorithm support. Signing and verification bind RS256, PS256, ES256, ES384, ES512, legacy EdDSA, Ed25519, or Ed448 to trusted keystore metadata or the key type and curve; verification never learns algorithm policy from a presented JWS header.
[1.2.5] - 2026-07-17
Fixed
- Require JOSE 1.11.12 or later on the 1.11 release line. JOSE 1.11.9 and
1.11.10 cannot encode or decode EC private keys on OTP 28 after OTP changed
the
ECPrivateKeyversion representation. JOSE 1.11.11 fixed EC handling but introduced a builtin-JSON regression that encoded Elixirnilas the string"nil"; 1.11.12 is the first release to include both fixes. Attesto's public API and runtime policy are unchanged.
[1.2.4] - 2026-07-16
Security
- Require JOSE 1.11.9 or later on the 1.11 release line. This excludes releases affected by the PBES2 iteration-count denial of service and avoids the unintended runtime Dialyxir dependency published in JOSE 1.11.7 and 1.11.8.
[1.2.3] - 2026-07-16
Security
- Constrain the optional Plug dependency to advisory-safe patch lines while retaining compatibility across Plug 1.16 through 1.20. This prevents package resolution from selecting affected releases between otherwise-safe lower and upper bounds; Attesto's runtime behavior is unchanged.
[1.2.2] - 2026-07-16
Fixed
- Token verification, RFC 7662 introspection, and protected-resource Plug authentication can now validate access tokens against an explicit, trusted set of RFC 8707 resource audiences without disabling audience checks. The Plug policy is configured per protected resource. Scalar audiences must occur in the configured set; every member of an array audience must be trusted. The default verifier behavior remains unchanged when no trusted set is supplied, and malformed policy fails closed.
Security
- Raise the optional Plug dependency floor to 1.19.5, excluding releases with published multipart temp-file exhaustion, nested-parameter quadratic-time denial-of-service, and cookie attribute-injection advisories.
[1.2.1] - 2026-07-08
Fixed
Attesto.Keystore.Staticnow labels the signing key's ownkidwith the configured:signing_alg, so a keystore that signsPS256(or any alg other than the one inferred from the key type) with a single key no longer needs a redundant:key_algsentry to verify its own tokens. Previously, settingsigning_alg: "PS256"on an RSA key without a matching:key_algsmap made verification inferRS256from the key and reject the server's own tokens as:invalid_signature. An explicit:key_algsentry for thatkidstill wins.
[1.2.0] - 2026-07-08
Changed
Attesto.Confignow raises a guiding error when the:issueris not anhttpsURL (RFC 8414 §2). Instead of a bare rejection, the message points developers at mkcert to serve a locally-trusted certificate so the issuer stayshttps— local development never needs to downgrade to plain http. There is deliberately no https-disable switch in the library.
[1.1.0] - 2026-07-07
Added
- OpenID Connect Client-Initiated Backchannel Authentication (CIBA Core
1.0). New
Attesto.CIBAprimitive - the device grant's async sibling:Attesto.CIBA.Request.validate/3runs the §7.1 backchannel authentication request rules (scope-with-openid, exactly-one-hint, ping/pushclient_notification_tokenentropy/length/charset,binding_message/user_code/requested_expiryshape) and verifies §7.1.1 signed authentication requests against the client's registered JWKS (all six REQUIRED claims enforced; FAPI-CIBA's 60-minute lifetime bound by default);issue/4mints a 256-bitauth_req_id(only its hash is stored) with the §7.3 acknowledgement fields;approve/4/deny/3record the user's decision atomically and return the ping-mode §10.2 notification data;redeem/4runs the token-endpoint state machine with the exact §11 vocabulary (authorization_pending/slow_down/expired_token/access_denied/invalid_grant), single-use, expiry-beats-approval, client- and DPoP-binding checked before consume. PlusAttesto.CIBA.Grant, theAttesto.CIBAStorebehaviour (every transition a single atomic guarded operation; the poll interval is frozen into the record at issue time), and an ETS reference store. Attesto.RequestObject.verify/3gains:require_iat,:require_jti, and:require_client_id_claimoptions for the CIBA signed-request profile (defaults preserve the RFC 9101 behaviour).- Discovery (
Attesto.Discovery) accepts the CIBA Core §4 metadata:backchannel_authentication_endpoint,backchannel_token_delivery_modes_supported,backchannel_authentication_request_signing_alg_values_supported, andbackchannel_user_code_parameter_supported. - OpenID Connect Front-Channel Logout 1.0 (OP side).
Attesto.FrontChannelLogout.logout_uri/3builds the exact URI an OP logout page loads in an iframe for one Relying Party: the registeredfrontchannel_logout_uriwithiss+sidquery parameters appended whenever the session'ssidis known (both together or neither, §2).Attesto.LogoutSessionStorerecords both logout channels: an entry now carriesfrontchannel_logout_uri/frontchannel_session_requiredalongside the back-channel fields, andbackchannel_logout_uriis optional — a row exists for any RP that registered at least one logout URI.Attesto.Discoveryaccepts thefrontchannel_logout_supportedandfrontchannel_logout_session_supportedhost members (§3).
- OpenID Connect Session Management 1.0 (OP side).
Attesto.SessionStatecomputes the §3.2session_statevalue (lowercase-hex SHA-256 overclient_id <> " " <> origin <> " " <> op_browser_state <> " " <> salt, dot, salt) plus the browser-formorigin/1of aredirect_uriand the salt / OP-browser-state generators — the pure computation the OP's authorization response and thecheck_session_iframe's JavaScript recomputation must agree on.Attesto.Discoveryaccepts thecheck_session_iframehost member (§3.3).
[1.0.0] - 2026-07-04
First stable release; the public API is now under semantic versioning. No functional change from 0.13.0.
The authorization-server core (tokens, ID tokens, PKCE, DPoP, PAR, discovery, RP-Initiated and Back-Channel Logout) backs an OpenID Provider that passes the OpenID Foundation conformance suite for OpenID Connect Core (Basic), FAPI 2.0 Security Profile Final, FAPI 2.0 Message Signing Final, RP-Initiated Logout, and Back-Channel Logout.
[0.13.0] - 2026-06-23
Added
- OpenID Connect Logout (RP-Initiated Logout 1.0 + Back-Channel Logout 1.0).
Attesto.LogoutTokenmints a signed Back-Channellogout_token(§2.4):typ: "logout+jwt", theeventsclaim{"http://schemas.openid.net/event/backchannel-logout": {}},iss/aud/iat/jti/short-exp, at least one ofsub/sid, and never anonce.Attesto.EndSessionis the conn-free RP-Initiated Logout validator:parse/2verifies theid_token_hint, resolves the Relying Partyclient_id(rejecting aclient_idparameter that disagrees with the hint'saud), and extracts the sessionsub/sid;confirm_redirect/2honors apost_logout_redirect_urionly on an exact match against the client's registered set and appendsstate— an unregistered or unidentifiable return URI is refused (no open redirect).Attesto.IDTokengains asidclaim (:sidmint option, OIDC Back-Channel Logout §2.1) andverify_logout_hint/2, which validates a hint's signature- issuer while tolerating expiry and reading the RP from
audrather than requiring it up front (RP-Initiated Logout §2).
- issuer while tolerating expiry and reading the RP from
Attesto.LogoutSessionStorebehaviour: the OP-side(sid, client_id) -> backchannel_logout_uridelivery map, with an atomictake_targets/1(enumerate-and-delete) so concurrent logouts cannot double-deliver.- Discovery (
Attesto.Discovery) gainsend_session_endpoint,backchannel_logout_supported, andbackchannel_logout_session_supported.
[0.12.0] - 2026-06-23
Added
- RFC 8628 Device Authorization Grant. New
Attesto.DeviceCodeprimitive (issue + the §3.5 polling state machine:authorization_pending/slow_down/expired_token/access_denied, expiry-beats-approval, single-use consume),Attesto.DeviceCode.Grant, theAttesto.DeviceCodeStorebehaviour (every transition a single atomic guarded operation), and an ETS reference store. Theuser_codeuses an ambiguity-free base-20 alphabet and is normalized + charset-validated before any store lookup.device_authorization_endpointis now an accepted RFC 8414 metadata field.
[0.11.0] - 2026-06-22
Added
- RFC 9470 Step-Up Authentication Challenge. New
Attesto.StepUp+Attesto.StepUp.Requirementprimitive: a requirement is acceptedacr_valuesand/or amax_agefreshness bound;evaluate/3checks a verified token'sacr/auth_timeclaims (conjunction, fail-closed on absent/malformed) and returns the §3 challenge params. Attesto.Token.mint/3accepts optional:acr/:auth_time, written as access-token claims (the carrier a resource server enforces step-up against).Attesto.RefreshTokencarries the originalacr/auth_timeacross rotation unchanged, so a refresh-minted access token reports the real authentication event (auth_timeis never re-stamped).Attesto.Plug.OAuthError.insufficient_user_authentication/4(the RFC 9470 §3 401 challenge) and a:step_upoption onAttesto.Plug.Authenticatethat enforces a per-route requirement after token verification.acr_values_supportedis now an accepted protected-resource-metadata host field (RFC 9728), so a resource server can advertise theacrvalues it can demand.
[0.10.0] - 2026-06-22
Added
- RFC 8707 Resource Indicators. New
Attesto.ResourceIndicatorprimitive (validate/1for §2.1 absolute-URI syntax over the scalar/arrayresourceparameter;authorize/2for §2.2 allow-listing →:invalid_target). Attesto.Token.mint/3's:audienceoption now accepts a list of resource identifiers, written as a JWTaudarray (a single resource still collapses to a string).Token.verifyalready checks array membership, so a resource server validates that its own identifier is inaud.Attesto.AuthorizationCode/Attesto.RefreshTokenbind aresourceset alongside scope; refresh rotation carries it and narrows it subset-only (a requested resource outside the granted set is:invalid_target).Attesto.AuthorizationRequestparses and validates theresourceparameter, surfacing a malformed value as a redirectableinvalid_targeterror.
[0.9.0] - 2026-06-21
Changed
Attesto.Plug.Authenticatebearer presentation methods are explicit and header-only by default. The new:bearer_methodsoption accepts:header/"header"and:body/"body"; it defaults to[:header]. A resource server that intentionally accepts RFC 6750 §2.2 form-bodyaccess_tokencredentials must opt in withbearer_methods: [:header, :body]and advertise the matchingbearer_methods_supportedmetadata. URI-query bearer tokens remain unsupported. DPoP, mTLS, and host-provided:credential_from_connfallback credentials are unchanged.
[0.8.1] - 2026-06-21
Changed
Attesto.Plug.OAuthError.insufficient_scope/4now honors the transport hooks. The 403 scope-rejection path threads the same:send_error,:www_authenticate, and:no_storeoptionsunauthorized/4already honored, so a resource server can override the 403 response envelope and inject a per-conn challenge (e.g. a request-derived RFC 9728resource_metadatapointer) on the scope-rejection path, not just the authentication-rejection path. Theinsufficient_scopecode, 403 status, and theerror_description/scopechallenge semantics remain owned by the renderer; the default response is byte-identical when no hooks are passed.Attesto.Plug.RequireScopesnow threads those transport hooks onto both the 403insufficient_scopeand the 401invalid_tokenit renders, alongside the existing:resource_metadatapointer. Previously they were dropped, so a host could not override the scope-rejection envelope through this plug.
[0.8.0] - 2026-06-20
Added
Attesto.ProtectedResourceMetadata— renderer for the RFC 9728 OAuth 2.0 Protected Resource Metadata document (the resource-server analogue ofAttesto.Discovery).metadata/2returns the string-keyed map a resource publishes at/.well-known/oauth-protected-resource: the REQUIREDresourceidentifier (defaulting toconfig.audience, overridable via:resource) plus the nil-droppable RFC 9728 §2 host fields (authorization_servers,jwks_uri,scopes_supported,bearer_methods_supported,resource_signing_alg_values_supported,authorization_details_types_supported, theresource_name/documentation/ policy/ToS members,tls_client_certificate_bound_access_tokens,dpop_bound_access_tokens_required,dpop_signing_alg_values_supported, andsigned_metadata). A present-but-malformed:resource(the REQUIRED member) fails fast withArgumentError. Conn-free; mounting a serving endpoint is the host's concern.RFC 9728 §5.1
resource_metadatachallenge pointer.Attesto.Plug.OAuthError.unauthorized/4andinsufficient_scope/3,4now append aresource_metadata="<url>"auth-param to theWWW-Authenticatechallenge when a:resource_metadataopt is present, so a client refused with 401/403 can discover the resource's protected-resource metadata (and thereby its authorization server). Threaded throughAttesto.Plug.Authenticate(:resource_metadatainit opt) andAttesto.Plug.RequireScopes(:resource_metadatainit opt). Omitted when unset.Attesto.Token.mint/3:audienceoption — a per-call override for the access token'saudclaim, defaulting toconfig.audience. RFC 8707 §2: when a token request carries aresourceindicator the access token'saudMUST identify that resource; the host derives the resource identifier and passes it here. The override is conn-free and does not mutateconfig, so one issuer can mint resource-audienced tokens for one grant without changingaudfor any other. A present-but-malformed override (anil,"", list, or other non-string) is rejected{:error, :invalid_audience}rather than minted, so a miswiredresourcecannot produce a malformedaud.Attesto.IdentityAssertion— verification for the Identity Assertion JWT Authorization Grant (ID-JAG), the resource Authorization Server's half ofdraft-ietf-oauth-identity-assertion-authz-grant-04(the grant behind MCP Enterprise-Managed Authorization). Conn-free and side-effect-free:verify/3checks the assertion's signature against a caller-supplied trusted issuer JWKS (kid selection,RS256/PS256/ES*/EdDSA) and enforces the draft's claim rules — JOSEtyppinned tooauth-id-jag+jwt,issmatches the trusted issuer (NOT theclient_id),audis exactly this server's issuer (strict single value), the requirediss/sub/aud/client_id/jti/exp/iatclaims,client_idbinding, andexp/iat/nbfskew with an optional:max_lifetime_secondsbound.peek_issuer/1reads the unverifiedissso the caller can select the trusted issuer before verifying. The stateful concerns (JWKS fetch/cache,jtireplay, subject resolution, error mapping to RFC 6749invalid_grant) belong to theattesto_phoenixtoken layer. Distinct fromprivate_key_jwtclient auth (RFC 7523 §3) and the RFC 8693 token-exchange grant (which runs at the IdP).
[0.7.2] - 2026-06-16
Added
Attesto.CodeStore.get/1(OPTIONAL callback) — read a stored authorization code WITHOUT consuming it (unliketake/1). Implemented by the bundledAttesto.CodeStore.ETS. Lets the token endpoint run read-only pre-checks (e.g. a holder-of-key requirement) without burning the single-use code.Attesto.AuthorizationCode.dpop_bound?/2— returns whether a stored code is bound to a DPoP key (RFC 9449 §10), via the store's non-consumingget/1. Used to surface a holder-of-key (invalid_request/invalid_dpop_proof) rejection ahead of the client-auth error at the token endpoint (FAPI2ensure-holder-of-key-required), without consuming the code. Returnsfalsewhen the store has noget/1, so behaviour is unchanged for stores that don't implement it.
[0.7.1] - 2026-06-14
Security
- Refresh-rotation grace no longer replays an already-rotated successor.
RefreshToken.rotate/3's within-grace idempotent-retry path returned the parent's cached successor without checking it was still the live, unconsumed leaf. AfterA → B → C, a replay of the captured parentAinside the grace window re-issuedB(and minted a fresh access token from it) instead of detecting reuse — suppressing the OAuth 2.0 Security BCP §4.13 captured-token signal and forking a live chain. The grace retry now confirms the cached successor is still unconsumed; if it has been rotated onward, the replay is treated as reuse and the whole family is revoked.
Added
Attesto.AuthorizationRequestcarriesdpop_jkt. The validated request now exposes the RFC 9449 §10dpop_jktparameter, read from the EFFECTIVE (post-request-object-merge) params — so a signed request object'sdpop_jktis authoritative and an unsigned outer-query value is ignored when a request object is present. (Consumed byattesto_phoenix's authorization endpoint, which previously read it from the raw outer query.)
[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 itsclient_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?/1andvalidate_client_id/1enforce the draft §2 URL grammar (https, path required, no fragment/userinfo/dot-segments);validate_document/2validates a fetched document (theclient_idmust equal the URL, no shared symmetric secret /client_secret_*auth method, a non-emptyredirect_uris) and normalizes it into the same client shape the RFC 7591 registration path produces. The network half (SSRF-guarded fetch, caching) lives inattesto_phoenix; this module touches no socket and adds no dependency.Attesto.Discoveryadvertises theclient_id_metadata_document_supportedauthorization-server metadata field when the host enables it.
[0.6.16] - 2026-06-13
Fixed
Authorization-code redemption is now atomic.
Attesto.AuthorizationCode.redeem/4no longer records the reuse marker (consumed_success) itself; that moved to the newfinalize/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 hostbuild_principalcallback returning the subject under the wrong key) is left single-use-spent but NOT reuse-flagged: a replay is a cleaninvalid_grantinstead 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/3to record the reuse marker. The bundledattesto_phoenixtoken endpoint (>= 0.7.7) does this. Stores that do not implement the optionalmark_consumed/2are unaffected.
[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.