✨ Add WebSocket support to security dependencies (#10147)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 13:09:15 +02:00
1 parent 4bf5ee9098
commit 1884325cd6
7 files changed
+250 -18

No files matched your search

+5 -5
View File
@@ -4,7 +4,7 @@ from annotated_doc import Doc
from fastapi.openapi.models import APIKey, APIKeyIn
from fastapi.security.base import SecurityBase
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.requests import HTTPConnection
from starlette.status import HTTP_401_UNAUTHORIZED
@@ -21,7 +21,7 @@ class APIKeyBase(SecurityBase):
):
self.auto_error = auto_error
self.model: APIKey = APIKey(
self.model = APIKey(
**{"in": location},
name=name,
description=description,
@@ -139,7 +139,7 @@ class APIKeyQuery(APIKeyBase):
auto_error=auto_error,
)
async def __call__(self, request: Request) -> str | None:
async def __call__(self, request: HTTPConnection) -> str | None:
api_key = request.query_params.get(self.model.name)
return self.check_api_key(api_key)
@@ -227,7 +227,7 @@ class APIKeyHeader(APIKeyBase):
auto_error=auto_error,
)
async def __call__(self, request: Request) -> str | None:
async def __call__(self, request: HTTPConnection) -> str | None:
api_key = request.headers.get(self.model.name)
return self.check_api_key(api_key)
@@ -315,6 +315,6 @@ class APIKeyCookie(APIKeyBase):
auto_error=auto_error,
)
async def __call__(self, request: Request) -> str | None:
async def __call__(self, request: HTTPConnection) -> str | None:
api_key = request.cookies.get(self.model.name)
return self.check_api_key(api_key)
+11 -5
View File
@@ -9,7 +9,7 @@ from fastapi.openapi.models import HTTPBearer as HTTPBearerModel
from fastapi.security.base import SecurityBase
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel
from starlette.requests import Request
from starlette.requests import HTTPConnection
from starlette.status import HTTP_401_UNAUTHORIZED
@@ -91,7 +91,9 @@ class HTTPBase(SecurityBase):
headers=self.make_authenticate_headers(),
)
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
async def __call__(
self, request: HTTPConnection
) -> HTTPAuthorizationCredentials | None:
authorization = request.headers.get("Authorization")
scheme, credentials = get_authorization_scheme_param(authorization)
if not (authorization and scheme and credentials):
@@ -200,7 +202,7 @@ class HTTPBasic(HTTPBase):
return {"WWW-Authenticate": "Basic"}
async def __call__( # type: ignore
self, request: Request
self, request: HTTPConnection
) -> HTTPBasicCredentials | None:
authorization = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
@@ -300,7 +302,9 @@ class HTTPBearer(HTTPBase):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
async def __call__(
self, request: HTTPConnection
) -> HTTPAuthorizationCredentials | None:
authorization = request.headers.get("Authorization")
scheme, credentials = get_authorization_scheme_param(authorization)
if not (authorization and scheme and credentials):
@@ -401,7 +405,9 @@ class HTTPDigest(HTTPBase):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
async def __call__(
self, request: HTTPConnection
) -> HTTPAuthorizationCredentials | None:
authorization = request.headers.get("Authorization")
scheme, credentials = get_authorization_scheme_param(authorization)
if not (authorization and scheme and credentials):
+4 -4
View File
@@ -7,7 +7,7 @@ from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
from fastapi.param_functions import Form
from fastapi.security.base import SecurityBase
from fastapi.security.utils import get_authorization_scheme_param
from starlette.requests import Request
from starlette.requests import HTTPConnection
from starlette.status import HTTP_401_UNAUTHORIZED
@@ -420,7 +420,7 @@ class OAuth2(SecurityBase):
headers={"WWW-Authenticate": "Bearer"},
)
async def __call__(self, request: Request) -> str | None:
async def __call__(self, request: HTTPConnection) -> str | None:
authorization = request.headers.get("Authorization")
if not authorization:
if self.auto_error:
@@ -533,7 +533,7 @@ class OAuth2PasswordBearer(OAuth2):
auto_error=auto_error,
)
async def __call__(self, request: Request) -> str | None:
async def __call__(self, request: HTTPConnection) -> str | None:
authorization = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
@@ -639,7 +639,7 @@ class OAuth2AuthorizationCodeBearer(OAuth2):
auto_error=auto_error,
)
async def __call__(self, request: Request) -> str | None:
async def __call__(self, request: HTTPConnection) -> str | None:
authorization = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
+2 -2
View File
@@ -4,7 +4,7 @@ from annotated_doc import Doc
from fastapi.openapi.models import OpenIdConnect as OpenIdConnectModel
from fastapi.security.base import SecurityBase
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.requests import HTTPConnection
from starlette.status import HTTP_401_UNAUTHORIZED
@@ -84,7 +84,7 @@ class OpenIdConnect(SecurityBase):
headers={"WWW-Authenticate": "Bearer"},
)
async def __call__(self, request: Request) -> str | None:
async def __call__(self, request: HTTPConnection) -> str | None:
authorization = request.headers.get("Authorization")
if not authorization:
if self.auto_error:
+28 -1
View File
@@ -1,4 +1,4 @@
from fastapi import FastAPI, Security
from fastapi import FastAPI, Security, WebSocket
from fastapi.security.http import HTTPAuthorizationCredentials, HTTPBase
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
@@ -17,6 +17,19 @@ def read_current_user(
return {"scheme": credentials.scheme, "credentials": credentials.credentials}
@app.websocket("/users/timeline")
async def read_user_timeline(
websocket: WebSocket,
credentials: HTTPAuthorizationCredentials | None = Security(security),
):
await websocket.accept()
await websocket.send_json(
{"scheme": credentials.scheme, "credentials": credentials.credentials}
if credentials
else {"msg": "Create an account first"}
)
client = TestClient(app)
@@ -32,6 +45,20 @@ def test_security_http_base_no_credentials():
assert response.json() == {"msg": "Create an account first"}
def test_security_http_base_with_ws():
with client.websocket_connect(
"/users/timeline", headers={"Authorization": "Other foobar"}
) as websocket:
data = websocket.receive_json()
assert data == {"scheme": "Other", "credentials": "foobar"}
def test_security_http_base_with_ws_no_credentials():
with client.websocket_connect("/users/timeline") as websocket:
data = websocket.receive_json()
assert data == {"msg": "Create an account first"}
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
+39 -1
View File
@@ -1,4 +1,5 @@
from fastapi import FastAPI, Security
from fastapi import FastAPI, Security, WebSocket
from fastapi.requests import HTTPConnection
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
@@ -36,6 +37,43 @@ def test_security_http_bearer_incorrect_scheme_credentials():
assert response.headers["WWW-Authenticate"] == "Bearer"
def test_security_http_bearer_subclass_request_keyword():
class CustomHTTPBearer(HTTPBearer):
async def __call__(
self, request: HTTPConnection
) -> HTTPAuthorizationCredentials | None:
return await super().__call__(request=request)
subclass_app = FastAPI()
subclass_security = CustomHTTPBearer()
@subclass_app.get("/users/me")
async def read_user(
credentials: HTTPAuthorizationCredentials = Security(subclass_security),
):
return credentials
@subclass_app.websocket("/users/me")
async def read_user_websocket(
websocket: WebSocket,
credentials: HTTPAuthorizationCredentials = Security(subclass_security),
):
await websocket.accept()
await websocket.send_json(credentials.model_dump())
await websocket.close()
subclass_client = TestClient(subclass_app)
headers = {"Authorization": "Bearer foobar"}
expected = {"scheme": "Bearer", "credentials": "foobar"}
response = subclass_client.get("/users/me", headers=headers)
assert response.status_code == 200, response.text
assert response.json() == expected
with subclass_client.websocket_connect("/users/me", headers=headers) as websocket:
assert websocket.receive_json() == expected
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
+161
View File
@@ -0,0 +1,161 @@
from functools import partial
import pytest
from fastapi import FastAPI, Security, WebSocket
from fastapi.encoders import jsonable_encoder
from fastapi.security import (
APIKeyCookie,
APIKeyHeader,
APIKeyQuery,
HTTPBasic,
HTTPBearer,
HTTPDigest,
OAuth2,
OAuth2AuthorizationCodeBearer,
OAuth2PasswordBearer,
OpenIdConnect,
)
from fastapi.security.http import HTTPBase
from fastapi.testclient import TestClient
from starlette.testclient import WebSocketDenialResponse
@pytest.mark.parametrize(
"security_factory, path, headers, expected, challenge",
[
pytest.param(
partial(HTTPBase, scheme="Other"),
"/",
{"Authorization": "Other secret"},
{"scheme": "Other", "credentials": "secret"},
"Other",
id="http-base",
),
pytest.param(
HTTPBasic,
"/",
{"Authorization": "Basic am9objpzZWNyZXQ="},
{"username": "john", "password": "secret"},
"Basic",
id="http-basic",
),
pytest.param(
HTTPBearer,
"/",
{"Authorization": "Bearer secret"},
{"scheme": "Bearer", "credentials": "secret"},
"Bearer",
id="http-bearer",
),
pytest.param(
HTTPDigest,
"/",
{"Authorization": "Digest secret"},
{"scheme": "Digest", "credentials": "secret"},
"Digest",
id="http-digest",
),
pytest.param(
partial(APIKeyQuery, name="key"),
"/?key=secret",
{},
"secret",
"APIKey",
id="api-key-query",
),
pytest.param(
partial(APIKeyHeader, name="key"),
"/",
{"key": "secret"},
"secret",
"APIKey",
id="api-key-header",
),
pytest.param(
partial(APIKeyCookie, name="key"),
"/",
{"Cookie": "key=secret"},
"secret",
"APIKey",
id="api-key-cookie",
),
pytest.param(
OAuth2,
"/",
{"Authorization": "Bearer secret"},
"Bearer secret",
"Bearer",
id="oauth2",
),
pytest.param(
partial(OAuth2PasswordBearer, tokenUrl="/token"),
"/",
{"Authorization": "Bearer secret"},
"secret",
"Bearer",
id="oauth2-password-bearer",
),
pytest.param(
partial(
OAuth2AuthorizationCodeBearer,
authorizationUrl="/authorize",
tokenUrl="/token",
),
"/",
{"Authorization": "Bearer secret"},
"secret",
"Bearer",
id="oauth2-authorization-code-bearer",
),
pytest.param(
partial(OpenIdConnect, openIdConnectUrl="/openid"),
"/",
{"Authorization": "Bearer secret"},
"Bearer secret",
"Bearer",
id="openid-connect",
),
],
)
@pytest.mark.parametrize(
"auto_error, has_credentials",
[
pytest.param(True, True, id="valid-credentials"),
pytest.param(True, False, id="missing-required-credentials"),
pytest.param(False, False, id="missing-optional-credentials"),
],
)
def test_security_websocket(
security_factory,
path: str,
headers: dict[str, str],
expected: dict[str, str] | str,
challenge: str,
auto_error: bool,
has_credentials: bool,
):
security = security_factory(auto_error=auto_error)
app = FastAPI()
@app.websocket("/")
async def endpoint(websocket: WebSocket, credentials=Security(security)):
await websocket.accept()
await websocket.send_json(jsonable_encoder(credentials))
await websocket.close()
with TestClient(app) as client:
if auto_error and not has_credentials:
with pytest.raises(WebSocketDenialResponse) as exc_info:
with client.websocket_connect("/"):
pass # pragma: no cover
assert exc_info.value.status_code == 401
assert exc_info.value.json() == {"detail": "Not authenticated"}
assert exc_info.value.headers["WWW-Authenticate"] == challenge
else:
with client.websocket_connect(
path if has_credentials else "/",
headers=headers if has_credentials else {},
) as websocket:
assert websocket.receive_json() == (
expected if has_credentials else None
)