Add `oauth_scopes`, deprecate `scopes`

This commit is contained in:
Yurii Motov 2025-11-25 20:37:35 +01:00
parent 81e4a639af
commit 73fd05ccf0
13 changed files with 105 additions and 40 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.121.4, the name of this parameter was `scopes`.
Since FastAPI 0.121.4, the `scopes` parameter has been 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

@ -138,7 +138,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")
@ -167,7 +167,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

@ -139,7 +139,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")
@ -170,7 +170,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

@ -138,7 +138,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")
@ -169,7 +169,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

@ -138,7 +138,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")
@ -169,7 +169,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

@ -137,7 +137,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")
@ -166,7 +166,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

@ -138,7 +138,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")
@ -167,7 +167,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

@ -126,8 +126,8 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De
"A parameter-less dependency must have a callable dependency"
)
use_security_scopes: List[str] = []
if isinstance(depends, params.Security) and depends.scopes:
use_security_scopes.extend(depends.scopes)
if isinstance(depends, params.Security) and depends.oauth_scopes:
use_security_scopes.extend(depends.oauth_scopes)
return get_dependant(
path=path,
call=depends.dependency,
@ -276,8 +276,8 @@ def get_dependant(
)
use_security_scopes = security_scopes or []
if isinstance(param_details.depends, params.Security):
if param_details.depends.scopes:
use_security_scopes.extend(param_details.depends.scopes)
if param_details.depends.oauth_scopes:
use_security_scopes.extend(param_details.depends.oauth_scopes)
sub_dependant = get_dependant(
path=path,
call=param_details.depends.dependency,

View File

@ -1,3 +1,4 @@
import warnings
from typing import (
Any,
Callable,
@ -2322,6 +2323,29 @@ def Security( # noqa: N802
),
] = None,
*,
oauth_scopes: Annotated[
Optional[
Union[
List[str],
Tuple[str, ...],
Set[str],
FrozenSet[str],
]
],
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[
Optional[
Union[
@ -2342,6 +2366,26 @@ def Security( # noqa: N802
These scopes are integrated with OpenAPI (and the API docs at `/docs`).
So they are visible in the OpenAPI specification.
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"])
```
)
"""
),
@ -2391,23 +2435,28 @@ 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}]
```
"""
if isinstance(scopes, str):
if scopes in ("function", "request"):
raise FastAPIError(
"Invalid value for `scopes` parameter in Security(). "
"You probably meant to use the `scope` parameter instead of `scopes`. "
"Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string."
)
raise FastAPIError(
"Invalid value for `scopes` parameter in Security(). "
"Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. "
"Wrap it in a list: scopes=['your_scope'] instead of scopes='your_scope'."
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,
)
return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache)
oauth_scopes = oauth_scopes or scopes
return params.Security(
dependency=dependency,
oauth_scopes=oauth_scopes,
use_cache=use_cache,
)

View File

@ -781,7 +781,7 @@ class Depends:
@dataclass
class Security(Depends):
scopes: Optional[
oauth_scopes: Optional[
Union[
List[str],
Tuple[str, ...],

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

@ -28,14 +28,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

@ -25,7 +25,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}