diff --git a/CHANGELOG.md b/CHANGELOG.md index 234c83a6..e2496678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。 +### 修复 + +- 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳。403 修复后的路径是:可选模型配置(云 Key / 本机,可跳过)→ 第一个智能体 `/chat/main`,不经过登录墙,也不停在工作台列表。`/setup` 在 guest 已创建后不再打回登录页。 + ### 文档 - 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。 diff --git a/dashboard/src/api/request.ts b/dashboard/src/api/request.ts index 9d8ef80a..6fecd513 100644 --- a/dashboard/src/api/request.ts +++ b/dashboard/src/api/request.ts @@ -95,7 +95,10 @@ function handleSetupRequired(): void { /** Remembered Wails shell flag — keep ``?desktop=1`` if the query was dropped. */ function setupRedirectPath(): string { try { - if (window.sessionStorage.getItem("freeos:desktop-shell") === "1") { + if ( + window.sessionStorage.getItem("freeos:desktop-shell") === "1" || + window.localStorage.getItem("freeos:desktop-shell") === "1" + ) { return "/setup?desktop=1"; } } catch { @@ -204,7 +207,7 @@ function buildHeaders(path: string, extra?: HeadersInit): HeadersInit { // Apply the global JWT first; the caller's `extra` (including a // wizard token) can still override it below. - const token = getAuthToken(); + const token = getAuthToken().trim(); if (token) { headers.Authorization = `Bearer ${token}`; } @@ -242,7 +245,7 @@ function buildAuthHeaders(path: string): Record { const headers: Record = { "Accept-Language": i18n.language?.startsWith("zh") ? "zh" : "en", }; - const token = getAuthToken(); + const token = getAuthToken().trim(); if (token) { headers.Authorization = `Bearer ${token}`; } @@ -271,6 +274,16 @@ function handleUnauthorized(): void { path.startsWith("/invite") ) return; + try { + if ( + window.sessionStorage.getItem("freeos:desktop-shell") === "1" || + window.localStorage.getItem("freeos:desktop-shell") === "1" + ) { + return; + } + } catch { + /* private mode */ + } _redirectingToLogin = true; const takenOver = !window.dispatchEvent( diff --git a/dashboard/src/components/AuthGuard.test.tsx b/dashboard/src/components/AuthGuard.test.tsx index fca54a28..71ffd740 100644 --- a/dashboard/src/components/AuthGuard.test.tsx +++ b/dashboard/src/components/AuthGuard.test.tsx @@ -42,6 +42,8 @@ function renderGuard() { /> login wall} /> setup wizard} /> + first agent} /> + conversation list} /> , ); @@ -80,7 +82,7 @@ describe("AuthGuard local session", () => { renderGuard(); - expect(await screen.findByText("usable app")).toBeInTheDocument(); + expect(await screen.findByText("setup wizard")).toBeInTheDocument(); expect(screen.queryByText("login wall")).toBeNull(); expect(getAuthToken()).toBe("guest-token"); await waitFor(() => expect(localSession).toHaveBeenCalledOnce()); @@ -205,25 +207,71 @@ describe("AuthGuard local session", () => { }); render( - + -
usable app
+
conversation list
} /> login wall} /> model setup} /> + first agent} />
, ); - expect(await screen.findByText("usable app")).toBeInTheDocument(); + expect(await screen.findByText("first agent")).toBeInTheDocument(); expect(screen.queryByText("model setup")).toBeNull(); expect(screen.queryByText("login wall")).toBeNull(); + expect(screen.queryByText("conversation list")).toBeNull(); + }); + + it("does not keep the /projects dump as home on first desktop launch", async () => { + getAuthStatus.mockResolvedValue({ + setup_required: true, + has_providers: false, + desktop: true, + }); + localSession.mockResolvedValue({ + access_token: "guest-token", + token_type: "Bearer", + expires_in: 3600, + user: { + id: 1, + username: "local", + role: "admin", + display_name: "FreeOS", + locale: "zh", + is_local: true, + }, + token: "guest-token", + }); + + render( + + + +
conversation list
+ + } + /> + login wall} /> + model setup} /> + first agent} /> +
+
, + ); + + expect(await screen.findByText("model setup")).toBeInTheDocument(); + expect(screen.queryByText("login wall")).toBeNull(); + expect(screen.queryByText("conversation list")).toBeNull(); }); it("opens the studio door even when the organization room is available", async () => { @@ -251,7 +299,7 @@ describe("AuthGuard local session", () => { renderGuard(); - expect(await screen.findByText("usable app")).toBeInTheDocument(); + expect(await screen.findByText("setup wizard")).toBeInTheDocument(); expect(screen.queryByText("login wall")).toBeNull(); expect(getAuthToken()).toBe("guest-token"); }); @@ -282,4 +330,31 @@ describe("AuthGuard local session", () => { expect(screen.queryByText("usable app")).toBeNull(); view.unmount(); }); + + it("keeps retrying after the router drops ?desktop=1", async () => { + sessionStorage.setItem("freeos:desktop-shell", "1"); + getAuthStatus.mockResolvedValue({ setup_required: false, desktop: false }); + localSession.mockRejectedValue(new Error("interactive login required")); + + const view = render( + + + +
usable app
+ + } + /> + login wall} /> + setup wizard} /> +
+
, + ); + + await waitFor(() => expect(localSession).toHaveBeenCalled()); + expect(screen.queryByText("login wall")).toBeNull(); + view.unmount(); + }); }); diff --git a/dashboard/src/components/AuthGuard.tsx b/dashboard/src/components/AuthGuard.tsx index 490c578a..f99640eb 100644 --- a/dashboard/src/components/AuthGuard.tsx +++ b/dashboard/src/components/AuthGuard.tsx @@ -4,7 +4,11 @@ import { Spin } from "antd"; import { clearAuthToken, getAuthToken, setAuthToken } from "../api/request"; import { authApi, type OctopUser } from "../api/modules/auth"; import { applyUserLocale } from "../utils/locale"; -import { needsDesktopModelOnboarding } from "../utils/desktopOnboarding"; +import { + DESKTOP_MODEL_SETUP_PATH, + desktopPostSessionPath, + needsDesktopModelOnboarding, +} from "../utils/desktopOnboarding"; import { isDesktopShell } from "../utils/desktopShell"; import { CurrentUserProvider } from "../hooks/useCurrentUser"; import { AuthPromptProvider } from "../context/AuthPromptContext"; @@ -39,26 +43,19 @@ export default function AuthGuard({ children }: AuthGuardProps) { } }; - const enterAfterSession = async ( - me: OctopUser, - hasProviders: boolean, - desktop: boolean, - ) => { - if (desktop && needsDesktopModelOnboarding(hasProviders)) { - if (!cancelled) navigate("/setup", { replace: true }); - return; - } + const enterAfterSession = async (me: OctopUser, hasProviders: boolean) => { + // Open → optional model (skippable) → first agent. Never the login wall. + const next = desktopPostSessionPath(hasProviders); + if (!cancelled) navigate(next, { replace: true }); + if (next === DESKTOP_MODEL_SETUP_PATH) return; await adopt(me); }; - const adoptLocal = async ( - hasProviders = false, - desktop = shellDesktop, - ): Promise => { + const adoptLocal = async (hasProviders = false): Promise => { try { const res = await authApi.localSession(); setAuthToken(res.access_token); - await enterAfterSession(res.user, hasProviders, desktop); + await enterAfterSession(res.user, hasProviders); return true; } catch { return false; @@ -69,10 +66,9 @@ export default function AuthGuard({ children }: AuthGuardProps) { attempts: number, delayMs: number, hasProviders = false, - desktop = shellDesktop, ) => { for (let attempt = 0; attempt < attempts; attempt += 1) { - if (await adoptLocal(hasProviders, desktop)) return true; + if (await adoptLocal(hasProviders)) return true; if (attempt < attempts - 1) { await new Promise((resolve) => { window.setTimeout(resolve, delayMs); @@ -82,12 +78,9 @@ export default function AuthGuard({ children }: AuthGuardProps) { return false; }; - const holdForDesktop = async ( - hasProviders = false, - desktop = shellDesktop, - ) => { + const holdForDesktop = async (hasProviders = false) => { while (!cancelled) { - if (await adoptLocal(hasProviders, desktop)) return; + if (await adoptLocal(hasProviders)) return; await new Promise((resolve) => { window.setTimeout(resolve, 400); }); @@ -106,12 +99,11 @@ export default function AuthGuard({ children }: AuthGuardProps) { desktop ? 20 : 4, desktop ? 250 : 150, hasProviders, - desktop, ) ) return; if (desktop) { - await holdForDesktop(hasProviders, desktop); + await holdForDesktop(hasProviders); return; } clearAuthToken(); @@ -120,24 +112,23 @@ export default function AuthGuard({ children }: AuthGuardProps) { } if (desktop && needsDesktopModelOnboarding(hasProviders)) { - if (await tryLocalSession(20, 250, hasProviders, desktop)) return; + if (await tryLocalSession(20, 250, hasProviders)) return; if (!cancelled) navigate("/setup", { replace: true }); return; } - const token = getAuthToken(); + const token = getAuthToken().trim(); if (!token) { if ( await tryLocalSession( desktop ? 20 : 4, desktop ? 250 : 150, hasProviders, - desktop, ) ) return; if (desktop) { - await holdForDesktop(hasProviders, desktop); + await holdForDesktop(hasProviders); return; } if (!cancelled) { @@ -156,12 +147,11 @@ export default function AuthGuard({ children }: AuthGuardProps) { desktop ? 20 : 4, desktop ? 250 : 150, hasProviders, - desktop, ) ) return; if (desktop) { - await holdForDesktop(hasProviders, desktop); + await holdForDesktop(hasProviders); return; } if (!cancelled) { diff --git a/dashboard/src/hooks/useUnauthorizedRedirect.ts b/dashboard/src/hooks/useUnauthorizedRedirect.ts index 73ed31f2..408d8515 100644 --- a/dashboard/src/hooks/useUnauthorizedRedirect.ts +++ b/dashboard/src/hooks/useUnauthorizedRedirect.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { UNAUTHORIZED_EVENT } from "../api/request"; +import { isDesktopShell } from "../utils/desktopShell"; /** * Route an expired session to /login through the router. @@ -15,6 +16,7 @@ export function useUnauthorizedRedirect(): void { useEffect(() => { const handler = (event: Event) => { event.preventDefault(); + if (isDesktopShell()) return; navigate("/login", { replace: true }); }; window.addEventListener(UNAUTHORIZED_EVENT, handler); diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 359b7757..b787887a 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -2,6 +2,7 @@ // registered synchronously before Chrome fires the event (which can happen // before React mounts and useEffect runs). import "./pwa-prompt"; +import { isDesktopShell } from "./utils/desktopShell"; import { createRoot } from "react-dom/client"; import App from "./App.tsx"; @@ -14,6 +15,8 @@ import { } from "./utils/reloadOnStaleChunk"; if (typeof window !== "undefined") { + // Capture `?desktop=1` before React Router replaces `/`. + isDesktopShell(); // Recover from post-deploy stale hashed chunks (white screen → soft reload). installChunkLoadRecovery(); diff --git a/dashboard/src/pages/Login/index.test.tsx b/dashboard/src/pages/Login/index.test.tsx index 8bb31906..ac496ff5 100644 --- a/dashboard/src/pages/Login/index.test.tsx +++ b/dashboard/src/pages/Login/index.test.tsx @@ -32,6 +32,7 @@ function renderLogin() { } /> usable app} /> + first chat} /> setup wizard} /> conversation list} /> @@ -66,7 +67,7 @@ describe("LoginPage local session", () => { renderLogin(); - expect(await screen.findByText("usable app")).toBeInTheDocument(); + expect(await screen.findByText("setup wizard")).toBeInTheDocument(); expect(screen.queryByText("login form")).toBeNull(); expect(getAuthToken()).toBe("guest-token"); await waitFor(() => expect(localSession).toHaveBeenCalledOnce()); @@ -115,7 +116,7 @@ describe("LoginPage local session", () => { expect(getAuthToken()).toBe("guest-token"); }); - it("opens returning desktop users on the conversation list", async () => { + it("opens returning desktop users on the first agent, not the list", async () => { localSession.mockResolvedValue({ access_token: "guest-token", token_type: "Bearer", @@ -141,15 +142,17 @@ describe("LoginPage local session", () => { } /> usable app} /> + first chat} /> model setup} /> conversation list} />
, ); - expect(await screen.findByText("conversation list")).toBeInTheDocument(); + expect(await screen.findByText("first chat")).toBeInTheDocument(); expect(screen.queryByText("login form")).toBeNull(); expect(screen.queryByText("model setup")).toBeNull(); + expect(screen.queryByText("conversation list")).toBeNull(); }); it("does not render the login form inside the desktop shell", async () => { diff --git a/dashboard/src/pages/Login/index.tsx b/dashboard/src/pages/Login/index.tsx index af31e9eb..cc0b7d0e 100644 --- a/dashboard/src/pages/Login/index.tsx +++ b/dashboard/src/pages/Login/index.tsx @@ -47,18 +47,13 @@ export default function LoginPage() { await applyUserLocale(session.user.locale); if (!cancelled) { let hasProviders = false; - let desktopFlow = desktop; try { const status = await authApi.getAuthStatus(); hasProviders = status?.has_providers === true; - desktopFlow = desktopFlow || status?.desktop === true; } catch { /* first-run still goes to model setup when the probe fails */ } - navigate( - desktopFlow ? desktopPostSessionPath(hasProviders) : "/chat", - { replace: true }, - ); + navigate(desktopPostSessionPath(hasProviders), { replace: true }); } return; } catch { diff --git a/dashboard/src/pages/Setup/index.test.tsx b/dashboard/src/pages/Setup/index.test.tsx index 1468cbd4..26bc1bc7 100644 --- a/dashboard/src/pages/Setup/index.test.tsx +++ b/dashboard/src/pages/Setup/index.test.tsx @@ -139,8 +139,9 @@ describe("SetupPage desktop first-run", () => { renderSetup(); - expect(await screen.findByText("conversation list")).toBeInTheDocument(); + expect(await screen.findByText("first chat")).toBeInTheDocument(); expect(screen.queryByText("model step")).toBeNull(); + expect(screen.queryByText("conversation list")).toBeNull(); expect(isDesktopModelOnboardingDone()).toBe(true); }); @@ -159,4 +160,24 @@ describe("SetupPage desktop first-run", () => { expect(screen.queryByText("model step")).toBeNull(); await waitFor(() => expect(localSession).not.toHaveBeenCalled()); }); + + it("does not bounce a local guest to login after setup_required becomes false", async () => { + isDesktopShell.mockReturnValue(false); + getAuthStatus.mockResolvedValue({ + setup_required: false, + wizard_password_required: false, + desktop: false, + has_providers: false, + }); + localSession.mockResolvedValue({ + access_token: "guest-token", + user: { id: 1, username: "local", locale: "zh" }, + }); + + renderSetup(); + + expect(await screen.findByText("model step")).toBeInTheDocument(); + expect(screen.queryByText("login wall")).toBeNull(); + expect(screen.queryByText("password step")).toBeNull(); + }); }); diff --git a/dashboard/src/pages/Setup/index.tsx b/dashboard/src/pages/Setup/index.tsx index e9b2d4ad..8f909a8b 100644 --- a/dashboard/src/pages/Setup/index.tsx +++ b/dashboard/src/pages/Setup/index.tsx @@ -108,6 +108,22 @@ export default function SetupPage() { } if (!status.setup_required) { + // local-session already created the guest, so setup_required is false. + // Do not bounce to /login — offer optional model setup on this device. + if (needsDesktopModelOnboarding(status.has_providers === true)) { + try { + const session = await authApi.localSession(); + setAuthToken(session.access_token); + wizardSession.saveSetupJwt(session.access_token); + if (cancelled) return; + setDesktopFlow(true); + goToStep(STEP_MODEL); + setChecking(false); + return; + } catch { + /* remote host still uses the login wall */ + } + } wizardSession.clearAll(); navigate("/login", { replace: true }); return; diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 37b1d57d..ecfd5a37 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -476,6 +476,6 @@ export const routeConfigs: RouteConfig[] = [ // Misc { path: "/pwa-debug", element: }, - { path: "/", element: }, + { path: "/", element: }, { path: "*", element: }, ]; diff --git a/dashboard/src/utils/desktopOnboarding.test.ts b/dashboard/src/utils/desktopOnboarding.test.ts index cfcfdc24..66368f43 100644 --- a/dashboard/src/utils/desktopOnboarding.test.ts +++ b/dashboard/src/utils/desktopOnboarding.test.ts @@ -27,7 +27,8 @@ describe("desktopOnboarding", () => { it("treats an existing provider as already finished", () => { expect(needsDesktopModelOnboarding(true)).toBe(false); - expect(desktopPostSessionPath(true)).toBe(DESKTOP_RETURNING_HOME_PATH); + expect(desktopPostSessionPath(true)).toBe(DESKTOP_FIRST_CHAT_PATH); + expect(desktopPostSessionPath(true)).not.toBe("/projects"); expect(isDesktopModelOnboardingDone()).toBe(true); }); diff --git a/dashboard/src/utils/desktopOnboarding.ts b/dashboard/src/utils/desktopOnboarding.ts index 37d44bc6..0ada630c 100644 --- a/dashboard/src/utils/desktopOnboarding.ts +++ b/dashboard/src/utils/desktopOnboarding.ts @@ -6,8 +6,8 @@ export const DESKTOP_FIRST_AGENT_ID = "main"; /** Canvas for the first-run assistant — not the shared conversation list. */ export const DESKTOP_FIRST_CHAT_PATH = `/chat/${DESKTOP_FIRST_AGENT_ID}`; -/** Returning desktop users land on the shared workspace conversation list. */ -export const DESKTOP_RETURNING_HOME_PATH = "/projects"; +/** After the door is open, land on the first agent — not the workspace list. */ +export const DESKTOP_RETURNING_HOME_PATH = DESKTOP_FIRST_CHAT_PATH; export const DESKTOP_MODEL_SETUP_PATH = "/setup"; @@ -37,15 +37,15 @@ export function needsDesktopModelOnboarding(hasProviders = false): boolean { } /** - * Path after a desktop local session is adopted. - * First launch → model setup; later launches → conversation list (not a loop). + * Path after a this-device local session is adopted. + * First launch → optional model setup; later launches → first agent chat. */ export function desktopPostSessionPath(hasProviders = false): string { if (needsDesktopModelOnboarding(hasProviders)) { return DESKTOP_MODEL_SETUP_PATH; } if (hasProviders) markDesktopModelOnboardingDone(); - return DESKTOP_RETURNING_HOME_PATH; + return DESKTOP_FIRST_CHAT_PATH; } /** After skip or a successful model save: chat with the default first agent. */ diff --git a/dashboard/src/utils/desktopShell.test.ts b/dashboard/src/utils/desktopShell.test.ts index 092f24ac..4984c926 100644 --- a/dashboard/src/utils/desktopShell.test.ts +++ b/dashboard/src/utils/desktopShell.test.ts @@ -4,6 +4,7 @@ import { isDesktopShell } from "./desktopShell"; describe("isDesktopShell", () => { afterEach(() => { sessionStorage.clear(); + localStorage.clear(); }); it("detects the desktop query and remembers it after the query is dropped", () => { @@ -11,6 +12,12 @@ describe("isDesktopShell", () => { expect(isDesktopShell("")).toBe(true); }); + it("survives a dropped query after React Router replaces /", () => { + expect(isDesktopShell("?desktop=1")).toBe(true); + sessionStorage.clear(); + expect(isDesktopShell("")).toBe(true); + }); + it("is false without the query or a remembered flag", () => { expect(isDesktopShell("")).toBe(false); expect(isDesktopShell("?foo=1")).toBe(false); diff --git a/dashboard/src/utils/desktopShell.ts b/dashboard/src/utils/desktopShell.ts index f7538b72..50333b50 100644 --- a/dashboard/src/utils/desktopShell.ts +++ b/dashboard/src/utils/desktopShell.ts @@ -1,25 +1,39 @@ const DESKTOP_FLAG = "freeos:desktop-shell"; +function rememberDesktopFlag(): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(DESKTOP_FLAG, "1"); + window.localStorage.setItem(DESKTOP_FLAG, "1"); + } catch { + // Ignore quota / private-mode failures; the query string still counts. + } +} + +function rememberedDesktopFlag(): boolean { + if (typeof window === "undefined") return false; + try { + return ( + window.sessionStorage.getItem(DESKTOP_FLAG) === "1" || + window.localStorage.getItem(DESKTOP_FLAG) === "1" + ); + } catch { + return false; + } +} + /** True when the SPA is inside the Wails shell (`?desktop=1` or remembered). */ export function isDesktopShell(search?: string): boolean { const query = search ?? (typeof window === "undefined" ? "" : window.location.search); if (new URLSearchParams(query).get("desktop") === "1") { - if (typeof window !== "undefined") { - try { - window.sessionStorage.setItem(DESKTOP_FLAG, "1"); - } catch { - // Ignore quota / private-mode failures; the query string still counts. - } - } + rememberDesktopFlag(); return true; } - if (typeof window === "undefined") { - return false; - } - try { - return window.sessionStorage.getItem(DESKTOP_FLAG) === "1"; - } catch { - return false; - } + return rememberedDesktopFlag(); +} + +/** Capture `?desktop=1` before React Router navigates `/` → `/projects`. */ +if (typeof window !== "undefined") { + isDesktopShell(); } diff --git a/src/octop/api/routers/auth.py b/src/octop/api/routers/auth.py index 1e73eb3b..5e6b66ab 100644 --- a/src/octop/api/routers/auth.py +++ b/src/octop/api/routers/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from fastapi import APIRouter, Depends, Request, Response @@ -13,26 +14,24 @@ claim_local_account, ensure_local_user, is_desktop_process, - is_loopback_host, is_unclaimed_local_user, + request_looks_local, ) from octop.infra.users.permissions import effective_permissions from octop.infra.utils.locale import normalize_locale, resolve_request_locale router = APIRouter() +logger = logging.getLogger(__name__) def _is_local_client(request: Request) -> bool: - if is_desktop_process(): - return True - host = (request.client.host if request.client else "") or "" - if is_loopback_host(host): - return True - forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() - if is_loopback_host(forwarded): - return True - req_host = (request.headers.get("host") or "").split(":")[0].lower() - return is_loopback_host(req_host) + return request_looks_local( + client_host=(request.client.host if request.client else "") or "", + forwarded_for=request.headers.get("x-forwarded-for") or "", + http_host=request.headers.get("host") or "", + origin=request.headers.get("origin") or "", + referer=request.headers.get("referer") or "", + ) def _user_json( @@ -99,6 +98,14 @@ class RegisterBody(BaseModel): async def local_session(request: Request, server: Any = Depends(get_server)) -> dict[str, Any]: """Issue a JWT without a login form on desktop / loopback first launch.""" if not _is_local_client(request): + logger.warning( + "local-session denied client=%s host=%s origin=%s forwarded=%s desktop=%s", + request.client.host if request.client else "", + request.headers.get("host"), + request.headers.get("origin"), + request.headers.get("x-forwarded-for"), + is_desktop_process(), + ) raise OctopError(ErrorCode.FORBIDDEN, "local session is only available on this device") locale = normalize_locale(resolve_request_locale(request)) user = await ensure_local_user(server, locale=locale) diff --git a/src/octop/api/routers/org_identity.py b/src/octop/api/routers/org_identity.py index d9271906..21119b0d 100644 --- a/src/octop/api/routers/org_identity.py +++ b/src/octop/api/routers/org_identity.py @@ -34,12 +34,16 @@ async def identity_status() -> dict[str, Any]: async def forward(request: Request, server: Any, path: str) -> Response: require_integrated() + extra_headers: dict[str, str] = {} + authorization = (request.headers.get("authorization") or "").strip() + if authorization: + extra_headers["Authorization"] = authorization try: return await proxy_request( request, base_url=org_module_from_paths(server.paths).sidecar_url(), path=path, - extra_headers={"Authorization": request.headers.get("authorization", "")}, + extra_headers=extra_headers, ) except httpx.HTTPError as exc: raise OctopError( diff --git a/src/octop/infra/users/local_session.py b/src/octop/infra/users/local_session.py index e15c49c2..16bbf0fb 100644 --- a/src/octop/infra/users/local_session.py +++ b/src/octop/infra/users/local_session.py @@ -7,6 +7,7 @@ import os import re from typing import Any +from urllib.parse import urlparse from octop.config import DatabaseConfig from octop.infra.agents.default_agent import SETUP_DEFAULT_AGENT_ID, try_bootstrap_default_agent @@ -32,18 +33,65 @@ def is_desktop_process() -> bool: return bool((os.environ.get("OCTOP_GREEN_PACKAGES") or "").strip()) +def hostname_from_host_header(host: str) -> str: + """Strip ``:port`` from a Host header, including bracketed IPv6.""" + h = (host or "").strip() + if not h: + return "" + if h.startswith("["): + end = h.find("]") + if end != -1: + return h[1:end].lower() + if h.count(":") == 1: + return h.split(":", 1)[0].lower() + return h.lower() + + +def hostname_from_url(url: str) -> str: + """Hostname from an Origin / Referer URL, or empty when missing.""" + raw = (url or "").strip() + if not raw: + return "" + parsed = urlparse(raw if "://" in raw else f"http://{raw}") + host = parsed.hostname or "" + return host.lower() + + def is_loopback_host(host: str) -> bool: - """True for localhost, IPv4/IPv6 loopback, and IPv4-mapped ::ffff:127.0.0.1.""" - h = (host or "").strip().lower() + """True for localhost, ``*.localhost``, IPv4/IPv6 loopback, and mapped IPv4.""" + h = hostname_from_host_header(host) if h.startswith("[") and h.endswith("]"): h = h[1:-1] if h in {"127.0.0.1", "::1", "localhost", "testclient"}: return True + if h.endswith(".localhost"): + return True if h.startswith("::ffff:"): return h.rsplit(":", 1)[-1] in {"127.0.0.1", "localhost"} return False +def request_looks_local( + *, + client_host: str = "", + forwarded_for: str = "", + http_host: str = "", + origin: str = "", + referer: str = "", +) -> bool: + """True when this HTTP request is from the machine that runs the server.""" + if is_desktop_process(): + return True + candidates = ( + client_host, + (forwarded_for or "").split(",")[0].strip(), + hostname_from_host_header(http_host), + hostname_from_url(origin), + hostname_from_url(referer), + ) + return any(is_loopback_host(value) for value in candidates if value) + + def is_unclaimed_local_user(server: Any, user: User) -> bool: """True while the auto-provisioned local user has not registered.""" if server.services is None: @@ -140,17 +188,18 @@ async def ensure_local_user(server: Any, *, locale: str) -> User: user = _single_user(server) if user is not None: return user - if is_desktop_process(): - picked = preferred_existing_user(server) - if picked is not None: - logger.info( - "desktop local-session using existing user %s id=%s", - picked.username, - picked.id, - ) - return picked - return await _provision_local_user(server, locale=loc) - raise OctopError(ErrorCode.FORBIDDEN, "interactive login required") + # Already gated by the HTTP handler as this device (desktop / loopback). + # Returning installs often have extra org-mapped rows, so never demand + # interactive login here — pick a studio principal or mint a guest. + picked = preferred_existing_user(server) + if picked is not None: + logger.info( + "local-session using existing user %s id=%s", + picked.username, + picked.id, + ) + return picked + return await _provision_local_user(server, locale=loc) async def claim_local_account( diff --git a/src/octop/modules/org_os/integration.py b/src/octop/modules/org_os/integration.py index e226f355..01cce2ec 100644 --- a/src/octop/modules/org_os/integration.py +++ b/src/octop/modules/org_os/integration.py @@ -21,12 +21,15 @@ def integrated_organization() -> bool: async def organization_user(server: Any, token: str) -> Any: """Validate at the identity authority on every request (including revocation).""" + bearer = (token or "").strip() + if not bearer: + raise OctopError(ErrorCode.AUTH_FAILED, "organization session expired or revoked") service = org_module_from_paths(server.paths) try: async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: result = await client.get( f"{service.sidecar_url()}/api/auth/me", - headers={"Authorization": f"Bearer {token}"}, + headers={"Authorization": f"Bearer {bearer}"}, ) except httpx.HTTPError as exc: raise OctopError( diff --git a/tests/integration/test_local_session.py b/tests/integration/test_local_session.py index 1007cdc5..c931694e 100644 --- a/tests/integration/test_local_session.py +++ b/tests/integration/test_local_session.py @@ -66,7 +66,13 @@ async def test_save_requires_account_then_register(fresh_client) -> None: assert me.json()["is_local"] is False -async def test_local_session_refuses_when_multiple_users(app_client) -> None: +async def test_local_session_loopback_picks_admin_when_multiple_users( + app_client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Returning ~/.freeos with extra users still gets a studio session on loopback.""" + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) c, _srv, home = app_client await bootstrap_admin(c, home, username="alice", password="TestPass12") tok = ( @@ -75,7 +81,43 @@ async def test_local_session_refuses_when_multiple_users(app_client) -> None: admin_auth = {"Authorization": f"Bearer {tok}"} await create_user(c, admin_auth, username="bob", password="TestPass12") r = await c.post("/api/auth/local-session") - assert r.status_code == 403 + assert r.status_code == 200 + assert r.json()["user"]["username"] == "alice" + assert r.json()["user"]["role"] == "admin" + + +async def test_local_session_rejects_non_loopback_client( + tmp_octop_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + async with octop_client(tmp_octop_home, bind_database=False) as (c, _srv): + app = c._octop_app # type: ignore[attr-defined] + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, client=("8.8.8.8", 43210)), + base_url="http://example.com", + ) as remote: + r = await remote.post("/api/auth/local-session") + assert r.status_code == 403 + assert r.json()["error"]["code"] == "FORBIDDEN" + + +async def test_local_session_accepts_wails_localhost_host( + tmp_octop_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + async with octop_client(tmp_octop_home, bind_database=False) as (c, _srv): + app = c._octop_app # type: ignore[attr-defined] + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, client=("192.168.0.20", 43210)), + base_url="http://wails.localhost:34115", + ) as webview: + r = await webview.post("/api/auth/local-session") + assert r.status_code == 200 + assert r.json()["user"]["username"] == "local" async def test_desktop_first_run_skips_server_wizard( @@ -119,3 +161,23 @@ async def test_local_session_desktop_picks_admin_when_multiple_users( assert r.status_code == 200 assert r.json()["user"]["username"] == "alice" assert r.json()["user"]["role"] == "admin" + + +async def test_desktop_returning_existing_db_without_jwt( + app_client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Existing SQLite + no session cookie still enters via local-session.""" + c, _srv, home = app_client + await bootstrap_admin(c, home, username="owner", password="TestPass12") + monkeypatch.setenv("OCTOP_DESKTOP", "1") + status = await c.get("/api/setup/status") + assert status.json()["setup_required"] is False + assert status.json()["desktop"] is True + r = await c.post("/api/auth/local-session") + assert r.status_code == 200 + assert r.json()["user"]["username"] == "owner" + me = await c.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {r.json()['access_token']}"}, + ) + assert me.status_code == 200 diff --git a/tests/unit/users/test_local_session.py b/tests/unit/users/test_local_session.py index fc3eb39d..06361a02 100644 --- a/tests/unit/users/test_local_session.py +++ b/tests/unit/users/test_local_session.py @@ -12,10 +12,12 @@ from octop.infra.users.local_session import ( LOCAL_SESSION_SETTING, LOCAL_USERNAME, + hostname_from_host_header, is_desktop_process, is_loopback_host, is_unclaimed_local_user, preferred_existing_user, + request_looks_local, require_claimed_account, ) @@ -73,6 +75,44 @@ def test_is_loopback_host_accepts_mapped_ipv4() -> None: assert is_loopback_host("10.0.0.4") is False +def test_is_loopback_host_accepts_localhost_names_and_ports() -> None: + assert is_loopback_host("localhost:8088") is True + assert is_loopback_host("127.0.0.1:8088") is True + assert is_loopback_host("[::1]:8088") is True + assert is_loopback_host("wails.localhost") is True + assert is_loopback_host("wails.localhost:34115") is True + assert hostname_from_host_header("[::1]:8088") == "::1" + + +def test_request_looks_local_uses_origin_when_peer_is_lan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + assert ( + request_looks_local( + client_host="192.168.1.50", + http_host="192.168.1.50:8088", + origin="http://127.0.0.1:8088", + ) + is True + ) + assert ( + request_looks_local( + client_host="8.8.8.8", + http_host="example.com", + origin="https://example.com", + ) + is False + ) + + +def test_request_looks_local_true_on_desktop_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OCTOP_DESKTOP", "1") + assert request_looks_local(client_host="8.8.8.8", http_host="example.com") is True + + def test_preferred_existing_user_picks_sole_admin() -> None: alice = User(id=2, username="alice", role=Role.ADMIN, display_name="Alice") bob = User(id=3, username="bob", role=Role.USER, display_name="Bob")