From f0ac43a9dcccab7ffd965883c9a0c5943a4412e4 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Wed, 4 Mar 2026 11:02:54 -0500 Subject: [PATCH 1/8] feat: add security module --- docs/module/security.md | 267 +++++ docs/reference/security.md | 28 + src/fastapi_toolsets/security/__init__.py | 15 + src/fastapi_toolsets/security/abc.py | 51 + src/fastapi_toolsets/security/oauth.py | 24 + .../security/sources/__init__.py | 8 + .../security/sources/bearer.py | 122 +++ .../security/sources/cookie.py | 142 +++ .../security/sources/header.py | 71 ++ .../security/sources/multi.py | 121 +++ tests/test_security.py | 964 ++++++++++++++++++ 11 files changed, 1813 insertions(+) create mode 100644 docs/module/security.md create mode 100644 docs/reference/security.md create mode 100644 src/fastapi_toolsets/security/__init__.py create mode 100644 src/fastapi_toolsets/security/abc.py create mode 100644 src/fastapi_toolsets/security/oauth.py create mode 100644 src/fastapi_toolsets/security/sources/__init__.py create mode 100644 src/fastapi_toolsets/security/sources/bearer.py create mode 100644 src/fastapi_toolsets/security/sources/cookie.py create mode 100644 src/fastapi_toolsets/security/sources/header.py create mode 100644 src/fastapi_toolsets/security/sources/multi.py create mode 100644 tests/test_security.py diff --git a/docs/module/security.md b/docs/module/security.md new file mode 100644 index 0000000..6ddddb5 --- /dev/null +++ b/docs/module/security.md @@ -0,0 +1,267 @@ +# 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 and a `MultiAuth` factory. Each 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. + +```python +from fastapi_toolsets.security import CookieAuth + +cookie_auth = CookieAuth("session", validator=verify_session) + +@app.get("/me") +async def me(user: User = Security(cookie_auth)): + return user +``` + +### [`OAuth2Auth`](../reference/security.md#fastapi_toolsets.security.OAuth2Auth) + +Reads the `Authorization: Bearer ` header and registers the token endpoint +in OpenAPI via `OAuth2PasswordBearer`. + +```python +from fastapi_toolsets.security import OAuth2Auth + +oauth2_auth = OAuth2Auth(token_url="/token", validator=verify_token) + +@app.get("/me") +async def me(user: User = Security(oauth2_auth)): + return user +``` + +### [`OpenIDAuth`](../reference/security.md#fastapi_toolsets.security.OpenIDAuth) + +Reads the `Authorization: Bearer ` header and registers the OpenID Connect +discovery URL in OpenAPI via `OpenIdConnect`. Token validation is fully delegated +to your validator — use any OIDC / JWT library (`authlib`, `python-jose`, `PyJWT`). + +```python +from fastapi_toolsets.security import OpenIDAuth + +async def verify_google_token(token: str, *, audience: str) -> User: + payload = jwt.decode(token, google_public_keys, algorithms=["RS256"], + audience=audience) + return User(email=payload["email"], name=payload["name"]) + +google_auth = OpenIDAuth( + "https://accounts.google.com/.well-known/openid-configuration", + verify_google_token, + audience="my-client-id", +) + +@app.get("/me") +async def me(user: User = Security(google_auth)): + return user +``` + +The discovery URL is used **only for OpenAPI documentation** — no requests are made +to it by this class. You are responsible for fetching and caching the provider's +public keys in your validator. + +Multiple providers work naturally with `MultiAuth`: + +```python +multi = MultiAuth(google_auth, github_auth) + +@app.get("/data") +async def data(user: User = Security(multi)): + return user +``` + +## 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`) and cookie name (for `CookieAuth`) are +always preserved. + +`.require()` instances work transparently inside `MultiAuth`: + +```python +multi = MultiAuth( + user_bearer.require(role=Role.USER), + org_bearer.require(role=Role.ADMIN), +) +``` + +## 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. + +```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_..." +``` + +--- + +[: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..f38235d --- /dev/null +++ b/docs/reference/security.md @@ -0,0 +1,28 @@ +# `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, + OAuth2Auth, + OpenIDAuth, + MultiAuth, +) +``` + +## ::: fastapi_toolsets.security.AuthSource + +## ::: fastapi_toolsets.security.BearerTokenAuth + +## ::: fastapi_toolsets.security.CookieAuth + +## ::: fastapi_toolsets.security.OAuth2Auth + +## ::: fastapi_toolsets.security.OpenIDAuth + +## ::: fastapi_toolsets.security.MultiAuth diff --git a/src/fastapi_toolsets/security/__init__.py b/src/fastapi_toolsets/security/__init__.py new file mode 100644 index 0000000..3d5505d --- /dev/null +++ b/src/fastapi_toolsets/security/__init__.py @@ -0,0 +1,15 @@ +"""Authentication helpers for FastAPI using Security().""" + +from .abc import AuthSource +from .oauth import decode_oauth_state, encode_oauth_state +from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth + +__all__ = [ + "APIKeyHeaderAuth", + "AuthSource", + "BearerTokenAuth", + "CookieAuth", + "MultiAuth", + "decode_oauth_state", + "encode_oauth_state", +] diff --git a/src/fastapi_toolsets/security/abc.py b/src/fastapi_toolsets/security/abc.py new file mode 100644 index 0000000..6640bf8 --- /dev/null +++ b/src/fastapi_toolsets/security/abc.py @@ -0,0 +1,51 @@ +"""Abstract base class for authentication sources.""" + +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 + + +async def _call_validator( + validator: Callable[..., Any], *args: Any, **kwargs: Any +) -> Any: + """Call *validator* with *args* and *kwargs*, awaiting it if it is a coroutine function.""" + if inspect.iscoroutinefunction(validator): + return await validator(*args, **kwargs) + return validator(*args, **kwargs) + + +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..4e95281 --- /dev/null +++ b/src/fastapi_toolsets/security/oauth.py @@ -0,0 +1,24 @@ +"""OAuth 2.0 / OIDC helper utilities.""" + +import base64 + + +def encode_oauth_state(url: str) -> str: + """Base64url-encode a URL to embed as an OAuth ``state`` parameter.""" + return base64.urlsafe_b64encode(url.encode()).decode() + + +def decode_oauth_state(state: str | None, *, fallback: str) -> str: + """Decode a base64url OAuth ``state`` parameter. + + Handles missing padding (some providers strip ``=``). + Returns *fallback* if *state* is absent, the literal string ``"null"``, + or cannot be decoded. + """ + if not state or state == "null": + return fallback + try: + padded = state + "=" * (4 - len(state) % 4) + return base64.urlsafe_b64decode(padded).decode() + except Exception: + 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..3e49a33 --- /dev/null +++ b/src/fastapi_toolsets/security/sources/bearer.py @@ -0,0 +1,122 @@ +"""Bearer token authentication source.""" + +import inspect +import secrets +from typing import Annotated, Any, Callable + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes + +from fastapi_toolsets.exceptions import UnauthorizedError + +from ..abc import AuthSource, _call_validator + + +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 = validator + self._prefix = prefix + self._kwargs = kwargs + self._scheme = HTTPBearer(auto_error=False) + + _scheme = self._scheme + _validator = validator + _kwargs = kwargs + _prefix = prefix + + async def _call( + security_scopes: SecurityScopes, # noqa: ARG001 + credentials: Annotated[ + HTTPAuthorizationCredentials | None, Depends(_scheme) + ] = None, + ) -> Any: + if credentials is None: + raise UnauthorizedError() + token = credentials.credentials + if _prefix is not None and not token.startswith(_prefix): + raise UnauthorizedError() + return await _call_validator(_validator, token, **_kwargs) + + self._call_fn = _call + self.__signature__ = inspect.signature(_call) + + async def extract(self, request: Any) -> 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 _call_validator(self._validator, credential, **self._kwargs) + + 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..89eb2bf --- /dev/null +++ b/src/fastapi_toolsets/security/sources/cookie.py @@ -0,0 +1,142 @@ +"""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, _call_validator + + +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. + **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, + **kwargs: Any, + ) -> None: + self._name = name + self._validator = validator + self._secret_key = secret_key + self._ttl = ttl + self._kwargs = kwargs + self._scheme = APIKeyCookie(name=name, auto_error=False) + + _scheme = self._scheme + _self = self + _kwargs = kwargs + + async def _call( + security_scopes: SecurityScopes, # noqa: ARG001 + value: Annotated[str | None, Depends(_scheme)] = None, + ) -> Any: + if value is None: + raise UnauthorizedError() + plain = _self._verify(value) + return await _call_validator(_self._validator, plain, **_kwargs) + + self._call_fn = _call + self.__signature__ = inspect.signature(_call) + + def _hmac(self, data: str) -> str: + assert self._secret_key is not None + 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 _call_validator(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, + **{**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", + 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") diff --git a/src/fastapi_toolsets/security/sources/header.py b/src/fastapi_toolsets/security/sources/header.py new file mode 100644 index 0000000..067d5e2 --- /dev/null +++ b/src/fastapi_toolsets/security/sources/header.py @@ -0,0 +1,71 @@ +"""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, _call_validator + + +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 = validator + self._kwargs = kwargs + self._scheme = APIKeyHeader(name=name, auto_error=False) + + _scheme = self._scheme + _validator = validator + _kwargs = kwargs + + async def _call( + security_scopes: SecurityScopes, # noqa: ARG001 + api_key: Annotated[str | None, Depends(_scheme)] = None, + ) -> Any: + if api_key is None: + raise UnauthorizedError() + return await _call_validator(_validator, api_key, **_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 _call_validator(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..78b6558 --- /dev/null +++ b/src/fastapi_toolsets/security/sources/multi.py @@ -0,0 +1,121 @@ +"""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. + + Sources are tried in order; the first one whose + :meth:`~AuthSource.extract` returns a non-``None`` credential wins. + Its :meth:`~AuthSource.authenticate` is called and the result returned. + + If a credential is found but the validator raises, the exception propagates + immediately — the remaining sources are **not** tried. This prevents + silent fallthrough on invalid credentials. + + If no source provides a credential, + :class:`~fastapi_toolsets.exceptions.UnauthorizedError` is raised. + + The :meth:`~AuthSource.extract` method of each source performs only + string matching (no I/O), so prefix-based dispatch is essentially free. + + Any :class:`~AuthSource` subclass — including user-defined ones — can be + passed as a source. + + Args: + *sources: Auth source instances to try in order. + + Example:: + + user_bearer = BearerTokenAuth(verify_user, prefix="user_") + org_bearer = BearerTokenAuth(verify_org, prefix="org_") + cookie = CookieAuth("session", verify_session) + + multi = MultiAuth(user_bearer, org_bearer, cookie) + + @app.get("/data") + async def data_route(user = Security(multi)): + return user + + # Apply a shared requirement to all sources at once + @app.get("/admin") + async def admin_route(user = Security(multi.require(role=Role.ADMIN))): + return user + """ + + def __init__(self, *sources: AuthSource) -> None: + self._sources = sources + + _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 _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. + + Calls ``.require(**kwargs)`` on every source that supports it. Sources + that do not implement ``.require()`` (e.g. custom :class:`~AuthSource` + subclasses) are passed through unchanged. + + New kwargs are merged over each source's existing kwargs — new values + win on conflict:: + + multi = MultiAuth(bearer, cookie) + + @app.get("/admin") + async def admin(user = Security(multi.require(role=Role.ADMIN))): + return user + """ + 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..2e5d600 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,964 @@ +"""Tests for fastapi_toolsets.security.""" + +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, +) + + +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 + + # --- extract() --- + + @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 + + # --- generate_token() --- + + 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 + + auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET) + + 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_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_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"} From 44dcea5ef232070270119f9497f96885ad9edd9f Mon Sep 17 00:00:00 2001 From: d3vyce Date: Sat, 7 Mar 2026 10:29:20 -0500 Subject: [PATCH 2/8] feat(security): add oauth helpers --- src/fastapi_toolsets/security/__init__.py | 15 +- src/fastapi_toolsets/security/oauth.py | 120 +++++++++++- tests/test_security.py | 218 +++++++++++++++++++++- 3 files changed, 344 insertions(+), 9 deletions(-) diff --git a/src/fastapi_toolsets/security/__init__.py b/src/fastapi_toolsets/security/__init__.py index 3d5505d..483b49b 100644 --- a/src/fastapi_toolsets/security/__init__.py +++ b/src/fastapi_toolsets/security/__init__.py @@ -1,7 +1,13 @@ """Authentication helpers for FastAPI using Security().""" from .abc import AuthSource -from .oauth import decode_oauth_state, encode_oauth_state +from .oauth import ( + oauth_build_authorization_redirect, + oauth_decode_state, + oauth_encode_state, + oauth_fetch_userinfo, + oauth_resolve_provider_urls, +) from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth __all__ = [ @@ -10,6 +16,9 @@ "BearerTokenAuth", "CookieAuth", "MultiAuth", - "decode_oauth_state", - "encode_oauth_state", + "oauth_build_authorization_redirect", + "oauth_decode_state", + "oauth_encode_state", + "oauth_fetch_userinfo", + "oauth_resolve_provider_urls", ] diff --git a/src/fastapi_toolsets/security/oauth.py b/src/fastapi_toolsets/security/oauth.py index 4e95281..f06c467 100644 --- a/src/fastapi_toolsets/security/oauth.py +++ b/src/fastapi_toolsets/security/oauth.py @@ -1,14 +1,130 @@ """OAuth 2.0 / OIDC helper utilities.""" import base64 +from typing import Any +from urllib.parse import urlencode +import httpx +from fastapi.responses import RedirectResponse -def encode_oauth_state(url: str) -> str: +_discovery_cache: dict[str, dict] = {} + + +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. + """ + if discovery_url not in _discovery_cache: + async with httpx.AsyncClient() as client: + resp = await client.get(discovery_url) + resp.raise_for_status() + _discovery_cache[discovery_url] = resp.json() + cfg = _discovery_cache[discovery_url] + 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, +) -> dict[str, Any]: + """Exchange an authorization code for tokens and return the userinfo payload. + + Performs the two-step OAuth 2.0 / OIDC token exchange: + + 1. POSTs the authorization *code* to *token_url* to obtain an access token. + 2. GETs *userinfo_url* using that access token as a Bearer credential. + + 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. + + Returns: + The JSON payload returned by the userinfo endpoint as a plain ``dict``. + """ + 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() + access_token = token_resp.json()["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_build_authorization_redirect( + authorization_url: str, + *, + client_id: str, + scopes: str, + redirect_uri: str, + destination: 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 (encoded as ``state``). + + 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), + } + ) + return RedirectResponse(f"{authorization_url}?{params}") + + +def oauth_encode_state(url: str) -> str: """Base64url-encode a URL to embed as an OAuth ``state`` parameter.""" return base64.urlsafe_b64encode(url.encode()).decode() -def decode_oauth_state(state: str | None, *, fallback: str) -> str: +def oauth_decode_state(state: str | None, *, fallback: str) -> str: """Decode a base64url OAuth ``state`` parameter. Handles missing padding (some providers strip ``=``). diff --git a/tests/test_security.py b/tests/test_security.py index 2e5d600..f2a7043 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,5 +1,8 @@ """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 @@ -11,6 +14,11 @@ BearerTokenAuth, CookieAuth, MultiAuth, + oauth_build_authorization_redirect, + oauth_decode_state, + oauth_encode_state, + oauth_fetch_userinfo, + oauth_resolve_provider_urls, ) @@ -129,8 +137,6 @@ async def me(user=Security(bearer)): response = client.get("/me", headers={"Authorization": "Bearer org_abc123"}) assert response.status_code == 401 - # --- extract() --- - @pytest.mark.anyio async def test_extract_no_header(self): from starlette.requests import Request @@ -196,8 +202,6 @@ async def test_extract_prefix_no_match(self): request = Request(scope) assert await bearer.extract(request) is None - # --- generate_token() --- - def test_generate_token_no_prefix(self): bearer = BearerTokenAuth(simple_validator) token = bearer.generate_token() @@ -962,3 +966,209 @@ async def me(user=Security(multi)): 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") + 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" + assert oauth_decode_state(oauth_encode_state(url), fallback="/") == url + + def test_decode_none_returns_fallback(self): + assert oauth_decode_state(None, fallback="/home") == "/home" + + def test_decode_null_string_returns_fallback(self): + assert oauth_decode_state("null", fallback="/home") == "/home" + + def test_decode_invalid_base64_returns_fallback(self): + assert oauth_decode_state("!!!notbase64!!!", fallback="/home") == "/home" + + def test_decode_handles_missing_padding(self): + url = "https://example.com/x" + encoded = oauth_encode_state(url).rstrip("=") + assert oauth_decode_state(encoded, fallback="/") == url + + +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", + ) + assert isinstance(response, RedirectResponse) + + def test_redirect_location_contains_all_params(self): + 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", + ) + 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], 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) + + with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): + with patch("httpx.AsyncClient", return_value=cm): + auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( + "https://auth.example.com/.well-known/openid-configuration" + ) + + 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) + + with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): + with patch("httpx.AsyncClient", return_value=cm): + _, _, userinfo_url = await oauth_resolve_provider_urls( + "https://auth.example.com/.well-known/openid-configuration" + ) + + 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" + with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): + with patch("httpx.AsyncClient", return_value=cm): + await oauth_resolve_provider_urls(url) + await oauth_resolve_provider_urls(url) + + 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"}, + ) From 34b733a5e13d164bdae084edcae3f5206bede511 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Mon, 9 Mar 2026 13:44:51 -0400 Subject: [PATCH 3/8] docs: add authentication example --- docs/examples/authentication.md | 1 + docs_src/examples/authentication/__init__.py | 0 docs_src/examples/authentication/app.py | 9 ++ docs_src/examples/authentication/crud.py | 9 ++ docs_src/examples/authentication/db.py | 15 +++ docs_src/examples/authentication/models.py | 105 ++++++++++++++++ docs_src/examples/authentication/routes.py | 122 +++++++++++++++++++ docs_src/examples/authentication/schemas.py | 64 ++++++++++ docs_src/examples/authentication/security.py | 100 +++++++++++++++ 9 files changed, 425 insertions(+) create mode 100644 docs/examples/authentication.md create mode 100644 docs_src/examples/authentication/__init__.py create mode 100644 docs_src/examples/authentication/app.py create mode 100644 docs_src/examples/authentication/crud.py create mode 100644 docs_src/examples/authentication/db.py create mode 100644 docs_src/examples/authentication/models.py create mode 100644 docs_src/examples/authentication/routes.py create mode 100644 docs_src/examples/authentication/schemas.py create mode 100644 docs_src/examples/authentication/security.py diff --git a/docs/examples/authentication.md b/docs/examples/authentication.md new file mode 100644 index 0000000..9c8a9e1 --- /dev/null +++ b/docs/examples/authentication.md @@ -0,0 +1 @@ +# Authentication diff --git a/docs_src/examples/authentication/__init__.py b/docs_src/examples/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/examples/authentication/app.py b/docs_src/examples/authentication/app.py new file mode 100644 index 0000000..8a6348f --- /dev/null +++ b/docs_src/examples/authentication/app.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + +from fastapi_toolsets.exceptions import init_exceptions_handlers + +from .routes import router + +app = FastAPI() +init_exceptions_handlers(app=app) +app.include_router(router=router) diff --git a/docs_src/examples/authentication/crud.py b/docs_src/examples/authentication/crud.py new file mode 100644 index 0000000..5a70acc --- /dev/null +++ b/docs_src/examples/authentication/crud.py @@ -0,0 +1,9 @@ +from fastapi_toolsets.crud import CrudFactory + +from .models import OAuthAccount, OAuthProvider, Team, User, UserToken + +TeamCrud = CrudFactory(model=Team) +UserCrud = CrudFactory(model=User) +UserTokenCrud = CrudFactory(model=UserToken) +OAuthProviderCrud = CrudFactory(model=OAuthProvider) +OAuthAccountCrud = CrudFactory(model=OAuthAccount) diff --git a/docs_src/examples/authentication/db.py b/docs_src/examples/authentication/db.py new file mode 100644 index 0000000..876cfd8 --- /dev/null +++ b/docs_src/examples/authentication/db.py @@ -0,0 +1,15 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from fastapi_toolsets.db import create_db_context, create_db_dependency + +DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" + +engine = create_async_engine(url=DATABASE_URL, future=True) +async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) + +get_db = create_db_dependency(session_maker=async_session_maker) +get_db_context = create_db_context(session_maker=async_session_maker) + + +SessionDep = Depends(get_db) diff --git a/docs_src/examples/authentication/models.py b/docs_src/examples/authentication/models.py new file mode 100644 index 0000000..9fb9a67 --- /dev/null +++ b/docs_src/examples/authentication/models.py @@ -0,0 +1,105 @@ +import enum +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ( + Boolean, + DateTime, + Enum, + ForeignKey, + Integer, + String, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from fastapi_toolsets.models import TimestampMixin, UUIDMixin + + +class Base(DeclarativeBase, UUIDMixin): + type_annotation_map = { + str: String(), + int: Integer(), + UUID: PG_UUID(as_uuid=True), + datetime: DateTime(timezone=True), + } + + +class UserRole(enum.Enum): + admin = "admin" + moderator = "moderator" + user = "user" + + +class Team(Base, TimestampMixin): + __tablename__ = "teams" + + name: Mapped[str] = mapped_column(String, unique=True, index=True) + users: Mapped[list["User"]] = relationship(back_populates="team") + + +class User(Base, TimestampMixin): + __tablename__ = "users" + + username: Mapped[str] = mapped_column(String, unique=True, index=True) + email: Mapped[str | None] = mapped_column( + String, unique=True, index=True, nullable=True + ) + hashed_password: Mapped[str | None] = mapped_column(String, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.user) + + team_id: Mapped[UUID | None] = mapped_column(ForeignKey("teams.id"), nullable=True) + team: Mapped["Team | None"] = relationship(back_populates="users") + oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="user") + tokens: Mapped[list["UserToken"]] = relationship(back_populates="user") + + +class UserToken(Base, TimestampMixin): + """API tokens for a user (multiple allowed).""" + + __tablename__ = "user_tokens" + + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) + # Store hashed token value + token_hash: Mapped[str] = mapped_column(String, unique=True, index=True) + name: Mapped[str | None] = mapped_column(String, nullable=True) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + user: Mapped["User"] = relationship(back_populates="tokens") + + +class OAuthProvider(Base, TimestampMixin): + """Configurable OAuth2 / OpenID Connect provider.""" + + __tablename__ = "oauth_providers" + + slug: Mapped[str] = mapped_column(String, unique=True, index=True) + name: Mapped[str] = mapped_column(String) + client_id: Mapped[str] = mapped_column(String) + client_secret: Mapped[str] = mapped_column(String) + discovery_url: Mapped[str] = mapped_column(String, nullable=False) + scopes: Mapped[str] = mapped_column(String, default="openid email profile") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="provider") + + +class OAuthAccount(Base, TimestampMixin): + """OAuth2 / OpenID Connect account linked to a user.""" + + __tablename__ = "oauth_accounts" + __table_args__ = ( + UniqueConstraint("provider_id", "subject", name="uq_oauth_provider_subject"), + ) + + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) + provider_id: Mapped[UUID] = mapped_column(ForeignKey("oauth_providers.id")) + # OAuth `sub` / OpenID subject identifier + subject: Mapped[str] = mapped_column(String) + + user: Mapped["User"] = relationship(back_populates="oauth_accounts") + provider: Mapped["OAuthProvider"] = relationship(back_populates="accounts") diff --git a/docs_src/examples/authentication/routes.py b/docs_src/examples/authentication/routes.py new file mode 100644 index 0000000..c800af0 --- /dev/null +++ b/docs_src/examples/authentication/routes.py @@ -0,0 +1,122 @@ +from typing import Annotated +from uuid import UUID + +import bcrypt +from fastapi import APIRouter, Form, HTTPException, Response, Security + +from fastapi_toolsets.dependencies import PathDependency + +from .crud import UserCrud, UserTokenCrud +from .db import SessionDep +from .models import OAuthProvider, User, UserToken +from .schemas import ( + ApiTokenCreateRequest, + ApiTokenResponse, + RegisterRequest, + UserCreate, + UserResponse, +) +from .security import auth, cookie_auth, create_api_token + +ProviderDep = PathDependency( + model=OAuthProvider, + field=OAuthProvider.slug, + session_dep=SessionDep, + param_name="slug", +) + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +router = APIRouter(prefix="/auth") + + +@router.post("/register", response_model=UserResponse, status_code=201) +async def register(body: RegisterRequest, session: SessionDep): + existing = await UserCrud.first( + session=session, filters=[User.username == body.username] + ) + if existing: + raise HTTPException(status_code=409, detail="Username already taken") + + user = await UserCrud.create( + session=session, + obj=UserCreate( + username=body.username, + email=body.email, + hashed_password=hash_password(body.password), + ), + ) + return user + + +@router.post("/token", status_code=204) +async def login( + session: SessionDep, + response: Response, + username: Annotated[str, Form()], + password: Annotated[str, Form()], +): + user = await UserCrud.first(session=session, filters=[User.username == username]) + + if ( + not user + or not user.hashed_password + or not verify_password(password, user.hashed_password) + ): + raise HTTPException(status_code=401, detail="Invalid credentials") + + if not user.is_active: + raise HTTPException(status_code=403, detail="Account disabled") + + cookie_auth.set_cookie(response, str(user.id)) + + +@router.post("/logout", status_code=204) +async def logout(response: Response): + cookie_auth.delete_cookie(response) + + +@router.get("/me", response_model=UserResponse) +async def me(user: User = Security(auth)): + return user + + +@router.post("/tokens", response_model=ApiTokenResponse, status_code=201) +async def create_token( + body: ApiTokenCreateRequest, + user: User = Security(auth), +): + raw, token_row = await create_api_token( + user.id, name=body.name, expires_at=body.expires_at + ) + return ApiTokenResponse( + id=token_row.id, + name=token_row.name, + expires_at=token_row.expires_at, + created_at=token_row.created_at, + token=raw, + ) + + +@router.delete("/tokens/{token_id}", status_code=204) +async def revoke_token( + session: SessionDep, + token_id: UUID, + user: User = Security(auth), +): + if not await UserTokenCrud.first( + session=session, + filters=[UserToken.id == token_id, UserToken.user_id == user.id], + ): + raise HTTPException(status_code=404, detail="Token not found") + await UserTokenCrud.delete( + session=session, + filters=[UserToken.id == token_id, UserToken.user_id == user.id], + ) diff --git a/docs_src/examples/authentication/schemas.py b/docs_src/examples/authentication/schemas.py new file mode 100644 index 0000000..3c21c15 --- /dev/null +++ b/docs_src/examples/authentication/schemas.py @@ -0,0 +1,64 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import EmailStr + +from fastapi_toolsets.schemas import PydanticBase + + +class RegisterRequest(PydanticBase): + username: str + password: str + email: EmailStr | None = None + + +class UserResponse(PydanticBase): + id: UUID + username: str + email: str | None + role: str + is_active: bool + + model_config = {"from_attributes": True} + + +class ApiTokenCreateRequest(PydanticBase): + name: str | None = None + expires_at: datetime | None = None + + +class ApiTokenResponse(PydanticBase): + id: UUID + name: str | None + expires_at: datetime | None + created_at: datetime + # Only populated on creation + token: str | None = None + + model_config = {"from_attributes": True} + + +class OAuthProviderResponse(PydanticBase): + slug: str + name: str + + model_config = {"from_attributes": True} + + +class UserCreate(PydanticBase): + username: str + email: str | None = None + hashed_password: str | None = None + + +class UserTokenCreate(PydanticBase): + user_id: UUID + token_hash: str + name: str | None = None + expires_at: datetime | None = None + + +class OAuthAccountCreate(PydanticBase): + user_id: UUID + provider_id: UUID + subject: str diff --git a/docs_src/examples/authentication/security.py b/docs_src/examples/authentication/security.py new file mode 100644 index 0000000..774ac05 --- /dev/null +++ b/docs_src/examples/authentication/security.py @@ -0,0 +1,100 @@ +import hashlib +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import selectinload + +from fastapi_toolsets.exceptions import UnauthorizedError +from fastapi_toolsets.security import ( + APIKeyHeaderAuth, + BearerTokenAuth, + CookieAuth, + MultiAuth, +) + +from .crud import UserCrud, UserTokenCrud +from .db import get_db_context +from .models import User, UserRole, UserToken +from .schemas import UserTokenCreate + +SESSION_COOKIE = "session" +SECRET_KEY = "123456789" + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +async def _verify_token(token: str, role: UserRole | None = None) -> User: + async with get_db_context() as db: + user_token = await UserTokenCrud.first( + session=db, + filters=[UserToken.token_hash == _hash_token(token)], + load_options=[selectinload(UserToken.user)], + ) + + if user_token is None or not user_token.user.is_active: + raise UnauthorizedError() + + if user_token.expires_at and user_token.expires_at < datetime.now(timezone.utc): + raise UnauthorizedError() + + user = user_token.user + + if role is not None and user.role != role: + raise HTTPException(status_code=403, detail="Insufficient permissions") + + return user + + +async def _verify_cookie(user_id: str, role: UserRole | None = None) -> User: + async with get_db_context() as db: + user = await UserCrud.first( + session=db, + filters=[User.id == UUID(user_id)], + ) + + if not user or not user.is_active: + raise UnauthorizedError() + + if role is not None and user.role != role: + raise HTTPException(status_code=403, detail="Insufficient permissions") + + return user + + +bearer_auth = BearerTokenAuth( + validator=_verify_token, + prefix="ctf_", +) +header_auth = APIKeyHeaderAuth( + name="X-API-Key", + validator=_verify_token, +) +cookie_auth = CookieAuth( + name=SESSION_COOKIE, + validator=_verify_cookie, + secret_key=SECRET_KEY, +) +auth = MultiAuth(bearer_auth, header_auth, cookie_auth) + + +async def create_api_token( + user_id: UUID, + *, + name: str | None = None, + expires_at: datetime | None = None, +) -> tuple[str, UserToken]: + raw = bearer_auth.generate_token() + async with get_db_context() as db: + token_row = await UserTokenCrud.create( + session=db, + obj=UserTokenCreate( + user_id=user_id, + token_hash=_hash_token(raw), + name=name, + expires_at=expires_at, + ), + ) + return raw, token_row From 3099e3f0e166a3db9d6343448d8de9fef9d1192d Mon Sep 17 00:00:00 2001 From: d3vyce Date: Wed, 18 Mar 2026 15:25:02 -0400 Subject: [PATCH 4/8] fix: cleanup + simplify --- pyproject.toml | 4 + src/fastapi_toolsets/security/abc.py | 16 ++-- .../security/sources/bearer.py | 24 +++--- .../security/sources/cookie.py | 19 ++--- .../security/sources/header.py | 14 ++-- .../security/sources/multi.py | 4 +- tests/test_security.py | 6 ++ uv.lock | 76 +++++++++++++++++++ 8 files changed, 120 insertions(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1a3296a..1c30b3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ 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", @@ -84,6 +85,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/abc.py b/src/fastapi_toolsets/security/abc.py index 6640bf8..3258225 100644 --- a/src/fastapi_toolsets/security/abc.py +++ b/src/fastapi_toolsets/security/abc.py @@ -10,13 +10,15 @@ from fastapi_toolsets.exceptions import UnauthorizedError -async def _call_validator( - validator: Callable[..., Any], *args: Any, **kwargs: Any -) -> Any: - """Call *validator* with *args* and *kwargs*, awaiting it if it is a coroutine function.""" - if inspect.iscoroutinefunction(validator): - return await validator(*args, **kwargs) - return validator(*args, **kwargs) +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 + + async def wrapper(*args: Any, **kwargs: Any) -> Any: + return fn(*args, **kwargs) + + return wrapper class AuthSource(ABC): diff --git a/src/fastapi_toolsets/security/sources/bearer.py b/src/fastapi_toolsets/security/sources/bearer.py index 3e49a33..2dcafbf 100644 --- a/src/fastapi_toolsets/security/sources/bearer.py +++ b/src/fastapi_toolsets/security/sources/bearer.py @@ -9,7 +9,7 @@ from fastapi_toolsets.exceptions import UnauthorizedError -from ..abc import AuthSource, _call_validator +from ..abc import AuthSource, _ensure_async class BearerTokenAuth(AuthSource): @@ -42,32 +42,30 @@ def __init__( prefix: str | None = None, **kwargs: Any, ) -> None: - self._validator = validator + self._validator = _ensure_async(validator) self._prefix = prefix self._kwargs = kwargs self._scheme = HTTPBearer(auto_error=False) - _scheme = self._scheme - _validator = validator - _kwargs = kwargs - _prefix = prefix - async def _call( security_scopes: SecurityScopes, # noqa: ARG001 credentials: Annotated[ - HTTPAuthorizationCredentials | None, Depends(_scheme) + HTTPAuthorizationCredentials | None, Depends(self._scheme) ] = None, ) -> Any: if credentials is None: raise UnauthorizedError() - token = credentials.credentials - if _prefix is not None and not token.startswith(_prefix): - raise UnauthorizedError() - return await _call_validator(_validator, token, **_kwargs) + 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: Any) -> str | None: """Extract the raw credential from the request without validating. @@ -91,7 +89,7 @@ async def authenticate(self, credential: str) -> Any: Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are the extra keyword arguments provided at instantiation. """ - return await _call_validator(self._validator, credential, **self._kwargs) + return await self._validate(credential) def require(self, **kwargs: Any) -> "BearerTokenAuth": """Return a new instance with additional (or overriding) validator kwargs.""" diff --git a/src/fastapi_toolsets/security/sources/cookie.py b/src/fastapi_toolsets/security/sources/cookie.py index 89eb2bf..3c269a0 100644 --- a/src/fastapi_toolsets/security/sources/cookie.py +++ b/src/fastapi_toolsets/security/sources/cookie.py @@ -13,7 +13,7 @@ from fastapi_toolsets.exceptions import UnauthorizedError -from ..abc import AuthSource, _call_validator +from ..abc import AuthSource, _ensure_async class CookieAuth(AuthSource): @@ -50,30 +50,27 @@ def __init__( **kwargs: Any, ) -> None: self._name = name - self._validator = validator + self._validator = _ensure_async(validator) self._secret_key = secret_key self._ttl = ttl self._kwargs = kwargs self._scheme = APIKeyCookie(name=name, auto_error=False) - _scheme = self._scheme - _self = self - _kwargs = kwargs - async def _call( security_scopes: SecurityScopes, # noqa: ARG001 - value: Annotated[str | None, Depends(_scheme)] = None, + value: Annotated[str | None, Depends(self._scheme)] = None, ) -> Any: if value is None: raise UnauthorizedError() - plain = _self._verify(value) - return await _call_validator(_self._validator, plain, **_kwargs) + 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: - assert self._secret_key is not None + 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() @@ -114,7 +111,7 @@ async def extract(self, request: Request) -> str | None: async def authenticate(self, credential: str) -> Any: plain = self._verify(credential) - return await _call_validator(self._validator, plain, **self._kwargs) + return await self._validator(plain, **self._kwargs) def require(self, **kwargs: Any) -> "CookieAuth": """Return a new instance with additional (or overriding) validator kwargs.""" diff --git a/src/fastapi_toolsets/security/sources/header.py b/src/fastapi_toolsets/security/sources/header.py index 067d5e2..ec4834f 100644 --- a/src/fastapi_toolsets/security/sources/header.py +++ b/src/fastapi_toolsets/security/sources/header.py @@ -8,7 +8,7 @@ from fastapi_toolsets.exceptions import UnauthorizedError -from ..abc import AuthSource, _call_validator +from ..abc import AuthSource, _ensure_async class APIKeyHeaderAuth(AuthSource): @@ -35,21 +35,17 @@ def __init__( **kwargs: Any, ) -> None: self._name = name - self._validator = validator + self._validator = _ensure_async(validator) self._kwargs = kwargs self._scheme = APIKeyHeader(name=name, auto_error=False) - _scheme = self._scheme - _validator = validator - _kwargs = kwargs - async def _call( security_scopes: SecurityScopes, # noqa: ARG001 - api_key: Annotated[str | None, Depends(_scheme)] = None, + api_key: Annotated[str | None, Depends(self._scheme)] = None, ) -> Any: if api_key is None: raise UnauthorizedError() - return await _call_validator(_validator, api_key, **_kwargs) + return await self._validator(api_key, **self._kwargs) self._call_fn = _call self.__signature__ = inspect.signature(_call) @@ -60,7 +56,7 @@ async def extract(self, request: Request) -> str | None: async def authenticate(self, credential: str) -> Any: """Validate a credential and return the identity.""" - return await _call_validator(self._validator, credential, **self._kwargs) + return await self._validator(credential, **self._kwargs) def require(self, **kwargs: Any) -> "APIKeyHeaderAuth": """Return a new instance with additional (or overriding) validator kwargs.""" diff --git a/src/fastapi_toolsets/security/sources/multi.py b/src/fastapi_toolsets/security/sources/multi.py index 78b6558..4a9160e 100644 --- a/src/fastapi_toolsets/security/sources/multi.py +++ b/src/fastapi_toolsets/security/sources/multi.py @@ -55,14 +55,12 @@ async def admin_route(user = Security(multi.require(role=Role.ADMIN))): def __init__(self, *sources: AuthSource) -> None: self._sources = sources - _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 _sources: + for source in self._sources: credential = await source.extract(request) if credential is not None: return await source.authenticate(credential) diff --git a/tests/test_security.py b/tests/test_security.py index f2a7043..8502b56 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -857,6 +857,12 @@ async def me(user=Security(auth)): 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 diff --git a/uv.lock b/uv.lock index 8608acf..1a5f98e 100644 --- a/uv.lock +++ b/uv.lock @@ -81,6 +81,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" @@ -282,6 +352,7 @@ pytest = [ [package.dev-dependencies] dev = [ + { name = "bcrypt" }, { name = "coverage" }, { name = "fastapi-toolsets", extra = ["all"] }, { name = "httpx" }, @@ -301,6 +372,9 @@ docs = [ { name = "mkdocstrings-python" }, { name = "zensical" }, ] +docs-src = [ + { name = "bcrypt" }, +] tests = [ { name = "coverage" }, { name = "httpx" }, @@ -327,6 +401,7 @@ provides-extras = ["cli", "metrics", "pytest", "all"] [package.metadata.requires-dev] dev = [ + { name = "bcrypt", specifier = ">=4.0.0" }, { name = "coverage", specifier = ">=7.0.0" }, { name = "fastapi-toolsets", extras = ["all"] }, { name = "httpx", specifier = ">=0.25.0" }, @@ -346,6 +421,7 @@ docs = [ { name = "mkdocstrings-python", specifier = ">=2.0.2" }, { name = "zensical", specifier = ">=0.0.30" }, ] +docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }] tests = [ { name = "coverage", specifier = ">=7.0.0" }, { name = "httpx", specifier = ">=0.25.0" }, From 95f5a83bd21cf24ab334e99e8d27c834123d10c2 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Sun, 3 May 2026 06:55:39 -0400 Subject: [PATCH 5/8] docs: update module and reference --- docs/module/security.md | 187 +++++++++++++++++++++++++++++-------- docs/reference/security.md | 22 ++++- zensical.toml | 2 + 3 files changed, 168 insertions(+), 43 deletions(-) diff --git a/docs/module/security.md b/docs/module/security.md index 6ddddb5..0664ae5 100644 --- a/docs/module/security.md +++ b/docs/module/security.md @@ -4,7 +4,7 @@ Composable authentication helpers for FastAPI that use `Security()` for OpenAPI ## Overview -The `security` module provides four auth source classes and a `MultiAuth` factory. Each class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as: +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) @@ -92,60 +92,53 @@ async def me(user: User = Security(cookie_auth)): return user ``` -### [`OAuth2Auth`](../reference/security.md#fastapi_toolsets.security.OAuth2Auth) +#### Signed cookies -Reads the `Authorization: Bearer ` header and registers the token endpoint -in OpenAPI via `OAuth2PasswordBearer`. +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. -```python -from fastapi_toolsets.security import OAuth2Auth - -oauth2_auth = OAuth2Auth(token_url="/token", validator=verify_token) - -@app.get("/me") -async def me(user: User = Security(oauth2_auth)): - return user -``` - -### [`OpenIDAuth`](../reference/security.md#fastapi_toolsets.security.OpenIDAuth) - -Reads the `Authorization: Bearer ` header and registers the OpenID Connect -discovery URL in OpenAPI via `OpenIdConnect`. Token validation is fully delegated -to your validator — use any OIDC / JWT library (`authlib`, `python-jose`, `PyJWT`). +Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to +clear it on logout: ```python -from fastapi_toolsets.security import OpenIDAuth +cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret") -async def verify_google_token(token: str, *, audience: str) -> User: - payload = jwt.decode(token, google_public_keys, algorithms=["RS256"], - audience=audience) - return User(email=payload["email"], name=payload["name"]) +@app.post("/login") +async def login(response: Response): + cookie_auth.set_cookie(response, user_id) + return {"ok": True} -google_auth = OpenIDAuth( - "https://accounts.google.com/.well-known/openid-configuration", - verify_google_token, - audience="my-client-id", -) +@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(google_auth)): +async def me(user: User = Security(cookie_auth)): return user ``` -The discovery URL is used **only for OpenAPI documentation** — no requests are made -to it by this class. You are responsible for fetching and caching the provider's -public keys in your validator. +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) -Multiple providers work naturally with `MultiAuth`: +Reads an API key from a named HTTP header. Wraps `APIKeyHeader` for OpenAPI. ```python -multi = MultiAuth(google_auth, github_auth) +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(multi)): +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. @@ -184,8 +177,8 @@ async def profile(user: User = Security(bearer.require(role=Role.USER))): ``` `.require()` kwargs are merged over existing ones — new values win on conflict. -The `prefix` (for `BearerTokenAuth`) and cookie name (for `CookieAuth`) are -always preserved. +The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for +`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved. `.require()` instances work transparently inside `MultiAuth`: @@ -202,6 +195,10 @@ multi = MultiAuth( 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 @@ -262,6 +259,120 @@ 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. The `destination` +URL (where to send the user after the full flow) is encoded as the `state` +parameter: + +```python +from fastapi_toolsets.security import oauth_build_authorization_redirect + +@app.get("/auth/google/login") +async def google_login(): + auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL) + 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", + ) +``` + +### 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: + +```python +from fastapi_toolsets.security import oauth_fetch_userinfo + +@app.get("/auth/google/callback") +async def google_callback(code: str, state: str): + _, 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", + ) + user = await db.upsert_user(email=userinfo["email"]) + destination = oauth_decode_state(state, fallback="/") + response = RedirectResponse(destination) + session_cookie.set_cookie(response, str(user.id)) + return response +``` + +### 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) +base64url-encode and decode the destination URL embedded in the OAuth `state` +parameter. `oauth_decode_state` handles missing padding and returns the `fallback` +if `state` is absent, `"null"`, or malformed: + +```python +from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state + +encoded = oauth_encode_state("/dashboard") # e.g. "L2Rhc2hib2FyZA==" +decoded = oauth_decode_state(encoded, fallback="/") # "/dashboard" +decoded = oauth_decode_state(None, fallback="/") # "/" +decoded = oauth_decode_state("null", fallback="/") # "/" +``` + --- [:material-api: API Reference](../reference/security.md) diff --git a/docs/reference/security.md b/docs/reference/security.md index f38235d..72a7692 100644 --- a/docs/reference/security.md +++ b/docs/reference/security.md @@ -9,9 +9,13 @@ from fastapi_toolsets.security import ( AuthSource, BearerTokenAuth, CookieAuth, - OAuth2Auth, - OpenIDAuth, + APIKeyHeaderAuth, MultiAuth, + oauth_build_authorization_redirect, + oauth_decode_state, + oauth_encode_state, + oauth_fetch_userinfo, + oauth_resolve_provider_urls, ) ``` @@ -21,8 +25,16 @@ from fastapi_toolsets.security import ( ## ::: fastapi_toolsets.security.CookieAuth -## ::: fastapi_toolsets.security.OAuth2Auth - -## ::: fastapi_toolsets.security.OpenIDAuth +## ::: 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_build_authorization_redirect + +## ::: fastapi_toolsets.security.oauth_encode_state + +## ::: fastapi_toolsets.security.oauth_decode_state 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]] From 6bef88fde6bb3ff9d67d15057f9ca9b56c847bdf Mon Sep 17 00:00:00 2001 From: d3vyce Date: Thu, 7 May 2026 18:21:34 -0400 Subject: [PATCH 6/8] fix: multiple security bugs + remove example for now --- docs/examples/authentication.md | 1 - docs/module/security.md | 135 +++++++------- docs/reference/security.md | 3 + docs_src/examples/authentication/__init__.py | 0 docs_src/examples/authentication/app.py | 9 - docs_src/examples/authentication/crud.py | 9 - docs_src/examples/authentication/db.py | 15 -- docs_src/examples/authentication/models.py | 105 ----------- docs_src/examples/authentication/routes.py | 122 ------------- docs_src/examples/authentication/schemas.py | 64 ------- docs_src/examples/authentication/security.py | 100 ----------- src/fastapi_toolsets/security/__init__.py | 2 + src/fastapi_toolsets/security/abc.py | 2 + src/fastapi_toolsets/security/oauth.py | 118 +++++++++--- .../security/sources/bearer.py | 4 +- .../security/sources/cookie.py | 11 +- .../security/sources/multi.py | 50 +----- tests/test_security.py | 169 +++++++++++++++++- 18 files changed, 334 insertions(+), 585 deletions(-) delete mode 100644 docs/examples/authentication.md delete mode 100644 docs_src/examples/authentication/__init__.py delete mode 100644 docs_src/examples/authentication/app.py delete mode 100644 docs_src/examples/authentication/crud.py delete mode 100644 docs_src/examples/authentication/db.py delete mode 100644 docs_src/examples/authentication/models.py delete mode 100644 docs_src/examples/authentication/routes.py delete mode 100644 docs_src/examples/authentication/schemas.py delete mode 100644 docs_src/examples/authentication/security.py diff --git a/docs/examples/authentication.md b/docs/examples/authentication.md deleted file mode 100644 index 9c8a9e1..0000000 --- a/docs/examples/authentication.md +++ /dev/null @@ -1 +0,0 @@ -# Authentication diff --git a/docs/module/security.md b/docs/module/security.md index 0664ae5..5013537 100644 --- a/docs/module/security.md +++ b/docs/module/security.md @@ -47,12 +47,9 @@ async def me(user: User = Security(bearer)): #### 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. +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`: +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_..." @@ -63,9 +60,7 @@ 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: +`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_") @@ -75,18 +70,23 @@ 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. +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 @@ -94,16 +94,17 @@ async def me(user: User = Security(cookie_auth)): #### 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. +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: +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) @@ -119,8 +120,7 @@ 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). +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) @@ -136,14 +136,11 @@ 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"`). +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. +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: @@ -155,14 +152,11 @@ async def verify_token(token: str, *, role: Role, permission: str) -> 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=[...])`. +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: +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) @@ -191,13 +185,9 @@ multi = MultiAuth( ## 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. +[`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. +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 @@ -211,9 +201,7 @@ async def data_route(user = Security(multi)): ### 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: +`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) @@ -237,9 +225,7 @@ MultiAuth( ### 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: +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_") @@ -251,8 +237,7 @@ multi = MultiAuth(user_bearer, org_bearer) # "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: +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_..." @@ -261,9 +246,7 @@ 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()`: +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 @@ -284,16 +267,11 @@ 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. +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: +[`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 @@ -303,42 +281,51 @@ auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( ) ``` -Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` -is `None` when the provider does not advertise one. +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. The `destination` -URL (where to send the user after the full flow) is encoded as the `state` -parameter: +[`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 `nonce` — a random CSRF token generated by [`oauth_generate_nonce()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_nonce) — 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): ```python -from fastapi_toolsets.security import oauth_build_authorization_redirect +from fastapi import Request +from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_nonce @app.get("/auth/google/login") -async def google_login(): +async def google_login(request: Request): auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL) + nonce = oauth_generate_nonce() + request.session["oauth_nonce"] = nonce # 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", + nonce=nonce, ) ``` ### 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: +[`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 nonce 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_toolsets.security import oauth_fetch_userinfo +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(code: str, state: str): +async def google_callback(request: Request, code: str, state: str): + # Pop nonce first — single-use, regardless of whether verification succeeds + nonce = request.session.pop("oauth_nonce", None) + if nonce is None: + raise HTTPException(status_code=400, detail="missing OAuth state") + destination = oauth_decode_state(state, expected_nonce=nonce, 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, @@ -347,30 +334,28 @@ async def google_callback(code: str, state: str): 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"]) - destination = oauth_decode_state(state, fallback="/") 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) -base64url-encode and decode the destination URL embedded in the OAuth `state` -parameter. `oauth_decode_state` handles missing padding and returns the `fallback` -if `state` is absent, `"null"`, or malformed: +[`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 nonce embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the nonce does not match: ```python from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state -encoded = oauth_encode_state("/dashboard") # e.g. "L2Rhc2hib2FyZA==" -decoded = oauth_decode_state(encoded, fallback="/") # "/dashboard" -decoded = oauth_decode_state(None, fallback="/") # "/" -decoded = oauth_decode_state("null", fallback="/") # "/" +nonce = "my-random-nonce" +encoded = oauth_encode_state("/dashboard", nonce) +decoded = oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") # "/dashboard" +decoded = oauth_decode_state(encoded, expected_nonce="wrong", fallback="/") # "/" +decoded = oauth_decode_state(None, expected_nonce=nonce, fallback="/") # "/" ``` --- diff --git a/docs/reference/security.md b/docs/reference/security.md index 72a7692..e5e1f08 100644 --- a/docs/reference/security.md +++ b/docs/reference/security.md @@ -15,6 +15,7 @@ from fastapi_toolsets.security import ( oauth_decode_state, oauth_encode_state, oauth_fetch_userinfo, + oauth_generate_nonce, oauth_resolve_provider_urls, ) ``` @@ -33,6 +34,8 @@ from fastapi_toolsets.security import ( ## ::: fastapi_toolsets.security.oauth_fetch_userinfo +## ::: fastapi_toolsets.security.oauth_generate_nonce + ## ::: fastapi_toolsets.security.oauth_build_authorization_redirect ## ::: fastapi_toolsets.security.oauth_encode_state diff --git a/docs_src/examples/authentication/__init__.py b/docs_src/examples/authentication/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docs_src/examples/authentication/app.py b/docs_src/examples/authentication/app.py deleted file mode 100644 index 8a6348f..0000000 --- a/docs_src/examples/authentication/app.py +++ /dev/null @@ -1,9 +0,0 @@ -from fastapi import FastAPI - -from fastapi_toolsets.exceptions import init_exceptions_handlers - -from .routes import router - -app = FastAPI() -init_exceptions_handlers(app=app) -app.include_router(router=router) diff --git a/docs_src/examples/authentication/crud.py b/docs_src/examples/authentication/crud.py deleted file mode 100644 index 5a70acc..0000000 --- a/docs_src/examples/authentication/crud.py +++ /dev/null @@ -1,9 +0,0 @@ -from fastapi_toolsets.crud import CrudFactory - -from .models import OAuthAccount, OAuthProvider, Team, User, UserToken - -TeamCrud = CrudFactory(model=Team) -UserCrud = CrudFactory(model=User) -UserTokenCrud = CrudFactory(model=UserToken) -OAuthProviderCrud = CrudFactory(model=OAuthProvider) -OAuthAccountCrud = CrudFactory(model=OAuthAccount) diff --git a/docs_src/examples/authentication/db.py b/docs_src/examples/authentication/db.py deleted file mode 100644 index 876cfd8..0000000 --- a/docs_src/examples/authentication/db.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi import Depends -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from fastapi_toolsets.db import create_db_context, create_db_dependency - -DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" - -engine = create_async_engine(url=DATABASE_URL, future=True) -async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) - -get_db = create_db_dependency(session_maker=async_session_maker) -get_db_context = create_db_context(session_maker=async_session_maker) - - -SessionDep = Depends(get_db) diff --git a/docs_src/examples/authentication/models.py b/docs_src/examples/authentication/models.py deleted file mode 100644 index 9fb9a67..0000000 --- a/docs_src/examples/authentication/models.py +++ /dev/null @@ -1,105 +0,0 @@ -import enum -from datetime import datetime -from uuid import UUID - -from sqlalchemy import ( - Boolean, - DateTime, - Enum, - ForeignKey, - Integer, - String, - UniqueConstraint, -) -from sqlalchemy.dialects.postgresql import UUID as PG_UUID -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - -from fastapi_toolsets.models import TimestampMixin, UUIDMixin - - -class Base(DeclarativeBase, UUIDMixin): - type_annotation_map = { - str: String(), - int: Integer(), - UUID: PG_UUID(as_uuid=True), - datetime: DateTime(timezone=True), - } - - -class UserRole(enum.Enum): - admin = "admin" - moderator = "moderator" - user = "user" - - -class Team(Base, TimestampMixin): - __tablename__ = "teams" - - name: Mapped[str] = mapped_column(String, unique=True, index=True) - users: Mapped[list["User"]] = relationship(back_populates="team") - - -class User(Base, TimestampMixin): - __tablename__ = "users" - - username: Mapped[str] = mapped_column(String, unique=True, index=True) - email: Mapped[str | None] = mapped_column( - String, unique=True, index=True, nullable=True - ) - hashed_password: Mapped[str | None] = mapped_column(String, nullable=True) - is_active: Mapped[bool] = mapped_column(Boolean, default=True) - role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.user) - - team_id: Mapped[UUID | None] = mapped_column(ForeignKey("teams.id"), nullable=True) - team: Mapped["Team | None"] = relationship(back_populates="users") - oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="user") - tokens: Mapped[list["UserToken"]] = relationship(back_populates="user") - - -class UserToken(Base, TimestampMixin): - """API tokens for a user (multiple allowed).""" - - __tablename__ = "user_tokens" - - user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) - # Store hashed token value - token_hash: Mapped[str] = mapped_column(String, unique=True, index=True) - name: Mapped[str | None] = mapped_column(String, nullable=True) - expires_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - - user: Mapped["User"] = relationship(back_populates="tokens") - - -class OAuthProvider(Base, TimestampMixin): - """Configurable OAuth2 / OpenID Connect provider.""" - - __tablename__ = "oauth_providers" - - slug: Mapped[str] = mapped_column(String, unique=True, index=True) - name: Mapped[str] = mapped_column(String) - client_id: Mapped[str] = mapped_column(String) - client_secret: Mapped[str] = mapped_column(String) - discovery_url: Mapped[str] = mapped_column(String, nullable=False) - scopes: Mapped[str] = mapped_column(String, default="openid email profile") - is_active: Mapped[bool] = mapped_column(Boolean, default=True) - - accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="provider") - - -class OAuthAccount(Base, TimestampMixin): - """OAuth2 / OpenID Connect account linked to a user.""" - - __tablename__ = "oauth_accounts" - __table_args__ = ( - UniqueConstraint("provider_id", "subject", name="uq_oauth_provider_subject"), - ) - - user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) - provider_id: Mapped[UUID] = mapped_column(ForeignKey("oauth_providers.id")) - # OAuth `sub` / OpenID subject identifier - subject: Mapped[str] = mapped_column(String) - - user: Mapped["User"] = relationship(back_populates="oauth_accounts") - provider: Mapped["OAuthProvider"] = relationship(back_populates="accounts") diff --git a/docs_src/examples/authentication/routes.py b/docs_src/examples/authentication/routes.py deleted file mode 100644 index c800af0..0000000 --- a/docs_src/examples/authentication/routes.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import Annotated -from uuid import UUID - -import bcrypt -from fastapi import APIRouter, Form, HTTPException, Response, Security - -from fastapi_toolsets.dependencies import PathDependency - -from .crud import UserCrud, UserTokenCrud -from .db import SessionDep -from .models import OAuthProvider, User, UserToken -from .schemas import ( - ApiTokenCreateRequest, - ApiTokenResponse, - RegisterRequest, - UserCreate, - UserResponse, -) -from .security import auth, cookie_auth, create_api_token - -ProviderDep = PathDependency( - model=OAuthProvider, - field=OAuthProvider.slug, - session_dep=SessionDep, - param_name="slug", -) - - -def hash_password(password: str) -> str: - return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() - - -def verify_password(plain: str, hashed: str) -> bool: - return bcrypt.checkpw(plain.encode(), hashed.encode()) - - -router = APIRouter(prefix="/auth") - - -@router.post("/register", response_model=UserResponse, status_code=201) -async def register(body: RegisterRequest, session: SessionDep): - existing = await UserCrud.first( - session=session, filters=[User.username == body.username] - ) - if existing: - raise HTTPException(status_code=409, detail="Username already taken") - - user = await UserCrud.create( - session=session, - obj=UserCreate( - username=body.username, - email=body.email, - hashed_password=hash_password(body.password), - ), - ) - return user - - -@router.post("/token", status_code=204) -async def login( - session: SessionDep, - response: Response, - username: Annotated[str, Form()], - password: Annotated[str, Form()], -): - user = await UserCrud.first(session=session, filters=[User.username == username]) - - if ( - not user - or not user.hashed_password - or not verify_password(password, user.hashed_password) - ): - raise HTTPException(status_code=401, detail="Invalid credentials") - - if not user.is_active: - raise HTTPException(status_code=403, detail="Account disabled") - - cookie_auth.set_cookie(response, str(user.id)) - - -@router.post("/logout", status_code=204) -async def logout(response: Response): - cookie_auth.delete_cookie(response) - - -@router.get("/me", response_model=UserResponse) -async def me(user: User = Security(auth)): - return user - - -@router.post("/tokens", response_model=ApiTokenResponse, status_code=201) -async def create_token( - body: ApiTokenCreateRequest, - user: User = Security(auth), -): - raw, token_row = await create_api_token( - user.id, name=body.name, expires_at=body.expires_at - ) - return ApiTokenResponse( - id=token_row.id, - name=token_row.name, - expires_at=token_row.expires_at, - created_at=token_row.created_at, - token=raw, - ) - - -@router.delete("/tokens/{token_id}", status_code=204) -async def revoke_token( - session: SessionDep, - token_id: UUID, - user: User = Security(auth), -): - if not await UserTokenCrud.first( - session=session, - filters=[UserToken.id == token_id, UserToken.user_id == user.id], - ): - raise HTTPException(status_code=404, detail="Token not found") - await UserTokenCrud.delete( - session=session, - filters=[UserToken.id == token_id, UserToken.user_id == user.id], - ) diff --git a/docs_src/examples/authentication/schemas.py b/docs_src/examples/authentication/schemas.py deleted file mode 100644 index 3c21c15..0000000 --- a/docs_src/examples/authentication/schemas.py +++ /dev/null @@ -1,64 +0,0 @@ -from datetime import datetime -from uuid import UUID - -from pydantic import EmailStr - -from fastapi_toolsets.schemas import PydanticBase - - -class RegisterRequest(PydanticBase): - username: str - password: str - email: EmailStr | None = None - - -class UserResponse(PydanticBase): - id: UUID - username: str - email: str | None - role: str - is_active: bool - - model_config = {"from_attributes": True} - - -class ApiTokenCreateRequest(PydanticBase): - name: str | None = None - expires_at: datetime | None = None - - -class ApiTokenResponse(PydanticBase): - id: UUID - name: str | None - expires_at: datetime | None - created_at: datetime - # Only populated on creation - token: str | None = None - - model_config = {"from_attributes": True} - - -class OAuthProviderResponse(PydanticBase): - slug: str - name: str - - model_config = {"from_attributes": True} - - -class UserCreate(PydanticBase): - username: str - email: str | None = None - hashed_password: str | None = None - - -class UserTokenCreate(PydanticBase): - user_id: UUID - token_hash: str - name: str | None = None - expires_at: datetime | None = None - - -class OAuthAccountCreate(PydanticBase): - user_id: UUID - provider_id: UUID - subject: str diff --git a/docs_src/examples/authentication/security.py b/docs_src/examples/authentication/security.py deleted file mode 100644 index 774ac05..0000000 --- a/docs_src/examples/authentication/security.py +++ /dev/null @@ -1,100 +0,0 @@ -import hashlib -from datetime import datetime, timezone -from uuid import UUID - -from fastapi import HTTPException -from sqlalchemy.orm import selectinload - -from fastapi_toolsets.exceptions import UnauthorizedError -from fastapi_toolsets.security import ( - APIKeyHeaderAuth, - BearerTokenAuth, - CookieAuth, - MultiAuth, -) - -from .crud import UserCrud, UserTokenCrud -from .db import get_db_context -from .models import User, UserRole, UserToken -from .schemas import UserTokenCreate - -SESSION_COOKIE = "session" -SECRET_KEY = "123456789" - - -def _hash_token(token: str) -> str: - return hashlib.sha256(token.encode()).hexdigest() - - -async def _verify_token(token: str, role: UserRole | None = None) -> User: - async with get_db_context() as db: - user_token = await UserTokenCrud.first( - session=db, - filters=[UserToken.token_hash == _hash_token(token)], - load_options=[selectinload(UserToken.user)], - ) - - if user_token is None or not user_token.user.is_active: - raise UnauthorizedError() - - if user_token.expires_at and user_token.expires_at < datetime.now(timezone.utc): - raise UnauthorizedError() - - user = user_token.user - - if role is not None and user.role != role: - raise HTTPException(status_code=403, detail="Insufficient permissions") - - return user - - -async def _verify_cookie(user_id: str, role: UserRole | None = None) -> User: - async with get_db_context() as db: - user = await UserCrud.first( - session=db, - filters=[User.id == UUID(user_id)], - ) - - if not user or not user.is_active: - raise UnauthorizedError() - - if role is not None and user.role != role: - raise HTTPException(status_code=403, detail="Insufficient permissions") - - return user - - -bearer_auth = BearerTokenAuth( - validator=_verify_token, - prefix="ctf_", -) -header_auth = APIKeyHeaderAuth( - name="X-API-Key", - validator=_verify_token, -) -cookie_auth = CookieAuth( - name=SESSION_COOKIE, - validator=_verify_cookie, - secret_key=SECRET_KEY, -) -auth = MultiAuth(bearer_auth, header_auth, cookie_auth) - - -async def create_api_token( - user_id: UUID, - *, - name: str | None = None, - expires_at: datetime | None = None, -) -> tuple[str, UserToken]: - raw = bearer_auth.generate_token() - async with get_db_context() as db: - token_row = await UserTokenCrud.create( - session=db, - obj=UserTokenCreate( - user_id=user_id, - token_hash=_hash_token(raw), - name=name, - expires_at=expires_at, - ), - ) - return raw, token_row diff --git a/src/fastapi_toolsets/security/__init__.py b/src/fastapi_toolsets/security/__init__.py index 483b49b..b8cd69e 100644 --- a/src/fastapi_toolsets/security/__init__.py +++ b/src/fastapi_toolsets/security/__init__.py @@ -6,6 +6,7 @@ oauth_decode_state, oauth_encode_state, oauth_fetch_userinfo, + oauth_generate_nonce, oauth_resolve_provider_urls, ) from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth @@ -20,5 +21,6 @@ "oauth_decode_state", "oauth_encode_state", "oauth_fetch_userinfo", + "oauth_generate_nonce", "oauth_resolve_provider_urls", ] diff --git a/src/fastapi_toolsets/security/abc.py b/src/fastapi_toolsets/security/abc.py index 3258225..9eb8c93 100644 --- a/src/fastapi_toolsets/security/abc.py +++ b/src/fastapi_toolsets/security/abc.py @@ -1,5 +1,6 @@ """Abstract base class for authentication sources.""" +import functools import inspect from abc import ABC, abstractmethod from typing import Any, Callable @@ -15,6 +16,7 @@ def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]: if inspect.iscoroutinefunction(fn): return fn + @functools.wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> Any: return fn(*args, **kwargs) diff --git a/src/fastapi_toolsets/security/oauth.py b/src/fastapi_toolsets/security/oauth.py index f06c467..4c27812 100644 --- a/src/fastapi_toolsets/security/oauth.py +++ b/src/fastapi_toolsets/security/oauth.py @@ -1,13 +1,20 @@ """OAuth 2.0 / OIDC helper utilities.""" import base64 +import binascii +import hmac +import json +import secrets +import time as _time from typing import Any from urllib.parse import urlencode import httpx from fastapi.responses import RedirectResponse -_discovery_cache: dict[str, dict] = {} +_discovery_cache: dict[str, tuple[dict[str, Any], float]] = {} +_DISCOVERY_TTL_SECONDS = 3600 # 1 hour +_DISCOVERY_CACHE_MAX = 32 async def oauth_resolve_provider_urls( @@ -22,12 +29,17 @@ async def oauth_resolve_provider_urls( A ``(authorization_url, token_url, userinfo_url)`` tuple. *userinfo_url* is ``None`` when the provider does not advertise one. """ - if discovery_url not in _discovery_cache: + now = _time.time() + cached = _discovery_cache.get(discovery_url) + if cached is None or now - cached[1] > _DISCOVERY_TTL_SECONDS: async with httpx.AsyncClient() as client: resp = await client.get(discovery_url) resp.raise_for_status() - _discovery_cache[discovery_url] = resp.json() - cfg = _discovery_cache[discovery_url] + if len(_discovery_cache) >= _DISCOVERY_CACHE_MAX: + oldest = min(_discovery_cache, key=lambda k: _discovery_cache[k][1]) + del _discovery_cache[oldest] + _discovery_cache[discovery_url] = (resp.json(), now) + cfg = _discovery_cache[discovery_url][0] return ( cfg["authorization_endpoint"], cfg["token_endpoint"], @@ -43,14 +55,10 @@ async def oauth_fetch_userinfo( 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. - Performs the two-step OAuth 2.0 / OIDC token exchange: - - 1. POSTs the authorization *code* to *token_url* to obtain an access token. - 2. GETs *userinfo_url* using that access token as a Bearer credential. - Args: token_url: Provider's token endpoint. userinfo_url: Provider's userinfo endpoint. @@ -58,9 +66,16 @@ async def oauth_fetch_userinfo( 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( @@ -75,7 +90,20 @@ async def oauth_fetch_userinfo( headers={"Accept": "application/json"}, ) token_resp.raise_for_status() - access_token = token_resp.json()["access_token"] + 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, @@ -85,6 +113,16 @@ async def oauth_fetch_userinfo( return userinfo_resp.json() +def oauth_generate_nonce() -> str: + """Generate a cryptographically random nonce for use as an OAuth CSRF token. + + Call this before :func:`oauth_build_authorization_redirect`, persist the + returned value in the user's session or a ``Secure; HttpOnly; SameSite=Lax`` + cookie, then verify it with :func:`oauth_decode_state` on the callback. + """ + return secrets.token_urlsafe(32) + + def oauth_build_authorization_redirect( authorization_url: str, *, @@ -92,6 +130,7 @@ def oauth_build_authorization_redirect( scopes: str, redirect_uri: str, destination: str, + nonce: str, ) -> RedirectResponse: """Return an OAuth 2.0 authorization ``RedirectResponse``. @@ -101,7 +140,10 @@ def oauth_build_authorization_redirect( 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 (encoded as ``state``). + completes (embedded in ``state``). + nonce: CSRF token generated by :func:`oauth_generate_nonce`. Must be + stored server-side (session or signed cookie) and verified via + :func:`oauth_decode_state` on the callback endpoint. Returns: A :class:`~fastapi.responses.RedirectResponse` to the provider's @@ -113,28 +155,56 @@ def oauth_build_authorization_redirect( "response_type": "code", "scope": scopes, "redirect_uri": redirect_uri, - "state": oauth_encode_state(destination), + "state": oauth_encode_state(destination, nonce), } ) return RedirectResponse(f"{authorization_url}?{params}") -def oauth_encode_state(url: str) -> str: - """Base64url-encode a URL to embed as an OAuth ``state`` parameter.""" - return base64.urlsafe_b64encode(url.encode()).decode() +def oauth_encode_state(url: str, nonce: str) -> str: + """Encode a destination URL and CSRF nonce into an OAuth ``state`` parameter. + Args: + url: Post-login destination URL. + nonce: CSRF token from :func:`oauth_generate_nonce`. + """ + payload = json.dumps({"n": nonce, "d": url}, separators=(",", ":")) + return base64.urlsafe_b64encode(payload.encode()).decode() + + +def oauth_decode_state(state: str | None, *, expected_nonce: str, fallback: str) -> str: + """Decode and CSRF-verify an OAuth ``state`` parameter. + + Uses a constant-time comparison for the nonce to prevent timing attacks. + + Args: + state: Raw ``state`` query parameter from the provider's callback. + expected_nonce: The nonce stored before the authorization redirect. + If the decoded nonce does not match, ``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``. -def oauth_decode_state(state: str | None, *, fallback: str) -> str: - """Decode a base64url OAuth ``state`` parameter. + Important: + **Single-use**: delete the stored nonce from the session immediately + after calling this function — whether it matched or not — so that a + captured callback URL cannot be replayed. - Handles missing padding (some providers strip ``=``). - Returns *fallback* if *state* is absent, the literal string ``"null"``, - or cannot be decoded. + **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": + if not state or state == "null": # "null" guards against JS JSON.stringify(null) return fallback try: - padded = state + "=" * (4 - len(state) % 4) - return base64.urlsafe_b64decode(padded).decode() - except Exception: + 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_nonce.encode() + ): + return fallback + return str(payload["d"]) + except (UnicodeDecodeError, ValueError, binascii.Error, KeyError): return fallback diff --git a/src/fastapi_toolsets/security/sources/bearer.py b/src/fastapi_toolsets/security/sources/bearer.py index 2dcafbf..b33f432 100644 --- a/src/fastapi_toolsets/security/sources/bearer.py +++ b/src/fastapi_toolsets/security/sources/bearer.py @@ -4,7 +4,7 @@ import secrets from typing import Annotated, Any, Callable -from fastapi import Depends +from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes from fastapi_toolsets.exceptions import UnauthorizedError @@ -66,7 +66,7 @@ async def _validate(self, token: str) -> Any: raise UnauthorizedError() return await self._validator(token, **self._kwargs) - async def extract(self, request: Any) -> str | None: + 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, diff --git a/src/fastapi_toolsets/security/sources/cookie.py b/src/fastapi_toolsets/security/sources/cookie.py index 3c269a0..9fd6904 100644 --- a/src/fastapi_toolsets/security/sources/cookie.py +++ b/src/fastapi_toolsets/security/sources/cookie.py @@ -36,6 +36,9 @@ class CookieAuth(AuthSource): 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``). """ @@ -47,12 +50,14 @@ def __init__( *, 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) @@ -120,6 +125,7 @@ def require(self, **kwargs: Any) -> "CookieAuth": self._validator, secret_key=self._secret_key, ttl=self._ttl, + secure=self._secure, **{**self._kwargs, **kwargs}, ) @@ -131,9 +137,12 @@ def set_cookie(self, response: Response, value: str) -> None: 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") + response.delete_cookie( + self._name, httponly=True, samesite="lax", secure=self._secure + ) diff --git a/src/fastapi_toolsets/security/sources/multi.py b/src/fastapi_toolsets/security/sources/multi.py index 4a9160e..5180b11 100644 --- a/src/fastapi_toolsets/security/sources/multi.py +++ b/src/fastapi_toolsets/security/sources/multi.py @@ -14,42 +14,8 @@ class MultiAuth: """Combine multiple authentication sources into a single callable. - Sources are tried in order; the first one whose - :meth:`~AuthSource.extract` returns a non-``None`` credential wins. - Its :meth:`~AuthSource.authenticate` is called and the result returned. - - If a credential is found but the validator raises, the exception propagates - immediately — the remaining sources are **not** tried. This prevents - silent fallthrough on invalid credentials. - - If no source provides a credential, - :class:`~fastapi_toolsets.exceptions.UnauthorizedError` is raised. - - The :meth:`~AuthSource.extract` method of each source performs only - string matching (no I/O), so prefix-based dispatch is essentially free. - - Any :class:`~AuthSource` subclass — including user-defined ones — can be - passed as a source. - Args: *sources: Auth source instances to try in order. - - Example:: - - user_bearer = BearerTokenAuth(verify_user, prefix="user_") - org_bearer = BearerTokenAuth(verify_org, prefix="org_") - cookie = CookieAuth("session", verify_session) - - multi = MultiAuth(user_bearer, org_bearer, cookie) - - @app.get("/data") - async def data_route(user = Security(multi)): - return user - - # Apply a shared requirement to all sources at once - @app.get("/admin") - async def admin_route(user = Security(multi.require(role=Role.ADMIN))): - return user """ def __init__(self, *sources: AuthSource) -> None: @@ -95,21 +61,7 @@ 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. - - Calls ``.require(**kwargs)`` on every source that supports it. Sources - that do not implement ``.require()`` (e.g. custom :class:`~AuthSource` - subclasses) are passed through unchanged. - - New kwargs are merged over each source's existing kwargs — new values - win on conflict:: - - multi = MultiAuth(bearer, cookie) - - @app.get("/admin") - async def admin(user = Security(multi.require(role=Role.ADMIN))): - return user - """ + """Return a new :class:`MultiAuth` with kwargs forwarded to each source.""" new_sources = tuple( cast(Any, source).require(**kwargs) if hasattr(source, "require") diff --git a/tests/test_security.py b/tests/test_security.py index 8502b56..5f33f9f 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -18,6 +18,7 @@ oauth_decode_state, oauth_encode_state, oauth_fetch_userinfo, + oauth_generate_nonce, oauth_resolve_provider_urls, ) @@ -760,7 +761,10 @@ 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 - auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET) + # 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") @@ -778,6 +782,26 @@ async def me(user=Security(auth)): 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 @@ -989,28 +1013,56 @@ def _make_async_client_mock(get_return=None, post_return=None): class TestEncodeDecodeOAuthState: def test_encode_returns_base64url_string(self): - result = oauth_encode_state("https://example.com/dashboard") + result = oauth_encode_state("https://example.com/dashboard", "test-nonce") 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" - assert oauth_decode_state(oauth_encode_state(url), fallback="/") == url + nonce = "test-nonce" + assert ( + oauth_decode_state( + oauth_encode_state(url, nonce), expected_nonce=nonce, fallback="/" + ) + == url + ) def test_decode_none_returns_fallback(self): - assert oauth_decode_state(None, fallback="/home") == "/home" + assert ( + oauth_decode_state(None, expected_nonce="any", fallback="/home") == "/home" + ) def test_decode_null_string_returns_fallback(self): - assert oauth_decode_state("null", fallback="/home") == "/home" + assert ( + oauth_decode_state("null", expected_nonce="any", fallback="/home") + == "/home" + ) def test_decode_invalid_base64_returns_fallback(self): - assert oauth_decode_state("!!!notbase64!!!", fallback="/home") == "/home" + assert ( + oauth_decode_state( + "!!!notbase64!!!", expected_nonce="any", fallback="/home" + ) + == "/home" + ) def test_decode_handles_missing_padding(self): url = "https://example.com/x" - encoded = oauth_encode_state(url).rstrip("=") - assert oauth_decode_state(encoded, fallback="/") == url + nonce = "test-nonce" + encoded = oauth_encode_state(url, nonce).rstrip("=") + assert oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") == url + + def test_decode_wrong_nonce_returns_fallback(self): + url = "https://example.com/dashboard" + encoded = oauth_encode_state(url, "correct-nonce") + assert ( + oauth_decode_state(encoded, expected_nonce="wrong-nonce", fallback="/") + == "/" + ) + + def test_generate_nonce_is_random(self): + assert oauth_generate_nonce() != oauth_generate_nonce() class TestBuildAuthorizationRedirect: @@ -1023,16 +1075,19 @@ def test_returns_redirect_response(self): scopes="openid email", redirect_uri="https://app.example.com/callback", destination="https://app.example.com/dashboard", + nonce="test-nonce", ) assert isinstance(response, RedirectResponse) def test_redirect_location_contains_all_params(self): + nonce = "test-nonce" 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", + nonce=nonce, ) location = response.headers["location"] parsed = urlparse(location) @@ -1046,7 +1101,7 @@ def test_redirect_location_contains_all_params(self): assert params["scope"] == ["openid email"] assert params["redirect_uri"] == ["https://app.example.com/callback"] assert ( - oauth_decode_state(params["state"][0], fallback="") + oauth_decode_state(params["state"][0], expected_nonce=nonce, fallback="") == "https://app.example.com/dashboard" ) @@ -1178,3 +1233,99 @@ async def test_posts_correct_token_request_and_uses_bearer(self): "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" From aeb9e0d9b7aac43771e377e92a4128045e4097eb Mon Sep 17 00:00:00 2001 From: d3vyce Date: Thu, 7 May 2026 18:33:16 -0400 Subject: [PATCH 7/8] feat: use async_lru for caching --- README.md | 2 ++ docs/index.md | 2 ++ pyproject.toml | 7 ++++++- src/fastapi_toolsets/security/oauth.py | 22 ++++++-------------- tests/test_security.py | 28 +++++++++++++------------- uv.lock | 24 ++++++++++++++++++++-- 6 files changed, 52 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0250824..7d61cb6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Install only the extras you need: ```bash uv add "fastapi-toolsets[cli]" uv add "fastapi-toolsets[metrics]" +uv add "fastapi-toolsets[security]" uv add "fastapi-toolsets[pytest]" ``` @@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]" ### Optional +- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers - **CLI**: Django-like command-line interface with fixture management and custom commands support - **Metrics**: Prometheus metrics endpoint with provider/collector registry - **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities diff --git a/docs/index.md b/docs/index.md index ac923e4..3ef1de8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +31,7 @@ Install only the extras you need: ```bash uv add "fastapi-toolsets[cli]" uv add "fastapi-toolsets[metrics]" +uv add "fastapi-toolsets[security]" uv add "fastapi-toolsets[pytest]" ``` @@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]" ### Optional +- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers - **CLI**: Django-like command-line interface with fixture management and custom commands support - **Metrics**: Prometheus metrics endpoint with provider/collector registry - **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities diff --git a/pyproject.toml b/pyproject.toml index 1c30b3a..46653a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,13 +50,17 @@ cli = [ metrics = [ "prometheus_client>=0.20.0", ] +security = [ + "async-lru>=1.0", + "httpx>=0.25.0", +] pytest = [ "httpx>=0.25.0", "pytest-xdist>=3.0.0", "pytest>=8.0.0", ] all = [ - "fastapi-toolsets[cli,metrics,pytest]", + "fastapi-toolsets[cli,metrics,pytest,security]", ] [project.scripts] @@ -73,6 +77,7 @@ dev = [ "ty>=0.0.1a0", ] tests = [ + "async-lru>=1.0", "coverage>=7.0.0", "httpx>=0.25.0", "pytest-anyio>=0.0.0", diff --git a/src/fastapi_toolsets/security/oauth.py b/src/fastapi_toolsets/security/oauth.py index 4c27812..ad1bb18 100644 --- a/src/fastapi_toolsets/security/oauth.py +++ b/src/fastapi_toolsets/security/oauth.py @@ -5,18 +5,15 @@ import hmac import json import secrets -import time as _time from typing import Any from urllib.parse import urlencode import httpx +from async_lru import alru_cache from fastapi.responses import RedirectResponse -_discovery_cache: dict[str, tuple[dict[str, Any], float]] = {} -_DISCOVERY_TTL_SECONDS = 3600 # 1 hour -_DISCOVERY_CACHE_MAX = 32 - +@alru_cache(maxsize=32) async def oauth_resolve_provider_urls( discovery_url: str, ) -> tuple[str, str, str | None]: @@ -29,17 +26,10 @@ async def oauth_resolve_provider_urls( A ``(authorization_url, token_url, userinfo_url)`` tuple. *userinfo_url* is ``None`` when the provider does not advertise one. """ - now = _time.time() - cached = _discovery_cache.get(discovery_url) - if cached is None or now - cached[1] > _DISCOVERY_TTL_SECONDS: - async with httpx.AsyncClient() as client: - resp = await client.get(discovery_url) - resp.raise_for_status() - if len(_discovery_cache) >= _DISCOVERY_CACHE_MAX: - oldest = min(_discovery_cache, key=lambda k: _discovery_cache[k][1]) - del _discovery_cache[oldest] - _discovery_cache[discovery_url] = (resp.json(), now) - cfg = _discovery_cache[discovery_url][0] + async with httpx.AsyncClient() as client: + resp = await client.get(discovery_url) + resp.raise_for_status() + cfg = resp.json() return ( cfg["authorization_endpoint"], cfg["token_endpoint"], diff --git a/tests/test_security.py b/tests/test_security.py index 5f33f9f..2a8d9c2 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1123,11 +1123,11 @@ async def test_returns_all_endpoints(self): mock_resp.json.return_value = self._discovery() cm, mock_client = _make_async_client_mock(get_return=mock_resp) - with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): - with patch("httpx.AsyncClient", return_value=cm): - auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( - "https://auth.example.com/.well-known/openid-configuration" - ) + oauth_resolve_provider_urls.cache_clear() + with patch("httpx.AsyncClient", return_value=cm): + auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( + "https://auth.example.com/.well-known/openid-configuration" + ) assert auth_url == "https://auth.example.com/authorize" assert token_url == "https://auth.example.com/token" @@ -1140,11 +1140,11 @@ async def test_userinfo_url_none_when_absent(self): mock_resp.json.return_value = self._discovery(userinfo=False) cm, mock_client = _make_async_client_mock(get_return=mock_resp) - with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): - with patch("httpx.AsyncClient", return_value=cm): - _, _, userinfo_url = await oauth_resolve_provider_urls( - "https://auth.example.com/.well-known/openid-configuration" - ) + oauth_resolve_provider_urls.cache_clear() + with patch("httpx.AsyncClient", return_value=cm): + _, _, userinfo_url = await oauth_resolve_provider_urls( + "https://auth.example.com/.well-known/openid-configuration" + ) assert userinfo_url is None @@ -1156,10 +1156,10 @@ async def test_caches_discovery_document(self): cm, mock_client = _make_async_client_mock(get_return=mock_resp) url = "https://auth.example.com/.well-known/openid-configuration" - with patch("fastapi_toolsets.security.oauth._discovery_cache", {}): - with patch("httpx.AsyncClient", return_value=cm): - await oauth_resolve_provider_urls(url) - await oauth_resolve_provider_urls(url) + oauth_resolve_provider_urls.cache_clear() + with patch("httpx.AsyncClient", return_value=cm): + await oauth_resolve_provider_urls(url) + await oauth_resolve_provider_urls(url) assert mock_client.get.call_count == 1 diff --git a/uv.lock b/uv.lock index 1a5f98e..0913328 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -332,6 +341,7 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "async-lru" }, { name = "httpx" }, { name = "prometheus-client" }, { name = "pytest" }, @@ -349,9 +359,14 @@ pytest = [ { name = "pytest" }, { name = "pytest-xdist" }, ] +security = [ + { name = "async-lru" }, + { name = "httpx" }, +] [package.dev-dependencies] dev = [ + { name = "async-lru" }, { name = "bcrypt" }, { name = "coverage" }, { name = "fastapi-toolsets", extra = ["all"] }, @@ -376,6 +391,7 @@ docs-src = [ { name = "bcrypt" }, ] tests = [ + { name = "async-lru" }, { name = "coverage" }, { name = "httpx" }, { name = "pytest" }, @@ -386,10 +402,12 @@ tests = [ [package.metadata] requires-dist = [ + { name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "fastapi", specifier = ">=0.100.0" }, - { name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" }, + { name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" }, { name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" }, + { name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" }, { name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" }, @@ -397,10 +415,11 @@ requires-dist = [ { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" }, ] -provides-extras = ["cli", "metrics", "pytest", "all"] +provides-extras = ["cli", "metrics", "security", "pytest", "all"] [package.metadata.requires-dev] dev = [ + { name = "async-lru", specifier = ">=1.0" }, { name = "bcrypt", specifier = ">=4.0.0" }, { name = "coverage", specifier = ">=7.0.0" }, { name = "fastapi-toolsets", extras = ["all"] }, @@ -423,6 +442,7 @@ docs = [ ] docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }] tests = [ + { name = "async-lru", specifier = ">=1.0" }, { name = "coverage", specifier = ">=7.0.0" }, { name = "httpx", specifier = ">=0.25.0" }, { name = "pytest", specifier = ">=8.0.0" }, From 3c2d14b70a9c11d736e175f8faa177947fc64e8f Mon Sep 17 00:00:00 2001 From: d3vyce Date: Thu, 7 May 2026 19:01:59 -0400 Subject: [PATCH 8/8] fix: rename nonce by state_token --- docs/module/security.md | 41 ++++++++----------- docs/reference/security.md | 4 +- src/fastapi_toolsets/security/__init__.py | 4 +- src/fastapi_toolsets/security/oauth.py | 41 +++++++++---------- tests/test_security.py | 48 ++++++++++++++--------- 5 files changed, 68 insertions(+), 70 deletions(-) diff --git a/docs/module/security.md b/docs/module/security.md index 5013537..10ebd40 100644 --- a/docs/module/security.md +++ b/docs/module/security.md @@ -174,15 +174,6 @@ async def profile(user: User = Security(bearer.require(role=Role.USER))): The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for `CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved. -`.require()` instances work transparently inside `MultiAuth`: - -```python -multi = MultiAuth( - user_bearer.require(role=Role.USER), - org_bearer.require(role=Role.ADMIN), -) -``` - ## 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. @@ -285,24 +276,24 @@ Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` i ### 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 `nonce` — a random CSRF token generated by [`oauth_generate_nonce()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_nonce) — 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): +[`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_nonce +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) - nonce = oauth_generate_nonce() - request.session["oauth_nonce"] = nonce # requires SessionMiddleware + 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", - nonce=nonce, + state_token=state_token, ) ``` @@ -310,7 +301,7 @@ async def google_login(request: Request): [`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 nonce 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: +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 @@ -318,11 +309,11 @@ 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 nonce first — single-use, regardless of whether verification succeeds - nonce = request.session.pop("oauth_nonce", None) - if nonce is None: + # 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_nonce=nonce, fallback="/") + destination = oauth_decode_state(state, expected_state_token=state_token, fallback="/") if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect destination = "/" @@ -346,16 +337,16 @@ Pass `required_scopes` to guard against providers silently granting fewer scopes ### 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 nonce embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the nonce does not match: +[`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 -nonce = "my-random-nonce" -encoded = oauth_encode_state("/dashboard", nonce) -decoded = oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") # "/dashboard" -decoded = oauth_decode_state(encoded, expected_nonce="wrong", fallback="/") # "/" -decoded = oauth_decode_state(None, expected_nonce=nonce, fallback="/") # "/" +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="/") # "/" ``` --- diff --git a/docs/reference/security.md b/docs/reference/security.md index e5e1f08..73b37f3 100644 --- a/docs/reference/security.md +++ b/docs/reference/security.md @@ -15,7 +15,7 @@ from fastapi_toolsets.security import ( oauth_decode_state, oauth_encode_state, oauth_fetch_userinfo, - oauth_generate_nonce, + oauth_generate_state_token, oauth_resolve_provider_urls, ) ``` @@ -34,7 +34,7 @@ from fastapi_toolsets.security import ( ## ::: fastapi_toolsets.security.oauth_fetch_userinfo -## ::: fastapi_toolsets.security.oauth_generate_nonce +## ::: fastapi_toolsets.security.oauth_generate_state_token ## ::: fastapi_toolsets.security.oauth_build_authorization_redirect diff --git a/src/fastapi_toolsets/security/__init__.py b/src/fastapi_toolsets/security/__init__.py index b8cd69e..138b94e 100644 --- a/src/fastapi_toolsets/security/__init__.py +++ b/src/fastapi_toolsets/security/__init__.py @@ -6,7 +6,7 @@ oauth_decode_state, oauth_encode_state, oauth_fetch_userinfo, - oauth_generate_nonce, + oauth_generate_state_token, oauth_resolve_provider_urls, ) from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth @@ -21,6 +21,6 @@ "oauth_decode_state", "oauth_encode_state", "oauth_fetch_userinfo", - "oauth_generate_nonce", + "oauth_generate_state_token", "oauth_resolve_provider_urls", ] diff --git a/src/fastapi_toolsets/security/oauth.py b/src/fastapi_toolsets/security/oauth.py index ad1bb18..4625ae1 100644 --- a/src/fastapi_toolsets/security/oauth.py +++ b/src/fastapi_toolsets/security/oauth.py @@ -103,13 +103,8 @@ async def oauth_fetch_userinfo( return userinfo_resp.json() -def oauth_generate_nonce() -> str: - """Generate a cryptographically random nonce for use as an OAuth CSRF token. - - Call this before :func:`oauth_build_authorization_redirect`, persist the - returned value in the user's session or a ``Secure; HttpOnly; SameSite=Lax`` - cookie, then verify it with :func:`oauth_decode_state` on the callback. - """ +def oauth_generate_state_token() -> str: + """Generate a cryptographically random CSRF token for the OAuth ``state`` parameter.""" return secrets.token_urlsafe(32) @@ -120,7 +115,7 @@ def oauth_build_authorization_redirect( scopes: str, redirect_uri: str, destination: str, - nonce: str, + state_token: str, ) -> RedirectResponse: """Return an OAuth 2.0 authorization ``RedirectResponse``. @@ -131,9 +126,9 @@ def oauth_build_authorization_redirect( 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``). - nonce: CSRF token generated by :func:`oauth_generate_nonce`. Must be - stored server-side (session or signed cookie) and verified via - :func:`oauth_decode_state` on the callback endpoint. + 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 @@ -145,32 +140,34 @@ def oauth_build_authorization_redirect( "response_type": "code", "scope": scopes, "redirect_uri": redirect_uri, - "state": oauth_encode_state(destination, nonce), + "state": oauth_encode_state(destination, state_token), } ) return RedirectResponse(f"{authorization_url}?{params}") -def oauth_encode_state(url: str, nonce: str) -> str: - """Encode a destination URL and CSRF nonce into an OAuth ``state`` parameter. +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. - nonce: CSRF token from :func:`oauth_generate_nonce`. + state_token: CSRF token from :func:`oauth_generate_state_token`. """ - payload = json.dumps({"n": nonce, "d": url}, separators=(",", ":")) + payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":")) return base64.urlsafe_b64encode(payload.encode()).decode() -def oauth_decode_state(state: str | None, *, expected_nonce: str, fallback: str) -> str: +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 nonce to prevent timing attacks. + 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_nonce: The nonce stored before the authorization redirect. - If the decoded nonce does not match, ``fallback`` is returned. + 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. @@ -178,7 +175,7 @@ def oauth_decode_state(state: str | None, *, expected_nonce: str, fallback: str) The destination URL embedded in ``state``, or ``fallback``. Important: - **Single-use**: delete the stored nonce from the session immediately + **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. @@ -192,7 +189,7 @@ def oauth_decode_state(state: str | None, *, expected_nonce: str, fallback: str) 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_nonce.encode() + payload.get("n", "").encode(), expected_state_token.encode() ): return fallback return str(payload["d"]) diff --git a/tests/test_security.py b/tests/test_security.py index 2a8d9c2..6887cf4 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -18,7 +18,7 @@ oauth_decode_state, oauth_encode_state, oauth_fetch_userinfo, - oauth_generate_nonce, + oauth_generate_state_token, oauth_resolve_provider_urls, ) @@ -1013,56 +1013,64 @@ def _make_async_client_mock(get_return=None, post_return=None): class TestEncodeDecodeOAuthState: def test_encode_returns_base64url_string(self): - result = oauth_encode_state("https://example.com/dashboard", "test-nonce") + 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" - nonce = "test-nonce" + state_token = "test-state-token" assert ( oauth_decode_state( - oauth_encode_state(url, nonce), expected_nonce=nonce, fallback="/" + 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_nonce="any", fallback="/home") == "/home" + 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_nonce="any", fallback="/home") + 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_nonce="any", fallback="/home" + "!!!notbase64!!!", expected_state_token="any", fallback="/home" ) == "/home" ) def test_decode_handles_missing_padding(self): url = "https://example.com/x" - nonce = "test-nonce" - encoded = oauth_encode_state(url, nonce).rstrip("=") - assert oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") == url + 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_nonce_returns_fallback(self): + def test_decode_wrong_state_token_returns_fallback(self): url = "https://example.com/dashboard" - encoded = oauth_encode_state(url, "correct-nonce") + encoded = oauth_encode_state(url, "correct-token") assert ( - oauth_decode_state(encoded, expected_nonce="wrong-nonce", fallback="/") + oauth_decode_state( + encoded, expected_state_token="wrong-token", fallback="/" + ) == "/" ) - def test_generate_nonce_is_random(self): - assert oauth_generate_nonce() != oauth_generate_nonce() + def test_generate_state_token_is_random(self): + assert oauth_generate_state_token() != oauth_generate_state_token() class TestBuildAuthorizationRedirect: @@ -1075,19 +1083,19 @@ def test_returns_redirect_response(self): scopes="openid email", redirect_uri="https://app.example.com/callback", destination="https://app.example.com/dashboard", - nonce="test-nonce", + state_token="test-state-token", ) assert isinstance(response, RedirectResponse) def test_redirect_location_contains_all_params(self): - nonce = "test-nonce" + 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", - nonce=nonce, + state_token=state_token, ) location = response.headers["location"] parsed = urlparse(location) @@ -1101,7 +1109,9 @@ def test_redirect_location_contains_all_params(self): assert params["scope"] == ["openid email"] assert params["redirect_uri"] == ["https://app.example.com/callback"] assert ( - oauth_decode_state(params["state"][0], expected_nonce=nonce, fallback="") + oauth_decode_state( + params["state"][0], expected_state_token=state_token, fallback="" + ) == "https://app.example.com/dashboard" )