feat: add DevPlace agent, auth token service, and form-data dependency modules

Add DevPlace agent configuration with dynamic OpenAPI schema fetching, implement access token issuance/resolution/revocation with configurable expiry, and create generic FastAPI dependency for JSON or form-encoded data validation against Pydantic models. Include comprehensive unit tests for form-data parsing and token lifecycle operations.
This commit is contained in:
2026-06-17 17:11:47 +00:00
parent 9598a1b867
commit 95dca73291
7 changed files with 1210 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
"""
Generic FastAPI dependency that accepts JSON or form-encoded data,
validated against a Pydantic model.
"""
import json
import logging
from typing import Any, TypeVar, get_origin
from fastapi import HTTPException, Request
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, ValidationError
from starlette.datastructures import FormData
logger = logging.getLogger(__name__)
_TModel = TypeVar("_TModel", bound=BaseModel)
# Container origins recognised as sequence fields that may receive
# multiple values from form data.
_SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset})
def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
"""Convert FormData to a dict suitable for Pydantic validation.
* Sequence-typed model fields collect every submitted value via
``getlist()``; a lone empty string is dropped (browsers emit empty
hidden inputs by default).
* Scalar fields use ``get()`` (the last value).
* Fields absent from the form are omitted so that Pydantic applies
the model default.
"""
body: dict[str, Any] = {}
for field_name, field_info in model.model_fields.items():
origin = get_origin(field_info.annotation)
if origin in _SEQUENCE_ORIGINS:
values = form.getlist(field_name)
if not values:
continue
if values == [""]:
continue
body[field_name] = [v for v in values if v != ""] or []
else:
value = form.get(field_name)
if value is not None:
body[field_name] = value
return body
class _JsonOrForm:
"""Internal callable that parses JSON or form data and validates."""
def __init__(self, model: type[BaseModel]):
self.model = model
async def __call__(self, request: Request) -> Any:
content_type = request.headers.get("content-type", "")
body: Any = None
try:
if "application/json" in content_type:
try:
body = await request.json()
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
logger.debug("JSON parse failed: %s", exc)
raise HTTPException(status_code=400, detail="Invalid JSON body")
if not isinstance(body, dict):
raise HTTPException(
status_code=400, detail="JSON body must be an object"
)
return self.model.model_validate(body)
# Default: form-encoded (multipart or url-encoded)
try:
form = await request.form()
except Exception as exc:
logger.debug("Form parse failed: %s", exc)
raise HTTPException(
status_code=400, detail="Could not parse form data"
)
body = _formdata_to_dict(form, self.model)
return self.model.model_validate(body)
except ValidationError as exc:
raise RequestValidationError(errors=exc.errors(), body=body)
def json_or_form(model: type[_TModel]) -> _JsonOrForm:
"""Dependency factory: accept JSON or form-encoded data for a Pydantic model.
Usage:
@router.post("/create")
async def create(data: Annotated[PostForm, Depends(json_or_form(PostForm))]):
...
"""
return _JsonOrForm(model)
+98
View File
@@ -0,0 +1,98 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table
from devplacepy.utils import verify_password_async, get_current_user
from devplacepy.models import LoginForm
from devplacepy.dependencies import json_or_form
from devplacepy.services.access_tokens import issue_token
from devplacepy.services.audit import record as audit
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/token")
async def token(
request: Request,
data: Annotated[LoginForm, Depends(json_or_form(LoginForm))],
):
"""Issue a DevPlace access token.
Accepts ``email`` + ``password`` (JSON or form-encoded). Returns a JSON
object with ``access_token``, ``token_type``, and ``expires_in`` on success,
or a ``401`` error on bad credentials.
"""
identifier = data.email.strip().lower()
password = data.password
if not identifier or not password:
return JSONResponse(
{"error": "email and password are required"},
status_code=400,
)
users = get_table("users")
user = users.find_one(email=identifier) or users.find_one(
username=identifier
)
if not user or not await verify_password_async(
password, user.get("password_hash", "")
):
audit.record(
request,
"auth.token.failure",
user=None,
actor_kind="guest",
result="failure",
metadata={"identifier": identifier},
summary=f"failed token request for {identifier}",
)
return JSONResponse(
{"error": "Invalid credentials"},
status_code=401,
)
if not user.get("is_active", True):
audit.record(
request,
"auth.token.failure",
user=None,
actor_kind="guest",
result="failure",
metadata={"identifier": identifier, "reason": "inactive"},
summary=f"token request for inactive user {identifier}",
)
return JSONResponse(
{"error": "Account is deactivated"},
status_code=403,
)
token = issue_token(user)
logger.info("Token issued for %s", user["username"])
audit.record(
request,
"auth.token.issued",
user=user,
target_type="user",
target_uid=user["uid"],
target_label=user["username"],
metadata={"token_uid": token["uid"], "expires_in": token["expires_in"]},
summary=f"access token issued for {user['username']}",
links=[audit.target("user", user["uid"], user["username"])],
)
return JSONResponse(
{
"access_token": token["access_token"],
"token_type": token["token_type"],
"expires_in": token["expires_in"],
}
)
+126
View File
@@ -0,0 +1,126 @@
# retoor <retoor@molodetz.nl>
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from devplacepy.database import get_table, get_int_setting
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.utils import generate_uid
TOKEN_KEY_BYTES = 32 # 64-char hex, matching session token size
def issue_token(
user: dict,
label: str = "",
max_age_days: Optional[int] = None,
) -> dict:
"""Issue a DevPlace access token for *user*.
Returns a dict with *access_token*, *token_type*, *expires_in* (seconds),
*expires_at* (ISO), and *uid*.
"""
if max_age_days is None:
max_age_days = max(1, get_int_setting("session_max_age_days", 7))
max_age_seconds = max_age_days * SECONDS_PER_DAY
token = secrets.token_hex(TOKEN_KEY_BYTES)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=max_age_seconds)
token_uid = generate_uid()
tokens = get_table("access_tokens")
tokens.insert(
{
"uid": token_uid,
"token": token,
"user_uid": user["uid"],
"label": label or "",
"expires_at": expires_at.isoformat(),
"created_at": now.isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": max_age_seconds,
"expires_at": expires_at.isoformat(),
"uid": token_uid,
}
def resolve_token(token: str) -> Optional[dict]:
"""Resolve a user from an access token string.
Returns the user dict or ``None`` when the token is invalid, expired, or
belongs to an inactive user.
"""
if not token:
return None
row = get_table("access_tokens").find_one(token=token, deleted_at=None)
if not row:
return None
expires_at = row.get("expires_at", "")
if expires_at:
try:
expires = datetime.fromisoformat(expires_at)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
return None
if expires < datetime.now(timezone.utc):
return None
user = get_table("users").find_one(uid=row.get("user_uid"))
if not user or not user.get("is_active", True):
return None
return user
def revoke_token(uid: str) -> bool:
"""Soft-delete a single access token by its uid. Returns ``True`` on success."""
tokens = get_table("access_tokens")
row = tokens.find_one(uid=uid, deleted_at=None)
if not row:
return False
stamp = datetime.now(timezone.utc).isoformat()
tokens.update({"id": row["id"], "deleted_at": stamp, "deleted_by": "manual"}, ["id"])
return True
def revoke_all(user_uid: str) -> int:
"""Soft-delete every access token for *user_uid*. Returns the count revoked."""
tokens = get_table("access_tokens")
stamp = datetime.now(timezone.utc).isoformat()
count = 0
for row in list(tokens.find(user_uid=user_uid, deleted_at=None)):
tokens.update({"id": row["id"], "deleted_at": stamp, "deleted_by": "manual"}, ["id"])
count += 1
return count
def prune_expired() -> int:
"""Soft-delete all expired access tokens. Returns the count pruned."""
tokens = get_table("access_tokens")
now = datetime.now(timezone.utc)
stamp = now.isoformat()
count = 0
for row in list(tokens.find(deleted_at=None)):
expires_at = row.get("expires_at", "")
if not expires_at:
continue
try:
expires = datetime.fromisoformat(expires_at)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
continue
if expires < now:
tokens.update({"id": row["id"], "deleted_at": stamp, "deleted_by": "prune"}, ["id"])
count += 1
return count