diff --git a/packages/starlette/README.md b/packages/starlette/README.md index 9115598..c2c7743 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: @@ -121,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 diff --git a/packages/starlette/src/keycardai/starlette/__init__.py b/packages/starlette/src/keycardai/starlette/__init__.py index ce223d4..9148a74 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/authorization.py b/packages/starlette/src/keycardai/starlette/authorization.py index 31ced40..ba40532 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,14 @@ 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. + # 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]: sig = inspect.signature(func) @@ -106,6 +115,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 +140,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 942d113..544aadd 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/src/keycardai/starlette/provider.py b/packages/starlette/src/keycardai/starlette/provider.py index d89bf80..ea51b98 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 diff --git a/packages/starlette/tests/keycardai/starlette/test_provider.py b/packages/starlette/tests/keycardai/starlette/test_provider.py index 19d1efb..ad4d175 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):