Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
a70cfcd
Update project version
serhiiur Oct 17, 2025
133f72c
Update project version
serhiiur Oct 17, 2025
39edb4a
Update title and description settings for FastAPI app
serhiiur Oct 17, 2025
6a2e5fc
Update project description
serhiiur Oct 17, 2025
3168074
Update project name
serhiiur Oct 17, 2025
1520507
Disable debug mode by default
serhiiur Oct 17, 2025
f64232e
Update value of admin_debug parameter
serhiiur Oct 17, 2025
0b0f4ab
Reset value of jwt_secret_key
serhiiur Oct 17, 2025
f580dfa
Fix code style errors
serhiiur Oct 17, 2025
799cc3e
Change ruff ignore rules for alembic
serhiiur Oct 17, 2025
1c084d7
Rename variable to specify the database engine object
serhiiur Oct 17, 2025
774fb72
Change values of the constants to be auto
serhiiur Oct 17, 2025
6688568
Move general error handlers into a separate file
serhiiur Oct 17, 2025
aee15b5
Update list of error handlers for FastAPI app
serhiiur Oct 17, 2025
d1b20e0
Move general error handlers to app.main.errors
serhiiur Oct 17, 2025
dc4fc6f
Define a dict of error handlers of the user module
serhiiur Oct 17, 2025
7af5f54
Update list of error handlers for FastAPI app
serhiiur Oct 17, 2025
6475d7e
Fix incorrect name of the project
serhiiur Oct 17, 2025
7bc1212
Remove file containing definition of the Redis client
serhiiur Oct 17, 2025
9f9db08
Add separate settings for Redis client
serhiiur Oct 17, 2025
c8ab2c2
Update state objects declaration in the lifespan
serhiiur Oct 17, 2025
f5ceebe
Add comment
serhiiur Oct 17, 2025
dd12860
Update docstring
serhiiur Oct 17, 2025
09029eb
Init module
serhiiur Oct 17, 2025
c350b36
Move functions to manage user passwords to a separate file
serhiiur Oct 17, 2025
487e295
Add helper function to return cached instance of CryptContext
serhiiur Oct 17, 2025
6bfc6de
Change import path for verify_password function
serhiiur Oct 17, 2025
a760d46
Change import path for verify_password function
serhiiur Oct 17, 2025
6682e16
Change import path for verify_password function
serhiiur Oct 17, 2025
d032b75
Move functions to manage JWT
serhiiur Oct 17, 2025
e4696e2
Move functions to manage JWT to .security.tokens
serhiiur Oct 17, 2025
aa3b03f
Change import path for get_token_payload function
serhiiur Oct 17, 2025
447e3e0
Change import path for get_token_payload function
serhiiur Oct 17, 2025
1c247e1
Move functions to manage user policies
serhiiur Oct 17, 2025
b1ab78e
Move functions to manage user policies to .security.rbac
serhiiur Oct 17, 2025
2756465
Change import path for verify_access function
serhiiur Oct 17, 2025
059cfb9
Remove fixture to provide fake async redis client
serhiiur Oct 17, 2025
249ee6e
Override logger of the main app
serhiiur Oct 17, 2025
4507a2d
Remove constant for enabling E2E mode
serhiiur Oct 17, 2025
807ab87
Set base url used by the test clients
serhiiur Oct 17, 2025
60ec4fe
Replace constant with the marker to run E2E tests
serhiiur Oct 17, 2025
302cb3a
Replace constant with the marker to run E2E tests
serhiiur Oct 17, 2025
a2eb72e
Update command to run tests using pytest
serhiiur Oct 17, 2025
964b6ab
Replace constants to specify types of JWT and cookies
serhiiur Oct 18, 2025
504b783
Replace static names of JWT with related constants
serhiiur Oct 18, 2025
3588f6c
Replace static names of JWT with related constants
serhiiur Oct 18, 2025
3b1fcd5
Replace static names of JWT with related constants
serhiiur Oct 18, 2025
e0f973f
Replace static names of JWT with related constants
serhiiur Oct 18, 2025
67e8fc6
Replace constant to specify types of JWT
serhiiur Oct 18, 2025
1788770
Replace static names of JWT with related constants
serhiiur Oct 18, 2025
bb7b3c3
Update steps to login the user
serhiiur Oct 18, 2025
b2c2f4a
Add step to check if the user is active or not in authenticate_user d…
serhiiur Oct 18, 2025
5b927a2
Update returned http status code of /signup route
serhiiur Oct 18, 2025
868fee1
Update returned http status code of /signup route
serhiiur Oct 18, 2025
95ba66b
Update method to retrieve info about user from db
serhiiur Oct 18, 2025
1f77b7e
Update headers
serhiiur Oct 18, 2025
4c91381
Remove param to specify JWT scheme
serhiiur Oct 18, 2025
2bd1cb5
Remove scheme from JWT generated in generate_token function
serhiiur Oct 18, 2025
80474bf
Modify dependency to decode JWT
serhiiur Oct 18, 2025
e231438
Remove unused file
serhiiur Oct 18, 2025
2ca2380
Add dependency to get instance of UsersDbService
serhiiur Oct 18, 2025
3bc0050
Move methods to get and create user to UsersDbService
serhiiur Oct 18, 2025
61b8325
Integrate UsersDbService to signup route
serhiiur Oct 18, 2025
3ac714d
Implement separate service to manage users in the database
serhiiur Oct 18, 2025
824ab78
Move service for managing users in db to .services.users.py
serhiiur Oct 19, 2025
c967cfc
Add fixture to provide a db session object.
serhiiur Oct 19, 2025
3fe1e14
Add comments
serhiiur Oct 19, 2025
bc26fda
Add cached property to return content of policy.json file
serhiiur Oct 19, 2025
a415862
Add type aliases for some dependencies
serhiiur Oct 19, 2025
54f9d4b
Add 'credentials' property to 'NewUser' schema
serhiiur Oct 19, 2025
8acc81f
Move 'UsersDbService' into a 'services' module
serhiiur Oct 19, 2025
57e26ff
Use aliases for dependencies
serhiiur Oct 19, 2025
5e48da2
Init module
serhiiur Oct 19, 2025
eaaba14
Add common function to add a user and authorize http client
serhiiur Oct 19, 2025
e291ff6
Move json policy declaration to settings.py
serhiiur Oct 19, 2025
fc379b5
Update docstrings
serhiiur Oct 20, 2025
bfd79e0
Update docstrings and descriptions
serhiiur Oct 20, 2025
bac5f00
Update steps to define the logger
serhiiur Oct 20, 2025
d211007
Replace static logger configuration with input args
serhiiur Oct 20, 2025
b9f6aca
Update the way to define logger for testing
serhiiur Oct 20, 2025
83f02c9
Add new JWT settings
serhiiur Oct 20, 2025
58c3383
Rename dependencies. Update docstrings
serhiiur Oct 20, 2025
df03ee2
Sync names of the renamed dependencies
serhiiur Oct 20, 2025
118b887
Fix the wrong way to save a user into db
serhiiur Oct 20, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
run: uv run mypy

- name: Run Pytest with Coverage
run: uv run pytest --cov
run: uv run pytest --cov -m "not e2e"

- name: Creating coverage folder
run: mkdir -p coverage
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "authapigateway"
version = "0.0.1"
description = "Cookie-based JWT Auth API"
name = "Auth-API"
version = "0.0.3"
description = "RBAC JWT Cookies-based API"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
Expand Down Expand Up @@ -59,7 +59,7 @@ lint.ignore = [
[tool.ruff.lint.per-file-ignores]
"src/tests/*" = ["D103", "S101"]
"src/app/user/exceptions.py" = ["D101"]
"src/alembic/versions/*" = ["D103", "D400", "D415", "INP001"]
"src/alembic/*" = ["D103", "D400", "D415", "INP001", "I001"]

[tool.mypy]
python_version = "3.12"
Expand Down
4 changes: 2 additions & 2 deletions src/alembic/env.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import asyncio # noqa: INP001
import asyncio
from logging.config import fileConfig

from alembic import context
from sqlalchemy.engine.base import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy.pool import NullPool

from alembic import context
from app.main.settings import settings
from app.user.models import User

Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from sqladmin import ModelView
from wtforms.fields import EmailField, Field, PasswordField, SelectField

from app.user.auth import generate_password_hash
from app.user.constants import UserRole
from app.user.models import User
from app.user.security.passwords import generate_password_hash

if TYPE_CHECKING:
from sqladmin._types import MODEL_ATTR
Expand Down
48 changes: 24 additions & 24 deletions src/app/main/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,16 @@
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.exc import IntegrityError
from redis.asyncio import Redis

from app.admin.app import init_admin_app
from app.user.errors import (
db_integrity_error_handler,
unexpected_error_handler,
validation_error_handler,
)
from app.user.errors import error_handlers as user_error_handlers
from app.user.routes import router as user_router

from .db import engine
from .errors import error_handlers as main_error_handlers
from .logger import configure_logging
from .redis import redis
from .routes import router as default_router
from .settings import settings

Expand All @@ -26,39 +21,44 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Init and release objects on app startup and shutdown.

On startup:
- init Redis client with connection pool;
- init Admin app with the specified database engine;
- add logger object to the application state;
- add Redis client object to the application state;
- init Admin app with the provided database engine;

On shutdown:
- dispose Redis client with connection pool;
- dispose Redis client including its connection pool;
- dispose database engine.

NOTE: during testing state's objects are redeclared to adapt the
NOTE: during testing state objects are redeclared to adapt the
application to the testing environment.
"""
redis_client = getattr(app.state, "redis", redis)
database_engine = getattr(app.state, "engine", engine)
db = getattr(app.state, "engine", engine)
logger = getattr(
app.state,
"logger",
configure_logging(settings.log_name, options=settings.logging_kwargs),
)
redis = getattr(app.state, "redis", Redis(**settings.redis_kwargs))

init_admin_app(app, database_engine)
# Init admin app based on the provided database engine
init_admin_app(app, db)

# set application state
app.state.logger = configure_logging()
app.state.redis = redis_client
# Init state objects of the app
app.state.logger = logger
app.state.redis = redis

yield

await redis_client.close()
await redis_client.connection_pool.disconnect()
await database_engine.dispose()
await redis.aclose()
await db.dispose()


app = FastAPI(
**settings.fastapi_kwargs,
lifespan=lifespan,
exception_handlers={
IntegrityError: db_integrity_error_handler,
RequestValidationError: validation_error_handler,
Exception: unexpected_error_handler,
**user_error_handlers,
**main_error_handlers,
},
)
app.add_middleware(CORSMiddleware, **settings.cors_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions src/app/main/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ async def get_db() -> AsyncIterator["AsyncSession"]:


async def get_redis(request: Request) -> "Redis":
"""Return initialized Redis client to be used as a dependency.
"""Return initialized in the lifespan client for Redis.

:param request: request object providing access to the app state
:return: Redis client
:return: client for Redis
"""
return cast("Redis", request.app.state.redis)


# https://fastapi.tiangolo.com/tutorial/dependencies/#share-annotated-dependencies
DbSession: TypeAlias = Annotated["AsyncSession", Depends(get_db)]
RedisT: TypeAlias = Annotated["Redis", Depends(get_redis)]
42 changes: 42 additions & 0 deletions src/app/main/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any, cast

from fastapi import Request, Response, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

if TYPE_CHECKING:
from logging import Logger


async def validation_error_handler(
_: Request,
e: RequestValidationError,
) -> JSONResponse:
"""Pydantic validation error handler."""
error = e.errors()[0]
field = error["loc"][1]
error_message = f"{error['msg']}. Field: {field}"
client_message = {"detail": error_message}
return JSONResponse(client_message, status.HTTP_422_UNPROCESSABLE_ENTITY)


async def unexpected_error_handler(
request: Request,
e: Exception,
) -> JSONResponse:
"""Error handler for all uncaught exceptions."""
logger = cast("Logger", request.app.state.logger)
logger.critical("Internal Server Error: %s", e)
client_message = {"error": "Service is temporarily unavailable"}
return JSONResponse(client_message, status.HTTP_500_INTERNAL_SERVER_ERROR)


# error handlers mapping to be registered in the main FastAPI app
error_handlers: dict[
int | type[Exception],
Callable[[Request, Any], Coroutine[Any, Any, Response]],
] = {
RequestValidationError: validation_error_handler,
Exception: unexpected_error_handler,
}
4 changes: 1 addition & 3 deletions src/app/main/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from fastapi import HTTPException, status

from app.main.settings import settings


class BaseHTTPException(HTTPException):
"""Base HTTP exception class."""
Expand All @@ -19,5 +17,5 @@ def __init__(
super().__init__(
status_code or self.status_code,
detail or self.detail,
headers or {"WWW-Authenticate": settings.token_scheme},
headers or {"WWW-Authenticate": "Bearer"},
)
11 changes: 6 additions & 5 deletions src/app/main/logger.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import logging
from typing import TYPE_CHECKING

from .settings import settings

if TYPE_CHECKING:
from logging import Logger

from .settings import LoggingKwargs


def configure_logging() -> "Logger":
def configure_logging(name: str, options: "LoggingKwargs | None" = None) -> "Logger":
"""Configure app logging and return logger object."""
logging.basicConfig(**settings.logging_kwargs)
return logging.getLogger(settings.log_name)
if options is not None:
logging.basicConfig(**options)
return logging.getLogger(name)
9 changes: 0 additions & 9 deletions src/app/main/redis.py

This file was deleted.

51 changes: 37 additions & 14 deletions src/app/main/settings.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import json
import logging
from functools import lru_cache
from functools import cached_property, lru_cache
from pathlib import Path
from typing import TypedDict
from typing import TypedDict, cast

from pydantic_settings import BaseSettings
from redis.asyncio import ConnectionPool

# type to describe content of the RBAC policy file
type RBACPolicyT = dict[str, dict[str, list[str]]]


class FastAPIKwargs(TypedDict):
Expand Down Expand Up @@ -42,30 +47,37 @@ class CORSMiddlewareKwargs(TypedDict):
max_age: int


class RedisKwargs(TypedDict):
"""Kwargs for Redis client."""

connection_pool: ConnectionPool


class Settings(BaseSettings):
"""Main project settings."""

# FastAPI settings
title: str = "JWT Auth API Gateway"
description: str = "RBAC JWT Cookies-based API Gateway"
version: str = "0.0.1"
debug: bool = True
title: str = "Auth API"
description: str = "RBAC JWT Cookies-based API"
version: str = "0.0.3"
debug: bool = False
docs_url: str = "/"

# Admin app setting
admin_base_url: str = "/admin"
admin_title: str = "Auth API Admin"
admin_debug: bool = True
admin_debug: bool = debug

# Logging settings
log_name: str = "app"
log_level: int = logging.INFO
log_format: str = "%(levelname)s - %(name)s - %(asctime)s - %(message)s"
log_datefmt: str = "%Y-%m-%d %H:%M:%S"
# name of the logger used in testing
# name of the logger used when running tests
test_log_name: str = "test"

host_server_domain: str = "localhost"
test_client_base_url: str = f"http://{host_server_domain}"

# CORS settings
cors_max_age: int = 600 # seconds
Expand All @@ -83,9 +95,7 @@ class Settings(BaseSettings):

# JWT settings
jwt_algorithm: str = "HS256"
jwt_secret_key: str = "supersecret12345" # noqa: S105

# JWT Tokens settings
jwt_secret_key: str = ""
access_token_expiration_time: int = 5 # minutes
access_token_cookie_expiration_time: int = (
access_token_expiration_time * 60
Expand All @@ -94,16 +104,23 @@ class Settings(BaseSettings):
refresh_token_cookie_expiration_time: int = (
refresh_token_expiration_time * 60
) # seconds
token_scheme: str = "Bearer" # noqa: S105
# size of the cache to store payload of the corresponding tokens
# size of the cache to store payload of JWTs
token_payload_max_cache_hits: int = 10_000
# specifies value of JWTs in Redis to avoid their reuse
jwt_blacklist_name: str = "blacklist"

# Passwords settings
max_password_length: int = 50
min_password_length: int = 5

# RBAC settings
user_policy_file: Path = Path(__file__).parents[2] / "policy.json"
rbac_policy_fp: Path = Path(__file__).parents[2] / "policy.json"

@cached_property
def rbac_policy(self) -> RBACPolicyT:
"""Read and return content of the policy.json file."""
with self.rbac_policy_fp.open() as f:
return cast("RBACPolicyT", json.load(f))

@property
def fastapi_kwargs(self) -> FastAPIKwargs:
Expand Down Expand Up @@ -145,6 +162,12 @@ def cors_kwargs(self) -> CORSMiddlewareKwargs:
max_age=self.cors_max_age,
)

@property
def redis_kwargs(self) -> RedisKwargs:
"""Kwargs for Redis client."""
connection_pool = ConnectionPool.from_url(self.redis_url, decode_responses=True)
return RedisKwargs(connection_pool=connection_pool)


@lru_cache
def get_settings() -> Settings:
Expand Down
Loading