Files
gh-christianlouis-dmarq/backend/app/middleware/auth.py
T
copilot-swe-agent[bot] 531dc968a8 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>
2026-03-30 10:09:50 +00:00

81 lines
2.9 KiB
Python

"""
Authentication redirect middleware.
Intercepts browser requests for protected HTML pages and redirects
unauthenticated visitors to ``/login`` (or ``/setup`` if Logto is not yet
configured).
API routes (``/api/…``) are intentionally left to handle their own 401
responses so that programmatic clients are not broken.
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from starlette.types import ASGIApp
from app.core.logto import SESSION_COOKIE, decode_session_token
# Paths that are always publicly accessible
_PUBLIC_PATHS: frozenset[str] = frozenset(
{
"/login",
"/setup",
"/health",
"/healthz",
}
)
# Request path prefixes that bypass auth checks
_PUBLIC_PREFIXES: tuple[str, ...] = (
"/api/",
"/static/",
"/docs",
"/redoc",
"/openapi",
)
class AuthRedirectMiddleware(BaseHTTPMiddleware):
"""
Redirect unauthenticated browser requests to the appropriate page.
Decision tree
-------------
1. Path is public → pass through.
2. Session cookie present and valid → pass through.
3. Logto not configured → redirect to ``/setup``.
4. Otherwise → redirect to ``/login?next=<original_path>``.
"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[override]
path = request.url.path
# ── 1. Public paths & prefixes ────────────────────────────────────────
if path in _PUBLIC_PATHS:
return await call_next(request)
if any(path.startswith(p) for p in _PUBLIC_PREFIXES):
return await call_next(request)
# ── 2. Valid session cookie ───────────────────────────────────────────
token = request.cookies.get(SESSION_COOKIE)
if token and decode_session_token(token) is not None:
return await call_next(request)
# ── 3. Logto not configured ───────────────────────────────────────────
from app.core.config import get_settings # local import avoids circular dep
if not get_settings().logto_configured:
return RedirectResponse(url="/setup", status_code=302)
# ── 4. Redirect to login ──────────────────────────────────────────────
next_path = request.url.path
if request.url.query:
next_path = f"{next_path}?{request.url.query}"
return RedirectResponse(url=f"/login?next={next_path}", status_code=302)