diff --git a/app.py b/app.py index e740ad518..b108490e2 100644 --- a/app.py +++ b/app.py @@ -67,7 +67,13 @@ def register_static_mime_types() -> None: REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE, ) from core.database import SessionLocal, ApiToken -from core.middleware import SecurityHeadersMiddleware, is_cors_preflight +from core.middleware import ( + SecurityHeadersMiddleware, + get_application_route_path, + is_cors_preflight, + path_is_route_or_child, + with_asgi_root_path, +) from core.auth import AuthManager, normalize_known_username from core.exceptions import ( SessionNotFoundError, InvalidFileUploadError, @@ -284,7 +290,7 @@ async def dispatch(self, request, call_next): def _is_auth_exempt(path: str) -> bool: if path in AUTH_EXEMPT_EXACT: return True - if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES): + if any(path_is_route_or_child(path, p) for p in AUTH_EXEMPT_PREFIXES): return True return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS) @@ -355,7 +361,7 @@ def _is_trusted_loopback(request: Request) -> bool: class AuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): - path = request.url.path + path = get_application_route_path(request.scope) # A genuine CORS preflight (OPTIONS + Access-Control-Request-Method) # carries no credentials by design and must reach CORSMiddleware to be # answered. AuthMiddleware is the outermost middleware, so gating the @@ -399,7 +405,10 @@ async def dispatch(self, request: Request, call_next): if not auth_manager.is_configured: # No users yet — redirect to login for first-time setup if not path.startswith("/api/"): - return RedirectResponse(url="/login", status_code=302) + return RedirectResponse( + url=with_asgi_root_path(request.scope, "/login"), + status_code=302, + ) return JSONResponse(status_code=401, content={"error": "Setup required"}) # --- Bearer token auth (API tokens for external integrations) --- @@ -461,7 +470,10 @@ def _do(): if not auth_manager.validate_token(token): if path.startswith("/api/"): return JSONResponse(status_code=401, content={"error": "Not authenticated"}) - return RedirectResponse(url="/login", status_code=302) + return RedirectResponse( + url=with_asgi_root_path(request.scope, "/login"), + status_code=302, + ) # Attach current username to request state for downstream routes request.state.current_user = auth_manager.get_username_for_token(token) diff --git a/core/middleware.py b/core/middleware.py index 0e164e35a..b28d29588 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -3,10 +3,12 @@ import os import secrets +from collections.abc import Mapping from fastapi import HTTPException, Request from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response +from starlette.routing import get_route_path # Per-process token that lets the in-app tool layer hit admin-gated @@ -19,6 +21,30 @@ INTERNAL_TOOL_USER = "internal-tool" +def get_application_route_path(scope: Mapping[str, object]) -> str: + """Return the application-relative path used by Starlette routing. + + Uvicorn prefixes ``scope["path"]`` with a configured ASGI ``root_path``; + Starlette removes that prefix before matching routes. Middleware policy + must use the same path form or a deployment prefix can change which policy + applies to an otherwise unchanged application route. + """ + return get_route_path(scope) + + +def with_asgi_root_path(scope: Mapping[str, object], path: str) -> str: + """Prefix an application path for a client-facing redirect target.""" + root_path = scope.get("root_path", "") + if not isinstance(root_path, str) or not root_path: + return path + return f"{root_path.rstrip('/')}{path}" + + +def path_is_route_or_child(path: str, prefix: str) -> bool: + """Return whether ``path`` is exactly ``prefix`` or below that route.""" + return path == prefix or path.startswith(prefix + "/") + + def is_cors_preflight(method: str, headers) -> bool: """True for a genuine CORS preflight: an OPTIONS request carrying the Access-Control-Request-Method header. Such requests are credential-less by diff --git a/static/login.html b/static/login.html index eeece7cc3..2d912cb54 100644 --- a/static/login.html +++ b/static/login.html @@ -5,10 +5,29 @@