Browse Source
When a security scheme (OAuth2PasswordBearer, HTTPBearer, APIKeyHeader, etc.) has auto_error=False, it returns None on missing or invalid credentials. Previously, FastAPI injected this None into the endpoint parameter regardless of its type annotation, causing either a 500 crash (if the handler called methods on the value) or a silent auth bypass (if the handler did a truthy check). Now, when a security scheme dependency returns None and the endpoint parameter is annotated as non-optional (e.g. `str` instead of `str | None`), FastAPI returns a 422 validation error with a message explaining the mismatch. Parameters annotated as optional continue to receive None as before. The check only applies to direct security scheme dependencies, not to intermediate functions in dependency chains.pull/15414/head
3 changed files with 300 additions and 0 deletions
@ -0,0 +1,264 @@ |
|||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import Depends, FastAPI |
||||
|
from fastapi.security import ( |
||||
|
APIKeyCookie, |
||||
|
APIKeyHeader, |
||||
|
APIKeyQuery, |
||||
|
HTTPAuthorizationCredentials, |
||||
|
HTTPBasic, |
||||
|
HTTPBasicCredentials, |
||||
|
HTTPBearer, |
||||
|
OAuth2PasswordBearer, |
||||
|
) |
||||
|
from fastapi.testclient import TestClient |
||||
|
from inline_snapshot import snapshot |
||||
|
|
||||
|
|
||||
|
def test_oauth2_non_optional_no_auth(): |
||||
|
app = FastAPI() |
||||
|
oauth2 = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) |
||||
|
|
||||
|
@app.get("/me") |
||||
|
def get_me(token: str = Depends(oauth2)): |
||||
|
return {"token": token} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/me") |
||||
|
assert resp.status_code == 422 |
||||
|
assert resp.json() == snapshot( |
||||
|
{ |
||||
|
"detail": [ |
||||
|
{ |
||||
|
"type": "missing", |
||||
|
"loc": ["dependency", "token"], |
||||
|
"msg": "Dependency returned None for parameter 'token' which is annotated as non-optional. Use 'Optional[...]' or '... | None' if the dependency can return None (e.g. when using auto_error=False).", |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def test_oauth2_optional_no_auth(): |
||||
|
app = FastAPI() |
||||
|
oauth2 = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) |
||||
|
|
||||
|
@app.get("/me") |
||||
|
def get_me(token: str | None = Depends(oauth2)): |
||||
|
return {"token": token} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/me") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"token": None} |
||||
|
|
||||
|
|
||||
|
def test_oauth2_non_optional_with_auth(): |
||||
|
app = FastAPI() |
||||
|
oauth2 = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) |
||||
|
|
||||
|
@app.get("/me") |
||||
|
def get_me(token: str = Depends(oauth2)): |
||||
|
return {"token": token} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/me", headers={"Authorization": "Bearer valid"}) |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"token": "valid"} |
||||
|
|
||||
|
|
||||
|
def test_http_bearer_non_optional_no_auth(): |
||||
|
app = FastAPI() |
||||
|
bearer = HTTPBearer(auto_error=False) |
||||
|
|
||||
|
@app.get("/profile") |
||||
|
def get_profile(creds: HTTPAuthorizationCredentials = Depends(bearer)): |
||||
|
return {"scheme": creds.scheme} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/profile") |
||||
|
assert resp.status_code == 422 |
||||
|
|
||||
|
|
||||
|
def test_http_bearer_optional_no_auth(): |
||||
|
app = FastAPI() |
||||
|
bearer = HTTPBearer(auto_error=False) |
||||
|
|
||||
|
@app.get("/profile") |
||||
|
def get_profile(creds: HTTPAuthorizationCredentials | None = Depends(bearer)): |
||||
|
if creds is None: |
||||
|
return {"status": "anonymous"} |
||||
|
return {"scheme": creds.scheme} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/profile") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"status": "anonymous"} |
||||
|
|
||||
|
|
||||
|
def test_api_key_header_non_optional_no_key(): |
||||
|
app = FastAPI() |
||||
|
api_key = APIKeyHeader(name="X-API-Key", auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(key: str = Depends(api_key)): |
||||
|
return {"key": key} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 422 |
||||
|
|
||||
|
|
||||
|
def test_api_key_query_non_optional_no_key(): |
||||
|
app = FastAPI() |
||||
|
api_key = APIKeyQuery(name="api_key", auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(key: str = Depends(api_key)): |
||||
|
return {"key": key} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 422 |
||||
|
|
||||
|
|
||||
|
def test_api_key_cookie_non_optional_no_key(): |
||||
|
app = FastAPI() |
||||
|
api_key = APIKeyCookie(name="session", auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(key: str = Depends(api_key)): |
||||
|
return {"key": key} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 422 |
||||
|
|
||||
|
|
||||
|
def test_http_basic_non_optional_no_auth(): |
||||
|
app = FastAPI() |
||||
|
basic = HTTPBasic(auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(creds: HTTPBasicCredentials = Depends(basic)): |
||||
|
return {"user": creds.username} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 422 |
||||
|
|
||||
|
|
||||
|
def test_annotated_syntax_non_optional(): |
||||
|
app = FastAPI() |
||||
|
api_key = APIKeyHeader(name="X-API-Key", auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(key: Annotated[str, Depends(api_key)]): |
||||
|
return {"key": key} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 422 |
||||
|
|
||||
|
resp2 = client.get("/data", headers={"X-API-Key": "secret"}) |
||||
|
assert resp2.status_code == 200 |
||||
|
assert resp2.json() == {"key": "secret"} |
||||
|
|
||||
|
|
||||
|
def test_annotated_syntax_optional(): |
||||
|
app = FastAPI() |
||||
|
api_key = APIKeyHeader(name="X-API-Key", auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(key: Annotated[str | None, Depends(api_key)]): |
||||
|
return {"key": key} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"key": None} |
||||
|
|
||||
|
|
||||
|
def test_any_annotation_allows_none(): |
||||
|
"""Any annotation should allow None (no type constraint).""" |
||||
|
from typing import Any |
||||
|
|
||||
|
app = FastAPI() |
||||
|
oauth2 = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) |
||||
|
|
||||
|
@app.get("/me") |
||||
|
def get_me(token: Any = Depends(oauth2)): |
||||
|
return {"token": token} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/me") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"token": None} |
||||
|
|
||||
|
|
||||
|
def test_none_type_annotation_allows_none(): |
||||
|
app = FastAPI() |
||||
|
oauth2 = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) |
||||
|
|
||||
|
@app.get("/me") |
||||
|
def get_me(token: None = Depends(oauth2)): |
||||
|
return {"token": token} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/me") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"token": None} |
||||
|
|
||||
|
|
||||
|
def test_annotated_non_optional_inner_blocked(): |
||||
|
"""Annotated[str, Depends(...)] should still be blocked when str is non-optional.""" |
||||
|
from typing import Annotated |
||||
|
|
||||
|
app = FastAPI() |
||||
|
api_key = APIKeyHeader(name="X-Key", auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(key: Annotated[str, Depends(api_key)]): |
||||
|
return {"key": key} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 422 |
||||
|
|
||||
|
|
||||
|
def test_annotated_optional_inner_allowed(): |
||||
|
"""Annotated[str | None, Depends(...)] should allow None.""" |
||||
|
from typing import Annotated |
||||
|
|
||||
|
app = FastAPI() |
||||
|
api_key = APIKeyHeader(name="X-Key", auto_error=False) |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(key: Annotated[str | None, Depends(api_key)]): |
||||
|
return {"key": key} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"key": None} |
||||
|
|
||||
|
|
||||
|
def test_chain_through_intermediate_not_blocked(): |
||||
|
app = FastAPI() |
||||
|
oauth2 = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) |
||||
|
|
||||
|
def get_user(token: str | None = Depends(oauth2)): |
||||
|
if token is None: |
||||
|
return None |
||||
|
return {"name": "alice"} |
||||
|
|
||||
|
@app.get("/data") |
||||
|
def get_data(user: dict = Depends(get_user)): |
||||
|
if user is None: |
||||
|
return {"msg": "anonymous"} |
||||
|
return user |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get("/data") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json() == {"msg": "anonymous"} |
||||
Loading…
Reference in new issue