Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 14 additions & 30 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2026-10-01

### Added

- `AccessDeniedException` (`access_denied`, 403) and `InvalidTargetException` (`invalid_target`, 400) typed by `MapOAuthError`; neither counts toward the circuit breaker.
- `AuthplaneMcpAuth.Options.ResourceMetadataUrl` points challenge `resource_metadata` at an AS-hosted PRM document instead of the derived resource-hosted URL; gated at construction under the resource identifier's shape rules (absolute `http(s)` URL with a host, and no fragment, userinfo, whitespace, backslash, malformed port, or out-of-grammar octet in the host, path or query), with no host policy and no `devMode` coupling, so `http://authserver:8080/...` boots.
- `AuthplaneMcpAuth.Options.ResourceMetadataUrl` points the `resource_metadata` challenge at an AS-hosted PRM document; validated at construction with the resource identifier's rules, and `http://authserver:8080/...` is accepted.
- README **Compatibility** section: tested against authserver 0.2.0; introspection-based revocation requires authserver 0.1.2 or later.
- Resource-server-side DPoP nonce enforcement (RFC 9449 §9). The SDK handled nonces only outbound until now, so a resource server built on it could not adopt the mitigation at all. `InboundDPoPOptions` gains a `nonceIssuer` parameter as the opt-in switch; `null`, the default, leaves every existing deployment byte-identical, and non-null makes the nonce mandatory on every inbound proof — every client's first DPoP request then takes a 401 `use_dpop_nonce` round trip.
- Inbound DPoP nonces — new public API: `IDPoPNonceIssuer` and its built-in `HmacDPoPNonceIssuer`, `DPoPNonceRequiredException`, `AuthplaneErrors.ResponseHeaders` and `VerifiedClaims.NextDPoPNonce`. The HMAC key is a required constructor argument — the key is the deployment topology, and a per-process default behind a load balancer degenerates into a 401 loop; `HmacDPoPNonceIssuer.CreateEphemeral()` is the explicit single-process door.
- Inbound DPoP nonces — a missing, unknown or expired nonce answers 401 with a `DPoP`-scheme challenge carrying `error="use_dpop_nonce"` and a fresh nonce in a `DPoP-Nonce` response header. A framework-agnostic adapter must copy `AuthplaneErrors.ResponseHeaders` onto the response, or that challenge cannot be satisfied.
- Inbound DPoP nonces — a nonce accepted in the second half of its lifetime surfaces as `VerifiedClaims.NextDPoPNonce`, which the middleware advertises in the `DPoP-Nonce` header of the 200 and of the insufficient-scope 403. An adapter that copies only `ResponseHeaders` never sends the rotated nonce, so its clients take a 401 each time one expires — the round trip that rotating at half-life exists to avoid.
- Inbound DPoP nonces — nonce checks run only after every other proof check has passed, so an invalid proof still gets its own error, and the per-request `DPoPRequestContext.RequiredNonce` exact-echo check keeps precedence over the resource-level policy. Issuer output violating the RFC 9449 §8.1 `NQCHAR` syntax surfaces as `VerifierRuntimeException` (HTTP 500), not `invalid_token`.
- `AuthplaneErrors.ErrorResponseBody(...)`, `ErrorDescriptionFor(code)` and `ErrorCodeFor(error)`: the RFC 6750 §3 JSON error body and the fixed description, built from the code the challenge names. A null or empty code is the no-credentials case: both accept it without guarding, and the body then omits `error` entirely, as the challenge does (RFC 6750 §3.1 ties `invalid_request` to a 400, not to a 401 asking the caller to authenticate).
- `Authplane.Mcp` — the middleware logs the exception behind every failure under the `Authplane.Mcp` category before writing the response: `Error` for a 5xx, which is this server's own fault, and `Debug` for a rejection, since reaching one takes no credentials and a higher level would let an unauthenticated caller choose the host's log volume. Logging is optional — a host with no `ILoggerFactory` registered gets no lines and no error.
- Resource-server DPoP nonce enforcement (RFC 9449 §9), opt-in via `InboundDPoPOptions.nonceIssuer`; `null`, the default, leaves existing deployments unchanged. When enabled, every client's first DPoP request takes a 401 `use_dpop_nonce` round trip.
- Inbound DPoP nonce API: `IDPoPNonceIssuer`, `HmacDPoPNonceIssuer` (key required; `CreateEphemeral()` for single-process), `DPoPNonceRequiredException`, `AuthplaneErrors.ResponseHeaders` and `VerifiedClaims.NextDPoPNonce`. Custom adapters must copy both onto the response.
- `AuthplaneErrors.ErrorResponseBody(...)`, `ErrorDescriptionFor(code)` and `ErrorCodeFor(error)` build the RFC 6750 §3 JSON error body and fixed description from the challenge's error code.
- `Authplane.Mcp` — the middleware logs the exception behind every failure: `Error` for a 5xx, `Debug` for a rejection. Hosts without an `ILoggerFactory` get no output.

### Changed

- `TokenRevokedException` from an `active=false` introspection now names the other cause: the AS not recognising this resource server as the token's owner (authserver ≥ 0.1.2 runtime-client rule).
- User guides document `access_denied` vs `consent_required` on token exchange, the `allowed_client_ids` operator step, and the confidential + runtime-client requirement for introspection.
- `manual-e2e-setup.sh` no longer sets `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED` (on by default since authserver 0.2.0) and accepts `AUTHSERVER_REF` to check out an authserver ref before building.
- `manual-e2e-smoke.sh` no longer calls `POST /admin/scopes` (the route does not exist in authserver 0.2.0; the demo provisioner creates the scopes).
- `OAuthProtectedResourceMetadata.GetDocumentUrl` now derives the whole document URL — authority and path, not only the query — by slicing the original identifier string, so the result is the configured identifier with the well-known string inserted between the authority and the path. **Migration**: none for an identifier already written in the form clients are configured with; one carrying an uppercase scheme or host, a default port, or dot-segments now advertises a different, non-normalized `resource_metadata` URL, so update any hard-coded expectation of the old value.
- Derived PRM URL — reading the path off `Uri.AbsolutePath` re-rendered what the identifier did carry — a percent-escaped unreserved character unescaped, a dot-segment removed, an uppercase scheme or host lowercased, a default port dropped — while the PRM `resource` member emitted the configured bytes verbatim, which is the mismatch RFC 9728 §3.3 has a conformant client discard the document over.
- Derived PRM URL — the MCP middleware's PRM routing follows that derivation: it now compares the request's encoded target against the path sliced off the derived URL, keeping the decoded-path comparison as a fallback for hosts that do not expose a raw request target.
- **Breaking** A resource identifier must now be an absolute URL with a scheme and a host, enforced at construction with an `ArgumentException` — RFC 8707 §2 for the scheme, RFC 9728 §3 for the host. **Migration**: configure the full URL clients address.
- **Breaking** The identifier is also rejected at construction when it carries userinfo (RFC 9110 §4.2.4), whitespace, a backslash, a C0 control or DEL. Userinfo would publish a credential to unauthenticated callers; the other characters are silently rewritten by `Uri`, so the served document's `resource` member no longer matches the advertised URL and a conformant client discards it (RFC 9728 §3.3). **Migration**: remove credentials and surrounding whitespace from the configured identifier, and percent-encode an intentional interior space (`%20`) or backslash (`%5C`).
- Identifier gates — the same gates run in the `ProtectedResourceMetadata` constructor and `Build` — the type that emits the identifier as the PRM `resource` field — so a document cannot name an identifier this SDK refuses to derive a URL from. The query gate stays excluded there, since a query is carried into the derived URL and raises no mismatch.
- **Breaking** A port that is not RFC 3986 §3.2.3's `*DIGIT` in range — `:80O` with a letter O, `:99999` — is now rejected at construction on its own axis with its own message, ahead of the absoluteness gate that would otherwise report the wrong defect. A leading zero is rejected too: `:0080` is legal syntax, but stripping it is not an RFC 3986 §6.2 equivalence and a normalizing URL stack renders it `:80`, so a client re-derives a document URL that disagrees with the served document's verbatim `resource` member. **Migration**: write the port as in-range digits with no leading zero.
- `OAuthProtectedResourceMetadata.GetDocumentUrl` now preserves the resource identifier's query in the derived document URL — RFC 9728 §3 inserts the well-known string ahead of the path and query. **Migration**: update any hard-coded expectation of the old query-less URL. A bare `?` derives a URL with no query, an identifier without a query is unaffected, and serving a different document per query value is not supported.
- **Breaking** The identifier's query is now validated at construction against the RFC 3986 §3.4 production, because it flows verbatim into the derived document URL, where an out-of-grammar octet yields an advertised `resource_metadata` no client can fetch. **Migration**: percent-encode the offending octets. Rejected: a literal `"`, a space, and a malformed `%zz`. Unreserved characters, sub-delims, `:`, `@`, `/`, `?` and well-formed `%XX` are accepted unchanged.
- **Breaking** The identifier's path is validated at construction against the RFC 3986 §3.3 production, for the same reason as the query: the byte-exact derivation carries it verbatim into the advertised URL. **Migration**: percent-encode the offending octets. Rejected: a non-ASCII segment such as `/café` (percent-encode it as UTF-8), a zero-width space (U+200B), the delimiter set `"<>[]^{|}` and the backtick, a malformed `%zz` and a truncated `%2`. For a rejected identifier the previously derived URL was already the percent-encoded form, so re-encoding it advertises the same URL as before — but the identifier string now spells it explicitly, and the PRM `resource` member the document serves changes with it.
- **Breaking** A resource identifier carrying a URI fragment is now rejected at construction with an `ArgumentException` instead of being silently accepted (RFC 8707 §2; RFC 9728 §1.2). It was previously stored verbatim and echoed into the PRM document. **Migration**: drop the fragment. A percent-encoded `%23` is still accepted as path data.

- CI and release runs now check out the shared conformance catalog at the SHA pinned in `.conformance-catalog-ref` instead of the catalog's default branch, so a catalog change can no longer break a build on its own. The alignment guard is asserted in both directions, and a weekly drift workflow reports divergence from the catalog tip.

- Conformance catalog pin bumped to `583a6d9`, with markers for its three new resource-identifier cases.
- `OAuthProtectedResourceMetadata.GetDocumentUrl` derives the document URL from the configured identifier string verbatim, query included. **Migration**: an identifier with an uppercase scheme or host, a default port or dot-segments now advertises a different URL; update hard-coded expectations.
- **Breaking** A resource identifier must be an absolute URL with a scheme and host, with no fragment, userinfo, whitespace, backslash, C0 control or DEL, a port of in-range digits with no leading zero, and an RFC 3986 path and query; anything else throws `ArgumentException` at construction. **Migration**: configure the full URL and percent-encode offending octets.
- The same identifier gates run in `ProtectedResourceMetadata`'s constructor and `Build`.
- **BREAKING** `AuthplaneErrors.WwwAuthenticate(...)` now emits a fixed `error_description` chosen by the `error=` code instead of the exception message. **Migration**: log `error.Message` server-side, or pass `verboseDescription: true`.
- **BREAKING** `Authplane.Mcp` — the middleware now answers every failure with an RFC 6750 §3 JSON body (`application/json; charset=utf-8`) instead of prose such as `Missing Authorization header.` or `invalid_token: dpop_proof_missing`. **Migration**: parse `error` and `error_description` from the object; the status is unchanged, but the challenge is not — see the next entry.
- **BREAKING** `Authplane.Mcp` — the challenge and the body no longer carry the exception message, and the middleware's seven hardcoded descriptions give way to a fixed description per `error` code. `use_dpop_nonce` has no fixed description and takes the contentless fallback.
- **BREAKING** `Authplane.Mcp` — a 503 (`JwksFetchException`, `MetadataFetchException`) now answers `temporarily_unavailable` (RFC 6749 §5.2) instead of `server_error`, which read as a defect in this resource server rather than the authorization server being unreachable and retryable; a 500 still answers `server_error`, `CircuitOpenException` included. **Migration**: match `temporarily_unavailable` wherever a client tells a transient outage from a fault.
- **BREAKING** `Authplane.Mcp` — every failure answers with an RFC 6750 §3 JSON body and a fixed description per `error` code, instead of prose and the exception message. **Migration**: parse `error` and `error_description` from the body.
- **BREAKING** `Authplane.Mcp` — a 503 (JWKS or metadata unreachable) answers `temporarily_unavailable` instead of `server_error`. **Migration**: match `temporarily_unavailable` to detect a transient outage.

### Deprecated

Expand All @@ -54,13 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- The MCP middleware's decoded-path fallback held `%5C` back from decoding while Kestrel decodes it, so a `%5C`-bearing resource identifier answered 401 at its own advertised metadata URL on hosts without a raw request target.
- `AuthplaneResource.CreateAsync` no longer abandons the `AuthplaneClient` it builds when the resource constructor rejects its arguments; `DisposeAsync` is now idempotent.
- The conformance drift marker is no longer duplicated between `ConformanceCatalogAlignment.DriftMarker` and the drift workflow with nothing tying them together; a test now fails if either copy changes without the other.
- The authority now has an RFC 3986 §3.2.2 character-production gate, so an internationalized host is rejected at construction instead of reaching a `WWW-Authenticate` challenge as a non-URI.
- The host production gate no longer throws `IndexOutOfRangeException` on an authority that is userinfo and nothing else (`https://user@`); the missing host is reported by the absoluteness gate as an `ArgumentException`, as it was before the gate was added.
- An authority that is only userinfo (`https://user@`) is reported as a missing host with `ArgumentException`, instead of throwing `IndexOutOfRangeException`.
- `AuthplaneClient.CreateAsync` no longer abandons the client it built when the priming metadata fetch fails or the caller's token is cancelled.
- An opaque resource identifier such as `mailto:ops@example.com` is no longer reported as carrying userinfo; it is still refused, now because an opaque URI has no host to derive a metadata document URL from.
- The MCP middleware's generic error arm hardcoded 401 for every `AuthplaneException`, so a JWKS or metadata outage surfaced as 401 `invalid_token` — prompting a pointless re-authentication against a healthy AS — instead of 503. The arm now takes its status from `AuthplaneErrors.HttpStatus` and emits `WWW-Authenticate` only on a 401, so a 5xx no longer carries a challenge, and a verifier runtime fault maps to 500. The 403 `insufficient_scope` challenge is unchanged.
- The conformance-catalog parser in `Authplane.Conformance.Shared` silently dropped cases it could not parse: one with an `id` but no `title` in any non-final position, and one whose title contains an apostrophe in any position. A dropped case never reaches `ConformanceCatalogAlignment`, so coverage it should have demanded went unasserted. The parser now keeps a title-less case with its id as the title, parses quoted titles properly, and no longer lets a full-line comment end the `cases:` block. Anything it still cannot parse — a case, a scalar, a block scalar indicator, an unknown escape — now throws, so a shape it does not understand breaks the build instead of vanishing.
- The MCP middleware answers a JWKS or metadata outage with 503 instead of 401 `invalid_token`, and sends `WWW-Authenticate` only on a 401.

## [0.1.0] - 2026-08-07

Expand Down
Loading