feat(api): add personal API tokens and enhance webhook integration UI

- Add ApiToken model with SHA-256 hashed storage and usage tracking
- Create API token CRUD endpoints (POST/GET/DELETE /api/api-tokens/)
- Add Bearer token authentication to require_login decorator
- Exempt Bearer-authenticated requests from CSRF validation
- Add API tokens management page with create/revoke/copy UI
- Enhance webhook integration type with detailed explanation and code snippets
- Add navigation links (desktop + mobile) to API tokens page
- Include 19 tests covering CRUD, auth resolution, and utility functions
- Create migration 024_add_api_tokens

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 18:42:55 +00:00
parent 906c1ca246
commit c3bb93c197
13 changed files with 1272 additions and 25 deletions
+87 -23
View File
@@ -2,6 +2,7 @@ import hashlib
import inspect
import logging
import pathlib
from datetime import datetime, timezone
from functools import wraps
from urllib.parse import urlparse
@@ -52,9 +53,61 @@ router = APIRouter()
def get_current_user(request: Request):
# Check for Bearer token auth first (API tokens)
api_user = getattr(request.state, "api_token_user", None)
if api_user:
return api_user
return request.session.get("user")
def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
"""Resolve a user from a Bearer API token in the Authorization header.
If the header is present and the token is valid, updates usage tracking
(last_used_at, last_used_ip) and returns a synthetic user dict compatible
with the session user format.
Returns:
A user dict or ``None`` if no valid Bearer token is present.
"""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[7:]
if not raw_token:
return None
from app.models import ApiToken
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first()
if db_token is None:
return None
# Update usage tracking
try:
db_token.last_used_at = datetime.now(timezone.utc)
# Extract client IP (respect X-Forwarded-For from reverse proxy)
client_ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not client_ip and request.client:
client_ip = request.client.host
db_token.last_used_ip = client_ip or None
db.commit()
except Exception:
db.rollback()
logger.debug("Failed to update API token usage tracking for token_id=%s", db_token.id)
# Build a synthetic user dict that mimics the session user format
return {
"id": db_token.owner_id,
"email": db_token.owner_id,
"preferred_username": db_token.owner_id,
"is_admin": False,
"_api_token_id": db_token.id,
}
def get_current_user_id(request: Request) -> str:
"""Return a stable string identifier for the authenticated user.
@@ -82,28 +135,39 @@ def require_login(func):
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
if not request.session.get("user"):
# For API endpoints return 401 instead of storing the URL in the session
# and redirecting to /login. Without this guard, the /api/auth/whoami
# probe issued by common.js on every page load would overwrite
# redirect_after_login with the API URL, causing the post-login redirect
# to land on a JSON endpoint rather than the original page.
url_path = urlparse(str(request.url)).path
if url_path.startswith("/api/"):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": "Not authenticated"},
)
request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
# Pass request as a keyword argument so that endpoints whose first
# parameter is a path variable (e.g. pipeline_id) are not accidentally
# bound to the request object when FastAPI supplies all arguments as
# keyword arguments.
if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs)
else:
return func(*args, request=request, **kwargs)
# Check session auth first
if request.session.get("user"):
if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs)
else:
return func(*args, request=request, **kwargs)
# Fall back to Bearer token auth for API endpoints
url_path = urlparse(str(request.url)).path
if url_path.startswith("/api/"):
from app.database import SessionLocal
db = SessionLocal()
try:
api_user = _resolve_bearer_user(request, db)
finally:
db.close()
if api_user:
request.state.api_token_user = api_user
if inspect.iscoroutinefunction(func):
return await func(*args, request=request, **kwargs)
else:
return func(*args, request=request, **kwargs)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": "Not authenticated"},
)
# Non-API endpoint with no session — redirect to login
request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
return wrapper
@@ -443,7 +507,7 @@ if AUTH_ENABLED:
@require_login
async def whoami(request: Request):
"""API endpoint to get current user information"""
user = request.session.get("user")
user = get_current_user(request)
return user or {"error": "Not authenticated"}