Skip to content

Commit 214eee3

Browse files
committed
Improve guard security integration
1 parent b66e1a5 commit 214eee3

File tree

4 files changed

+82
-16
lines changed

4 files changed

+82
-16
lines changed

docs/guards.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Guards and Authentication
22

3-
PyNest now supports route guards similar to NestJS. Guards are classes that implement custom authorization logic. Use the `UseGuards` decorator to attach one or more guards to a controller or to specific routes.
3+
PyNest now supports route guards similar to NestJS. Guards are classes that implement custom authorization logic. Use the `UseGuards` decorator to attach one or more guards to a controller or to specific routes. If a guard defines a FastAPI security scheme via the ``security_scheme`` attribute, the generated OpenAPI schema will mark the route as protected and the interactive docs will allow entering credentials.
44

55
```python
66
from fastapi import Request
@@ -23,26 +23,31 @@ When the guard returns `False`, a `403 Forbidden` response is sent automatically
2323

2424
## JWT Authentication Example
2525

26-
You can use third-party libraries like `pyjwt` to validate tokens inside a guard.
26+
You can use third-party libraries like `pyjwt` together with FastAPI's security utilities.
2727

2828
```python
2929
import jwt
3030
from fastapi import Request
31+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
3132
from nest.core import BaseGuard
3233

3334
class JWTGuard(BaseGuard):
34-
def can_activate(self, request: Request) -> bool:
35-
auth = request.headers.get("Authorization", "")
36-
token = auth.replace("Bearer ", "")
35+
security_scheme = HTTPBearer()
36+
37+
def can_activate(
38+
self, request: Request, credentials: HTTPAuthorizationCredentials
39+
) -> bool:
3740
try:
38-
payload = jwt.decode(token, "your-secret", algorithms=["HS256"])
41+
payload = jwt.decode(
42+
credentials.credentials, "your-secret", algorithms=["HS256"]
43+
)
3944
except jwt.PyJWTError:
4045
return False
4146
request.state.user = payload.get("sub")
4247
return True
4348
```
4449

45-
Attach the guard with `@UseGuards(JWTGuard)` on controllers or routes to secure them.
50+
Attach the guard with `@UseGuards(JWTGuard)` on controllers or routes to secure them. Because ``JWTGuard`` specifies a ``security_scheme`` the route will display a lock icon in the docs and allow entering a token.
4651

4752
## Controller vs. Route Guards
4853

@@ -102,3 +107,10 @@ class AsyncGuard(BaseGuard):
102107

103108
PyNest awaits the result automatically.
104109

110+
## OpenAPI Integration
111+
112+
When a guard sets the ``security_scheme`` attribute, the generated OpenAPI schema
113+
includes the corresponding security requirement. The docs page will show a lock
114+
icon next to the route and present an input box for the token or credentials.
115+
This works with any ``fastapi.security`` scheme (e.g. ``HTTPBearer``, ``OAuth2PasswordBearer``).
116+

nest/core/decorators/controller.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ def _collect_guards(cls: Type, method: callable) -> List[BaseGuard]:
138138
return guards
139139

140140

141-
def add_route_to_router(router: APIRouter, method_function: callable, cls: Type) -> None:
141+
def add_route_to_router(
142+
router: APIRouter, method_function: callable, cls: Type
143+
) -> None:
142144
"""Add the configured route to the router."""
143145
route_kwargs = {
144146
"path": method_function.__route_path__,
@@ -154,7 +156,7 @@ def add_route_to_router(router: APIRouter, method_function: callable, cls: Type)
154156
if guards:
155157
dependencies = route_kwargs.get("dependencies", [])
156158
for guard in guards:
157-
dependencies.append(Depends(guard()))
159+
dependencies.append(Depends(guard.as_dependency()))
158160
route_kwargs["dependencies"] = dependencies
159161

160162
router.add_api_route(**route_kwargs)

nest/core/guards.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,51 @@
1-
from fastapi import Request, HTTPException, status
1+
from fastapi import Request, HTTPException, status, Security
2+
from fastapi.security.base import SecurityBase
23
import inspect
34

45

56
class BaseGuard:
6-
"""Base class for creating route guards."""
7+
"""Base class for creating route guards.
78
8-
def can_activate(self, request: Request) -> bool:
9+
If ``security_scheme`` is set to an instance of ``fastapi.security.SecurityBase``
10+
the guard will be injected with the credentials from that scheme and the
11+
corresponding security requirement will appear in the generated OpenAPI
12+
schema.
13+
"""
14+
15+
security_scheme: SecurityBase | None = None
16+
17+
def can_activate(self, request: Request, credentials=None) -> bool:
918
"""Override this method with your authorization logic."""
1019
raise NotImplementedError
1120

12-
async def __call__(self, request: Request):
13-
result = self.can_activate(request)
21+
async def __call__(self, request: Request, credentials=None):
22+
result = self.can_activate(request, credentials)
1423
if inspect.isawaitable(result):
1524
result = await result
1625
if not result:
17-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
26+
raise HTTPException(
27+
status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden"
28+
)
29+
30+
@classmethod
31+
def as_dependency(cls):
32+
"""Return a dependency callable for FastAPI routes."""
33+
34+
if cls.security_scheme is None:
35+
36+
async def dependency(request: Request):
37+
guard = cls()
38+
await guard(request)
39+
40+
return dependency
41+
42+
security_scheme = cls.security_scheme
43+
44+
async def dependency(request: Request, credentials=Security(security_scheme)):
45+
guard = cls()
46+
await guard(request, credentials)
1847

48+
return dependency
1949

2050

2151
def UseGuards(*guards):

tests/test_core/test_decorators/test_guard.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from fastapi import Request
44

5+
from fastapi.security import HTTPBearer
6+
57
from nest.core import Controller, Get, UseGuards, BaseGuard
68

79

@@ -14,6 +16,13 @@ def can_activate(self, request: Request) -> bool:
1416
return True
1517

1618

19+
class BearerGuard(BaseGuard):
20+
security_scheme = HTTPBearer()
21+
22+
def can_activate(self, request: Request, credentials) -> bool:
23+
return True
24+
25+
1726
@Controller("/guard")
1827
class GuardController:
1928
@Get("/")
@@ -32,4 +41,17 @@ def test_guard_added_to_route_dependencies():
3241
route = router.routes[0]
3342
deps = route.dependencies
3443
assert len(deps) == 1
35-
assert isinstance(deps[0].dependency, SimpleGuard)
44+
assert callable(deps[0].dependency)
45+
46+
47+
def test_openapi_security_requirement():
48+
@Controller("/bearer")
49+
class BearerController:
50+
@Get("/")
51+
@UseGuards(BearerGuard)
52+
def root(self):
53+
return {"ok": True}
54+
55+
router = BearerController.get_router()
56+
route = router.routes[0]
57+
assert route.dependant.security_requirements

0 commit comments

Comments
 (0)