diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a9046..b448a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,53 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-10-01 + > **Versioning:** This entry contains breaking changes. The project is pre-1.0 (`0.x`); per SemVer, breaking changes on the `0.x` line ship in the next **minor** (targeting `0.5.0`), not a major bump. `RELEASE_POLICY.md`'s "major bump for breaking changes" rule takes effect once the project reaches `1.0.0`. ### Added -- `AuthplaneClient.resource(...)`, `authplane_auth()` and `authplane_mcp_auth()` accept `resource_metadata_url=` — the RFC 9728 §5.1 URL to advertise when the PRM document is AS-hosted — read back through the new `AuthplaneResource.resource_metadata_url()`, which falls back to `prm_url()`. -- `AuthplaneClient.resource(...)` logs at INFO when a `revocation_checker` is configured while `fail_closed` is left at its fail-open default, so the posture appears in startup output rather than only in the docstring. INFO rather than a warning because that default is a documented choice — the no-op pairing 0.4.0 added a warning for — `fail_closed=True` with no `revocation_checker` — is the mistake, and that one warns. -- `read_dpop_header_from_scope`, `raw_request_path_from_scope` and `get_or_create_verify_cache_from_scope` are exported from the `authplane` package root, for middleware that cannot run under Starlette's `BaseHTTPMiddleware` because its response queue stalls a long-lived `text/event-stream` body. They are the raw-ASGI counterparts of the request-based helpers the adapters use, routing through the same rules, so the two entry points cannot drift. Exported rather than left in `authplane._dpop_adapter` because the consumer is third-party middleware outside this repository: a private-module import on a `0.x` package would break an installed resource server at import time the first time the module is renamed. -- `validate_prm_resource_identifier` is exported from the `authplane` package root: the construction-time gate itself (fragment, whitespace/control, absoluteness, userinfo, port), which the MCP adapters now call before `AuthplaneClient.create()` through the public API rather than through `authplane.internal`. The name is scoped to the **resource-server** identifier, the one that publishes Protected Resource Metadata (RFC 9728 §3): it rejects a valid RFC 8707 §2 resource indicator such as `urn:example:api`, and it does not gate a token request's `resource` parameter. -- `validate_resource_metadata_url` is exported from the `authplane` package root, alongside `validate_prm_resource_identifier` and `validate_issuer_identifier`. It is the construction-time gate for `resource_metadata_url=` (absolute `http`/`https` URL with a host, no fragment, no userinfo, no whitespace or control character, no `"` or `\` anywhere — RFC 9728 §3/§5.1, RFC 3986 §2, RFC 9110 §4.2.4/§11.2), so a consumer can check configuration before construction instead of importing it from `authplane.internal`. Both adapters call it through the package root for the same reason the two sibling gates are exported: a private-module import on a `0.x` package breaks an installed adapter/core pair the first time the module moves. -- `validate_issuer_identifier` is exported from the `authplane` package root, alongside `validate_prm_resource_identifier`. It is the construction-time issuer gate itself (query/fragment, whitespace/control, absoluteness, userinfo — RFC 8414 §2/§3.1, RFC 3986 §2, RFC 9110 §4.2.4), so a consumer can check configuration before calling `AuthplaneClient.create(...)` or `build_prm(...)` instead of importing it from `authplane.internal` — a private-module import on a `0.x` package, which breaks an installed application the first time the module moves. `InvalidIssuerError` was already exported; the predicate that raises it was not. -- `www_authenticate_challenges(error, *, schemes=None, algs=(), realm="", resource_metadata_url=None, scope=None, verbose_description=False)` — returns one `WWW-Authenticate` value per acceptable scheme (RFC 7235 §4.1), so a resource can advertise both `Bearer` and `DPoP` (RFC 9449 §7.1) with `algs` on the DPoP challenge. -- `AccessDeniedError` and `InvalidTargetError` — typed `AuthError` subclasses for the `access_denied` (403) and `invalid_target` (400, RFC 8707 §2.2) answers authserver 0.2.0 gives a token exchange; both are exported from the package root and neither counts toward the circuit breaker. -- `AuthplaneClient.resource(...)` logs a warning when `IntrospectionRevocation` is configured on a client created without `auth=`: authserver ≥ 0.1.2 answers `active: false` to unauthenticated introspection, so every token would be rejected as revoked. -- `AuthplaneResource` logs one warning per resource the first time introspection answers `active: false` for a token that passed local verification, naming the runtime-client requirement — a non-owner gets the same answer as a revocation. +- `AuthplaneClient.resource(...)`, `authplane_auth()` and `authplane_mcp_auth()` accept `resource_metadata_url=`, the RFC 9728 §5.1 URL to advertise when the PRM document is AS-hosted; read it back with `AuthplaneResource.resource_metadata_url()`, which falls back to `prm_url()`. +- `AuthplaneClient.resource(...)` logs at INFO when a `revocation_checker` is configured with the fail-open default, so the posture shows in startup output. +- `read_dpop_header_from_scope`, `raw_request_path_from_scope` and `get_or_create_verify_cache_from_scope` are exported from the package root: raw-ASGI counterparts of the adapter helpers, for middleware that cannot run under Starlette's `BaseHTTPMiddleware` with long-lived `text/event-stream` bodies. +- `validate_prm_resource_identifier`, `validate_issuer_identifier` and `validate_resource_metadata_url` are exported from the package root, so configuration can be checked before construction without importing `authplane.internal`. +- `www_authenticate_challenges(error, *, schemes=None, algs=(), ...)` returns one `WWW-Authenticate` value per scheme, so a resource can advertise both `Bearer` and `DPoP` (RFC 9449 §7.1) with `algs`. +- `AccessDeniedError` (403) and `InvalidTargetError` (400, RFC 8707 §2.2) for the token-exchange answers authserver 0.2.0 gives; neither counts toward the circuit breaker. +- `AuthplaneClient.resource(...)` warns when `IntrospectionRevocation` is configured on a client created without `auth=`: authserver ≥ 0.1.2 answers `active: false` to unauthenticated introspection, so every token would be rejected. +- `AuthplaneResource` warns once per resource when introspection answers `active: false` for a locally valid token, naming the runtime-client requirement. ### Deprecated - `VerifiedClaims.may_act` — now emits `DeprecationWarning`; authserver 0.2.0 no longer issues `may_act`; removed in the next minor. ### Security -- `www_authenticate()` no longer copies the exception message into `error_description`; the description is now a fixed sentence chosen by the RFC 6750 §3.1 error code, and `www_authenticate()` / `response_headers_for()` take `verbose_description=True` to opt the message back onto the wire for development. +- `www_authenticate()` no longer copies the exception message into `error_description`; it sends a fixed sentence per RFC 6750 error code. Pass `verbose_description=True` to `www_authenticate()` / `response_headers_for()` to restore the message in development. ### Fixed -- A rotated `jwks_uri` is now followed on ordinary verification traffic, not only when the rotation also introduces a new `kid`. **Impact:** a key the AS published at the old location and left out of the new one now stops verifying as soon as the rotation is observed, rather than after up to `jwks_refresh_seconds` (one hour by default). Rotating `jwks_uri` was never a way to migrate keys gradually. -- The `InvalidResourceError` / `InvalidIssuerError` messages now echo the identifier faithfully: only the components the input actually has are rendered (still userinfo-redacted, query/fragment still dropped). The previous fixed `scheme://host/path` template invented the missing half on exactly the inputs the new absoluteness gate rejects — `/mcp` echoed as `':///mcp'`, and `urn:example:api` as `'urn://example:api'`, an opaque identifier shown as though it had the host (and port) the message says is missing — and made the scheme-less and scheme-relative forms indistinguishable. -- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth(...)` / `authplane_mcp_auth(...)` close the `AuthplaneClient` they created when anything raises between `AuthplaneClient.create()` and the return — e.g. `client.resource(...)`'s `ValueError` for an out-of-range `allowed_algorithms` — instead of stranding it un-`aclose()`d behind the configuration error. The close is best-effort (`contextlib.suppress`): a failure in `aclose()` itself no longer replaces the configuration error the handler exists to surface. -- The `InvalidResourceError` echo no longer drops the `//` from an identifier whose authority is present but empty: `https://` echoed as `'https:'` (indistinguishable from the opaque form) and `file:///x` as `'file:/x'`. The `//` is now rendered when the raw string spells one out, not only when the parsed netloc is non-empty. -- AS metadata is now re-read on the verify path, so `metadata_refresh_seconds` is no longer inert on a resource server that only verifies tokens, and a rotated `jwks_uri` is followed. A verify-only deployment previously fetched metadata exactly once, at construction. The re-read runs after the token's header, `alg` and `typ` checks, so a structurally invalid token from an unauthenticated caller reaches no network I/O, and a failed refresh is logged with the current key set kept, so it cannot fail a verification that used to pass. -- A failed document refresh now backs off for `max(1, min(30, refresh_seconds))` seconds instead of being retried by the next reader, for the JWKS cache as well as the metadata cache. **Impact:** a one-second blip at a newly advertised `jwks_uri` now rejects tokens signed by the rotated key for up to `min(30, jwks_refresh_seconds)` seconds, where the retry used to be immediate; lower `jwks_refresh_seconds` to shorten it. -- `jwks_uri` is now resolved from the metadata document on every key-set fetch instead of being captured at construction and rebound by a change callback, so a rotation takes effect on the next fetch and a newly advertised URI that turns out to be unreachable leaves the working key set serving. The guarantee is "on the next fetch", not atomic: a `kid` miss re-reads the metadata document at most once per `min(metadata_refresh_seconds, 60)` seconds, so a rotation arriving inside a window a previous miss already consumed waits out the remainder. -- A fetched AS metadata document is validated before it is cached, not after it is read. Committing first left a document failing the RFC 8414 §3.3 issuer check visible to everything reading the cache — including where the key set is fetched from — while the error surfaced to a caller that had already acted on it. A document that fails the issuer check, or that names a non-HTTPS or malformed endpoint, now reaches nothing: the previously accepted one stays in place and verification continues against the key set it was already using. -- `authplane-fastmcp`, `authplane-mcp`: the path used to build the DPoP `htu` now drops everything from the first `?` on, so the reader's contract matches what RFC 9449 §4.2 defines `htu` to be. No verification behaviour changes: `dpop_verification` already normalized both the request URL and the proof's `htu` through `normalize_dpop_htu`, which strips query and fragment, so a query-carrying `raw_path` was discarded at the comparison boundary anyway. What this fixes is a helper that returned more than it promised — relied on by any future consumer that does not normalize. uvicorn (h11 and httptools) and Starlette's `TestClient` strip the query before the helper ever sees it. +- AS metadata is re-read on the verify path, so `metadata_refresh_seconds` now applies to verify-only deployments and a rotated `jwks_uri` is followed. The re-read happens after header checks, so malformed tokens trigger no network I/O. +- `jwks_uri` is resolved from metadata on every key-set fetch, so a rotation takes effect on the next fetch even without a new `kid`. **Impact:** keys left out of the new location stop verifying once the rotation is observed. +- A failed JWKS or metadata refresh backs off for `max(1, min(30, refresh_seconds))` seconds instead of retrying on the next read. **Impact:** a blip at a newly rotated `jwks_uri` can reject the new key for up to that window. +- A fetched AS metadata document is validated before it is cached; one that fails the issuer check or names a bad endpoint is discarded and the previous one keeps serving. +- `InvalidResourceError` / `InvalidIssuerError` messages echo only the components the input actually has (still userinfo-redacted), instead of a fixed template that invented the missing parts. +- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth(...)` / `authplane_mcp_auth(...)` close the client they created when configuration fails before returning. +- `authplane-fastmcp`, `authplane-mcp`: the path used to build the DPoP `htu` drops the query, matching RFC 9449 §4.2. No verification behaviour changes. ### Changed -- Docs: `IntrospectionRevocation`, `AuthplaneClient.resource(...)` and the three user guides now lead with the consequence — an introspection error lets the token through unless `fail_closed=True` — and say when each direction is right; the fail-open default is unchanged. -- **BREAKING (pre-1.0)** `ASCredentials` raises `ValueError` at construction when `client_id` or `client_secret` is empty; an empty secret authenticates as a public client, which cannot introspect at all. **Migration:** pass the real secret, or omit `ASCredentials` entirely. -- Docs: the introspection sections state that the resource server's client must be confidential and either the issuing client or a runtime-client of the Resource (`authserver admin resource runtime-client add`); the token-exchange sections explain `access_denied` versus `consent_required`, `invalid_target`, and the `PATCH /admin/resources/{id}` exchange allowlist step; the README carries the authserver compatibility statement. -- `scripts/manual-e2e-setup.sh` no longer sets `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED` (on by default since authserver 0.2.0) and takes an optional `AUTHSERVER_REF` to check out before building; `scripts/manual-e2e-smoke.sh` no longer calls `POST /admin/scopes`, which authserver 0.2.0 does not serve. -- **BREAKING (pre-1.0)** A resource identifier carrying userinfo — including the empty form `https://@api.example.com/mcp` — is now rejected with `InvalidResourceError` at the same construction-time gate as the fragment and absoluteness checks, citing RFC 9110 §4.2.4. The rejection message is itself userinfo-redacted. **Migration**: remove the credentials from the configured resource; a resource identifier names an endpoint, it does not authenticate to one. -- **BREAKING (pre-1.0)** A resource identifier containing whitespace or a control character anywhere in the raw string (`https://api.exa\tmple.com/mcp`, a leading space, a trailing space) is now rejected with `InvalidResourceError` at the same gate. CPython's `urlsplit` silently *cleans* such input — tab/CR/LF are removed anywhere, and since 3.11 any leading C0-control-or-space is stripped — so a parse-time check would pass a value whose derived well-known URL diverges byte-for-byte from the identifier the SDK stores and the adapters advertise verbatim, which RFC 9728 §3.3 obliges a conformant client to discard. **Migration:** strip stray whitespace from the configured resource string. -- The construction-gate messages now say `resource identifier` (was `resource indicator`), matching the conformance catalog's wording — and the distinction is real, not cosmetic: what this gate requires is a resource *server* identifier that can publish Protected Resource Metadata (RFC 9728 §3), which is stricter than RFC 8707 §2's resource indicator. Code matching on the old operand text must match on `identifier` (or on the axis clause, e.g. `absolute URL with a scheme and a host`, which is unchanged). -- **BREAKING (pre-1.0)** A resource identifier whose port does not parse — non-numeric, or outside 0-65535 — is now rejected with `InvalidResourceError`, citing RFC 3986 §3.2.3. `SplitResult.port` parses lazily, so such an authority previously passed the scheme-and-host check and then produced an unusable PRM URL. The message renders the port the operator wrote when it is all digits and marks it malformed otherwise — a non-digit port has the same shape as a userinfo whose `@` was forgotten, so quoting it back would defeat the message's own redaction. **Migration**: correct the port. -- **BREAKING (pre-1.0)** A resource identifier must now be an absolute URL with a scheme and a host, rejected with `InvalidResourceError` at the same construction-time gate as the fragment check — `AuthplaneClient.resource(...)`, `AuthplaneResource(...)`, and `authplane_mcp_auth(...)` / `authplane_auth(...)` before metadata discovery. A relative or opaque identifier (`/mcp`, `//api.example.com/mcp`, `urn:example:api`) previously produced a malformed metadata URL and, in the MCP adapter, a malformed DPoP `htu` origin (the literal `"://"`), so DPoP-bound requests could not verify. `http` hosts are still accepted for local development. **Migration**: configure `resource` as the full URL clients address it by — `https://api.example.com/mcp`, or `http://localhost:8080/mcp` in development — not a bare path or URN. -- **BREAKING (pre-1.0)** `DocumentCache`'s and `MetadataCache`'s `on_change` keyword argument is removed, along with the `DocumentChangeCallback` alias and its `authplane.internal.__all__` entry. It existed only to rebind the JWKS cache when `jwks_uri` changed, and `jwks_uri` is now resolved per fetch instead. **Migration:** none expected — the module is named `internal` and nothing here is re-exported from the package root — but passing `on_change=` is now a `TypeError` rather than being ignored. -- **BREAKING (pre-1.0)** `AuthplaneClient.create(...)` and `build_prm(...)` now raise `InvalidIssuerError` for an issuer that is not an absolute URL with a scheme and a host (RFC 8414 §2, §3.1) or that carries a userinfo subcomponent (RFC 9110 §4.2.4) — `build_prm` previously published it unchecked to unauthenticated callers. **Migration:** configure the issuer as scheme + host + path, no credentials. -- **BREAKING (pre-1.0)** A resource identifier whose host holds a literal `"` or `\` is now rejected with `InvalidResourceError` at construction: both are `WWW-Authenticate` quoted-string delimiters (RFC 9110 §5.6.4, §11.2), so the `resource_metadata` challenge advertised a URL clients could not parse or silently unescaped into a different host. **Migration:** percent-encode or remove the character. -- **BREAKING (pre-1.0)** An issuer identifier containing whitespace or a control character (RFC 3986 §2) is now rejected with `InvalidIssuerError` at `AuthplaneClient.create(...)`, `build_metadata_url(...)` and `build_prm(...)`, using the same class as the resource gate — C0 and space, DEL, the C1 controls, Unicode whitespace and U+FEFF — with the offending codepoint and offset named. `urlsplit` removes tab, CR and LF from anywhere in the input, so such an issuer derived a fetch target the AS-metadata comparison could no longer match and failed as a confusing `AS metadata issuer mismatch`. **Migration:** remove the invisible character the offset points at. -- **BREAKING (pre-1.0)** `build_prm(...)` now also raises `InvalidResourceError` for a `resource` that is not a usable resource-server identifier — fragment, whitespace or control character, non-absolute, userinfo, quoted-string delimiter in the host, or malformed port — applying the same construction gate as `AuthplaneResource`. It was copied unchecked into the document RFC 9728 §3 serves to unauthenticated callers, beside the issuer, and §3.3 has a client discard a document whose `resource` does not match the identifier it dereferenced. **Migration:** pass the identifier you configure on `AuthplaneClient.resource(...)`; every in-SDK caller already does. -- **BREAKING (pre-1.0)** The resource identifier's whitespace and control rejection now covers DEL, the C1 controls (U+007F-U+009F) and U+FEFF, none of which is a URI character (RFC 3986 §2) and none of which `urlsplit` strips; the message names the offending codepoint and its offset. **Migration:** remove the invisible character the offset points at. +- Docs: introspection now leads with the fail-open consequence and the confidential / runtime-client requirement; token exchange explains `access_denied` vs `consent_required`, `invalid_target` and the exchange allowlist; the README states authserver compatibility. +- `scripts/manual-e2e-setup.sh` no longer sets `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED` and accepts `AUTHSERVER_REF`; `scripts/manual-e2e-smoke.sh` no longer calls `POST /admin/scopes`, which authserver 0.2.0 does not serve. +- **BREAKING (pre-1.0)** `ASCredentials` raises `ValueError` when `client_id` or `client_secret` is empty. **Migration:** pass the real secret, or omit `ASCredentials`. +- **BREAKING (pre-1.0)** A resource identifier must be an absolute URL with a scheme and host, with no userinfo, whitespace or control characters, `"` or `\` in the host, or malformed port; anything else raises `InvalidResourceError` at construction. **Migration:** configure the full URL clients use, e.g. `https://api.example.com/mcp`. +- **BREAKING (pre-1.0)** An issuer must be an absolute URL with a scheme and host, with no userinfo, whitespace or control characters; `AuthplaneClient.create(...)`, `build_metadata_url(...)` and `build_prm(...)` raise `InvalidIssuerError` otherwise. **Migration:** fix the configured issuer. +- **BREAKING (pre-1.0)** `build_prm(...)` applies the same resource-identifier gate as `AuthplaneResource`. **Migration:** pass the identifier you configure on `AuthplaneClient.resource(...)`. +- Construction-gate messages say `resource identifier` instead of `resource indicator`. Code matching on the old text must be updated. +- **BREAKING (pre-1.0)** The `on_change` argument of `DocumentCache` / `MetadataCache` and the `DocumentChangeCallback` alias are removed from `authplane.internal`. **Migration:** none expected; passing `on_change=` is now a `TypeError`. ## [0.4.0] - 2026-08-28