Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/starlette/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/starlette/src/keycardai/starlette/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/starlette/src/keycardai/starlette/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,22 @@ 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
omitted - browser redirects do not apply to OAuth 2.0 protected
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)
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down
28 changes: 20 additions & 8 deletions packages/starlette/src/keycardai/starlette/middleware/bearer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
7 changes: 6 additions & 1 deletion packages/starlette/src/keycardai/starlette/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions packages/starlette/tests/keycardai/starlette/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading