diff --git a/README.md b/README.md index 0250824..7d61cb6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Install only the extras you need: ```bash uv add "fastapi-toolsets[cli]" uv add "fastapi-toolsets[metrics]" +uv add "fastapi-toolsets[security]" uv add "fastapi-toolsets[pytest]" ``` @@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]" ### Optional +- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers - **CLI**: Django-like command-line interface with fixture management and custom commands support - **Metrics**: Prometheus metrics endpoint with provider/collector registry - **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities diff --git a/docs/index.md b/docs/index.md index ac923e4..3ef1de8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +31,7 @@ Install only the extras you need: ```bash uv add "fastapi-toolsets[cli]" uv add "fastapi-toolsets[metrics]" +uv add "fastapi-toolsets[security]" uv add "fastapi-toolsets[pytest]" ``` @@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]" ### Optional +- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers - **CLI**: Django-like command-line interface with fixture management and custom commands support - **Metrics**: Prometheus metrics endpoint with provider/collector registry - **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities diff --git a/pyproject.toml b/pyproject.toml index 1c30b3a..46653a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,13 +50,17 @@ cli = [ metrics = [ "prometheus_client>=0.20.0", ] +security = [ + "async-lru>=1.0", + "httpx>=0.25.0", +] pytest = [ "httpx>=0.25.0", "pytest-xdist>=3.0.0", "pytest>=8.0.0", ] all = [ - "fastapi-toolsets[cli,metrics,pytest]", + "fastapi-toolsets[cli,metrics,pytest,security]", ] [project.scripts] @@ -73,6 +77,7 @@ dev = [ "ty>=0.0.1a0", ] tests = [ + "async-lru>=1.0", "coverage>=7.0.0", "httpx>=0.25.0", "pytest-anyio>=0.0.0", diff --git a/src/fastapi_toolsets/security/oauth.py b/src/fastapi_toolsets/security/oauth.py index 4c27812..ad1bb18 100644 --- a/src/fastapi_toolsets/security/oauth.py +++ b/src/fastapi_toolsets/security/oauth.py @@ -5,18 +5,15 @@ import binascii import hmac import json import secrets -import time as _time from typing import Any from urllib.parse import urlencode import httpx +from async_lru import alru_cache from fastapi.responses import RedirectResponse -_discovery_cache: dict[str, tuple[dict[str, Any], float]] = {} -_DISCOVERY_TTL_SECONDS = 3600 # 1 hour -_DISCOVERY_CACHE_MAX = 32 - +@alru_cache(maxsize=32) async def oauth_resolve_provider_urls( discovery_url: str, ) -> tuple[str, str, str | None]: @@ -29,17 +26,10 @@ async def oauth_resolve_provider_urls( A ``(authorization_url, token_url, userinfo_url)`` tuple. *userinfo_url* is ``None`` when the provider does not advertise one. """ - now = _time.time() - cached = _discovery_cache.get(discovery_url) - if cached is None or now - cached[1] > _DISCOVERY_TTL_SECONDS: - async with httpx.AsyncClient() as client: - resp = await client.get(discovery_url) - resp.raise_for_status() - if len(_discovery_cache) >= _DISCOVERY_CACHE_MAX: - oldest = min(_discovery_cache, key=lambda k: _discovery_cache[k][1]) - del _discovery_cache[oldest] - _discovery_cache[discovery_url] = (resp.json(), now) - cfg = _discovery_cache[discovery_url][0] + async with httpx.AsyncClient() as client: + resp = await client.get(discovery_url) + resp.raise_for_status() + cfg = resp.json() return ( cfg["authorization_endpoint"], cfg["token_endpoint"], diff --git a/tests/test_security.py b/tests/test_security.py index 5f33f9f..2a8d9c2 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1123,11 +1123,11 @@ class TestResolveProviderUrls: mock_resp.json.return_value = self._discovery() cm, mock_client = _make_async_client_mock(get_return=mock_resp) - with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): - with patch("httpx.AsyncClient", return_value=cm): - auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( - "https://auth.example.com/.well-known/openid-configuration" - ) + oauth_resolve_provider_urls.cache_clear() + with patch("httpx.AsyncClient", return_value=cm): + auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( + "https://auth.example.com/.well-known/openid-configuration" + ) assert auth_url == "https://auth.example.com/authorize" assert token_url == "https://auth.example.com/token" @@ -1140,11 +1140,11 @@ class TestResolveProviderUrls: mock_resp.json.return_value = self._discovery(userinfo=False) cm, mock_client = _make_async_client_mock(get_return=mock_resp) - with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): - with patch("httpx.AsyncClient", return_value=cm): - _, _, userinfo_url = await oauth_resolve_provider_urls( - "https://auth.example.com/.well-known/openid-configuration" - ) + oauth_resolve_provider_urls.cache_clear() + with patch("httpx.AsyncClient", return_value=cm): + _, _, userinfo_url = await oauth_resolve_provider_urls( + "https://auth.example.com/.well-known/openid-configuration" + ) assert userinfo_url is None @@ -1156,10 +1156,10 @@ class TestResolveProviderUrls: cm, mock_client = _make_async_client_mock(get_return=mock_resp) url = "https://auth.example.com/.well-known/openid-configuration" - with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): - with patch("httpx.AsyncClient", return_value=cm): - await oauth_resolve_provider_urls(url) - await oauth_resolve_provider_urls(url) + oauth_resolve_provider_urls.cache_clear() + with patch("httpx.AsyncClient", return_value=cm): + await oauth_resolve_provider_urls(url) + await oauth_resolve_provider_urls(url) assert mock_client.get.call_count == 1 diff --git a/uv.lock b/uv.lock index 1a5f98e..0913328 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -332,6 +341,7 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "async-lru" }, { name = "httpx" }, { name = "prometheus-client" }, { name = "pytest" }, @@ -349,9 +359,14 @@ pytest = [ { name = "pytest" }, { name = "pytest-xdist" }, ] +security = [ + { name = "async-lru" }, + { name = "httpx" }, +] [package.dev-dependencies] dev = [ + { name = "async-lru" }, { name = "bcrypt" }, { name = "coverage" }, { name = "fastapi-toolsets", extra = ["all"] }, @@ -376,6 +391,7 @@ docs-src = [ { name = "bcrypt" }, ] tests = [ + { name = "async-lru" }, { name = "coverage" }, { name = "httpx" }, { name = "pytest" }, @@ -386,10 +402,12 @@ tests = [ [package.metadata] requires-dist = [ + { name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "fastapi", specifier = ">=0.100.0" }, - { name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" }, + { name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" }, { name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" }, + { name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" }, { name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" }, @@ -397,10 +415,11 @@ requires-dist = [ { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" }, ] -provides-extras = ["cli", "metrics", "pytest", "all"] +provides-extras = ["cli", "metrics", "security", "pytest", "all"] [package.metadata.requires-dev] dev = [ + { name = "async-lru", specifier = ">=1.0" }, { name = "bcrypt", specifier = ">=4.0.0" }, { name = "coverage", specifier = ">=7.0.0" }, { name = "fastapi-toolsets", extras = ["all"] }, @@ -423,6 +442,7 @@ docs = [ ] docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }] tests = [ + { name = "async-lru", specifier = ">=1.0" }, { name = "coverage", specifier = ">=7.0.0" }, { name = "httpx", specifier = ">=0.25.0" }, { name = "pytest", specifier = ">=8.0.0" },