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/docs/module/security.md b/docs/module/security.md new file mode 100644 index 0000000..10ebd40 --- /dev/null +++ b/docs/module/security.md @@ -0,0 +1,354 @@ +# Security + +Composable authentication helpers for FastAPI that use `Security()` for OpenAPI documentation and accept user-provided validator functions with full type flexibility. + +## Overview + +The `security` module provides four auth source classes, a `MultiAuth` factory, and a set of OAuth 2.0 / OIDC helper utilities. Each auth class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as: + +```python +await validator(credential, **kwargs) +``` + +where `kwargs` are the extra keyword arguments provided at instantiation (roles, permissions, enums, etc.). The validator returns the authenticated identity (e.g. a `User` model) which becomes the route dependency value. + +```python +from fastapi import Security +from fastapi_toolsets.security import BearerTokenAuth + +async def verify_token(token: str, *, role: str) -> User: + user = await db.get_by_token(token) + if not user or user.role != role: + raise UnauthorizedError() + return user + +bearer_admin = BearerTokenAuth(verify_token, role="admin") + +@app.get("/admin") +async def admin_route(user: User = Security(bearer_admin)): + return user +``` + +## Auth sources + +### [`BearerTokenAuth`](../reference/security.md#fastapi_toolsets.security.BearerTokenAuth) + +Reads the `Authorization: Bearer ` header. Wraps `HTTPBearer` for OpenAPI. + +```python +from fastapi_toolsets.security import BearerTokenAuth + +bearer = BearerTokenAuth(validator=verify_token) + +@app.get("/me") +async def me(user: User = Security(bearer)): + return user +``` + +#### Token prefix + +The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens that start with a given string. The prefix is **kept** in the value passed to the validator — store and compare tokens with their prefix included. + +This lets you deploy multiple `BearerTokenAuth` instances in the same application and disambiguate them efficiently in `MultiAuth`: + +```python +user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..." +org_bearer = BearerTokenAuth(verify_org, prefix="org_") # matches "Bearer org_..." +``` + +Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens. + +#### Token generation + +`BearerTokenAuth.generate_token()` produces a secure random token ready to store in your database and return to the client. If a prefix is configured it is prepended automatically: + +```python +bearer = BearerTokenAuth(verify_token, prefix="user_") + +token = bearer.generate_token() # e.g. "user_Xk3mN..." +await db.store_token(user_id, token) +return {"access_token": token, "token_type": "bearer"} +``` + +The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives the full token (prefix included) to compare against the stored value. + +### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth) + +Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI. + +Cookies are issued with the `Secure` flag set by default, meaning they are only transmitted over HTTPS. Set `secure=False` when running locally over plain HTTP: + +```python +from fastapi_toolsets.security import CookieAuth + +# Production (HTTPS) — default +cookie_auth = CookieAuth("session", validator=verify_session) + +# Local development (HTTP only) +cookie_auth = CookieAuth("session", validator=verify_session, secure=False) + +@app.get("/me") +async def me(user: User = Security(cookie_auth)): + return user +``` + +#### Signed cookies + +Pass `secret_key` to enable HMAC-SHA256 signed, tamper-proof cookies. The cookie payload includes an expiry timestamp (`ttl`, default 24 h). No database entry is required — the signature is self-contained. + +Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to clear it on logout: + +```python +# Production +cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret") + +# Local development +cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret", secure=False) + +@app.post("/login") +async def login(response: Response): + cookie_auth.set_cookie(response, user_id) + return {"ok": True} + +@app.post("/logout") +async def logout(response: Response): + cookie_auth.delete_cookie(response) + return {"ok": True} + +@app.get("/me") +async def me(user: User = Security(cookie_auth)): + return user +``` + +When `secret_key` is not set, the raw cookie value is passed directly to the validator (stateful session behaviour — you manage the session store). + +### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth) + +Reads an API key from a named HTTP header. Wraps `APIKeyHeader` for OpenAPI. + +```python +from fastapi_toolsets.security import APIKeyHeaderAuth + +api_key_auth = APIKeyHeaderAuth("X-API-Key", validator=verify_api_key) + +@app.get("/data") +async def data(user: User = Security(api_key_auth)): + return user +``` + +The header name is configurable — use any header your API defines (e.g. `"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`). + +## Typed validator kwargs + +All auth classes forward extra instantiation keyword arguments to the validator. Arguments can be any type — enums, strings, integers, etc. The validator returns the authenticated identity, which FastAPI injects directly into the route handler. + +```python +async def verify_token(token: str, *, role: Role, permission: str) -> User: + user = await decode_token(token) + if user.role != role or permission not in user.permissions: + raise UnauthorizedError() + return user + +bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read") +``` + +Each auth instance is self-contained — create a separate instance per distinct requirement instead of passing requirements through `Security(scopes=[...])`. + +### Using `.require()` inline + +If declaring a new top-level variable per role feels verbose, use `.require()` to create a configured clone directly in the route decorator. The original instance is not mutated: + +```python +bearer = BearerTokenAuth(verify_token) + +@app.get("/admin/stats") +async def admin_stats(user: User = Security(bearer.require(role=Role.ADMIN))): + return {"message": f"Hello admin {user.name}"} + +@app.get("/profile") +async def profile(user: User = Security(bearer.require(role=Role.USER))): + return {"id": user.id, "name": user.name} +``` + +`.require()` kwargs are merged over existing ones — new values win on conflict. +The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for +`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved. + +## MultiAuth + +[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines multiple auth sources into a single callable. Sources are tried in order; the first one that finds a credential wins. + +If a credential is extracted but the validator raises, the exception propagates immediately — the remaining sources are **not** tried. This prevents silent fallthrough on invalid credentials. + +```python +from fastapi_toolsets.security import MultiAuth + +multi = MultiAuth(user_bearer, org_bearer, cookie_auth) + +@app.get("/data") +async def data_route(user = Security(multi)): + return user +``` + +### Using `.require()` on MultiAuth + +`MultiAuth` also supports `.require()`, which propagates the kwargs to every source that implements it. Sources that do not (e.g. custom `AuthSource` subclasses) are passed through unchanged: + +```python +multi = MultiAuth(bearer, cookie) + +@app.get("/admin") +async def admin(user: User = Security(multi.require(role=Role.ADMIN))): + return user +``` + +This is equivalent to calling `.require()` on each source individually: + +```python +# These two are identical +multi.require(role=Role.ADMIN) + +MultiAuth( + bearer.require(role=Role.ADMIN), + cookie.require(role=Role.ADMIN), +) +``` + +### Prefix-based dispatch + +Because `extract()` is pure string matching (no I/O), prefix-based source selection is essentially free. Only the matching source's validator (which may involve DB or network I/O) is ever called: + +```python +user_bearer = BearerTokenAuth(verify_user, prefix="user_") +org_bearer = BearerTokenAuth(verify_org, prefix="org_") + +multi = MultiAuth(user_bearer, org_bearer) + +# "Bearer user_alice" → only verify_user runs, receives "user_alice" +# "Bearer org_acme" → only verify_org runs, receives "org_acme" +``` + +Tokens are stored and compared **with their prefix** — use `generate_token()` on each source to issue correctly-prefixed tokens: + +```python +user_token = user_bearer.generate_token() # "user_..." +org_token = org_bearer.generate_token() # "org_..." +``` + +## Custom auth sources + +Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource) to implement any credential extraction strategy. You only need to implement `extract()` and `authenticate()`: + +```python +from fastapi_toolsets.security import AuthSource +from fastapi_toolsets.exceptions import UnauthorizedError + +class MTLSAuth(AuthSource): + async def extract(self, request) -> str | None: + return request.headers.get("X-Client-Cert-DN") or None + + async def authenticate(self, credential: str): + dn = parse_dn(credential) + if dn.get("O") != "MyOrg": + raise UnauthorizedError() + return {"dn": credential} +``` + +Custom sources work transparently inside `MultiAuth`. + +## OAuth 2.0 / OIDC helpers + +The module provides standalone async utilities for building OAuth 2.0 / OIDC login flows. They handle provider discovery, authorization redirects, token exchange, and state encoding — leaving JWT validation and session management to your application. + +### Provider discovery + +[`oauth_resolve_provider_urls()`](../reference/security.md#fastapi_toolsets.security.oauth_resolve_provider_urls) fetches the OIDC discovery document and returns the endpoint URLs. Results are cached in-process to avoid repeated network calls: + +```python +from fastapi_toolsets.security import oauth_resolve_provider_urls + +auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( + "https://accounts.google.com/.well-known/openid-configuration" +) +``` + +Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` is `None` when the provider does not advertise one. + +### Authorization redirect + +[`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect) constructs the redirect to the provider's authorization page. It requires a `state_token` — a random CSRF token generated by [`oauth_generate_state_token()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_state_token) — that must be stored server-side (e.g. in the session) and verified on the callback to prevent login-CSRF attacks ([RFC 6749 §10.12](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12)): + +```python +from fastapi import Request +from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_state_token + +@app.get("/auth/google/login") +async def google_login(request: Request): + auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL) + state_token = oauth_generate_state_token() + request.session["oauth_state"] = state_token # requires SessionMiddleware + return oauth_build_authorization_redirect( + auth_url, + client_id=GOOGLE_CLIENT_ID, + scopes="openid email profile", + redirect_uri="https://myapp.com/auth/google/callback", + destination="/dashboard", + state_token=state_token, + ) +``` + +### Token exchange and userinfo + +[`oauth_fetch_userinfo()`](../reference/security.md#fastapi_toolsets.security.oauth_fetch_userinfo) performs the two-step exchange: it POSTs the authorization code to the token endpoint, then GETs the userinfo endpoint with the resulting access token. + +On the callback, retrieve the stored token and pass it to [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) to verify the CSRF token before processing the code: + +```python +from fastapi import HTTPException, Request +from fastapi_toolsets.security import oauth_decode_state, oauth_fetch_userinfo + +@app.get("/auth/google/callback") +async def google_callback(request: Request, code: str, state: str): + # Pop token first — single-use, regardless of whether verification succeeds + state_token = request.session.pop("oauth_state", None) + if state_token is None: + raise HTTPException(status_code=400, detail="missing OAuth state") + destination = oauth_decode_state(state, expected_state_token=state_token, fallback="/") + if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect + destination = "/" + + _, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL) + userinfo = await oauth_fetch_userinfo( + token_url=token_url, + userinfo_url=userinfo_url, + code=code, + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + redirect_uri="https://myapp.com/auth/google/callback", + required_scopes="openid email profile", + ) + user = await db.upsert_user(email=userinfo["email"]) + response = RedirectResponse(destination) + session_cookie.set_cookie(response, str(user.id)) + return response +``` + +Pass `required_scopes` to guard against providers silently granting fewer scopes than requested — `oauth_fetch_userinfo` raises `ValueError` if any are missing. + +### State encoding + +[`oauth_encode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_encode_state) and [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) encode and decode the destination URL together with the CSRF token embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the token does not match: + +```python +from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state + +state_token = oauth_generate_state_token() +encoded = oauth_encode_state("/dashboard", state_token) +decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard" +decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/" +decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/" +``` + +--- + +[:material-api: API Reference](../reference/security.md) diff --git a/docs/reference/security.md b/docs/reference/security.md new file mode 100644 index 0000000..73b37f3 --- /dev/null +++ b/docs/reference/security.md @@ -0,0 +1,43 @@ +# `security` + +Here's the reference for the authentication helpers provided by the `security` module. + +You can import them directly from `fastapi_toolsets.security`: + +```python +from fastapi_toolsets.security import ( + AuthSource, + BearerTokenAuth, + CookieAuth, + APIKeyHeaderAuth, + MultiAuth, + oauth_build_authorization_redirect, + oauth_decode_state, + oauth_encode_state, + oauth_fetch_userinfo, + oauth_generate_state_token, + oauth_resolve_provider_urls, +) +``` + +## ::: fastapi_toolsets.security.AuthSource + +## ::: fastapi_toolsets.security.BearerTokenAuth + +## ::: fastapi_toolsets.security.CookieAuth + +## ::: fastapi_toolsets.security.APIKeyHeaderAuth + +## ::: fastapi_toolsets.security.MultiAuth + +## ::: fastapi_toolsets.security.oauth_resolve_provider_urls + +## ::: fastapi_toolsets.security.oauth_fetch_userinfo + +## ::: fastapi_toolsets.security.oauth_generate_state_token + +## ::: fastapi_toolsets.security.oauth_build_authorization_redirect + +## ::: fastapi_toolsets.security.oauth_encode_state + +## ::: fastapi_toolsets.security.oauth_decode_state diff --git a/pyproject.toml b/pyproject.toml index 1a3296a..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] @@ -66,12 +70,14 @@ manager = "fastapi_toolsets.cli.app:cli" dev = [ {include-group = "tests"}, {include-group = "docs"}, + {include-group = "docs-src"}, "fastapi-toolsets[all]", "prek>=0.3.8", "ruff>=0.1.0", "ty>=0.0.1a0", ] tests = [ + "async-lru>=1.0", "coverage>=7.0.0", "httpx>=0.25.0", "pytest-anyio>=0.0.0", @@ -84,6 +90,9 @@ docs = [ "mkdocstrings-python>=2.0.2", "zensical>=0.0.30", ] +docs-src = [ + "bcrypt>=4.0.0", +] [build-system] requires = ["uv_build>=0.10,<0.12.0"] diff --git a/src/fastapi_toolsets/security/__init__.py b/src/fastapi_toolsets/security/__init__.py new file mode 100644 index 0000000..138b94e --- /dev/null +++ b/src/fastapi_toolsets/security/__init__.py @@ -0,0 +1,26 @@ +"""Authentication helpers for FastAPI using Security().""" + +from .abc import AuthSource +from .oauth import ( + oauth_build_authorization_redirect, + oauth_decode_state, + oauth_encode_state, + oauth_fetch_userinfo, + oauth_generate_state_token, + oauth_resolve_provider_urls, +) +from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth + +__all__ = [ + "APIKeyHeaderAuth", + "AuthSource", + "BearerTokenAuth", + "CookieAuth", + "MultiAuth", + "oauth_build_authorization_redirect", + "oauth_decode_state", + "oauth_encode_state", + "oauth_fetch_userinfo", + "oauth_generate_state_token", + "oauth_resolve_provider_urls", +] diff --git a/src/fastapi_toolsets/security/abc.py b/src/fastapi_toolsets/security/abc.py new file mode 100644 index 0000000..9eb8c93 --- /dev/null +++ b/src/fastapi_toolsets/security/abc.py @@ -0,0 +1,55 @@ +"""Abstract base class for authentication sources.""" + +import functools +import inspect +from abc import ABC, abstractmethod +from typing import Any, Callable + +from fastapi import Request +from fastapi.security import SecurityScopes + +from fastapi_toolsets.exceptions import UnauthorizedError + + +def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]: + """Wrap *fn* so it can always be awaited, caching the coroutine check at init time.""" + if inspect.iscoroutinefunction(fn): + return fn + + @functools.wraps(fn) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + return fn(*args, **kwargs) + + return wrapper + + +class AuthSource(ABC): + """Abstract base class for authentication sources.""" + + def __init__(self) -> None: + """Set up the default FastAPI dependency signature.""" + source = self + + async def _call( + request: Request, + security_scopes: SecurityScopes, # noqa: ARG001 + ) -> Any: + credential = await source.extract(request) + if credential is None: + raise UnauthorizedError() + return await source.authenticate(credential) + + self._call_fn: Callable[..., Any] = _call + self.__signature__ = inspect.signature(_call) + + @abstractmethod + async def extract(self, request: Request) -> str | None: + """Extract the raw credential from the request without validating.""" + + @abstractmethod + async def authenticate(self, credential: str) -> Any: + """Validate a credential and return the authenticated identity.""" + + async def __call__(self, **kwargs: Any) -> Any: + """FastAPI dependency dispatch.""" + return await self._call_fn(**kwargs) diff --git a/src/fastapi_toolsets/security/oauth.py b/src/fastapi_toolsets/security/oauth.py new file mode 100644 index 0000000..4625ae1 --- /dev/null +++ b/src/fastapi_toolsets/security/oauth.py @@ -0,0 +1,197 @@ +"""OAuth 2.0 / OIDC helper utilities.""" + +import base64 +import binascii +import hmac +import json +import secrets +from typing import Any +from urllib.parse import urlencode + +import httpx +from async_lru import alru_cache +from fastapi.responses import RedirectResponse + + +@alru_cache(maxsize=32) +async def oauth_resolve_provider_urls( + discovery_url: str, +) -> tuple[str, str, str | None]: + """Fetch the OIDC discovery document and return endpoint URLs. + + Args: + discovery_url: URL of the provider's ``/.well-known/openid-configuration``. + + Returns: + A ``(authorization_url, token_url, userinfo_url)`` tuple. + *userinfo_url* is ``None`` when the provider does not advertise one. + """ + 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"], + cfg.get("userinfo_endpoint"), + ) + + +async def oauth_fetch_userinfo( + *, + token_url: str, + userinfo_url: str, + code: str, + client_id: str, + client_secret: str, + redirect_uri: str, + required_scopes: str | None = None, +) -> dict[str, Any]: + """Exchange an authorization code for tokens and return the userinfo payload. + + Args: + token_url: Provider's token endpoint. + userinfo_url: Provider's userinfo endpoint. + code: Authorization code received from the provider's callback. + client_id: OAuth application client ID. + client_secret: OAuth application client secret. + redirect_uri: Redirect URI that was used in the authorization request. + required_scopes: Space-separated scopes that must be present in the token + response ``scope`` field (RFC 6749 §3.3). Raises ``ValueError`` if + the provider granted fewer scopes than requested. + + Returns: + The JSON payload returned by the userinfo endpoint as a plain ``dict``. + + Raises: + ValueError: If the provider granted a different token type than ``bearer`` + or did not grant all ``required_scopes``. + """ + async with httpx.AsyncClient() as client: + token_resp = await client.post( + token_url, + data={ + "grant_type": "authorization_code", + "code": code, + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + }, + headers={"Accept": "application/json"}, + ) + token_resp.raise_for_status() + token_data = token_resp.json() + + if token_data.get("token_type", "bearer").lower() != "bearer": + raise ValueError( + f"unsupported token_type: {token_data.get('token_type')!r}" + ) + + if required_scopes is not None: + granted = set(token_data.get("scope", "").split()) + missing = set(required_scopes.split()) - granted + if missing: + raise ValueError(f"provider did not grant required scopes: {missing}") + + access_token = token_data["access_token"] + + userinfo_resp = await client.get( + userinfo_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + userinfo_resp.raise_for_status() + return userinfo_resp.json() + + +def oauth_generate_state_token() -> str: + """Generate a cryptographically random CSRF token for the OAuth ``state`` parameter.""" + return secrets.token_urlsafe(32) + + +def oauth_build_authorization_redirect( + authorization_url: str, + *, + client_id: str, + scopes: str, + redirect_uri: str, + destination: str, + state_token: str, +) -> RedirectResponse: + """Return an OAuth 2.0 authorization ``RedirectResponse``. + + Args: + authorization_url: Provider's authorization endpoint. + client_id: OAuth application client ID. + scopes: Space-separated list of requested scopes. + redirect_uri: URI the provider should redirect back to after authorization. + destination: URL the user should be sent to after the full OAuth flow + completes (embedded in ``state``). + state_token: CSRF token generated by :func:`oauth_generate_state_token`. + Must be stored server-side (session or signed cookie) and verified via + :func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12). + + Returns: + A :class:`~fastapi.responses.RedirectResponse` to the provider's + authorization page. + """ + params = urlencode( + { + "client_id": client_id, + "response_type": "code", + "scope": scopes, + "redirect_uri": redirect_uri, + "state": oauth_encode_state(destination, state_token), + } + ) + return RedirectResponse(f"{authorization_url}?{params}") + + +def oauth_encode_state(url: str, state_token: str) -> str: + """Encode a destination URL and CSRF token into an OAuth ``state`` parameter. + + Args: + url: Post-login destination URL. + state_token: CSRF token from :func:`oauth_generate_state_token`. + """ + payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":")) + return base64.urlsafe_b64encode(payload.encode()).decode() + + +def oauth_decode_state( + state: str | None, *, expected_state_token: str, fallback: str +) -> str: + """Decode and CSRF-verify an OAuth ``state`` parameter. + + Uses a constant-time comparison for the CSRF token to prevent timing attacks. + + Args: + state: Raw ``state`` query parameter from the provider's callback. + expected_state_token: The token stored before the authorization redirect. + If it does not match the decoded value, ``fallback`` is returned. + fallback: URL to return when ``state`` is absent, malformed, or fails + CSRF verification. + + Returns: + The destination URL embedded in ``state``, or ``fallback``. + + Important: + **Single-use**: delete the stored token from the session immediately + after calling this function — whether it matched or not — so that a + captured callback URL cannot be replayed. + + **Open-redirect**: validate the returned URL against a known-good + origin or relative-path allowlist before issuing the final redirect. + Do not forward arbitrary URLs to ``RedirectResponse``. + """ + if not state or state == "null": # "null" guards against JS JSON.stringify(null) + return fallback + try: + padded = state + "=" * (-len(state) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) + if not isinstance(payload, dict) or not hmac.compare_digest( + payload.get("n", "").encode(), expected_state_token.encode() + ): + return fallback + return str(payload["d"]) + except (UnicodeDecodeError, ValueError, binascii.Error, KeyError): + return fallback diff --git a/src/fastapi_toolsets/security/sources/__init__.py b/src/fastapi_toolsets/security/sources/__init__.py new file mode 100644 index 0000000..8f90c54 --- /dev/null +++ b/src/fastapi_toolsets/security/sources/__init__.py @@ -0,0 +1,8 @@ +"""Built-in authentication source implementations.""" + +from .header import APIKeyHeaderAuth +from .bearer import BearerTokenAuth +from .cookie import CookieAuth +from .multi import MultiAuth + +__all__ = ["APIKeyHeaderAuth", "BearerTokenAuth", "CookieAuth", "MultiAuth"] diff --git a/src/fastapi_toolsets/security/sources/bearer.py b/src/fastapi_toolsets/security/sources/bearer.py new file mode 100644 index 0000000..b33f432 --- /dev/null +++ b/src/fastapi_toolsets/security/sources/bearer.py @@ -0,0 +1,120 @@ +"""Bearer token authentication source.""" + +import inspect +import secrets +from typing import Annotated, Any, Callable + +from fastapi import Depends, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes + +from fastapi_toolsets.exceptions import UnauthorizedError + +from ..abc import AuthSource, _ensure_async + + +class BearerTokenAuth(AuthSource): + """Bearer token authentication source. + + Wraps :class:`fastapi.security.HTTPBearer` for OpenAPI documentation. + The validator is called as ``await validator(credential, **kwargs)`` + where ``kwargs`` are the extra keyword arguments provided at instantiation. + + Args: + validator: Sync or async callable that receives the credential and any + extra keyword arguments, and returns the authenticated identity + (e.g. a ``User`` model). Should raise + :class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure. + prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens + whose value starts with this prefix are matched. The prefix is + **kept** in the value passed to the validator — store and compare + tokens with their prefix included. Use :meth:`generate_token` to + create correctly-prefixed tokens. This enables multiple + ``BearerTokenAuth`` instances in the same app (e.g. ``"user_"`` + for user tokens, ``"org_"`` for org tokens). + **kwargs: Extra keyword arguments forwarded to the validator on every + call (e.g. ``role=Role.ADMIN``). + """ + + def __init__( + self, + validator: Callable[..., Any], + *, + prefix: str | None = None, + **kwargs: Any, + ) -> None: + self._validator = _ensure_async(validator) + self._prefix = prefix + self._kwargs = kwargs + self._scheme = HTTPBearer(auto_error=False) + + async def _call( + security_scopes: SecurityScopes, # noqa: ARG001 + credentials: Annotated[ + HTTPAuthorizationCredentials | None, Depends(self._scheme) + ] = None, + ) -> Any: + if credentials is None: + raise UnauthorizedError() + return await self._validate(credentials.credentials) + + self._call_fn = _call + self.__signature__ = inspect.signature(_call) + + async def _validate(self, token: str) -> Any: + """Check prefix and call the validator.""" + if self._prefix is not None and not token.startswith(self._prefix): + raise UnauthorizedError() + return await self._validator(token, **self._kwargs) + + async def extract(self, request: Request) -> str | None: + """Extract the raw credential from the request without validating. + + Returns ``None`` if no ``Authorization: Bearer`` header is present, + the token is empty, or the token does not match the configured prefix. + The prefix is included in the returned value. + """ + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return None + token = auth[7:] + if not token: + return None + if self._prefix is not None and not token.startswith(self._prefix): + return None + return token + + async def authenticate(self, credential: str) -> Any: + """Validate a credential and return the identity. + + Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are + the extra keyword arguments provided at instantiation. + """ + return await self._validate(credential) + + def require(self, **kwargs: Any) -> "BearerTokenAuth": + """Return a new instance with additional (or overriding) validator kwargs.""" + return BearerTokenAuth( + self._validator, + prefix=self._prefix, + **{**self._kwargs, **kwargs}, + ) + + def generate_token(self, nbytes: int = 32) -> str: + """Generate a secure random token for this auth source. + + Returns a URL-safe random token. If a prefix is configured it is + prepended — the returned value is what you store in your database + and return to the client as-is. + + Args: + nbytes: Number of random bytes before base64 encoding. The + resulting string is ``ceil(nbytes * 4 / 3)`` characters + (43 chars for the default 32 bytes). Defaults to 32. + + Returns: + A ready-to-use token string (e.g. ``"user_Xk3..."``). + """ + token = secrets.token_urlsafe(nbytes) + if self._prefix is not None: + return f"{self._prefix}{token}" + return token diff --git a/src/fastapi_toolsets/security/sources/cookie.py b/src/fastapi_toolsets/security/sources/cookie.py new file mode 100644 index 0000000..9fd6904 --- /dev/null +++ b/src/fastapi_toolsets/security/sources/cookie.py @@ -0,0 +1,148 @@ +"""Cookie-based authentication source.""" + +import base64 +import hashlib +import hmac +import inspect +import json +import time +from typing import Annotated, Any, Callable + +from fastapi import Depends, Request, Response +from fastapi.security import APIKeyCookie, SecurityScopes + +from fastapi_toolsets.exceptions import UnauthorizedError + +from ..abc import AuthSource, _ensure_async + + +class CookieAuth(AuthSource): + """Cookie-based authentication source. + + Wraps :class:`fastapi.security.APIKeyCookie` for OpenAPI documentation. + Optionally signs the cookie with HMAC-SHA256 to provide stateless, tamper- + proof sessions without any database entry. + + Args: + name: Cookie name. + validator: Sync or async callable that receives the cookie value + (plain, after signature verification when ``secret_key`` is set) + and any extra keyword arguments, and returns the authenticated + identity. + secret_key: When provided, the cookie is HMAC-SHA256 signed. + :meth:`set_cookie` embeds an expiry and signs the payload; + :meth:`extract` verifies the signature and expiry before handing + the plain value to the validator. When ``None`` (default), the raw + cookie value is passed to the validator as-is. + ttl: Cookie lifetime in seconds (default 24 h). Only used when + ``secret_key`` is set. + secure: Set the ``Secure`` flag on the cookie so it is only transmitted + over HTTPS (default ``True``). Set to ``False`` only in local + development environments where HTTPS is unavailable. + **kwargs: Extra keyword arguments forwarded to the validator on every + call (e.g. ``role=Role.ADMIN``). + """ + + def __init__( + self, + name: str, + validator: Callable[..., Any], + *, + secret_key: str | None = None, + ttl: int = 86400, + secure: bool = True, + **kwargs: Any, + ) -> None: + self._name = name + self._validator = _ensure_async(validator) + self._secret_key = secret_key + self._ttl = ttl + self._secure = secure + self._kwargs = kwargs + self._scheme = APIKeyCookie(name=name, auto_error=False) + + async def _call( + security_scopes: SecurityScopes, # noqa: ARG001 + value: Annotated[str | None, Depends(self._scheme)] = None, + ) -> Any: + if value is None: + raise UnauthorizedError() + plain = self._verify(value) + return await self._validator(plain, **self._kwargs) + + self._call_fn = _call + self.__signature__ = inspect.signature(_call) + + def _hmac(self, data: str) -> str: + if self._secret_key is None: + raise RuntimeError("_hmac called without secret_key configured") + return hmac.new( + self._secret_key.encode(), data.encode(), hashlib.sha256 + ).hexdigest() + + def _sign(self, value: str) -> str: + data = base64.urlsafe_b64encode( + json.dumps({"v": value, "exp": int(time.time()) + self._ttl}).encode() + ).decode() + return f"{data}.{self._hmac(data)}" + + def _verify(self, cookie_value: str) -> str: + """Return the plain value, verifying HMAC + expiry when signed.""" + if not self._secret_key: + return cookie_value + + try: + data, sig = cookie_value.rsplit(".", 1) + except ValueError: + raise UnauthorizedError() + + if not hmac.compare_digest(self._hmac(data), sig): + raise UnauthorizedError() + + try: + payload = json.loads(base64.urlsafe_b64decode(data)) + value: str = payload["v"] + exp: int = payload["exp"] + except Exception: + raise UnauthorizedError() + + if exp < int(time.time()): + raise UnauthorizedError() + + return value + + async def extract(self, request: Request) -> str | None: + return request.cookies.get(self._name) + + async def authenticate(self, credential: str) -> Any: + plain = self._verify(credential) + return await self._validator(plain, **self._kwargs) + + def require(self, **kwargs: Any) -> "CookieAuth": + """Return a new instance with additional (or overriding) validator kwargs.""" + return CookieAuth( + self._name, + self._validator, + secret_key=self._secret_key, + ttl=self._ttl, + secure=self._secure, + **{**self._kwargs, **kwargs}, + ) + + def set_cookie(self, response: Response, value: str) -> None: + """Attach the cookie to *response*, signing it when ``secret_key`` is set.""" + cookie_value = self._sign(value) if self._secret_key else value + response.set_cookie( + self._name, + cookie_value, + httponly=True, + samesite="lax", + secure=self._secure, + max_age=self._ttl, + ) + + def delete_cookie(self, response: Response) -> None: + """Clear the session cookie (logout).""" + response.delete_cookie( + self._name, httponly=True, samesite="lax", secure=self._secure + ) diff --git a/src/fastapi_toolsets/security/sources/header.py b/src/fastapi_toolsets/security/sources/header.py new file mode 100644 index 0000000..ec4834f --- /dev/null +++ b/src/fastapi_toolsets/security/sources/header.py @@ -0,0 +1,67 @@ +"""API key header authentication source.""" + +import inspect +from typing import Annotated, Any, Callable + +from fastapi import Depends, Request +from fastapi.security import APIKeyHeader, SecurityScopes + +from fastapi_toolsets.exceptions import UnauthorizedError + +from ..abc import AuthSource, _ensure_async + + +class APIKeyHeaderAuth(AuthSource): + """API key header authentication source. + + Wraps :class:`fastapi.security.APIKeyHeader` for OpenAPI documentation. + The validator is called as ``await validator(api_key, **kwargs)`` + where ``kwargs`` are the extra keyword arguments provided at instantiation. + + Args: + name: HTTP header name that carries the API key (e.g. ``"X-API-Key"``). + validator: Sync or async callable that receives the API key and any + extra keyword arguments, and returns the authenticated identity. + Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError` + on failure. + **kwargs: Extra keyword arguments forwarded to the validator on every + call (e.g. ``role=Role.ADMIN``). + """ + + def __init__( + self, + name: str, + validator: Callable[..., Any], + **kwargs: Any, + ) -> None: + self._name = name + self._validator = _ensure_async(validator) + self._kwargs = kwargs + self._scheme = APIKeyHeader(name=name, auto_error=False) + + async def _call( + security_scopes: SecurityScopes, # noqa: ARG001 + api_key: Annotated[str | None, Depends(self._scheme)] = None, + ) -> Any: + if api_key is None: + raise UnauthorizedError() + return await self._validator(api_key, **self._kwargs) + + self._call_fn = _call + self.__signature__ = inspect.signature(_call) + + async def extract(self, request: Request) -> str | None: + """Extract the API key from the configured header.""" + return request.headers.get(self._name) or None + + async def authenticate(self, credential: str) -> Any: + """Validate a credential and return the identity.""" + return await self._validator(credential, **self._kwargs) + + def require(self, **kwargs: Any) -> "APIKeyHeaderAuth": + """Return a new instance with additional (or overriding) validator kwargs.""" + return APIKeyHeaderAuth( + self._name, + self._validator, + **{**self._kwargs, **kwargs}, + ) diff --git a/src/fastapi_toolsets/security/sources/multi.py b/src/fastapi_toolsets/security/sources/multi.py new file mode 100644 index 0000000..5180b11 --- /dev/null +++ b/src/fastapi_toolsets/security/sources/multi.py @@ -0,0 +1,71 @@ +"""MultiAuth: combine multiple authentication sources into a single callable.""" + +import inspect +from typing import Any, cast + +from fastapi import Request +from fastapi.security import SecurityScopes + +from fastapi_toolsets.exceptions import UnauthorizedError + +from ..abc import AuthSource + + +class MultiAuth: + """Combine multiple authentication sources into a single callable. + + Args: + *sources: Auth source instances to try in order. + """ + + def __init__(self, *sources: AuthSource) -> None: + self._sources = sources + + async def _call( + request: Request, + security_scopes: SecurityScopes, # noqa: ARG001 + **kwargs: Any, # noqa: ARG001 — absorbs scheme values injected by FastAPI + ) -> Any: + for source in self._sources: + credential = await source.extract(request) + if credential is not None: + return await source.authenticate(credential) + raise UnauthorizedError() + + self._call_fn = _call + + # Build a merged signature that includes the security-scheme Depends() + # parameters from every source so FastAPI registers them in OpenAPI docs. + seen: set[str] = {"request", "security_scopes"} + merged: list[inspect.Parameter] = [ + inspect.Parameter( + "request", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=Request, + ), + inspect.Parameter( + "security_scopes", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=SecurityScopes, + ), + ] + for i, source in enumerate(sources): + for name, param in inspect.signature(source).parameters.items(): + if name in seen: + continue + merged.append(param.replace(name=f"_s{i}_{name}")) + seen.add(name) + self.__signature__ = inspect.Signature(merged, return_annotation=Any) + + async def __call__(self, **kwargs: Any) -> Any: + return await self._call_fn(**kwargs) + + def require(self, **kwargs: Any) -> "MultiAuth": + """Return a new :class:`MultiAuth` with kwargs forwarded to each source.""" + new_sources = tuple( + cast(Any, source).require(**kwargs) + if hasattr(source, "require") + else source + for source in self._sources + ) + return MultiAuth(*new_sources) diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..6887cf4 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,1341 @@ +"""Tests for fastapi_toolsets.security.""" + +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi import FastAPI, Security +from fastapi.testclient import TestClient + +from fastapi_toolsets.exceptions import UnauthorizedError, init_exceptions_handlers +from fastapi_toolsets.security import ( + APIKeyHeaderAuth, + AuthSource, + BearerTokenAuth, + CookieAuth, + MultiAuth, + oauth_build_authorization_redirect, + oauth_decode_state, + oauth_encode_state, + oauth_fetch_userinfo, + oauth_generate_state_token, + oauth_resolve_provider_urls, +) + + +def _app(*routes_setup_fns): + """Build a minimal FastAPI test app with exception handlers.""" + app = FastAPI() + init_exceptions_handlers(app) + for fn in routes_setup_fns: + fn(app) + return app + + +VALID_TOKEN = "secret" +VALID_COOKIE = "session123" + + +async def simple_validator(credential: str) -> dict: + if credential != VALID_TOKEN: + raise UnauthorizedError() + return {"user": "alice"} + + +async def role_validator(credential: str, *, role: str) -> dict: + if credential != VALID_TOKEN: + raise UnauthorizedError() + return {"user": "alice", "role": role} + + +async def cookie_validator(value: str) -> dict: + if value != VALID_COOKIE: + raise UnauthorizedError() + return {"session": value} + + +class TestBearerTokenAuth: + def test_valid_token_returns_identity(self): + bearer = BearerTokenAuth(simple_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + def test_missing_header_returns_401(self): + bearer = BearerTokenAuth(simple_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me") + assert response.status_code == 401 + + def test_invalid_token_returns_401(self): + bearer = BearerTokenAuth(simple_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": "Bearer wrong"}) + assert response.status_code == 401 + + def test_kwargs_forwarded_to_validator(self): + bearer = BearerTokenAuth(role_validator, role="admin") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) + assert response.status_code == 200 + assert response.json() == {"user": "alice", "role": "admin"} + + def test_prefix_matching_passes_full_token(self): + """Token with matching prefix: full token (with prefix) is passed to validator.""" + received: list[str] = [] + + async def capturing_validator(credential: str) -> dict: + received.append(credential) + return {"user": "alice"} + + bearer = BearerTokenAuth(capturing_validator, prefix="user_") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": "Bearer user_abc123"}) + assert response.status_code == 200 + # Prefix is kept — validator receives the full token as stored in DB + assert received == ["user_abc123"] + + def test_prefix_mismatch_returns_401(self): + bearer = BearerTokenAuth(simple_validator, prefix="user_") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": "Bearer org_abc123"}) + assert response.status_code == 401 + + @pytest.mark.anyio + async def test_extract_no_header(self): + from starlette.requests import Request + + bearer = BearerTokenAuth(simple_validator) + scope = {"type": "http", "method": "GET", "path": "/", "headers": []} + request = Request(scope) + assert await bearer.extract(request) is None + + @pytest.mark.anyio + async def test_extract_empty_token(self): + from starlette.requests import Request + + bearer = BearerTokenAuth(simple_validator) + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"authorization", b"Bearer ")], + } + request = Request(scope) + assert await bearer.extract(request) is None + + @pytest.mark.anyio + async def test_extract_no_prefix(self): + from starlette.requests import Request + + bearer = BearerTokenAuth(simple_validator) + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"authorization", b"Bearer mytoken")], + } + request = Request(scope) + assert await bearer.extract(request) == "mytoken" + + @pytest.mark.anyio + async def test_extract_prefix_match(self): + from starlette.requests import Request + + bearer = BearerTokenAuth(simple_validator, prefix="user_") + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"authorization", b"Bearer user_abc")], + } + request = Request(scope) + assert await bearer.extract(request) == "user_abc" + + @pytest.mark.anyio + async def test_extract_prefix_no_match(self): + from starlette.requests import Request + + bearer = BearerTokenAuth(simple_validator, prefix="user_") + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"authorization", b"Bearer org_abc")], + } + request = Request(scope) + assert await bearer.extract(request) is None + + def test_generate_token_no_prefix(self): + bearer = BearerTokenAuth(simple_validator) + token = bearer.generate_token() + assert isinstance(token, str) + assert len(token) > 0 + + def test_generate_token_with_prefix(self): + bearer = BearerTokenAuth(simple_validator, prefix="user_") + token = bearer.generate_token() + assert token.startswith("user_") + + def test_generate_token_uniqueness(self): + bearer = BearerTokenAuth(simple_validator) + assert bearer.generate_token() != bearer.generate_token() + + def test_generate_token_is_valid_credential(self): + """A generated token (with prefix) is accepted by the same auth source.""" + stored: list[str] = [] + + async def storing_validator(credential: str) -> dict: + stored.append(credential) + return {"token": credential} + + bearer = BearerTokenAuth(storing_validator, prefix="user_") + token = bearer.generate_token() + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + assert stored == [token] + + +class TestCookieAuth: + def test_valid_cookie_returns_identity(self): + cookie_auth = CookieAuth("session", cookie_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(cookie_auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", cookies={"session": VALID_COOKIE}) + assert response.status_code == 200 + assert response.json() == {"session": VALID_COOKIE} + + def test_missing_cookie_returns_401(self): + cookie_auth = CookieAuth("session", cookie_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(cookie_auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me") + assert response.status_code == 401 + + def test_invalid_cookie_returns_401(self): + cookie_auth = CookieAuth("session", cookie_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(cookie_auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", cookies={"session": "wrong"}) + assert response.status_code == 401 + + def test_kwargs_forwarded_to_validator(self): + async def session_validator(value: str, *, scope: str) -> dict: + if value != VALID_COOKIE: + raise UnauthorizedError() + return {"session": value, "scope": scope} + + cookie_auth = CookieAuth("session", session_validator, scope="read") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(cookie_auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", cookies={"session": VALID_COOKIE}) + assert response.status_code == 200 + assert response.json() == {"session": VALID_COOKIE, "scope": "read"} + + @pytest.mark.anyio + async def test_extract_no_cookie(self): + from starlette.requests import Request + + auth = CookieAuth("session", cookie_validator) + scope = {"type": "http", "method": "GET", "path": "/", "headers": []} + request = Request(scope) + assert await auth.extract(request) is None + + @pytest.mark.anyio + async def test_extract_cookie_present(self): + from starlette.requests import Request + + auth = CookieAuth("session", cookie_validator) + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"cookie", b"session=abc")], + } + request = Request(scope) + assert await auth.extract(request) == "abc" + + +class TestAPIKeyHeaderAuth: + def test_valid_key_returns_identity(self): + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"X-API-Key": VALID_TOKEN}) + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + def test_missing_header_returns_401(self): + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me") + assert response.status_code == 401 + + def test_invalid_key_returns_401(self): + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"X-API-Key": "wrong"}) + assert response.status_code == 401 + + def test_kwargs_forwarded_to_validator(self): + auth = APIKeyHeaderAuth("X-API-Key", role_validator, role="admin") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"X-API-Key": VALID_TOKEN}) + assert response.status_code == 200 + assert response.json() == {"user": "alice", "role": "admin"} + + def test_require_forwards_kwargs(self): + auth = APIKeyHeaderAuth("X-API-Key", role_validator) + + def setup(app: FastAPI): + @app.get("/admin") + async def admin(user=Security(auth.require(role="admin"))): + return user + + client = TestClient(_app(setup)) + response = client.get("/admin", headers={"X-API-Key": VALID_TOKEN}) + assert response.status_code == 200 + assert response.json() == {"user": "alice", "role": "admin"} + + def test_require_preserves_name(self): + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + derived = auth.require(role="admin") + assert derived._name == "X-API-Key" + + def test_require_does_not_mutate_original(self): + auth = APIKeyHeaderAuth("X-API-Key", role_validator, role="user") + auth.require(role="admin") + assert auth._kwargs == {"role": "user"} + + def test_in_multi_auth(self): + """APIKeyHeaderAuth.authenticate() is exercised inside MultiAuth.""" + bearer = BearerTokenAuth(simple_validator) + api_key = APIKeyHeaderAuth("X-API-Key", simple_validator) + multi = MultiAuth(bearer, api_key) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + # No bearer → falls through to API key header + response = client.get("/me", headers={"X-API-Key": VALID_TOKEN}) + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + def test_is_auth_source(self): + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + assert isinstance(auth, AuthSource) + + @pytest.mark.anyio + async def test_extract_no_header(self): + from starlette.requests import Request + + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + scope = {"type": "http", "method": "GET", "path": "/", "headers": []} + request = Request(scope) + assert await auth.extract(request) is None + + @pytest.mark.anyio + async def test_extract_empty_header(self): + from starlette.requests import Request + + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"x-api-key", b"")], + } + request = Request(scope) + assert await auth.extract(request) is None + + @pytest.mark.anyio + async def test_extract_key_present(self): + from starlette.requests import Request + + auth = APIKeyHeaderAuth("X-API-Key", simple_validator) + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"x-api-key", b"mykey")], + } + request = Request(scope) + assert await auth.extract(request) == "mykey" + + +class TestMultiAuth: + def test_first_source_matches(self): + bearer = BearerTokenAuth(simple_validator) + cookie = CookieAuth("session", cookie_validator) + multi = MultiAuth(bearer, cookie) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + def test_second_source_matches_when_first_absent(self): + bearer = BearerTokenAuth(simple_validator) + cookie = CookieAuth("session", cookie_validator) + multi = MultiAuth(bearer, cookie) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + # No Authorization header — falls through to cookie + response = client.get("/me", cookies={"session": VALID_COOKIE}) + assert response.status_code == 200 + assert response.json() == {"session": VALID_COOKIE} + + def test_no_source_matches_returns_401(self): + bearer = BearerTokenAuth(simple_validator) + cookie = CookieAuth("session", cookie_validator) + multi = MultiAuth(bearer, cookie) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me") + assert response.status_code == 401 + + def test_invalid_credential_does_not_fallthrough(self): + """If a credential is found but invalid, the next source is NOT tried.""" + second_called: list[bool] = [] + + async def tracking_validator(credential: str) -> dict: + second_called.append(True) + return {"from": "second"} + + bearer = BearerTokenAuth(simple_validator) # raises on wrong token + cookie = CookieAuth("session", tracking_validator) + multi = MultiAuth(bearer, cookie) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + # Bearer credential present but wrong — should NOT try cookie + response = client.get( + "/me", + headers={"Authorization": "Bearer wrong"}, + cookies={"session": VALID_COOKIE}, + ) + assert response.status_code == 401 + assert second_called == [] # cookie validator was never called + + def test_prefix_routes_to_correct_source(self): + """Prefix-based dispatch: only the matching source's validator is called.""" + user_calls: list[str] = [] + org_calls: list[str] = [] + + async def user_validator(credential: str) -> dict: + user_calls.append(credential) + return {"type": "user", "id": credential} + + async def org_validator(credential: str) -> dict: + org_calls.append(credential) + return {"type": "org", "id": credential} + + user_bearer = BearerTokenAuth(user_validator, prefix="user_") + org_bearer = BearerTokenAuth(org_validator, prefix="org_") + multi = MultiAuth(user_bearer, org_bearer) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + + response = client.get("/me", headers={"Authorization": "Bearer user_alice"}) + assert response.status_code == 200 + assert response.json() == {"type": "user", "id": "user_alice"} + assert user_calls == ["user_alice"] + assert org_calls == [] + + user_calls.clear() + + response = client.get("/me", headers={"Authorization": "Bearer org_acme"}) + assert response.status_code == 200 + assert response.json() == {"type": "org", "id": "org_acme"} + assert user_calls == [] + assert org_calls == ["org_acme"] + + def test_require_returns_new_multi_auth(self): + from fastapi_toolsets.security.sources import MultiAuth as MultiAuthClass + + bearer = BearerTokenAuth(role_validator) + multi = MultiAuth(bearer) + derived = multi.require(role="admin") + assert isinstance(derived, MultiAuthClass) + assert derived is not multi + + def test_require_forwards_kwargs_to_sources(self): + """multi.require() propagates to all sources that support it.""" + bearer = BearerTokenAuth(role_validator) + multi = MultiAuth(bearer) + + def setup(app: FastAPI): + @app.get("/admin") + async def admin(user=Security(multi.require(role="admin"))): + return user + + client = TestClient(_app(setup)) + response = client.get( + "/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"} + ) + assert response.status_code == 200 + assert response.json() == {"user": "alice", "role": "admin"} + + def test_require_skips_sources_without_require(self): + """Sources without require() are passed through unchanged.""" + header_auth = _HeaderAuth(secret="s3cr3t") + multi = MultiAuth(header_auth) + derived = multi.require(role="admin") + assert derived._sources[0] is header_auth + + def test_require_does_not_mutate_original(self): + bearer = BearerTokenAuth(role_validator, role="user") + multi = MultiAuth(bearer) + multi.require(role="admin") + assert bearer._kwargs == {"role": "user"} + + def test_require_mixed_sources(self): + """require() applies to sources with require(), skips those without.""" + from typing import cast + + bearer = BearerTokenAuth(role_validator) + header_auth = _HeaderAuth(secret="s3cr3t") + multi = MultiAuth(bearer, header_auth) + derived = multi.require(role="admin") + # bearer got require() applied, header_auth passed through + assert cast(BearerTokenAuth, derived._sources[0])._kwargs == {"role": "admin"} + assert derived._sources[1] is header_auth + + +class TestRequire: + def test_bearer_require_forwards_kwargs(self): + """require() creates a new instance that passes merged kwargs to validator.""" + bearer = BearerTokenAuth(role_validator) + + def setup(app: FastAPI): + @app.get("/admin") + async def admin(user=Security(bearer.require(role="admin"))): + return user + + client = TestClient(_app(setup)) + response = client.get( + "/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"} + ) + assert response.status_code == 200 + assert response.json() == {"user": "alice", "role": "admin"} + + def test_bearer_require_overrides_existing_kwarg(self): + """require() kwargs override kwargs set at instantiation.""" + bearer = BearerTokenAuth(role_validator, role="user") + + def setup(app: FastAPI): + @app.get("/admin") + async def admin(user=Security(bearer.require(role="admin"))): + return user + + client = TestClient(_app(setup)) + response = client.get( + "/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"} + ) + assert response.status_code == 200 + assert response.json()["role"] == "admin" + + def test_bearer_require_preserves_prefix(self): + """require() keeps the prefix of the original instance.""" + bearer = BearerTokenAuth(role_validator, prefix="user_") + derived = bearer.require(role="admin") + assert derived._prefix == "user_" + + def test_bearer_require_does_not_mutate_original(self): + """require() returns a new instance — original kwargs are unchanged.""" + bearer = BearerTokenAuth(role_validator, role="user") + bearer.require(role="admin") + assert bearer._kwargs == {"role": "user"} + + def test_cookie_require_forwards_kwargs(self): + async def scoped_validator(value: str, *, scope: str) -> dict: + if value != VALID_COOKIE: + raise UnauthorizedError() + return {"session": value, "scope": scope} + + cookie = CookieAuth("session", scoped_validator) + + def setup(app: FastAPI): + @app.get("/admin") + async def admin(user=Security(cookie.require(scope="admin"))): + return user + + client = TestClient(_app(setup)) + response = client.get("/admin", cookies={"session": VALID_COOKIE}) + assert response.status_code == 200 + assert response.json() == {"session": VALID_COOKIE, "scope": "admin"} + + def test_cookie_require_preserves_name(self): + cookie = CookieAuth("session", cookie_validator) + derived = cookie.require(scope="admin") + assert derived._name == "session" + + def test_bearer_require_in_multi_auth(self): + """require() instances work seamlessly inside MultiAuth.""" + PREFIXED_TOKEN = f"user_{VALID_TOKEN}" + + async def prefixed_role_validator(credential: str, *, role: str) -> dict: + if credential != PREFIXED_TOKEN: + raise UnauthorizedError() + return {"user": "alice", "role": role} + + bearer = BearerTokenAuth(prefixed_role_validator, prefix="user_") + multi = MultiAuth(bearer.require(role="admin")) + + def setup(app: FastAPI): + @app.get("/admin") + async def admin(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + response = client.get( + "/admin", headers={"Authorization": f"Bearer {PREFIXED_TOKEN}"} + ) + assert response.status_code == 200 + assert response.json() == {"user": "alice", "role": "admin"} + + +class TestSyncValidators: + """Sync (non-async) validators — covers the sync path in _call_validator.""" + + def test_bearer_sync_validator(self): + def sync_validator(credential: str) -> dict: + if credential != VALID_TOKEN: + raise UnauthorizedError() + return {"user": "alice"} + + bearer = BearerTokenAuth(sync_validator) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(bearer)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + def test_sync_validator_via_authenticate(self): + """authenticate() with sync validator (MultiAuth path).""" + + def sync_validator(credential: str) -> dict: + if credential != VALID_TOKEN: + raise UnauthorizedError() + return {"user": "alice"} + + bearer = BearerTokenAuth(sync_validator) + multi = MultiAuth(bearer) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + +class TestCookieAuthSigned: + """CookieAuth with HMAC-SHA256 signed cookies (secret_key path).""" + + SECRET = "test-hmac-secret" + + def test_valid_signed_cookie_via_set_cookie(self): + """set_cookie signs the value; the signed cookie is verified on read.""" + from fastapi import Response + + # secure=False for test client which runs over plain HTTP + auth = CookieAuth( + "session", cookie_validator, secret_key=self.SECRET, secure=False + ) + + def setup(app: FastAPI): + @app.get("/login") + async def login(response: Response): + auth.set_cookie(response, VALID_COOKIE) + return {"ok": True} + + @app.get("/me") + async def me(user=Security(auth)): + return user + + with TestClient(_app(setup)) as client: + client.get("/login") + response = client.get("/me") + assert response.status_code == 200 + assert response.json() == {"session": VALID_COOKIE} + + def test_set_cookie_has_secure_flag_by_default(self): + """set_cookie includes Secure flag when secure=True (the default).""" + from starlette.responses import Response as StarletteResponse + + auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET) + response = StarletteResponse() + auth.set_cookie(response, "value") + assert "secure" in response.headers["set-cookie"].lower() + + def test_set_cookie_no_secure_flag_when_disabled(self): + """set_cookie omits Secure flag when secure=False (local dev).""" + from starlette.responses import Response as StarletteResponse + + auth = CookieAuth( + "session", cookie_validator, secret_key=self.SECRET, secure=False + ) + response = StarletteResponse() + auth.set_cookie(response, "value") + assert "secure" not in response.headers["set-cookie"].lower() + + def test_tampered_signature_returns_401(self): + """A cookie whose HMAC signature has been modified is rejected.""" + import base64 as _b64 + import json as _json + import time as _time + + auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + data = _b64.urlsafe_b64encode( + _json.dumps({"v": VALID_COOKIE, "exp": int(_time.time()) + 9999}).encode() + ).decode() + response = client.get("/me", cookies={"session": f"{data}.invalidsig"}) + assert response.status_code == 401 + + def test_expired_signed_cookie_returns_401(self): + """A signed cookie past its expiry timestamp is rejected.""" + import base64 as _b64 + import hashlib as _hashlib + import hmac as _hmac + import json as _json + import time as _time + + auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + data = _b64.urlsafe_b64encode( + _json.dumps({"v": VALID_COOKIE, "exp": int(_time.time()) - 1}).encode() + ).decode() + sig = _hmac.new( + self.SECRET.encode(), data.encode(), _hashlib.sha256 + ).hexdigest() + response = client.get("/me", cookies={"session": f"{data}.{sig}"}) + assert response.status_code == 401 + + def test_invalid_json_payload_returns_401(self): + """A signed cookie whose payload is not valid JSON is rejected.""" + import base64 as _b64 + import hashlib as _hashlib + import hmac as _hmac + + auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + data = _b64.urlsafe_b64encode(b"not-valid-json").decode() + sig = _hmac.new( + self.SECRET.encode(), data.encode(), _hashlib.sha256 + ).hexdigest() + response = client.get("/me", cookies={"session": f"{data}.{sig}"}) + assert response.status_code == 401 + + def test_malformed_cookie_no_dot_returns_401(self): + """A signed cookie without the dot separator is rejected.""" + auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", cookies={"session": "nodothere"}) + assert response.status_code == 401 + + def test_hmac_without_secret_key_raises(self): + """Calling _hmac on an instance without secret_key raises RuntimeError.""" + auth = CookieAuth("session", cookie_validator) + with pytest.raises(RuntimeError, match="secret_key"): + auth._hmac("data") + + def test_set_cookie_without_secret(self): + """set_cookie without secret_key writes the raw value.""" + from starlette.responses import Response as StarletteResponse + + auth = CookieAuth("session", cookie_validator) + response = StarletteResponse() + auth.set_cookie(response, "rawvalue") + assert "session=rawvalue" in response.headers["set-cookie"] + + def test_delete_cookie(self): + """delete_cookie produces a Set-Cookie header that clears the session.""" + from starlette.responses import Response as StarletteResponse + + auth = CookieAuth("session", cookie_validator) + response = StarletteResponse() + auth.delete_cookie(response) + assert "session" in response.headers["set-cookie"] + + +# Minimal concrete subclass used only in tests below. +class _HeaderAuth(AuthSource): + """Reads a custom X-Token header — no FastAPI security scheme.""" + + def __init__(self, secret: str) -> None: + super().__init__() + self._secret = secret + + async def extract(self, request) -> str | None: + return request.headers.get("X-Token") or None + + async def authenticate(self, credential: str) -> dict: + if credential != self._secret: + raise UnauthorizedError() + return {"token": credential} + + +class TestAuthSource: + def test_cannot_instantiate_abstract_class(self): + with pytest.raises(TypeError): + AuthSource() + + def test_builtin_classes_are_auth_sources(self): + bearer = BearerTokenAuth(simple_validator) + cookie = CookieAuth("session", cookie_validator) + api_key = APIKeyHeaderAuth("X-API-Key", simple_validator) + assert isinstance(bearer, AuthSource) + assert isinstance(cookie, AuthSource) + assert isinstance(api_key, AuthSource) + + def test_custom_source_standalone_valid(self): + """Default __call__ wires extract + authenticate via Request injection.""" + auth = _HeaderAuth(secret="s3cr3t") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"X-Token": "s3cr3t"}) + assert response.status_code == 200 + assert response.json() == {"token": "s3cr3t"} + + def test_custom_source_standalone_missing_credential(self): + auth = _HeaderAuth(secret="s3cr3t") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me") # no X-Token header + assert response.status_code == 401 + + def test_custom_source_standalone_invalid_credential(self): + auth = _HeaderAuth(secret="s3cr3t") + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(auth)): + return user + + client = TestClient(_app(setup)) + response = client.get("/me", headers={"X-Token": "wrong"}) + assert response.status_code == 401 + + def test_custom_source_in_multi_auth(self): + """Custom AuthSource works transparently inside MultiAuth.""" + header_auth = _HeaderAuth(secret="s3cr3t") + bearer = BearerTokenAuth(simple_validator) + multi = MultiAuth(bearer, header_auth) + + def setup(app: FastAPI): + @app.get("/me") + async def me(user=Security(multi)): + return user + + client = TestClient(_app(setup)) + + # Bearer matches first + response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) + assert response.status_code == 200 + assert response.json() == {"user": "alice"} + + # No bearer → falls through to custom header source + response = client.get("/me", headers={"X-Token": "s3cr3t"}) + assert response.status_code == 200 + assert response.json() == {"token": "s3cr3t"} + + +def _make_async_client_mock(get_return=None, post_return=None): + """Return a patched httpx.AsyncClient context-manager mock.""" + mock_client = AsyncMock() + if get_return is not None: + mock_client.get.return_value = get_return + if post_return is not None: + mock_client.post.return_value = post_return + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=mock_client) + cm.__aexit__ = AsyncMock(return_value=None) + return cm, mock_client + + +class TestEncodeDecodeOAuthState: + def test_encode_returns_base64url_string(self): + result = oauth_encode_state("https://example.com/dashboard", "test-state-token") + assert isinstance(result, str) + assert "+" not in result + assert "/" not in result + + def test_round_trip(self): + url = "https://example.com/after-login?next=/home" + state_token = "test-state-token" + assert ( + oauth_decode_state( + oauth_encode_state(url, state_token), + expected_state_token=state_token, + fallback="/", + ) + == url + ) + + def test_decode_none_returns_fallback(self): + assert ( + oauth_decode_state(None, expected_state_token="any", fallback="/home") + == "/home" + ) + + def test_decode_null_string_returns_fallback(self): + assert ( + oauth_decode_state("null", expected_state_token="any", fallback="/home") + == "/home" + ) + + def test_decode_invalid_base64_returns_fallback(self): + assert ( + oauth_decode_state( + "!!!notbase64!!!", expected_state_token="any", fallback="/home" + ) + == "/home" + ) + + def test_decode_handles_missing_padding(self): + url = "https://example.com/x" + state_token = "test-state-token" + encoded = oauth_encode_state(url, state_token).rstrip("=") + assert ( + oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") + == url + ) + + def test_decode_wrong_state_token_returns_fallback(self): + url = "https://example.com/dashboard" + encoded = oauth_encode_state(url, "correct-token") + assert ( + oauth_decode_state( + encoded, expected_state_token="wrong-token", fallback="/" + ) + == "/" + ) + + def test_generate_state_token_is_random(self): + assert oauth_generate_state_token() != oauth_generate_state_token() + + +class TestBuildAuthorizationRedirect: + def test_returns_redirect_response(self): + from fastapi.responses import RedirectResponse + + response = oauth_build_authorization_redirect( + "https://auth.example.com/authorize", + client_id="my-client", + scopes="openid email", + redirect_uri="https://app.example.com/callback", + destination="https://app.example.com/dashboard", + state_token="test-state-token", + ) + assert isinstance(response, RedirectResponse) + + def test_redirect_location_contains_all_params(self): + state_token = "test-state-token" + response = oauth_build_authorization_redirect( + "https://auth.example.com/authorize", + client_id="my-client", + scopes="openid email", + redirect_uri="https://app.example.com/callback", + destination="https://app.example.com/dashboard", + state_token=state_token, + ) + location = response.headers["location"] + parsed = urlparse(location) + assert ( + parsed.scheme + "://" + parsed.netloc + parsed.path + == "https://auth.example.com/authorize" + ) + params = parse_qs(parsed.query) + assert params["client_id"] == ["my-client"] + assert params["response_type"] == ["code"] + assert params["scope"] == ["openid email"] + assert params["redirect_uri"] == ["https://app.example.com/callback"] + assert ( + oauth_decode_state( + params["state"][0], expected_state_token=state_token, fallback="" + ) + == "https://app.example.com/dashboard" + ) + + +class TestResolveProviderUrls: + def _discovery(self, *, userinfo=True): + doc = { + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + } + if userinfo: + doc["userinfo_endpoint"] = "https://auth.example.com/userinfo" + return doc + + @pytest.mark.anyio + async def test_returns_all_endpoints(self): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = self._discovery() + cm, mock_client = _make_async_client_mock(get_return=mock_resp) + + 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" + assert userinfo_url == "https://auth.example.com/userinfo" + + @pytest.mark.anyio + async def test_userinfo_url_none_when_absent(self): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = self._discovery(userinfo=False) + cm, mock_client = _make_async_client_mock(get_return=mock_resp) + + 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 + + @pytest.mark.anyio + async def test_caches_discovery_document(self): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = self._discovery() + cm, mock_client = _make_async_client_mock(get_return=mock_resp) + + url = "https://auth.example.com/.well-known/openid-configuration" + 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 + + +class TestFetchUserinfo: + @pytest.mark.anyio + async def test_returns_userinfo_payload(self): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = {"access_token": "tok123"} + + userinfo_resp = MagicMock() + userinfo_resp.raise_for_status = MagicMock() + userinfo_resp.json.return_value = { + "sub": "user-1", + "email": "alice@example.com", + } + + cm, mock_client = _make_async_client_mock( + post_return=token_resp, get_return=userinfo_resp + ) + + with patch("httpx.AsyncClient", return_value=cm): + result = await oauth_fetch_userinfo( + token_url="https://auth.example.com/token", + userinfo_url="https://auth.example.com/userinfo", + code="authcode123", + client_id="client-id", + client_secret="client-secret", + redirect_uri="https://app.example.com/callback", + ) + + assert result == {"sub": "user-1", "email": "alice@example.com"} + + @pytest.mark.anyio + async def test_posts_correct_token_request_and_uses_bearer(self): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = {"access_token": "tok123"} + + userinfo_resp = MagicMock() + userinfo_resp.raise_for_status = MagicMock() + userinfo_resp.json.return_value = {} + + cm, mock_client = _make_async_client_mock( + post_return=token_resp, get_return=userinfo_resp + ) + + with patch("httpx.AsyncClient", return_value=cm): + await oauth_fetch_userinfo( + token_url="https://auth.example.com/token", + userinfo_url="https://auth.example.com/userinfo", + code="authcode123", + client_id="my-client", + client_secret="my-secret", + redirect_uri="https://app.example.com/callback", + ) + + mock_client.post.assert_called_once_with( + "https://auth.example.com/token", + data={ + "grant_type": "authorization_code", + "code": "authcode123", + "client_id": "my-client", + "client_secret": "my-secret", + "redirect_uri": "https://app.example.com/callback", + }, + headers={"Accept": "application/json"}, + ) + mock_client.get.assert_called_once_with( + "https://auth.example.com/userinfo", + headers={"Authorization": "Bearer tok123"}, + ) + + @pytest.mark.anyio + async def test_raises_on_unsupported_token_type(self): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = {"access_token": "tok123", "token_type": "mac"} + + cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock()) + + with patch("httpx.AsyncClient", return_value=cm): + with pytest.raises(ValueError, match="unsupported token_type"): + await oauth_fetch_userinfo( + token_url="https://auth.example.com/token", + userinfo_url="https://auth.example.com/userinfo", + code="authcode123", + client_id="client-id", + client_secret="client-secret", + redirect_uri="https://app.example.com/callback", + ) + + @pytest.mark.anyio + async def test_accepts_bearer_token_type_case_insensitive(self): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = { + "access_token": "tok123", + "token_type": "Bearer", + } + + userinfo_resp = MagicMock() + userinfo_resp.raise_for_status = MagicMock() + userinfo_resp.json.return_value = {"sub": "user-1"} + + cm, _ = _make_async_client_mock( + post_return=token_resp, get_return=userinfo_resp + ) + + with patch("httpx.AsyncClient", return_value=cm): + result = await oauth_fetch_userinfo( + token_url="https://auth.example.com/token", + userinfo_url="https://auth.example.com/userinfo", + code="authcode123", + client_id="client-id", + client_secret="client-secret", + redirect_uri="https://app.example.com/callback", + ) + assert result == {"sub": "user-1"} + + @pytest.mark.anyio + async def test_raises_when_required_scopes_not_granted(self): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = {"access_token": "tok123", "scope": "openid"} + + cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock()) + + with patch("httpx.AsyncClient", return_value=cm): + with pytest.raises(ValueError, match="required scopes"): + await oauth_fetch_userinfo( + token_url="https://auth.example.com/token", + userinfo_url="https://auth.example.com/userinfo", + code="authcode123", + client_id="client-id", + client_secret="client-secret", + redirect_uri="https://app.example.com/callback", + required_scopes="openid email profile", + ) + + @pytest.mark.anyio + async def test_passes_when_all_required_scopes_granted(self): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = { + "access_token": "tok123", + "scope": "openid email profile", + } + + userinfo_resp = MagicMock() + userinfo_resp.raise_for_status = MagicMock() + userinfo_resp.json.return_value = {"sub": "user-1", "email": "a@b.com"} + + cm, _ = _make_async_client_mock( + post_return=token_resp, get_return=userinfo_resp + ) + + with patch("httpx.AsyncClient", return_value=cm): + result = await oauth_fetch_userinfo( + token_url="https://auth.example.com/token", + userinfo_url="https://auth.example.com/userinfo", + code="authcode123", + client_id="client-id", + client_secret="client-secret", + redirect_uri="https://app.example.com/callback", + required_scopes="openid email", + ) + assert result["email"] == "a@b.com" diff --git a/uv.lock b/uv.lock index 8608acf..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" @@ -81,6 +90,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -262,6 +341,7 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "async-lru" }, { name = "httpx" }, { name = "prometheus-client" }, { name = "pytest" }, @@ -279,9 +359,15 @@ 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"] }, { name = "httpx" }, @@ -301,7 +387,11 @@ docs = [ { name = "mkdocstrings-python" }, { name = "zensical" }, ] +docs-src = [ + { name = "bcrypt" }, +] tests = [ + { name = "async-lru" }, { name = "coverage" }, { name = "httpx" }, { name = "pytest" }, @@ -312,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" }, @@ -323,10 +415,12 @@ 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"] }, { name = "httpx", specifier = ">=0.25.0" }, @@ -346,7 +440,9 @@ docs = [ { name = "mkdocstrings-python", specifier = ">=2.0.2" }, { name = "zensical", specifier = ">=0.0.30" }, ] +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" }, diff --git a/zensical.toml b/zensical.toml index c5f2816..f7e4a09 100644 --- a/zensical.toml +++ b/zensical.toml @@ -121,6 +121,7 @@ Modules = [ {Models = "module/models.md"}, {Pytest = "module/pytest.md"}, {Schemas = "module/schemas.md"}, + {Security = "module/security.md"}, ] [[project.nav]] @@ -136,6 +137,7 @@ Reference = [ {Models = "reference/models.md"}, {Pytest = "reference/pytest.md"}, {Schemas = "reference/schemas.md"}, + {Security = "reference/security.md"}, ] [[project.nav]]