diff --git a/docs/en/tutorial/authentication.md b/docs/en/tutorial/authentication.md index 1432141..0832076 100644 --- a/docs/en/tutorial/authentication.md +++ b/docs/en/tutorial/authentication.md @@ -72,6 +72,44 @@ if __name__ == "__main__": app.run() ``` +## OAuth2ClientCredentials + +Acquires a token from the token endpoint using the **Client Credentials** grant type and automatically refreshes it before expiry: + +```python +from fasthttp import FastHTTP +from fasthttp.auth import OAuth2ClientCredentials +from fasthttp.response import Response + +app = FastHTTP() + +auth = OAuth2ClientCredentials( + token_url="https://auth.example.com/oauth/token", + client_id="my-client", + client_secret="my-secret", + scopes=["read", "write"], +) + + +@app.get("https://api.example.com/users", auth=auth) +async def get_users(resp: Response) -> list: + return resp.json() + + +if __name__ == "__main__": + app.run() +``` + +The token is fetched lazily on the first request and cached. FastHTTP automatically requests a new token 60 seconds before the current one expires, based on the `expires_in` field in the token response. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `token_url` | Yes | OAuth2 token endpoint URL | +| `client_id` | Yes | Client identifier | +| `client_secret` | Yes | Client secret | +| `scopes` | No | List of scope strings to request | +| `extra` | No | Additional key-value pairs sent in the token request body | + ## Auth on Routers The `auth` parameter works on all `Router` decorators: diff --git a/docs/en/tutorial/openapi/swagger-ui.md b/docs/en/tutorial/openapi/swagger-ui.md index f965c85..22a4cb1 100644 --- a/docs/en/tutorial/openapi/swagger-ui.md +++ b/docs/en/tutorial/openapi/swagger-ui.md @@ -150,6 +150,7 @@ FastHTTP emits the following scheme entries automatically: | `BearerAuth` | `bearerAuth` | `http / bearer / JWT` | | `BasicAuth` | `basicAuth` | `http / basic` | | `DigestAuth` | `digestAuth` | `http / digest` | +| `OAuth2ClientCredentials` | `oauth2ClientCredentials` | `oauth2 / clientCredentials` | Schemes are only added to the schema when at least one route uses the corresponding `auth=` class. diff --git a/docs/ru/tutorial/authentication.md b/docs/ru/tutorial/authentication.md index 257770e..eaeffd2 100644 --- a/docs/ru/tutorial/authentication.md +++ b/docs/ru/tutorial/authentication.md @@ -72,6 +72,44 @@ if __name__ == "__main__": app.run() ``` +## OAuth2ClientCredentials + +Получает токен через грант **Client Credentials** и автоматически обновляет его до истечения срока действия: + +```python +from fasthttp import FastHTTP +from fasthttp.auth import OAuth2ClientCredentials +from fasthttp.response import Response + +app = FastHTTP() + +auth = OAuth2ClientCredentials( + token_url="https://auth.example.com/oauth/token", + client_id="my-client", + client_secret="my-secret", + scopes=["read", "write"], +) + + +@app.get("https://api.example.com/users", auth=auth) +async def get_users(resp: Response) -> list: + return resp.json() + + +if __name__ == "__main__": + app.run() +``` + +Токен запрашивается лениво (при первом запросе) и кешируется. FastHTTP автоматически запрашивает новый токен за 60 секунд до истечения текущего, основываясь на поле `expires_in` в ответе токен-эндпоинта. + +| Параметр | Обязательный | Описание | +|----------|-------------|----------| +| `token_url` | Да | URL токен-эндпоинта OAuth2 | +| `client_id` | Да | Идентификатор клиента | +| `client_secret` | Да | Секрет клиента | +| `scopes` | Нет | Список строк запрашиваемых разрешений | +| `extra` | Нет | Дополнительные поля, отправляемые в теле запроса токена | + ## Auth в роутерах Параметр `auth` работает на всех декораторах `Router`: diff --git a/docs/ru/tutorial/openapi/swagger-ui.md b/docs/ru/tutorial/openapi/swagger-ui.md index 1a9a0e3..c69116b 100644 --- a/docs/ru/tutorial/openapi/swagger-ui.md +++ b/docs/ru/tutorial/openapi/swagger-ui.md @@ -138,6 +138,7 @@ FastHTTP добавляет схемы автоматически: | `BearerAuth` | `bearerAuth` | `http / bearer / JWT` | | `BasicAuth` | `basicAuth` | `http / basic` | | `DigestAuth` | `digestAuth` | `http / digest` | +| `OAuth2ClientCredentials` | `oauth2ClientCredentials` | `oauth2 / clientCredentials` | Схема добавляется в документ только если хотя бы один маршрут использует соответствующий `auth=`. diff --git a/fasthttp/__init__.py b/fasthttp/__init__.py index f9c89cd..f8b55d6 100644 --- a/fasthttp/__init__.py +++ b/fasthttp/__init__.py @@ -1,7 +1,7 @@ from . import status from .__meta__ import __version__ from .app import FastHTTP -from .auth import BasicAuth, BearerAuth, DigestAuth +from .auth import BasicAuth, BearerAuth, DigestAuth, OAuth2ClientCredentials from .dependencies import Depends from .events import EventHooks from .middleware import ( @@ -32,6 +32,7 @@ "FastHTTP", "MiddlewareChain", "MiddlewareManager", + "OAuth2ClientCredentials", "RetryMiddleware", "Router", "SessionMiddleware", diff --git a/fasthttp/app.py b/fasthttp/app.py index 0b01dec..de5a574 100644 --- a/fasthttp/app.py +++ b/fasthttp/app.py @@ -52,7 +52,7 @@ from pydantic import BaseModel - from .auth import BasicAuth, BearerAuth, DigestAuth + from .auth import BasicAuth, BearerAuth, DigestAuth, OAuth2ClientCredentials from .response import Response from .types import RequestsOptinal @@ -523,7 +523,7 @@ async def lifespan(app: FastHTTP): if self.generate_startup_uuid: if self.startup_uuid_version == "v7": if sys.version_info >= (3, 13): - self.startup_uuid = str(uuid.uuid7()) + self.startup_uuid = str(uuid.uuid7()) # type: ignore else: self.startup_uuid = str(uuid.uuid4()) else: @@ -652,7 +652,7 @@ def _add_route( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: def decorator(func: Callable[..., object]) -> Callable[..., object]: @@ -784,7 +784,7 @@ def get( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: return self._add_route( @@ -811,7 +811,7 @@ def post( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: return self._add_route( @@ -839,7 +839,7 @@ def put( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: return self._add_route( @@ -867,7 +867,7 @@ def patch( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: return self._add_route( @@ -895,7 +895,7 @@ def delete( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: return self._add_route( @@ -922,7 +922,7 @@ def head( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: return self._add_route( @@ -948,7 +948,7 @@ def options( tags: list[str] | None = None, dependencies: list | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: return self._add_route( diff --git a/fasthttp/auth.py b/fasthttp/auth.py index 2ce64af..4391de6 100644 --- a/fasthttp/auth.py +++ b/fasthttp/auth.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING import httpx @@ -31,6 +32,37 @@ def __init__(self, token: str) -> None: self.token = token +class OAuth2ClientCredentials: + """ + OAuth2 Client Credentials flow with automatic token refresh. + + Acquires an access token from the token endpoint on first use + and automatically refreshes it before expiry. + + Usage: + auth = OAuth2ClientCredentials( + token_url="https://auth.example.com/oauth/token", + client_id="my-client", + client_secret="my-secret", + scopes=["read", "write"], + ) + """ + + def __init__( + self, + token_url: str, + client_id: str, + client_secret: str, + scopes: list[str] | None = None, + extra: dict[str, str] | None = None, + ) -> None: + self.token_url = token_url + self.client_id = client_id + self.client_secret = client_secret + self.scopes = scopes + self.extra = extra or {} + + class _HttpxBearerAuth(httpx.Auth): def __init__(self, token: str) -> None: self._token = token @@ -42,8 +74,56 @@ def auth_flow( yield request +class _HttpxOAuth2Auth(httpx.Auth): + """ + httpx-compatible auth that handles the OAuth2 Client Credentials flow. + + Fetches a token on first request and automatically refreshes + before the current token expires. + """ + + def __init__(self, config: OAuth2ClientCredentials) -> None: + self._config = config + self._access_token: str | None = None + self._expires_at: float = 0.0 + self._client = httpx.Client() + + def _ensure_token(self) -> str: + if self._access_token and time.monotonic() < self._expires_at: + return self._access_token + + data: dict[str, str] = { + "grant_type": "client_credentials", + "client_id": self._config.client_id, + "client_secret": self._config.client_secret, + } + if self._config.scopes: + data["scope"] = " ".join(self._config.scopes) + data.update(self._config.extra) + + resp = self._client.post( + self._config.token_url, + data=data, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + body = resp.json() + + self._access_token = body["access_token"] + expires_in = body.get("expires_in", 3600) + self._expires_at = time.monotonic() + expires_in - 60 + + return self._access_token + + def auth_flow( + self, request: httpx.Request + ) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = f"Bearer {self._ensure_token()}" + yield request + + def resolve_auth( - auth: BasicAuth | DigestAuth | BearerAuth | None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None, ) -> httpx.Auth | None: """Convert a fasthttp auth object to an httpx-compatible auth.""" if isinstance(auth, BasicAuth): @@ -52,4 +132,6 @@ def resolve_auth( return httpx.DigestAuth(auth.username, auth.password) if isinstance(auth, BearerAuth): return _HttpxBearerAuth(auth.token) + if isinstance(auth, OAuth2ClientCredentials): + return _HttpxOAuth2Auth(auth) return None diff --git a/fasthttp/openapi/generator.py b/fasthttp/openapi/generator.py index 389f2e4..87b9c32 100644 --- a/fasthttp/openapi/generator.py +++ b/fasthttp/openapi/generator.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from fasthttp.app import FastHTTP - from fasthttp.auth import BasicAuth, BearerAuth, DigestAuth + from fasthttp.auth import BasicAuth, BearerAuth, DigestAuth, OAuth2ClientCredentials from fasthttp.routing import Route @@ -259,16 +259,18 @@ def _normalize_path(url: str) -> str: return api_path -def _get_security_scheme_name(auth: BasicAuth | BearerAuth | DigestAuth) -> str: +def _get_security_scheme_name( + auth: BasicAuth | BearerAuth | DigestAuth | OAuth2ClientCredentials, +) -> str: """Return the securityScheme key for a given auth object.""" - from fasthttp.auth import BasicAuth, BearerAuth, DigestAuth - if isinstance(auth, BearerAuth): return "bearerAuth" if isinstance(auth, BasicAuth): return "basicAuth" if isinstance(auth, DigestAuth): return "digestAuth" + if isinstance(auth, OAuth2ClientCredentials): + return "oauth2ClientCredentials" return "auth" @@ -276,8 +278,6 @@ def _collect_security_schemes( routes: list[Route], ) -> dict[str, Any]: """Build components/securitySchemes from auth objects used in routes.""" - from fasthttp.auth import BasicAuth, BearerAuth, DigestAuth - schemes: dict[str, Any] = {} for route in routes: @@ -289,6 +289,16 @@ def _collect_security_schemes( schemes["basicAuth"] = {"type": "http", "scheme": "basic"} elif isinstance(route.auth, DigestAuth) and "digestAuth" not in schemes: schemes["digestAuth"] = {"type": "http", "scheme": "digest"} + elif isinstance(route.auth, OAuth2ClientCredentials) and "oauth2ClientCredentials" not in schemes: + schemes["oauth2ClientCredentials"] = { + "type": "oauth2", + "flows": { + "clientCredentials": { + "tokenUrl": route.auth.token_url, + "scopes": {s: "" for s in route.auth.scopes} if route.auth.scopes else {}, + } + }, + } return schemes diff --git a/fasthttp/routing.py b/fasthttp/routing.py index b24ffe9..e2b448e 100644 --- a/fasthttp/routing.py +++ b/fasthttp/routing.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -from .auth import BasicAuth, BearerAuth, DigestAuth +from .auth import BasicAuth, BearerAuth, DigestAuth, OAuth2ClientCredentials from .events import ErrorHook, EventHooks, RequestHook, ResponseHook from .helpers.route_inspect import validate_handler from .helpers.routing import join_prefix as _join_prefix @@ -73,8 +73,8 @@ class Route(BaseModel): raise_for_status: bool = False """When True, raises FastHTTPBadStatusError on 4xx/5xx for this route only.""" - auth: BasicAuth | DigestAuth | BearerAuth | None = None - """Authentication for this route (BasicAuth, DigestAuth, or BearerAuth).""" + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None + """Authentication for this route (BasicAuth, DigestAuth, BearerAuth, or OAuth2ClientCredentials).""" responses: dict[int, dict[Literal["model"], type[BaseModel]]] = Field( default_factory=dict @@ -108,7 +108,7 @@ def __init__( dependencies: list[Any] | None = None, skip_request: bool = False, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> None: @@ -146,7 +146,7 @@ class _RouteDef: dependencies: list[Any] skip_request: bool raise_for_status: bool - auth: BasicAuth | DigestAuth | BearerAuth | None + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None responses: dict[int, dict[Literal["model"], type[BaseModel]]] @@ -234,7 +234,7 @@ def _add_route( dependencies: list[Any] | None = None, skip_request: bool = False, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: def decorator(func: Callable[..., object]) -> Callable[..., object]: @@ -271,7 +271,7 @@ def get( tags: list[str] | None = None, dependencies: list[Any] | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: """Decorator for registering a GET route on the router.""" @@ -299,7 +299,7 @@ def post( tags: list[str] | None = None, dependencies: list[Any] | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: """Decorator for registering a POST route on the router.""" @@ -328,7 +328,7 @@ def put( tags: list[str] | None = None, dependencies: list[Any] | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: """Decorator for registering a PUT route on the router.""" @@ -357,7 +357,7 @@ def patch( tags: list[str] | None = None, dependencies: list[Any] | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: """Decorator for registering a PATCH route on the router.""" @@ -386,7 +386,7 @@ def delete( tags: list[str] | None = None, dependencies: list[Any] | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: """Decorator for registering a DELETE route on the router.""" @@ -414,7 +414,7 @@ def head( tags: list[str] | None = None, dependencies: list[Any] | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: """Decorator for registering a HEAD route on the router.""" @@ -441,7 +441,7 @@ def options( tags: list[str] | None = None, dependencies: list[Any] | None = None, raise_for_status: bool = False, - auth: BasicAuth | DigestAuth | BearerAuth | None = None, + auth: BasicAuth | DigestAuth | BearerAuth | OAuth2ClientCredentials | None = None, responses: dict[int, dict[Literal["model"], type[BaseModel]]] | None = None, ) -> Callable[[Callable[..., object]], Callable[..., object]]: """Decorator for registering an OPTIONS route on the router."""