From 245d31ae0974f257046a9582635f905fce4b7222 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 28 Jul 2026 19:58:49 -0300 Subject: [PATCH 1/2] fix: stop normalising resource and issuer identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8414 §3.3 and RFC 9728 §3.3 require the advertised issuer/resource to be identical to the configured value — a simple string comparison — and both well-known URLs are formed by inserting the well-known path segment into the identifier verbatim (RFC 8414 §3 / RFC 9728 §3). The SDK instead stripped slashes in five places: - AuthplaneClient.create rewrote the configured issuer with rstrip("/"). The rewritten value became the verifier's expected iss claim, so an AS whose issuer identifier legitimately ends in "/" had every token rejected (RFC 9068 requires iss to carry the slash). - build_prm_url and build_metadata_url used path.strip("/"), dropping the trailing slash the insertion rule requires be preserved; both are now pure insertion of the parsed path. - MetadataCache rstripped both sides of the issuer comparison, weakening the §3.3 identical-match MUST that defeats metadata substitution. Identifiers are now validated at construction instead (absolute http(s) URL with an authority, no fragment — RFC 8707 §2) via the new internal validate_identifier helper, and never transformed. Conformance: extends the rfc9728 well-known-path case with the trailing-slash resource datum and adds two issuer variants — metadata issuer differing only by a trailing slash is rejected, and a token whose iss matches a configured trailing-slash issuer verifies end to end (discovery at the trailing-slash well-known URL included). Migration: if a configured issuer or resource differs from the authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them. --- CHANGELOG.md | 6 +++ authplane/client.py | 6 ++- authplane/internal/__init__.py | 2 + authplane/internal/identifiers.py | 33 ++++++++++++++++ authplane/internal/metadata.py | 6 ++- authplane/internal/urls.py | 28 ++++++------- authplane/verifier/verifier.py | 6 ++- .../test_jwt_and_dpop_conformance.py | 39 ++++++++++++++++++- conformance-tests/test_rfc8414_conformance.py | 23 +++++++++++ tests/internal/test_metadata.py | 10 +++-- tests/internal/test_urls.py | 8 ++-- 11 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 authplane/internal/identifiers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c32b4..9f29728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Resource and issuer identifiers are never rewritten (RFC 8414 §3.3 / RFC 9728 §3.3 require the advertised value to be *identical* to the configured one). `build_prm_url` and `build_metadata_url` are formed by pure insertion, preserving the identifier's path exactly — including any trailing slash — and AS-metadata issuer comparison is now an exact string match. Identifiers are validated at construction (absolute http(s) URL with an authority and no fragment) and raise `ValueError` otherwise; trailing slashes, host case, and explicit ports are legal and preserved. **Migration**: if your configured issuer or resource differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them. + +### Fixed +- A configured issuer whose identifier legitimately ends in `/` no longer has every token rejected. The trailing slash was silently stripped at client creation and the stripped value compared against the token's `iss`, which RFC 9068 requires to carry the slash; discovery now also resolves the RFC 8414 well-known URL for such issuers correctly. + ## [0.3.0] - 2026-07-21 ### Added diff --git a/authplane/client.py b/authplane/client.py index e1dc630..92b014f 100644 --- a/authplane/client.py +++ b/authplane/client.py @@ -17,6 +17,7 @@ JWKSCache, MetadataCache, build_metadata_url, + validate_identifier, ) from .net import FetchSettings from .net.ssrf import SSRFError @@ -134,7 +135,10 @@ async def create( circuit trips. Default 30s. """ client = cls() - client._issuer = issuer.rstrip("/") + # RFC 8414 Section 3.3 — the issuer is an opaque identifier compared + # with simple string equality (against metadata and token iss); it is + # validated but never rewritten. + client._issuer = validate_identifier(issuer, "issuer") # Dev mode resolved_dev_mode = ( diff --git a/authplane/internal/__init__.py b/authplane/internal/__init__.py index a15075a..8e43c05 100644 --- a/authplane/internal/__init__.py +++ b/authplane/internal/__init__.py @@ -9,6 +9,7 @@ ) from .document_fetcher import DocumentFetcher from .fetch_result import FetchResult +from .identifiers import validate_identifier from .metadata import MetadataCache from .urls import build_metadata_url, build_prm_url @@ -23,4 +24,5 @@ "build_metadata_url", "build_prm_url", "parse_expires_at", + "validate_identifier", ] diff --git a/authplane/internal/identifiers.py b/authplane/internal/identifiers.py new file mode 100644 index 0000000..8c36382 --- /dev/null +++ b/authplane/internal/identifiers.py @@ -0,0 +1,33 @@ +"""Validation for resource and issuer identifiers. + +RFC 8414 Section 3.3 and RFC 9728 Section 3.3 require the advertised +issuer/resource to be identical to the configured value — a simple string +comparison, not RFC 3986 equivalence. The SDK therefore never rewrites an +identifier: well-known URLs are formed by inserting the well-known path +segment between the authority and the identifier's path (RFC 8414 Section 3 / +RFC 9728 Section 3), and validation rejects structurally unusable identifiers +instead of repairing them. +""" + +from urllib.parse import urlparse + + +def validate_identifier(value: str, label: str) -> str: + """Validate that *value* is an absolute http(s) URL identifier. + + The identifier must have an http or https scheme, an authority, and no + fragment (RFC 8707 Section 2 forbids fragments in resource identifiers). + Returns *value* unchanged — trailing slashes, host case, and explicit + ports are all legal identifier variations and are preserved verbatim. + + Raises: + ValueError: When the identifier is structurally invalid. + """ + parsed = urlparse(value) + if parsed.scheme not in ("https", "http"): + raise ValueError(f"{label} must be an absolute http or https URL: {value!r}") + if not parsed.netloc: + raise ValueError(f"{label} must include an authority: {value!r}") + if "#" in value: + raise ValueError(f"{label} must not contain a fragment: {value!r}") + return value diff --git a/authplane/internal/metadata.py b/authplane/internal/metadata.py index cb26a68..a303360 100644 --- a/authplane/internal/metadata.py +++ b/authplane/internal/metadata.py @@ -30,7 +30,7 @@ def __init__( on_change=on_change, error_factory=lambda msg: MetadataFetchError(msg), ) - self._expected_issuer = expected_issuer.rstrip("/") + self._expected_issuer = expected_issuer self._allow_http = allow_http def _validate_endpoint_url(self, field: str, value: str) -> None: @@ -50,9 +50,11 @@ def _validate_endpoint_url(self, field: str, value: str) -> None: ) def _validate_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: - issuer = str(metadata.get("issuer", "")).rstrip("/") + issuer = str(metadata.get("issuer", "")) if not issuer: raise MetadataFetchError("AS metadata missing required 'issuer' field") + # RFC 8414 Section 3.3 — the returned issuer MUST be identical to the + # configured one; simple string comparison, no normalisation. if self._expected_issuer and issuer != self._expected_issuer: raise MetadataFetchError( f"AS metadata issuer mismatch: expected {self._expected_issuer!r}, got {issuer!r}" diff --git a/authplane/internal/urls.py b/authplane/internal/urls.py index 3b13669..cac9b3a 100644 --- a/authplane/internal/urls.py +++ b/authplane/internal/urls.py @@ -12,6 +12,9 @@ def build_prm_url(resource: str) -> str: RFC 9728 Section 3: https://{host}/.well-known/oauth-protected-resource/{path} + The insertion is a pure string operation: the resource's path is preserved + exactly, including any trailing slash. + Examples: >>> build_prm_url("https://api.example.com") 'https://api.example.com/.well-known/oauth-protected-resource' @@ -22,6 +25,9 @@ def build_prm_url(resource: str) -> str: >>> build_prm_url("https://api.example.com/v2/mcp") 'https://api.example.com/.well-known/oauth-protected-resource/v2/mcp' + >>> build_prm_url("https://api.example.com/mcp/") + 'https://api.example.com/.well-known/oauth-protected-resource/mcp/' + Args: resource: The resource server URI. @@ -29,12 +35,7 @@ def build_prm_url(resource: str) -> str: The fully constructed PRM discovery URL. """ parsed = urlparse(resource) - path = parsed.path.strip("/") - - if path: - well_known_path = f"/.well-known/oauth-protected-resource/{path}" - else: - well_known_path = "/.well-known/oauth-protected-resource" + well_known_path = "/.well-known/oauth-protected-resource" + parsed.path return urlunparse( ( @@ -57,6 +58,9 @@ def build_metadata_url(issuer: str) -> str: RFC 8414 Section 3: https://{host}/.well-known/oauth-authorization-server/{path} + The insertion is a pure string operation: the issuer's path is preserved + exactly, including any trailing slash. + Examples: >>> build_metadata_url("https://auth.example.com") 'https://auth.example.com/.well-known/oauth-authorization-server' @@ -67,6 +71,9 @@ def build_metadata_url(issuer: str) -> str: >>> build_metadata_url("https://auth.example.com/org/tenant1") 'https://auth.example.com/.well-known/oauth-authorization-server/org/tenant1' + >>> build_metadata_url("https://auth.example.com/tenant1/") + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1/' + Args: issuer: The OAuth 2.1 authorization server issuer URL. @@ -74,14 +81,7 @@ def build_metadata_url(issuer: str) -> str: The fully constructed metadata discovery URL. """ parsed = urlparse(issuer) - - # Strip leading/trailing slashes from the path to normalize - path = parsed.path.strip("/") - - if path: - well_known_path = f"/.well-known/oauth-authorization-server/{path}" - else: - well_known_path = "/.well-known/oauth-authorization-server" + well_known_path = "/.well-known/oauth-authorization-server" + parsed.path return urlunparse( ( diff --git a/authplane/verifier/verifier.py b/authplane/verifier/verifier.py index ebd7db2..5e3604b 100644 --- a/authplane/verifier/verifier.py +++ b/authplane/verifier/verifier.py @@ -35,6 +35,7 @@ TokenRevokedError, VerifierRuntimeError, ) +from ..internal.identifiers import validate_identifier from ..internal.jwt import decode_jwt_header from ..internal.urls import build_prm_url from ..oauth.prm import build_prm @@ -71,7 +72,10 @@ def __init__( ) self._client = client - self._resource = resource + # RFC 8707 Section 2 — the resource identifier is opaque; validated for + # structure, never rewritten (it is compared verbatim against aud and + # advertised verbatim in PRM). + self._resource = validate_identifier(resource, "resource") self._scopes = tuple(scopes) self._allowed_algorithms = allowed_algorithms self._clock_skew_seconds = clock_skew_seconds diff --git a/conformance-tests/test_jwt_and_dpop_conformance.py b/conformance-tests/test_jwt_and_dpop_conformance.py index 59e63ee..050c62e 100644 --- a/conformance-tests/test_jwt_and_dpop_conformance.py +++ b/conformance-tests/test_jwt_and_dpop_conformance.py @@ -166,10 +166,42 @@ async def test_rfc9068_typ_must_be_at_jwt(verifier: Any, token_factory: Any) -> @pytest.mark.conformance("rfc9068-issuer-must-match") -async def test_rfc9068_issuer_must_match(verifier: Any, token_factory: Any) -> None: +async def test_rfc9068_issuer_must_match( + verifier: Any, token_factory: Any, jwks_keypair: dict[str, Any] +) -> None: with pytest.raises(InvalidClaimsError): await verifier.verify(token_factory(iss="https://wrong-issuer.com")) + # Variant: a token whose iss is identical to a configured trailing-slash + # issuer must verify — the issuer is never rewritten, end to end: + # discovery resolves the trailing-slash well-known URL (RFC 8414 §3), the + # advertised issuer matches exactly (§3.3), and the token's iss matches + # exactly (RFC 9068 §4). + slash_issuer = "https://auth.example.com/" + with respx.mock: + respx.get("https://auth.example.com/.well-known/oauth-authorization-server/").mock( + return_value=respx.MockResponse( + 200, + json={ + "issuer": slash_issuer, + "jwks_uri": "https://auth.example.com/.well-known/jwks.json", + }, + ) + ) + respx.get("https://auth.example.com/.well-known/jwks.json").mock( + return_value=respx.MockResponse(200, json=jwks_keypair["jwks"]) + ) + + client = await AuthplaneClient.create(issuer=slash_issuer, fetch_settings=_NO_SSRF) + try: + slash_verifier = client.resource( + resource="https://api.example.com", scopes=["read:data"] + ) + claims = await slash_verifier.verify(token_factory(iss=slash_issuer)) + assert claims.issuer == slash_issuer + finally: + await client.aclose() + @pytest.mark.conformance("rfc9068-audience-must-match-resource") async def test_rfc9068_audience_must_match_resource(verifier: Any, token_factory: Any) -> None: @@ -1113,6 +1145,11 @@ async def test_rfc9728_well_known_path_must_derive_from_resource_uri() -> None: build_prm_url("https://api.example.com/v2/mcp") == "https://api.example.com/.well-known/oauth-protected-resource/v2/mcp" ) + # RFC 9728 §3 insertion preserves the path exactly, including a trailing slash. + assert ( + build_prm_url("https://api.example.com/mcp/") + == "https://api.example.com/.well-known/oauth-protected-resource/mcp/" + ) @pytest.mark.conformance("rfc9728-prm-must-contain-required-fields") diff --git a/conformance-tests/test_rfc8414_conformance.py b/conformance-tests/test_rfc8414_conformance.py index 576cbe7..e28815c 100644 --- a/conformance-tests/test_rfc8414_conformance.py +++ b/conformance-tests/test_rfc8414_conformance.py @@ -38,6 +38,29 @@ async def test_rfc8414_metadata_issuer_must_match_configured_issuer( fetch_settings=_NO_SSRF, ) + # Variant: a trailing-slash difference is equivalent per RFC 3986 §6.2.3 + # but not identical — RFC 8414 §3.3 requires identity, so it must be + # rejected. + with respx.mock: + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + return_value=respx.MockResponse( + 200, + json={ + "issuer": "https://auth.example.com/", + "jwks_uri": "https://auth.example.com/.well-known/jwks.json", + }, + ) + ) + respx.get("https://auth.example.com/.well-known/jwks.json").mock( + return_value=respx.MockResponse(200, json=jwks_keypair["jwks"]) + ) + + with pytest.raises(MetadataFetchError, match="issuer mismatch"): + await AuthplaneClient.create( + issuer="https://auth.example.com", + fetch_settings=_NO_SSRF, + ) + @pytest.mark.conformance("rfc8414-jwks-uri-required-for-jwt-validation") async def test_rfc8414_jwks_uri_required_for_jwt_validation() -> None: diff --git a/tests/internal/test_metadata.py b/tests/internal/test_metadata.py index dafe6d6..9570c0f 100644 --- a/tests/internal/test_metadata.py +++ b/tests/internal/test_metadata.py @@ -88,7 +88,10 @@ async def test_get_jwks_uri_missing_field() -> None: await cache.get_jwks_uri() -async def test_expected_issuer_trailing_slash_is_normalized() -> None: +async def test_expected_issuer_trailing_slash_mismatch_raises() -> None: + # RFC 8414 §3.3 — the returned issuer must be identical to the configured + # one. A trailing-slash difference is equivalent per RFC 3986 §6.2.3 but + # not identical. fetcher = TrackingFetcher(metadata=SAMPLE_METADATA) cache = MetadataCache( fetcher, @@ -96,9 +99,8 @@ async def test_expected_issuer_trailing_slash_is_normalized() -> None: document_type="metadata", ) - metadata = await cache.get() - - assert metadata["issuer"] == "https://auth.example.com" + with pytest.raises(MetadataFetchError, match="issuer mismatch"): + await cache.get() # --------------------------------------------------------------------------- diff --git a/tests/internal/test_urls.py b/tests/internal/test_urls.py index 94e3552..7cb05fb 100644 --- a/tests/internal/test_urls.py +++ b/tests/internal/test_urls.py @@ -24,14 +24,14 @@ def test_issuer_with_multi_segment_path(self) -> None: ) def test_issuer_with_trailing_slash(self) -> None: - """Trailing slash on issuer is normalized away.""" + """RFC 8414 §3 insertion preserves the issuer's path (here "/") verbatim.""" result = build_metadata_url("https://auth.example.com/") - assert result == "https://auth.example.com/.well-known/oauth-authorization-server" + assert result == "https://auth.example.com/.well-known/oauth-authorization-server/" def test_issuer_with_path_and_trailing_slash(self) -> None: - """Trailing slash on path issuer is normalized.""" + """A trailing slash on the issuer path is preserved, not normalized.""" result = build_metadata_url("https://auth.example.com/tenant1/") - assert result == "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" + assert result == "https://auth.example.com/.well-known/oauth-authorization-server/tenant1/" def test_issuer_with_port(self) -> None: """Issuer with explicit port is preserved.""" From 73fdb568e5b2bdf7bffe68c941197b4328cf048e Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Wed, 29 Jul 2026 11:19:00 -0300 Subject: [PATCH 2/2] chore: reformat docs code blocks for current ruff ruff's newer formatter changed how it renders code blocks embedded in Markdown; CI installs the latest ruff, so the format check fails on files this branch does not otherwise touch. Formatting-only, no content changes. Expected to become a no-op once the in-flight change that already carries this reformatting lands on main. --- README.md | 2 ++ authplane-fastmcp/README.md | 4 +--- authplane-fastmcp/docs/user-guide.md | 35 ++++++++++++++++++---------- authplane-mcp/docs/user-guide.md | 20 +++++++++++----- authplane/docs/user-guide.md | 20 +++++++++------- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 83c55c4..2e339c5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ from authplane_fastmcp import authplane_auth from fastmcp import FastMCP from fastmcp.server.auth import require_scopes + async def main() -> None: result = await authplane_auth( issuer="https://auth.company.com", @@ -37,6 +38,7 @@ async def main() -> None: finally: await result.aclose() + asyncio.run(main()) ``` diff --git a/authplane-fastmcp/README.md b/authplane-fastmcp/README.md index 310db36..f061e60 100644 --- a/authplane-fastmcp/README.md +++ b/authplane-fastmcp/README.md @@ -32,9 +32,7 @@ async def main(): ) @mcp.tool(auth=require_scopes("tools/query")) - async def query_database( - query: str, token: AccessToken = CurrentAccessToken() - ) -> str: + async def query_database(query: str, token: AccessToken = CurrentAccessToken()) -> str: user_id = token.claims.get("sub") return f"Query: {query}, User: {user_id}" diff --git a/authplane-fastmcp/docs/user-guide.md b/authplane-fastmcp/docs/user-guide.md index 28699f0..083545c 100644 --- a/authplane-fastmcp/docs/user-guide.md +++ b/authplane-fastmcp/docs/user-guide.md @@ -37,6 +37,7 @@ import asyncio from fastmcp import FastMCP from authplane_fastmcp import authplane_auth + async def main() -> None: result = await authplane_auth( issuer="https://auth.company.com", @@ -55,6 +56,7 @@ async def main() -> None: finally: await result.aclose() + asyncio.run(main()) ``` @@ -98,11 +100,13 @@ Use FastMCP's built-in `require_scopes` decorator to enforce per-tool scope requ ```python 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 f"Ran: {sql}" # replace with your real handler + @mcp.tool(auth=require_scopes("tools/admin", "tools/delete")) def delete_all() -> str: """Requires BOTH tools/admin AND tools/delete scopes.""" @@ -119,21 +123,22 @@ FastMCP enforces scopes **before** the handler runs by **filtering tools the cal from fastmcp.dependencies import CurrentAccessToken from fastmcp.server.auth import AccessToken + @mcp.tool() async def my_tool(data: str, token: AccessToken = CurrentAccessToken()) -> str: # Standard JWT claims - sub = token.claims.get("sub") # Subject (user ID) - jti = token.claims.get("jti") # JWT ID - iss = token.claims.get("iss") # Issuer - aud = token.claims.get("aud") # Audience - exp = token.claims.get("exp") # Expiration (Unix timestamp) - nbf = token.claims.get("nbf") # Not before - iat = token.claims.get("iat") # Issued at + sub = token.claims.get("sub") # Subject (user ID) + jti = token.claims.get("jti") # JWT ID + iss = token.claims.get("iss") # Issuer + aud = token.claims.get("aud") # Audience + exp = token.claims.get("exp") # Expiration (Unix timestamp) + nbf = token.claims.get("nbf") # Not before + iat = token.claims.get("iat") # Issued at # OAuth claims - client_id = token.client_id # Client ID - scopes = token.scopes # List of granted scopes - expires_at = token.expires_at # Expiration (Unix timestamp) + client_id = token.client_id # Client ID + scopes = token.scopes # List of granted scopes + expires_at = token.expires_at # Expiration (Unix timestamp) # Custom claims tenant = token.claims.get("tenant_id") @@ -149,6 +154,7 @@ The `claims` dict contains the **full JWT payload** including all standard and c ```python from fastmcp.server.dependencies import get_access_token + @mcp.tool() async def my_tool(data: str) -> str: token = get_access_token() # Returns None if unauthenticated @@ -220,10 +226,12 @@ Implement your own revocation logic with an async callable: ```python from authplane import VerifiedClaims + async def check_blocklist(claims: VerifiedClaims, raw_token: str) -> bool: """Return True to reject the token (it is revoked).""" return await redis_client.sismember("revoked_tokens", claims.jti) + await authplane_auth( issuer="https://auth.company.com", base_url="https://mcp.company.com", @@ -253,8 +261,8 @@ result = await authplane_auth( downstream = await result.client.exchange( TokenExchangeOptions( subject_token=inbound_token, - scope="tools/add", # narrow to the minimum - resources=("https://downstream.example",), # RFC 8707 audience binding + scope="tools/add", # narrow to the minimum + resources=("https://downstream.example",), # RFC 8707 audience binding ) ) @@ -287,6 +295,7 @@ 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: try: @@ -379,6 +388,7 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J ```python import asyncio + async def main() -> None: result = await authplane_auth(...) try: @@ -387,6 +397,7 @@ async def main() -> None: finally: await result.aclose() + asyncio.run(main()) ``` diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index 76b852b..ade4a31 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -101,12 +101,14 @@ Use the `require_scope()` helper at the top of tool handlers to enforce per-tool ```python from authplane_mcp import require_scope + @mcp.tool() async def query(sql: str) -> str: """Requires the tools/query scope.""" require_scope("tools/query") return f"Ran: {sql}" # replace with your real handler + @mcp.tool() async def delete_all() -> str: """Requires the tools/admin scope.""" @@ -127,14 +129,15 @@ Use the MCP SDK's `get_access_token()` to access the validated token in tool han ```python from mcp.server.auth.middleware.auth_context import get_access_token + @mcp.tool() async def my_tool(data: str) -> str: token = get_access_token() if token: - client_id = token.client_id # Client ID - scopes = token.scopes # List of granted scopes - expires_at = token.expires_at # Expiration (Unix timestamp) - resource = token.resource # Resource (audience) URL + client_id = token.client_id # Client ID + scopes = token.scopes # List of granted scopes + expires_at = token.expires_at # Expiration (Unix timestamp) + resource = token.resource # Resource (audience) URL return f"Processing {data}" ``` @@ -211,10 +214,12 @@ Implement your own revocation logic with an async callable: ```python from authplane import VerifiedClaims + async def check_blocklist(claims: VerifiedClaims, raw_token: str) -> bool: """Return True to reject the token (it is revoked).""" return await redis_client.sismember("revoked_tokens", claims.jti) + await authplane_mcp_auth( issuer="https://auth.company.com", resource="https://mcp.company.com", @@ -244,8 +249,8 @@ result = await authplane_mcp_auth( downstream = await result.client.exchange( TokenExchangeOptions( subject_token=inbound_token, - scope="tools/add", # narrow to the minimum - resources=("https://downstream.example",), # RFC 8707 audience binding + scope="tools/add", # narrow to the minimum + resources=("https://downstream.example",), # RFC 8707 audience binding ) ) @@ -278,6 +283,7 @@ The adapter handles this for you. The `client` returned by `authplane_mcp_auth(. ```python from authplane.oauth import TokenExchangeOptions + @mcp.tool() async def call_downstream(user_token: str, payload: str) -> str: downstream = await result.client.exchange( @@ -379,6 +385,7 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J ```python import asyncio + async def main() -> None: auth_result = await authplane_mcp_auth(...) try: @@ -387,6 +394,7 @@ async def main() -> None: finally: await auth_result.aclose() + asyncio.run(main()) ``` diff --git a/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index 7925063..5a1146a 100644 --- a/authplane/docs/user-guide.md +++ b/authplane/docs/user-guide.md @@ -137,11 +137,11 @@ res = client.resource( resource="https://api.example.com", scopes=["read"], inbound_dpop=InboundDPoPOptions( - replay_store=InMemoryDPoPReplayStore(), # process-scoped by default + replay_store=InMemoryDPoPReplayStore(), # process-scoped by default max_proof_age_seconds=300, clock_skew_seconds=30, allowed_proof_algorithms=("RS256", "ES256"), - required=True, # reject bearer-only tokens + required=True, # reject bearer-only tokens ), ) ``` @@ -159,13 +159,16 @@ For each incoming request that may carry a DPoP-bound token, build a ```python from dataclasses import dataclass + @dataclass class IncomingRequest: """Implements DPoPRequestContext.""" + method: str url: str proof: str | None + claims = await res.verify( token, dpop_request=IncomingRequest( @@ -261,6 +264,7 @@ res = client.resource( ```python from authplane import VerifiedClaims + async def my_revocation_checker(claims: VerifiedClaims, raw_token: str) -> bool: return claims.jti in revoked_jtis ``` @@ -415,12 +419,12 @@ For multi-instance or shared-state deployments, provide your own `DPoPNonceStore ```python from authplane import DPoPKeyMaterial, DPoPNonceStore, DPoPProvider + class MyNonceStore: - def get(self, key: str) -> str: - ... + def get(self, key: str) -> str: ... + + def put(self, key: str, nonce: str) -> None: ... - def put(self, key: str, nonce: str) -> None: - ... provider = DPoPProvider( DPoPKeyMaterial.from_pem(private_key_pem), @@ -597,8 +601,8 @@ except AuthplaneError as e: Generate an RFC 9728 protected resource metadata document with: ```python -prm = res.prm_response() # the document body (a dict) -url = res.prm_url() # the well-known URL where clients can fetch it +prm = res.prm_response() # the document body (a dict) +url = res.prm_url() # the well-known URL where clients can fetch it ``` Example output: