Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ jobs:
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.13"
- run: uv sync --dev
- run: uv sync --dev --all-extras
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy --strict fastapi_fullauth

test:
runs-on: ubuntu-latest
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Changelog

## 0.9.2

### Security

- `/auth/refresh` is now rate-limited via `AUTH_RATE_LIMIT_REFRESH` (default 30 req/min per IP). Without this, an attacker holding a stolen refresh token could hammer the endpoint for fresh access tokens, or use the response shape as a token-validation oracle. The default sits well above legitimate usage (a single user typically refreshes a handful of times per session) but caps abuse.

### Fixed

- `/auth/refresh` was passing `str(user.id)` to `RefreshToken(user_id=...)` which expects `UUID`. Pydantic v2 coerced silently so there was no runtime break, but the path now passes `user.id` directly — consistent with `flows/login.py` and clean under static type checking.
- `LoginResponse` is now a real subclass of `TokenPair` with `user: UserSchema | None = None` instead of a dynamically created model with no static type. The dynamic factory still narrows the `user` field to the configured user schema for OpenAPI, but `LoginResponse(...)` calls now type-check cleanly in mypy/pyright.
- `DELETE /oauth/accounts/{provider}` was calling `delete_oauth_account(provider, user.id)` — passing the local user UUID where the provider's `provider_user_id` (e.g. a Google subject ID) was expected. The query never matched, so unlinking silently no-op'd. Now resolves the OAuth account for the current user first and passes the right `provider_user_id`. Returns `404` if the user doesn't have an account on that provider.
- `FullAuth.get_custom_claims` annotated as `-> dict[str, Any]` instead of bare `dict`.
- Passkey and OAuth flows now pass `user.id` (UUID) to `RefreshToken(user_id=...)` instead of `str(user.id)`, matching the password login path.
- OAuth state without a stored `redirect_uri` now falls back to `provider.redirect_uris[0]` instead of passing `None` to `provider.exchange_code(...)`. Test mocks were unaffected; production callers always set it.

### Changed

- **Strict mypy is now clean** across the entire codebase. The 184 strict-mode errors that had been parked for a future typed-hardening release are gone — `uv run mypy --strict fastapi_fullauth` passes 0/0. Mostly mechanical: missing `dict` type args, missing parameter/return annotations, `hash_password(algorithm=...)` Literal at call sites, ASGI middleware app/call_next types. Mixin-method calls through `AbstractUserAdapter` are now narrowed via `cast()` to the appropriate `RoleAdapterMixin` / `PermissionAdapterMixin` / `OAuthAdapterMixin` at each call site. SQLAlchemy 2.0 / SQLModel column-comparison stub limitations (`where(self.user_model.id == user_id)` types as `bool` instead of `ColumnElement[bool]`) are scoped to the two adapter modules via a focused `[[tool.mypy.overrides]]` block — the rest of the codebase stays strict.
- CI now runs `mypy --strict` on every push and PR to keep the type surface clean.
- `Development Status` classifier bumped from `3 - Alpha` to `4 - Beta`. Reflects 189 tests passing, multi-version CI on Python 3.10–3.14, OIDC-based PyPI publishing, the security hardening trail through 0.7.0–0.9.1, and `py.typed` shipped. Reserved `5 - Production/Stable` for v1.0.
- Added `Operating System :: OS Independent` classifier (CI runs on Linux and Windows).
- All dependency floors bumped to current stable versions: `fastapi>=0.136`, `pydantic[email]>=2.13`, `pydantic-settings>=2.14`, `pyjwt>=2.12`, `argon2-cffi>=25.1`, plus extras (`sqlalchemy>=2.0.49`, `alembic>=1.18`, `sqlmodel>=0.0.38`, `redis>=7.4`, `httpx>=0.28`, `webauthn>=2.7`).
- Version is now read dynamically from `fastapi_fullauth/__init__.py` via `[tool.hatch.version]` so a bump only touches one file.
- `[tool.pytest.ini_options]` migrated to `[tool.pytest]` (pytest 9 supports the flat key).
- `pytest-cov` added to the dev dependency group so contributors can run `uv run pytest --cov=fastapi_fullauth` locally.

## 0.9.1

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ Grouped for readability. All read from env with `FULLAUTH_` prefix.
- `AUTH_RATE_LIMIT_REGISTER: int = 3`
- `AUTH_RATE_LIMIT_PASSWORD_RESET: int = 3`
- `AUTH_RATE_LIMIT_PASSKEY_AUTH: int = 10`
- `AUTH_RATE_LIMIT_REFRESH: int = 30`
- `AUTH_RATE_LIMIT_WINDOW_SECONDS: int = 60`

### Redis
Expand Down
2 changes: 1 addition & 1 deletion fastapi_fullauth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "0.9.1"
__version__ = "0.9.2"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align package version with the intended release target.

Given dynamic versioning now reads from this file, keeping 0.9.2 will stamp builds as 0.9.2. PR objectives indicate this should roll into 0.10.0, so this should be updated before merge to avoid accidental mis-versioned publish.

Suggested fix
-__version__ = "0.9.2"
+__version__ = "0.10.0"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
__version__ = "0.9.2"
__version__ = "0.10.0"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastapi_fullauth/__init__.py` at line 1, Update the package version constant
__version__ in fastapi_fullauth/__init__.py from "0.9.2" to the intended release
"0.10.0" so dynamic versioning and builds reflect the correct release target.


from fastapi_fullauth.config import FullAuthConfig
from fastapi_fullauth.fullauth import FullAuth
Expand Down
2 changes: 1 addition & 1 deletion fastapi_fullauth/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
# when sqlalchemy/sqlmodel are not installed


def __getattr__(name: str):
def __getattr__(name: str) -> object:
if name == "SQLAlchemyAdapter":
from fastapi_fullauth.adapters.sqlalchemy import SQLAlchemyAdapter

Expand Down
2 changes: 1 addition & 1 deletion fastapi_fullauth/adapters/sqlalchemy/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
__all__ = list(_LAZY_IMPORTS.keys())


def __getattr__(name: str):
def __getattr__(name: str) -> object:
module_path = _LAZY_IMPORTS.get(name)
if module_path is not None:
import importlib
Expand Down
2 changes: 1 addition & 1 deletion fastapi_fullauth/adapters/sqlmodel/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
__all__ = list(_LAZY_IMPORTS.keys())


def __getattr__(name: str):
def __getattr__(name: str) -> object:
module_path = _LAZY_IMPORTS.get(name)
if module_path is not None:
import importlib
Expand Down
4 changes: 3 additions & 1 deletion fastapi_fullauth/backends/cookie.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

from fastapi import Request, Response

from fastapi_fullauth.backends.base import AbstractBackend
Expand Down Expand Up @@ -25,7 +27,7 @@ async def delete_token(self, response: Response) -> None:
# a SameSite=None set-cookie without Secure.
response.delete_cookie(key=self.config.COOKIE_NAME, **self._cookie_attrs())

def _cookie_attrs(self) -> dict:
def _cookie_attrs(self) -> dict[str, Any]:
return {
"httponly": self.config.COOKIE_HTTPONLY,
"secure": self.config.COOKIE_SECURE,
Expand Down
1 change: 1 addition & 0 deletions fastapi_fullauth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class FullAuthConfig(BaseSettings):
AUTH_RATE_LIMIT_REGISTER: int = 3
AUTH_RATE_LIMIT_PASSWORD_RESET: int = 3
AUTH_RATE_LIMIT_PASSKEY_AUTH: int = 10
AUTH_RATE_LIMIT_REFRESH: int = 30
AUTH_RATE_LIMIT_WINDOW_SECONDS: int = 60

REDIS_URL: str | None = None
Expand Down
8 changes: 6 additions & 2 deletions fastapi_fullauth/core/challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
import logging
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from fastapi_fullauth.config import FullAuthConfig

logger = logging.getLogger("fastapi_fullauth.challenges")

Expand Down Expand Up @@ -69,7 +73,7 @@ async def store(self, key: str, challenge: str, ttl: int = 60) -> None:

async def pop(self, key: str) -> str | None:
redis_key = f"{self._prefix}{key}"
challenge = await self._redis.getdel(redis_key)
challenge: str | None = await self._redis.getdel(redis_key)
return challenge


Expand All @@ -84,7 +88,7 @@ def register_challenge_store_backend(name: str, cls: type[ChallengeStore]) -> No
_challenge_store_registry[name] = cls


def create_challenge_store(config) -> ChallengeStore:
def create_challenge_store(config: "FullAuthConfig") -> ChallengeStore:
"""Create a challenge store based on config."""
backend = config.PASSKEY_CHALLENGE_BACKEND
backend_cls = _challenge_store_registry.get(backend)
Expand Down
8 changes: 5 additions & 3 deletions fastapi_fullauth/core/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ def hash_password(password: str, algorithm: Literal["argon2id", "bcrypt"] = "arg
f"bcrypt passwords must be at most {_BCRYPT_MAX_BYTES} bytes when "
"UTF-8 encoded. Use argon2id for longer passwords."
)
import bcrypt
import bcrypt # type: ignore[import-not-found]

return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
return hashed.decode()
return _argon2_hasher.hash(password)


Expand All @@ -30,7 +31,8 @@ def verify_password(plain: str, hashed: str) -> bool:
try:
import bcrypt

return bcrypt.checkpw(plain.encode(), hashed.encode())
ok: bool = bcrypt.checkpw(plain.encode(), hashed.encode())
return ok
except ImportError:
return False
try:
Expand Down
2 changes: 1 addition & 1 deletion fastapi_fullauth/core/redis_blacklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ async def add(self, jti: str, ttl_seconds: int | None = None) -> None:
)

async def is_blacklisted(self, jti: str) -> bool:
return await self._redis.exists(f"{self._prefix}{jti}") > 0
return bool(await self._redis.exists(f"{self._prefix}{jti}") > 0)
15 changes: 11 additions & 4 deletions fastapi_fullauth/core/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any

import jwt

Expand Down Expand Up @@ -47,15 +48,17 @@ def create_access_token(
self,
user_id: str,
roles: list[str] | None = None,
extra: dict | None = None,
extra: dict[str, Any] | None = None,
expire_seconds: int | None = None,
) -> str:
if self.config.SECRET_KEY is None:
raise RuntimeError("SECRET_KEY must be set to create tokens")
now = datetime.now(timezone.utc)
if expire_seconds is not None:
expires = now + timedelta(seconds=expire_seconds)
else:
expires = now + timedelta(minutes=self.config.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
payload: dict[str, Any] = {
"sub": user_id,
"exp": expires,
"iat": now,
Expand All @@ -71,10 +74,12 @@ def create_refresh_token(
user_id: str,
family_id: str | None = None,
) -> RefreshTokenMeta:
if self.config.SECRET_KEY is None:
raise RuntimeError("SECRET_KEY must be set to create tokens")
now = datetime.now(timezone.utc)
expires_at = now + timedelta(days=self.config.REFRESH_TOKEN_EXPIRE_DAYS)
resolved_family_id = family_id or uuid.uuid4().hex
payload = {
payload: dict[str, Any] = {
"sub": user_id,
"exp": expires_at,
"iat": now,
Expand All @@ -86,6 +91,8 @@ def create_refresh_token(
return RefreshTokenMeta(token=token, expires_at=expires_at, family_id=resolved_family_id)

async def decode_token(self, token: str) -> TokenPayload:
if self.config.SECRET_KEY is None:
raise RuntimeError("SECRET_KEY must be set to decode tokens")
try:
data = jwt.decode(
token,
Expand Down Expand Up @@ -123,7 +130,7 @@ def create_token_pair(
self,
user_id: str,
roles: list[str] | None = None,
extra: dict | None = None,
extra: dict[str, Any] | None = None,
family_id: str | None = None,
) -> tuple[str, RefreshTokenMeta]:
access = self.create_access_token(user_id, roles, extra)
Expand Down
15 changes: 11 additions & 4 deletions fastapi_fullauth/dependencies/current_user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import TYPE_CHECKING, Annotated, cast
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Annotated, Any, cast
from uuid import UUID

from fastapi import Depends, Request
Expand Down Expand Up @@ -87,7 +88,9 @@ async def current_superuser(
SuperUser = Annotated[UserSchema, Depends(current_superuser)]


def get_current_user_dependency(user_type: type[UserSchemaType]):
def get_current_user_dependency(
user_type: type[UserSchemaType],
) -> Callable[..., Coroutine[Any, Any, UserSchemaType]]:
"""Create a typed current_user dependency for custom user schemas.

Usage::
Expand Down Expand Up @@ -121,7 +124,9 @@ async def _current_user(
return _current_user


def get_verified_user_dependency(user_type: type[UserSchemaType]):
def get_verified_user_dependency(
user_type: type[UserSchemaType],
) -> Callable[..., Coroutine[Any, Any, UserSchemaType]]:
"""Create a typed verified-user dependency for custom user schemas."""
_current = get_current_user_dependency(user_type)

Expand All @@ -137,7 +142,9 @@ async def _dep(
return _dep


def get_superuser_dependency(user_type: type[UserSchemaType]):
def get_superuser_dependency(
user_type: type[UserSchemaType],
) -> Callable[..., Coroutine[Any, Any, UserSchemaType]]:
"""Create a typed superuser dependency for custom user schemas."""
_current = get_current_user_dependency(user_type)

Expand Down
11 changes: 7 additions & 4 deletions fastapi_fullauth/dependencies/require_role.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from typing import TYPE_CHECKING
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any, cast

from fastapi import Depends

from fastapi_fullauth.adapters.base import PermissionAdapterMixin
from fastapi_fullauth.dependencies.current_user import _get_fullauth, current_user
from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
from fastapi_fullauth.types import UserSchema
Expand All @@ -10,7 +12,7 @@
from fastapi_fullauth.fullauth import FullAuth


def require_role(*roles: str):
def require_role(*roles: str) -> Callable[..., Coroutine[Any, Any, UserSchema]]:
"""Dependency that checks the user has at least one of the given roles."""

async def _dep(
Expand All @@ -30,7 +32,7 @@ async def _dep(
return _dep


def require_permission(*permissions: str):
def require_permission(*permissions: str) -> Callable[..., Coroutine[Any, Any, UserSchema]]:
"""Dependency that checks the user has at least one of the given permissions.

Permissions use the format 'resource:action' (e.g. 'posts:delete').
Expand All @@ -44,7 +46,8 @@ async def _dep(
if user.is_superuser:
return user

user_perms = await fullauth.adapter.get_user_permissions(user.id)
adapter = cast("PermissionAdapterMixin", fullauth.adapter)
user_perms = await adapter.get_user_permissions(user.id)
if not set(permissions).intersection(user_perms):
raise FORBIDDEN_EXCEPTION

Expand Down
3 changes: 2 additions & 1 deletion fastapi_fullauth/flows/change_password.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from typing import Literal

from fastapi_fullauth.adapters.base import AbstractUserAdapter
from fastapi_fullauth.core.crypto import hash_password, verify_password
Expand All @@ -14,7 +15,7 @@ async def change_password(
user_id: UserID,
current_password: str,
new_password: str,
hash_algorithm: str = "argon2id",
hash_algorithm: Literal["argon2id", "bcrypt"] = "argon2id",
password_validator: PasswordValidator | None = None,
) -> None:
hashed = await adapter.get_hashed_password(user_id)
Expand Down
5 changes: 3 additions & 2 deletions fastapi_fullauth/flows/login.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from typing import Any, Literal

from fastapi_fullauth.adapters.base import AbstractUserAdapter
from fastapi_fullauth.core.crypto import hash_password, password_needs_rehash, verify_password
Expand All @@ -17,9 +18,9 @@ async def login(
password: str,
login_field: str = "email",
lockout: LockoutManager | None = None,
extra_claims: dict | None = None,
extra_claims: dict[str, Any] | None = None,
user: UserSchema | None = None,
hash_algorithm: str = "argon2id",
hash_algorithm: Literal["argon2id", "bcrypt"] = "argon2id",
) -> TokenPair:
if lockout and await lockout.is_locked(identifier):
logger.warning("Login blocked — account locked: %s", identifier)
Expand Down
Loading
Loading