Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions .conformance-catalog-ref
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,20 @@ jobs:
- name: Clone shared conformance catalog (out of tree)
if: matrix.package == 'root'
run: |
git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance"
# Conformance catalog pinned by SHA, single-sourced from the tracked
# .conformance-catalog-ref at the repo root (read from the checked-out
# workspace, so the Checkout step above must precede this one). Bump
# that file when adopting new catalog cases, together with the SDK-side
# conformance coverage, so a catalog change can never break CI on its
# own. Source: github.com/AuthPlane/conformance.
CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
git init -q "$RUNNER_TEMP/conformance"
git -C "$RUNNER_TEMP/conformance" \
fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; }
git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD

- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
Expand Down
64 changes: 64 additions & 0 deletions .github/workflows/conformance-catalog-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Conformance catalog drift

# Weekly (plus on-demand) check that the SDK's @pytest.mark.conformance markers
# still cover the LATEST conformance catalog default branch, independent of the
# pinned SHA that gates PR CI (.conformance-catalog-ref). A newly added,
# uncovered catalog case FAILS this scheduled job so the drift is visible on the
# Actions dashboard; it never breaks PR CI, which has no pull_request trigger and
# runs against the pinned .conformance-catalog-ref.

on:
schedule:
# Mondays 06:00 UTC
- cron: "0 6 * * 1"
workflow_dispatch:

# Least-privilege default; this workflow only reads the repo.
permissions:
contents: read

jobs:
drift:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Python 3.11
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"

- name: Install package dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

# Intentionally UNPINNED: track the catalog's default branch so newly
# added cases surface here. PR CI stays on the pinned .conformance-catalog-ref.
- name: Clone latest conformance catalog default branch (out of tree)
run: |
git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance"

- name: Check catalog alignment against the latest catalog
id: align
env:
AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
run: |
pytest conformance-tests/test_catalog_alignment.py -v

- name: Report drift
if: always()
run: |
if [ "${{ steps.align.outcome }}" = "success" ]; then
echo "Conformance markers cover the latest catalog default branch." >> "$GITHUB_STEP_SUMMARY"
else
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."
{
echo "## Conformance catalog drift detected"
echo ""
echo "The SDK's \`@pytest.mark.conformance\` markers do not cover every case in the **latest** conformance catalog default branch."
echo "PR CI is unaffected — it runs against the pinned \`.conformance-catalog-ref\`."
echo "Extend coverage in \`conformance-tests/\`, then bump \`.conformance-catalog-ref\` to adopt the new cases."
} >> "$GITHUB_STEP_SUMMARY"
fi
15 changes: 14 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,20 @@ jobs:
# need its auth/persist-credentials features for a public read-only repo.
- name: Clone shared conformance catalog (out of tree)
run: |
git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance"
# Conformance catalog pinned by SHA, single-sourced from the tracked
# .conformance-catalog-ref at the repo root (read from the checked-out
# workspace, so the Checkout step above must precede this one). Bump
# that file when adopting new catalog cases, together with the SDK-side
# conformance coverage, so a catalog change can never break CI on its
# own. Source: github.com/AuthPlane/conformance.
CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
git init -q "$RUNNER_TEMP/conformance"
git -C "$RUNNER_TEMP/conformance" \
fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; }
git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD

- name: Set up Python 3.11
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

> **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`.

### Added
- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`.
- `AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`.

### Security
- `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`.
- `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.

### Fixed
- `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`.

### Changed
- **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.
- 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.

## [0.3.0] - 2026-07-21

### Added
Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ The conformance suite in `conformance-tests/` validates the SDK against the shar
```bash
# From the directory that contains your python-sdk/ clone
git clone https://github.com/AuthPlane/conformance.git

# Check out the same catalog revision CI pins, so local runs match CI exactly.
git -C conformance checkout "$(cat python-sdk/.conformance-catalog-ref)"
```

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.)

Expected layout:

```
Expand Down
4 changes: 4 additions & 0 deletions authplane-fastmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Authplane JWT validation for servers built on [FastMCP](https://github.com/Prefe
pip install authplane-fastmcp
```

## Compatibility

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.

## Quickstart

```python
Expand Down
129 changes: 129 additions & 0 deletions authplane-fastmcp/authplane_fastmcp/_prm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Serve the Protected Resource Metadata identifiers verbatim.

The core SDK stores and compares the issuer / resource identifier byte-for-byte
(RFC 8414 §3.3, RFC 9728 §3.3). The upstream MCP machinery that serves the PRM
document types ``authorization_servers`` and ``resource`` as
``pydantic.AnyHttpUrl``, which normalizes an empty-path authority by appending a
trailing slash (``https://auth.example.com`` -> ``https://auth.example.com/``).
A client that follows that advertised value literally then does discovery and
audience checks against the slashed form and is rejected by the strict
comparison ("issuer mismatch"), and a token minted for the advertised
``resource`` fails the verbatim ``aud`` check.

This module post-processes the served PRM response so the two identifier fields
carry exactly the operator-configured strings, without touching any other field
(scopes, bearer methods, cache headers, CORS) the upstream route emits.
"""

import json
from collections.abc import Awaitable, Callable, MutableSequence
from typing import Any

from starlette.routing import BaseRoute, Route

_PRM_PATH_PREFIX = "/.well-known/oauth-protected-resource"

_Scope = dict[str, Any]
_Message = dict[str, Any]
_Receive = Callable[[], Awaitable[_Message]]
_Send = Callable[[_Message], Awaitable[None]]
_ASGIApp = Callable[[_Scope, _Receive, _Send], Awaitable[None]]


def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes:
"""Return the PRM JSON body with the configured identifiers set verbatim.

Rewrites only the entries that match the configured identifier up to a
trailing-slash normalization: in ``authorization_servers`` the element equal
to ``issuer`` or ``issuer + "/"`` is swapped for the verbatim ``issuer`` and
every other entry is left in place, so a multi-AS advertisement keeps its
extra entries. ``resource`` is set verbatim.

Any body that is not a JSON object (e.g. a CORS preflight with an empty
body) is returned unchanged.
"""
try:
doc = json.loads(body)
except (ValueError, TypeError):
return body
if not isinstance(doc, dict):
return body
changed = False
servers = doc.get("authorization_servers")
if isinstance(servers, list):
rewritten = [issuer if entry in (issuer, issuer + "/") else entry for entry in servers]
if rewritten != servers:
doc["authorization_servers"] = rewritten
changed = True
if "resource" in doc and doc["resource"] != resource:
doc["resource"] = resource
changed = True
if not changed:
return body
return json.dumps(doc, separators=(",", ":")).encode("utf-8")


def _wrap_app(inner: _ASGIApp, *, issuer: str, resource: str) -> _ASGIApp:
"""Wrap an ASGI app so a JSON PRM body is rewritten before it is sent.

The PRM document is small and always flushed in a single body frame, so
the wrapper buffers the whole body, rewrites it, then emits the (possibly
resized) response in one shot.
"""

async def app(scope: _Scope, receive: _Receive, send: _Send) -> None:
if scope.get("type") != "http":
await inner(scope, receive, send)
return

start: _Message | None = None
chunks: list[bytes] = []

async def capture(message: _Message) -> None:
nonlocal start
message_type = message["type"]
if message_type == "http.response.start":
# Defer the start frame until the body is assembled so the
# Content-Length header can be corrected for the rewrite.
start = message
return
if message_type == "http.response.body":
chunks.append(message.get("body", b""))
if message.get("more_body", False):
return
if start is None:
# An ASGI server must send http.response.start before any
# http.response.body frame; guard explicitly rather than
# asserting, since ``assert`` is stripped under ``python -O``.
raise RuntimeError("http.response.body received before http.response.start")
new_body = _rewrite_body(b"".join(chunks), issuer=issuer, resource=resource)
headers = [
(key, value)
for (key, value) in start.get("headers", [])
if key.lower() != b"content-length"
]
headers.append((b"content-length", str(len(new_body)).encode("latin-1")))
await send({**start, "headers": headers})
await send({"type": "http.response.body", "body": new_body})
return
await send(message)

await inner(scope, receive, capture)

return app


def rewrite_prm_routes_verbatim(
routes: MutableSequence[BaseRoute], *, issuer: str, resource: str
) -> None:
"""Wrap, in place, every Protected Resource Metadata route in ``routes``.

Matches routes registered under ``/.well-known/oauth-protected-resource``
(RFC 9728 §3) and swaps their ASGI app for one that advertises ``issuer``
and ``resource`` verbatim.
"""
for route in routes:
if isinstance(route, Route) and (
route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/")
):
route.app = _wrap_app(route.app, issuer=issuer, resource=resource)
Loading
Loading