Skip to content

Commit dc419e7

Browse files
fix(sdk,mcp,fastmcp): preserve issuer identity, serve PRM verbatim, raise 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.
1 parent 0bba7df commit dc419e7

37 files changed

Lines changed: 1600 additions & 89 deletions

.conformance-catalog-ref

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf

.github/workflows/ci.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,20 @@ jobs:
3434
- name: Clone shared conformance catalog (out of tree)
3535
if: matrix.package == 'root'
3636
run: |
37-
git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance"
37+
# Conformance catalog pinned by SHA, single-sourced from the tracked
38+
# .conformance-catalog-ref at the repo root (read from the checked-out
39+
# workspace, so the Checkout step above must precede this one). Bump
40+
# that file when adopting new catalog cases, together with the SDK-side
41+
# conformance coverage, so a catalog change can never break CI on its
42+
# own. Source: github.com/AuthPlane/conformance.
43+
CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
44+
grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \
45+
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
46+
git init -q "$RUNNER_TEMP/conformance"
47+
git -C "$RUNNER_TEMP/conformance" \
48+
fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \
49+
|| { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; }
50+
git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD
3851
3952
- name: Setup Python
4053
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: Conformance catalog drift
2+
3+
# Weekly (plus on-demand) check that the SDK's @pytest.mark.conformance markers
4+
# still cover the LATEST conformance catalog default branch, independent of the
5+
# pinned SHA that gates PR CI (.conformance-catalog-ref). A newly added,
6+
# uncovered catalog case FAILS this scheduled job so the drift is visible on the
7+
# Actions dashboard; it never breaks PR CI, which has no pull_request trigger and
8+
# runs against the pinned .conformance-catalog-ref.
9+
10+
on:
11+
schedule:
12+
# Mondays 06:00 UTC
13+
- cron: "0 6 * * 1"
14+
workflow_dispatch:
15+
16+
# Least-privilege default; this workflow only reads the repo.
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
drift:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26+
27+
- name: Set up Python 3.11
28+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
29+
with:
30+
python-version: "3.11"
31+
32+
- name: Install package dependencies
33+
run: |
34+
python -m pip install --upgrade pip
35+
pip install -e ".[dev]"
36+
37+
# Intentionally UNPINNED: track the catalog's default branch so newly
38+
# added cases surface here. PR CI stays on the pinned .conformance-catalog-ref.
39+
- name: Clone latest conformance catalog default branch (out of tree)
40+
run: |
41+
git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance"
42+
43+
- name: Check catalog alignment against the latest catalog
44+
id: align
45+
env:
46+
AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
47+
run: |
48+
pytest conformance-tests/test_catalog_alignment.py -v
49+
50+
- name: Report drift
51+
if: always()
52+
run: |
53+
if [ "${{ steps.align.outcome }}" = "success" ]; then
54+
echo "Conformance markers cover the latest catalog default branch." >> "$GITHUB_STEP_SUMMARY"
55+
else
56+
echo "::warning::Conformance catalog drift detected: the SDK's @pytest.mark.conformance markers do not cover every case in the latest catalog default branch. Extend coverage in conformance-tests/, then bump .conformance-catalog-ref to adopt the new cases."
57+
{
58+
echo "## Conformance catalog drift detected"
59+
echo ""
60+
echo "The SDK's \`@pytest.mark.conformance\` markers do not cover every case in the **latest** conformance catalog default branch."
61+
echo "PR CI is unaffected — it runs against the pinned \`.conformance-catalog-ref\`."
62+
echo "Extend coverage in \`conformance-tests/\`, then bump \`.conformance-catalog-ref\` to adopt the new cases."
63+
} >> "$GITHUB_STEP_SUMMARY"
64+
fi

.github/workflows/release.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,20 @@ jobs:
9696
# need its auth/persist-credentials features for a public read-only repo.
9797
- name: Clone shared conformance catalog (out of tree)
9898
run: |
99-
git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance"
99+
# Conformance catalog pinned by SHA, single-sourced from the tracked
100+
# .conformance-catalog-ref at the repo root (read from the checked-out
101+
# workspace, so the Checkout step above must precede this one). Bump
102+
# that file when adopting new catalog cases, together with the SDK-side
103+
# conformance coverage, so a catalog change can never break CI on its
104+
# own. Source: github.com/AuthPlane/conformance.
105+
CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
106+
grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \
107+
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
108+
git init -q "$RUNNER_TEMP/conformance"
109+
git -C "$RUNNER_TEMP/conformance" \
110+
fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \
111+
|| { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; }
112+
git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD
100113
101114
- name: Set up Python 3.11
102115
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
> **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.4.0`), not a major bump. `RELEASE_POLICY.md`'s "major bump for breaking changes" rule takes effect once the project reaches `1.0.0`.
11+
1012
### Added
1113
- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`.
1214
- `AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`.
1315

16+
### Security
17+
- `authplane-mcp`: the `mcp` dependency floor is now `>=1.28.1` (was `>=1.23.0`), pulling in the fix for [PYSEC-2026-3483](https://osv.dev/vulnerability/PYSEC-2026-3483), which affects `mcp <=1.28.0`.
18+
- `authplane-fastmcp`: now declares a direct `mcp>=1.28.1,<2` dependency. The adapter imports the top-level `mcp` package directly (e.g. `mcp.shared.exceptions`, `mcp.types`), so the PYSEC-2026-3483 floor must be pinned here explicitly — the transitive `fastmcp>=3.2,<4` dependency does not guarantee it.
19+
20+
### Fixed
21+
- `authplane-fastmcp`, `authplane-mcp`: the Protected Resource Metadata now advertises the configured issuer (`authorization_servers`) and `resource` byte-for-byte. The adapters serve the PRM through upstream MCP's `pydantic.AnyHttpUrl` fields, which normalize an empty-path authority with a trailing slash (`https://auth.example.com``https://auth.example.com/`); after the core SDK began comparing these identifiers verbatim (RFC 8414 §3.3, RFC 9728 §3.3), a client that followed the advertised value literally was rejected by the strict comparison and tokens minted for the advertised `resource` failed the `aud` check. The served document is now rewritten so both identifiers match the configured strings, leaving every other PRM field untouched. `authplane-fastmcp` applies this automatically; `authplane-mcp` applies it inside `install_request_context(mcp)`, so call that after constructing `FastMCP`.
22+
23+
### Changed
24+
- **BREAKING (pre-1.0)** `authplane-mcp`: the supported `mcp` range is now `>=1.28.1, <2.0.0` (was `>=1.23.0, <1.28.0`). The adapter still targets the mcp 1.x server API (`mcp.server.fastmcp.FastMCP`) and the camelCase URL-elicitation field (`ElicitRequestURLParams(elicitationId=...)`), which are the current 1.x shape. As a belt-and-braces measure the adapter no longer hard-codes the field spelling: it resolves the elicitation-id field name from the model's own schema, so a rename within 1.x is picked up automatically. The upper bound excludes mcp 2.0, which removes `mcp.server.fastmcp` and renames the elicitation field to snake_case `elicitation_id`. **Migration:** projects on `mcp <1.28.1` must upgrade to at least `1.28.1`; projects on `mcp 2.0` are not yet supported by this adapter — track the mcp 2.0 port separately.
25+
- Issuer identifiers are now stored and compared byte-for-byte (RFC 9068 `iss`, RFC 8414 §3.3). The configured issuer is no longer trailing-slash-stripped before storage, and the AS-metadata issuer comparison no longer strips either side — a metadata document whose `issuer` differs from the configured issuer only by a trailing slash is now correctly rejected. This fixes an outage for authorization servers whose issuer ends in `/`: such an AS mints tokens whose `iss` keeps the slash, and the SDK was comparing them against the stripped form, rejecting every token. Building `.well-known` discovery URLs still strips the terminating slash (RFC 8414/9728 §3.1) — that is derivation, not identity, and is unchanged. `build_prm_url` now also preserves the resource's query component in the derived Protected Resource Metadata URL (RFC 9728 §3.1), while a fragment-bearing resource — for which `build_prm_url` and the resource `prm_url()` previously returned a (fragment-stripped) URL — is now rejected with a `ValueError` (RFC 8707 §2 forbids a fragment in a resource indicator). A query-bearing **or fragment-bearing** issuer (RFC 8414 §2 forbids both a query and a fragment component in the issuer identifier) is now rejected at `AuthplaneClient.create()` with a clear `ValueError` instead of being silently stripped and later surfacing as a confusing "issuer mismatch". **Migration:** If your configured issuer differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them. If your configured issuer carries a query or fragment component, remove it. If your resource identifier carries a fragment component, remove it — `build_prm_url` / `prm_url()` now raise instead of returning a fragment-stripped URL.
26+
1427
## [0.3.0] - 2026-07-21
1528

1629
### Added

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,13 @@ The conformance suite in `conformance-tests/` validates the SDK against the shar
8282
```bash
8383
# From the directory that contains your python-sdk/ clone
8484
git clone https://github.com/AuthPlane/conformance.git
85+
86+
# Check out the same catalog revision CI pins, so local runs match CI exactly.
87+
git -C conformance checkout "$(cat python-sdk/.conformance-catalog-ref)"
8588
```
8689

90+
CI runs the suite against the catalog revision pinned in `.conformance-catalog-ref` at the repo root, not the catalog's latest default branch — checking out that revision locally keeps your results aligned with CI. (A separate scheduled `conformance-catalog-drift` workflow tracks the latest catalog and fails when new cases need coverage.)
91+
8792
Expected layout:
8893

8994
```

authplane-fastmcp/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ Authplane JWT validation for servers built on [FastMCP](https://github.com/Prefe
1111
pip install authplane-fastmcp
1212
```
1313

14+
## Compatibility
15+
16+
Supported `fastmcp` range: **`>=3.2, <4.0.0`**. This adapter also imports the top-level `mcp` package directly (`mcp.shared.exceptions`, `mcp.types`), so it carries its own `mcp` constraint: **`>=1.28.1, <2.0.0`**. The floor is `1.28.1` because earlier releases (`<=1.28.0`) are affected by [PYSEC-2026-3483](https://osv.dev/vulnerability/PYSEC-2026-3483), fixed in `1.28.1`; `fastmcp>=3.2` alone does not guarantee that floor. The adapter targets the mcp 1.x camelCase URL-elicitation field (`ElicitRequestURLParams(elicitationId=...)`), which is the shape of the current 1.x line. As a belt-and-braces measure the adapter does not hard-code that spelling: it resolves the elicitation-id field name from the model's own schema — a known spelling is checked at import, then resolved per call — so a rename within 1.x would be picked up automatically rather than breaking the consent path. mcp 2.0 is not yet supported: it renames the elicitation field to snake_case `elicitation_id`, which is a separate port. If your project needs mcp 2.0, please open an issue.
17+
1418
## Quickstart
1519

1620
```python
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Serve the Protected Resource Metadata identifiers verbatim.
2+
3+
The core SDK stores and compares the issuer / resource identifier byte-for-byte
4+
(RFC 8414 §3.3, RFC 9728 §3.3). The upstream MCP machinery that serves the PRM
5+
document types ``authorization_servers`` and ``resource`` as
6+
``pydantic.AnyHttpUrl``, which normalizes an empty-path authority by appending a
7+
trailing slash (``https://auth.example.com`` -> ``https://auth.example.com/``).
8+
A client that follows that advertised value literally then does discovery and
9+
audience checks against the slashed form and is rejected by the strict
10+
comparison ("issuer mismatch"), and a token minted for the advertised
11+
``resource`` fails the verbatim ``aud`` check.
12+
13+
This module post-processes the served PRM response so the two identifier fields
14+
carry exactly the operator-configured strings, without touching any other field
15+
(scopes, bearer methods, cache headers, CORS) the upstream route emits.
16+
"""
17+
18+
import json
19+
from collections.abc import Awaitable, Callable, MutableSequence
20+
from typing import Any
21+
22+
from starlette.routing import BaseRoute, Route
23+
24+
_PRM_PATH_PREFIX = "/.well-known/oauth-protected-resource"
25+
26+
_Scope = dict[str, Any]
27+
_Message = dict[str, Any]
28+
_Receive = Callable[[], Awaitable[_Message]]
29+
_Send = Callable[[_Message], Awaitable[None]]
30+
_ASGIApp = Callable[[_Scope, _Receive, _Send], Awaitable[None]]
31+
32+
33+
def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes:
34+
"""Return the PRM JSON body with the configured identifiers set verbatim.
35+
36+
Rewrites only the entries that match the configured identifier up to a
37+
trailing-slash normalization: in ``authorization_servers`` the element equal
38+
to ``issuer`` or ``issuer + "/"`` is swapped for the verbatim ``issuer`` and
39+
every other entry is left in place, so a multi-AS advertisement keeps its
40+
extra entries. ``resource`` is set verbatim.
41+
42+
Any body that is not a JSON object (e.g. a CORS preflight with an empty
43+
body) is returned unchanged.
44+
"""
45+
try:
46+
doc = json.loads(body)
47+
except (ValueError, TypeError):
48+
return body
49+
if not isinstance(doc, dict):
50+
return body
51+
changed = False
52+
servers = doc.get("authorization_servers")
53+
if isinstance(servers, list):
54+
rewritten = [issuer if entry in (issuer, issuer + "/") else entry for entry in servers]
55+
if rewritten != servers:
56+
doc["authorization_servers"] = rewritten
57+
changed = True
58+
if "resource" in doc and doc["resource"] != resource:
59+
doc["resource"] = resource
60+
changed = True
61+
if not changed:
62+
return body
63+
return json.dumps(doc, separators=(",", ":")).encode("utf-8")
64+
65+
66+
def _wrap_app(inner: _ASGIApp, *, issuer: str, resource: str) -> _ASGIApp:
67+
"""Wrap an ASGI app so a JSON PRM body is rewritten before it is sent.
68+
69+
The PRM document is small and always flushed in a single body frame, so
70+
the wrapper buffers the whole body, rewrites it, then emits the (possibly
71+
resized) response in one shot.
72+
"""
73+
74+
async def app(scope: _Scope, receive: _Receive, send: _Send) -> None:
75+
if scope.get("type") != "http":
76+
await inner(scope, receive, send)
77+
return
78+
79+
start: _Message | None = None
80+
chunks: list[bytes] = []
81+
82+
async def capture(message: _Message) -> None:
83+
nonlocal start
84+
message_type = message["type"]
85+
if message_type == "http.response.start":
86+
# Defer the start frame until the body is assembled so the
87+
# Content-Length header can be corrected for the rewrite.
88+
start = message
89+
return
90+
if message_type == "http.response.body":
91+
chunks.append(message.get("body", b""))
92+
if message.get("more_body", False):
93+
return
94+
if start is None:
95+
# An ASGI server must send http.response.start before any
96+
# http.response.body frame; guard explicitly rather than
97+
# asserting, since ``assert`` is stripped under ``python -O``.
98+
raise RuntimeError("http.response.body received before http.response.start")
99+
new_body = _rewrite_body(b"".join(chunks), issuer=issuer, resource=resource)
100+
headers = [
101+
(key, value)
102+
for (key, value) in start.get("headers", [])
103+
if key.lower() != b"content-length"
104+
]
105+
headers.append((b"content-length", str(len(new_body)).encode("latin-1")))
106+
await send({**start, "headers": headers})
107+
await send({"type": "http.response.body", "body": new_body})
108+
return
109+
await send(message)
110+
111+
await inner(scope, receive, capture)
112+
113+
return app
114+
115+
116+
def rewrite_prm_routes_verbatim(
117+
routes: MutableSequence[BaseRoute], *, issuer: str, resource: str
118+
) -> None:
119+
"""Wrap, in place, every Protected Resource Metadata route in ``routes``.
120+
121+
Matches routes registered under ``/.well-known/oauth-protected-resource``
122+
(RFC 9728 §3) and swaps their ASGI app for one that advertises ``issuer``
123+
and ``resource`` verbatim.
124+
"""
125+
for route in routes:
126+
if isinstance(route, Route) and (
127+
route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/")
128+
):
129+
route.app = _wrap_app(route.app, issuer=issuer, resource=resource)

0 commit comments

Comments
 (0)