Skip to main content

CHANGELOG.md

# 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).

## [Unreleased]

## [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.