feat: integrate Logto OIDC for user authentication

- Add Logto OIDC integration (app/core/logto.py): CookieStorage adapter,
  create/decode session token helpers, sync_logto_user upsert
- New auth endpoints (/api/v1/auth): sign-in, callback, sign-out, me
- AuthRedirectMiddleware: protects HTML pages, redirects to /setup when
  Logto is unconfigured, to /login otherwise
- Update require_admin_auth: accepts dmarq_session cookie JWT first,
  then API key, then Bearer JWT (fully backward compatible)
- Update User model: add logto_id, username, picture, created_at, updated_at;
  make hashed_password nullable for Logto-only users; is_superuser default=True
- New Alembic migration d4e5f6a7b8c9 for the above schema changes
- Add LOGTO_ENDPOINT / LOGTO_APP_ID / LOGTO_APP_SECRET / LOGTO_REDIRECT_URI
  settings with logto_configured property
- Create login.html (Sign in with Logto button) and setup.html (step-by-step
  configuration guide)
- Update base.html: user menu with avatar/name and sign-out via Alpine.js
  fetch to /api/v1/auth/me
- Update settings.html: remove localStorage adminApiKey; session cookie is
  sent automatically by browser; add 401 → /login redirect
- Update requirements.txt: replace fastapi-users additions with logto + aiohttp
- Add test_auth.py: 18 new tests covering session tokens, CookieStorage,
  sync_logto_user, /me, /sign-in (503), /sign-out cookie clearing
- Fix test_security_extra.py: pass Request mock to require_admin_auth;
  add new test_valid_session_cookie_returns_auth_context

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/b448f585-7646-40f8-ae2d-9986c361e3fd

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-30 10:09:50 +00:00
parent 308e6f8d91
commit 531dc968a8
21 changed files with 1496 additions and 203 deletions
+30 -17
View File
@@ -4,7 +4,7 @@ import secrets
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from fastapi import HTTPException, Security, status
from fastapi import HTTPException, Request, Security, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
@@ -162,42 +162,55 @@ async def verify_token(
async def require_admin_auth(
request: Request,
api_key: Optional[str] = Security(api_key_header),
bearer: Optional[HTTPAuthorizationCredentials] = Security(security_bearer),
) -> dict:
"""
Dependency to require either API key or JWT token authentication for admin endpoints.
Dependency to require authentication for admin/API endpoints.
Checks API key first, then falls back to JWT token.
Accepts (in priority order):
1. ``dmarq_session`` cookie set after a successful Logto login.
2. ``X-API-Key`` header static admin key for programmatic access.
3. ``Authorization: Bearer <token>`` header app-issued JWT.
Args:
api_key: Optional API key from X-API-Key header
bearer: Optional JWT token from Authorization header
Returns:
Authentication context (api_key or token payload)
Raises:
HTTPException: If no valid authentication is provided
Returns an authentication context dict describing how the request was
authenticated. Raises ``HTTP 401`` when no valid credential is present.
"""
# Try API key first
# 1. Session cookie (Logto-backed app session)
from app.core.logto import SESSION_COOKIE, decode_session_token # local import
session_token = request.cookies.get(SESSION_COOKIE)
if session_token:
user_id = decode_session_token(session_token)
if user_id is not None:
return {"auth_type": "session", "user_id": user_id}
# 2. Static admin API key
if api_key and verify_api_key(api_key):
return {"auth_type": "api_key", "api_key": api_key}
# Try JWT token
# 3. Bearer JWT (app-issued; also covers Bearer tokens set by older clients)
if bearer:
from app.core.logto import decode_session_token as _dec # local import
user_id = _dec(bearer.credentials)
if user_id is not None:
return {"auth_type": "bearer", "user_id": user_id}
# Fallback: legacy python-jose JWT (pre-Logto API keys / CI tokens)
try:
payload = jwt.decode(
bearer.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return {"auth_type": "jwt", "payload": payload}
except JWTError as e:
logger.warning("Invalid JWT token: %s", str(e))
logger.warning("Invalid Bearer JWT: %s", str(e))
# No valid authentication provided
# No valid authentication
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required. Provide either X-API-Key header or Bearer token.",
detail="Authentication required. Provide a session cookie, X-API-Key header, or Bearer token.",
headers={"WWW-Authenticate": "ApiKey, Bearer"},
)