From a4939959cff5d618789b4a5b3d12d2c8d9c10412 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 9 May 2024 14:40:10 +0100 Subject: [PATCH 1/7] Adding OAuth2ClientCredentials class. --- docs/de/docs/reference/security/index.md | 3 + docs/en/docs/reference/security/index.md | 3 + docs/en/docs/release-notes.md | 2 + fastapi/security/__init__.py | 1 + fastapi/security/oauth2.py | 123 +++++++++++++++++- ...oauth2_authorization_client_credentials.py | 72 ++++++++++ ...rization_client_credentials_description.py | 77 +++++++++++ 7 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 tests/test_security_oauth2_authorization_client_credentials.py create mode 100644 tests/test_security_oauth2_authorization_client_credentials_description.py diff --git a/docs/de/docs/reference/security/index.md b/docs/de/docs/reference/security/index.md index 4c2375f2f..ca25f7d95 100644 --- a/docs/de/docs/reference/security/index.md +++ b/docs/de/docs/reference/security/index.md @@ -21,6 +21,7 @@ from fastapi.security import ( OAuth2, OAuth2AuthorizationCodeBearer, OAuth2PasswordBearer, + OAuth2ClientCredentials, OAuth2PasswordRequestForm, OAuth2PasswordRequestFormStrict, OpenIdConnect, @@ -58,6 +59,8 @@ from fastapi.security import ( ::: fastapi.security.OAuth2PasswordBearer +::: fastapi.security.OAuth2ClientCredentials + ## OAuth2-Passwortformulare ::: fastapi.security.OAuth2PasswordRequestForm diff --git a/docs/en/docs/reference/security/index.md b/docs/en/docs/reference/security/index.md index 9a5c5e15f..22efd8ba5 100644 --- a/docs/en/docs/reference/security/index.md +++ b/docs/en/docs/reference/security/index.md @@ -21,6 +21,7 @@ from fastapi.security import ( OAuth2, OAuth2AuthorizationCodeBearer, OAuth2PasswordBearer, + OAuth2ClientCredentials, OAuth2PasswordRequestForm, OAuth2PasswordRequestFormStrict, OpenIdConnect, @@ -58,6 +59,8 @@ from fastapi.security import ( ::: fastapi.security.OAuth2PasswordBearer +::: fastapi.security.OAuth2ClientCredentials + ## OAuth2 Password Form ::: fastapi.security.OAuth2PasswordRequestForm diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 97845718d..f1e7f0fff 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -5,6 +5,8 @@ hide: # Release Notes +* Implement `OAuth2ClientCredentials` class. PR []() by [@rhysrevans3](https://github.com/rhysrevans3). + ## Latest Changes * 🌐 Add Turkish translation for `docs/tr/docs/tutorial/request-forms.md`. PR [#11553](https://github.com/tiangolo/fastapi/pull/11553) by [@hasansezertasan](https://github.com/hasansezertasan). diff --git a/fastapi/security/__init__.py b/fastapi/security/__init__.py index 3aa6bf21e..e951f21fc 100644 --- a/fastapi/security/__init__.py +++ b/fastapi/security/__init__.py @@ -8,6 +8,7 @@ from .http import HTTPBearer as HTTPBearer from .http import HTTPDigest as HTTPDigest from .oauth2 import OAuth2 as OAuth2 from .oauth2 import OAuth2AuthorizationCodeBearer as OAuth2AuthorizationCodeBearer +from .oauth2 import OAuth2ClientCredentials as OAuth2ClientCredentials from .oauth2 import OAuth2PasswordBearer as OAuth2PasswordBearer from .oauth2 import OAuth2PasswordRequestForm as OAuth2PasswordRequestForm from .oauth2 import OAuth2PasswordRequestFormStrict as OAuth2PasswordRequestFormStrict diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index 9720cace0..b5985b7d4 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -1,16 +1,17 @@ from typing import Any, Dict, List, Optional, Union, cast +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN + +# TODO: import from typing when deprecating Python 3.9 +from typing_extensions import Annotated, Doc + from fastapi.exceptions import HTTPException from fastapi.openapi.models import OAuth2 as OAuth2Model 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.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN - -# TODO: import from typing when deprecating Python 3.9 -from typing_extensions import Annotated, Doc class OAuth2PasswordRequestForm: @@ -595,6 +596,114 @@ class OAuth2AuthorizationCodeBearer(OAuth2): return param +class OAuth2ClientCredentials(OAuth2): + """ + OAuth2 flow for authentication using a bearer token obtained with an OAuth2 client + credentials flow. An instance of it would be used as a dependency. + """ + + def __init__( + self, + tokenUrl: Annotated[ + str, + Doc( + """ + The URL to obtain the OAuth2 token. + """ + ), + ], + refreshUrl: Annotated[ + Optional[str], + Doc( + """ + The URL to refresh the token and obtain a new one. + """ + ), + ] = None, + scheme_name: Annotated[ + Optional[str], + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + scopes: Annotated[ + Optional[Dict[str, str]], + Doc( + """ + The OAuth2 scopes that would be required by the *"path" operations* that + use this dependency. + """ + ), + ] = None, + description: Annotated[ + Optional[str], + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if no HTTP Auhtorization header is provided, required for + OAuth2 authentication, it will automatically cancel the request and + send the client an error. + + If `auto_error` is set to `False`, when the HTTP Authorization header + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, with OAuth2 + or in a cookie). + """ + ), + ] = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel( + clientCredentials=cast( + Any, + { + "tokenUrl": tokenUrl, + "refreshUrl": refreshUrl, + "scopes": scopes, + }, + ) + ) + super().__init__( + flows=flows, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> Optional[str]: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + if self.auto_error: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None # pragma: nocover + return param + + class SecurityScopes: """ This is a special class that you can define in a parameter in a dependency to @@ -626,7 +735,9 @@ class SecurityScopes: The list of all the scopes required by dependencies. """ ), - ] = scopes or [] + ] = ( + scopes or [] + ) self.scope_str: Annotated[ str, Doc( diff --git a/tests/test_security_oauth2_authorization_client_credentials.py b/tests/test_security_oauth2_authorization_client_credentials.py new file mode 100644 index 000000000..d23a5bc18 --- /dev/null +++ b/tests/test_security_oauth2_authorization_client_credentials.py @@ -0,0 +1,72 @@ +from typing import Optional + +from fastapi import FastAPI, Security +from fastapi.security import OAuth2ClientCredentials +from fastapi.testclient import TestClient + +app = FastAPI() + +oauth2_scheme = OAuth2ClientCredentials(tokenUrl="token", auto_error=True) + + +@app.get("/items/") +async def read_items(token: Optional[str] = Security(oauth2_scheme)): + return {"token": token} + + +client = TestClient(app) + + +def test_no_token(): + response = client.get("/items") + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_incorrect_token(): + response = client.get("/items", headers={"Authorization": "Non-existent testtoken"}) + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_token(): + response = client.get("/items", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "summary": "Read Items", + "operationId": "read_items_items__get", + "security": [{"OAuth2ClientCredentials": []}], + } + } + }, + "components": { + "securitySchemes": { + "OAuth2ClientCredentials": { + "type": "oauth2", + "flows": { + "clientCredentials": { + "tokenUrl": "token", + "scopes": {}, + } + }, + } + } + }, + } diff --git a/tests/test_security_oauth2_authorization_client_credentials_description.py b/tests/test_security_oauth2_authorization_client_credentials_description.py new file mode 100644 index 000000000..64a62203f --- /dev/null +++ b/tests/test_security_oauth2_authorization_client_credentials_description.py @@ -0,0 +1,77 @@ +from typing import Optional + +from fastapi import FastAPI, Security +from fastapi.security import OAuth2ClientCredentials +from fastapi.testclient import TestClient + +app = FastAPI() + +oauth2_scheme = OAuth2ClientCredentials( + tokenUrl="token", + description="OAuth2 Client Credentials Flow", + auto_error=True, +) + + +@app.get("/items/") +async def read_items(token: Optional[str] = Security(oauth2_scheme)): + return {"token": token} + + +client = TestClient(app) + + +def test_no_token(): + response = client.get("/items") + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_incorrect_token(): + response = client.get("/items", headers={"Authorization": "Non-existent testtoken"}) + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_token(): + response = client.get("/items", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/items/": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "summary": "Read Items", + "operationId": "read_items_items__get", + "security": [{"OAuth2ClientCredentials": []}], + } + } + }, + "components": { + "securitySchemes": { + "OAuth2ClientCredentials": { + "type": "oauth2", + "flows": { + "clientCredentials": { + "tokenUrl": "token", + "scopes": {}, + } + }, + "description": "OAuth2 Client Credentials Flow", + } + } + }, + } From e30065583e8e0c343dd01e756b1b774e42605459 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 9 May 2024 14:57:56 +0100 Subject: [PATCH 2/7] Running formatter. --- fastapi/security/oauth2.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index b5985b7d4..6ffc87eed 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -1,17 +1,16 @@ from typing import Any, Dict, List, Optional, Union, cast -from starlette.requests import Request -from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN - -# TODO: import from typing when deprecating Python 3.9 -from typing_extensions import Annotated, Doc - from fastapi.exceptions import HTTPException from fastapi.openapi.models import OAuth2 as OAuth2Model 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.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN + +# TODO: import from typing when deprecating Python 3.9 +from typing_extensions import Annotated, Doc class OAuth2PasswordRequestForm: @@ -735,9 +734,7 @@ class SecurityScopes: The list of all the scopes required by dependencies. """ ), - ] = ( - scopes or [] - ) + ] = scopes or [] self.scope_str: Annotated[ str, Doc( From cd5462595d32091550404cb04392dbae167f98eb Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 9 May 2024 15:00:15 +0100 Subject: [PATCH 3/7] Updating release note. --- docs/en/docs/release-notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index f1e7f0fff..e179c1a5a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -5,7 +5,7 @@ hide: # Release Notes -* Implement `OAuth2ClientCredentials` class. PR []() by [@rhysrevans3](https://github.com/rhysrevans3). +* Implement `OAuth2ClientCredentials` class. PR [#11560](https://github.com/tiangolo/fastapi/pull/11560) by [@rhysrevans3](https://github.com/rhysrevans3). ## Latest Changes From 55693c9e51941097cd658f6f09bf85e4835c5f0e Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 13 May 2024 09:56:46 +0100 Subject: [PATCH 4/7] Removing refreshUrl from client credentials flow. --- fastapi/security/oauth2.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index 6ffc87eed..c51323cde 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -1,16 +1,17 @@ from typing import Any, Dict, List, Optional, Union, cast +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN + +# TODO: import from typing when deprecating Python 3.9 +from typing_extensions import Annotated, Doc + from fastapi.exceptions import HTTPException from fastapi.openapi.models import OAuth2 as OAuth2Model 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.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN - -# TODO: import from typing when deprecating Python 3.9 -from typing_extensions import Annotated, Doc class OAuth2PasswordRequestForm: @@ -611,14 +612,6 @@ class OAuth2ClientCredentials(OAuth2): """ ), ], - refreshUrl: Annotated[ - Optional[str], - Doc( - """ - The URL to refresh the token and obtain a new one. - """ - ), - ] = None, scheme_name: Annotated[ Optional[str], Doc( @@ -676,7 +669,6 @@ class OAuth2ClientCredentials(OAuth2): Any, { "tokenUrl": tokenUrl, - "refreshUrl": refreshUrl, "scopes": scopes, }, ) @@ -734,7 +726,9 @@ class SecurityScopes: The list of all the scopes required by dependencies. """ ), - ] = scopes or [] + ] = ( + scopes or [] + ) self.scope_str: Annotated[ str, Doc( From 277ce1120643cabbb75ea497b1f949f82ae3ec17 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 08:57:19 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20for?= =?UTF-8?q?mat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/security/oauth2.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index c51323cde..1ca543e69 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -1,17 +1,16 @@ from typing import Any, Dict, List, Optional, Union, cast -from starlette.requests import Request -from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN - -# TODO: import from typing when deprecating Python 3.9 -from typing_extensions import Annotated, Doc - from fastapi.exceptions import HTTPException from fastapi.openapi.models import OAuth2 as OAuth2Model 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.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN + +# TODO: import from typing when deprecating Python 3.9 +from typing_extensions import Annotated, Doc class OAuth2PasswordRequestForm: @@ -726,9 +725,7 @@ class SecurityScopes: The list of all the scopes required by dependencies. """ ), - ] = ( - scopes or [] - ) + ] = scopes or [] self.scope_str: Annotated[ str, Doc( From 3b00893d54175db05ba8f47da37b4809d964e735 Mon Sep 17 00:00:00 2001 From: rhysrevans3 <34507919+rhysrevans3@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:21:22 +0000 Subject: [PATCH 6/7] Fixing spelling error. --- fastapi/security/oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index 1ca543e69..a2a9c2efc 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -644,7 +644,7 @@ class OAuth2ClientCredentials(OAuth2): bool, Doc( """ - By default, if no HTTP Auhtorization header is provided, required for + By default, if no HTTP Authorization header is provided, required for OAuth2 authentication, it will automatically cancel the request and send the client an error. From 76a12bfcfdf1447d53a7569a3b6d4c5656a44ba0 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 6 Jan 2025 09:32:36 +0000 Subject: [PATCH 7/7] Fixing spelling errror. --- fastapi/security/oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index c51323cde..4b989644e 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -645,7 +645,7 @@ class OAuth2ClientCredentials(OAuth2): bool, Doc( """ - By default, if no HTTP Auhtorization header is provided, required for + By default, if no HTTP Authorization header is provided, required for OAuth2 authentication, it will automatically cancel the request and send the client an error.