]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🐛 Fix OpenAPI security scheme OAuth2 scopes declaration, deduplicate security schemes...
authorSebastián Ramírez <tiangolo@gmail.com>
Thu, 4 Dec 2025 12:59:24 +0000 (04:59 -0800)
committerGitHub <noreply@github.com>
Thu, 4 Dec 2025 12:59:24 +0000 (13:59 +0100)
fastapi/openapi/utils.py
tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py [new file with mode: 0644]

index dbc93d28920e41f534ff0e84f2146f4a5de99a60..e7e6da2f764572e5d9e8e3b605ba03b18cafb7b5 100644 (file)
@@ -79,7 +79,8 @@ def get_openapi_security_definitions(
     flat_dependant: Dependant,
 ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
     security_definitions = {}
-    operation_security = []
+    # Use a dict to merge scopes for same security scheme
+    operation_security_dict: Dict[str, List[str]] = {}
     for security_requirement in flat_dependant.security_requirements:
         security_definition = jsonable_encoder(
             security_requirement.security_scheme.model,
@@ -88,7 +89,15 @@ def get_openapi_security_definitions(
         )
         security_name = security_requirement.security_scheme.scheme_name
         security_definitions[security_name] = security_definition
-        operation_security.append({security_name: security_requirement.scopes})
+        # Merge scopes for the same security scheme
+        if security_name not in operation_security_dict:
+            operation_security_dict[security_name] = []
+        for scope in security_requirement.scopes or []:
+            if scope not in operation_security_dict[security_name]:
+                operation_security_dict[security_name].append(scope)
+    operation_security = [
+        {name: scopes} for name, scopes in operation_security_dict.items()
+    ]
     return security_definitions, operation_security
 
 
diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py
new file mode 100644 (file)
index 0000000..644df8d
--- /dev/null
@@ -0,0 +1,131 @@
+# Ref: https://github.com/fastapi/fastapi/issues/14454
+
+from typing import Optional
+
+from fastapi import APIRouter, FastAPI, Security
+from fastapi.security import OAuth2AuthorizationCodeBearer
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+
+oauth2_scheme = OAuth2AuthorizationCodeBearer(
+    authorizationUrl="authorize",
+    tokenUrl="token",
+    auto_error=True,
+    scopes={"read": "Read access", "write": "Write access"},
+)
+
+app = FastAPI(dependencies=[Security(oauth2_scheme)])
+
+
+@app.get("/")
+async def root():
+    return {"message": "Hello World"}
+
+
+router = APIRouter(dependencies=[Security(oauth2_scheme, scopes=["read"])])
+
+
+@router.get("/items/")
+async def read_items(token: Optional[str] = Security(oauth2_scheme)):
+    return {"token": token}
+
+
+@router.post("/items/")
+async def create_item(
+    token: Optional[str] = Security(oauth2_scheme, scopes=["read", "write"]),
+):
+    return {"token": token}
+
+
+app.include_router(router)
+
+client = TestClient(app)
+
+
+def test_root():
+    response = client.get("/", headers={"Authorization": "Bearer testtoken"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"message": "Hello World"}
+
+
+def test_read_token():
+    response = client.get("/items/", headers={"Authorization": "Bearer testtoken"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"token": "testtoken"}
+
+
+def test_create_token():
+    response = client.post("/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() == snapshot(
+        {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/": {
+                    "get": {
+                        "summary": "Root",
+                        "operationId": "root__get",
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            }
+                        },
+                        "security": [{"OAuth2AuthorizationCodeBearer": []}],
+                    }
+                },
+                "/items/": {
+                    "get": {
+                        "summary": "Read Items",
+                        "operationId": "read_items_items__get",
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            }
+                        },
+                        "security": [
+                            {"OAuth2AuthorizationCodeBearer": ["read"]},
+                        ],
+                    },
+                    "post": {
+                        "summary": "Create Item",
+                        "operationId": "create_item_items__post",
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            }
+                        },
+                        "security": [
+                            {"OAuth2AuthorizationCodeBearer": ["read", "write"]},
+                        ],
+                    },
+                },
+            },
+            "components": {
+                "securitySchemes": {
+                    "OAuth2AuthorizationCodeBearer": {
+                        "type": "oauth2",
+                        "flows": {
+                            "authorizationCode": {
+                                "scopes": {
+                                    "read": "Read access",
+                                    "write": "Write access",
+                                },
+                                "authorizationUrl": "authorize",
+                                "tokenUrl": "token",
+                            }
+                        },
+                    }
+                }
+            },
+        }
+    )