mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-07 17:04:09 +00:00
Compare commits
7
Commits
v4.0.0
..
aeb9e0d9b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb9e0d9b7
|
||
|
|
6bef88fde6
|
||
|
|
95f5a83bd2
|
||
|
|
3099e3f0e1
|
||
|
|
34b733a5e1
|
||
|
|
44dcea5ef2
|
||
|
|
f0ac43a9dc
|
@@ -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-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.
|
[`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.
|
||||||
|
|
||||||
```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"
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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
@@ -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. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits:
|
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
from fastapi_toolsets.db import lock_tables
|
||||||
|
|
||||||
async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"):
|
||||||
# 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_maker, [Tag]) as session:
|
async with lock_tables(session, [Tag]):
|
||||||
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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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-endpoint-both-strategies)).
|
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)).
|
||||||
|
|
||||||
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#faceted-search)). 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#filter-attributes-facets)). 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)
|
||||||
|
|
||||||
|
|||||||
+25
-16
@@ -174,6 +174,15 @@ 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.
|
||||||
@@ -276,24 +285,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 `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)):
|
[`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
|
```python
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_state_token
|
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_nonce
|
||||||
|
|
||||||
@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)
|
||||||
state_token = oauth_generate_state_token()
|
nonce = oauth_generate_nonce()
|
||||||
request.session["oauth_state"] = state_token # requires SessionMiddleware
|
request.session["oauth_nonce"] = nonce # 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",
|
||||||
state_token=state_token,
|
nonce=nonce,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -301,7 +310,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 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:
|
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
|
```python
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
@@ -309,11 +318,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 token first — single-use, regardless of whether verification succeeds
|
# Pop nonce first — single-use, regardless of whether verification succeeds
|
||||||
state_token = request.session.pop("oauth_state", None)
|
nonce = request.session.pop("oauth_nonce", None)
|
||||||
if state_token is None:
|
if nonce 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_state_token=state_token, fallback="/")
|
destination = oauth_decode_state(state, expected_nonce=nonce, 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 = "/"
|
||||||
|
|
||||||
@@ -337,16 +346,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 token embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the token 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 nonce embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the nonce 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
|
||||||
|
|
||||||
state_token = oauth_generate_state_token()
|
nonce = "my-random-nonce"
|
||||||
encoded = oauth_encode_state("/dashboard", state_token)
|
encoded = oauth_encode_state("/dashboard", nonce)
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard"
|
decoded = oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") # "/dashboard"
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/"
|
decoded = oauth_decode_state(encoded, expected_nonce="wrong", fallback="/") # "/"
|
||||||
decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/"
|
decoded = oauth_decode_state(None, expected_nonce=nonce, fallback="/") # "/"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from fastapi_toolsets.exceptions import (
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
InvalidSearchColumnError,
|
|
||||||
InvalidFacetFilterError,
|
InvalidFacetFilterError,
|
||||||
InvalidOrderFieldError,
|
InvalidOrderFieldError,
|
||||||
generate_error_responses,
|
generate_error_responses,
|
||||||
@@ -32,8 +31,6 @@ 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
|
||||||
|
|||||||
@@ -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_state_token,
|
oauth_generate_nonce,
|
||||||
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_state_token
|
## ::: fastapi_toolsets.security.oauth_generate_nonce
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
|
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "4.0.0"
|
version = "3.1.1"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.0.0"
|
__version__ = "3.1.1"
|
||||||
|
|||||||
+18
-23
@@ -151,57 +151,52 @@ class LockMode(str, Enum):
|
|||||||
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
||||||
|
|
||||||
|
|
||||||
def lock_tables(
|
@asynccontextmanager
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
async def lock_tables(
|
||||||
|
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",
|
||||||
) -> AbstractAsyncContextManager[_SessionT]:
|
) -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""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_maker: Async session factory used to create the dedicated
|
session: AsyncSession instance
|
||||||
session.
|
tables: List of SQLAlchemy model classes to lock
|
||||||
tables: List of SQLAlchemy model classes to lock.
|
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE)
|
||||||
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
|
timeout: Lock timeout (default: "5s")
|
||||||
timeout: Lock timeout (default: "5s").
|
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
The dedicated session, open within the locked transaction.
|
The session with locked tables
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
SQLAlchemyError: If the lock cannot be acquired within *timeout*.
|
SQLAlchemyError: If 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_maker, [User, Account]) as session:
|
async with lock_tables(session, [User, Account]):
|
||||||
# Tables are locked; changes are committed when the context exits.
|
# Tables are locked with SHARE UPDATE EXCLUSIVE mode
|
||||||
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_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
||||||
|
# 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)
|
||||||
|
|
||||||
@asynccontextmanager
|
async with get_transaction(session):
|
||||||
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(
|
||||||
|
|||||||
@@ -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_state_token,
|
oauth_generate_nonce,
|
||||||
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_state_token",
|
"oauth_generate_nonce",
|
||||||
"oauth_resolve_provider_urls",
|
"oauth_resolve_provider_urls",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -103,8 +103,13 @@ async def oauth_fetch_userinfo(
|
|||||||
return userinfo_resp.json()
|
return userinfo_resp.json()
|
||||||
|
|
||||||
|
|
||||||
def oauth_generate_state_token() -> str:
|
def oauth_generate_nonce() -> str:
|
||||||
"""Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
|
"""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)
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +120,7 @@ def oauth_build_authorization_redirect(
|
|||||||
scopes: str,
|
scopes: str,
|
||||||
redirect_uri: str,
|
redirect_uri: str,
|
||||||
destination: str,
|
destination: str,
|
||||||
state_token: str,
|
nonce: str,
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
||||||
|
|
||||||
@@ -126,9 +131,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``).
|
||||||
state_token: CSRF token generated by :func:`oauth_generate_state_token`.
|
nonce: CSRF token generated by :func:`oauth_generate_nonce`. Must be
|
||||||
Must be stored server-side (session or signed cookie) and verified via
|
stored server-side (session or signed cookie) and verified via
|
||||||
:func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12).
|
:func:`oauth_decode_state` on the callback endpoint.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
||||||
@@ -140,34 +145,32 @@ 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, state_token),
|
"state": oauth_encode_state(destination, nonce),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return RedirectResponse(f"{authorization_url}?{params}")
|
return RedirectResponse(f"{authorization_url}?{params}")
|
||||||
|
|
||||||
|
|
||||||
def oauth_encode_state(url: str, state_token: str) -> str:
|
def oauth_encode_state(url: str, nonce: str) -> str:
|
||||||
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
|
"""Encode a destination URL and CSRF nonce into an OAuth ``state`` parameter.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Post-login destination URL.
|
url: Post-login destination URL.
|
||||||
state_token: CSRF token from :func:`oauth_generate_state_token`.
|
nonce: CSRF token from :func:`oauth_generate_nonce`.
|
||||||
"""
|
"""
|
||||||
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":"))
|
payload = json.dumps({"n": nonce, "d": url}, separators=(",", ":"))
|
||||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
return base64.urlsafe_b64encode(payload.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
def oauth_decode_state(
|
def oauth_decode_state(state: str | None, *, expected_nonce: str, fallback: str) -> str:
|
||||||
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 CSRF token to prevent timing attacks.
|
Uses a constant-time comparison for the nonce 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_state_token: The token stored before the authorization redirect.
|
expected_nonce: The nonce stored before the authorization redirect.
|
||||||
If it does not match the decoded value, ``fallback`` is returned.
|
If the decoded nonce does not match, ``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.
|
||||||
|
|
||||||
@@ -175,7 +178,7 @@ def oauth_decode_state(
|
|||||||
The destination URL embedded in ``state``, or ``fallback``.
|
The destination URL embedded in ``state``, or ``fallback``.
|
||||||
|
|
||||||
Important:
|
Important:
|
||||||
**Single-use**: delete the stored token from the session immediately
|
**Single-use**: delete the stored nonce 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.
|
||||||
|
|
||||||
@@ -189,7 +192,7 @@ def oauth_decode_state(
|
|||||||
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_state_token.encode()
|
payload.get("n", "").encode(), expected_nonce.encode()
|
||||||
):
|
):
|
||||||
return fallback
|
return fallback
|
||||||
return str(payload["d"])
|
return str(payload["d"])
|
||||||
|
|||||||
@@ -439,21 +439,6 @@ 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.
|
||||||
|
|||||||
+53
-41
@@ -116,8 +116,13 @@ class TestCreateDbDependency:
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_data_inside_lock_is_committed(self):
|
async def test_update_after_lock_tables_is_persisted(self):
|
||||||
"""Changes made inside lock_tables are committed when the context exits."""
|
"""Changes made after lock_tables exits (before endpoint returns) are committed.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -125,12 +130,21 @@ class TestCreateDbDependency:
|
|||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with lock_tables(session_factory, [Role]) as session:
|
get_db = create_db_dependency(session_factory)
|
||||||
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(verify, [Role.name == "lock_committed"])
|
result = await RoleCrud.first(
|
||||||
|
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:
|
||||||
@@ -273,54 +287,53 @@ 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, session_maker):
|
async def test_lock_single_table(self, db_session: AsyncSession):
|
||||||
"""Lock a single table; changes inside are committed on context exit."""
|
"""Lock a single table."""
|
||||||
async with lock_tables(session_maker, [Role]) as session:
|
async with lock_tables(db_session, [Role]):
|
||||||
|
# Inside the lock, we can still perform operations
|
||||||
role = Role(name="locked_role")
|
role = Role(name="locked_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
async with session_maker() as verify:
|
# After lock is released, verify the data was committed
|
||||||
result = await RoleCrud.first(verify, [Role.name == "locked_role"])
|
result = await RoleCrud.first(db_session, [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, session_maker):
|
async def test_lock_multiple_tables(self, db_session: AsyncSession):
|
||||||
"""Lock multiple tables."""
|
"""Lock multiple tables."""
|
||||||
async with lock_tables(session_maker, [Role, User]) as session:
|
async with lock_tables(db_session, [Role, User]):
|
||||||
role = Role(name="multi_lock_role")
|
role = Role(name="multi_lock_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
async with session_maker() as verify:
|
result = await RoleCrud.first(db_session, [Role.name == "multi_lock_role"])
|
||||||
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, session_maker):
|
async def test_lock_with_custom_mode(self, db_session: AsyncSession):
|
||||||
"""Lock with custom lock mode."""
|
"""Lock with custom lock mode."""
|
||||||
async with lock_tables(
|
async with lock_tables(db_session, [Role], mode=LockMode.EXCLUSIVE):
|
||||||
session_maker, [Role], mode=LockMode.EXCLUSIVE
|
|
||||||
) as session:
|
|
||||||
role = Role(name="exclusive_lock_role")
|
role = Role(name="exclusive_lock_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
async with session_maker() as verify:
|
result = await RoleCrud.first(db_session, [Role.name == "exclusive_lock_role"])
|
||||||
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, session_maker):
|
async def test_lock_rollback_on_exception(self, db_session: AsyncSession):
|
||||||
"""Lock context rolls back on exception."""
|
"""Lock context rolls back on exception."""
|
||||||
try:
|
try:
|
||||||
async with lock_tables(session_maker, [Role]) as session:
|
async with lock_tables(db_session, [Role]):
|
||||||
role = Role(name="lock_rollback_role")
|
role = Role(name="lock_rollback_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
await session.flush()
|
await db_session.flush()
|
||||||
raise ValueError("Simulated error")
|
raise ValueError("Simulated error")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async with session_maker() as verify:
|
result = await RoleCrud.first(db_session, [Role.name == "lock_rollback_role"])
|
||||||
result = await RoleCrud.first(verify, [Role.name == "lock_rollback_role"])
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@@ -630,25 +643,24 @@ 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, session_maker):
|
async def test_works_inside_lock_tables(self, db_session: AsyncSession):
|
||||||
"""m2m_add works correctly inside a lock_tables context."""
|
"""m2m_add works correctly inside a lock_tables nested transaction."""
|
||||||
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")
|
||||||
session.add(user)
|
db_session.add(user)
|
||||||
await session.flush()
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with lock_tables(db_session, [Tag]):
|
||||||
tag = Tag(name="locked_tag")
|
tag = Tag(name="locked_tag")
|
||||||
session.add(tag)
|
db_session.add(tag)
|
||||||
await session.flush()
|
await db_session.flush()
|
||||||
|
|
||||||
post = Post(title="Post Lock", author_id=user.id)
|
post = Post(title="Post Lock", author_id=user.id)
|
||||||
session.add(post)
|
db_session.add(post)
|
||||||
await session.flush()
|
await db_session.flush()
|
||||||
|
|
||||||
await m2m_add(session, post, Post.tags, tag)
|
await m2m_add(db_session, post, Post.tags, tag)
|
||||||
|
|
||||||
async with session_maker() as verify:
|
result = await db_session.execute(
|
||||||
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()
|
||||||
|
|||||||
+6
-23
@@ -7,7 +7,6 @@ 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
|
||||||
@@ -21,7 +20,6 @@ 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,
|
||||||
@@ -340,23 +338,6 @@ 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):
|
||||||
@@ -1578,13 +1559,15 @@ 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_maker):
|
async def test_lock_tables_with_events(self, mixin_session):
|
||||||
"""Events fire correctly when lock_tables commits on context exit."""
|
"""Events fire correctly after lock_tables context."""
|
||||||
from fastapi_toolsets.db import lock_tables
|
from fastapi_toolsets.db import lock_tables
|
||||||
|
|
||||||
async with lock_tables(mixin_session_maker, [WatchedModel]) as session:
|
async with lock_tables(mixin_session, [WatchedModel]):
|
||||||
obj = WatchedModel(status="locked", other="x")
|
obj = WatchedModel(status="locked", other="x")
|
||||||
session.add(obj)
|
mixin_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
|
||||||
|
|||||||
+19
-29
@@ -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_state_token,
|
oauth_generate_nonce,
|
||||||
oauth_resolve_provider_urls,
|
oauth_resolve_provider_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1013,64 +1013,56 @@ 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-state-token")
|
result = oauth_encode_state("https://example.com/dashboard", "test-nonce")
|
||||||
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"
|
||||||
state_token = "test-state-token"
|
nonce = "test-nonce"
|
||||||
assert (
|
assert (
|
||||||
oauth_decode_state(
|
oauth_decode_state(
|
||||||
oauth_encode_state(url, state_token),
|
oauth_encode_state(url, nonce), expected_nonce=nonce, fallback="/"
|
||||||
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_state_token="any", fallback="/home")
|
oauth_decode_state(None, expected_nonce="any", fallback="/home") == "/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_state_token="any", fallback="/home")
|
oauth_decode_state("null", expected_nonce="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_state_token="any", fallback="/home"
|
"!!!notbase64!!!", expected_nonce="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"
|
||||||
state_token = "test-state-token"
|
nonce = "test-nonce"
|
||||||
encoded = oauth_encode_state(url, state_token).rstrip("=")
|
encoded = oauth_encode_state(url, nonce).rstrip("=")
|
||||||
assert (
|
assert oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") == url
|
||||||
oauth_decode_state(encoded, expected_state_token=state_token, fallback="/")
|
|
||||||
== url
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_wrong_state_token_returns_fallback(self):
|
def test_decode_wrong_nonce_returns_fallback(self):
|
||||||
url = "https://example.com/dashboard"
|
url = "https://example.com/dashboard"
|
||||||
encoded = oauth_encode_state(url, "correct-token")
|
encoded = oauth_encode_state(url, "correct-nonce")
|
||||||
assert (
|
assert (
|
||||||
oauth_decode_state(
|
oauth_decode_state(encoded, expected_nonce="wrong-nonce", fallback="/")
|
||||||
encoded, expected_state_token="wrong-token", fallback="/"
|
|
||||||
)
|
|
||||||
== "/"
|
== "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_generate_state_token_is_random(self):
|
def test_generate_nonce_is_random(self):
|
||||||
assert oauth_generate_state_token() != oauth_generate_state_token()
|
assert oauth_generate_nonce() != oauth_generate_nonce()
|
||||||
|
|
||||||
|
|
||||||
class TestBuildAuthorizationRedirect:
|
class TestBuildAuthorizationRedirect:
|
||||||
@@ -1083,19 +1075,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",
|
||||||
state_token="test-state-token",
|
nonce="test-nonce",
|
||||||
)
|
)
|
||||||
assert isinstance(response, RedirectResponse)
|
assert isinstance(response, RedirectResponse)
|
||||||
|
|
||||||
def test_redirect_location_contains_all_params(self):
|
def test_redirect_location_contains_all_params(self):
|
||||||
state_token = "test-state-token"
|
nonce = "test-nonce"
|
||||||
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",
|
||||||
state_token=state_token,
|
nonce=nonce,
|
||||||
)
|
)
|
||||||
location = response.headers["location"]
|
location = response.headers["location"]
|
||||||
parsed = urlparse(location)
|
parsed = urlparse(location)
|
||||||
@@ -1109,9 +1101,7 @@ 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(
|
oauth_decode_state(params["state"][0], expected_nonce=nonce, fallback="")
|
||||||
params["state"][0], expected_state_token=state_token, fallback=""
|
|
||||||
)
|
|
||||||
== "https://app.example.com/dashboard"
|
== "https://app.example.com/dashboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "4.0.0"
|
version = "3.1.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ 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"},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user