mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
fix(auth): normalize mounted request paths (#5807)
* fix(auth): normalize mounted request paths * fix: make login page mount-aware
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user