Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) ---
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
52 changes: 36 additions & 16 deletions static/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -300,9 +319,10 @@ <h1 class="logo">

<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;
Expand Down Expand Up @@ -363,12 +383,12 @@ <h1 class="logo">

// 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;
Expand Down Expand Up @@ -405,7 +425,7 @@ <h1 class="logo">
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',
Expand Down Expand Up @@ -449,7 +469,7 @@ <h1 class="logo">

// 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',
Expand Down Expand Up @@ -477,19 +497,19 @@ <h1 class="logo">
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',
Expand Down Expand Up @@ -587,7 +607,7 @@ <h1 class="logo">
// `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) {
Expand Down
Loading