This commit is contained in:
Motov Yurii 2026-02-13 15:26:47 +00:00 committed by GitHub
commit 60fa95ebf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 257 additions and 41 deletions

View File

@ -106,7 +106,17 @@ Now we declare that the *path operation* for `/users/me/items/` requires the sco
For this, we import and use `Security` from `fastapi`.
You can use `Security` to declare dependencies (just like `Depends`), but `Security` also receives a parameter `scopes` with a list of scopes (strings).
You can use `Security` to declare dependencies (just like `Depends`), but `Security` also receives a parameter `oauth_scopes` with a list of scopes (strings).
/// note
Before version 0.122.X, the name of this parameter was `scopes`.
In FastAPI 0.122.X, the `scopes` parameter was deprecated in favor of `oauth_scopes`
to avoid confusing it with the `scope` parameter, which is used to specify when the exit code
of dependencies with `yield` should run.
///
In this case, we pass a dependency function `get_current_active_user` to `Security` (the same way we would do with `Depends`).
@ -124,7 +134,7 @@ We are doing it here to demonstrate how **FastAPI** handles scopes declared at d
///
{* ../../docs_src/security/tutorial005_an_py310.py hl[5,141,172] *}
{* ../../docs_src/security/tutorial005_an_py310.py hl[5,141,173] *}
/// info | Technical Details
@ -213,13 +223,13 @@ Here's how the hierarchy of dependencies and scopes looks like:
* This `security_scopes` parameter has a property `scopes` with a `list` containing all these scopes declared above, so:
* `security_scopes.scopes` will contain `["me", "items"]` for the *path operation* `read_own_items`.
* `security_scopes.scopes` will contain `["me"]` for the *path operation* `read_users_me`, because it is declared in the dependency `get_current_active_user`.
* `security_scopes.scopes` will contain `[]` (nothing) for the *path operation* `read_system_status`, because it didn't declare any `Security` with `scopes`, and its dependency, `get_current_user`, doesn't declare any `scopes` either.
* `security_scopes.scopes` will contain `[]` (nothing) for the *path operation* `read_system_status`, because it didn't declare any `Security` with `oauth_scopes`, and its dependency, `get_current_user`, doesn't declare any `oauth_scopes` either.
/// tip
The important and "magic" thing here is that `get_current_user` will have a different list of `scopes` to check for each *path operation*.
All depending on the `scopes` declared in each *path operation* and each dependency in the dependency tree for that specific *path operation*.
All depending on the `oauth_scopes` declared in each *path operation* and each dependency in the dependency tree for that specific *path operation*.
///
@ -271,4 +281,4 @@ But in the end, they are implementing the same OAuth2 standard.
## `Security` in decorator `dependencies` { #security-in-decorator-dependencies }
The same way you can define a `list` of `Depends` in the decorator's `dependencies` parameter (as explained in [Dependencies in path operation decorators](../../tutorial/dependencies/dependencies-in-path-operation-decorators.md){.internal-link target=_blank}), you could also use `Security` with `scopes` there.
The same way you can define a `list` of `Depends` in the decorator's `dependencies` parameter (as explained in [Dependencies in path operation decorators](../../tutorial/dependencies/dependencies-in-path-operation-decorators.md){.internal-link target=_blank}), you could also use `Security` with `oauth_scopes` there.

View File

@ -141,7 +141,7 @@ async def get_current_user(
async def get_current_active_user(
current_user: Annotated[User, Security(get_current_user, scopes=["me"])],
current_user: Annotated[User, Security(get_current_user, oauth_scopes=["me"])],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
@ -172,7 +172,9 @@ async def read_users_me(
@app.get("/users/me/items/")
async def read_own_items(
current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])],
current_user: Annotated[
User, Security(get_current_active_user, oauth_scopes=["items"])
],
):
return [{"item_id": "Foo", "owner": current_user.username}]

View File

@ -140,7 +140,7 @@ async def get_current_user(
async def get_current_active_user(
current_user: User = Security(get_current_user, scopes=["me"]),
current_user: User = Security(get_current_user, oauth_scopes=["me"]),
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
@ -169,7 +169,7 @@ async def read_users_me(current_user: User = Depends(get_current_active_user)) -
@app.get("/users/me/items/")
async def read_own_items(
current_user: User = Security(get_current_active_user, scopes=["items"]),
current_user: User = Security(get_current_active_user, oauth_scopes=["items"]),
):
return [{"item_id": "Foo", "owner": current_user.username}]

View File

@ -113,8 +113,8 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De
"A parameter-less dependency must have a callable dependency"
)
own_oauth_scopes: list[str] = []
if isinstance(depends, params.Security) and depends.scopes:
own_oauth_scopes.extend(depends.scopes)
if isinstance(depends, params.Security) and depends.oauth_scopes:
own_oauth_scopes.extend(depends.oauth_scopes)
return get_dependant(
path=path,
call=depends.dependency,
@ -296,8 +296,8 @@ def get_dependant(
)
sub_own_oauth_scopes: list[str] = []
if isinstance(param_details.depends, params.Security):
if param_details.depends.scopes:
sub_own_oauth_scopes = list(param_details.depends.scopes)
if param_details.depends.oauth_scopes:
sub_own_oauth_scopes = list(param_details.depends.oauth_scopes)
sub_dependant = get_dependant(
path=path,
call=param_details.depends.dependency,

View File

@ -1,9 +1,11 @@
from collections.abc import Callable, Sequence
import warnings
from collections.abc import Callable
from typing import Annotated, Any, Literal
from annotated_doc import Doc
from fastapi import params
from fastapi._compat import Undefined
from fastapi.exceptions import FastAPIError
from fastapi.openapi.models import Example
from pydantic import AliasChoices, AliasPath
from typing_extensions import deprecated
@ -2367,6 +2369,16 @@ def Depends( # noqa: N802
return commons
```
"""
# Handle case when `scope` parameter value is invalid
if scope not in ("function", "request", None):
raise FastAPIError(
"Invalid value for 'scope' parameter in Depends(). "
"Expected 'function', 'request', or None. "
f'Did you mean to use Security(dependency_fn, oauth_scopes="{scope}") '
"to specify OAuth2 scopes instead?"
)
return params.Depends(dependency=dependency, use_cache=use_cache, scope=scope)
@ -2386,8 +2398,24 @@ def Security( # noqa: N802
),
] = None,
*,
oauth_scopes: Annotated[
list[str] | tuple[str, ...] | set[str] | frozenset[str] | None,
Doc(
"""
OAuth2 scopes required for the *path operation* that uses this Security
dependency.
The term "scope" comes from the OAuth2 specification, it seems to be
intentionally vague and interpretable. It normally refers to permissions,
in cases to roles.
These scopes are integrated with OpenAPI (and the API docs at `/docs`).
So they are visible in the OpenAPI specification.
"""
),
] = None,
scopes: Annotated[
Sequence[str] | None,
list[str] | tuple[str, ...] | set[str] | frozenset[str] | None,
Doc(
"""
OAuth2 scopes required for the *path operation* that uses this Security
@ -2402,6 +2430,48 @@ def Security( # noqa: N802
Read more about it in the
[FastAPI docs about OAuth2 scopes](https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/)
This parameter is deprecated in favor of `oauth_scopes`.
"""
),
deprecated(
"""
In order to avoid confusion with `scope` parameter, the `scopes` parameter
is deprecated in favor of `oauth_scopes`.
To specify dependency scope for dependencies with `yield` use `scope` parameter:
```python
Security(dependency_fn, scope="function")
```
To specify OAuth2 scopes use `oauth_scopes` parameter:
```python
Security(dependency_fn, oauth_scopes=["items", "users"])
```
​
"""
),
] = None,
scope: Annotated[
Literal["function", "request"] | None,
Doc(
"""
Mainly for dependencies with `yield`, define when the dependency function
should start (the code before `yield`) and when it should end (the code
after `yield`).
* `"function"`: start the dependency before the *path operation function*
that handles the request, end the dependency after the *path operation
function* ends, but **before** the response is sent back to the client.
So, the dependency function will be executed **around** the *path operation
**function***.
* `"request"`: start the dependency before the *path operation function*
that handles the request (similar to when using `"function"`), but end
**after** the response is sent back to the client. So, the dependency
function will be executed **around** the **request** and response cycle.
"""
),
] = None,
@ -2427,8 +2497,8 @@ def Security( # noqa: N802
Declare a FastAPI Security dependency.
The only difference with a regular dependency is that it can declare OAuth2
scopes that will be integrated with OpenAPI and the automatic UI docs (by default
at `/docs`).
scopes (`oauth_scopes` parameter) that will be integrated with OpenAPI and the
automatic UI docs (by default at `/docs`).
It takes a single "dependable" callable (like a function).
@ -2453,9 +2523,58 @@ def Security( # noqa: N802
@app.get("/users/me/items/")
async def read_own_items(
current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])]
current_user: Annotated[
User, Security(get_current_active_user, oauth_scopes=["items"])
]
):
return [{"item_id": "Foo", "owner": current_user.username}]
```
"""
return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache)
if scopes is not None:
warnings.warn(
(
"The 'scopes' parameter in Security() is deprecated in favor of "
"'oauth_scopes' in order to avoid confusion with 'scope' parameter."
),
DeprecationWarning,
stacklevel=2,
)
# Handle case when `scopes="function"` is mistakenly used instead of `scope="function"`
if isinstance(scopes, str) and (scopes in ("function", "request")):
raise FastAPIError(
"Invalid value for the 'scopes' parameter in Security(). "
"Expected a sequence of strings (e.g., ['admin', 'user']), but received "
"a single string. "
f'Did you mean to use scope="{scopes}" to specify when the exit code '
"of dependencies with yield should run? "
)
oauth_scopes_param = "oauth_scopes" if (oauth_scopes is not None) else "scopes"
oauth_scopes = oauth_scopes or scopes
# Handle case when single string is passed to `scopes` or `oauth_scopes` instead of
# a list of strings
if isinstance(oauth_scopes, str):
raise FastAPIError(
f"Invalid value for the '{oauth_scopes_param}' parameter in Security(). "
"Expected a sequence of strings (e.g., ['admin', 'user']), but received a "
"single string. Wrap it in a list: oauth_scopes=['your_scope'] instead of "
"oauth_scopes='your_scope'."
)
# Handle case when `scope` parameter value is invalid
if scope not in ("function", "request", None):
raise FastAPIError(
"Invalid value for 'scope' parameter in Security(). "
"Expected 'function', 'request', or None. "
f'Did you mean oauth_scopes="{scope}" to specify OAuth2 scopes instead?'
)
return params.Security(
dependency=dependency,
oauth_scopes=oauth_scopes,
use_cache=use_cache,
scope=scope,
)

View File

@ -1,5 +1,5 @@
import warnings
from collections.abc import Callable, Sequence
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import Annotated, Any, Literal
@ -752,4 +752,4 @@ class Depends:
@dataclass(frozen=True)
class Security(Depends):
scopes: Sequence[str] | None = None
oauth_scopes: list[str] | tuple[str, ...] | set[str] | frozenset[str] | None = None

View File

@ -38,8 +38,8 @@ async def get_sub_counter_no_cache(
@app.get("/scope-counter")
async def get_scope_counter(
count: int = Security(dep_counter),
scope_count_1: int = Security(dep_counter, scopes=["scope"]),
scope_count_2: int = Security(dep_counter, scopes=["scope"]),
scope_count_1: int = Security(dep_counter, oauth_scopes=["scope"]),
scope_count_2: int = Security(dep_counter, oauth_scopes=["scope"]),
):
return {
"counter": count,

View File

@ -1,4 +1,4 @@
from typing import Annotated, Union
from typing import Annotated
from fastapi import FastAPI, HTTPException, Security
from fastapi.security import (
@ -13,7 +13,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def process_auth(
credentials: Annotated[Union[str, None], Security(oauth2_scheme)],
credentials: Annotated[str | None, Security(oauth2_scheme)],
security_scopes: SecurityScopes,
):
# This is an incorrect way of using it, this is not checking if the scopes are
@ -27,14 +27,14 @@ def process_auth(
@app.get("/get-credentials")
def get_credentials(
credentials: Annotated[dict, Security(process_auth, scopes=["a", "b"])],
credentials: Annotated[dict, Security(process_auth, oauth_scopes=["a", "b"])],
):
return credentials
@app.get(
"/parameterless-with-scopes",
dependencies=[Security(process_auth, scopes=["a", "b"])],
dependencies=[Security(process_auth, oauth_scopes=["a", "b"])],
)
def get_parameterless_with_scopes():
return {"status": "ok"}

View File

@ -23,7 +23,7 @@ def get_data_override():
@app.get("/user")
def read_user(
user_data: tuple[str, list[str]] = Security(get_user, scopes=["foo", "bar"]),
user_data: tuple[str, list[str]] = Security(get_user, oauth_scopes=["foo", "bar"]),
data: list[int] = Depends(get_data),
):
return {"user": user_data[0], "scopes": user_data[1], "data": data}

View File

@ -0,0 +1,84 @@
import pytest
from fastapi import Depends, Security
from fastapi.exceptions import FastAPIError
def test_scopes_deprecation_warning():
"""
Test that using `scopes` parameter raises a deprecation warning.
"""
with pytest.warns(DeprecationWarning) as record:
Security(dependency=lambda: None, scopes=["admin"])
assert len(record) == 1
warning = record[0]
assert issubclass(warning.category, DeprecationWarning)
assert str(warning.message) == (
"The 'scopes' parameter in Security() is deprecated in favor of "
"'oauth_scopes' in order to avoid confusion with 'scope' parameter."
)
@pytest.mark.parametrize("parameter_name", ["scopes", "oauth_scopes"])
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_pass_single_str(parameter_name: str):
"""
Test passing single string instead of list of strings to `scopes` or `oauth_scopes`.
"""
with pytest.raises(FastAPIError) as exc_info:
Security(dependency=lambda: None, **{parameter_name: "admin"})
assert str(exc_info.value) == (
f"Invalid value for the '{parameter_name}' parameter in Security(). "
"Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. "
"Wrap it in a list: oauth_scopes=['your_scope'] instead of oauth_scopes='your_scope'."
)
@pytest.mark.parametrize("value", ["function", "request"])
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_pass_scope_as_scopes(value: str):
"""
Test passing `scopes="function"` instead of `scope="function"` to `Security`.
"""
with pytest.raises(FastAPIError) as exc_info:
Security(dependency=lambda: None, scopes=value)
assert str(exc_info.value) == (
"Invalid value for the 'scopes' parameter in Security(). "
"Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. "
f'Did you mean to use scope="{value}" to specify when the exit code of dependencies with yield should run? '
)
def test_pass_invalid_scope_value_to_security():
"""
Test passing invalid value to `scope` parameter in `Security`.
"""
with pytest.raises(FastAPIError) as exc_info:
Security(dependency=lambda: None, scope="invalid_scope")
assert str(exc_info.value) == (
"Invalid value for 'scope' parameter in Security(). "
"Expected 'function', 'request', or None. "
'Did you mean oauth_scopes="invalid_scope" to specify OAuth2 scopes instead?'
)
def test_pass_invalid_scope_value_to_depends():
"""
Test passing invalid value to `scope` parameter in `Depends`.
"""
with pytest.raises(FastAPIError) as exc_info:
Depends(dependency=lambda: None, scope="invalid_scope")
assert str(exc_info.value) == (
"Invalid value for 'scope' parameter in Depends(). "
"Expected 'function', 'request', or None. "
'Did you mean to use Security(dependency_fn, oauth_scopes="invalid_scope") to specify OAuth2 scopes instead?'
)

View File

@ -1,6 +1,6 @@
# Ref: https://github.com/fastapi/fastapi/issues/14454
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, Security
from fastapi.security import OAuth2AuthorizationCodeBearer
@ -29,30 +29,31 @@ async def root():
@app.get(
"/with-oauth2-scheme",
dependencies=[Security(oauth2_scheme, scopes=["read", "write"])],
dependencies=[Security(oauth2_scheme, oauth_scopes=["read", "write"])],
)
async def read_with_oauth2_scheme():
return {"message": "Admin Access"}
@app.get(
"/with-get-token", dependencies=[Security(get_token, scopes=["read", "write"])]
"/with-get-token",
dependencies=[Security(get_token, oauth_scopes=["read", "write"])],
)
async def read_with_get_token():
return {"message": "Admin Access"}
router = APIRouter(dependencies=[Security(oauth2_scheme, scopes=["read"])])
router = APIRouter(dependencies=[Security(oauth2_scheme, oauth_scopes=["read"])])
@router.get("/items/")
async def read_items(token: Optional[str] = Depends(oauth2_scheme)):
async def read_items(token: str | None = Depends(oauth2_scheme)):
return {"token": token}
@router.post("/items/")
async def create_item(
token: Optional[str] = Security(oauth2_scheme, scopes=["read", "write"]),
token: str | None = Security(oauth2_scheme, oauth_scopes=["read", "write"]),
):
return {"token": token}

View File

@ -21,7 +21,7 @@ async def get_token(token: Annotated[str, Depends(oauth2_scheme)]) -> str:
app = FastAPI(dependencies=[Depends(get_token)])
@app.get("/admin", dependencies=[Security(get_token, scopes=["read", "write"])])
@app.get("/admin", dependencies=[Security(get_token, oauth_scopes=["read", "write"])])
async def read_admin():
return {"message": "Admin Access"}

View File

@ -24,7 +24,7 @@ def app_fixture(call_counter: dict[str, int]):
@app.get("/")
def endpoint(
db: Annotated[str, Depends(get_db)],
user: Annotated[str, Security(get_user, scopes=["read"])],
user: Annotated[str, Security(get_user, oauth_scopes=["read"])],
):
return {"db": db}

View File

@ -16,8 +16,8 @@ async def security2(scopes: SecurityScopes):
async def dep3(
dep1: Annotated[list[str], Security(security1, scopes=["scope1"])],
dep2: Annotated[list[str], Security(security2, scopes=["scope2"])],
dep1: Annotated[list[str], Security(security1, oauth_scopes=["scope1"])],
dep2: Annotated[list[str], Security(security2, oauth_scopes=["scope2"])],
):
return {"dep1": dep1, "dep2": dep2}
@ -27,7 +27,7 @@ app = FastAPI()
@app.get("/scopes")
def get_scopes(
dep3: Annotated[dict[str, Any], Security(dep3, scopes=["scope3"])],
dep3: Annotated[dict[str, Any], Security(dep3, oauth_scopes=["scope3"])],
):
return dep3

View File

@ -37,7 +37,7 @@ def app_fixture(call_counts: dict[str, int]):
}
def get_user_me(
current_user: Annotated[dict, Security(get_current_user, scopes=["me"])],
current_user: Annotated[dict, Security(get_current_user, oauth_scopes=["me"])],
):
call_counts["get_user_me"] += 1
return {
@ -59,7 +59,7 @@ def app_fixture(call_counts: dict[str, int]):
@app.get("/")
def path_operation(
user_me: Annotated[dict, Depends(get_user_me)],
user_items: Annotated[dict, Security(get_user_items, scopes=["items"])],
user_items: Annotated[dict, Security(get_user_items, oauth_scopes=["items"])],
):
return {
"user_me": user_me,