mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-06 16:44:08 +00:00
Compare commits
5
Commits
v4.0.0
..
805ef04054
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805ef04054
|
||
|
|
a475af6262
|
||
|
|
f7fac10ea9
|
||
|
|
8e62a2d658
|
||
|
|
771b6a973e
|
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### 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
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Authentication
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### 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
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+84
-60
@@ -47,9 +47,12 @@ async def me(user: User = Security(bearer)):
|
|||||||
|
|
||||||
#### Token prefix
|
#### 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
|
```python
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
||||||
@@ -60,7 +63,9 @@ Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
|
|||||||
|
|
||||||
#### Token generation
|
#### 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
|
```python
|
||||||
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
||||||
@@ -70,23 +75,18 @@ await db.store_token(user_id, token)
|
|||||||
return {"access_token": token, "token_type": "bearer"}
|
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)
|
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
|
||||||
|
|
||||||
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
|
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
|
```python
|
||||||
from fastapi_toolsets.security import CookieAuth
|
from fastapi_toolsets.security import CookieAuth
|
||||||
|
|
||||||
# Production (HTTPS) — default
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session)
|
cookie_auth = CookieAuth("session", validator=verify_session)
|
||||||
|
|
||||||
# Local development (HTTP only)
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session, secure=False)
|
|
||||||
|
|
||||||
@app.get("/me")
|
@app.get("/me")
|
||||||
async def me(user: User = Security(cookie_auth)):
|
async def me(user: User = Security(cookie_auth)):
|
||||||
return user
|
return user
|
||||||
@@ -94,17 +94,16 @@ async def me(user: User = Security(cookie_auth)):
|
|||||||
|
|
||||||
#### Signed cookies
|
#### 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
|
```python
|
||||||
# Production
|
|
||||||
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret")
|
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")
|
@app.post("/login")
|
||||||
async def login(response: Response):
|
async def login(response: Response):
|
||||||
cookie_auth.set_cookie(response, user_id)
|
cookie_auth.set_cookie(response, user_id)
|
||||||
@@ -120,7 +119,8 @@ async def me(user: User = Security(cookie_auth)):
|
|||||||
return user
|
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)
|
### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth)
|
||||||
|
|
||||||
@@ -136,11 +136,14 @@ async def data(user: User = Security(api_key_auth)):
|
|||||||
return user
|
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
|
## 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
|
```python
|
||||||
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
||||||
@@ -152,11 +155,14 @@ async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
|||||||
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
|
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
|
### 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
|
```python
|
||||||
bearer = BearerTokenAuth(verify_token)
|
bearer = BearerTokenAuth(verify_token)
|
||||||
@@ -174,11 +180,24 @@ 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.
|
||||||
|
|
||||||
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
|
```python
|
||||||
from fastapi_toolsets.security import MultiAuth
|
from fastapi_toolsets.security import MultiAuth
|
||||||
@@ -192,7 +211,9 @@ async def data_route(user = Security(multi)):
|
|||||||
|
|
||||||
### Using `.require()` on MultiAuth
|
### 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
|
```python
|
||||||
multi = MultiAuth(bearer, cookie)
|
multi = MultiAuth(bearer, cookie)
|
||||||
@@ -216,7 +237,9 @@ MultiAuth(
|
|||||||
|
|
||||||
### Prefix-based dispatch
|
### 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
|
```python
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
||||||
@@ -228,7 +251,8 @@ multi = MultiAuth(user_bearer, org_bearer)
|
|||||||
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
|
# "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
|
```python
|
||||||
user_token = user_bearer.generate_token() # "user_..."
|
user_token = user_bearer.generate_token() # "user_..."
|
||||||
@@ -237,7 +261,9 @@ org_token = org_bearer.generate_token() # "org_..."
|
|||||||
|
|
||||||
## Custom auth sources
|
## 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
|
```python
|
||||||
from fastapi_toolsets.security import AuthSource
|
from fastapi_toolsets.security import AuthSource
|
||||||
@@ -258,11 +284,16 @@ Custom sources work transparently inside `MultiAuth`.
|
|||||||
|
|
||||||
## OAuth 2.0 / OIDC helpers
|
## 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
|
### 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
|
```python
|
||||||
from fastapi_toolsets.security import oauth_resolve_provider_urls
|
from fastapi_toolsets.security import oauth_resolve_provider_urls
|
||||||
@@ -272,51 +303,42 @@ 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
|
### 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. The `destination`
|
||||||
|
URL (where to send the user after the full flow) is encoded as the `state`
|
||||||
|
parameter:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi import Request
|
from fastapi_toolsets.security import oauth_build_authorization_redirect
|
||||||
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():
|
||||||
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()
|
|
||||||
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",
|
||||||
state_token=state_token,
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Token exchange and userinfo
|
### 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
|
||||||
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:
|
endpoint, then GETs the userinfo endpoint with the resulting access token:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi import HTTPException, Request
|
from fastapi_toolsets.security import oauth_fetch_userinfo
|
||||||
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(code: str, state: str):
|
||||||
# Pop token first — single-use, regardless of whether verification succeeds
|
|
||||||
state_token = request.session.pop("oauth_state", None)
|
|
||||||
if state_token is None:
|
|
||||||
raise HTTPException(status_code=400, detail="missing OAuth state")
|
|
||||||
destination = oauth_decode_state(state, expected_state_token=state_token, fallback="/")
|
|
||||||
if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect
|
|
||||||
destination = "/"
|
|
||||||
|
|
||||||
_, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
_, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
||||||
userinfo = await oauth_fetch_userinfo(
|
userinfo = await oauth_fetch_userinfo(
|
||||||
token_url=token_url,
|
token_url=token_url,
|
||||||
@@ -325,28 +347,30 @@ async def google_callback(request: Request, code: str, state: str):
|
|||||||
client_id=GOOGLE_CLIENT_ID,
|
client_id=GOOGLE_CLIENT_ID,
|
||||||
client_secret=GOOGLE_CLIENT_SECRET,
|
client_secret=GOOGLE_CLIENT_SECRET,
|
||||||
redirect_uri="https://myapp.com/auth/google/callback",
|
redirect_uri="https://myapp.com/auth/google/callback",
|
||||||
required_scopes="openid email profile",
|
|
||||||
)
|
)
|
||||||
user = await db.upsert_user(email=userinfo["email"])
|
user = await db.upsert_user(email=userinfo["email"])
|
||||||
|
destination = oauth_decode_state(state, fallback="/")
|
||||||
response = RedirectResponse(destination)
|
response = RedirectResponse(destination)
|
||||||
session_cookie.set_cookie(response, str(user.id))
|
session_cookie.set_cookie(response, str(user.id))
|
||||||
return response
|
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
|
### 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)
|
||||||
|
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
|
```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()
|
encoded = oauth_encode_state("/dashboard") # e.g. "L2Rhc2hib2FyZA=="
|
||||||
encoded = oauth_encode_state("/dashboard", state_token)
|
decoded = oauth_decode_state(encoded, fallback="/") # "/dashboard"
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard"
|
decoded = oauth_decode_state(None, fallback="/") # "/"
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/"
|
decoded = oauth_decode_state("null", fallback="/") # "/"
|
||||||
decoded = oauth_decode_state(None, expected_state_token=state_token, 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,6 @@ 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_resolve_provider_urls,
|
oauth_resolve_provider_urls,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -34,8 +33,6 @@ 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_build_authorization_redirect
|
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_encode_state
|
## ::: fastapi_toolsets.security.oauth_encode_state
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+2
-7
@@ -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"
|
||||||
@@ -50,17 +50,13 @@ cli = [
|
|||||||
metrics = [
|
metrics = [
|
||||||
"prometheus_client>=0.20.0",
|
"prometheus_client>=0.20.0",
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
"async-lru>=1.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
pytest = [
|
pytest = [
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-xdist>=3.0.0",
|
"pytest-xdist>=3.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest,security]",
|
"fastapi-toolsets[cli,metrics,pytest]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -77,7 +73,6 @@ dev = [
|
|||||||
"ty>=0.0.1a0",
|
"ty>=0.0.1a0",
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
"async-lru>=1.0",
|
|
||||||
"coverage>=7.0.0",
|
"coverage>=7.0.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-anyio>=0.0.0",
|
"pytest-anyio>=0.0.0",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+21
-26
@@ -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]:
|
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||||
async with session_maker() as session:
|
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
||||||
try:
|
yield session
|
||||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
|
||||||
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
|
||||||
yield session
|
|
||||||
await session.commit()
|
|
||||||
except BaseException:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
return _lock()
|
|
||||||
|
|
||||||
|
|
||||||
async def create_database(
|
async def create_database(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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_resolve_provider_urls,
|
oauth_resolve_provider_urls,
|
||||||
)
|
)
|
||||||
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
||||||
@@ -21,6 +20,5 @@ __all__ = [
|
|||||||
"oauth_decode_state",
|
"oauth_decode_state",
|
||||||
"oauth_encode_state",
|
"oauth_encode_state",
|
||||||
"oauth_fetch_userinfo",
|
"oauth_fetch_userinfo",
|
||||||
"oauth_generate_state_token",
|
|
||||||
"oauth_resolve_provider_urls",
|
"oauth_resolve_provider_urls",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Abstract base class for authentication sources."""
|
"""Abstract base class for authentication sources."""
|
||||||
|
|
||||||
import functools
|
|
||||||
import inspect
|
import inspect
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -16,7 +15,6 @@ def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
if inspect.iscoroutinefunction(fn):
|
if inspect.iscoroutinefunction(fn):
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
"""OAuth 2.0 / OIDC helper utilities."""
|
"""OAuth 2.0 / OIDC helper utilities."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from async_lru import alru_cache
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
_discovery_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
@alru_cache(maxsize=32)
|
|
||||||
async def oauth_resolve_provider_urls(
|
async def oauth_resolve_provider_urls(
|
||||||
discovery_url: str,
|
discovery_url: str,
|
||||||
) -> tuple[str, str, str | None]:
|
) -> tuple[str, str, str | None]:
|
||||||
@@ -26,10 +22,12 @@ async def oauth_resolve_provider_urls(
|
|||||||
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
||||||
*userinfo_url* is ``None`` when the provider does not advertise one.
|
*userinfo_url* is ``None`` when the provider does not advertise one.
|
||||||
"""
|
"""
|
||||||
async with httpx.AsyncClient() as client:
|
if discovery_url not in _discovery_cache:
|
||||||
resp = await client.get(discovery_url)
|
async with httpx.AsyncClient() as client:
|
||||||
resp.raise_for_status()
|
resp = await client.get(discovery_url)
|
||||||
cfg = resp.json()
|
resp.raise_for_status()
|
||||||
|
_discovery_cache[discovery_url] = resp.json()
|
||||||
|
cfg = _discovery_cache[discovery_url]
|
||||||
return (
|
return (
|
||||||
cfg["authorization_endpoint"],
|
cfg["authorization_endpoint"],
|
||||||
cfg["token_endpoint"],
|
cfg["token_endpoint"],
|
||||||
@@ -45,10 +43,14 @@ async def oauth_fetch_userinfo(
|
|||||||
client_id: str,
|
client_id: str,
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
redirect_uri: str,
|
redirect_uri: str,
|
||||||
required_scopes: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Exchange an authorization code for tokens and return the userinfo payload.
|
"""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:
|
Args:
|
||||||
token_url: Provider's token endpoint.
|
token_url: Provider's token endpoint.
|
||||||
userinfo_url: Provider's userinfo endpoint.
|
userinfo_url: Provider's userinfo endpoint.
|
||||||
@@ -56,16 +58,9 @@ async def oauth_fetch_userinfo(
|
|||||||
client_id: OAuth application client ID.
|
client_id: OAuth application client ID.
|
||||||
client_secret: OAuth application client secret.
|
client_secret: OAuth application client secret.
|
||||||
redirect_uri: Redirect URI that was used in the authorization request.
|
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:
|
Returns:
|
||||||
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
token_resp = await client.post(
|
token_resp = await client.post(
|
||||||
@@ -80,20 +75,7 @@ async def oauth_fetch_userinfo(
|
|||||||
headers={"Accept": "application/json"},
|
headers={"Accept": "application/json"},
|
||||||
)
|
)
|
||||||
token_resp.raise_for_status()
|
token_resp.raise_for_status()
|
||||||
token_data = token_resp.json()
|
access_token = token_resp.json()["access_token"]
|
||||||
|
|
||||||
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_resp = await client.get(
|
||||||
userinfo_url,
|
userinfo_url,
|
||||||
@@ -103,11 +85,6 @@ async def oauth_fetch_userinfo(
|
|||||||
return userinfo_resp.json()
|
return userinfo_resp.json()
|
||||||
|
|
||||||
|
|
||||||
def oauth_generate_state_token() -> str:
|
|
||||||
"""Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_build_authorization_redirect(
|
def oauth_build_authorization_redirect(
|
||||||
authorization_url: str,
|
authorization_url: str,
|
||||||
*,
|
*,
|
||||||
@@ -115,7 +92,6 @@ def oauth_build_authorization_redirect(
|
|||||||
scopes: str,
|
scopes: str,
|
||||||
redirect_uri: str,
|
redirect_uri: str,
|
||||||
destination: str,
|
destination: str,
|
||||||
state_token: str,
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
||||||
|
|
||||||
@@ -125,10 +101,7 @@ def oauth_build_authorization_redirect(
|
|||||||
scopes: Space-separated list of requested scopes.
|
scopes: Space-separated list of requested scopes.
|
||||||
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 (encoded as ``state``).
|
||||||
state_token: CSRF token generated by :func:`oauth_generate_state_token`.
|
|
||||||
Must be stored server-side (session or signed cookie) and verified via
|
|
||||||
:func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
||||||
@@ -140,58 +113,28 @@ 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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
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) -> str:
|
||||||
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
|
"""Base64url-encode a URL to embed as an OAuth ``state`` parameter."""
|
||||||
|
return base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Post-login destination URL.
|
def oauth_decode_state(state: str | None, *, fallback: str) -> str:
|
||||||
state_token: CSRF token from :func:`oauth_generate_state_token`.
|
"""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.
|
||||||
"""
|
"""
|
||||||
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":"))
|
if not state or state == "null":
|
||||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_decode_state(
|
|
||||||
state: str | None, *, expected_state_token: str, fallback: str
|
|
||||||
) -> str:
|
|
||||||
"""Decode and CSRF-verify an OAuth ``state`` parameter.
|
|
||||||
|
|
||||||
Uses a constant-time comparison for the CSRF token to prevent timing attacks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Raw ``state`` query parameter from the provider's callback.
|
|
||||||
expected_state_token: The token stored before the authorization redirect.
|
|
||||||
If it does not match the decoded value, ``fallback`` is returned.
|
|
||||||
fallback: URL to return when ``state`` is absent, malformed, or fails
|
|
||||||
CSRF verification.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The destination URL embedded in ``state``, or ``fallback``.
|
|
||||||
|
|
||||||
Important:
|
|
||||||
**Single-use**: delete the stored token from the session immediately
|
|
||||||
after calling this function — whether it matched or not — so that a
|
|
||||||
captured callback URL cannot be replayed.
|
|
||||||
|
|
||||||
**Open-redirect**: validate the returned URL against a known-good
|
|
||||||
origin or relative-path allowlist before issuing the final redirect.
|
|
||||||
Do not forward arbitrary URLs to ``RedirectResponse``.
|
|
||||||
"""
|
|
||||||
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
|
|
||||||
return fallback
|
return fallback
|
||||||
try:
|
try:
|
||||||
padded = state + "=" * (-len(state) % 4)
|
padded = state + "=" * (4 - len(state) % 4)
|
||||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
return base64.urlsafe_b64decode(padded).decode()
|
||||||
if not isinstance(payload, dict) or not hmac.compare_digest(
|
except Exception:
|
||||||
payload.get("n", "").encode(), expected_state_token.encode()
|
|
||||||
):
|
|
||||||
return fallback
|
|
||||||
return str(payload["d"])
|
|
||||||
except (UnicodeDecodeError, ValueError, binascii.Error, KeyError):
|
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import inspect
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Annotated, Any, Callable
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
@@ -66,7 +66,7 @@ class BearerTokenAuth(AuthSource):
|
|||||||
raise UnauthorizedError()
|
raise UnauthorizedError()
|
||||||
return await self._validator(token, **self._kwargs)
|
return await self._validator(token, **self._kwargs)
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
async def extract(self, request: Any) -> str | None:
|
||||||
"""Extract the raw credential from the request without validating.
|
"""Extract the raw credential from the request without validating.
|
||||||
|
|
||||||
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ class CookieAuth(AuthSource):
|
|||||||
cookie value is passed to the validator as-is.
|
cookie value is passed to the validator as-is.
|
||||||
ttl: Cookie lifetime in seconds (default 24 h). Only used when
|
ttl: Cookie lifetime in seconds (default 24 h). Only used when
|
||||||
``secret_key`` is set.
|
``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
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
call (e.g. ``role=Role.ADMIN``).
|
||||||
"""
|
"""
|
||||||
@@ -50,14 +47,12 @@ class CookieAuth(AuthSource):
|
|||||||
*,
|
*,
|
||||||
secret_key: str | None = None,
|
secret_key: str | None = None,
|
||||||
ttl: int = 86400,
|
ttl: int = 86400,
|
||||||
secure: bool = True,
|
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._name = name
|
self._name = name
|
||||||
self._validator = _ensure_async(validator)
|
self._validator = _ensure_async(validator)
|
||||||
self._secret_key = secret_key
|
self._secret_key = secret_key
|
||||||
self._ttl = ttl
|
self._ttl = ttl
|
||||||
self._secure = secure
|
|
||||||
self._kwargs = kwargs
|
self._kwargs = kwargs
|
||||||
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
||||||
|
|
||||||
@@ -125,7 +120,6 @@ class CookieAuth(AuthSource):
|
|||||||
self._validator,
|
self._validator,
|
||||||
secret_key=self._secret_key,
|
secret_key=self._secret_key,
|
||||||
ttl=self._ttl,
|
ttl=self._ttl,
|
||||||
secure=self._secure,
|
|
||||||
**{**self._kwargs, **kwargs},
|
**{**self._kwargs, **kwargs},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,12 +131,9 @@ class CookieAuth(AuthSource):
|
|||||||
cookie_value,
|
cookie_value,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=self._secure,
|
|
||||||
max_age=self._ttl,
|
max_age=self._ttl,
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_cookie(self, response: Response) -> None:
|
def delete_cookie(self, response: Response) -> None:
|
||||||
"""Clear the session cookie (logout)."""
|
"""Clear the session cookie (logout)."""
|
||||||
response.delete_cookie(
|
response.delete_cookie(self._name, httponly=True, samesite="lax")
|
||||||
self._name, httponly=True, samesite="lax", secure=self._secure
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -14,8 +14,42 @@ from ..abc import AuthSource
|
|||||||
class MultiAuth:
|
class MultiAuth:
|
||||||
"""Combine multiple authentication sources into a single callable.
|
"""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:
|
Args:
|
||||||
*sources: Auth source instances to try in order.
|
*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:
|
def __init__(self, *sources: AuthSource) -> None:
|
||||||
@@ -61,7 +95,21 @@ class MultiAuth:
|
|||||||
return await self._call_fn(**kwargs)
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "MultiAuth":
|
def require(self, **kwargs: Any) -> "MultiAuth":
|
||||||
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
|
"""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(
|
new_sources = tuple(
|
||||||
cast(Any, source).require(**kwargs)
|
cast(Any, source).require(**kwargs)
|
||||||
if hasattr(source, "require")
|
if hasattr(source, "require")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+64
-52
@@ -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")
|
|
||||||
session.add(role)
|
async for session in get_db():
|
||||||
|
async with lock_tables(session, [Role]):
|
||||||
|
role = Role(name="lock_then_update")
|
||||||
|
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,55 +287,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, 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
|
|
||||||
|
|
||||||
|
|
||||||
class TestWaitForRowChange:
|
class TestWaitForRowChange:
|
||||||
@@ -630,30 +643,29 @@ 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")
|
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")
|
||||||
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()
|
assert len(loaded.tags) == 1
|
||||||
assert len(loaded.tags) == 1
|
assert loaded.tags[0].name == "locked_tag"
|
||||||
assert loaded.tags[0].name == "locked_tag"
|
|
||||||
|
|
||||||
|
|
||||||
class _LocalBase(DeclarativeBase):
|
class _LocalBase(DeclarativeBase):
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+23
-184
@@ -18,7 +18,6 @@ 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_resolve_provider_urls,
|
oauth_resolve_provider_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -761,10 +760,7 @@ class TestCookieAuthSigned:
|
|||||||
"""set_cookie signs the value; the signed cookie is verified on read."""
|
"""set_cookie signs the value; the signed cookie is verified on read."""
|
||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
|
|
||||||
# secure=False for test client which runs over plain HTTP
|
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
|
||||||
auth = CookieAuth(
|
|
||||||
"session", cookie_validator, secret_key=self.SECRET, secure=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def setup(app: FastAPI):
|
def setup(app: FastAPI):
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
@@ -782,26 +778,6 @@ class TestCookieAuthSigned:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"session": VALID_COOKIE}
|
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):
|
def test_tampered_signature_returns_401(self):
|
||||||
"""A cookie whose HMAC signature has been modified is rejected."""
|
"""A cookie whose HMAC signature has been modified is rejected."""
|
||||||
import base64 as _b64
|
import base64 as _b64
|
||||||
@@ -1013,64 +989,28 @@ 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")
|
||||||
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"
|
assert oauth_decode_state(oauth_encode_state(url), fallback="/") == url
|
||||||
assert (
|
|
||||||
oauth_decode_state(
|
|
||||||
oauth_encode_state(url, state_token),
|
|
||||||
expected_state_token=state_token,
|
|
||||||
fallback="/",
|
|
||||||
)
|
|
||||||
== url
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_none_returns_fallback(self):
|
def test_decode_none_returns_fallback(self):
|
||||||
assert (
|
assert oauth_decode_state(None, 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", fallback="/home") == "/home"
|
||||||
oauth_decode_state("null", expected_state_token="any", fallback="/home")
|
|
||||||
== "/home"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_invalid_base64_returns_fallback(self):
|
def test_decode_invalid_base64_returns_fallback(self):
|
||||||
assert (
|
assert oauth_decode_state("!!!notbase64!!!", fallback="/home") == "/home"
|
||||||
oauth_decode_state(
|
|
||||||
"!!!notbase64!!!", expected_state_token="any", fallback="/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"
|
encoded = oauth_encode_state(url).rstrip("=")
|
||||||
encoded = oauth_encode_state(url, state_token).rstrip("=")
|
assert oauth_decode_state(encoded, fallback="/") == url
|
||||||
assert (
|
|
||||||
oauth_decode_state(encoded, expected_state_token=state_token, fallback="/")
|
|
||||||
== url
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_wrong_state_token_returns_fallback(self):
|
|
||||||
url = "https://example.com/dashboard"
|
|
||||||
encoded = oauth_encode_state(url, "correct-token")
|
|
||||||
assert (
|
|
||||||
oauth_decode_state(
|
|
||||||
encoded, expected_state_token="wrong-token", fallback="/"
|
|
||||||
)
|
|
||||||
== "/"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_generate_state_token_is_random(self):
|
|
||||||
assert oauth_generate_state_token() != oauth_generate_state_token()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildAuthorizationRedirect:
|
class TestBuildAuthorizationRedirect:
|
||||||
@@ -1083,19 +1023,16 @@ 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",
|
|
||||||
)
|
)
|
||||||
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"
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
location = response.headers["location"]
|
location = response.headers["location"]
|
||||||
parsed = urlparse(location)
|
parsed = urlparse(location)
|
||||||
@@ -1109,9 +1046,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], fallback="")
|
||||||
params["state"][0], expected_state_token=state_token, fallback=""
|
|
||||||
)
|
|
||||||
== "https://app.example.com/dashboard"
|
== "https://app.example.com/dashboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1133,11 +1068,11 @@ class TestResolveProviderUrls:
|
|||||||
mock_resp.json.return_value = self._discovery()
|
mock_resp.json.return_value = self._discovery()
|
||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
oauth_resolve_provider_urls.cache_clear()
|
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
||||||
"https://auth.example.com/.well-known/openid-configuration"
|
"https://auth.example.com/.well-known/openid-configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert auth_url == "https://auth.example.com/authorize"
|
assert auth_url == "https://auth.example.com/authorize"
|
||||||
assert token_url == "https://auth.example.com/token"
|
assert token_url == "https://auth.example.com/token"
|
||||||
@@ -1150,11 +1085,11 @@ class TestResolveProviderUrls:
|
|||||||
mock_resp.json.return_value = self._discovery(userinfo=False)
|
mock_resp.json.return_value = self._discovery(userinfo=False)
|
||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
oauth_resolve_provider_urls.cache_clear()
|
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
_, _, userinfo_url = await oauth_resolve_provider_urls(
|
_, _, userinfo_url = await oauth_resolve_provider_urls(
|
||||||
"https://auth.example.com/.well-known/openid-configuration"
|
"https://auth.example.com/.well-known/openid-configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert userinfo_url is None
|
assert userinfo_url is None
|
||||||
|
|
||||||
@@ -1166,10 +1101,10 @@ class TestResolveProviderUrls:
|
|||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
url = "https://auth.example.com/.well-known/openid-configuration"
|
url = "https://auth.example.com/.well-known/openid-configuration"
|
||||||
oauth_resolve_provider_urls.cache_clear()
|
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
await oauth_resolve_provider_urls(url)
|
await oauth_resolve_provider_urls(url)
|
||||||
await oauth_resolve_provider_urls(url)
|
await oauth_resolve_provider_urls(url)
|
||||||
|
|
||||||
assert mock_client.get.call_count == 1
|
assert mock_client.get.call_count == 1
|
||||||
|
|
||||||
@@ -1243,99 +1178,3 @@ class TestFetchUserinfo:
|
|||||||
"https://auth.example.com/userinfo",
|
"https://auth.example.com/userinfo",
|
||||||
headers={"Authorization": "Bearer tok123"},
|
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"
|
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.31.0"
|
||||||
@@ -314,7 +305,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.1"
|
version = "0.136.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -323,14 +314,14 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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" },
|
||||||
@@ -341,7 +332,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -359,14 +349,9 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||||
@@ -391,7 +376,6 @@ docs-src = [
|
|||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -402,12 +386,10 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
|
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
||||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
{ 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 = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
@@ -415,11 +397,10 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
|
provides-extras = ["cli", "metrics", "pytest", "all"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "async-lru", specifier = ">=1.0" },
|
|
||||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||||
@@ -442,7 +423,6 @@ docs = [
|
|||||||
]
|
]
|
||||||
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "async-lru", specifier = ">=1.0" },
|
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.25.0" },
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
@@ -836,35 +816,35 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prek"
|
name = "prek"
|
||||||
version = "0.3.13"
|
version = "0.3.11"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/59/0a279983f96bd5d538b4975f0a23121082aa3b8560b6649fdf61f8011b07/prek-0.3.13.tar.gz", hash = "sha256:c48586ee3708bfbf3df80121f55583e9a7d0fa166b08172c091fe5971e92a0ac", size = 444848, upload-time = "2026-05-05T18:07:09.076Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6c/60/5b980c70525ca5f0d17942d8eae13b399051aa384413366fe5df229712ea/prek-0.3.11.tar.gz", hash = "sha256:c4cf77848009503c58d80ff216e32af45b63ea49652bb5546748c1ebfd4d9847", size = 433440, upload-time = "2026-04-27T04:22:59.923Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/6a/9baa2bda21dccc2927e952416f6cc23a75eb99c9ed18837164ac2e4a5640/prek-0.3.13-py3-none-linux_armv6l.whl", hash = "sha256:b00d38f01235073c35aa5f48df57fefef45a6cec2ae0884d750345a2c7220370", size = 5506622, upload-time = "2026-05-05T18:06:53.091Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/2a/3392fa7d1fd1ce538915baa7597e7203bbe888367a8b15bfd51ca74d4714/prek-0.3.11-py3-none-linux_armv6l.whl", hash = "sha256:787e605716cfdc86ec01e7c5cf62799f39c28d49de5e37d75595c8e6248cb0f3", size = 5423112, upload-time = "2026-04-27T04:22:52.659Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/77/d44b5d9bdca0879b865f8e47bf84cf5dc9e8b358d029e6d9b83d8809c116/prek-0.3.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0d89ac712c60e34d1550a606ad5fdfb8ad71d44ced8afa2fa5cbc106be4abd9e", size = 5878743, upload-time = "2026-05-05T18:07:23.164Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/b0/3fc653b30b70d6c2714fc56bcfe1c2439437fc38f60b72bc300603ace4cd/prek-0.3.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ef1f37187ca52d75ba9c46b53007476c4eab2c3f11bd23defd57a81c62d90442", size = 5801382, upload-time = "2026-04-27T04:23:04.464Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/cf/19e8525cde8b3aa12858aca434d1fa653ef3b152da5af11eafc857634dc2/prek-0.3.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f9b5265863d18b5be4ea094fdce4fd6ca61a8c89a70ee3d8ee153b3e0ed6b272", size = 5434909, upload-time = "2026-05-05T18:07:25.276Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/46/39aedc7843c3703f1f43b686622e4f8cd123e03b87a163e5c8f2fbd56cda/prek-0.3.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d0828a1b50447502ea1be3f5a84da474fdca558cd5d76a1a5205169bb808c7", size = 5370817, upload-time = "2026-04-27T04:22:49.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/9a/e5f97194782de4dab622ce09dafb3ebdd2ee4d354a83ac4def7ebeee236c/prek-0.3.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:64b59a1550780af2bba37297c704b17f81d8e9df6288af1fab4017938e33b1db", size = 5697536, upload-time = "2026-05-05T18:07:05.475Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/83/df5f3aeacbdea96a88c4f06c98d3932469711fed4e3bf5b703dd6507abe7/prek-0.3.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bf49464b526ee36d2130baf60ab9580560bfaa60efd2997328e6d6671e209014", size = 5621405, upload-time = "2026-04-27T04:23:10.58Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/8c/e1f548ffc4b227e4c2b5a9b30f5978a7e0e6dad51305b97a2ba5b2a923e7/prek-0.3.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ce6cd8f114ba9bbdbe97422103fd886101949b1c42e588a7543c4436ead2020", size = 5428160, upload-time = "2026-05-05T18:07:01.489Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/f2/e32c9720747a327669863a4f92d05b9e6fadb851e903b0d7310a97c956a4/prek-0.3.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac344529f0d34757c7c95f65e66b9f6440a691f826eaf43f503247bd22023558", size = 5339780, upload-time = "2026-04-27T04:22:47.614Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/44/abd919b00905a32d21dca2cec32c707860cf217da2431b62dd52684b310e/prek-0.3.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc03e924a24d8d961f56195853c8b206cb196be6db4ad8312125dae847d718ac", size = 5827275, upload-time = "2026-05-05T18:07:17.437Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/2e/0e2f71b63bc2e5372575d5c1574b0666d2f90d30da51ed706a32cbf465a0/prek-0.3.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83672d963f249e2f246d3bec97f8fd2e8032e70da0a7d9acb2fa38af76dd82d2", size = 5735277, upload-time = "2026-04-27T04:23:12.437Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/ed/cafd2b80d58a83faf8371c6543bd1475a2224242a3294da7f8582f6aa551/prek-0.3.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ca8c526a23873177fb3b92013500b08ef5f8bedc7263f9f3a44dd2f49645a26", size = 6710293, upload-time = "2026-05-05T18:07:10.663Z" },
|
{ url = "https://files.pythonhosted.org/packages/09/46/88abf51ac88eeff1ad2fe7d1797ca1fea43eb1ac1ddb8331463ac5b27ed2/prek-0.3.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef70957195d2896a30dd849e64f88344df7bb51af9c950cf16bd11519e7424b0", size = 6622420, upload-time = "2026-04-27T04:23:05.982Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/09/52a4a27596b764173a34d74db09356b30faaacb4a1075b75adbc036a0008/prek-0.3.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5bbb175478438a871e3281d2c3c3f067288af73ad81707a9bdebfd769766c7d", size = 6096556, upload-time = "2026-05-05T18:07:19.46Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/b6/592028a45b084a68b76c7edef909c789d1c96b26761388f63659beef7166/prek-0.3.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4f0cc07d2cdb5fe2882015d5fdafc9af98b4c560d4caa1ae948caeab4341b79", size = 6020038, upload-time = "2026-04-27T04:22:54.367Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/60/80f61729ce6498815d46d5580cf76da2c157c9b6494046183682441a0ea3/prek-0.3.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a65327a014d838341af757dfc05a706d10e8e33f039bc32bb3dbe2fa21c440c0", size = 5693267, upload-time = "2026-05-05T18:07:03.66Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/f7/e97f55a1645a2e9becffeee28892ad8bb66cd144dabfa4392ea8e2674bbe/prek-0.3.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d7554b436dae2ec97f4351a46817e3561657244307d1c0915f355b859f4fab71", size = 5622539, upload-time = "2026-04-27T04:23:01.314Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/9d/c7a663fe70676ffab2e0c6c9a71997a3ccd002ed5bc60b7422a937911af0/prek-0.3.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b6a200843a36a5b0c41764ce7639ccb3471d48b097f1c5e3fc8f034219b42626", size = 5532865, upload-time = "2026-05-05T18:07:15.237Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/e2/f3119eef6b621782ad216a86d449609858ea34c57cf4a40fc6dc80556d7e/prek-0.3.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:caba5d635a5b64b7ac64d903f29b043ca5b0d9d9693543a0ef331ade89e6ad3f", size = 5440681, upload-time = "2026-04-27T04:23:07.422Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/68/506ef5a235536030e16f61e7210474554f6e05f845f27df5877d2dbb1a06/prek-0.3.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:bdacaad8f35f343e063d251211fe34db1de9e5cc591795361ad69a6485202258", size = 5395951, upload-time = "2026-05-05T18:06:55.183Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/62/22dd4f59a47654faeebe74651182ecc48d436542646cc92723052dfd9a45/prek-0.3.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:c95a63f19dde48e84b70bd63a235670834af15fa4df8b85d8b7894dd5bc419a9", size = 5314773, upload-time = "2026-04-27T04:22:57.912Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/00/22d7c6db7f43b58f7d015913c12660c9bbc82751cff6cfd8c31993cf30eb/prek-0.3.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f00328f1c520d8fefb910ab0d3c6764ee330d227952baa19b7e3de7242bd8b3b", size = 5681195, upload-time = "2026-05-05T18:07:12.804Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/94/a8361462acb8d8f5b8505255b95ffbfc2ee0872a79b4e066eb330692f7be/prek-0.3.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7353b45f44a386c676fe96ba72a5ee326b676f789339f405cf6f1d69a1707194", size = 5596208, upload-time = "2026-04-27T04:23:09.08Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/e3/fdf9882238796914ddaf11381a9083b374980156200a953324f6c795f34d/prek-0.3.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e5530a867bcf5b172b7513a64e71b06a337d1d184696227ae953845867376b8d", size = 6212085, upload-time = "2026-05-05T18:07:07.213Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/0c/5f065b86bbeb9977074a055d8a05e90c7201f6c4c7032dada61739b5f8cb/prek-0.3.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:39f4e86176ccbb70c098df6abbc8e36c1d86cb81281abe92fb79dcd572418214", size = 6132833, upload-time = "2026-04-27T04:22:56.054Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/1d/528759931344b5c7103085798f5fa2e86d27d9410b753a6bcbe7726aa8ba/prek-0.3.13-py3-none-win32.whl", hash = "sha256:326fac2bdce00074ce6c5046b861d310638aee2b9de1ed241ba7eb32bdc83898", size = 5199566, upload-time = "2026-05-05T18:07:21.416Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/0c/8ab0ae140201dcee505f58b60abbe56bd05ac96b821a6866f6f90c4d971f/prek-0.3.11-py3-none-win32.whl", hash = "sha256:35d2361049653a3dcf27227b7f1b340c5c42a12c0e0361c4b785921bfd125839", size = 5120856, upload-time = "2026-04-27T04:23:02.752Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/d0/8715ee837c73314a02767d20652cc312d1b6ff6733fa00f52de2b648bc3a/prek-0.3.13-py3-none-win_amd64.whl", hash = "sha256:841049f89f5ec9f4035299283d11e566ac5a068e3742ead1055ea04f886831fc", size = 5589599, upload-time = "2026-05-05T18:06:57.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/05/9844c1125d3714f6f6c7b475884128a4b0c6c3ee0cd208ead44ca8174687/prek-0.3.11-py3-none-win_amd64.whl", hash = "sha256:a387689cd2e182f92dbb681151ee5a04f494fe97e95d6d783875da90b950e6d5", size = 5510916, upload-time = "2026-04-27T04:22:45.704Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/cf/0af0b15be0ebd82f7e50adee149b05a73533d78cb1b97cb889f0647ebffe/prek-0.3.13-py3-none-win_arm64.whl", hash = "sha256:a9fd74e0aec550c6b8d41076fdcdd6ff121cd7d94d743c1338bd794784e3c775", size = 5419029, upload-time = "2026-05-05T18:06:59.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/13/24b0288c553dc8d61f44c4d0746fe9bb1e1bd29d1e70571658536e4c0f72/prek-0.3.11-py3-none-win_arm64.whl", hash = "sha256:e4a8f900378a6657c7eb2fc4b12fa5c934edf209d0a24544539842479ec16e0b", size = 5345988, upload-time = "2026-04-27T04:22:50.918Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prometheus-client"
|
name = "prometheus-client"
|
||||||
version = "0.25.0"
|
version = "0.24.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1353,7 +1333,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.25.1"
|
version = "0.25.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -1361,9 +1341,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "shellingham" },
|
{ name = "shellingham" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150, upload-time = "2026-04-26T08:46:14.767Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993, upload-time = "2026-04-26T08:46:15.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1425,30 +1405,29 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.40"
|
version = "0.0.37"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "deepmerge" },
|
{ name = "deepmerge" },
|
||||||
{ name = "jinja2" },
|
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "tomli" },
|
{ name = "tomli" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/88062f7e235f58a5f05d82005fc35d9dbaed27c024fe9ffae5bce7f33661/zensical-0.0.40.tar.gz", hash = "sha256:5c294751977a664614cb84e987186ad8e282af77ce0d0d800fe48ee57791279d", size = 3920555, upload-time = "2026-05-04T16:19:07.962Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/57/f499eb86c487953866ab7ff364096b92d9a61fd68d38ca73f740bc42b78e/zensical-0.0.37.tar.gz", hash = "sha256:e43a59e939fbddb50d218aa5b643c24d0e7259155e9e36fb791e5f865af588d5", size = 3903829, upload-time = "2026-04-27T07:59:14.885Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/c4/3066f4442923ca1e49269147b70ca7c84467524e8f5228724693b9ac85c2/zensical-0.0.40-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b65a7143c9c6a460880bf3e65b777952bd2dcede9dd17a6c6bac9b4a0686ad9b", size = 12691533, upload-time = "2026-05-04T16:18:31.72Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/fb/63175f1d6785616541a29b30a3a749a04a1c334d431bbae2635399612705/zensical-0.0.37-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f75668ec86f4da8302862a272a6f02a32b7384e5518842960de592e0e7b522c7", size = 12509324, upload-time = "2026-04-27T07:58:41.711Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/cb/03e961cbd01620ea91aeb835b0b4e8848c7bcdf5a799a620fb3e57bfc277/zensical-0.0.40-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:045bdcb6d00a11ddcab7d379d0d986cdf78dba8e9287d8e628ef11958241507d", size = 12556486, upload-time = "2026-05-04T16:18:35.278Z" },
|
{ url = "https://files.pythonhosted.org/packages/34/19/6c981a60ad0758b4b8601589c5016b5bf8724b066346c933af9600e3a3c0/zensical-0.0.37-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d853c285ff942883a6f93e665e12af2f4d83b682bbff16db02ce7f058f70d7a4", size = 12383773, upload-time = "2026-04-27T07:58:44.745Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/76/7dde50220808bdc5f5e63b97866a684418410b3cae9d00cdae1d449bcc20/zensical-0.0.40-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48ec476c2e8ce3f8585a1278083aabc35ec80361f2c4fc4a53b9a525778f7fc", size = 12935602, upload-time = "2026-05-04T16:18:38.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/b0/5fd6d066ca8d9fea9b4ccafddc4b26f938c584864c648d6be29e5515787b/zensical-0.0.37-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfd5d4afa750c785a2671f586b6b82f18c906c066990ab374f76ef7d359221ce", size = 12779684, upload-time = "2026-04-27T07:58:47.451Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/55/6c8ef951c390b42249738f4338498e7a1fd64ff09e44d7cc19f5c948c45b/zensical-0.0.40-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c38e0ae314c25f2e5e64210bbad9be6e970f2d40fe9da106586ad90ce5e85e", size = 12904314, upload-time = "2026-05-04T16:18:41.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/9f/30daa249ab326d059311c51e802957a1e0bdd0505ae7d8d30c19fb00672c/zensical-0.0.37-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c3e7e33b15d2602de4670171ab119e2e7500b34aa7a1eb1d8abb26a3725240", size = 12726437, upload-time = "2026-04-27T07:58:50.028Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/ae/95008f5dc2ee441efcdc2fab36ff29ce24d7477e53390fc340c8add39342/zensical-0.0.40-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25f62dcd61f6306cab890dfa34c81d2709f5db290b4c3f2675343771db28c90", size = 13269946, upload-time = "2026-05-04T16:18:44.387Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/3c/9c1a1de28fa807b81746f58eef5f29cf353873fb7fcc2b6af51645fd4569/zensical-0.0.37-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:649c39cec8b3805b0e4abb8484f1ef85582792cea2f7376b33aa1263fe87ad95", size = 13073605, upload-time = "2026-04-27T07:58:52.882Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/96/cdbb2bf04255ccaaa07861bdda1ee8dd1630d2233fc2f09636abbd5e084c/zensical-0.0.40-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168fe3489dd93ae92978b4db11d9300c63e10d382b81634232c2872ce9e746c2", size = 12974962, upload-time = "2026-05-04T16:18:47.462Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/31/de06d2481581891839f9e2a1395fe32bfb9c9eb98cd1d635c560fb759ac6/zensical-0.0.37-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4e23ef4245b07bde6d6d8dd2ab902f34f6d3cd459fd136f6d0345cb87ccbd6c", size = 12804476, upload-time = "2026-04-27T07:58:55.478Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/ce/66e86f89fc15bbe667794ba67d7efc8fa72fe7a1be19e1efb4246ff55442/zensical-0.0.40-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8652ba203bd588ebf2d66bda4457a4a7d8e193c886960859c75081c0e3b946de", size = 13111599, upload-time = "2026-05-04T16:18:50.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/d0/5bbc27820d6cd622ddd2de5df3743a619003dcec01d51365e06ad623d984/zensical-0.0.37-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:21c6b629bc46062f8cb53e5ad575720df384e4b7db34770f3aeb3f097c3eb4cc", size = 12954884, upload-time = "2026-04-27T07:58:58.108Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/76/3d71ebdabb02d79a5c523b5e646141c362c9559947078c8d56a9f3bd7a30/zensical-0.0.40-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ffa6cf208b7ab6b771703be827d4d8c7f07f173abeffb35a8015a0b832b2a40", size = 13175406, upload-time = "2026-05-04T16:18:53.209Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/9f/e5c7624ccfe792254f22f8dbc7e2e4c30bf3ed14ad830e838b0009b42a58/zensical-0.0.37-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a5f95ebe531264b36cacabf208f3ea7cb7e9d874de857bb6bd3328cdabcf9257", size = 12997404, upload-time = "2026-04-27T07:59:00.488Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/6a/2bb5f730786d590f02cb0fef796c148d5ac0d5c1556f2d78c987ad4e1346/zensical-0.0.40-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7101ba0c739c78bc3a57d22130b59b9e6fdf96c21c8a6b4244070de6b34527d4", size = 13324783, upload-time = "2026-05-04T16:18:56.41Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/7a/70fe375ce974cdfd4eb42df53340c5b2e790f6a1d20a224f6ab5283c0a32/zensical-0.0.37-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:96375cd6338c2db731ce671e41afc108194552f5ceb75edd14a54eea48adfb89", size = 13139582, upload-time = "2026-04-27T07:59:03.452Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/8c/1d2ba1454360ee948dd0f0807b048c076d9578d0d9ebba2a438ecfa9f82f/zensical-0.0.40-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39bf728a68a5418feeda8f3385cd1063fdb8d896a6812c3dede4267b2868df12", size = 13260045, upload-time = "2026-05-04T16:18:59.244Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/49/0a7b27c89d52d0116ac466845f88495acb0b925405527df0079b2ad56508/zensical-0.0.37-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7be0976869317a2e1672f426853183c32a8193f211347d560d6037358a52fe7b", size = 13082714, upload-time = "2026-04-27T07:59:06.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/61/efd51c5c5e15cfd5498d59df250f60294cc44d36d8ce4dc2a76fa3669c2f/zensical-0.0.40-cp310-abi3-win32.whl", hash = "sha256:bc750c3ba8d11833d9b9ac8fc14adc3435225b6d17314a21a91eb60209511ca5", size = 12244913, upload-time = "2026-05-04T16:19:02.219Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/25/81c3f1de09abf571a7d6fdab3c836e37cc5a093596939d8d3af745251ae7/zensical-0.0.37-cp310-abi3-win32.whl", hash = "sha256:e1295f63230621ccb01ac7cd6be24f0ff079a12e95d9bdde631a13331c7ba67f", size = 12093854, upload-time = "2026-04-27T07:59:09.244Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/9e/f3f2118fbcfd1c2dc705491c8864c596b1a748b67ffe2a024e512b9201ab/zensical-0.0.40-cp310-abi3-win_amd64.whl", hash = "sha256:c5c86ac468df2dfe515ff54ffa97725c38226f1e5c970059b7e88078abab89ab", size = 12475762, upload-time = "2026-05-04T16:19:05.025Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/77/419377bee6b91746c5c3de19712eeba6c367c0b14b77c6b9c94e6bf2c3a2/zensical-0.0.37-cp310-abi3-win_amd64.whl", hash = "sha256:c015451bd9af60ee0fb01b95d0b17b751a0790fec58c3e0efd11ee4286ad2724", size = 12317524, upload-time = "2026-04-27T07:59:11.929Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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