fix(auth): normalize mounted request paths (#5807)

* fix(auth): normalize mounted request paths

* fix: make login page mount-aware
This commit is contained in:
RaresKeY
2026-08-15 18:55:15 +01:00
committed by GitHub
parent 2c394704c6
commit 443f7d2963
4 changed files with 362 additions and 21 deletions
+17 -5
View File
@@ -67,7 +67,13 @@ from core.constants import (
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 @@ if AUTH_ENABLED:
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 @@ if AUTH_ENABLED:
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 @@ if AUTH_ENABLED:
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 @@ if AUTH_ENABLED:
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)
+26
View File
@@ -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_HEADER = "X-Odysseus-Internal-Token"
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
+36 -16
View File
@@ -5,10 +5,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
<title>Odysseus — Login</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="static/manifest.json">
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
(function(){
function computeAppBasePath() {
var path = window.location.pathname || '/login';
if (path === '/login') {
return '';
}
if (path.endsWith('/login')) {
return path.slice(0, -'/login'.length).replace(/\/+$/, '');
}
return '';
}
var appBasePath = computeAppBasePath();
window.__odysseusLoginBasePath = appBasePath;
window.__odysseusLoginAppUrl = function(path) {
var normalizedPath = String(path || '/');
if (!normalizedPath.startsWith('/')) {
normalizedPath = '/' + normalizedPath;
}
return appBasePath + normalizedPath;
};
// Per-theme bg-effect defaults — mirrors THEME_DEFAULT_* maps in
// static/js/theme.js so login picks the same default pattern as the
// main app for users who never explicitly chose one.
@@ -85,8 +104,8 @@
})();
</script>
<style>
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
uncustomized theme — or a fresh browser with no `odysseus-theme` in
localStorage — renders the login page in the same palette as the rest
@@ -300,9 +319,10 @@
<script nonce="{{CSP_NONCE}}">
(async () => {
const appUrl = window.__odysseusLoginAppUrl || ((path) => path);
// Load version
try {
const vr = await fetch('/api/version');
const vr = await fetch(appUrl('/api/version'));
if (vr.ok) {
const vd = await vr.json();
document.getElementById('version-label').textContent = 'v' + vd.version;
@@ -363,12 +383,12 @@
// Check auth status and fetch policy in parallel, but don't block the
// authenticated redirect on the policy response.
const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null);
const policyPromise = fetch(appUrl('/api/auth/policy'), { credentials: 'same-origin' }).catch(() => null);
try {
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
const statusRes = await fetch(appUrl('/api/auth/status'), { credentials: 'same-origin' });
const data = await statusRes.json();
if (data.authenticated) {
window.location.replace('/');
window.location.replace(appUrl('/'));
return;
}
signupAllowed = !!data.signup_enabled;
@@ -405,7 +425,7 @@
if (!code) { totpInput.focus(); submitBtn.disabled = false; return; }
const remember = document.getElementById('remember').checked;
try {
const res = await fetch('/api/auth/login', {
const res = await fetch(appUrl('/api/auth/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
@@ -449,7 +469,7 @@
// Setup or signup first
if (mode === 'setup' || mode === 'signup') {
const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/signup';
const endpoint = mode === 'setup' ? appUrl('/api/auth/setup') : appUrl('/api/auth/signup');
try {
const res = await fetch(endpoint, {
method: 'POST',
@@ -477,19 +497,19 @@
submitBtn.innerHTML = '<span class="login-spinner" aria-hidden="true"></span>';
submitBtn.disabled = true;
Promise.all([
fetch('/api/sessions', { credentials: 'same-origin' }).then(r => r.json()),
fetch('/api/auth/features', { credentials: 'same-origin' }).then(r => r.json()),
fetch('/api/auth/settings', { credentials: 'same-origin' }).then(r => r.json()),
fetch(appUrl('/api/sessions'), { credentials: 'same-origin' }).then(r => r.json()),
fetch(appUrl('/api/auth/features'), { credentials: 'same-origin' }).then(r => r.json()),
fetch(appUrl('/api/auth/settings'), { credentials: 'same-origin' }).then(r => r.json()),
]).then(([sess, feat, sett]) => {
sessionStorage.setItem('ody-prefetch-sessions', JSON.stringify(sess));
sessionStorage.setItem('ody-prefetch-features', JSON.stringify(feat));
sessionStorage.setItem('ody-prefetch-settings', JSON.stringify(sett));
}).catch(() => {}).finally(() => { window.location.replace('/'); });
}).catch(() => {}).finally(() => { window.location.replace(appUrl('/')); });
}
async function doLogin(totpCode) {
const loginBody = { username, password, remember };
if (totpCode) loginBody.totp_code = totpCode;
const res = await fetch('/api/auth/login', {
const res = await fetch(appUrl('/api/auth/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
@@ -587,7 +607,7 @@ if (window.visualViewport) {
// `applyBgPattern` would never fire on its own. We call it directly
// here against the pattern the bootstrap already chose.
try {
const tm = await import('/static/js/theme.js');
const tm = await import((window.__odysseusLoginAppUrl || ((path) => path))('/static/js/theme.js'));
const pattern = window.__loginBgPattern;
if (pattern && tm.applyBgPattern) tm.applyBgPattern(pattern);
} catch (e) {
+283
View File
@@ -0,0 +1,283 @@
"""Auth middleware must evaluate the same path that Starlette routes."""
import json
import os
from pathlib import Path
import subprocess
import sys
import textwrap
from urllib.parse import urljoin, urlparse
import pytest
from core.middleware import (
get_application_route_path,
path_is_route_or_child,
with_asgi_root_path,
)
ROOT = Path(__file__).resolve().parents[1]
def test_login_page_uses_mount_aware_urls():
html = (ROOT / "static/login.html").read_text(encoding="utf-8")
assert "window.__odysseusLoginAppUrl" in html
for api_path in (
"/api/version",
"/api/auth/policy",
"/api/auth/status",
"/api/auth/login",
"/api/auth/setup",
"/api/auth/signup",
"/api/sessions",
"/api/auth/features",
"/api/auth/settings",
):
assert f"appUrl('{api_path}')" in html
assert "window.location.replace(appUrl('/'))" in html
assert (
"__odysseusLoginAppUrl || ((path) => path))('/static/js/theme.js')"
in html
)
def test_login_page_static_assets_resolve_under_mount_path():
html = (ROOT / "static/login.html").read_text(encoding="utf-8")
for forbidden in (
"fetch('/api/",
'fetch("/api/',
"window.location.replace('/')",
'window.location.replace("/")',
"import('/static/",
'href="/static/',
"url('/static/",
):
assert forbidden not in html
mounted_login_url = "https://example.test/odysseus/login"
for relative_asset, mounted_path in (
("static/manifest.json", "/odysseus/static/manifest.json"),
("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"),
(
"static/fonts/FiraCode-Regular.woff2",
"/odysseus/static/fonts/FiraCode-Regular.woff2",
),
(
"static/fonts/FiraCode-SemiBold.woff2",
"/odysseus/static/fonts/FiraCode-SemiBold.woff2",
),
):
assert relative_asset in html
assert urlparse(urljoin(mounted_login_url, relative_asset)).path == mounted_path
@pytest.mark.parametrize(
("root_path", "path", "expected"),
[
("", "/api/models", "/api/models"),
("/odysseus", "/odysseus/api/models", "/api/models"),
("/odysseus/", "/odysseus//api/models", "/api/models"),
("/", "//api/models", "/api/models"),
("/odysseus", "/odyssey/api/models", "/odyssey/api/models"),
("/app", "/application/api/models", "/application/api/models"),
("/odysseus", "/odysseus", ""),
],
)
def test_application_route_path_matches_starlette_semantics(
root_path,
path,
expected,
):
assert get_application_route_path({
"root_path": root_path,
"path": path,
}) == expected
@pytest.mark.parametrize(
("root_path", "expected"),
[
("", "/login"),
("/odysseus", "/odysseus/login"),
("/odysseus/", "/odysseus/login"),
("/", "/login"),
],
)
def test_client_redirect_path_includes_asgi_root_path(root_path, expected):
assert with_asgi_root_path({"root_path": root_path}, "/login") == expected
def test_route_prefix_matching_is_segment_aware():
assert path_is_route_or_child("/assets", "/assets") is True
assert path_is_route_or_child("/assets/app.js", "/assets") is True
assert path_is_route_or_child("/assets-v2/app.js", "/assets") is False
def test_real_auth_middleware_uses_application_relative_path(tmp_path):
env = os.environ.copy()
env.update({
"AUTH_ENABLED": "true",
"CHROMADB_CONNECT_TIMEOUT": "0.01",
"CHROMADB_HOST": "127.0.0.1",
"CHROMADB_PORT": "9",
"DATABASE_URL": f"sqlite:///{tmp_path / 'app.db'}",
"LOCALHOST_BYPASS": "false",
"ODYSSEUS_DATA_DIR": str(tmp_path),
"ODYSSEUS_DISABLE_MCP": "1",
"OPENAI_API_KEY": "",
"PYTHONPATH": str(ROOT),
"PYTHON_DOTENV_DISABLED": "1",
})
probe = textwrap.dedent(
"""
import asyncio
import json
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
import app as app_module
class _AuthManager:
def __init__(self, configured):
self.is_configured = configured
@staticmethod
def validate_token(_token):
return False
@staticmethod
def get_username_for_token(_token):
return None
def _scope(root_path, route_path, downstream):
full_path = root_path + route_path
return {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": full_path,
"raw_path": full_path.encode(),
"root_path": root_path,
"query_string": b"",
"headers": [],
"client": ("192.0.2.10", 4321),
"server": ("testserver", 80),
"app": downstream,
}
async def _case(root_path, route_path, *, configured):
manager = _AuthManager(configured)
app_module.auth_manager = manager
calls = []
async def endpoint(request):
calls.append(request)
return JSONResponse({"reached": True})
downstream = Starlette(routes=[Route(route_path, endpoint)])
downstream.state.auth_manager = manager
middleware = app_module.AuthMiddleware(downstream)
scope = _scope(root_path, route_path, downstream)
sent = []
request_sent = False
async def receive():
nonlocal request_sent
if request_sent:
return {"type": "http.disconnect"}
request_sent = True
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
sent.append(message)
await middleware(scope, receive, send)
response_start = next(m for m in sent if m["type"] == "http.response.start")
headers = {k.decode().lower(): v.decode() for k, v in response_start["headers"]}
return {
"status": response_start["status"],
"location": headers.get("location"),
"called": len(calls),
}
async def main():
setup = await _case("/odysseus", "/api/auth/setup", configured=False)
mounted_api = await _case("/odysseus", "/api/models", configured=True)
mounted_browser = await _case("/odysseus", "/notes", configured=True)
webhook = await _case(
"/odysseus",
"/api/tasks/task-1/webhook/secret-token",
configured=True,
)
static_child = await _case(
"/odysseus",
"/static/app.js",
configured=True,
)
static_lookalike = await _case(
"/odysseus",
"/static-v2/app.js",
configured=True,
)
default_api = await _case("", "/api/models", configured=True)
print("RESULT=" + json.dumps({
"setup": setup,
"mounted_api": mounted_api,
"mounted_browser": mounted_browser,
"webhook": webhook,
"static_child": static_child,
"static_lookalike": static_lookalike,
"default_api": default_api,
}, sort_keys=True))
asyncio.run(main())
"""
)
result = subprocess.run(
[sys.executable, "-c", probe],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=30,
check=False,
)
assert result.returncode == 0, result.stderr
result_line = next(
(line for line in result.stdout.splitlines() if line.startswith("RESULT=")),
None,
)
assert result_line is not None, result.stdout
payload = json.loads(result_line.removeprefix("RESULT="))
assert payload["setup"] == {"status": 200, "location": None, "called": 1}
assert payload["webhook"] == {"status": 200, "location": None, "called": 1}
assert payload["static_child"] == {
"status": 200,
"location": None,
"called": 1,
}
assert payload["static_lookalike"] == {
"status": 302,
"location": "/odysseus/login",
"called": 0,
}
assert payload["mounted_browser"] == {
"status": 302,
"location": "/odysseus/login",
"called": 0,
}
for name in ("mounted_api", "default_api"):
assert payload[name] == {"status": 401, "location": None, "called": 0}