Compare commits

..
2 Commits
Author SHA1 Message Date
d3vyceandGitHub 189e9e1a13 Version 4.0.0 (#263)
* fix: lock_tables acquires dedicated session to enforce RAII lock boundaries (#261)

* fix: make lock_tables generic over session type (#270)

* docs: fix zensical warnings

* Version 4.0.0

* docs: add v4 migration + fix lock_tables documentation
2026-05-08 13:04:24 +02:00
d3vyceandGitHub 99eabe3b23 feat: add security module (#111)
* feat: add security module

* feat(security): add oauth helpers

* docs: add authentication example

* fix: cleanup + simplify

* docs: update module and reference

* fix: multiple security bugs + remove example for now

* feat: use async_lru for caching

* fix: rename nonce by state_token
2026-05-08 12:50:06 +02:00
18 changed files with 240 additions and 173 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ Pass `next_cursor` as the `cursor` query parameter on the next request to advanc
!!! info "Added in `v2.3.0`" !!! info "Added in `v2.3.0`"
[`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling. [`paginate()`](../module/crud.md#unified-endpoint-both-strategies) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
```python title="routes.py:61:79" ```python title="routes.py:61:79"
--8<-- "docs_src/examples/pagination_search/routes.py:61:79" --8<-- "docs_src/examples/pagination_search/routes.py:61:79"
+40
View File
@@ -0,0 +1,40 @@
# Migrating to v4.0
This page covers every breaking change introduced in **v4.0** and the steps required to update your code.
---
## Database
### `lock_tables` now takes a `session_maker` instead of a `session`
The first argument of `lock_tables` changed from an `AsyncSession` instance to an `async_sessionmaker`.
The function creates and manages its own **dedicated session** internally, yielding it to the caller.
=== "Before (`v3`)"
```python
from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session=session, tables=[User, Account]):
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
# With a custom lock mode
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
await process_order(session, order_id)
```
=== "Now (`v4`)"
```python
from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session_maker=session_maker, tables=[User, Account]) as session:
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
# With a custom lock mode
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
await process_order(session, order_id)
```
+4 -4
View File
@@ -57,12 +57,12 @@ async def create_user_with_role(session=session):
## Table locking ## Table locking
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections: [`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits:
```python ```python
from fastapi_toolsets.db import lock_tables from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"): async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session:
# No other transaction can modify User until this block exits # No other transaction can modify User until this block exits
... ...
``` ```
@@ -129,7 +129,7 @@ SQLAlchemy's ORM collection API triggers lazy-loads when you append to a relatio
```python ```python
from fastapi_toolsets.db import lock_tables, m2m_add from fastapi_toolsets.db import lock_tables, m2m_add
async with lock_tables(session, [Tag]): async with lock_tables(session_maker, [Tag]) as session:
tag = await TagCrud.create(session, TagCreate(name="python")) tag = await TagCrud.create(session, TagCreate(name="python"))
await m2m_add(session, post, Post.tags, tag) await m2m_add(session, post, Post.tags, tag)
``` ```
+2 -2
View File
@@ -102,7 +102,7 @@ async def list_events(
#### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) #### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)). Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-endpoint-both-strategies)).
When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate: When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate:
@@ -129,7 +129,7 @@ async def list_users(
#### Pagination metadata models #### Pagination metadata models
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`. The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#faceted-search)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse) ### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
+16 -25
View File
@@ -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 The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for
`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved. `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
[`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.
@@ -285,24 +276,24 @@ Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` i
### Authorization redirect ### 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 ```python
from fastapi import Request 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") @app.get("/auth/google/login")
async def google_login(request: Request): async def google_login(request: Request):
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL) auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
nonce = oauth_generate_nonce() state_token = oauth_generate_state_token()
request.session["oauth_nonce"] = nonce # requires SessionMiddleware request.session["oauth_state"] = state_token # requires SessionMiddleware
return oauth_build_authorization_redirect( return oauth_build_authorization_redirect(
auth_url, auth_url,
client_id=GOOGLE_CLIENT_ID, client_id=GOOGLE_CLIENT_ID,
scopes="openid email profile", scopes="openid email profile",
redirect_uri="https://myapp.com/auth/google/callback", redirect_uri="https://myapp.com/auth/google/callback",
destination="/dashboard", 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. [`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 ```python
from fastapi import HTTPException, Request 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") @app.get("/auth/google/callback")
async def google_callback(request: Request, code: str, state: str): async def google_callback(request: Request, code: str, state: str):
# Pop nonce first — single-use, regardless of whether verification succeeds # Pop token first — single-use, regardless of whether verification succeeds
nonce = request.session.pop("oauth_nonce", None) state_token = request.session.pop("oauth_state", None)
if nonce is None: if state_token is None:
raise HTTPException(status_code=400, detail="missing OAuth state") 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 if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect
destination = "/" destination = "/"
@@ -346,16 +337,16 @@ Pass `required_scopes` to guard against providers silently granting fewer scopes
### State encoding ### 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 ```python
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
nonce = "my-random-nonce" state_token = oauth_generate_state_token()
encoded = oauth_encode_state("/dashboard", nonce) encoded = oauth_encode_state("/dashboard", state_token)
decoded = oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") # "/dashboard" decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard"
decoded = oauth_decode_state(encoded, expected_nonce="wrong", fallback="/") # "/" decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/"
decoded = oauth_decode_state(None, expected_nonce=nonce, fallback="/") # "/" decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/"
``` ```
--- ---
+3
View File
@@ -12,6 +12,7 @@ from fastapi_toolsets.exceptions import (
NotFoundError, NotFoundError,
ConflictError, ConflictError,
NoSearchableFieldsError, NoSearchableFieldsError,
InvalidSearchColumnError,
InvalidFacetFilterError, InvalidFacetFilterError,
InvalidOrderFieldError, InvalidOrderFieldError,
generate_error_responses, generate_error_responses,
@@ -31,6 +32,8 @@ from fastapi_toolsets.exceptions import (
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError ## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
## ::: fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError
## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError ## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError
## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError ## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError
+2 -2
View File
@@ -15,7 +15,7 @@ from fastapi_toolsets.security import (
oauth_decode_state, oauth_decode_state,
oauth_encode_state, oauth_encode_state,
oauth_fetch_userinfo, oauth_fetch_userinfo,
oauth_generate_nonce, oauth_generate_state_token,
oauth_resolve_provider_urls, oauth_resolve_provider_urls,
) )
``` ```
@@ -34,7 +34,7 @@ from fastapi_toolsets.security import (
## ::: fastapi_toolsets.security.oauth_fetch_userinfo ## ::: 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 ## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "3.1.1" version = "4.0.0"
description = "Production-ready utilities for FastAPI applications" description = "Production-ready utilities for FastAPI applications"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -21,4 +21,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success") return Response(data={"user": user.username}, message="Success")
""" """
__version__ = "3.1.1" __version__ = "4.0.0"
+23 -18
View File
@@ -151,52 +151,57 @@ class LockMode(str, Enum):
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE" ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
@asynccontextmanager def lock_tables(
async def lock_tables( session_maker: async_sessionmaker[_SessionT],
session: AsyncSession,
tables: list[type[DeclarativeBase]], tables: list[type[DeclarativeBase]],
*, *,
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE, mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
timeout: str = "5s", timeout: str = "5s",
) -> AsyncGenerator[AsyncSession, None]: ) -> AbstractAsyncContextManager[_SessionT]:
"""Lock PostgreSQL tables for the duration of a transaction. """Lock PostgreSQL tables for the duration of a transaction.
Acquires table-level locks that are held until the transaction ends.
Useful for preventing concurrent modifications during critical operations.
Args: Args:
session: AsyncSession instance session_maker: Async session factory used to create the dedicated
tables: List of SQLAlchemy model classes to lock session.
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE) tables: List of SQLAlchemy model classes to lock.
timeout: Lock timeout (default: "5s") mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
timeout: Lock timeout (default: "5s").
Yields: Yields:
The session with locked tables The dedicated session, open within the locked transaction.
Raises: Raises:
SQLAlchemyError: If lock cannot be acquired within timeout SQLAlchemyError: If the lock cannot be acquired within *timeout*.
Example: Example:
```python ```python
from fastapi_toolsets.db import lock_tables, LockMode from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session, [User, Account]): async with lock_tables(session_maker, [User, Account]) as session:
# Tables are locked with SHARE UPDATE EXCLUSIVE mode # Tables are locked; changes are committed when the context exits.
user = await UserCrud.get(session, [User.id == 1]) user = await UserCrud.get(session, [User.id == 1])
user.balance += 100 user.balance += 100
# With custom lock mode # With custom lock mode
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE): async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
# Exclusive lock - no other transactions can access
await process_order(session, order_id) await process_order(session, order_id)
``` ```
""" """
table_names = ",".join(table.__tablename__ for table in tables) table_names = ",".join(table.__tablename__ for table in tables)
async with get_transaction(session): @asynccontextmanager
async def _lock() -> AsyncGenerator[_SessionT, None]:
async with session_maker() as session:
try:
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'")) await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE")) await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
yield session yield session
await session.commit()
except BaseException:
await session.rollback()
raise
return _lock()
async def create_database( async def create_database(
+2 -2
View File
@@ -6,7 +6,7 @@ from .oauth import (
oauth_decode_state, oauth_decode_state,
oauth_encode_state, oauth_encode_state,
oauth_fetch_userinfo, oauth_fetch_userinfo,
oauth_generate_nonce, oauth_generate_state_token,
oauth_resolve_provider_urls, oauth_resolve_provider_urls,
) )
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
@@ -21,6 +21,6 @@ __all__ = [
"oauth_decode_state", "oauth_decode_state",
"oauth_encode_state", "oauth_encode_state",
"oauth_fetch_userinfo", "oauth_fetch_userinfo",
"oauth_generate_nonce", "oauth_generate_state_token",
"oauth_resolve_provider_urls", "oauth_resolve_provider_urls",
] ]
+19 -22
View File
@@ -103,13 +103,8 @@ async def oauth_fetch_userinfo(
return userinfo_resp.json() return userinfo_resp.json()
def oauth_generate_nonce() -> str: def oauth_generate_state_token() -> str:
"""Generate a cryptographically random nonce for use as an OAuth CSRF token. """Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
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) return secrets.token_urlsafe(32)
@@ -120,7 +115,7 @@ def oauth_build_authorization_redirect(
scopes: str, scopes: str,
redirect_uri: str, redirect_uri: str,
destination: str, destination: str,
nonce: str, state_token: str,
) -> RedirectResponse: ) -> RedirectResponse:
"""Return an OAuth 2.0 authorization ``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. redirect_uri: URI the provider should redirect back to after authorization.
destination: URL the user should be sent to after the full OAuth flow destination: URL the user should be sent to after the full OAuth flow
completes (embedded in ``state``). completes (embedded in ``state``).
nonce: CSRF token generated by :func:`oauth_generate_nonce`. Must be state_token: CSRF token generated by :func:`oauth_generate_state_token`.
stored server-side (session or signed cookie) and verified via Must be stored server-side (session or signed cookie) and verified via
:func:`oauth_decode_state` on the callback endpoint. :func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12).
Returns: Returns:
A :class:`~fastapi.responses.RedirectResponse` to the provider's A :class:`~fastapi.responses.RedirectResponse` to the provider's
@@ -145,32 +140,34 @@ def oauth_build_authorization_redirect(
"response_type": "code", "response_type": "code",
"scope": scopes, "scope": scopes,
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
"state": oauth_encode_state(destination, nonce), "state": oauth_encode_state(destination, state_token),
} }
) )
return RedirectResponse(f"{authorization_url}?{params}") return RedirectResponse(f"{authorization_url}?{params}")
def oauth_encode_state(url: str, nonce: str) -> str: def oauth_encode_state(url: str, state_token: str) -> str:
"""Encode a destination URL and CSRF nonce into an OAuth ``state`` parameter. """Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
Args: Args:
url: Post-login destination URL. 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() 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. """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: Args:
state: Raw ``state`` query parameter from the provider's callback. state: Raw ``state`` query parameter from the provider's callback.
expected_nonce: The nonce stored before the authorization redirect. expected_state_token: The token stored before the authorization redirect.
If the decoded nonce does not match, ``fallback`` is returned. If it does not match the decoded value, ``fallback`` is returned.
fallback: URL to return when ``state`` is absent, malformed, or fails fallback: URL to return when ``state`` is absent, malformed, or fails
CSRF verification. 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``. The destination URL embedded in ``state``, or ``fallback``.
Important: 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 after calling this function — whether it matched or not — so that a
captured callback URL cannot be replayed. 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) padded = state + "=" * (-len(state) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
if not isinstance(payload, dict) or not hmac.compare_digest( 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 fallback
return str(payload["d"]) return str(payload["d"])
+15
View File
@@ -439,6 +439,21 @@ async def engine():
await engine.dispose() await engine.dispose()
@pytest.fixture(scope="function")
async def session_maker(engine):
"""Provide a session factory with tables created and dropped around the test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
yield factory
finally:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
async def db_session(engine): async def db_session(engine):
"""Create a test database session with tables. """Create a test database session with tables.
+41 -53
View File
@@ -116,13 +116,8 @@ class TestCreateDbDependency:
await engine.dispose() await engine.dispose()
@pytest.mark.anyio @pytest.mark.anyio
async def test_update_after_lock_tables_is_persisted(self): async def test_data_inside_lock_is_committed(self):
"""Changes made after lock_tables exits (before endpoint returns) are committed. """Changes made inside lock_tables are committed when the context exits."""
Regression: without the auto-begin fix, lock_tables would start and commit a
real outer transaction, leaving the session idle. Any modifications after that
point were silently dropped.
"""
engine = create_async_engine(DATABASE_URL, echo=False) engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False) session_factory = async_sessionmaker(engine, expire_on_commit=False)
@@ -130,21 +125,12 @@ class TestCreateDbDependency:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
try: try:
get_db = create_db_dependency(session_factory) async with lock_tables(session_factory, [Role]) as session:
role = Role(name="lock_committed")
async for session in get_db():
async with lock_tables(session, [Role]):
role = Role(name="lock_then_update")
session.add(role) session.add(role)
await session.flush()
# lock_tables has exited — outer transaction must still be open
assert session.in_transaction()
role.name = "updated_after_lock"
async with session_factory() as verify: async with session_factory() as verify:
result = await RoleCrud.first( result = await RoleCrud.first(verify, [Role.name == "lock_committed"])
verify, [Role.name == "updated_after_lock"]
)
assert result is not None assert result is not None
finally: finally:
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -287,53 +273,54 @@ class TestLockTables:
"""Tests for lock_tables context manager (PostgreSQL-specific).""" """Tests for lock_tables context manager (PostgreSQL-specific)."""
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_single_table(self, db_session: AsyncSession): async def test_lock_single_table(self, session_maker):
"""Lock a single table.""" """Lock a single table; changes inside are committed on context exit."""
async with lock_tables(db_session, [Role]): async with lock_tables(session_maker, [Role]) as session:
# Inside the lock, we can still perform operations
role = Role(name="locked_role") role = Role(name="locked_role")
db_session.add(role) session.add(role)
await db_session.flush()
# After lock is released, verify the data was committed async with session_maker() as verify:
result = await RoleCrud.first(db_session, [Role.name == "locked_role"]) result = await RoleCrud.first(verify, [Role.name == "locked_role"])
assert result is not None assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_multiple_tables(self, db_session: AsyncSession): async def test_lock_multiple_tables(self, session_maker):
"""Lock multiple tables.""" """Lock multiple tables."""
async with lock_tables(db_session, [Role, User]): async with lock_tables(session_maker, [Role, User]) as session:
role = Role(name="multi_lock_role") role = Role(name="multi_lock_role")
db_session.add(role) session.add(role)
await db_session.flush()
result = await RoleCrud.first(db_session, [Role.name == "multi_lock_role"]) async with session_maker() as verify:
result = await RoleCrud.first(verify, [Role.name == "multi_lock_role"])
assert result is not None assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_with_custom_mode(self, db_session: AsyncSession): async def test_lock_with_custom_mode(self, session_maker):
"""Lock with custom lock mode.""" """Lock with custom lock mode."""
async with lock_tables(db_session, [Role], mode=LockMode.EXCLUSIVE): async with lock_tables(
session_maker, [Role], mode=LockMode.EXCLUSIVE
) as session:
role = Role(name="exclusive_lock_role") role = Role(name="exclusive_lock_role")
db_session.add(role) session.add(role)
await db_session.flush()
result = await RoleCrud.first(db_session, [Role.name == "exclusive_lock_role"]) async with session_maker() as verify:
result = await RoleCrud.first(verify, [Role.name == "exclusive_lock_role"])
assert result is not None assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_rollback_on_exception(self, db_session: AsyncSession): async def test_lock_rollback_on_exception(self, session_maker):
"""Lock context rolls back on exception.""" """Lock context rolls back on exception."""
try: try:
async with lock_tables(db_session, [Role]): async with lock_tables(session_maker, [Role]) as session:
role = Role(name="lock_rollback_role") role = Role(name="lock_rollback_role")
db_session.add(role) session.add(role)
await db_session.flush() await session.flush()
raise ValueError("Simulated error") raise ValueError("Simulated error")
except ValueError: except ValueError:
pass pass
result = await RoleCrud.first(db_session, [Role.name == "lock_rollback_role"]) async with session_maker() as verify:
result = await RoleCrud.first(verify, [Role.name == "lock_rollback_role"])
assert result is None assert result is None
@@ -643,24 +630,25 @@ class TestM2MAdd:
await m2m_add(db_session, user, User.role, role) await m2m_add(db_session, user, User.role, role)
@pytest.mark.anyio @pytest.mark.anyio
async def test_works_inside_lock_tables(self, db_session: AsyncSession): async def test_works_inside_lock_tables(self, session_maker):
"""m2m_add works correctly inside a lock_tables nested transaction.""" """m2m_add works correctly inside a lock_tables context."""
async with lock_tables(session_maker, [Tag]) as session:
user = User(username="m2m_lock_author", email="m2m_lock@test.com") user = User(username="m2m_lock_author", email="m2m_lock@test.com")
db_session.add(user) session.add(user)
await db_session.flush() await session.flush()
async with lock_tables(db_session, [Tag]):
tag = Tag(name="locked_tag") tag = Tag(name="locked_tag")
db_session.add(tag) session.add(tag)
await db_session.flush() await session.flush()
post = Post(title="Post Lock", author_id=user.id) post = Post(title="Post Lock", author_id=user.id)
db_session.add(post) session.add(post)
await db_session.flush() await session.flush()
await m2m_add(db_session, post, Post.tags, tag) await m2m_add(session, post, Post.tags, tag)
result = await db_session.execute( async with session_maker() as verify:
result = await verify.execute(
select(Post).where(Post.id == post.id).options(selectinload(Post.tags)) select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
) )
loaded = result.scalar_one() loaded = result.scalar_one()
+23 -6
View File
@@ -7,6 +7,7 @@ from unittest.mock import patch
import pytest import pytest
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
import fastapi_toolsets.models.watched as _watched_module import fastapi_toolsets.models.watched as _watched_module
@@ -20,6 +21,7 @@ from fastapi_toolsets.models import (
listens_for, listens_for,
) )
from fastapi_toolsets.models.watched import ( from fastapi_toolsets.models.watched import (
EventSession,
_EVENT_HANDLERS, _EVENT_HANDLERS,
_SESSION_CREATES, _SESSION_CREATES,
_SESSION_DELETES, _SESSION_DELETES,
@@ -338,6 +340,23 @@ async def mixin_session_expire():
yield session yield session
@pytest.fixture(scope="function")
async def mixin_session_maker():
"""Provide an EventSession-backed session factory with MixinBase tables."""
engine = create_async_engine(DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
try:
yield factory
finally:
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.drop_all)
await engine.dispose()
class TestUUIDMixin: class TestUUIDMixin:
@pytest.mark.anyio @pytest.mark.anyio
async def test_uuid_generated_by_db(self, mixin_session): async def test_uuid_generated_by_db(self, mixin_session):
@@ -1559,15 +1578,13 @@ class TestEventSessionWithGetTransaction:
assert creates[0]["obj_id"] == survivor.id assert creates[0]["obj_id"] == survivor.id
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_tables_with_events(self, mixin_session): async def test_lock_tables_with_events(self, mixin_session_maker):
"""Events fire correctly after lock_tables context.""" """Events fire correctly when lock_tables commits on context exit."""
from fastapi_toolsets.db import lock_tables from fastapi_toolsets.db import lock_tables
async with lock_tables(mixin_session, [WatchedModel]): async with lock_tables(mixin_session_maker, [WatchedModel]) as session:
obj = WatchedModel(status="locked", other="x") obj = WatchedModel(status="locked", other="x")
mixin_session.add(obj) session.add(obj)
await mixin_session.commit()
creates = [e for e in _test_events if e["event"] == "create"] creates = [e for e in _test_events if e["event"] == "create"]
assert len(creates) == 1 assert len(creates) == 1
+31 -21
View File
@@ -18,7 +18,7 @@ from fastapi_toolsets.security import (
oauth_decode_state, oauth_decode_state,
oauth_encode_state, oauth_encode_state,
oauth_fetch_userinfo, oauth_fetch_userinfo,
oauth_generate_nonce, oauth_generate_state_token,
oauth_resolve_provider_urls, oauth_resolve_provider_urls,
) )
@@ -1013,56 +1013,64 @@ def _make_async_client_mock(get_return=None, post_return=None):
class TestEncodeDecodeOAuthState: class TestEncodeDecodeOAuthState:
def test_encode_returns_base64url_string(self): 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 isinstance(result, str)
assert "+" not in result assert "+" not in result
assert "/" not in result assert "/" not in result
def test_round_trip(self): def test_round_trip(self):
url = "https://example.com/after-login?next=/home" url = "https://example.com/after-login?next=/home"
nonce = "test-nonce" state_token = "test-state-token"
assert ( assert (
oauth_decode_state( 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 == url
) )
def test_decode_none_returns_fallback(self): def test_decode_none_returns_fallback(self):
assert ( 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): def test_decode_null_string_returns_fallback(self):
assert ( assert (
oauth_decode_state("null", expected_nonce="any", fallback="/home") oauth_decode_state("null", expected_state_token="any", fallback="/home")
== "/home" == "/home"
) )
def test_decode_invalid_base64_returns_fallback(self): def test_decode_invalid_base64_returns_fallback(self):
assert ( assert (
oauth_decode_state( oauth_decode_state(
"!!!notbase64!!!", expected_nonce="any", fallback="/home" "!!!notbase64!!!", expected_state_token="any", fallback="/home"
) )
== "/home" == "/home"
) )
def test_decode_handles_missing_padding(self): def test_decode_handles_missing_padding(self):
url = "https://example.com/x" url = "https://example.com/x"
nonce = "test-nonce" state_token = "test-state-token"
encoded = oauth_encode_state(url, nonce).rstrip("=") encoded = oauth_encode_state(url, state_token).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 ( assert (
oauth_decode_state(encoded, expected_nonce="wrong-nonce", fallback="/") oauth_decode_state(encoded, expected_state_token=state_token, fallback="/")
== url
)
def test_decode_wrong_state_token_returns_fallback(self):
url = "https://example.com/dashboard"
encoded = oauth_encode_state(url, "correct-token")
assert (
oauth_decode_state(
encoded, expected_state_token="wrong-token", fallback="/"
)
== "/" == "/"
) )
def test_generate_nonce_is_random(self): def test_generate_state_token_is_random(self):
assert oauth_generate_nonce() != oauth_generate_nonce() assert oauth_generate_state_token() != oauth_generate_state_token()
class TestBuildAuthorizationRedirect: class TestBuildAuthorizationRedirect:
@@ -1075,19 +1083,19 @@ class TestBuildAuthorizationRedirect:
scopes="openid email", scopes="openid email",
redirect_uri="https://app.example.com/callback", redirect_uri="https://app.example.com/callback",
destination="https://app.example.com/dashboard", destination="https://app.example.com/dashboard",
nonce="test-nonce", state_token="test-state-token",
) )
assert isinstance(response, RedirectResponse) assert isinstance(response, RedirectResponse)
def test_redirect_location_contains_all_params(self): def test_redirect_location_contains_all_params(self):
nonce = "test-nonce" state_token = "test-state-token"
response = oauth_build_authorization_redirect( response = oauth_build_authorization_redirect(
"https://auth.example.com/authorize", "https://auth.example.com/authorize",
client_id="my-client", client_id="my-client",
scopes="openid email", scopes="openid email",
redirect_uri="https://app.example.com/callback", redirect_uri="https://app.example.com/callback",
destination="https://app.example.com/dashboard", destination="https://app.example.com/dashboard",
nonce=nonce, state_token=state_token,
) )
location = response.headers["location"] location = response.headers["location"]
parsed = urlparse(location) parsed = urlparse(location)
@@ -1101,7 +1109,9 @@ class TestBuildAuthorizationRedirect:
assert params["scope"] == ["openid email"] assert params["scope"] == ["openid email"]
assert params["redirect_uri"] == ["https://app.example.com/callback"] assert params["redirect_uri"] == ["https://app.example.com/callback"]
assert ( 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" == "https://app.example.com/dashboard"
) )
Generated
+1 -1
View File
@@ -330,7 +330,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "3.1.1" version = "4.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
+1
View File
@@ -147,6 +147,7 @@ Examples = [
[[project.nav]] [[project.nav]]
Migration = [ Migration = [
{"v4.0" = "migration/v4.md"},
{"v3.0" = "migration/v3.md"}, {"v3.0" = "migration/v3.md"},
{"v2.0" = "migration/v2.md"}, {"v2.0" = "migration/v2.md"},
] ]