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", + } + } + }, + }