diff --git a/docs/en/docs/advanced/security/oauth2-scopes.md b/docs/en/docs/advanced/security/oauth2-scopes.md index 67c927cd0..eb4b2f8e9 100644 --- a/docs/en/docs/advanced/security/oauth2-scopes.md +++ b/docs/en/docs/advanced/security/oauth2-scopes.md @@ -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. diff --git a/docs_src/security/tutorial005_an_py310.py b/docs_src/security/tutorial005_an_py310.py index 9911723db..75a8c4779 100644 --- a/docs_src/security/tutorial005_an_py310.py +++ b/docs_src/security/tutorial005_an_py310.py @@ -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}] diff --git a/docs_src/security/tutorial005_py310.py b/docs_src/security/tutorial005_py310.py index 710cdac32..b8d95a360 100644 --- a/docs_src/security/tutorial005_py310.py +++ b/docs_src/security/tutorial005_py310.py @@ -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}] diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index ab18ec2db..5a6163623 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -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, diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index 4be504f43..c5feb7aed 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -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, + ) diff --git a/fastapi/params.py b/fastapi/params.py index 68f987081..41b74f519 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -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 diff --git a/tests/test_dependency_cache.py b/tests/test_dependency_cache.py index 08fb9b74f..d3f43ce39 100644 --- a/tests/test_dependency_cache.py +++ b/tests/test_dependency_cache.py @@ -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, diff --git a/tests/test_dependency_paramless.py b/tests/test_dependency_paramless.py index 1774196fe..26ca7dead 100644 --- a/tests/test_dependency_paramless.py +++ b/tests/test_dependency_paramless.py @@ -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"} diff --git a/tests/test_dependency_security_overrides.py b/tests/test_dependency_security_overrides.py index 14b65c777..340bbcf90 100644 --- a/tests/test_dependency_security_overrides.py +++ b/tests/test_dependency_security_overrides.py @@ -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} diff --git a/tests/test_scope_and_scopes_confusion.py b/tests/test_scope_and_scopes_confusion.py new file mode 100644 index 000000000..62ed6771d --- /dev/null +++ b/tests/test_scope_and_scopes_confusion.py @@ -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?' + ) diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py index 583007c8b..7dd3421c1 100644 --- a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py @@ -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} diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py index 1c21369d3..cf04eff58 100644 --- a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py @@ -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"} diff --git a/tests/test_security_scopes.py b/tests/test_security_scopes.py index fccb026fe..c63b3a435 100644 --- a/tests/test_security_scopes.py +++ b/tests/test_security_scopes.py @@ -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} diff --git a/tests/test_security_scopes_dont_propagate.py b/tests/test_security_scopes_dont_propagate.py index c306ed059..e78b8fcf5 100644 --- a/tests/test_security_scopes_dont_propagate.py +++ b/tests/test_security_scopes_dont_propagate.py @@ -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 diff --git a/tests/test_security_scopes_sub_dependency.py b/tests/test_security_scopes_sub_dependency.py index 2c64d5f3d..f3ff606eb 100644 --- a/tests/test_security_scopes_sub_dependency.py +++ b/tests/test_security_scopes_sub_dependency.py @@ -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,