diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bb75b..4cd5e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,29 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-10-01 + ### Added -- `core/resource`: `WithResourceMetadataURL(string)` option and `Resource.ResourceMetadataURL()` accessor — point the `WWW-Authenticate` `resource_metadata` parameter at an AS-hosted RFC 9728 document instead of the derived resource-hosted one; the adapters (`http`, `mcp` and `mark3labs` via `Options.ResourceMetadataURL`) advertise it on every challenge. +- `core/resource`: `WithResourceMetadataURL(string)` option and `Resource.ResourceMetadataURL()` accessor point the `resource_metadata` challenge parameter at an AS-hosted RFC 9728 document; the `http`, `mcp` and `mark3labs` adapters advertise it via `Options.ResourceMetadataURL`. - `core/resource`: `AuthErrorResponseWithMetadata(err, resourceMetadataURL, realm...)` emits the RFC 9728 §5.1 `resource_metadata` parameter that the `http` adapter used to append itself; the header is byte-identical. - `core/authplane`: `ErrAccessDenied` and `ErrInvalidTarget` sentinels for the token-endpoint errors `access_denied` (403, cross-client exchange not allowlisted on the target Resource) and `invalid_target` (400, RFC 8707 §2.2); match with `errors.Is`. -- `core/authplane`: the auto-wired introspection checker logs one warning per resource when the AS answers `active: false` for a token that passed local JWT verification, pointing at the runtime-client requirement (authserver ≥ 0.1.2 answers `active: false` to any client that is not the issuing client or a runtime-client of the Resource). +- `core/authplane`: the auto-wired introspection checker warns once per resource when the AS answers `active: false` for a locally valid token, pointing at the runtime-client requirement (authserver ≥ 0.1.2). - `core/resource/verifier`: the fail-open revocation branch logs a `log/slog` warning carrying the checker error instead of accepting the token silently. - `http`, `mcp`, `mark3labs`: every 401 `WWW-Authenticate` challenge now carries `scope="…"` listing the resource's configured scopes (RFC 6750 §3; MCP authorization spec SHOULD), omitted when none are configured. 403 challenges are unchanged. - `core/resource`: `AuthErrorResponseVerbose(err error, realm ...string)` — `AuthErrorResponse` with the error's own message restored in the JSON `error_description`. A development aid: it discloses SDK-internal detail to unauthenticated callers, so do not use it in production. ### Fixed -- `core/resource`: a resource URI rejected at construction no longer reaches the error verbatim when it carries an `@` — `net/url.Error` prints its URL field unredacted, so a password in the identifier landed in whatever log the error did. The fragment, query, space and quote branches now always redact to scheme and host, those being the components that carry credentials (`#access_token=…`, `?access_token=…`); the scheme/host branch redacts only when the identifier holds an `@`, so the misconfigurations it exists to diagnose (`/mcp`, `://no-scheme`) still name themselves, and an identifier with no parseable scheme and host redacts to `(unparseable resource URI)` rather than to scheme and host. `errors.As(err, new(*url.Error))` keeps working. -- `core/internal/cache`: a forced refresh now has a retry floor, so an unknown `kid` — a token header an attacker chooses, decoded before anything is authenticated — no longer costs one JWKS fetch per verification. The cost: when a forced fetch within the last `min(jwksCacheTTL, 1m)` did not return the requested `kid`, a newly rotated `kid` is rejected until that floor elapses. A failed fetch now holds the cached document for a backoff window instead of leaving it expired. -- `core/internal/cache`: a server expiry at or before the moment a document was cached — a stale `Expires:` header is the realistic source — is now treated as no preference, letting the configured refresh interval govern, instead of caching the document already expired and putting every subsequent read on the synchronous fetch path. A server expiry *longer* than the configured interval is clamped to it, so `WithJWKSCacheTTL` / `RefreshInterval` is an upper bound and an authorization server cannot pin a key set for longer than the operator asked. A usable server expiry shorter than the configured interval still wins. -- `core/resource`: the JSON error body for a request that presented no credentials no longer names `invalid_request` while the challenge beside it omits `error` entirely. RFC 6750 §3.1 ties `invalid_request` to a malformed request answered with 400, not to a 401 asking the caller to authenticate, so a client reading the body could treat the response as non-retryable instead of starting RFC 9728 discovery, while a client reading the header saw no error at all. The body now omits the member too, carrying only `error_description`; every other code is unchanged, and the challenge is untouched. +- `core/resource`: a resource URI rejected at construction no longer appears unredacted in the error when it carries credentials; `errors.As(err, new(*url.Error))` keeps working. +- `core/internal/cache`: an unknown `kid` no longer costs one JWKS fetch per verification; forced refreshes have a retry floor of `min(jwksCacheTTL, 1m)`. **Impact:** a newly rotated `kid` can be rejected until that floor elapses. +- `core/internal/cache`: a server expiry at or before caching time no longer leaves the document permanently expired, and a server expiry longer than the configured interval is clamped to it. +- `core/resource`: the JSON error body for a request with no credentials no longer says `invalid_request`; like the challenge, it now omits `error`. ### Changed - `core/authplane`: `access_denied` and `invalid_target` no longer count toward the circuit breaker — both are policy answers about the request, not an AS outage. -- **BREAKING** `core/resource`: `AuthErrorResponse` no longer copies the error's message into the JSON `error_description`; it emits a fixed sentence chosen by the error code. The `http` adapter's `RequireScopes` changes with it (the verifier's `(*VerifiedClaims).RequireScopes` is unchanged): the missing scopes still travel in the `scope=` challenge parameter, but no longer in the body. **Migration:** the `net/http` adapter now logs the diagnostic to `slog.Default()` at DEBUG; log `err` yourself if you verify through `resource.VerifyToken`, or call `AuthErrorResponseVerbose` in development. -- **BREAKING** `core/resource`: `resource.New` now rejects a literal space in the resource URI path. `net/url` accepts `0x20`, but the derived PRM URL escapes it to `%20` while the document's `resource` member keeps it raw, so RFC 9728 §3.3 makes every client discard the document. **Migration:** percent-encode the space. -- **BREAKING** `core/resource`: `resource.New` now rejects a literal `"` in the resource URI host. `net/url` passes it through unescaped, and it closes the `WWW-Authenticate` quoted-string carrying `resource_metadata` early (RFC 9110 §11.2). **Migration:** remove the quote from the host. -- **BREAKING** `core/resource`: `PRMURL()` now preserves the resource identifier's query in the derived PRM URL — RFC 9728 §3 inserts the well-known string ahead of the path and query, so identifiers differing only by query no longer collapse onto one URL. A bare trailing `?` still derives the query-less form, and `WellKnownPRMPath()` is unchanged. **Migration:** update any hard-coded expectation of the old query-less URL. -- **BREAKING** `core/resource`: `resource.New` now rejects a resource URI whose query is not a valid RFC 3986 §3.4 query. The query now reaches the `resource_metadata` quoted-string, where a literal `"`, a space or a malformed `%zz` makes the 401 challenge unparseable. An identifier that worked in 0.3.0 can now fail at startup. **Migration:** percent-encode the offending octets. -- **BREAKING** `core/resource`: `resource.New` now rejects a resource URI carrying userinfo (RFC 9110 §4.2.4). Such an identifier published the credential three times over: in the PRM document, in the 401 challenge, and in the origin the DPoP `htu` comparison is built from. The empty form `https://@host/mcp` counts; an `@` in a path or query does not. **Migration:** move the credentials out of the identifier. +- **BREAKING** `core/resource`: `AuthErrorResponse` emits a fixed `error_description` per error code instead of the error's message; `RequireScopes` no longer lists missing scopes in the body. **Migration:** the `net/http` adapter logs the diagnostic at DEBUG; log `err` yourself elsewhere, or use `AuthErrorResponseVerbose` in development. +- **BREAKING** `core/resource`: `resource.New` rejects a resource URI with a literal space in the path, a `"` in the host, userinfo, or a query that is not valid RFC 3986. **Migration:** percent-encode the offending octets and remove credentials. +- **BREAKING** `core/resource`: `PRMURL()` now keeps the resource identifier's query in the derived PRM URL (RFC 9728 §3); `WellKnownPRMPath()` is unchanged. **Migration:** update any hard-coded expectation of the query-less URL. ### Deprecated - `core/resource/verifier`: `(*VerifiedClaims).MayAct()` — authserver 0.2.0 no longer issues `may_act`; removed in the next minor. Parsing is unchanged until then.