From eea5567e1e07d752dcbabda96ea39e66173381a8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 20 Jul 2026 17:22:03 -0700 Subject: [PATCH 1/4] docs(keycardai-starlette): set audience= in Quick Start examples The example app sets audience= with an RFC 8707 comment, but the README Quick Start and the module docstrings (the actual copy-paste path) omitted it. An unset audience silently disables audience validation in the shared verifier, so servers built from the Quick Start accepted tokens minted for any resource in the zone. Show audience= in every Quick Start example and state plainly that leaving it unset disables the audience check. Co-Authored-By: Claude Fable 5 --- packages/starlette/README.md | 6 ++++++ packages/starlette/src/keycardai/starlette/__init__.py | 6 ++++++ packages/starlette/src/keycardai/starlette/provider.py | 7 ++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/starlette/README.md b/packages/starlette/README.md index 91155985..7c605649 100644 --- a/packages/starlette/README.md +++ b/packages/starlette/README.md @@ -21,6 +21,9 @@ from keycardai.oauth.server import AccessContext, ClientSecret auth = AuthProvider( zone_id="your-zone-id", application_credential=ClientSecret(("client_id", "client_secret")), + # Resource indicator (RFC 8707) Keycard mints tokens for. The verifier + # rejects tokens whose "aud" claim does not include this value. + audience="https://your-api.example.com", ) app = FastAPI() @@ -43,6 +46,9 @@ async def get_data(request: Request, access: AccessContext): token = access.access("https://api.example.com").access_token ``` +Leaving `audience` unset disables the audience check: the verifier accepts any +token minted by the zone regardless of its `aud` claim. + ## How it integrates with Starlette `AuthProvider.install(app)` does two things: diff --git a/packages/starlette/src/keycardai/starlette/__init__.py b/packages/starlette/src/keycardai/starlette/__init__.py index ce223d4c..9148a74c 100644 --- a/packages/starlette/src/keycardai/starlette/__init__.py +++ b/packages/starlette/src/keycardai/starlette/__init__.py @@ -17,6 +17,9 @@ auth = AuthProvider( zone_id="your-zone-id", application_credential=ClientSecret(("client_id", "client_secret")), + # Resource indicator (RFC 8707) Keycard mints tokens for. The verifier + # rejects tokens whose "aud" claim does not include this value. + audience="https://your-api.example.com", ) app = FastAPI() @@ -37,6 +40,9 @@ async def me(request: Request): @auth.grant("https://api.example.com") async def get_data(request: Request, access: AccessContext): token = access.access("https://api.example.com").access_token + +Leaving ``audience`` unset disables the audience check: the verifier accepts +any token minted by the zone regardless of its ``aud`` claim. """ from .authorization import grant, requires diff --git a/packages/starlette/src/keycardai/starlette/provider.py b/packages/starlette/src/keycardai/starlette/provider.py index d89bf80e..ea51b98d 100644 --- a/packages/starlette/src/keycardai/starlette/provider.py +++ b/packages/starlette/src/keycardai/starlette/provider.py @@ -20,6 +20,9 @@ auth = AuthProvider( zone_id="your-zone-id", application_credential=ClientSecret(("client_id", "client_secret")), + # Resource indicator (RFC 8707) Keycard mints tokens for. The verifier + # rejects tokens whose "aud" claim does not include this value. + audience="https://your-api.example.com", ) app = FastAPI() @@ -106,7 +109,9 @@ def __init__( this should be the top-level domain. server_name: Human-readable name for the server. required_scopes: Required scopes for token validation. - audience: Expected token audience for verification. + audience: Expected token audience for verification. When left + unset (None), the audience check is disabled and tokens + verify regardless of their ``aud`` claim. server_url: Resource server URL. enable_multi_zone: Enable multi-zone support where zone_url is the top-level domain and the zone is extracted from request From a7efbbb4d4dd6ecc8eed2156ab16d1687e5a3eef Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 20 Jul 2026 17:23:47 -0700 Subject: [PATCH 2/4] fix(keycardai-starlette): add RFC 6750 scope attribute to insufficient_scope challenge The 403 insufficient_scope challenge told clients they were missing scopes without saying which ones. RFC 6750 section 3 defines the scope attribute as the space-delimited list of scopes needed to access the protected resource, which lets clients re-request authorization with the right scopes. The challenge now carries scope="..." built from the @requires(...) scope list. The synthetic "authenticated" gating scope is excluded since it is a Starlette convention, not an OAuth scope a client can request. Existing challenge attributes (error, error_description, resource_metadata) are unchanged. Co-Authored-By: Claude Fable 5 --- .../src/keycardai/starlette/authorization.py | 8 +++++ .../keycardai/starlette/middleware/bearer.py | 28 +++++++++++----- .../keycardai/starlette/test_provider.py | 32 +++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/starlette/src/keycardai/starlette/authorization.py b/packages/starlette/src/keycardai/starlette/authorization.py index 31ced40e..f268838f 100644 --- a/packages/starlette/src/keycardai/starlette/authorization.py +++ b/packages/starlette/src/keycardai/starlette/authorization.py @@ -64,6 +64,7 @@ def requires( - If the user is authenticated but missing one of the required scopes, returns a 403 (configurable via ``status_code``) carrying an RFC 6750 ``WWW-Authenticate: Bearer error="insufficient_scope"`` challenge with + the ``scope=`` attribute listing the required scopes (RFC 6750 §3) and the ``resource_metadata=`` URL (RFC 9728). The ``redirect`` argument from stock ``requires`` is intentionally @@ -71,6 +72,11 @@ def requires( resources. """ scopes_list = [scopes] if isinstance(scopes, str) else list(scopes) + # RFC 6750 §3 scope attribute for insufficient_scope challenges. The + # synthetic "authenticated" scope is a Starlette gating convention + # (always present on verified requests), not an OAuth scope a client + # can request from the authorization server, so it is excluded. + challenge_scope = " ".join(s for s in scopes_list if s != "authenticated") or None def decorator(func: Callable[..., Any]) -> Callable[..., Any]: sig = inspect.signature(func) @@ -106,6 +112,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: error="insufficient_scope", description="Insufficient scope", status_code=status_code, + scope=challenge_scope, ) return await func(*args, **kwargs) @@ -130,6 +137,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: error="insufficient_scope", description="Insufficient scope", status_code=status_code, + scope=challenge_scope, ) return func(*args, **kwargs) diff --git a/packages/starlette/src/keycardai/starlette/middleware/bearer.py b/packages/starlette/src/keycardai/starlette/middleware/bearer.py index 942d113b..544aadd7 100644 --- a/packages/starlette/src/keycardai/starlette/middleware/bearer.py +++ b/packages/starlette/src/keycardai/starlette/middleware/bearer.py @@ -77,12 +77,22 @@ def _get_bearer_token(conn: HTTPConnection | Request) -> str | None: return parts[1] -def _build_challenge_header(error: str, description: str, resource_metadata: str) -> str: - return ( - f'Bearer error="{error}", ' - f'error_description="{description}", ' - f'resource_metadata="{resource_metadata}"' - ) +def _build_challenge_header( + error: str, + description: str, + resource_metadata: str, + scope: str | None = None, +) -> str: + parts = [ + f'error="{error}"', + f'error_description="{description}"', + ] + if scope: + # RFC 6750 §3: space-delimited list of scopes needed to access the + # protected resource. Sent with insufficient_scope challenges. + parts.append(f'scope="{scope}"') + parts.append(f'resource_metadata="{resource_metadata}"') + return "Bearer " + ", ".join(parts) class KeycardAuthError(AuthenticationError): @@ -274,13 +284,15 @@ def _build_unauthorized_response( error: str = "invalid_token", description: str = "Authentication required", status_code: int = 401, + scope: str | None = None, ) -> Response: """Build an RFC 6750 ``WWW-Authenticate`` challenge response. Used by ``keycard_on_error`` (when the authentication backend raises) and by the ``@requires`` / ``@auth.grant`` decorators (when the request is anonymous). The ``resource_metadata=`` URL is computed from the request - per RFC 9728. + per RFC 9728. ``scope`` carries the RFC 6750 §3 space-delimited list of + required scopes for ``insufficient_scope`` challenges. """ if status_code >= 500: # Verification could not complete (e.g. the JWKS endpoint was @@ -295,7 +307,7 @@ def _build_unauthorized_response( status_code=status_code, ) response.headers["WWW-Authenticate"] = _build_challenge_header( - error, description, resource_metadata + error, description, resource_metadata, scope=scope ) return response diff --git a/packages/starlette/tests/keycardai/starlette/test_provider.py b/packages/starlette/tests/keycardai/starlette/test_provider.py index 19d1efba..ad4d175e 100644 --- a/packages/starlette/tests/keycardai/starlette/test_provider.py +++ b/packages/starlette/tests/keycardai/starlette/test_provider.py @@ -481,6 +481,38 @@ async def admin(request: Request): assert 'error="insufficient_scope"' in challenge assert "resource_metadata=" in challenge + def test_insufficient_scope_challenge_includes_scope_attribute(self): + """RFC 6750 §3: the insufficient_scope challenge carries a scope + attribute with the space-delimited list of required scopes.""" + provider = AuthProvider( + zone_id="test-zone", + application_credential=ClientSecret(("cid", "csec")), + ) + provider.get_token_verifier = MagicMock( # type: ignore[method-assign] + return_value=_stub_verifier(scopes=["read"]) + ) + + app = FastAPI() + provider.install(app) + + @app.get("/api/admin") + @requires(["authenticated", "read", "admin"]) + async def admin(request: Request): + return {"ok": True} + + client = TestClient(app, raise_server_exceptions=False) + response = client.get( + "/api/admin", headers={"Authorization": "Bearer some-token"} + ) + assert response.status_code == 403 + challenge = response.headers.get("WWW-Authenticate", "") + assert 'error="insufficient_scope"' in challenge + # Space-delimited required scopes per RFC 6750 §3. The synthetic + # "authenticated" gating scope is not an OAuth scope a client can + # request, so it is excluded from the attribute. + assert 'scope="read admin"' in challenge + assert "resource_metadata=" in challenge + class TestGrant: def test_missing_access_context_param_raises(self): From 73c227b9f9447102bc0cf204308dd4750f7c7a80 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 20 Jul 2026 17:24:10 -0700 Subject: [PATCH 3/4] docs(keycardai-starlette): document require_authentication for opaque mounts require_authentication=True (the escape hatch for mounted sub-apps that bypass route decorators) was only discoverable through docstrings. Add a README section under protected_router() showing when the flag is needed and the one-liner to enable it, including the KeycardAuthBackend spelling for direct middleware registration. Co-Authored-By: Claude Fable 5 --- packages/starlette/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/starlette/README.md b/packages/starlette/README.md index 7c605649..c2c77439 100644 --- a/packages/starlette/README.md +++ b/packages/starlette/README.md @@ -127,6 +127,28 @@ app = Starlette(routes=protected_router( )) ``` +#### Opaque sub-apps: `require_authentication=True` + +`@requires(...)` only gates routes you decorate. A mounted sub-app that +handles its own routing (an MCP JSONRPC dispatcher, a gRPC handler, any +non-Starlette ASGI app) bypasses route decorators, so anonymous requests +would fall through to it. Pass `require_authentication=True` to make the +backend itself the gate: requests without an `Authorization` header get an +RFC 6750 401 challenge instead of reaching the sub-app anonymously. + +```python +app = Starlette(routes=protected_router( + issuer=auth.issuer, + app=inner, + verifier=auth.get_token_verifier(), + require_authentication=True, # every request to `inner` needs a token +)) +``` + +The same flag exists on `KeycardAuthBackend(verifier, require_authentication=True)` +when you register the middleware yourself. OAuth metadata paths under +`/.well-known/` stay public either way (RFC 9728 §2, RFC 8414 §3). + ### `AuthenticationMiddleware` directly For full control over middleware ordering, register the standard Starlette From 8cf6a14a7b6bff26b8b687301c1de20860ce25d3 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 21 Jul 2026 14:21:50 -0700 Subject: [PATCH 4/4] docs(keycardai-starlette): note the scope attribute carries static developer input Co-Authored-By: Claude Fable 5 --- packages/starlette/src/keycardai/starlette/authorization.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/starlette/src/keycardai/starlette/authorization.py b/packages/starlette/src/keycardai/starlette/authorization.py index f268838f..ba405328 100644 --- a/packages/starlette/src/keycardai/starlette/authorization.py +++ b/packages/starlette/src/keycardai/starlette/authorization.py @@ -76,6 +76,9 @@ def requires( # synthetic "authenticated" scope is a Starlette gating convention # (always present on verified requests), not an OAuth scope a client # can request from the authorization server, so it is excluded. + # Values are interpolated into the header unescaped: they are static + # developer literals from this decorator, never request- or + # token-derived, so no attacker-controlled path reaches the header. challenge_scope = " ".join(s for s in scopes_list if s != "authenticated") or None def decorator(func: Callable[..., Any]) -> Callable[..., Any]: