Compare commits

..
12 Commits
Author SHA1 Message Date
d3vyce aeb9e0d9b7 feat: use async_lru for caching 2026-05-07 18:33:16 -04:00
d3vyce 6bef88fde6 fix: multiple security bugs + remove example for now 2026-05-07 18:21:34 -04:00
d3vyce 95f5a83bd2 docs: update module and reference 2026-05-07 14:59:26 -04:00
d3vyce 3099e3f0e1 fix: cleanup + simplify 2026-05-07 14:59:26 -04:00
d3vyce 34b733a5e1 docs: add authentication example 2026-05-07 14:59:26 -04:00
d3vyce 44dcea5ef2 feat(security): add oauth helpers 2026-05-07 14:59:26 -04:00
d3vyce f0ac43a9dc feat: add security module 2026-05-07 14:59:26 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bfd0207991 ⬆ Bump typer from 0.25.0 to 0.25.1 (#275)
Bumps [typer](https://github.com/fastapi/typer) from 0.25.0 to 0.25.1.
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.25.0...0.25.1)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.25.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 20:57:58 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b9aa094dda ⬆ Bump prek from 0.3.11 to 0.3.13 (#274)
Bumps [prek](https://github.com/j178/prek) from 0.3.11 to 0.3.13.
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.3.11...v0.3.13)

---
updated-dependencies:
- dependency-name: prek
  dependency-version: 0.3.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 20:57:43 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6bdc537558 ⬆ Bump prometheus-client from 0.24.1 to 0.25.0 (#273)
Bumps [prometheus-client](https://github.com/prometheus/client_python) from 0.24.1 to 0.25.0.
- [Release notes](https://github.com/prometheus/client_python/releases)
- [Commits](https://github.com/prometheus/client_python/compare/v0.24.1...v0.25.0)

---
updated-dependencies:
- dependency-name: prometheus-client
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 20:57:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2ad20ebe3c ⬆ Bump fastapi from 0.136.0 to 0.136.1 (#272)
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.136.0 to 0.136.1.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.136.0...0.136.1)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.136.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 20:57:15 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
929e9cc0fd ⬆ Bump zensical from 0.0.37 to 0.0.40 (#271)
Bumps [zensical](https://github.com/zensical/zensical) from 0.0.37 to 0.0.40.
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.37...v0.0.40)

---
updated-dependencies:
- dependency-name: zensical
  dependency-version: 0.0.40
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 20:56:54 +02:00
22 changed files with 418 additions and 649 deletions
+2
View File
@@ -31,6 +31,7 @@ Install only the extras you need:
```bash
uv add "fastapi-toolsets[cli]"
uv add "fastapi-toolsets[metrics]"
uv add "fastapi-toolsets[security]"
uv add "fastapi-toolsets[pytest]"
```
@@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]"
### Optional
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
- **CLI**: Django-like command-line interface with fixture management and custom commands support
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
-1
View File
@@ -1 +0,0 @@
# Authentication
+2
View File
@@ -31,6 +31,7 @@ Install only the extras you need:
```bash
uv add "fastapi-toolsets[cli]"
uv add "fastapi-toolsets[metrics]"
uv add "fastapi-toolsets[security]"
uv add "fastapi-toolsets[pytest]"
```
@@ -56,6 +57,7 @@ uv add "fastapi-toolsets[all]"
### Optional
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
- **CLI**: Django-like command-line interface with fixture management and custom commands support
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
+60 -75
View File
@@ -47,12 +47,9 @@ async def me(user: User = Security(bearer)):
#### Token prefix
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens
that start with a given string. The prefix is **kept** in the value passed to the
validator — store and compare tokens with their prefix included.
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens that start with a given string. The prefix is **kept** in the value passed to the validator — store and compare tokens with their prefix included.
This lets you deploy multiple `BearerTokenAuth` instances in the same application
and disambiguate them efficiently in `MultiAuth`:
This lets you deploy multiple `BearerTokenAuth` instances in the same application and disambiguate them efficiently in `MultiAuth`:
```python
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
@@ -63,9 +60,7 @@ Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
#### Token generation
`BearerTokenAuth.generate_token()` produces a secure random token ready to store
in your database and return to the client. If a prefix is configured it is
prepended automatically:
`BearerTokenAuth.generate_token()` produces a secure random token ready to store in your database and return to the client. If a prefix is configured it is prepended automatically:
```python
bearer = BearerTokenAuth(verify_token, prefix="user_")
@@ -75,18 +70,23 @@ await db.store_token(user_id, token)
return {"access_token": token, "token_type": "bearer"}
```
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives
the full token (prefix included) to compare against the stored value.
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives the full token (prefix included) to compare against the stored value.
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
Cookies are issued with the `Secure` flag set by default, meaning they are only transmitted over HTTPS. Set `secure=False` when running locally over plain HTTP:
```python
from fastapi_toolsets.security import CookieAuth
# Production (HTTPS) — default
cookie_auth = CookieAuth("session", validator=verify_session)
# Local development (HTTP only)
cookie_auth = CookieAuth("session", validator=verify_session, secure=False)
@app.get("/me")
async def me(user: User = Security(cookie_auth)):
return user
@@ -94,16 +94,17 @@ async def me(user: User = Security(cookie_auth)):
#### Signed cookies
Pass `secret_key` to enable HMAC-SHA256 signed, tamper-proof cookies. The cookie
payload includes an expiry timestamp (`ttl`, default 24 h). No database entry is
required — the signature is self-contained.
Pass `secret_key` to enable HMAC-SHA256 signed, tamper-proof cookies. The cookie payload includes an expiry timestamp (`ttl`, default 24 h). No database entry is required — the signature is self-contained.
Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to
clear it on logout:
Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to clear it on logout:
```python
# Production
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret")
# Local development
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret", secure=False)
@app.post("/login")
async def login(response: Response):
cookie_auth.set_cookie(response, user_id)
@@ -119,8 +120,7 @@ async def me(user: User = Security(cookie_auth)):
return user
```
When `secret_key` is not set, the raw cookie value is passed directly to the
validator (stateful session behaviour — you manage the session store).
When `secret_key` is not set, the raw cookie value is passed directly to the validator (stateful session behaviour — you manage the session store).
### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth)
@@ -136,14 +136,11 @@ async def data(user: User = Security(api_key_auth)):
return user
```
The header name is configurable — use any header your API defines (e.g.
`"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`).
The header name is configurable — use any header your API defines (e.g. `"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`).
## Typed validator kwargs
All auth classes forward extra instantiation keyword arguments to the validator.
Arguments can be any type — enums, strings, integers, etc. The validator returns
the authenticated identity, which FastAPI injects directly into the route handler.
All auth classes forward extra instantiation keyword arguments to the validator. Arguments can be any type — enums, strings, integers, etc. The validator returns the authenticated identity, which FastAPI injects directly into the route handler.
```python
async def verify_token(token: str, *, role: Role, permission: str) -> User:
@@ -155,14 +152,11 @@ async def verify_token(token: str, *, role: Role, permission: str) -> User:
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
```
Each auth instance is self-contained — create a separate instance per distinct
requirement instead of passing requirements through `Security(scopes=[...])`.
Each auth instance is self-contained — create a separate instance per distinct requirement instead of passing requirements through `Security(scopes=[...])`.
### Using `.require()` inline
If declaring a new top-level variable per role feels verbose, use `.require()` to
create a configured clone directly in the route decorator. The original instance
is not mutated:
If declaring a new top-level variable per role feels verbose, use `.require()` to create a configured clone directly in the route decorator. The original instance is not mutated:
```python
bearer = BearerTokenAuth(verify_token)
@@ -191,13 +185,9 @@ multi = MultiAuth(
## MultiAuth
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines
multiple auth sources into a single callable. Sources are tried in order; the
first one that finds a credential wins.
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines multiple auth sources into a single callable. Sources are tried in order; the first one that finds a credential wins.
If a credential is extracted but the validator raises, the exception propagates
immediately — the remaining sources are **not** tried. This prevents silent
fallthrough on invalid credentials.
If a credential is extracted but the validator raises, the exception propagates immediately — the remaining sources are **not** tried. This prevents silent fallthrough on invalid credentials.
```python
from fastapi_toolsets.security import MultiAuth
@@ -211,9 +201,7 @@ async def data_route(user = Security(multi)):
### Using `.require()` on MultiAuth
`MultiAuth` also supports `.require()`, which propagates the kwargs to every
source that implements it. Sources that do not (e.g. custom `AuthSource`
subclasses) are passed through unchanged:
`MultiAuth` also supports `.require()`, which propagates the kwargs to every source that implements it. Sources that do not (e.g. custom `AuthSource` subclasses) are passed through unchanged:
```python
multi = MultiAuth(bearer, cookie)
@@ -237,9 +225,7 @@ MultiAuth(
### Prefix-based dispatch
Because `extract()` is pure string matching (no I/O), prefix-based source
selection is essentially free. Only the matching source's validator (which may
involve DB or network I/O) is ever called:
Because `extract()` is pure string matching (no I/O), prefix-based source selection is essentially free. Only the matching source's validator (which may involve DB or network I/O) is ever called:
```python
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
@@ -251,8 +237,7 @@ multi = MultiAuth(user_bearer, org_bearer)
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
```
Tokens are stored and compared **with their prefix** — use `generate_token()` on
each source to issue correctly-prefixed tokens:
Tokens are stored and compared **with their prefix** — use `generate_token()` on each source to issue correctly-prefixed tokens:
```python
user_token = user_bearer.generate_token() # "user_..."
@@ -261,9 +246,7 @@ org_token = org_bearer.generate_token() # "org_..."
## Custom auth sources
Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource)
to implement any credential extraction strategy. You only need to implement
`extract()` and `authenticate()`:
Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource) to implement any credential extraction strategy. You only need to implement `extract()` and `authenticate()`:
```python
from fastapi_toolsets.security import AuthSource
@@ -284,16 +267,11 @@ Custom sources work transparently inside `MultiAuth`.
## OAuth 2.0 / OIDC helpers
The module provides standalone async utilities for building OAuth 2.0 / OIDC
login flows. They handle provider discovery, authorization redirects, token
exchange, and state encoding — leaving JWT validation and session management to
your application.
The module provides standalone async utilities for building OAuth 2.0 / OIDC login flows. They handle provider discovery, authorization redirects, token exchange, and state encoding — leaving JWT validation and session management to your application.
### Provider discovery
[`oauth_resolve_provider_urls()`](../reference/security.md#fastapi_toolsets.security.oauth_resolve_provider_urls)
fetches the OIDC discovery document and returns the endpoint URLs. Results are
cached in-process to avoid repeated network calls:
[`oauth_resolve_provider_urls()`](../reference/security.md#fastapi_toolsets.security.oauth_resolve_provider_urls) fetches the OIDC discovery document and returns the endpoint URLs. Results are cached in-process to avoid repeated network calls:
```python
from fastapi_toolsets.security import oauth_resolve_provider_urls
@@ -303,42 +281,51 @@ auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
)
```
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url`
is `None` when the provider does not advertise one.
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` is `None` when the provider does not advertise one.
### Authorization redirect
[`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect)
constructs the redirect to the provider's authorization page. The `destination`
URL (where to send the user after the full flow) is encoded as the `state`
parameter:
[`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect) constructs the redirect to the provider's authorization page. It requires a `nonce` — a random CSRF token generated by [`oauth_generate_nonce()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_nonce) — that must be stored server-side (e.g. in the session) and verified on the callback to prevent login-CSRF attacks (RFC 6749 §10.12):
```python
from fastapi_toolsets.security import oauth_build_authorization_redirect
from fastapi import Request
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_nonce
@app.get("/auth/google/login")
async def google_login():
async def google_login(request: Request):
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
nonce = oauth_generate_nonce()
request.session["oauth_nonce"] = nonce # requires SessionMiddleware
return oauth_build_authorization_redirect(
auth_url,
client_id=GOOGLE_CLIENT_ID,
scopes="openid email profile",
redirect_uri="https://myapp.com/auth/google/callback",
destination="/dashboard",
nonce=nonce,
)
```
### Token exchange and userinfo
[`oauth_fetch_userinfo()`](../reference/security.md#fastapi_toolsets.security.oauth_fetch_userinfo)
performs the two-step exchange: it POSTs the authorization code to the token
endpoint, then GETs the userinfo endpoint with the resulting access token:
[`oauth_fetch_userinfo()`](../reference/security.md#fastapi_toolsets.security.oauth_fetch_userinfo) performs the two-step exchange: it POSTs the authorization code to the token endpoint, then GETs the userinfo endpoint with the resulting access token.
On the callback, retrieve the stored nonce and pass it to [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) to verify the CSRF token before processing the code:
```python
from fastapi_toolsets.security import oauth_fetch_userinfo
from fastapi import HTTPException, Request
from fastapi_toolsets.security import oauth_decode_state, oauth_fetch_userinfo
@app.get("/auth/google/callback")
async def google_callback(code: str, state: str):
async def google_callback(request: Request, code: str, state: str):
# Pop nonce first — single-use, regardless of whether verification succeeds
nonce = request.session.pop("oauth_nonce", None)
if nonce is None:
raise HTTPException(status_code=400, detail="missing OAuth state")
destination = oauth_decode_state(state, expected_nonce=nonce, fallback="/")
if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect
destination = "/"
_, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
userinfo = await oauth_fetch_userinfo(
token_url=token_url,
@@ -347,30 +334,28 @@ async def google_callback(code: str, state: str):
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
redirect_uri="https://myapp.com/auth/google/callback",
required_scopes="openid email profile",
)
user = await db.upsert_user(email=userinfo["email"])
destination = oauth_decode_state(state, fallback="/")
response = RedirectResponse(destination)
session_cookie.set_cookie(response, str(user.id))
return response
```
Pass `required_scopes` to guard against providers silently granting fewer scopes than requested — `oauth_fetch_userinfo` raises `ValueError` if any are missing.
### State encoding
[`oauth_encode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_encode_state)
and
[`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state)
base64url-encode and decode the destination URL embedded in the OAuth `state`
parameter. `oauth_decode_state` handles missing padding and returns the `fallback`
if `state` is absent, `"null"`, or malformed:
[`oauth_encode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_encode_state) and [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) encode and decode the destination URL together with the CSRF nonce embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the nonce does not match:
```python
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
encoded = oauth_encode_state("/dashboard") # e.g. "L2Rhc2hib2FyZA=="
decoded = oauth_decode_state(encoded, fallback="/") # "/dashboard"
decoded = oauth_decode_state(None, fallback="/") # "/"
decoded = oauth_decode_state("null", fallback="/") # "/"
nonce = "my-random-nonce"
encoded = oauth_encode_state("/dashboard", nonce)
decoded = oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") # "/dashboard"
decoded = oauth_decode_state(encoded, expected_nonce="wrong", fallback="/") # "/"
decoded = oauth_decode_state(None, expected_nonce=nonce, fallback="/") # "/"
```
---
+3
View File
@@ -15,6 +15,7 @@ from fastapi_toolsets.security import (
oauth_decode_state,
oauth_encode_state,
oauth_fetch_userinfo,
oauth_generate_nonce,
oauth_resolve_provider_urls,
)
```
@@ -33,6 +34,8 @@ from fastapi_toolsets.security import (
## ::: fastapi_toolsets.security.oauth_fetch_userinfo
## ::: fastapi_toolsets.security.oauth_generate_nonce
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
## ::: fastapi_toolsets.security.oauth_encode_state
-9
View File
@@ -1,9 +0,0 @@
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
from .routes import router
app = FastAPI()
init_exceptions_handlers(app=app)
app.include_router(router=router)
-9
View File
@@ -1,9 +0,0 @@
from fastapi_toolsets.crud import CrudFactory
from .models import OAuthAccount, OAuthProvider, Team, User, UserToken
TeamCrud = CrudFactory(model=Team)
UserCrud = CrudFactory(model=User)
UserTokenCrud = CrudFactory(model=UserToken)
OAuthProviderCrud = CrudFactory(model=OAuthProvider)
OAuthAccountCrud = CrudFactory(model=OAuthAccount)
-15
View File
@@ -1,15 +0,0 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from fastapi_toolsets.db import create_db_context, create_db_dependency
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
engine = create_async_engine(url=DATABASE_URL, future=True)
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
get_db = create_db_dependency(session_maker=async_session_maker)
get_db_context = create_db_context(session_maker=async_session_maker)
SessionDep = Depends(get_db)
-105
View File
@@ -1,105 +0,0 @@
import enum
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from fastapi_toolsets.models import TimestampMixin, UUIDMixin
class Base(DeclarativeBase, UUIDMixin):
type_annotation_map = {
str: String(),
int: Integer(),
UUID: PG_UUID(as_uuid=True),
datetime: DateTime(timezone=True),
}
class UserRole(enum.Enum):
admin = "admin"
moderator = "moderator"
user = "user"
class Team(Base, TimestampMixin):
__tablename__ = "teams"
name: Mapped[str] = mapped_column(String, unique=True, index=True)
users: Mapped[list["User"]] = relationship(back_populates="team")
class User(Base, TimestampMixin):
__tablename__ = "users"
username: Mapped[str] = mapped_column(String, unique=True, index=True)
email: Mapped[str | None] = mapped_column(
String, unique=True, index=True, nullable=True
)
hashed_password: Mapped[str | None] = mapped_column(String, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.user)
team_id: Mapped[UUID | None] = mapped_column(ForeignKey("teams.id"), nullable=True)
team: Mapped["Team | None"] = relationship(back_populates="users")
oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="user")
tokens: Mapped[list["UserToken"]] = relationship(back_populates="user")
class UserToken(Base, TimestampMixin):
"""API tokens for a user (multiple allowed)."""
__tablename__ = "user_tokens"
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"))
# Store hashed token value
token_hash: Mapped[str] = mapped_column(String, unique=True, index=True)
name: Mapped[str | None] = mapped_column(String, nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
user: Mapped["User"] = relationship(back_populates="tokens")
class OAuthProvider(Base, TimestampMixin):
"""Configurable OAuth2 / OpenID Connect provider."""
__tablename__ = "oauth_providers"
slug: Mapped[str] = mapped_column(String, unique=True, index=True)
name: Mapped[str] = mapped_column(String)
client_id: Mapped[str] = mapped_column(String)
client_secret: Mapped[str] = mapped_column(String)
discovery_url: Mapped[str] = mapped_column(String, nullable=False)
scopes: Mapped[str] = mapped_column(String, default="openid email profile")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="provider")
class OAuthAccount(Base, TimestampMixin):
"""OAuth2 / OpenID Connect account linked to a user."""
__tablename__ = "oauth_accounts"
__table_args__ = (
UniqueConstraint("provider_id", "subject", name="uq_oauth_provider_subject"),
)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"))
provider_id: Mapped[UUID] = mapped_column(ForeignKey("oauth_providers.id"))
# OAuth `sub` / OpenID subject identifier
subject: Mapped[str] = mapped_column(String)
user: Mapped["User"] = relationship(back_populates="oauth_accounts")
provider: Mapped["OAuthProvider"] = relationship(back_populates="accounts")
-122
View File
@@ -1,122 +0,0 @@
from typing import Annotated
from uuid import UUID
import bcrypt
from fastapi import APIRouter, Form, HTTPException, Response, Security
from fastapi_toolsets.dependencies import PathDependency
from .crud import UserCrud, UserTokenCrud
from .db import SessionDep
from .models import OAuthProvider, User, UserToken
from .schemas import (
ApiTokenCreateRequest,
ApiTokenResponse,
RegisterRequest,
UserCreate,
UserResponse,
)
from .security import auth, cookie_auth, create_api_token
ProviderDep = PathDependency(
model=OAuthProvider,
field=OAuthProvider.slug,
session_dep=SessionDep,
param_name="slug",
)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
router = APIRouter(prefix="/auth")
@router.post("/register", response_model=UserResponse, status_code=201)
async def register(body: RegisterRequest, session: SessionDep):
existing = await UserCrud.first(
session=session, filters=[User.username == body.username]
)
if existing:
raise HTTPException(status_code=409, detail="Username already taken")
user = await UserCrud.create(
session=session,
obj=UserCreate(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
),
)
return user
@router.post("/token", status_code=204)
async def login(
session: SessionDep,
response: Response,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
):
user = await UserCrud.first(session=session, filters=[User.username == username])
if (
not user
or not user.hashed_password
or not verify_password(password, user.hashed_password)
):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
cookie_auth.set_cookie(response, str(user.id))
@router.post("/logout", status_code=204)
async def logout(response: Response):
cookie_auth.delete_cookie(response)
@router.get("/me", response_model=UserResponse)
async def me(user: User = Security(auth)):
return user
@router.post("/tokens", response_model=ApiTokenResponse, status_code=201)
async def create_token(
body: ApiTokenCreateRequest,
user: User = Security(auth),
):
raw, token_row = await create_api_token(
user.id, name=body.name, expires_at=body.expires_at
)
return ApiTokenResponse(
id=token_row.id,
name=token_row.name,
expires_at=token_row.expires_at,
created_at=token_row.created_at,
token=raw,
)
@router.delete("/tokens/{token_id}", status_code=204)
async def revoke_token(
session: SessionDep,
token_id: UUID,
user: User = Security(auth),
):
if not await UserTokenCrud.first(
session=session,
filters=[UserToken.id == token_id, UserToken.user_id == user.id],
):
raise HTTPException(status_code=404, detail="Token not found")
await UserTokenCrud.delete(
session=session,
filters=[UserToken.id == token_id, UserToken.user_id == user.id],
)
@@ -1,64 +0,0 @@
from datetime import datetime
from uuid import UUID
from pydantic import EmailStr
from fastapi_toolsets.schemas import PydanticBase
class RegisterRequest(PydanticBase):
username: str
password: str
email: EmailStr | None = None
class UserResponse(PydanticBase):
id: UUID
username: str
email: str | None
role: str
is_active: bool
model_config = {"from_attributes": True}
class ApiTokenCreateRequest(PydanticBase):
name: str | None = None
expires_at: datetime | None = None
class ApiTokenResponse(PydanticBase):
id: UUID
name: str | None
expires_at: datetime | None
created_at: datetime
# Only populated on creation
token: str | None = None
model_config = {"from_attributes": True}
class OAuthProviderResponse(PydanticBase):
slug: str
name: str
model_config = {"from_attributes": True}
class UserCreate(PydanticBase):
username: str
email: str | None = None
hashed_password: str | None = None
class UserTokenCreate(PydanticBase):
user_id: UUID
token_hash: str
name: str | None = None
expires_at: datetime | None = None
class OAuthAccountCreate(PydanticBase):
user_id: UUID
provider_id: UUID
subject: str
@@ -1,100 +0,0 @@
import hashlib
from datetime import datetime, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.orm import selectinload
from fastapi_toolsets.exceptions import UnauthorizedError
from fastapi_toolsets.security import (
APIKeyHeaderAuth,
BearerTokenAuth,
CookieAuth,
MultiAuth,
)
from .crud import UserCrud, UserTokenCrud
from .db import get_db_context
from .models import User, UserRole, UserToken
from .schemas import UserTokenCreate
SESSION_COOKIE = "session"
SECRET_KEY = "123456789"
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def _verify_token(token: str, role: UserRole | None = None) -> User:
async with get_db_context() as db:
user_token = await UserTokenCrud.first(
session=db,
filters=[UserToken.token_hash == _hash_token(token)],
load_options=[selectinload(UserToken.user)],
)
if user_token is None or not user_token.user.is_active:
raise UnauthorizedError()
if user_token.expires_at and user_token.expires_at < datetime.now(timezone.utc):
raise UnauthorizedError()
user = user_token.user
if role is not None and user.role != role:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
async def _verify_cookie(user_id: str, role: UserRole | None = None) -> User:
async with get_db_context() as db:
user = await UserCrud.first(
session=db,
filters=[User.id == UUID(user_id)],
)
if not user or not user.is_active:
raise UnauthorizedError()
if role is not None and user.role != role:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
bearer_auth = BearerTokenAuth(
validator=_verify_token,
prefix="ctf_",
)
header_auth = APIKeyHeaderAuth(
name="X-API-Key",
validator=_verify_token,
)
cookie_auth = CookieAuth(
name=SESSION_COOKIE,
validator=_verify_cookie,
secret_key=SECRET_KEY,
)
auth = MultiAuth(bearer_auth, header_auth, cookie_auth)
async def create_api_token(
user_id: UUID,
*,
name: str | None = None,
expires_at: datetime | None = None,
) -> tuple[str, UserToken]:
raw = bearer_auth.generate_token()
async with get_db_context() as db:
token_row = await UserTokenCrud.create(
session=db,
obj=UserTokenCreate(
user_id=user_id,
token_hash=_hash_token(raw),
name=name,
expires_at=expires_at,
),
)
return raw, token_row
+6 -1
View File
@@ -50,13 +50,17 @@ cli = [
metrics = [
"prometheus_client>=0.20.0",
]
security = [
"async-lru>=1.0",
"httpx>=0.25.0",
]
pytest = [
"httpx>=0.25.0",
"pytest-xdist>=3.0.0",
"pytest>=8.0.0",
]
all = [
"fastapi-toolsets[cli,metrics,pytest]",
"fastapi-toolsets[cli,metrics,pytest,security]",
]
[project.scripts]
@@ -73,6 +77,7 @@ dev = [
"ty>=0.0.1a0",
]
tests = [
"async-lru>=1.0",
"coverage>=7.0.0",
"httpx>=0.25.0",
"pytest-anyio>=0.0.0",
@@ -6,6 +6,7 @@ from .oauth import (
oauth_decode_state,
oauth_encode_state,
oauth_fetch_userinfo,
oauth_generate_nonce,
oauth_resolve_provider_urls,
)
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
@@ -20,5 +21,6 @@ __all__ = [
"oauth_decode_state",
"oauth_encode_state",
"oauth_fetch_userinfo",
"oauth_generate_nonce",
"oauth_resolve_provider_urls",
]
+2
View File
@@ -1,5 +1,6 @@
"""Abstract base class for authentication sources."""
import functools
import inspect
from abc import ABC, abstractmethod
from typing import Any, Callable
@@ -15,6 +16,7 @@ def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
if inspect.iscoroutinefunction(fn):
return fn
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs)
+87 -27
View File
@@ -1,15 +1,19 @@
"""OAuth 2.0 / OIDC helper utilities."""
import base64
import binascii
import hmac
import json
import secrets
from typing import Any
from urllib.parse import urlencode
import httpx
from async_lru import alru_cache
from fastapi.responses import RedirectResponse
_discovery_cache: dict[str, dict] = {}
@alru_cache(maxsize=32)
async def oauth_resolve_provider_urls(
discovery_url: str,
) -> tuple[str, str, str | None]:
@@ -22,12 +26,10 @@ async def oauth_resolve_provider_urls(
A ``(authorization_url, token_url, userinfo_url)`` tuple.
*userinfo_url* is ``None`` when the provider does not advertise one.
"""
if discovery_url not in _discovery_cache:
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
_discovery_cache[discovery_url] = resp.json()
cfg = _discovery_cache[discovery_url]
cfg = resp.json()
return (
cfg["authorization_endpoint"],
cfg["token_endpoint"],
@@ -43,14 +45,10 @@ async def oauth_fetch_userinfo(
client_id: str,
client_secret: str,
redirect_uri: str,
required_scopes: str | None = None,
) -> dict[str, Any]:
"""Exchange an authorization code for tokens and return the userinfo payload.
Performs the two-step OAuth 2.0 / OIDC token exchange:
1. POSTs the authorization *code* to *token_url* to obtain an access token.
2. GETs *userinfo_url* using that access token as a Bearer credential.
Args:
token_url: Provider's token endpoint.
userinfo_url: Provider's userinfo endpoint.
@@ -58,9 +56,16 @@ async def oauth_fetch_userinfo(
client_id: OAuth application client ID.
client_secret: OAuth application client secret.
redirect_uri: Redirect URI that was used in the authorization request.
required_scopes: Space-separated scopes that must be present in the token
response ``scope`` field (RFC 6749 §3.3). Raises ``ValueError`` if
the provider granted fewer scopes than requested.
Returns:
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
Raises:
ValueError: If the provider granted a different token type than ``bearer``
or did not grant all ``required_scopes``.
"""
async with httpx.AsyncClient() as client:
token_resp = await client.post(
@@ -75,7 +80,20 @@ async def oauth_fetch_userinfo(
headers={"Accept": "application/json"},
)
token_resp.raise_for_status()
access_token = token_resp.json()["access_token"]
token_data = token_resp.json()
if token_data.get("token_type", "bearer").lower() != "bearer":
raise ValueError(
f"unsupported token_type: {token_data.get('token_type')!r}"
)
if required_scopes is not None:
granted = set(token_data.get("scope", "").split())
missing = set(required_scopes.split()) - granted
if missing:
raise ValueError(f"provider did not grant required scopes: {missing}")
access_token = token_data["access_token"]
userinfo_resp = await client.get(
userinfo_url,
@@ -85,6 +103,16 @@ async def oauth_fetch_userinfo(
return userinfo_resp.json()
def oauth_generate_nonce() -> str:
"""Generate a cryptographically random nonce for use as an OAuth CSRF token.
Call this before :func:`oauth_build_authorization_redirect`, persist the
returned value in the user's session or a ``Secure; HttpOnly; SameSite=Lax``
cookie, then verify it with :func:`oauth_decode_state` on the callback.
"""
return secrets.token_urlsafe(32)
def oauth_build_authorization_redirect(
authorization_url: str,
*,
@@ -92,6 +120,7 @@ def oauth_build_authorization_redirect(
scopes: str,
redirect_uri: str,
destination: str,
nonce: str,
) -> RedirectResponse:
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
@@ -101,7 +130,10 @@ def oauth_build_authorization_redirect(
scopes: Space-separated list of requested scopes.
redirect_uri: URI the provider should redirect back to after authorization.
destination: URL the user should be sent to after the full OAuth flow
completes (encoded as ``state``).
completes (embedded in ``state``).
nonce: CSRF token generated by :func:`oauth_generate_nonce`. Must be
stored server-side (session or signed cookie) and verified via
:func:`oauth_decode_state` on the callback endpoint.
Returns:
A :class:`~fastapi.responses.RedirectResponse` to the provider's
@@ -113,28 +145,56 @@ def oauth_build_authorization_redirect(
"response_type": "code",
"scope": scopes,
"redirect_uri": redirect_uri,
"state": oauth_encode_state(destination),
"state": oauth_encode_state(destination, nonce),
}
)
return RedirectResponse(f"{authorization_url}?{params}")
def oauth_encode_state(url: str) -> str:
"""Base64url-encode a URL to embed as an OAuth ``state`` parameter."""
return base64.urlsafe_b64encode(url.encode()).decode()
def oauth_encode_state(url: str, nonce: str) -> str:
"""Encode a destination URL and CSRF nonce into an OAuth ``state`` parameter.
def oauth_decode_state(state: str | None, *, fallback: str) -> str:
"""Decode a base64url OAuth ``state`` parameter.
Handles missing padding (some providers strip ``=``).
Returns *fallback* if *state* is absent, the literal string ``"null"``,
or cannot be decoded.
Args:
url: Post-login destination URL.
nonce: CSRF token from :func:`oauth_generate_nonce`.
"""
if not state or state == "null":
payload = json.dumps({"n": nonce, "d": url}, separators=(",", ":"))
return base64.urlsafe_b64encode(payload.encode()).decode()
def oauth_decode_state(state: str | None, *, expected_nonce: str, fallback: str) -> str:
"""Decode and CSRF-verify an OAuth ``state`` parameter.
Uses a constant-time comparison for the nonce to prevent timing attacks.
Args:
state: Raw ``state`` query parameter from the provider's callback.
expected_nonce: The nonce stored before the authorization redirect.
If the decoded nonce does not match, ``fallback`` is returned.
fallback: URL to return when ``state`` is absent, malformed, or fails
CSRF verification.
Returns:
The destination URL embedded in ``state``, or ``fallback``.
Important:
**Single-use**: delete the stored nonce from the session immediately
after calling this function — whether it matched or not — so that a
captured callback URL cannot be replayed.
**Open-redirect**: validate the returned URL against a known-good
origin or relative-path allowlist before issuing the final redirect.
Do not forward arbitrary URLs to ``RedirectResponse``.
"""
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
return fallback
try:
padded = state + "=" * (4 - len(state) % 4)
return base64.urlsafe_b64decode(padded).decode()
except Exception:
padded = state + "=" * (-len(state) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
if not isinstance(payload, dict) or not hmac.compare_digest(
payload.get("n", "").encode(), expected_nonce.encode()
):
return fallback
return str(payload["d"])
except (UnicodeDecodeError, ValueError, binascii.Error, KeyError):
return fallback
@@ -4,7 +4,7 @@ import inspect
import secrets
from typing import Annotated, Any, Callable
from fastapi import Depends
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
@@ -66,7 +66,7 @@ class BearerTokenAuth(AuthSource):
raise UnauthorizedError()
return await self._validator(token, **self._kwargs)
async def extract(self, request: Any) -> str | None:
async def extract(self, request: Request) -> str | None:
"""Extract the raw credential from the request without validating.
Returns ``None`` if no ``Authorization: Bearer`` header is present,
@@ -36,6 +36,9 @@ class CookieAuth(AuthSource):
cookie value is passed to the validator as-is.
ttl: Cookie lifetime in seconds (default 24 h). Only used when
``secret_key`` is set.
secure: Set the ``Secure`` flag on the cookie so it is only transmitted
over HTTPS (default ``True``). Set to ``False`` only in local
development environments where HTTPS is unavailable.
**kwargs: Extra keyword arguments forwarded to the validator on every
call (e.g. ``role=Role.ADMIN``).
"""
@@ -47,12 +50,14 @@ class CookieAuth(AuthSource):
*,
secret_key: str | None = None,
ttl: int = 86400,
secure: bool = True,
**kwargs: Any,
) -> None:
self._name = name
self._validator = _ensure_async(validator)
self._secret_key = secret_key
self._ttl = ttl
self._secure = secure
self._kwargs = kwargs
self._scheme = APIKeyCookie(name=name, auto_error=False)
@@ -120,6 +125,7 @@ class CookieAuth(AuthSource):
self._validator,
secret_key=self._secret_key,
ttl=self._ttl,
secure=self._secure,
**{**self._kwargs, **kwargs},
)
@@ -131,9 +137,12 @@ class CookieAuth(AuthSource):
cookie_value,
httponly=True,
samesite="lax",
secure=self._secure,
max_age=self._ttl,
)
def delete_cookie(self, response: Response) -> None:
"""Clear the session cookie (logout)."""
response.delete_cookie(self._name, httponly=True, samesite="lax")
response.delete_cookie(
self._name, httponly=True, samesite="lax", secure=self._secure
)
+1 -49
View File
@@ -14,42 +14,8 @@ from ..abc import AuthSource
class MultiAuth:
"""Combine multiple authentication sources into a single callable.
Sources are tried in order; the first one whose
:meth:`~AuthSource.extract` returns a non-``None`` credential wins.
Its :meth:`~AuthSource.authenticate` is called and the result returned.
If a credential is found but the validator raises, the exception propagates
immediately — the remaining sources are **not** tried. This prevents
silent fallthrough on invalid credentials.
If no source provides a credential,
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` is raised.
The :meth:`~AuthSource.extract` method of each source performs only
string matching (no I/O), so prefix-based dispatch is essentially free.
Any :class:`~AuthSource` subclass — including user-defined ones — can be
passed as a source.
Args:
*sources: Auth source instances to try in order.
Example::
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
cookie = CookieAuth("session", verify_session)
multi = MultiAuth(user_bearer, org_bearer, cookie)
@app.get("/data")
async def data_route(user = Security(multi)):
return user
# Apply a shared requirement to all sources at once
@app.get("/admin")
async def admin_route(user = Security(multi.require(role=Role.ADMIN))):
return user
"""
def __init__(self, *sources: AuthSource) -> None:
@@ -95,21 +61,7 @@ class MultiAuth:
return await self._call_fn(**kwargs)
def require(self, **kwargs: Any) -> "MultiAuth":
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source.
Calls ``.require(**kwargs)`` on every source that supports it. Sources
that do not implement ``.require()`` (e.g. custom :class:`~AuthSource`
subclasses) are passed through unchanged.
New kwargs are merged over each source's existing kwargs — new values
win on conflict::
multi = MultiAuth(bearer, cookie)
@app.get("/admin")
async def admin(user = Security(multi.require(role=Role.ADMIN))):
return user
"""
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
new_sources = tuple(
cast(Any, source).require(**kwargs)
if hasattr(source, "require")
+163 -12
View File
@@ -18,6 +18,7 @@ from fastapi_toolsets.security import (
oauth_decode_state,
oauth_encode_state,
oauth_fetch_userinfo,
oauth_generate_nonce,
oauth_resolve_provider_urls,
)
@@ -760,7 +761,10 @@ class TestCookieAuthSigned:
"""set_cookie signs the value; the signed cookie is verified on read."""
from fastapi import Response
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
# secure=False for test client which runs over plain HTTP
auth = CookieAuth(
"session", cookie_validator, secret_key=self.SECRET, secure=False
)
def setup(app: FastAPI):
@app.get("/login")
@@ -778,6 +782,26 @@ class TestCookieAuthSigned:
assert response.status_code == 200
assert response.json() == {"session": VALID_COOKIE}
def test_set_cookie_has_secure_flag_by_default(self):
"""set_cookie includes Secure flag when secure=True (the default)."""
from starlette.responses import Response as StarletteResponse
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
response = StarletteResponse()
auth.set_cookie(response, "value")
assert "secure" in response.headers["set-cookie"].lower()
def test_set_cookie_no_secure_flag_when_disabled(self):
"""set_cookie omits Secure flag when secure=False (local dev)."""
from starlette.responses import Response as StarletteResponse
auth = CookieAuth(
"session", cookie_validator, secret_key=self.SECRET, secure=False
)
response = StarletteResponse()
auth.set_cookie(response, "value")
assert "secure" not in response.headers["set-cookie"].lower()
def test_tampered_signature_returns_401(self):
"""A cookie whose HMAC signature has been modified is rejected."""
import base64 as _b64
@@ -989,28 +1013,56 @@ def _make_async_client_mock(get_return=None, post_return=None):
class TestEncodeDecodeOAuthState:
def test_encode_returns_base64url_string(self):
result = oauth_encode_state("https://example.com/dashboard")
result = oauth_encode_state("https://example.com/dashboard", "test-nonce")
assert isinstance(result, str)
assert "+" not in result
assert "/" not in result
def test_round_trip(self):
url = "https://example.com/after-login?next=/home"
assert oauth_decode_state(oauth_encode_state(url), fallback="/") == url
nonce = "test-nonce"
assert (
oauth_decode_state(
oauth_encode_state(url, nonce), expected_nonce=nonce, fallback="/"
)
== url
)
def test_decode_none_returns_fallback(self):
assert oauth_decode_state(None, fallback="/home") == "/home"
assert (
oauth_decode_state(None, expected_nonce="any", fallback="/home") == "/home"
)
def test_decode_null_string_returns_fallback(self):
assert oauth_decode_state("null", fallback="/home") == "/home"
assert (
oauth_decode_state("null", expected_nonce="any", fallback="/home")
== "/home"
)
def test_decode_invalid_base64_returns_fallback(self):
assert oauth_decode_state("!!!notbase64!!!", fallback="/home") == "/home"
assert (
oauth_decode_state(
"!!!notbase64!!!", expected_nonce="any", fallback="/home"
)
== "/home"
)
def test_decode_handles_missing_padding(self):
url = "https://example.com/x"
encoded = oauth_encode_state(url).rstrip("=")
assert oauth_decode_state(encoded, fallback="/") == url
nonce = "test-nonce"
encoded = oauth_encode_state(url, nonce).rstrip("=")
assert oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") == url
def test_decode_wrong_nonce_returns_fallback(self):
url = "https://example.com/dashboard"
encoded = oauth_encode_state(url, "correct-nonce")
assert (
oauth_decode_state(encoded, expected_nonce="wrong-nonce", fallback="/")
== "/"
)
def test_generate_nonce_is_random(self):
assert oauth_generate_nonce() != oauth_generate_nonce()
class TestBuildAuthorizationRedirect:
@@ -1023,16 +1075,19 @@ class TestBuildAuthorizationRedirect:
scopes="openid email",
redirect_uri="https://app.example.com/callback",
destination="https://app.example.com/dashboard",
nonce="test-nonce",
)
assert isinstance(response, RedirectResponse)
def test_redirect_location_contains_all_params(self):
nonce = "test-nonce"
response = oauth_build_authorization_redirect(
"https://auth.example.com/authorize",
client_id="my-client",
scopes="openid email",
redirect_uri="https://app.example.com/callback",
destination="https://app.example.com/dashboard",
nonce=nonce,
)
location = response.headers["location"]
parsed = urlparse(location)
@@ -1046,7 +1101,7 @@ class TestBuildAuthorizationRedirect:
assert params["scope"] == ["openid email"]
assert params["redirect_uri"] == ["https://app.example.com/callback"]
assert (
oauth_decode_state(params["state"][0], fallback="")
oauth_decode_state(params["state"][0], expected_nonce=nonce, fallback="")
== "https://app.example.com/dashboard"
)
@@ -1068,7 +1123,7 @@ class TestResolveProviderUrls:
mock_resp.json.return_value = self._discovery()
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
oauth_resolve_provider_urls.cache_clear()
with patch("httpx.AsyncClient", return_value=cm):
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
"https://auth.example.com/.well-known/openid-configuration"
@@ -1085,7 +1140,7 @@ class TestResolveProviderUrls:
mock_resp.json.return_value = self._discovery(userinfo=False)
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
oauth_resolve_provider_urls.cache_clear()
with patch("httpx.AsyncClient", return_value=cm):
_, _, userinfo_url = await oauth_resolve_provider_urls(
"https://auth.example.com/.well-known/openid-configuration"
@@ -1101,7 +1156,7 @@ class TestResolveProviderUrls:
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
url = "https://auth.example.com/.well-known/openid-configuration"
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
oauth_resolve_provider_urls.cache_clear()
with patch("httpx.AsyncClient", return_value=cm):
await oauth_resolve_provider_urls(url)
await oauth_resolve_provider_urls(url)
@@ -1178,3 +1233,99 @@ class TestFetchUserinfo:
"https://auth.example.com/userinfo",
headers={"Authorization": "Bearer tok123"},
)
@pytest.mark.anyio
async def test_raises_on_unsupported_token_type(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {"access_token": "tok123", "token_type": "mac"}
cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock())
with patch("httpx.AsyncClient", return_value=cm):
with pytest.raises(ValueError, match="unsupported token_type"):
await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
)
@pytest.mark.anyio
async def test_accepts_bearer_token_type_case_insensitive(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {
"access_token": "tok123",
"token_type": "Bearer",
}
userinfo_resp = MagicMock()
userinfo_resp.raise_for_status = MagicMock()
userinfo_resp.json.return_value = {"sub": "user-1"}
cm, _ = _make_async_client_mock(
post_return=token_resp, get_return=userinfo_resp
)
with patch("httpx.AsyncClient", return_value=cm):
result = await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
)
assert result == {"sub": "user-1"}
@pytest.mark.anyio
async def test_raises_when_required_scopes_not_granted(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {"access_token": "tok123", "scope": "openid"}
cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock())
with patch("httpx.AsyncClient", return_value=cm):
with pytest.raises(ValueError, match="required scopes"):
await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
required_scopes="openid email profile",
)
@pytest.mark.anyio
async def test_passes_when_all_required_scopes_granted(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {
"access_token": "tok123",
"scope": "openid email profile",
}
userinfo_resp = MagicMock()
userinfo_resp.raise_for_status = MagicMock()
userinfo_resp.json.return_value = {"sub": "user-1", "email": "a@b.com"}
cm, _ = _make_async_client_mock(
post_return=token_resp, get_return=userinfo_resp
)
with patch("httpx.AsyncClient", return_value=cm):
result = await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
required_scopes="openid email",
)
assert result["email"] == "a@b.com"
Generated
+64 -43
View File
@@ -33,6 +33,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "async-lru"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
@@ -305,7 +314,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.136.0"
version = "0.136.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -314,9 +323,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -332,6 +341,7 @@ dependencies = [
[package.optional-dependencies]
all = [
{ name = "async-lru" },
{ name = "httpx" },
{ name = "prometheus-client" },
{ name = "pytest" },
@@ -349,9 +359,14 @@ pytest = [
{ name = "pytest" },
{ name = "pytest-xdist" },
]
security = [
{ name = "async-lru" },
{ name = "httpx" },
]
[package.dev-dependencies]
dev = [
{ name = "async-lru" },
{ name = "bcrypt" },
{ name = "coverage" },
{ name = "fastapi-toolsets", extra = ["all"] },
@@ -376,6 +391,7 @@ docs-src = [
{ name = "bcrypt" },
]
tests = [
{ name = "async-lru" },
{ name = "coverage" },
{ name = "httpx" },
{ name = "pytest" },
@@ -386,10 +402,12 @@ tests = [
[package.metadata]
requires-dist = [
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
{ name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" },
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
@@ -397,10 +415,11 @@ requires-dist = [
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
]
provides-extras = ["cli", "metrics", "pytest", "all"]
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
[package.metadata.requires-dev]
dev = [
{ name = "async-lru", specifier = ">=1.0" },
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "coverage", specifier = ">=7.0.0" },
{ name = "fastapi-toolsets", extras = ["all"] },
@@ -423,6 +442,7 @@ docs = [
]
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
tests = [
{ name = "async-lru", specifier = ">=1.0" },
{ name = "coverage", specifier = ">=7.0.0" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "pytest", specifier = ">=8.0.0" },
@@ -816,35 +836,35 @@ wheels = [
[[package]]
name = "prek"
version = "0.3.11"
version = "0.3.13"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/13/24b0288c553dc8d61f44c4d0746fe9bb1e1bd29d1e70571658536e4c0f72/prek-0.3.11-py3-none-win_arm64.whl", hash = "sha256:e4a8f900378a6657c7eb2fc4b12fa5c934edf209d0a24544539842479ec16e0b", size = 5345988, upload-time = "2026-04-27T04:22:50.918Z" },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1333,7 +1353,7 @@ wheels = [
[[package]]
name = "typer"
version = "0.25.0"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -1341,9 +1361,9 @@ dependencies = [
{ name = "rich" },
{ name = "shellingham" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1405,29 +1425,30 @@ wheels = [
[[package]]
name = "zensical"
version = "0.0.37"
version = "0.0.40"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "deepmerge" },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "pygments" },
{ name = "pymdown-extensions" },
{ name = "pyyaml" },
{ name = "tomli" },
]
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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" },
{ 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/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/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/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/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/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/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/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/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/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/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/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" },
]