From ade9d830f60fb4823d56f9e798cf554299b442d0 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 11 Jun 2025 19:10:08 +0200 Subject: [PATCH] Fix `OAuth2` security scheme status code on "Not authenticated" error --- fastapi/security/oauth2.py | 44 +++++++++++++++++-- tests/test_security_oauth2.py | 3 +- tests/test_security_status_code_403_option.py | 11 +++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index 5ffad5986..5d2b80d2c 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -10,7 +10,7 @@ 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 typing_extensions import Annotated, Doc, Literal, deprecated class OAuth2PasswordRequestForm: @@ -369,20 +369,56 @@ class OAuth2(SecurityBase): """ ), ] = True, + not_authenticated_status_code: Annotated[ + Literal[401, 403], + Doc( + """ + By default, if no HTTP Authorization header provided and `auto_error` + is set to `True`, it will automatically raise an`HTTPException` with + the status code `401`. + + If your client relies on the old (incorrect) behavior and expects the + status code to be `403`, you can set `not_authenticated_status_code` to + `403` to achieve it. + + Keep in mind that this parameter is temporary and will be removed in + the near future. + """ + ), + deprecated( + """ + This parameter is temporary. It was introduced to give users time + to upgrade their clients to follow the new behavior and will eventually + be removed. + + Use it as a short-term workaround, but consider updating your clients + to align with the new behavior. + """ + ), + ] = 401, ): self.model = OAuth2Model( flows=cast(OAuthFlowsModel, flows), description=description ) self.scheme_name = scheme_name or self.__class__.__name__ self.auto_error = auto_error + self.not_authenticated_status_code = not_authenticated_status_code async def __call__(self, request: Request) -> Optional[str]: authorization = request.headers.get("Authorization") if not authorization: if self.auto_error: - raise HTTPException( - status_code=HTTP_403_FORBIDDEN, detail="Not authenticated" - ) + if self.not_authenticated_status_code == HTTP_403_FORBIDDEN: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, detail="Not authenticated" + ) + else: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: return None return authorization diff --git a/tests/test_security_oauth2.py b/tests/test_security_oauth2.py index 2b7e3457a..804e4152d 100644 --- a/tests/test_security_oauth2.py +++ b/tests/test_security_oauth2.py @@ -56,8 +56,9 @@ def test_security_oauth2_password_other_header(): def test_security_oauth2_password_bearer_no_header(): response = client.get("/users/me") - assert response.status_code == 403, response.text + assert response.status_code == 401, response.text assert response.json() == {"detail": "Not authenticated"} + assert response.headers["WWW-Authenticate"] == "Bearer" def test_strict_login_no_data(): diff --git a/tests/test_security_status_code_403_option.py b/tests/test_security_status_code_403_option.py index 29866bfd4..6d6e904ae 100644 --- a/tests/test_security_status_code_403_option.py +++ b/tests/test_security_status_code_403_option.py @@ -4,6 +4,7 @@ import pytest from fastapi import FastAPI, Security from fastapi.security.api_key import APIKeyBase, APIKeyCookie, APIKeyHeader, APIKeyQuery from fastapi.security.http import HTTPBase, HTTPBearer, HTTPDigest +from fastapi.security.oauth2 import OAuth2 from fastapi.security.open_id_connect_url import OpenIdConnect from fastapi.testclient import TestClient @@ -65,6 +66,10 @@ def test_apikey_status_code_403_on_auth_error_no_auto_error(auth: APIKeyBase): [ HTTPBearer(not_authenticated_status_code=403), OpenIdConnect(not_authenticated_status_code=403, openIdConnectUrl="/openid"), + OAuth2( + not_authenticated_status_code=403, + flows={"password": {"tokenUrl": "token", "scopes": {}}}, + ), ], ) def test_oauth2_status_code_403_on_auth_error(auth: Union[HTTPBase, OpenIdConnect]): @@ -95,6 +100,12 @@ def test_oauth2_status_code_403_on_auth_error(auth: Union[HTTPBase, OpenIdConnec openIdConnectUrl="/openid", auto_error=False, ), + OAuth2( + not_authenticated_status_code=403, + flows={"password": {"tokenUrl": "token", "scopes": {}}}, + auto_error=False, + ), + ], ) def test_oauth2_status_code_403_on_auth_error_no_auto_error(