Skip to content

Identifier identity and validation, DPoP htu ;params, verbatim PRM hooks, pinned conformance catalog - #25

Open
RobertoIskandarani wants to merge 5 commits into
mainfrom
port/v0.4.0
Open

Identifier identity and validation, DPoP htu ;params, verbatim PRM hooks, pinned conformance catalog#25
RobertoIskandarani wants to merge 5 commits into
mainfrom
port/v0.4.0

Conversation

@RobertoIskandarani

Copy link
Copy Markdown
Contributor

Everything queued for the next release, consolidated into a single branch so it reviews and lands as one unit. Two behaviour changes are breaking; both carry a migration note in CHANGELOG.md.

Identifier identity and validation

The configured issuer is preserved byte-for-byte and compared for identity (RFC 8414 §2/§3.3); the terminating slash is dropped only where a well-known URL is derived (RFC 8414/9728 §3.1). An issuer carrying a query or fragment is rejected at AuthplaneClient.create().

The matching gate for the resource indicator previously lived only in build_prm_url, whose production caller is AuthplaneResource.prm_url() — a method reached from inside a 401 response path. A deployment configured with a fragment-bearing identifier therefore started normally and surfaced the problem later as a 500 emitted from the failure path. It is now rejected at client.resource(...), and again inside AuthplaneResource so a resource built without the factory is covered too; build_prm_url keeps the check as a backstop.

InvalidIssuerError and InvalidResourceError subclass both AuthplaneError and ValueError, so except ValueError handlers are unaffected while callers can now distinguish an identifier misconfiguration from any other ValueError the SDK raises.

build_prm_url and build_metadata_url no longer strip slashes from the front of the path: str.strip("/") removed them from both ends, so //mcp lost a segment and derived the same well-known URL as /mcp — two distinct identifiers collapsing onto one document.

Migration: remove any fragment from a resource indicator (RFC 8707 §2 forbids one), and correct any issuer that differs from the AS's published identifier by a trailing slash.

DPoP htu and the ;params segment

urlparse peels an RFC 3986 ;params segment off the last path segment, so a request to /orders;v=2 bound an htu of /orders — a proof computed over a different resource than the one being accessed. RFC 3986 §3.3 puts ;params in the path; the construction now uses urlsplit, which leaves it there.

Verbatim PRM

VerbatimPRMRemoteAuthProvider becomes public and rewrite_prm_routes_verbatim is exported: building a RemoteAuthProvider by hand is a documented FastMCP pattern and doing so silently lost the verbatim PRM. install_request_context(mcp) now warns when the verifier carries no verbatim identifiers instead of skipping the rewrite in silence — a verifier built through the public constructor has none, so the previous check never fired for it and the served document kept advertising slash-normalized identifiers that this SDK's own comparison rejects. It also stops reading mcp.sse_app unguarded, so a future mcp 1.x that drops the attribute cannot take down servers that never touch SSE.

Security and tooling

The mcp dependency floor moves to >=1.28.1, clearing PYSEC-2026-3483; authplane-fastmcp declares it directly, since it imports the top-level mcp package and the transitive fastmcp dependency does not guarantee the floor. verify_dpop_proof no longer echoes the expected or received nonce into its error — www_authenticate copies the message verbatim into error_description, which would hand an unauthenticated caller a currently-valid resource-server nonce.

The conformance catalog is fetched at a pinned SHA with a drift check, and uv.lock files are ignored: nothing tracks or consumes them, and three were once swept into a PR as untracked files accounting for 94% of its diff.

… branch

CI cloned github.com/AuthPlane/conformance at its default branch, so any change
to the catalog reached this repo immediately — a case added there could turn an
unrelated PR red here with nothing in this repo having changed. Pinning
decouples them: a catalog change arrives only when this repo deliberately bumps
the pin together with the coverage for it.

The revision is single-sourced in a tracked .conformance-catalog-ref, guarded by
a 40-hex shape check before the fetch so a branch or tag name cannot silently
un-pin CI, and read by both ci.yml and release.yml.

A weekly conformance-catalog-drift workflow clones the unpinned tip and runs the
alignment assertion, so new cases surface as an early warning instead of a
surprise at bump time.

No SDK source changes. Verified locally against the pinned revision: the catalog
alignment assertion passes, the conformance suite is green (104 passed, 1 xfailed
— the xfail is pre-existing and unrelated), and the core suite is green (525).
The pin adopts no new case ids, so it requires no coverage change.
…aise mcp floor past PYSEC-2026-3483

- authplane-sdk: issuer identifiers are stored and compared byte-for-byte
  (RFC 9068 iss, RFC 8414 §3.3) — no trailing-slash stripping on storage
  or comparison; query- or fragment-bearing issuers are rejected at
  AuthplaneClient.create() (RFC 8414 §2). Well-known derivation still
  strips the terminating slash (RFC 8414/9728 §3.1); build_prm_url keeps
  the resource query and rejects fragment-bearing resources (RFC 8707 §2).
- authplane-mcp, authplane-fastmcp: the served Protected Resource
  Metadata advertises the configured issuer and resource byte-for-byte
  instead of the AnyHttpUrl-normalised form (RFC 8414/9728 §3.3).
- authplane-mcp: mcp floor raised to >=1.28.1,<2 (PYSEC-2026-3483);
  the elicitation-id field name is resolved from the model schema instead
  of being hard-coded. authplane-fastmcp declares the same direct floor.
- ci: single-source the conformance catalog pin in
  .conformance-catalog-ref (40-hex guarded) and add a scheduled drift
  check; pin ruff to >=0.16,<0.17 so formatting stays stable.
…h matchable errors

The fragment check for a resource indicator lived only in `build_prm_url`, whose
production caller is `AuthplaneResource.prm_url()` — a method reached from
inside a 401 response path. A deployment configured with a fragment-bearing
identifier therefore started normally and failed later, from the failure path,
as a 500. It is now rejected at `AuthplaneClient.resource(...)`, symmetrically
with the issuer check in `create()`, and again in `AuthplaneResource` itself so
the gate holds for a resource built without the factory. The check in
`build_prm_url` stays as a backstop.

`InvalidIssuerError` and `InvalidResourceError` subclass both `AuthplaneError`
and `ValueError`, so existing `except ValueError` handlers keep working while
callers gain the ability to tell an identifier misconfiguration apart from any
other `ValueError` the SDK raises — which matters now that the resource
rejection is a behaviour change deployments have to react to.

`build_prm_url` and `build_metadata_url` no longer strip slashes from the front
of the path: `str.strip("/")` took them from both ends, so `//mcp` lost a
segment and derived the same well-known URL as `/mcp` — two distinct identifiers
collapsing onto one document. RFC 9728 §3.1 and RFC 8414 §3.1 speak only of the
terminating slash.

BREAKING CHANGE: a fragment-bearing resource identifier now raises
`InvalidResourceError` at `client.resource(...)` instead of starting and failing
later. Remove the fragment — RFC 8707 §2 forbids one in a resource indicator.
…ake the PRM hook public

`urlparse` peels an RFC 3986 `;params` segment off the last path segment, so a
request to `/orders;v=2` bound an `htu` of `/orders` — a proof computed over a
different resource than the one being accessed, and RFC 3986 §3.3 puts `;params`
squarely in the path. The htu construction uses `urlsplit`, which does not split
it off, and the round-trip no longer has to fill `urlunparse`'s params slot.

`VerbatimPRMRemoteAuthProvider` is now public (was `_VerbatimPRMRemoteAuthProvider`)
and `rewrite_prm_routes_verbatim` is exported: building a `RemoteAuthProvider` by
hand is a documented FastMCP pattern, and doing so silently lost the verbatim PRM.
`install_request_context(mcp)` now detects a verifier carrying no verbatim
identifiers and warns instead of skipping the rewrite in silence — a verifier
built through the public `AuthplaneTokenVerifier(verifier)` constructor has none,
so the previous `is None` check never fired for it and the served document kept
advertising slash-normalized identifiers that this SDK's own byte-for-byte
comparison rejects. `AuthplaneTokenVerifier.verbatim_identifiers()` exposes the
pair without cross-module private attribute reads.

`install_request_context` also stops touching `mcp.sse_app` unguarded: SSE is not
on the streamable-HTTP path, so a future `mcp` 1.x that drops the attribute would
have taken down servers that never touch SSE.
…kfiles, record the changelog

The conformance suite stops xfailing the inbound DPoP-Nonce case now that the
catalog carries it, and the catalog-fetch tooling is aligned with the pinned ref
so a local run matches CI rather than the moving default branch.

`backport-fixes.sh` accepts a tag as `--from`: `release.yml` deletes
`release/vX.Y.Z` once the tag is pushed, so afterwards the tag is the only ref
naming those commits — which is exactly what the release summary tells the
operator to pass. `--from` and `--to` are validated as ref names before reaching
a fetch refspec, since `git ls-remote` matches its arguments as globs.

`uv.lock` is ignored: nothing tracks or consumes these files — no workflow
installs with uv — so they are local resolution artifacts. Three of them were
swept into a merge commit as untracked files once and accounted for 94% of that
diff. Tracking them for reproducible installs is a real decision and belongs in
its own PR, alongside the CI change that would make them load-bearing.
@RobertoIskandarani
RobertoIskandarani requested a review from a team as a code owner August 18, 2026 22:15
with pytest.raises(ValueError) as exc:
validate_resource_indicator("https://svc:s3cr3t@api.example.com/mcp#frag")
assert "s3cr3t" not in str(exc.value)
assert "api.example.com" in str(exc.value)
# call site the failure really is "the installed distribution is unusable", so
# translate it to the shape a reader expects from a failing import.
try:
_ELICITATION_ID_KWARG = _resolve_elicitation_id_kwarg(ElicitRequestURLParams)
# call site the failure really is "the installed distribution is unusable", so
# translate it to the shape a reader expects from a failing import.
try:
_ELICITATION_ID_KWARG = _resolve_elicitation_id_kwarg(ElicitRequestURLParams)
# RemoteAuthProvider does not silently lose the verbatim PRM. Nothing else
# imports them from the root — conftest reaches into .auth — so without this
# the __all__ entries could rot without a test noticing.
import authplane_fastmcp
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams
from pydantic import BaseModel

import authplane_mcp.url_elicitation as url_elicitation
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams
from pydantic import BaseModel

import authplane_fastmcp.url_elicitation as url_elicitation

def __call__(
self, base_url: str, resource: str, *, scopes: list[str] | None = None
) -> AuthplaneTokenVerifier: ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants