# Changelog
All notable changes to this project are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.13.0] - 2026-06-23
### Added
- **OpenID Connect Logout (RP-Initiated Logout 1.0 + Back-Channel Logout 1.0).**
- `Attesto.LogoutToken` mints a signed Back-Channel `logout_token`
(§2.4): `typ: "logout+jwt"`, the `events` claim
`{"http://schemas.openid.net/event/backchannel-logout": {}}`, `iss`/`aud`/
`iat`/`jti`/short-`exp`, at least one of `sub`/`sid`, and never a `nonce`.
- `Attesto.EndSession` is the conn-free RP-Initiated Logout validator:
`parse/2` verifies the `id_token_hint`, resolves the Relying Party
`client_id` (rejecting a `client_id` parameter that disagrees with the
hint's `aud`), and extracts the session `sub`/`sid`; `confirm_redirect/2`
honors a `post_logout_redirect_uri` only on an **exact** match against the
client's registered set and appends `state` — an unregistered or
unidentifiable return URI is refused (no open redirect).
- `Attesto.IDToken` gains a `sid` claim (`:sid` mint option, OIDC Back-Channel
Logout §2.1) and `verify_logout_hint/2`, which validates a hint's signature
+ issuer while **tolerating expiry** and reading the RP from `aud` rather
than requiring it up front (RP-Initiated Logout §2).
- `Attesto.LogoutSessionStore` behaviour: the OP-side
`(sid, client_id) -> backchannel_logout_uri` delivery map, with an atomic
`take_targets/1` (enumerate-and-delete) so concurrent logouts cannot
double-deliver.
- Discovery (`Attesto.Discovery`) gains `end_session_endpoint`,
`backchannel_logout_supported`, and `backchannel_logout_session_supported`.
## [0.12.0] - 2026-06-23
### Added
- **RFC 8628 Device Authorization Grant.** New `Attesto.DeviceCode` primitive
(issue + the §3.5 polling state machine: `authorization_pending` / `slow_down`
/ `expired_token` / `access_denied`, expiry-beats-approval, single-use
consume), `Attesto.DeviceCode.Grant`, the `Attesto.DeviceCodeStore` behaviour
(every transition a single atomic guarded operation), and an ETS reference
store. The `user_code` uses an ambiguity-free base-20 alphabet and is
normalized + charset-validated before any store lookup. `device_authorization_endpoint`
is 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.Requirement` primitive: a requirement is accepted `acr_values`
and/or a `max_age` freshness bound; `evaluate/3` checks a verified token's
`acr` / `auth_time` claims (conjunction, fail-closed on absent/malformed) and
returns the §3 challenge params.
- `Attesto.Token.mint/3` accepts optional `:acr` / `:auth_time`, written as
access-token claims (the carrier a resource server enforces step-up against).
- `Attesto.RefreshToken` carries the original `acr` / `auth_time` across rotation
unchanged, so a refresh-minted access token reports the real authentication
event (`auth_time` is never re-stamped).
- `Attesto.Plug.OAuthError.insufficient_user_authentication/4` (the RFC 9470 §3
401 challenge) and a `:step_up` option on `Attesto.Plug.Authenticate` that
enforces a per-route requirement after token verification.
- `acr_values_supported` is now an accepted protected-resource-metadata host
field (RFC 9728), so a resource server can advertise the `acr` values it can
demand.
## [0.10.0] - 2026-06-22
### Added
- **RFC 8707 Resource Indicators.** New `Attesto.ResourceIndicator` primitive
(`validate/1` for §2.1 absolute-URI syntax over the scalar/array `resource`
parameter; `authorize/2` for §2.2 allow-listing → `:invalid_target`).
- `Attesto.Token.mint/3`'s `:audience` option now accepts a list of resource
identifiers, written as a JWT `aud` array (a single resource still collapses
to a string). `Token.verify` already checks array membership, so a resource
server validates that its own identifier is in `aud`.
- `Attesto.AuthorizationCode` / `Attesto.RefreshToken` bind a `resource` set
alongside scope; refresh rotation carries it and narrows it subset-only (a
requested resource outside the granted set is `:invalid_target`).
- `Attesto.AuthorizationRequest` parses and validates the `resource` parameter,
surfacing a malformed value as a redirectable `invalid_target` error.
## [0.9.0] - 2026-06-21
### Changed
- **`Attesto.Plug.Authenticate` bearer presentation methods are explicit and
header-only by default.** The new `:bearer_methods` option accepts `:header` /
`"header"` and `:body` / `"body"`; it defaults to `[:header]`. A resource
server that intentionally accepts RFC 6750 §2.2 form-body `access_token`
credentials must opt in with `bearer_methods: [:header, :body]` and advertise
the matching `bearer_methods_supported` metadata. URI-query bearer tokens
remain unsupported. DPoP, mTLS, and host-provided `:credential_from_conn`
fallback credentials are unchanged.
## [0.8.1] - 2026-06-21
### Changed
- **`Attesto.Plug.OAuthError.insufficient_scope/4` now honors the transport
hooks.** The 403 scope-rejection path threads the same `:send_error`,
`:www_authenticate`, and `:no_store` options `unauthorized/4` already honored,
so a resource server can override the 403 response envelope and inject a
per-conn challenge (e.g. a request-derived RFC 9728 `resource_metadata`
pointer) on the scope-rejection path, not just the authentication-rejection
path. The `insufficient_scope` code, 403 status, and the `error_description` /
`scope` challenge semantics remain owned by the renderer; the default response
is byte-identical when no hooks are passed.
- **`Attesto.Plug.RequireScopes` now threads those transport hooks** onto both
the 403 `insufficient_scope` and the 401 `invalid_token` it renders, alongside
the existing `:resource_metadata` pointer. 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 of
`Attesto.Discovery`). `metadata/2` returns the string-keyed map a resource
publishes at `/.well-known/oauth-protected-resource`: the REQUIRED `resource`
identifier (defaulting to `config.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`, the `resource_name`/documentation/
policy/ToS members, `tls_client_certificate_bound_access_tokens`,
`dpop_bound_access_tokens_required`, `dpop_signing_alg_values_supported`, and
`signed_metadata`). A present-but-malformed `:resource` (the REQUIRED member)
fails fast with `ArgumentError`. Conn-free; mounting a serving endpoint is the
host's concern.
- **RFC 9728 §5.1 `resource_metadata` challenge pointer.**
`Attesto.Plug.OAuthError.unauthorized/4` and `insufficient_scope/3,4` now
append a `resource_metadata="<url>"` auth-param to the `WWW-Authenticate`
challenge when a `:resource_metadata` opt is present, so a client refused with
401/403 can discover the resource's protected-resource metadata (and thereby
its authorization server). Threaded through `Attesto.Plug.Authenticate`
(`:resource_metadata` init opt) and `Attesto.Plug.RequireScopes`
(`:resource_metadata` init opt). Omitted when unset.
- **`Attesto.Token.mint/3` `:audience` option** — a per-call override for the
access token's `aud` claim, defaulting to `config.audience`. RFC 8707 §2: when
a token request carries a `resource` indicator the access token's `aud` MUST
identify that resource; the host derives the resource identifier and passes it
here. The override is conn-free and does not mutate `config`, so one issuer can
mint resource-audienced tokens for one grant without changing `aud` for any
other. A present-but-malformed override (a `nil`, `""`, list, or other
non-string) is rejected `{:error, :invalid_audience}` rather than minted, so a
miswired `resource` cannot produce a malformed `aud`.
- **`Attesto.IdentityAssertion`** — verification for the Identity Assertion JWT
Authorization Grant (ID-JAG), the resource Authorization Server's half of
`draft-ietf-oauth-identity-assertion-authz-grant-04` (the grant behind MCP
Enterprise-Managed Authorization). Conn-free and side-effect-free:
`verify/3` checks the assertion's signature against a caller-supplied trusted
issuer JWKS (kid selection, `RS256`/`PS256`/`ES*`/`EdDSA`) and enforces the
draft's claim rules — JOSE `typ` pinned to `oauth-id-jag+jwt`, `iss` matches
the trusted issuer (NOT the `client_id`), `aud` is exactly this server's
issuer (strict single value), the required `iss`/`sub`/`aud`/`client_id`/
`jti`/`exp`/`iat` claims, `client_id` binding, and `exp`/`iat`/`nbf` skew with
an optional `:max_lifetime_seconds` bound. `peek_issuer/1` reads the
unverified `iss` so the caller can select the trusted issuer before verifying.
The stateful concerns (JWKS fetch/cache, `jti` replay, subject resolution,
error mapping to RFC 6749 `invalid_grant`) belong to the `attesto_phoenix`
token layer. Distinct from `private_key_jwt` client auth (RFC 7523 §3) and the
RFC 8693 token-exchange grant (which runs at the IdP).
## [0.7.2] - 2026-06-16
### Added
- **`c:Attesto.CodeStore.get/1`** (OPTIONAL callback) — read a stored authorization
code WITHOUT consuming it (unlike `take/1`). Implemented by the bundled
`Attesto.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-consuming `get/1`.
Used to surface a holder-of-key (`invalid_request`/`invalid_dpop_proof`)
rejection ahead of the client-auth error at the token endpoint (FAPI2
`ensure-holder-of-key-required`), without consuming the code. Returns `false`
when the store has no `get/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. After `A → B → C`, a replay of the captured parent `A` inside the grace
window re-issued `B` (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.AuthorizationRequest` carries `dpop_jkt`.** The validated request
now exposes the RFC 9449 §10 `dpop_jkt` parameter, read from the EFFECTIVE
(post-`request`-object-merge) params — so a signed request object's `dpop_jkt`
is authoritative and an unsigned outer-query value is ignored when a request
object is present. (Consumed by `attesto_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 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).
- `c: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.