Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/en/tutorial/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/en/tutorial/openapi/swagger-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
38 changes: 38 additions & 0 deletions docs/ru/tutorial/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
1 change: 1 addition & 0 deletions docs/ru/tutorial/openapi/swagger-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ FastHTTP добавляСт схСмы автоматичСски:
| `BearerAuth` | `bearerAuth` | `http / bearer / JWT` |
| `BasicAuth` | `basicAuth` | `http / basic` |
| `DigestAuth` | `digestAuth` | `http / digest` |
| `OAuth2ClientCredentials` | `oauth2ClientCredentials` | `oauth2 / clientCredentials` |

Π‘Ρ…Π΅ΠΌΠ° добавляСтся Π² Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ссли хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ ΡΠΎΠΎΡ‚Π²Π΅Ρ‚ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ `auth=`.

Expand Down
3 changes: 2 additions & 1 deletion fasthttp/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -32,6 +32,7 @@
"FastHTTP",
"MiddlewareChain",
"MiddlewareManager",
"OAuth2ClientCredentials",
"RetryMiddleware",
"Router",
"SessionMiddleware",
Expand Down
20 changes: 10 additions & 10 deletions fasthttp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
84 changes: 83 additions & 1 deletion fasthttp/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import time
from typing import TYPE_CHECKING

import httpx
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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
22 changes: 16 additions & 6 deletions fasthttp/openapi/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -259,25 +259,25 @@ 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"


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:
Expand All @@ -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

Expand Down
Loading
Loading