Skip to content
Closed
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ Versions align with [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **`Meta.cache` was ignored on async engines**: cached GETs and cache invalidation
only ran on the sync path, so an app on an `AsyncEngine` never read or wrote the
cache. Both engine kinds now use it. The Redis calls run in a worker thread.
- **Sync verb overrides were ignored**: a plain `def get(self, request)` (or `post`,
`put`, `patch`, `delete`) on an endpoint was skipped and the built-in CRUD ran
instead; only `async def` overrides were served. Both are served now, on sync and
async engines. A column that happens to be called `post` or `get` is still a
column, not an override.
- **`/auth/login` ignored a backend's `validate_credentials`**: the login route
always validated through a bare `JWTAuthentication()`, so overriding
`validate_credentials()` on a subclass (the README's "custom authentication")
never ran and login returned `401`. The route now uses the backend the endpoint
configured, sync or `async def`. An app-level `login_validator` still wins.
- **Auth settings were dropped for backend subclasses**: `jwt_expiration`,
`jwt_algorithm` and `jwt_extra_claims` only reached a backend whose class was named
exactly `JWTAuthentication`. Subclasses get them too, unless they define their own
`__init__`.

## [Unreleased] — 0.1.24

Expand Down
6 changes: 3 additions & 3 deletions docs/technical-reference/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ When `app.register({"/items": ItemEndpoint})` is called, LightAPI registers two

| Route | Methods | Handler |
|-------|---------|---------|
| `/items` | `GET`, `POST` | `handler_factory.make_collection_handler(cls, ...)` |
| `/items/{id:int}` | `GET`, `PUT`, `PATCH`, `DELETE` | `handler_factory.make_detail_handler(cls, ...)` |
| `/items` | `GET`, `POST` | `EndpointHandler.for_collection(cls, ...).handle` |
| `/items/{id:int}` | `GET`, `PUT`, `PATCH`, `DELETE` | `EndpointHandler.for_detail(cls, ...).handle` |

Both handlers are closures that instantiate the endpoint class per request and dispatch to the appropriate CRUD method.
Both handlers instantiate the endpoint class per request and dispatch to the appropriate CRUD method.

## Request Dispatch Flow

Expand Down
127 changes: 55 additions & 72 deletions lightapi/_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,20 @@
from __future__ import annotations

import base64
import inspect
import json
import logging
from typing import Any, Optional
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, ConfigDict, Field, ValidationError
from starlette.requests import Request
from starlette.responses import JSONResponse

from lightapi.constants import (
RESPONSE_KEY_DETAIL,
RESPONSE_KEY_TOKEN,
RESPONSE_KEY_USER,
HTTPStatus,
)
from lightapi.constants import RESPONSE_KEY_DETAIL, HTTPStatus

# JWTAuthentication imported locally where needed to avoid circular import
if TYPE_CHECKING:
from lightapi.authentication.base import BaseAuthentication, LoginValidator
from lightapi.rate_limiter import RateLimiter

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -78,80 +76,65 @@ async def _read_body(request: Request) -> dict[str, Any]:
return {}


async def login_handler(
request: Request,
*,
has_jwt: bool,
jwt_expiration: int | None = None,
jwt_extra_claims: list[str] | None = None,
jwt_algorithm: str | None = None,
rate_limiter: Optional[Any] = None,
auth_backend: Optional[Any] = None,
) -> JSONResponse:
"""
Handle POST /auth/login and POST /auth/token.
class LoginEndpoint:
"""POST /auth/login and POST /auth/token.

Returns 422 for body validation, 401 for malformed Basic, invalid credentials,
or any exception raised by the validator; 200 with token+user (JWT) or user (Basic).
or any exception raised while validating; 200 with whatever the backend puts
in a login response (token and user for JWT, user for Basic).
"""
# Apply rate limiting if a rate limiter is provided
if rate_limiter is not None:
is_limited, window = rate_limiter.is_rate_limited(request, endpoint="auth")
if is_limited:
return rate_limiter.get_rate_limit_response(request, window)

if request.method != "POST":
return JSONResponse(
{RESPONSE_KEY_DETAIL: "method not allowed"},
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
headers={"Allow": "POST"},
)

try:
creds = await _parse_credentials(request)
except ValidationError as exc:
return JSONResponse(
{RESPONSE_KEY_DETAIL: exc.errors()},
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
)

if creds is None:
return JSONResponse(
{RESPONSE_KEY_DETAIL: "Invalid credentials"},
status_code=HTTPStatus.UNAUTHORIZED,
def __init__(
self,
backend: BaseAuthentication,
login_validator: LoginValidator | None,
rate_limiter: RateLimiter,
) -> None:
self._backend = backend
self._login_validator = login_validator
self._rate_limiter = rate_limiter

async def handle(self, request: Request) -> JSONResponse:
is_limited, window = self._rate_limiter.is_rate_limited(
request, endpoint="auth"
)
if is_limited:
return self._rate_limiter.get_rate_limit_response(request, window)

username, password = creds
if request.method != "POST":
return JSONResponse(
{RESPONSE_KEY_DETAIL: "method not allowed"},
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
headers={"Allow": "POST"},
)

# Use auth_backend's validate_credentials if provided
payload = None
if auth_backend is not None:
try:
payload = auth_backend.validate_credentials(username, password)
except Exception as e:
logger.warning("validate_credentials raised: %s", e)
creds = await _parse_credentials(request)
except ValidationError as exc:
return JSONResponse(
{RESPONSE_KEY_DETAIL: exc.errors()},
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
)

user = await self._validated_user(*creds) if creds is not None else None
if user is None:
return JSONResponse(
{RESPONSE_KEY_DETAIL: "Invalid credentials"},
status_code=HTTPStatus.UNAUTHORIZED,
)
return JSONResponse(self._backend.login_response(user))

if payload is None:
return JSONResponse(
{RESPONSE_KEY_DETAIL: "Invalid credentials"},
status_code=HTTPStatus.UNAUTHORIZED,
)

if has_jwt:
from lightapi.authentication import JWTAuthentication

jwt_auth = JWTAuthentication(algorithm=jwt_algorithm)
if jwt_extra_claims and isinstance(payload, dict):
token_payload = {k: payload[k] for k in jwt_extra_claims if k in payload}
if not token_payload:
token_payload = payload
else:
token_payload = payload
token = jwt_auth.generate_token(token_payload, expiration=jwt_expiration)
return JSONResponse({RESPONSE_KEY_TOKEN: token, RESPONSE_KEY_USER: payload})

return JSONResponse({RESPONSE_KEY_USER: payload})
async def _validated_user(
self, username: str, password: str
) -> dict[str, Any] | None:
"""The app's login_validator wins; otherwise the backend's own override."""
validate = self._login_validator or self._backend.validate_credentials
try:
user = validate(username, password)
# The README shows `async def validate_credentials` on a subclass.
if inspect.isawaitable(user):
user = await user
except Exception as exc:
logger.warning("validate_credentials raised: %s", exc)
return None
return user
101 changes: 39 additions & 62 deletions lightapi/auth_checker.py
Original file line number Diff line number Diff line change
@@ -1,74 +1,51 @@
"""Authentication and authorization checker."""
"""Authentication and permission check for one endpoint."""

from __future__ import annotations

from typing import TYPE_CHECKING

from starlette.requests import Request
from starlette.responses import JSONResponse, Response

from lightapi.authentication import AllowAny
from lightapi.constants import HTTPStatus


def check_auth(cls: type, request: Request) -> Response | None:
"""Run authentication + permission checks; return 401/403 response or None."""

auth_cfg = cls._meta.get("authentication")
if auth_cfg is None:
return None

backend = auth_cfg.backend
permission_cls = auth_cfg.permission
is_per_method = isinstance(permission_cls, dict)

if is_per_method:
perm_cls = permission_cls.get(request.method)
if perm_cls is None:
perm_cls = AllowAny
elif permission_cls is not None:
perm_cls = permission_cls
else:
perm_cls = AllowAny

# Explicitly setting AllowAny (non-dict) means the endpoint is fully public —
# skip the backend authentication check entirely.
# Note: permission_cls=None defaults to AllowAny but still requires auth
# if a backend is set; only an explicit AllowAny bypasses the backend.
if not is_per_method and permission_cls is AllowAny:
return None

requires_auth = backend is not None and (
not is_per_method or perm_cls is not AllowAny
)

if requires_auth:
login_validator = getattr(auth_cfg, "_login_validator", None)

if backend.__name__ == "JWTAuthentication":
authenticator = backend(
expiration=getattr(auth_cfg, "jwt_expiration", None),
algorithm=getattr(auth_cfg, "jwt_algorithm", None),
rate_limiter=getattr(auth_cfg, "rate_limiter", None),
)
elif backend.__name__ == "BasicAuthentication":
authenticator = backend(
rate_limiter=getattr(auth_cfg, "rate_limiter", None),
login_validator=login_validator,
)
else:
authenticator = backend()

authenticated = authenticator.authenticate(request)

if not authenticated:
return JSONResponse(
{"detail": "Authentication required"},
status_code=HTTPStatus.UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},
)

if perm_cls is not AllowAny:
if not perm_cls().has_permission(request):
if TYPE_CHECKING:
from lightapi.authentication.base import LoginValidator
from lightapi.config import Authentication


class EndpointGuard:
"""Decides whether a request may reach an endpoint."""

def __init__(
self,
authentication: Authentication | None,
login_validator: LoginValidator | None = None,
) -> None:
self._authentication = authentication
self._login_validator = login_validator

def check(self, request: Request) -> Response | None:
"""The 401 or 403 response that stops the request, or None to let it in."""
authentication = self._authentication
if authentication is None:
return None

if authentication.requires_login(request.method):
backend = authentication.build_backend(self._login_validator)
if not backend.authenticate(request):
return JSONResponse(
{"detail": "Authentication required"},
status_code=HTTPStatus.UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},
)

permission = authentication.permission_for(request.method)
if permission is not AllowAny and not permission().has_permission(request):
return JSONResponse(
{"detail": "Insufficient permissions"},
status_code=HTTPStatus.FORBIDDEN,
)

return None
return None
24 changes: 22 additions & 2 deletions lightapi/authentication/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Callable

from starlette.requests import Request
from starlette.responses import JSONResponse

from lightapi.constants import RESPONSE_KEY_USER

if TYPE_CHECKING:
pass
from lightapi.config import Authentication

LoginValidator = Callable[[str, str], "dict[str, Any] | None"]


class BaseAuthentication:
Expand All @@ -18,6 +22,22 @@ class BaseAuthentication:
By default, allows all requests.
"""

@classmethod
def from_config(
cls,
authentication: Authentication,
login_validator: LoginValidator | None = None,
) -> BaseAuthentication:
"""Build the backend for an endpoint's ``Authentication`` settings.

Backends that take settings override this; the default needs none.
"""
return cls()

def login_response(self, user: dict[str, Any]) -> dict[str, Any]:
"""Body of a successful ``/auth/login`` for a validated user."""
return {RESPONSE_KEY_USER: user}

def authenticate(self, request: Request) -> bool:
"""Authenticate a request.

Expand Down
16 changes: 15 additions & 1 deletion lightapi/authentication/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
from starlette.responses import JSONResponse

from lightapi._login import _parse_basic_header
from lightapi.authentication.base import BaseAuthentication
from lightapi.authentication.base import BaseAuthentication, LoginValidator

if TYPE_CHECKING:
from lightapi.config import Authentication
from lightapi.rate_limiter import RateLimiter


Expand All @@ -29,6 +30,19 @@ def __init__(
self.rate_limiter = rate_limiter
self._login_validator = login_validator

@classmethod
def from_config(
cls,
authentication: Authentication,
login_validator: LoginValidator | None = None,
) -> BasicAuthentication:
# A subclass with its own constructor keeps being built without arguments.
if cls.__init__ is not BasicAuthentication.__init__:
return cls()
return cls(
rate_limiter=authentication.rate_limiter, login_validator=login_validator
)

def authenticate(self, request: Request) -> bool:
if request.method == "OPTIONS":
return True
Expand Down
Loading