From 55185b0a76fe4307191a36ba56e85d93aaa86a46 Mon Sep 17 00:00:00 2001 From: muralx Date: Tue, 19 May 2026 16:14:09 +0100 Subject: [PATCH] fix: WWW-Authenticate hardening, DPoP htu port, docs and conformance WWW-Authenticate / http_status - Security: www_authenticate() sanitizes CR, LF, ", and \ from every interpolated value, closing a header-injection path through attacker-influenced error messages. - DPoPNotSupportedError now emits WWW-Authenticate: Bearer (was DPoP). - http_status(CircuitOpenError) returns 503 (was 500). - www_authenticate() gains keyword-only resource_metadata_url= (RFC 9728) and scope= (RFC 6750), with scope= auto-populated from InsufficientScopeError.required_scopes. - New helpers: response_headers_for() bundles status + WWW-Authenticate into one call; AuthplaneResource.prm_url() returns the RFC 9728 well-known URL. - Both adapter verifiers emit a logging.DEBUG event "authplane.token_verification_failed" with structured error_class / error fields before returning None. DPoP htu host header - Outbound HTTP layer preserves non-default ports in the Host header and brackets IPv6 hostnames, so DPoP-protected requests to authservers on non-standard ports verify under RFC 9449. Docs - Fix 6 snippets that referenced an undefined run_query(); switch URL elicitation example to UrlElicitationRequiredError; rewrite MCP adapter Quick Starts to a single asyncio.run(main()) loop so refresh tasks share the request loop; small inaccuracies removed. Conformance tests - Align RFC 8693 issued_token_type test with the catalog; enforce one-test-per-case_id at collection time (collapses 3 sibling pairs); unify env var to AUTHPLANE_CONFORMANCE_CATALOG; raise a clear error when the catalog file is missing; cleanup (extract repeated SSRF stub, hoist imports). Tests: new coverage in tests/test_errors.py and tests/net/test_ssrf.py; adapter tests cover the new debug log event. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 12 + CONTRIBUTING.md | 2 +- README.md | 2 +- .../authplane_fastmcp/verifier.py | 9 +- authplane-fastmcp/docs/user-guide.md | 80 ++++-- authplane-fastmcp/tests/test_verifier.py | 43 +++- authplane-mcp/README.md | 26 +- authplane-mcp/authplane_mcp/verifier.py | 10 +- authplane-mcp/docs/user-guide.md | 56 +++-- authplane-mcp/tests/test_verifier.py | 47 +++- authplane/__init__.py | 2 + authplane/docs/user-guide.md | 21 +- authplane/errors.py | 94 ++++++- authplane/net/ssrf.py | 13 +- authplane/verifier/claims.py | 3 +- authplane/verifier/verifier.py | 11 + conformance-tests/README.md | 19 +- conformance-tests/conftest.py | 42 +++- conformance-tests/test_catalog_alignment.py | 4 +- .../test_jwt_and_dpop_conformance.py | 233 ++++++++--------- .../test_oauth_protocol_conformance.py | 15 ++ conformance-tests/test_rfc8414_conformance.py | 31 +-- llm-full.txt | 92 +++++-- tests/net/test_ssrf.py | 111 +++++++++ tests/test_errors.py | 234 ++++++++++++++++++ tests/verifier/test_verifier.py | 12 + 28 files changed, 959 insertions(+), 269 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d533ef..a1e9a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: - name: Test and coverage env: - CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml + AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml run: | if [ "${{ matrix.package }}" = "root" ]; then coverage run -m pytest tests conformance-tests && coverage report diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d800fcf..30b9f98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,7 +173,7 @@ jobs: # below, as part of the single release commit. - name: Install packages and run tests env: - CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml + AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml run: | pip install -e ".[dev]" pip install -e "./authplane-mcp[dev]" diff --git a/CHANGELOG.md b/CHANGELOG.md index b75e2fa..c4e22c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security +- `www_authenticate()` now sanitizes CR, LF, double-quote, and backslash from every value it interpolates (`realm`, `error_description`, `scope`, `resource_metadata`), closing a header-injection path through attacker-influenced error messages. + ### Fixed +- `DPoPNotSupportedError` now emits `WWW-Authenticate: Bearer` instead of `DPoP`. The resource is bearer-only by configuration, so advertising the DPoP scheme misled clients into retries that would fail the same way. +- `http_status(CircuitOpenError)` now returns `503` (was `500`). The circuit breaker is structurally identical to other temporary-AS-unavailability errors and should be retryable, not surfaced as an internal error. +- Outbound `Host` header now preserves non-default ports and brackets IPv6 hostnames, fixing DPoP `htu` validation against authservers on non-standard ports. - Packaging issues discovered after the first release. - Documentation links and demo references. - `authplane-fastmcp` dependency range now correctly requires `fastmcp>=3.2,<4` (was `>=2.0`, which could resolve to a version the adapter can't import). +### Added +- `www_authenticate()` accepts `resource_metadata_url=` (RFC 9728 §5.1) and `scope=` (RFC 6750 §3) keyword arguments. When the caller does not pass `scope=`, the helper auto-populates it from `InsufficientScopeError.required_scopes`. +- `InsufficientScopeError` now carries a structured `required_scopes` attribute, populated automatically by `VerifiedClaims.require_scope()` so the wire challenge can advertise the missing scope. +- `response_headers_for(error, *, realm, resource_metadata_url, scope)` — bundled helper returning `(status, {"WWW-Authenticate": challenge})` in one call. +- Both adapter verifiers (`authplane-mcp`, `authplane-fastmcp`) now emit a `logging.DEBUG` event `authplane.token_verification_failed` with structured `error_class` and `error` fields before returning `None`. Wire behaviour is unchanged; operators can now distinguish expired tokens from JWKS outages and DPoP replays in logs. + ### Changed - CI and release workflow improvements from first-release learnings. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a47d740..52d8543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,7 @@ Then run: pytest conformance-tests/ ``` -`conformance-tests/conftest.py` resolves the catalog at `../conformance/oauth-sdk-conformance-catalog.yaml` by default. Set `CONFORMANCE_CATALOG_PATH=/absolute/path/to/oauth-sdk-conformance-catalog.yaml` to override (useful if your checkout layout differs). Without the catalog, `conformance-tests/` fails with a clear error — the rest of the test suite still runs. +`conformance-tests/conftest.py` resolves the catalog at `../conformance/oauth-sdk-conformance-catalog.yaml` by default. Set `AUTHPLANE_CONFORMANCE_CATALOG=/absolute/path/to/oauth-sdk-conformance-catalog.yaml` to override (useful if your checkout layout differs). Without the catalog, `conformance-tests/` fails with a clear error — the rest of the test suite still runs. **Coverage (matches CI):** diff --git a/README.md b/README.md index 527df41..83c55c4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ async def main() -> None: @mcp.tool(auth=require_scopes("tools/query")) def query(sql: str) -> str: - return run_query(sql) + return f"Ran: {sql}" # replace with your real handler try: await mcp.run_async(transport="http", port=8080) diff --git a/authplane-fastmcp/authplane_fastmcp/verifier.py b/authplane-fastmcp/authplane_fastmcp/verifier.py index b256c7d..e4dc54a 100644 --- a/authplane-fastmcp/authplane_fastmcp/verifier.py +++ b/authplane-fastmcp/authplane_fastmcp/verifier.py @@ -5,11 +5,14 @@ to FastMCP's ``AccessToken`` with the full JWT payload in ``claims``. """ +import logging from typing import Any, cast from authplane import AuthplaneError, AuthplaneResource from fastmcp.server.auth import AccessToken, TokenVerifier +logger = logging.getLogger(__name__) + class AuthplaneTokenVerifier(TokenVerifier): """FastMCP TokenVerifier backed by AuthplaneResource. @@ -83,7 +86,11 @@ async def verify_token(self, token: str) -> AccessToken | None: """ try: claims = await self._verifier.verify(token) - except AuthplaneError: + except AuthplaneError as error: + logger.debug( + "authplane.token_verification_failed", + extra={"error_class": type(error).__name__, "error": str(error)}, + ) return None return AccessToken( diff --git a/authplane-fastmcp/docs/user-guide.md b/authplane-fastmcp/docs/user-guide.md index b888c7e..28699f0 100644 --- a/authplane-fastmcp/docs/user-guide.md +++ b/authplane-fastmcp/docs/user-guide.md @@ -32,24 +32,30 @@ Requires Python 3.11+. ## Quick Start ```python +import asyncio + from fastmcp import FastMCP from authplane_fastmcp import authplane_auth -mcp = FastMCP( - "My Server", - **await authplane_auth( +async def main() -> None: + result = await authplane_auth( issuer="https://auth.company.com", base_url="https://mcp.company.com", scopes=["tools/query", "tools/write"], - ), -) + ) + mcp = FastMCP("My Server", **result) -@mcp.tool() -def query(sql: str) -> str: - """Execute a query.""" - return run_query(sql) + @mcp.tool() + def query(sql: str) -> str: + """Execute a query.""" + return f"Ran: {sql}" # replace with your real handler + + try: + await mcp.run_async(transport="http", port=8080) + finally: + await result.aclose() -mcp.run(transport="http", port=8080) +asyncio.run(main()) ``` `authplane_auth()` performs RFC 8414 metadata discovery, fetches the JWKS, and wires up all authentication components. The result unpacks directly into `FastMCP()`. @@ -95,7 +101,7 @@ from fastmcp.server.auth import require_scopes @mcp.tool(auth=require_scopes("tools/query")) def query(sql: str) -> str: """Requires the tools/query scope.""" - return run_query(sql) + return f"Ran: {sql}" # replace with your real handler @mcp.tool(auth=require_scopes("tools/admin", "tools/delete")) def delete_all() -> str: @@ -103,7 +109,7 @@ def delete_all() -> str: return clear_database() ``` -FastMCP enforces scopes **before** the handler runs. If the token is missing a required scope, FastMCP returns a 403 response and the handler is never called. +FastMCP enforces scopes **before** the handler runs by **filtering tools the caller cannot use out of the catalog**. If the token is missing a required scope, the tool is hidden from `tools/list`, and a `tools/call` for that tool returns HTTP 200 with `{"isError": true, "content": [{"text": "Unknown tool: ''"}]}` — **not** a 403. UX layers expecting a 403 to prompt for re-auth will not see one; key off `isError` + the tool-not-found content text instead. ## Accessing Token Claims @@ -274,11 +280,12 @@ downstream = await result.client.exchange( When a token exchange needs interactive user consent at the AS (for example, first-time authorization against a third-party service), the AS returns `consent_required` with a `consent_url`. MCP surfaces this through the URL elicitation flow (JSON-RPC error `-32042`). The [`authplane-mcp`](../../authplane-mcp/docs/user-guide.md#url-elicitation-for-consent) adapter wires it up end-to-end. -**fastmcp 3.2 does not propagate `McpError` from tool handlers** (its tool dispatch wraps everything except `FastMCPError` as `{"isError": true}`), so `-32042` never reaches the wire. Handle `ConsentRequiredError` in the tool body for now: +**fastmcp 3.2 does not propagate `McpError` from tool handlers** (its tool dispatch wraps everything except `FastMCPError` as `{"isError": true}`), so `-32042` never reaches the wire. The wrapped `client.exchange()` raises `UrlElicitationRequiredError` (the MCP-shaped form of the consent error) — catch it in the tool body and render the consent URL into the response yourself: ```python from authplane import ConsentRequiredError from authplane.oauth import TokenExchangeOptions +from mcp.shared.exceptions import UrlElicitationRequiredError @mcp.tool(auth=require_scopes("tools/call_downstream")) async def call_downstream(payload: str) -> str: @@ -286,8 +293,14 @@ async def call_downstream(payload: str) -> str: downstream = await auth_result.client.exchange( TokenExchangeOptions(subject_token=..., scope="downstream/write") ) + except UrlElicitationRequiredError as error: + urls = [e.url for e in error.elicitations] if error.elicitations else [] + return f"Consent required: {urls[0] if urls else ''}" except ConsentRequiredError as error: - return f"Consent required: {error.consent_url}" + # The wrapper only translates to UrlElicitationRequiredError when the + # AS supplied a consent_url. Without one, the bare error reaches us — + # surface its formatted description (no URL to render). + return f"Consent required: {error.describe()}" return await downstream_api_call(downstream.access_token, payload) ``` @@ -364,12 +377,17 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J `authplane_auth()` returns an `AuthplaneAuthResult` that holds background JWKS / metadata refresh tasks and an HTTP connection pool. Call `aclose()` on shutdown: ```python -result = await authplane_auth(...) -try: - mcp = FastMCP("My Server", **result) - await mcp.run_async(transport="http", port=8080) -finally: - await result.aclose() +import asyncio + +async def main() -> None: + result = await authplane_auth(...) + try: + mcp = FastMCP("My Server", **result) + await mcp.run_async(transport="http", port=8080) + finally: + await result.aclose() + +asyncio.run(main()) ``` `result.aclose()` closes the underlying `AuthplaneClient`, cancels its background tasks, and releases connections. Skipping it surfaces as leaked tasks, open sockets, and `ResourceWarning` in tests. @@ -378,11 +396,29 @@ finally: ### Verification path -`AuthplaneTokenVerifier.verify_token` catches every `AuthplaneError` raised by `AuthplaneResource.verify()` (missing/expired/invalid/revoked token, DPoP failure, etc.) and returns `None`. FastMCP turns that into a uniform **401 Unauthorized** for the request — the adapter does not differentiate by error type. +`AuthplaneTokenVerifier.verify_token` catches every `AuthplaneError` raised by `AuthplaneResource.verify()` (missing/expired/invalid/revoked token, DPoP failure, etc.) and returns `None`. FastMCP turns that into a uniform **401 Unauthorized** on the wire — the *wire* does not differentiate by error type, but the verifier emits a `logging.DEBUG` event `authplane.token_verification_failed` (logger `authplane_fastmcp.verifier`) with structured `error_class` and `error` fields so operators can distinguish expired tokens from JWKS outages from DPoP replays in logs. ### Scope enforcement -Scope checks happen *after* token validation succeeds and are a separate enforcement layer — see [Scope Enforcement](#scope-enforcement) above for `@mcp.tool(auth=require_scopes(...))`. Inside a handler, `claims.require_scope("…")` raises `InsufficientScopeError` (which `http_status()` maps to 403 if you call it). +Scope checks happen *after* token validation succeeds and are a separate enforcement layer — see [Scope Enforcement](#scope-enforcement) above for `@mcp.tool(auth=require_scopes(...))`. Inside a handler, `claims.require_scope("…")` raises `InsufficientScopeError`. The error carries `required_scopes` so the SDK can emit RFC 6750's `scope=` challenge automatically. + +### Building a `WWW-Authenticate` challenge in custom middleware + +When you handle an `AuthplaneError` outside the verifier — typically because you are wrapping the adapter in your own middleware or calling `AuthplaneResource.verify()` directly — use `response_headers_for(error, …)` to map the error to `(status, {"WWW-Authenticate": challenge})` in one call. It forwards `realm`, `resource_metadata_url`, and `scope` into the underlying `www_authenticate()` helper, which sanitizes every interpolated value against header injection. + +```python +from authplane import AuthplaneError, response_headers_for + +try: + claims = await resource.verify(token, dpop_request=ctx) +except AuthplaneError as error: + status, headers = response_headers_for( + error, + realm="api.example.com", + resource_metadata_url=resource.prm_url(), + ) + return Response(status_code=status, headers=headers) +``` ### Catching SDK errors directly diff --git a/authplane-fastmcp/tests/test_verifier.py b/authplane-fastmcp/tests/test_verifier.py index a1046fb..0f78844 100644 --- a/authplane-fastmcp/tests/test_verifier.py +++ b/authplane-fastmcp/tests/test_verifier.py @@ -1,9 +1,10 @@ """Unit tests for AuthplaneTokenVerifier.""" +import logging from unittest.mock import AsyncMock import pytest -from authplane import VerifiedClaims +from authplane import AuthplaneError, TokenExpiredError, VerifiedClaims from authplane_fastmcp import AuthplaneTokenVerifier @@ -103,3 +104,43 @@ def test_verifier_property( ) -> None: """verifier property exposes the underlying AuthplaneResource.""" assert token_verifier.verifier is mock_verifier + + +@pytest.mark.asyncio +async def test_verify_token_failure_logs_typed_error_at_debug( + mock_verifier: AsyncMock, caplog: pytest.LogCaptureFixture +) -> None: + # Regression: contract-required None return must still + # produce an operator-side signal carrying the typed error class and + # message. Debug-level so steady-state invalid tokens stay quiet. + mock_verifier.verify.side_effect = TokenExpiredError("expired at 2026") + + verifier = AuthplaneTokenVerifier(mock_verifier) + + with caplog.at_level(logging.DEBUG, logger="authplane_fastmcp.verifier"): + result = await verifier.verify_token("expired_jwt") + + assert result is None + matching = [r for r in caplog.records if r.name == "authplane_fastmcp.verifier"] + assert len(matching) == 1 + record = matching[0] + assert record.levelno == logging.DEBUG + assert record.message == "authplane.token_verification_failed" + assert record.error_class == "TokenExpiredError" # type: ignore[attr-defined] + assert record.error == "expired at 2026" # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_verify_token_failure_silent_above_debug( + mock_verifier: AsyncMock, caplog: pytest.LogCaptureFixture +) -> None: + # Default INFO level must not surface the event. + mock_verifier.verify.side_effect = AuthplaneError("bad token") + + verifier = AuthplaneTokenVerifier(mock_verifier) + + with caplog.at_level(logging.INFO, logger="authplane_fastmcp.verifier"): + result = await verifier.verify_token("bad") + + assert result is None + assert not [r for r in caplog.records if r.name == "authplane_fastmcp.verifier"] diff --git a/authplane-mcp/README.md b/authplane-mcp/README.md index 3a1df1b..428da67 100644 --- a/authplane-mcp/README.md +++ b/authplane-mcp/README.md @@ -19,28 +19,34 @@ Supported `mcp` range: **`>=1.23.0, <1.28.0`**. MCP 1.28 renamed the elicitation ```python import asyncio + from authplane_mcp import authplane_mcp_auth, require_scope from mcp.server.fastmcp import FastMCP -auth_result = asyncio.run( - authplane_mcp_auth( + +async def main() -> None: + auth_result = await authplane_mcp_auth( issuer="https://auth.company.com", resource="https://mcp.company.com", scopes=["tools/query", "tools/write"], ) -) + mcp = FastMCP("My MCP Server", port=8080, json_response=True, **auth_result) + + @mcp.tool() + async def query_database(query: str) -> str: + require_scope("tools/query") + return f"Result for: {query}" -mcp = FastMCP("My MCP Server", json_response=True, **auth_result) + try: + await mcp.run_streamable_http_async() + finally: + await auth_result.aclose() -@mcp.tool() -async def query_database(query: str) -> str: - require_scope("tools/query") - return f"Result for: {query}" -mcp.run(transport="streamable-http") +asyncio.run(main()) ``` -`mcp.run()` starts its own event loop, so the auth setup runs synchronously via `asyncio.run(...)` first. `auth_result` holds background JWKS and metadata refresh tasks — call `await auth_result.aclose()` during server shutdown. +`auth_result` holds background JWKS and metadata refresh tasks bound to the running event loop. Keep the setup, server, and `aclose()` inside a single `asyncio.run(main())` so those tasks stay alive for the server's lifetime. ## Documentation diff --git a/authplane-mcp/authplane_mcp/verifier.py b/authplane-mcp/authplane_mcp/verifier.py index e059a06..d62dfe0 100644 --- a/authplane-mcp/authplane_mcp/verifier.py +++ b/authplane-mcp/authplane_mcp/verifier.py @@ -5,9 +5,13 @@ ``AuthplaneResource`` and mapping results to MCP's ``AccessToken``. """ +import logging + from authplane import AuthplaneError, AuthplaneResource from mcp.server.auth.provider import AccessToken, TokenVerifier +logger = logging.getLogger(__name__) + class AuthplaneTokenVerifier(TokenVerifier): """MCP SDK TokenVerifier backed by AuthplaneResource. @@ -55,7 +59,11 @@ async def verify_token(self, token: str) -> AccessToken | None: """ try: claims = await self._verifier.verify(token) - except AuthplaneError: + except AuthplaneError as error: + logger.debug( + "authplane.token_verification_failed", + extra={"error_class": type(error).__name__, "error": str(error)}, + ) return None # AccessToken.resource must be a string. Since audience is a list, diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index 299a65c..d4acd1c 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -32,27 +32,33 @@ Requires Python 3.11+. ## Quick Start ```python +import asyncio + from mcp.server.fastmcp import FastMCP from authplane_mcp import authplane_mcp_auth, require_scope -mcp = FastMCP( - "My Server", - port=8080, - json_response=True, - **await authplane_mcp_auth( + +async def main() -> None: + auth_result = await authplane_mcp_auth( issuer="https://auth.company.com", resource="https://mcp.company.com", scopes=["tools/query", "tools/write"], - ), -) + ) + mcp = FastMCP("My Server", port=8080, json_response=True, **auth_result) -@mcp.tool() -async def query(sql: str) -> str: - """Execute a query.""" - require_scope("tools/query") - return run_query(sql) + @mcp.tool() + async def query(sql: str) -> str: + """Execute a query.""" + require_scope("tools/query") + return f"Ran: {sql}" # replace with your real handler -mcp.run(transport="streamable-http") + try: + await mcp.run_streamable_http_async() + finally: + await auth_result.aclose() + + +asyncio.run(main()) ``` `authplane_mcp_auth()` performs RFC 8414 metadata discovery, fetches the JWKS, and returns a dict with `token_verifier` and `auth` keys that unpack directly into `FastMCP()`. @@ -99,7 +105,7 @@ from authplane_mcp import require_scope async def query(sql: str) -> str: """Requires the tools/query scope.""" require_scope("tools/query") - return run_query(sql) + return f"Ran: {sql}" # replace with your real handler @mcp.tool() async def delete_all() -> str: @@ -398,11 +404,29 @@ asyncio.run(main()) ### Verification path -`AuthplaneTokenVerifier.verify_token` catches every `AuthplaneError` raised by `AuthplaneResource.verify()` (missing/expired/invalid/revoked token, DPoP failure, etc.) and returns `None`. The MCP server turns that into a uniform **401 Unauthorized** for the request — the adapter does not differentiate by error type. +`AuthplaneTokenVerifier.verify_token` catches every `AuthplaneError` raised by `AuthplaneResource.verify()` (missing/expired/invalid/revoked token, DPoP failure, etc.) and returns `None`. The MCP server turns that into a uniform **401 Unauthorized** on the wire — the *wire* does not differentiate by error type, but the verifier emits a `logging.DEBUG` event `authplane.token_verification_failed` (logger `authplane_mcp.verifier`) with structured `error_class` and `error` fields so operators can distinguish expired tokens from JWKS outages from DPoP replays in logs. ### Scope enforcement -Scope checks happen *after* token validation succeeds and are a separate enforcement layer — see [Scope Enforcement](#scope-enforcement) above for the `require_scope()` helper. Inside a handler, `claims.require_scope("…")` raises `InsufficientScopeError` (which `http_status()` maps to 403 if you call it). +Scope checks happen *after* token validation succeeds and are a separate enforcement layer — see [Scope Enforcement](#scope-enforcement) above for the `require_scope()` helper. Inside a handler, `claims.require_scope("…")` raises `InsufficientScopeError`. The error carries `required_scopes` so the SDK can emit RFC 6750's `scope=` challenge automatically. + +### Building a `WWW-Authenticate` challenge in custom middleware + +When you handle an `AuthplaneError` outside the verifier — typically because you are wrapping the adapter in your own middleware or calling `AuthplaneResource.verify()` directly — use `response_headers_for(error, …)` to map the error to `(status, {"WWW-Authenticate": challenge})` in one call. It forwards `realm`, `resource_metadata_url`, and `scope` into the underlying `www_authenticate()` helper, which sanitizes every interpolated value against header injection. + +```python +from authplane import AuthplaneError, response_headers_for + +try: + claims = await resource.verify(token, dpop_request=ctx) +except AuthplaneError as error: + status, headers = response_headers_for( + error, + realm="api.example.com", + resource_metadata_url=resource.prm_url(), + ) + return Response(status_code=status, headers=headers) +``` ### Catching SDK errors directly diff --git a/authplane-mcp/tests/test_verifier.py b/authplane-mcp/tests/test_verifier.py index 3875547..43ae1f2 100644 --- a/authplane-mcp/tests/test_verifier.py +++ b/authplane-mcp/tests/test_verifier.py @@ -1,8 +1,9 @@ +import logging from dataclasses import replace from unittest.mock import AsyncMock import pytest -from authplane import AuthplaneError, VerifiedClaims +from authplane import AuthplaneError, TokenExpiredError, VerifiedClaims from mcp.server.auth.provider import AccessToken from authplane_mcp.verifier import AuthplaneTokenVerifier @@ -76,3 +77,47 @@ def test_verifier_property() -> None: mock_verifier = AsyncMock() adapter = AuthplaneTokenVerifier(mock_verifier) assert adapter.verifier is mock_verifier + + +@pytest.mark.asyncio +async def test_verify_token_failure_logs_typed_error_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + # Regression: returning None still has to happen (the MCP + # contract requires it), but operators must see *which* typed error caused + # the 401 in their logs. Debug-level because invalid tokens are expected + # steady-state and shouldn't page on-call. + mock_verifier = AsyncMock() + mock_verifier.verify.side_effect = TokenExpiredError("expired at 2026") + + adapter = AuthplaneTokenVerifier(mock_verifier) + + with caplog.at_level(logging.DEBUG, logger="authplane_mcp.verifier"): + result = await adapter.verify_token("expired_jwt") + + assert result is None + matching = [r for r in caplog.records if r.name == "authplane_mcp.verifier"] + assert len(matching) == 1 + record = matching[0] + assert record.levelno == logging.DEBUG + assert record.message == "authplane.token_verification_failed" + assert record.error_class == "TokenExpiredError" # type: ignore[attr-defined] + assert record.error == "expired at 2026" # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_verify_token_failure_silent_above_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + # At default (INFO) level the structured event must not surface — invalid + # tokens are too common in steady state to log at info. + mock_verifier = AsyncMock() + mock_verifier.verify.side_effect = AuthplaneError("bad token") + + adapter = AuthplaneTokenVerifier(mock_verifier) + + with caplog.at_level(logging.INFO, logger="authplane_mcp.verifier"): + result = await adapter.verify_token("bad") + + assert result is None + assert not [r for r in caplog.records if r.name == "authplane_mcp.verifier"] diff --git a/authplane/__init__.py b/authplane/__init__.py index 5ef5504..373586b 100644 --- a/authplane/__init__.py +++ b/authplane/__init__.py @@ -57,6 +57,7 @@ UnsupportedGrantTypeError, VerifierRuntimeError, http_status, + response_headers_for, www_authenticate, ) @@ -118,5 +119,6 @@ "VerifierRuntimeError", "__version__", "http_status", + "response_headers_for", "www_authenticate", ] diff --git a/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index affa5e6..7925063 100644 --- a/authplane/docs/user-guide.md +++ b/authplane/docs/user-guide.md @@ -345,7 +345,7 @@ Client-credentials responses are cached in memory by `(scope, resource)`. Cached ## 7. DPoP for Outbound Calls -Use `DPoPProvider` when your MCP server needs sender-constrained tokens or when the AS/downstream service requires DPoP proofs. +Use `DPoPProvider` when your MCP server needs to acquire sender-constrained tokens for its own use against downstream services, or when the AS/downstream service requires DPoP proofs on the request itself. (For accepting DPoP-bound tokens from incoming requests, see `inbound_dpop` in §3.) ```python from authplane import DPoPKeyMaterial, DPoPProvider @@ -567,30 +567,38 @@ The SDK maps OAuth error responses into typed `AuthError` subclasses. The circui ### HTTP status mapping -Use `http_status()` to map any `AuthplaneError` to an HTTP status code: +Use `http_status()` to map any `AuthplaneError` to an HTTP status code, `www_authenticate()` to build the matching `WWW-Authenticate` challenge, or `response_headers_for()` to get both in one call: ```python -from authplane import AuthplaneError, http_status +from authplane import AuthplaneError, response_headers_for try: claims = await res.verify(token) except AuthplaneError as e: - status = http_status(e) + status, headers = response_headers_for( + e, + realm="api.example.com", + resource_metadata_url=res.prm_url(), + ) + # status: int, headers: {"WWW-Authenticate": "Bearer error=..."} ``` | Exception | HTTP Status | |-----------|-------------| | `InsufficientScopeError` | 403 | -| `JWKSFetchError`, `MetadataFetchError` | 503 | +| `JWKSFetchError`, `MetadataFetchError`, `CircuitOpenError` | 503 | | `TokenMissingError`, `TokenExpiredError`, `InvalidSignatureError`, `InvalidClaimsError`, `TokenRevokedError`, `DPoPError` (and subclasses) | 401 | | `ProtocolError`, `VerifierRuntimeError`, other | 500 | +`www_authenticate()` selects the scheme (`Bearer` by default, `DPoP` for DPoP-flow errors except `DPoPNotSupportedError`, which stays `Bearer` because the resource is bearer-only). When `scope=` is omitted it auto-populates from `InsufficientScopeError.required_scopes`. Every interpolated value is sanitized against header injection. + ## 12. Protected Resource Metadata Generate an RFC 9728 protected resource metadata document with: ```python -prm = res.prm_response() +prm = res.prm_response() # the document body (a dict) +url = res.prm_url() # the well-known URL where clients can fetch it ``` Example output: @@ -600,7 +608,6 @@ Example output: "resource": "https://api.example.com", "authorization_servers": ["https://auth.example.com"], "bearer_methods_supported": ["header"], - "resource_signing_alg_values_supported": ["RS256", "ES256"], "scopes_supported": ["read", "write"] } ``` diff --git a/authplane/errors.py b/authplane/errors.py index 14fd901..e329b5b 100644 --- a/authplane/errors.py +++ b/authplane/errors.py @@ -4,6 +4,18 @@ InsufficientScope is distinguishable for 403 HTTP status mapping. """ +import re + +_HEADER_VALUE_UNSAFE = re.compile(r'[\r\n"\\]+') + + +def _sanitize_header_value(value: str) -> str: + """Replace CR, LF, double-quote, and backslash with a single space so the + value cannot break out of a quoted ``WWW-Authenticate`` parameter or inject + additional header fields. Leading/trailing whitespace is stripped. + """ + return _HEADER_VALUE_UNSAFE.sub(" ", value).strip() + class AuthplaneError(Exception): """Base exception for all Authplane SDK errors.""" @@ -38,11 +50,16 @@ class InvalidClaimsError(AuthplaneError): class InsufficientScopeError(AuthplaneError): """Raised when the token lacks required scopes. - This exception maps to HTTP 403 Forbidden, while other AuthplaneError - exceptions typically map to HTTP 401 Unauthorized. + Maps to HTTP 403 Forbidden, while other AuthplaneError exceptions + typically map to HTTP 401 Unauthorized. The optional ``required_scopes`` + attribute carries the scopes the caller required so + :func:`www_authenticate` can emit the RFC 6750 ``scope=`` challenge + parameter automatically. """ - pass + def __init__(self, message: str, *, required_scopes: tuple[str, ...] = ()) -> None: + super().__init__(message) + self.required_scopes = required_scopes class JWKSFetchError(AuthplaneError): @@ -225,14 +242,31 @@ class CircuitOpenError(AuthError): pass -def www_authenticate(error: AuthplaneError, *, realm: str = "") -> str: +def www_authenticate( + error: AuthplaneError, + *, + realm: str = "", + resource_metadata_url: str | None = None, + scope: list[str] | None = None, +) -> str: """Build an RFC 6750 §3 ``WWW-Authenticate`` header value. Maps SDK errors to the correct error code and authentication scheme: - ``InsufficientScopeError`` → ``insufficient_scope`` - - ``DPoPError`` subclasses → ``DPoP`` scheme with ``invalid_token`` + - ``DPoPError`` subclasses (except ``DPoPNotSupportedError``) → ``DPoP`` + scheme with ``invalid_token`` - All other ``AuthplaneError`` → ``Bearer`` scheme with ``invalid_token`` + If ``scope`` is provided (or the error is an :class:`InsufficientScopeError` + carrying ``required_scopes``), an RFC 6750 §3 ``scope="…"`` challenge + parameter is included. An explicit ``scope`` argument takes precedence. + + If ``resource_metadata_url`` is provided, the RFC 9728 §5.1 + ``resource_metadata`` challenge parameter is included so clients can + discover the Protected Resource Metadata document. + + Every interpolated value is sanitized to prevent header injection. + Returns: A header value like ``Bearer error="invalid_token", error_description="..."`` """ @@ -241,13 +275,24 @@ def www_authenticate(error: AuthplaneError, *, realm: str = "") -> str: else: error_code = "invalid_token" - scheme = "DPoP" if isinstance(error, DPoPError) else "Bearer" + scheme = ( + "DPoP" + if isinstance(error, DPoPError) and not isinstance(error, DPoPNotSupportedError) + else "Bearer" + ) + + if scope is None and isinstance(error, InsufficientScopeError) and error.required_scopes: + scope = list(error.required_scopes) parts: list[str] = [] if realm: - parts.append(f'realm="{realm}"') + parts.append(f'realm="{_sanitize_header_value(realm)}"') parts.append(f'error="{error_code}"') - parts.append(f'error_description="{error}"') + parts.append(f'error_description="{_sanitize_header_value(str(error))}"') + if scope: + parts.append(f'scope="{_sanitize_header_value(" ".join(scope))}"') + if resource_metadata_url: + parts.append(f'resource_metadata="{_sanitize_header_value(resource_metadata_url)}"') return f"{scheme} " + ", ".join(parts) @@ -256,15 +301,15 @@ def http_status(error: AuthplaneError) -> int: Returns: 403 for InsufficientScopeError. - 503 for JWKSFetchError and MetadataFetchError (service temporarily - unable to validate tokens). + 503 for JWKSFetchError, MetadataFetchError, and CircuitOpenError + (the AS is temporarily unable to participate in validation). 401 for all authentication failures (missing/expired/invalid tokens, DPoP errors, revoked tokens). 500 for internal errors (SSRF, protocol, runtime). """ if isinstance(error, InsufficientScopeError): return 403 - if isinstance(error, (JWKSFetchError, MetadataFetchError)): + if isinstance(error, (JWKSFetchError, MetadataFetchError, CircuitOpenError)): return 503 if isinstance( error, @@ -283,6 +328,33 @@ def http_status(error: AuthplaneError) -> int: return 500 +def response_headers_for( + error: AuthplaneError, + *, + realm: str = "", + resource_metadata_url: str | None = None, + scope: list[str] | None = None, +) -> tuple[int, dict[str, str]]: + """Return ``(status, {"WWW-Authenticate": challenge})`` for an Authplane error. + + One call replaces the parallel use of :func:`http_status` and + :func:`www_authenticate`. Forwards keyword arguments to + :func:`www_authenticate` so callers can include ``realm``, + ``resource_metadata_url``, and ``scope`` without re-deriving the mapping. + """ + return ( + http_status(error), + { + "WWW-Authenticate": www_authenticate( + error, + realm=realm, + resource_metadata_url=resource_metadata_url, + scope=scope, + ) + }, + ) + + def map_oauth_error( operation: str, status_code: int, diff --git a/authplane/net/ssrf.py b/authplane/net/ssrf.py index 4088a35..60757ac 100644 --- a/authplane/net/ssrf.py +++ b/authplane/net/ssrf.py @@ -112,6 +112,8 @@ async def _execute_pinned_request( *, method: str, hostname: str, + scheme: str, + port: int, form_data: dict[str, str] | list[tuple[str, str]] | None = None, json_data: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None, @@ -127,7 +129,14 @@ async def _execute_pinned_request( headers: dict[str, str] = {} if extra_headers: headers.update(extra_headers) - headers["Host"] = hostname + # Host header must mirror the URI authority (RFC 7230 §5.4 / RFC 9110 §7.2): + # include the port when it isn't the scheme's default. Required for RFC 9449 + # DPoP htu validation, which compares the proof's htu against the server's + # reconstruction of the request URI from Host + path. IPv6 literals are + # bracketed per RFC 3986 §3.2.2. + default_port = 443 if scheme == "https" else 80 + host_literal = f"[{hostname}]" if ":" in hostname else hostname + headers["Host"] = host_literal if port == default_port else f"{host_literal}:{port}" headers["Accept"] = "application/json" stream_kwargs: dict[str, Any] = { @@ -244,6 +253,8 @@ async def _ssrf_safe_request( pinned_url, method=method, hostname=validated.hostname, + scheme=scheme, + port=validated.port, form_data=form_data, json_data=json_data, extra_headers=extra_headers, diff --git a/authplane/verifier/claims.py b/authplane/verifier/claims.py index 6c35342..1aa99b3 100644 --- a/authplane/verifier/claims.py +++ b/authplane/verifier/claims.py @@ -56,7 +56,8 @@ def require_scope(self, scope: str) -> None: """Raise InsufficientScopeError when the validated token lacks the scope.""" if not self.has_scope(scope): raise InsufficientScopeError( - f"Token missing required scope '{scope}'. Token has scopes: {list(self.scopes)}" + f"Token missing required scope '{scope}'. Token has scopes: {list(self.scopes)}", + required_scopes=(scope,), ) def has_claim(self, key: str, value: Any = _MISSING) -> bool: diff --git a/authplane/verifier/verifier.py b/authplane/verifier/verifier.py index cabc786..ebd7db2 100644 --- a/authplane/verifier/verifier.py +++ b/authplane/verifier/verifier.py @@ -36,6 +36,7 @@ VerifierRuntimeError, ) from ..internal.jwt import decode_jwt_header +from ..internal.urls import build_prm_url from ..oauth.prm import build_prm from ..oauth.types import IntrospectionRevocation from .claims import VerifiedClaims, freeze_value @@ -411,3 +412,13 @@ def prm_response(self) -> dict[str, object]: else None, dpop_required=self._dpop_required, ) + + def prm_url(self) -> str: + """Return the RFC 9728 well-known PRM discovery URL for this resource. + + Symmetric with :meth:`prm_response`: this is the URL clients can fetch + to retrieve that document, suitable for the ``resource_metadata`` + challenge parameter (:func:`authplane.www_authenticate`, + :func:`authplane.response_headers_for`). + """ + return build_prm_url(self._resource) diff --git a/conformance-tests/README.md b/conformance-tests/README.md index c7f510c..f518398 100644 --- a/conformance-tests/README.md +++ b/conformance-tests/README.md @@ -40,7 +40,7 @@ async def test_...(...): ### Not-yet-implemented tests -Tests for features that don't exist yet should still be present with the marker and a note explaining the gap: +Tests for features that don't exist yet should still be present with the marker and a `pytest.xfail(...)` body that documents what is missing: ```python @pytest.mark.conformance( @@ -48,13 +48,26 @@ Tests for features that don't exist yet should still be present with the marker note="Not implemented: the SDK has no nonce generation, DPoP-Nonce challenge emission, or challenge-retry lifecycle for resource servers.", ) async def test_rfc9449_dpop_inbound_nonce_must_be_validated_when_required(...): - ... # test body exercises the expected API — will fail until implemented + pytest.xfail("Not implemented: ...") ``` -These tests fail intentionally and show up as `failed` with their note in the report. +These tests show up as `skipped` (with their `note` carried through) in both `conformance-report.json` and `conformance-report.md` — pytest classifies `xfail` outcomes as skips. Keeping the suite green for known gaps means CI never has to be ignored to merge; the gap is still visible in the report's per-case status and coverage notes. ## Running +The suite needs the shared catalog YAML on disk. By default it looks for +`../conformance/oauth-sdk-conformance-catalog.yaml` (i.e. `python-sdk` and +[`conformance`](https://github.com/AuthPlane/conformance) checked out as +siblings). If your layout differs — e.g. nested inside another monorepo — +point the suite at the catalog explicitly: + +```bash +export AUTHPLANE_CONFORMANCE_CATALOG=/abs/path/to/oauth-sdk-conformance-catalog.yaml +``` + +The suite refuses to start with a clear error if the catalog cannot be +found. + ```bash # Run the conformance suite pytest conformance-tests/ diff --git a/conformance-tests/conftest.py b/conformance-tests/conftest.py index 5055573..ec9baf0 100644 --- a/conformance-tests/conftest.py +++ b/conformance-tests/conftest.py @@ -18,7 +18,10 @@ async def test_rfc9068_valid_at_jwt_must_verify(...): async def test_...(...): ... -Tests that are not yet implemented should use ``pytest.xfail``:: +Tests that are not yet implemented should use ``pytest.xfail`` — these +appear as ``skipped`` in the generated report (pytest classifies ``xfail`` +outcomes as skips) so the suite stays green for known gaps while the gap +itself remains visible in the per-case status and the ``note`` field:: @pytest.mark.conformance("rfc9449-dpop-inbound-nonce-must-be-validated-when-required") async def test_...(...): @@ -33,17 +36,27 @@ async def test_...(...): from pathlib import Path from typing import Any +import pytest + import authplane _ROOT = Path(__file__).resolve().parents[1] +# Default layout: python-sdk and conformance cloned as siblings (see README +# `Catalog path` section). Contributors with a different layout override via +# AUTHPLANE_CONFORMANCE_CATALOG. _DEFAULT_CATALOG_PATH = _ROOT.parent / "conformance" / "oauth-sdk-conformance-catalog.yaml" _CATALOG_PATH = ( - Path(os.environ["CONFORMANCE_CATALOG_PATH"]) - if "CONFORMANCE_CATALOG_PATH" in os.environ - else Path(os.environ["AUTHPLANE_CONFORMANCE_CATALOG"]) + Path(os.environ["AUTHPLANE_CONFORMANCE_CATALOG"]) if "AUTHPLANE_CONFORMANCE_CATALOG" in os.environ else _DEFAULT_CATALOG_PATH ) +if not _CATALOG_PATH.exists(): + raise RuntimeError( + f"OAuth SDK conformance catalog not found at {_CATALOG_PATH}. " + "Clone https://github.com/AuthPlane/conformance as a sibling of this repo, " + "or set AUTHPLANE_CONFORMANCE_CATALOG to the absolute path of " + "oauth-sdk-conformance-catalog.yaml." + ) _REPORT_PATH = _ROOT / "conformance-report.json" _REPORT_MD_PATH = _ROOT / "conformance-report.md" _SOURCE = _ROOT / "tests" / "conftest.py" @@ -55,6 +68,9 @@ async def test_...(...): _catalog_version = "" _catalog_ids: list[str] = [] +# One test function ↔ one catalog case_id. Enforced at collection time by +# pytest_collection_modifyitems below — a duplicate marker fails fast instead +# of letting a passing sibling silently mask a failing one in the rollup. _results: dict[str, dict[str, Any]] = {} _uncatalogued_results: dict[str, dict[str, Any]] = {} @@ -234,10 +250,23 @@ def pytest_configure(config: Any) -> None: def pytest_collection_modifyitems(items: list[Any]) -> None: - """Build the case-id index from markers after collection.""" + """Build the case-id index from markers, and enforce that each catalog + case_id maps to at most one test function. Duplicates are a structural bug + (a passing sibling could mask a failing one in the report).""" + seen: dict[str, str] = {} for item in items: case_id, coverage = _extract_conformance_marker(item) _ITEM_CASE_MAP[item.nodeid] = (case_id, coverage) + if case_id is None: + continue + if case_id in seen: + raise pytest.UsageError( + f"@pytest.mark.conformance({case_id!r}) is declared on multiple " + f"test functions ({seen[case_id]} and {item.nodeid}). Each catalog " + "case maps to exactly one test — merge the assertions, or split " + "the catalog case." + ) + seen[case_id] = item.nodeid def pytest_runtest_logreport(report: Any) -> None: @@ -268,7 +297,8 @@ def pytest_runtest_logreport(report: Any) -> None: def pytest_sessionfinish(session: Any, exitstatus: int) -> None: report_cases: list[dict[str, Any]] = [] for case_id in _catalog_ids: - report_cases.append(_results.get(case_id, {"case_id": case_id, "status": "not_run"})) + case = _results.get(case_id) + report_cases.append(case if case is not None else {"case_id": case_id, "status": "not_run"}) summary = { "passed": sum(1 for case in report_cases if case["status"] == "passed"), diff --git a/conformance-tests/test_catalog_alignment.py b/conformance-tests/test_catalog_alignment.py index 2caca8d..73842c7 100644 --- a/conformance-tests/test_catalog_alignment.py +++ b/conformance-tests/test_catalog_alignment.py @@ -32,8 +32,8 @@ def test_catalog_case_ids_are_represented_in_conformance_tests() -> None: root = Path(__file__).resolve().parents[1] default_catalog_path = root.parent / "conformance" / "oauth-sdk-conformance-catalog.yaml" catalog_path = ( - Path(os.environ["CONFORMANCE_CATALOG_PATH"]) - if "CONFORMANCE_CATALOG_PATH" in os.environ + Path(os.environ["AUTHPLANE_CONFORMANCE_CATALOG"]) + if "AUTHPLANE_CONFORMANCE_CATALOG" in os.environ else default_catalog_path ) catalog_text = catalog_path.read_text(encoding="utf-8") diff --git a/conformance-tests/test_jwt_and_dpop_conformance.py b/conformance-tests/test_jwt_and_dpop_conformance.py index 7aafa70..59e63ee 100644 --- a/conformance-tests/test_jwt_and_dpop_conformance.py +++ b/conformance-tests/test_jwt_and_dpop_conformance.py @@ -25,20 +25,63 @@ DPoPReplayDetectedError, FetchSettings, InboundDPoPOptions, + InMemoryDPoPReplayStore, + InsufficientScopeError, InvalidClaimsError, InvalidDPoPProofError, InvalidSignatureError, + TokenExpiredError, + www_authenticate, ) from authplane.dpop_verification import verify_dpop_proof from authplane.internal.document_cache import JWKSCache from authplane.internal.fetch_result import FetchResult from authplane.internal.urls import build_prm_url from authplane.net.http import form_post +from authplane.net.ssrf import HttpResponse from authplane.oauth.prm import build_prm _NO_SSRF = FetchSettings(ssrf_protection=False) +def _stub_ssrf_post( + responses: list[HttpResponse], + *, + capture_headers: list[dict[str, str] | None] | None = None, +) -> Any: + """Build an async stub for ``authplane.net.http.ssrf_safe_post`` that + returns ``responses`` in order. ``capture_headers``, when provided, + is appended to with each call's ``extra_headers`` so the test can + assert on what the SDK sent. + + Replaces three near-identical 9-parameter stubs that were inlined into + the DPoP-nonce conformance tests. + """ + index = {"i": 0} + + async def fake_post( + url: str, + *, + form_data: dict[str, str] | None = None, + json_data: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + allow_http: bool = False, + allow_localhost: bool = False, + allow_private_networks: bool = False, + max_size: int = 65536, + timeout: float = 10.0, + ) -> HttpResponse: + del url, form_data, json_data + del allow_http, allow_localhost, allow_private_networks, max_size, timeout + if capture_headers is not None: + capture_headers.append(extra_headers) + i = index["i"] + index["i"] = i + 1 + return responses[i] + + return fake_post + + @dataclass class MemoryReplayStore: seen_jtis: set[str] = field(default_factory=lambda: set[str]()) @@ -232,9 +275,6 @@ async def test_rfc8725_allowed_jwt_algorithms_must_be_restricted( verifier: Any, token_factory: Any, ) -> None: - import base64 - import json - header = {"alg": "none", "typ": "at+jwt"} payload = { "iss": "https://auth.example.com", @@ -255,6 +295,9 @@ async def test_rfc8725_allowed_jwt_algorithms_must_be_restricted( with pytest.raises((InvalidClaimsError, InvalidSignatureError)): await verifier.verify(token) + # Reach into the verifier's internal client to assert the SDK rejects + # HS-family algorithms even when the caller tries to opt back in via + # the public AuthplaneClient.resource() API. client = verifier._client # pyright: ignore[reportAttributeAccessIssue] with pytest.raises(ValueError): client.resource(resource="https://api.example.com", allowed_algorithms=["HS256"]) @@ -498,33 +541,21 @@ async def test_rfc9449_dpop_nonce_challenge_must_trigger_single_retry( DPoPKeyMaterial.from_pem(jwks_keypair["private_key"], algorithm="ES256") ) calls: list[dict[str, str] | None] = [] - - async def fake_post( - url: str, - *, - form_data: dict[str, str] | None = None, - json_data: dict[str, Any] | None = None, - extra_headers: dict[str, str] | None = None, - allow_http: bool = False, - allow_localhost: bool = False, - allow_private_networks: bool = False, - max_size: int = 65536, - timeout: float = 10.0, - ) -> Any: - from authplane.net.ssrf import HttpResponse - - calls.append(extra_headers) - if len(calls) == 1: - return HttpResponse( + fake_post = _stub_ssrf_post( + [ + HttpResponse( body={"error": "use_dpop_nonce"}, headers={"DPoP-Nonce": "nonce-123"}, status_code=400, - ) - return HttpResponse( - body={"access_token": "ok", "token_type": "Bearer"}, headers={}, status_code=200 - ) - - from authplane.net.http import form_post + ), + HttpResponse( + body={"access_token": "ok", "token_type": "Bearer"}, + headers={}, + status_code=200, + ), + ], + capture_headers=calls, + ) with patch("authplane.net.http.ssrf_safe_post", side_effect=fake_post): result = await form_post( @@ -546,36 +577,15 @@ async def test_rfc9449_dpop_nonce_on_success_response_should_be_stored( provider = DPoPProvider( DPoPKeyMaterial.from_pem(jwks_keypair["private_key"], algorithm="ES256") ) - - async def fake_post( - url: str, - *, - form_data: dict[str, str] | None = None, - json_data: dict[str, Any] | None = None, - extra_headers: dict[str, str] | None = None, - allow_http: bool = False, - allow_localhost: bool = False, - allow_private_networks: bool = False, - max_size: int = 65536, - timeout: float = 10.0, - ) -> Any: - from authplane.net.ssrf import HttpResponse - - del ( - form_data, - json_data, - extra_headers, - allow_http, - allow_localhost, - allow_private_networks, - max_size, - timeout, - ) - return HttpResponse( - body={"access_token": "ok", "token_type": "Bearer"}, - headers={"DPoP-Nonce": "nonce-456"}, - status_code=200, - ) + fake_post = _stub_ssrf_post( + [ + HttpResponse( + body={"access_token": "ok", "token_type": "Bearer"}, + headers={"DPoP-Nonce": "nonce-456"}, + status_code=200, + ), + ], + ) with patch("authplane.net.http.ssrf_safe_post", side_effect=fake_post): result = await form_post( @@ -597,31 +607,22 @@ async def test_rfc9110_rfc9449_dpop_nonce_header_must_be_treated_case_insensitiv DPoPKeyMaterial.from_pem(jwks_keypair["private_key"], algorithm="ES256") ) calls: list[dict[str, str] | None] = [] - - async def fake_post( - url: str, - *, - form_data: dict[str, str] | None = None, - json_data: dict[str, Any] | None = None, - extra_headers: dict[str, str] | None = None, - allow_http: bool = False, - allow_localhost: bool = False, - allow_private_networks: bool = False, - max_size: int = 65536, - timeout: float = 10.0, - ) -> Any: - from authplane.net.ssrf import HttpResponse - - calls.append(extra_headers) - if len(calls) == 1: - return HttpResponse( + fake_post = _stub_ssrf_post( + [ + HttpResponse( body={"error": "use_dpop_nonce"}, + # Mixed-case header — the SDK must treat DPoP-Nonce case-insensitively. headers={"Dpop-Nonce": "nonce-123"}, status_code=400, - ) - return HttpResponse( - body={"access_token": "ok", "token_type": "Bearer"}, headers={}, status_code=200 - ) + ), + HttpResponse( + body={"access_token": "ok", "token_type": "Bearer"}, + headers={}, + status_code=200, + ), + ], + capture_headers=calls, + ) with patch("authplane.net.http.ssrf_safe_post", side_effect=fake_post): result = await form_post( @@ -1065,8 +1066,6 @@ async def test_rfc9449_dpop_proof_htm_must_be_case_sensitive( async def test_rfc9449_dpop_replay_store_must_evict_expired_entries() -> None: """The replay store should evict expired entries so memory does not grow unbounded under sustained traffic.""" - from authplane.dpop import InMemoryDPoPReplayStore - store = InMemoryDPoPReplayStore() # Store an entry that has already expired await store.check_and_store("proof-1", expires_at=0) @@ -1155,21 +1154,13 @@ async def test_authplane_agent_id_must_be_exposed_as_first_class_field( verifier: Any, token_factory: Any, ) -> None: - """VerifiedClaims.agent_id must surface the agent_id claim as a str.""" - token = token_factory(agent_id="agent-007") - claims = await verifier.verify(token) - assert claims.agent_id == "agent-007" + """VerifiedClaims.agent_id surfaces the agent_id claim as a str when present, + and defaults to '' when absent.""" + claims_present = await verifier.verify(token_factory(agent_id="agent-007")) + assert claims_present.agent_id == "agent-007" - -@pytest.mark.conformance("authplane-agent-id-must-be-exposed-as-first-class-field") -async def test_authplane_agent_id_defaults_to_empty_when_absent( - verifier: Any, - token_factory: Any, -) -> None: - """VerifiedClaims.agent_id defaults to '' when the claim is absent.""" - token = token_factory() - claims = await verifier.verify(token) - assert claims.agent_id == "" + claims_absent = await verifier.verify(token_factory()) + assert claims_absent.agent_id == "" @pytest.mark.conformance("authplane-agent-chain-must-be-exposed-as-first-class-field") @@ -1177,21 +1168,15 @@ async def test_authplane_agent_chain_must_be_exposed_as_first_class_field( verifier: Any, token_factory: Any, ) -> None: - """VerifiedClaims.agent_chain must surface the agent_chain claim as a tuple.""" - token = token_factory(agent_chain=["agent-1", "agent-2", "agent-3"]) - claims = await verifier.verify(token) - assert claims.agent_chain == ("agent-1", "agent-2", "agent-3") - + """VerifiedClaims.agent_chain surfaces the agent_chain claim as a tuple when + present, and defaults to () when absent.""" + claims_present = await verifier.verify( + token_factory(agent_chain=["agent-1", "agent-2", "agent-3"]) + ) + assert claims_present.agent_chain == ("agent-1", "agent-2", "agent-3") -@pytest.mark.conformance("authplane-agent-chain-must-be-exposed-as-first-class-field") -async def test_authplane_agent_chain_defaults_to_empty_when_absent( - verifier: Any, - token_factory: Any, -) -> None: - """VerifiedClaims.agent_chain defaults to () when the claim is absent.""" - token = token_factory() - claims = await verifier.verify(token) - assert claims.agent_chain == () + claims_absent = await verifier.verify(token_factory()) + assert claims_absent.agent_chain == () @pytest.mark.conformance("authplane-nbf-must-be-exposed-as-typed-field-on-verified-claims") @@ -1199,22 +1184,14 @@ async def test_authplane_nbf_must_be_exposed_as_typed_field_on_verified_claims( verifier: Any, token_factory: Any, ) -> None: - """VerifiedClaims.not_before must surface the nbf claim as an int.""" + """VerifiedClaims.not_before surfaces the nbf claim as an int when present, + and defaults to 0 when absent.""" now = int(time.time()) - token = token_factory(nbf=now) - claims = await verifier.verify(token) - assert claims.not_before == now + claims_present = await verifier.verify(token_factory(nbf=now)) + assert claims_present.not_before == now - -@pytest.mark.conformance("authplane-nbf-must-be-exposed-as-typed-field-on-verified-claims") -async def test_authplane_nbf_defaults_to_zero_when_absent( - verifier: Any, - token_factory: Any, -) -> None: - """VerifiedClaims.not_before defaults to 0 when nbf is absent.""" - token = token_factory(exclude_claims=["nbf"]) - claims = await verifier.verify(token) - assert claims.not_before == 0 + claims_absent = await verifier.verify(token_factory(exclude_claims=["nbf"])) + assert claims_absent.not_before == 0 # --------------------------------------------------------------------------- @@ -1226,14 +1203,6 @@ async def test_authplane_nbf_defaults_to_zero_when_absent( async def test_rfc6750_error_response_must_map_error_codes() -> None: """SDK must provide a www_authenticate() helper that maps errors to RFC 6750 §3.1 error codes (invalid_token, insufficient_scope).""" - from authplane.errors import ( - InsufficientScopeError, - InvalidDPoPProofError, - InvalidSignatureError, - TokenExpiredError, - www_authenticate, - ) - # Authentication failures → "invalid_token" result = www_authenticate(TokenExpiredError("expired")) assert result.startswith("Bearer ") @@ -1257,8 +1226,6 @@ async def test_rfc6750_error_response_must_map_error_codes() -> None: @pytest.mark.conformance("rfc6750-error-response-realm-should-be-included") async def test_rfc6750_error_response_realm_should_be_included() -> None: """When realm is provided, the WWW-Authenticate header must include it.""" - from authplane.errors import TokenExpiredError, www_authenticate - result = www_authenticate(TokenExpiredError("expired"), realm="https://api.example.com") assert 'realm="https://api.example.com"' in result diff --git a/conformance-tests/test_oauth_protocol_conformance.py b/conformance-tests/test_oauth_protocol_conformance.py index 9839023..cac8ce7 100644 --- a/conformance-tests/test_oauth_protocol_conformance.py +++ b/conformance-tests/test_oauth_protocol_conformance.py @@ -514,6 +514,21 @@ async def test_rfc8693_empty_resource_and_audience_values_must_be_omitted() -> N async def test_rfc8693_success_response_must_use_access_token_issued_token_type_when_present() -> ( None ): + # The catalog requires the SDK to (a) accept `access_token` as the issued + # type and (b) surface it unchanged to callers. Verifying the accept-and- + # preserve contract: + response = parse_token_response( + { + "access_token": "tok", + "token_type": "Bearer", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + }, + allow_issued_token_type=True, + ) + assert response.issued_token_type == "urn:ietf:params:oauth:token-type:access_token" + + # Any other issued_token_type must be rejected (kept from the original + # test — exercises the matching reject path). with pytest.raises(ProtocolError, match="unsupported issued_token_type"): parse_token_response( { diff --git a/conformance-tests/test_rfc8414_conformance.py b/conformance-tests/test_rfc8414_conformance.py index f56cdfe..576cbe7 100644 --- a/conformance-tests/test_rfc8414_conformance.py +++ b/conformance-tests/test_rfc8414_conformance.py @@ -7,6 +7,8 @@ from authplane import AuthplaneClient, FetchSettings from authplane.errors import MetadataFetchError, MissingMetadataEndpointError +from authplane.internal.fetch_result import FetchResult +from authplane.internal.metadata import MetadataCache from authplane.internal.urls import build_metadata_url _NO_SSRF = FetchSettings(ssrf_protection=False) @@ -39,9 +41,6 @@ async def test_rfc8414_metadata_issuer_must_match_configured_issuer( @pytest.mark.conformance("rfc8414-jwks-uri-required-for-jwt-validation") async def test_rfc8414_jwks_uri_required_for_jwt_validation() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult(document={"issuer": "https://auth.example.com"}) @@ -52,9 +51,6 @@ async def fetcher() -> FetchResult: @pytest.mark.conformance("rfc8414-metadata-must-contain-issuer") async def test_rfc8414_metadata_must_contain_issuer() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult(document={"jwks_uri": "https://auth.example.com/.well-known/jwks.json"}) @@ -65,9 +61,6 @@ async def fetcher() -> FetchResult: @pytest.mark.conformance("rfc8414-jwks-uri-must-be-absolute-https-url") async def test_rfc8414_jwks_uri_must_be_absolute_https_url() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult( document={ @@ -112,9 +105,6 @@ async def test_rfc8414_introspection_endpoint_required_when_introspection_is_use @pytest.mark.conformance("rfc8414-token-endpoint-required-when-token-operation-is-used") async def test_rfc8414_token_endpoint_required_when_token_operation_is_used() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult( document={ @@ -130,9 +120,6 @@ async def fetcher() -> FetchResult: @pytest.mark.conformance("rfc8414-revocation-endpoint-required-when-revocation-is-used") async def test_rfc8414_revocation_endpoint_required_when_revocation_is_used() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult( document={ @@ -148,9 +135,6 @@ async def fetcher() -> FetchResult: @pytest.mark.conformance("rfc8414-token-endpoint-must-be-absolute-https-url") async def test_rfc8414_token_endpoint_must_be_absolute_https_url() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult( document={ @@ -167,9 +151,6 @@ async def fetcher() -> FetchResult: @pytest.mark.conformance("rfc8414-introspection-endpoint-must-be-absolute-https-url") async def test_rfc8414_introspection_endpoint_must_be_absolute_https_url() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult( document={ @@ -186,9 +167,6 @@ async def fetcher() -> FetchResult: @pytest.mark.conformance("rfc8414-revocation-endpoint-must-be-absolute-https-url") async def test_rfc8414_revocation_endpoint_must_be_absolute_https_url() -> None: - from authplane.internal.fetch_result import FetchResult - from authplane.internal.metadata import MetadataCache - async def fetcher() -> FetchResult: return FetchResult( document={ @@ -270,6 +248,11 @@ async def test_rfc8414_jwks_uri_rotation_must_reconfigure_jwks_cache( "jwks_uri": "https://auth.example.com/jwks-v2.json", } + # Drive the rotation directly via the internal hook. A full end-to- + # end test would simulate a metadata-refresh tick, but the SDK + # exposes no public seam for that yet; using the private hook here + # is a deliberate trade-off. Follow-up: lift this to a public + # test seam when the metadata-cache lifecycle is refactored. await client._on_metadata_changed(old_metadata, new_metadata) # pyright: ignore[reportPrivateUsage] assert client._jwks_uri == "https://auth.example.com/jwks-v2.json" # pyright: ignore[reportPrivateUsage] finally: diff --git a/llm-full.txt b/llm-full.txt index 7db0377..90d6b8f 100644 --- a/llm-full.txt +++ b/llm-full.txt @@ -92,7 +92,8 @@ twine check dist/* - `await client.introspect(token)` — RFC 7662 - `await client.revoke(token)` — RFC 7009 - `await client.aclose()` — required on shutdown. -- `resource.prm_response()` — RFC 9728 PRM document. +- `resource.prm_response()` — RFC 9728 PRM document (dict body). +- `resource.prm_url()` — the well-known URL where clients fetch that document; pass to `response_headers_for(... , resource_metadata_url=...)`. ### Errors (root export) @@ -109,8 +110,27 @@ AS-facing: `AuthError`, `InvalidClientError`, `InvalidGrantError`, 6749 `invalid_grant`, `ConsentRequiredError` for `consent_required` / `interaction_required`. -`http_status(error)` maps any `AuthplaneError` to an HTTP status; 403 only -for `InsufficientScopeError`. +`http_status(error)` maps any `AuthplaneError` to an HTTP status: 403 only +for `InsufficientScopeError`; 503 for `JWKSFetchError`, `MetadataFetchError`, +and `CircuitOpenError` (temporary AS unavailability); 401 for token failures; +500 for `ProtocolError` / `VerifierRuntimeError` / unknown. + +`www_authenticate(error, *, realm="", resource_metadata_url=None, scope=None)` +builds the matching `WWW-Authenticate` header. Scheme: `DPoP` for `DPoPError` +subclasses except `DPoPNotSupportedError` (which stays `Bearer` because the +resource is bearer-only); `Bearer` otherwise. When `scope=` is omitted, it +auto-populates from `InsufficientScopeError.required_scopes`. Every +interpolated value is sanitized (`\r\n"\\` stripped) so attacker-influenced +error messages cannot break out of the quoted parameter or inject headers. + +`response_headers_for(error, *, realm="", resource_metadata_url=None, +scope=None)` returns `(status, {"WWW-Authenticate": challenge})` in one call. + +Both adapter verifiers log a `logging.DEBUG` event +`authplane.token_verification_failed` (logger `authplane_mcp.verifier` or +`authplane_fastmcp.verifier`) with structured `error_class` / `error` fields +before returning `None`, so operators can distinguish 401 causes in logs even +though the wire stays uniform. ### Adapters @@ -119,7 +139,9 @@ for `InsufficientScopeError`. `await result.aclose()`. - `await authplane_mcp_auth(issuer, resource, scopes=..., enforce_scopes_on_all_requests=False, ...)` (MCP) → `AuthplaneAuthResult` with `.token_verifier`, `.auth`, `.client`, and - `await result.aclose()`. + `await result.aclose()`. PRM advertises `scopes_supported` only when + `enforce_scopes_on_all_requests=True` (MCP-SDK limitation: no separate + "supported" field). The FastMCP adapter advertises whenever `scopes=` is set. - Both unpack into `FastMCP(...)` via `**result` (mapping view yields only the framework-required keys). - Both wrap `client.exchange()` so `ConsentRequiredError` with a @@ -147,7 +169,7 @@ async def main() -> None: @mcp.tool(auth=require_scopes("tools/query")) def query(sql: str) -> str: - return run_query(sql) + return f"Ran: {sql}" # replace with your real handler try: await mcp.run_async(transport="http", port=8080) @@ -161,25 +183,31 @@ asyncio.run(main()) ```python import asyncio + from authplane_mcp import authplane_mcp_auth, require_scope from mcp.server.fastmcp import FastMCP -auth_result = asyncio.run(authplane_mcp_auth( - issuer="https://auth.company.com", - resource="https://mcp.company.com", - scopes=["tools/query"], -)) -mcp = FastMCP("My Server", json_response=True, **auth_result) - -@mcp.tool() -async def query(sql: str) -> str: - require_scope("tools/query") - return run_query(sql) - -mcp.run(transport="streamable-http") -# Call ``await auth_result.aclose()`` on shutdown — mcp.run() owns the loop, -# so the cleanest pattern is to install a signal handler or wrap mcp.run() -# in your own supervisor that runs aclose() after it returns. + +async def main() -> None: + auth_result = await authplane_mcp_auth( + issuer="https://auth.company.com", + resource="https://mcp.company.com", + scopes=["tools/query"], + ) + mcp = FastMCP("My Server", port=8080, json_response=True, **auth_result) + + @mcp.tool() + async def query(sql: str) -> str: + require_scope("tools/query") + return f"Ran: {sql}" # replace with your real handler + + try: + await mcp.run_streamable_http_async() + finally: + await auth_result.aclose() + + +asyncio.run(main()) ``` ### Verify a token directly (framework-agnostic) @@ -200,12 +228,17 @@ await client.aclose() ### Map errors to HTTP ```python -from authplane import AuthplaneError, http_status +from authplane import AuthplaneError, response_headers_for try: claims = await res.verify(token) except AuthplaneError as e: - return Response(status=http_status(e)) + status, headers = response_headers_for( + e, + realm="api.example.com", + resource_metadata_url=res.prm_url(), + ) + return Response(status_code=status, headers=headers) ``` ## Demo Flows @@ -289,8 +322,10 @@ RFC 8693 token exchange goes through `client.exchange(TokenExchangeOptions(...)) import asyncio import os -from authplane import ASCredentials, AuthplaneClient, DPoPProvider +from authplane import ASCredentials, AuthplaneClient, DPoPKeyMaterial, DPoPProvider from authplane.oauth import TokenExchangeOptions +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec async def main() -> None: @@ -300,10 +335,17 @@ async def main() -> None: scope = os.getenv("DEMO_SCOPE", "tools/add") resource = os.getenv("RESOURCE_URL", "http://localhost:8080/mcp") + # Ephemeral EC key for DPoP proof-of-possession on outbound AS calls. + dpop_pem = ec.generate_private_key(ec.SECP256R1()).private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + client = await AuthplaneClient.create( issuer=issuer, auth=ASCredentials(client_id=client_id, client_secret=client_secret), - dpop=DPoPProvider(), + dpop=DPoPProvider(DPoPKeyMaterial.from_pem(dpop_pem)), dev_mode=True, # allow http://localhost issuer ) try: diff --git a/tests/net/test_ssrf.py b/tests/net/test_ssrf.py index c39212c..0a649e4 100644 --- a/tests/net/test_ssrf.py +++ b/tests/net/test_ssrf.py @@ -264,6 +264,117 @@ async def mock_aiter_bytes() -> AsyncGenerator[bytes, None]: assert call_args[0][1] == "https://1.2.3.4:443/.well-known/jwks.json" assert call_args[1]["headers"]["Host"] == "example.com" + @patch("authplane.net.ssrf.validate_url") + @patch("httpx.AsyncClient") + async def test_host_header_includes_non_default_port( + self, mock_client_class: MagicMock, mock_validate: AsyncMock + ) -> None: + """Non-default port must appear in the Host header (RFC 7230 §5.4).""" + mock_validate.return_value = ValidatedURL( + original_url="http://localhost:9000/foo", + hostname="localhost", + port=9000, + path="/foo", + resolved_ips=["127.0.0.1"], + ) + + mock_response = MagicMock() + mock_response.headers = {"content-length": "2"} + + async def mock_aiter_bytes() -> AsyncGenerator[bytes, None]: + yield b"{}" + + mock_response.aiter_bytes = mock_aiter_bytes + mock_response.status_code = 200 + + mock_stream_cm = AsyncMock() + mock_stream_cm.__aenter__.return_value = mock_response + mock_stream_cm.__aexit__.return_value = None + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream_cm) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + await ssrf_safe_get("http://localhost:9000/foo", allow_http=True, allow_localhost=True) + + assert mock_client.stream.call_args[1]["headers"]["Host"] == "localhost:9000" + + @patch("authplane.net.ssrf.validate_url") + @patch("httpx.AsyncClient") + async def test_host_header_strips_default_http_port( + self, mock_client_class: MagicMock, mock_validate: AsyncMock + ) -> None: + """Default HTTP port 80 must be omitted from the Host header.""" + mock_validate.return_value = ValidatedURL( + original_url="http://example.com/path", + hostname="example.com", + port=80, + path="/path", + resolved_ips=["1.2.3.4"], + ) + + mock_response = MagicMock() + mock_response.headers = {"content-length": "2"} + + async def mock_aiter_bytes() -> AsyncGenerator[bytes, None]: + yield b"{}" + + mock_response.aiter_bytes = mock_aiter_bytes + mock_response.status_code = 200 + + mock_stream_cm = AsyncMock() + mock_stream_cm.__aenter__.return_value = mock_response + mock_stream_cm.__aexit__.return_value = None + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream_cm) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + await ssrf_safe_get("http://example.com/path", allow_http=True) + + assert mock_client.stream.call_args[1]["headers"]["Host"] == "example.com" + + @patch("authplane.net.ssrf.validate_url") + @patch("httpx.AsyncClient") + async def test_host_header_brackets_ipv6_literal( + self, mock_client_class: MagicMock, mock_validate: AsyncMock + ) -> None: + """IPv6 hostnames must be bracketed in the Host header (RFC 3986 §3.2.2).""" + mock_validate.return_value = ValidatedURL( + original_url="http://[::1]:9000/foo", + hostname="::1", + port=9000, + path="/foo", + resolved_ips=["::1"], + ) + + mock_response = MagicMock() + mock_response.headers = {"content-length": "2"} + + async def mock_aiter_bytes() -> AsyncGenerator[bytes, None]: + yield b"{}" + + mock_response.aiter_bytes = mock_aiter_bytes + mock_response.status_code = 200 + + mock_stream_cm = AsyncMock() + mock_stream_cm.__aenter__.return_value = mock_response + mock_stream_cm.__aexit__.return_value = None + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream_cm) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + await ssrf_safe_get("http://[::1]:9000/foo", allow_http=True, allow_localhost=True) + + assert mock_client.stream.call_args[1]["headers"]["Host"] == "[::1]:9000" + @patch("authplane.net.ssrf.validate_url") @patch("httpx.AsyncClient") async def test_response_too_large_content_length( diff --git a/tests/test_errors.py b/tests/test_errors.py index be653ae..ce0ef22 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -9,17 +9,30 @@ ConsentRequiredError, DPoPBindingMismatchError, DPoPError, + DPoPNotSupportedError, DPoPProofMissingError, DPoPReplayDetectedError, InsufficientScopeError, + InvalidClaimsError, InvalidClientError, InvalidDPoPProofError, InvalidGrantError, InvalidRequestError, InvalidScopeError, + InvalidSignatureError, + JWKSFetchError, + MetadataFetchError, + ProtocolError, ServerError, + TokenExpiredError, + TokenMissingError, + TokenRevokedError, UnauthorizedClientError, UnsupportedGrantTypeError, + VerifierRuntimeError, + http_status, + response_headers_for, + www_authenticate, ) @@ -107,3 +120,224 @@ def test_insufficient_scope_is_not_an_auth_error() -> None: assert isinstance(error, AuthplaneError) assert not isinstance(error, AuthError) assert str(error) == "scope missing" + + +def test_insufficient_scope_required_scopes_default_empty() -> None: + # Backwards-compatible default: callers passing only a message still work + # and required_scopes is the empty tuple. + error = InsufficientScopeError("scope missing") + assert error.required_scopes == () + + +def test_insufficient_scope_required_scopes_preserved() -> None: + error = InsufficientScopeError("scope missing", required_scopes=("read", "write")) + assert error.required_scopes == ("read", "write") + + +# --------------------------------------------------------------------------- +# www_authenticate() — wire-format guarantees +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("error", "expected_scheme", "expected_error_code"), + [ + (TokenMissingError("missing"), "Bearer", "invalid_token"), + (TokenExpiredError("expired"), "Bearer", "invalid_token"), + (InvalidSignatureError("bad sig"), "Bearer", "invalid_token"), + (InvalidClaimsError("bad claims"), "Bearer", "invalid_token"), + (TokenRevokedError("revoked"), "Bearer", "invalid_token"), + (InsufficientScopeError("need scope"), "Bearer", "insufficient_scope"), + (DPoPProofMissingError("no proof"), "DPoP", "invalid_token"), + (InvalidDPoPProofError("bad proof"), "DPoP", "invalid_token"), + (DPoPReplayDetectedError("replay"), "DPoP", "invalid_token"), + (DPoPBindingMismatchError("binding"), "DPoP", "invalid_token"), + ], +) +def test_www_authenticate_scheme_and_error_code( + error: AuthplaneError, expected_scheme: str, expected_error_code: str +) -> None: + header = www_authenticate(error) + assert header.startswith(f"{expected_scheme} ") + assert f'error="{expected_error_code}"' in header + + +def test_www_authenticate_dpop_not_supported_uses_bearer_scheme() -> None: + # Regression: DPoPNotSupportedError subclasses DPoPError + # but the resource is bearer-only, so the challenge must advertise Bearer + # (a DPoP retry would just fail again). + header = www_authenticate(DPoPNotSupportedError("not supported")) + assert header.startswith("Bearer ") + assert "DPoP" not in header.split(" ", 1)[0] + + +def test_www_authenticate_includes_realm_when_provided() -> None: + header = www_authenticate(TokenExpiredError("expired"), realm="api.example.com") + assert 'realm="api.example.com"' in header + + +def test_www_authenticate_omits_realm_when_empty() -> None: + header = www_authenticate(TokenExpiredError("expired")) + assert "realm=" not in header + + +def test_www_authenticate_includes_resource_metadata_when_provided() -> None: + url = "https://resource.example.com/.well-known/oauth-protected-resource" + header = www_authenticate(TokenExpiredError("expired"), resource_metadata_url=url) + assert f'resource_metadata="{url}"' in header + + +def test_www_authenticate_omits_resource_metadata_when_absent() -> None: + header = www_authenticate(TokenExpiredError("expired")) + assert "resource_metadata=" not in header + + +def test_www_authenticate_explicit_scope_round_trips() -> None: + header = www_authenticate(InsufficientScopeError("need scopes"), scope=["read", "write"]) + assert 'scope="read write"' in header + + +def test_www_authenticate_scope_omitted_when_empty_list() -> None: + header = www_authenticate(InsufficientScopeError("need scopes"), scope=[]) + assert "scope=" not in header + + +def test_www_authenticate_auto_populates_scope_from_required_scopes() -> None: + # When the caller doesn't pass scope= but the error carries required_scopes, + # the helper emits scope= automatically. + error = InsufficientScopeError("missing 'admin'", required_scopes=("admin",)) + header = www_authenticate(error) + assert 'scope="admin"' in header + + +def test_www_authenticate_explicit_scope_overrides_required_scopes() -> None: + error = InsufficientScopeError("missing 'admin'", required_scopes=("admin",)) + header = www_authenticate(error, scope=["read", "write"]) + assert 'scope="read write"' in header + assert 'scope="admin"' not in header + + +def test_www_authenticate_no_scope_when_required_scopes_empty() -> None: + error = InsufficientScopeError("scope missing") + header = www_authenticate(error) + assert "scope=" not in header + + +@pytest.mark.parametrize( + "message", + [ + 'evil", error="invalid_token', # quote breaks out of param + "evil\r\nSet-Cookie: pwned=1", # CRLF header injection + "evil\nX-Injected: 1", # LF only + 'mix " and \\ chars', # quote + backslash combo + ], +) +def test_www_authenticate_sanitizes_error_description(message: str) -> None: + # Regression: error message must not break out of the + # quoted error_description parameter or inject additional headers. + header = www_authenticate(InvalidClaimsError(message)) + # CR/LF/quote/backslash are stripped from the emitted header value. + assert "\r" not in header + assert "\n" not in header + assert "\\" not in header + # Exactly one error_description parameter (no premature termination + reopen). + assert header.count('error_description="') == 1 + # The wire form keeps the quotes that bound our parameters, but no extras. + # 4 quote chars: error="...", error_description="..." + assert header.count('"') == 4 + + +def test_www_authenticate_sanitizes_realm() -> None: + header = www_authenticate(TokenExpiredError("expired"), realm='bad", error="injected') + assert '", error="injected' not in header + assert header.count('realm="') == 1 + + +def test_www_authenticate_sanitizes_resource_metadata_url() -> None: + header = www_authenticate( + TokenExpiredError("expired"), + resource_metadata_url='https://x.example/.well-known/r"\r\nX: 1', + ) + assert "\r" not in header + assert "\n" not in header + assert header.count('resource_metadata="') == 1 + + +def test_www_authenticate_sanitizes_scope_values() -> None: + header = www_authenticate( + InsufficientScopeError("nope"), + scope=['evil"', "ok"], + ) + assert '"' not in header.split('scope="', 1)[1].split('"', 1)[0] + + +# --------------------------------------------------------------------------- +# http_status() +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("error", "expected_status"), + [ + (InsufficientScopeError("nope"), 403), + (JWKSFetchError("jwks"), 503), + (MetadataFetchError("meta"), 503), + (CircuitOpenError("circuit open"), 503), + (TokenMissingError("missing"), 401), + (TokenExpiredError("expired"), 401), + (InvalidSignatureError("sig"), 401), + (InvalidClaimsError("claims"), 401), + (TokenRevokedError("revoked"), 401), + (DPoPProofMissingError("no proof"), 401), + (InvalidDPoPProofError("bad proof"), 401), + (DPoPReplayDetectedError("replay"), 401), + (DPoPBindingMismatchError("binding"), 401), + (DPoPNotSupportedError("not supported"), 401), + (ProtocolError("protocol"), 500), + (VerifierRuntimeError("runtime"), 500), + ], +) +def test_http_status_mapping(error: AuthplaneError, expected_status: int) -> None: + assert http_status(error) == expected_status + + +def test_http_status_unknown_authplane_error_defaults_to_500() -> None: + class _CustomError(AuthplaneError): + pass + + assert http_status(_CustomError("custom")) == 500 + + +# --------------------------------------------------------------------------- +# response_headers_for() — bundled helper +# --------------------------------------------------------------------------- + + +def test_response_headers_for_returns_status_and_challenge() -> None: + status, headers = response_headers_for(TokenExpiredError("expired")) + assert status == 401 + assert set(headers.keys()) == {"WWW-Authenticate"} + assert headers["WWW-Authenticate"].startswith("Bearer ") + assert 'error="invalid_token"' in headers["WWW-Authenticate"] + + +def test_response_headers_for_forwards_keyword_arguments() -> None: + status, headers = response_headers_for( + InsufficientScopeError("need", required_scopes=("admin",)), + realm="api", + resource_metadata_url="https://x/.well-known/oauth-protected-resource", + scope=["read", "write"], # explicit override of required_scopes + ) + assert status == 403 + challenge = headers["WWW-Authenticate"] + assert challenge.startswith("Bearer ") + assert 'realm="api"' in challenge + assert 'error="insufficient_scope"' in challenge + assert 'scope="read write"' in challenge + assert 'resource_metadata="https://x/.well-known/oauth-protected-resource"' in challenge + + +def test_response_headers_for_dpop_error_uses_dpop_scheme() -> None: + status, headers = response_headers_for(InvalidDPoPProofError("bad")) + assert status == 401 + assert headers["WWW-Authenticate"].startswith("DPoP ") diff --git a/tests/verifier/test_verifier.py b/tests/verifier/test_verifier.py index 353dc90..dcc604f 100644 --- a/tests/verifier/test_verifier.py +++ b/tests/verifier/test_verifier.py @@ -424,6 +424,18 @@ async def test_prm_response(verifier: AuthplaneResource) -> None: assert prm["bearer_methods_supported"] == ["header"] +async def test_prm_url_for_root_resource(verifier: AuthplaneResource) -> None: + # RFC 9728 §3: well-known suffix is appended directly when the resource + # has no path component. + assert verifier.prm_url() == "https://api.example.com/.well-known/oauth-protected-resource" + + +async def test_prm_url_for_path_resource(client: AuthplaneClient) -> None: + # RFC 9728 §3: the path component shifts behind the well-known segment. + resource = client.resource(resource="https://api.example.com/mcp", scopes=["read:data"]) + assert resource.prm_url() == "https://api.example.com/.well-known/oauth-protected-resource/mcp" + + async def test_prm_omits_dpop_fields_when_inbound_dpop_not_configured( client: AuthplaneClient, ) -> None: