From 475eead80963dcd59784881e3018b4eba15bec07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Ternstr=C3=B6m?= Date: Sun, 18 Oct 2026 22:19:07 +0200 Subject: [PATCH] fix(web): an expired proxy session asks for a reload instead of reconnecting forever Behind an identity-aware proxy (Cloudflare Access in the reporter's setup), an expired proxy session made the API fetch receive a 302 to the proxy's login page on another origin. fetch cannot follow that, so the health probe saw a network error and the reconnect dialog never resolved; only a manual reload got out (#981). API calls, health probes and uploads now send X-Requested-With: XMLHttpRequest, which Cloudflare Access answers with a 401 instead of the redirect (measured against a live Access application: 302 without the header, 401 with it). A 401 alone would then bounce the user to the token login and wipe a token that is still valid. Codeg's own 401 now carries WWW-Authenticate: Bearer realm="codeg", and the client reads that challenge to tell the two apart: Codeg's 401 keeps today's "Session expired" / "Go to login", any other 401 shows "Sign-in expired" with Reload (the proxy then runs its own login flow; the service worker passes navigations through, so a reload reaches it) and keeps the token. "Go to login" stays in that dialog as the way out if the proxy keeps refusing. The root page no longer clears the token on a proxy's 401, and the login page no longer calls the token invalid for one. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/web/auth.rs | 62 +++++++++++++- src/app/login/page.test.tsx | 74 +++++++++++++++++ src/app/login/page.tsx | 10 ++- src/app/page.test.tsx | 59 ++++++++++++++ src/app/page.tsx | 13 ++- .../connection/web-connection-guard.test.tsx | 30 ++++++- .../connection/web-connection-guard.tsx | 40 +++++++--- src/i18n/messages/ar.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + src/lib/api.ts | 36 ++++++--- src/lib/transport/web-auth.test.ts | 46 +++++++++++ src/lib/transport/web-auth.ts | 26 ++++++ src/lib/transport/web-connection-store.ts | 5 +- src/lib/transport/web-transport.test.ts | 80 ++++++++++++++++++- src/lib/transport/web-transport.ts | 44 +++++++--- 23 files changed, 497 insertions(+), 48 deletions(-) create mode 100644 src/app/login/page.test.tsx create mode 100644 src/app/page.test.tsx create mode 100644 src/lib/transport/web-auth.test.ts diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs index 4e4d9630f1..09a3d4143b 100644 --- a/src-tauri/src/web/auth.rs +++ b/src-tauri/src/web/auth.rs @@ -1,6 +1,6 @@ use axum::{ extract::Request, - http::StatusCode, + http::{header, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; @@ -8,6 +8,20 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; pub const WS_EVENT_PROTOCOL: &str = "codeg-events"; const WS_TOKEN_PROTOCOL_PREFIX: &str = "codeg-token."; +// Sent with every 401 this middleware returns. The web client reads it to +// tell Codeg's own rejection (re-enter the token) from a 401 sent by an +// identity-aware proxy in front of Codeg whose session expired (reload, so the +// proxy can run its login flow); see `src/lib/transport/web-auth.ts`. +const CODEG_CHALLENGE: &str = r#"Bearer realm="codeg""#; + +fn unauthorized(message: &'static str) -> Response { + ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, CODEG_CHALLENGE)], + message, + ) + .into_response() +} fn token_from_ws_protocols(value: &str) -> Option { value @@ -22,7 +36,7 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo // Fail closed on a misconfigured empty token: otherwise `Bearer ` (an empty // bearer value) would match it and silently disable authentication. if token.is_empty() { - return (StatusCode::UNAUTHORIZED, "Server token is not configured").into_response(); + return unauthorized("Server token is not configured"); } if let Some(auth_header) = request.headers().get("authorization") { @@ -41,7 +55,7 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo } } - (StatusCode::UNAUTHORIZED, "Invalid or missing token").into_response() + unauthorized("Invalid or missing token") } #[cfg(test)] @@ -63,4 +77,46 @@ mod tests { fn ignores_invalid_ws_protocol_token() { assert!(token_from_ws_protocols("codeg-events, codeg-token.not-valid-@@@@").is_none()); } + + fn guarded(server_token: &str) -> axum_test::TestServer { + let server_token = server_token.to_string(); + let app = axum::Router::new() + .route("/api/health", axum::routing::post(|| async { "ok" })) + .layer(axum::middleware::from_fn(move |req, next| { + require_token(req, next, server_token.clone()) + })); + axum_test::TestServer::new(app).unwrap() + } + + // The web client tells Codeg's own 401 from one sent by a proxy in front + // of it (whose session expired) by this challenge; see `web-auth.ts`. + #[tokio::test] + async fn every_rejection_carries_the_codeg_challenge() { + let server = guarded("tok"); + + let missing = server.post("/api/health").await; + missing.assert_status_unauthorized(); + assert_eq!(missing.header("www-authenticate"), CODEG_CHALLENGE); + + let wrong = server + .post("/api/health") + .authorization_bearer("nope") + .await; + wrong.assert_status_unauthorized(); + assert_eq!(wrong.header("www-authenticate"), CODEG_CHALLENGE); + + let unconfigured = guarded("").post("/api/health").await; + unconfigured.assert_status_unauthorized(); + assert_eq!(unconfigured.header("www-authenticate"), CODEG_CHALLENGE); + } + + #[tokio::test] + async fn an_accepted_request_carries_no_challenge() { + let ok = guarded("tok") + .post("/api/health") + .authorization_bearer("tok") + .await; + ok.assert_status_ok(); + assert!(ok.maybe_header("www-authenticate").is_none()); + } } diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx new file mode 100644 index 0000000000..3a540a06b3 --- /dev/null +++ b/src/app/login/page.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const router = vi.hoisted(() => ({ replace: vi.fn() })) +vi.mock("next/navigation", () => ({ useRouter: () => router })) +vi.mock("@/lib/platform", () => ({ isDesktop: () => false })) + +import LoginPage from "./page" +import enMessages from "@/i18n/messages/en.json" + +const reply = (status: number, challenge?: string) => ({ + ok: status >= 200 && status < 300, + status, + headers: new Headers(challenge ? { "WWW-Authenticate": challenge } : {}), +}) + +let fetchMock: ReturnType + +beforeEach(() => { + router.replace.mockClear() + fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() +}) + +function submit(token: string) { + render( + + + + ) + fireEvent.change(screen.getByPlaceholderText("Access Token"), { + target: { value: token }, + }) + fireEvent.click(screen.getByRole("button", { name: "Connect" })) +} + +describe("login page", () => { + it("asks a proxy for a 401 instead of a redirect", async () => { + fetchMock.mockResolvedValue(reply(200)) + submit("tok") + await vi.waitFor(() => expect(router.replace).toHaveBeenCalled()) + expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("calls the token invalid when Codeg rejects it", async () => { + fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"')) + submit("bad") + expect( + await screen.findByText("Invalid token. Please check and try again.") + ).toBeInTheDocument() + }) + + it("does not blame the token when a proxy in front of Codeg refused", async () => { + fetchMock.mockResolvedValue( + reply(401, 'Cloudflare-Access resource_metadata="https://x.test/m"') + ) + submit("tok") + expect( + await screen.findByText(/Your sign-in to the proxy/) + ).toBeInTheDocument() + expect( + screen.queryByText("Invalid token. Please check and try again.") + ).not.toBeInTheDocument() + }) +}) diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a02ef36fe3..038182bc16 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,10 +4,12 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { isDesktop } from "@/lib/platform" +import { codegAuthHeaders, unauthorizedSource } from "@/lib/transport/web-auth" export default function LoginPage() { const router = useRouter() const t = useTranslations("LoginPage") + const tConnection = useTranslations("WebConnection") const [token, setToken] = useState("") const [error, setError] = useState("") const [loading, setLoading] = useState(false) @@ -33,7 +35,7 @@ export default function LoginPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: "{}", }) @@ -41,6 +43,12 @@ export default function LoginPage() { if (res.ok) { localStorage.setItem("codeg_token", token) router.replace("/workspace") + } else if ( + res.status === 401 && + unauthorizedSource(res.headers.get("www-authenticate")) === "proxy" + ) { + // The proxy in front of Codeg refused before the token was checked. + setError(tConnection("proxySignInDescription")) } else if (res.status === 401) { setError(t("invalidToken")) } else { diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx new file mode 100644 index 0000000000..d386fb1ab3 --- /dev/null +++ b/src/app/page.test.tsx @@ -0,0 +1,59 @@ +import { render, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const router = vi.hoisted(() => ({ replace: vi.fn() })) +vi.mock("next/navigation", () => ({ useRouter: () => router })) +vi.mock("@/lib/platform", () => ({ isDesktop: () => false })) + +import Page from "./page" + +const reply = (status: number, challenge?: string) => ({ + ok: status >= 200 && status < 300, + status, + headers: new Headers(challenge ? { "WWW-Authenticate": challenge } : {}), +}) + +let fetchMock: ReturnType + +beforeEach(() => { + router.replace.mockClear() + localStorage.setItem("codeg_token", "tok") + fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() +}) + +describe("root page token check", () => { + it("asks a proxy for a 401 instead of a redirect", async () => { + fetchMock.mockResolvedValue(reply(200)) + render() + await waitFor(() => expect(router.replace).toHaveBeenCalled()) + expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("clears the token and goes to /login when Codeg rejects it", async () => { + fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"')) + render() + await waitFor(() => expect(router.replace).toHaveBeenCalledWith("/login")) + expect(localStorage.getItem("codeg_token")).toBeNull() + }) + + it("keeps the token when the 401 came from a proxy in front of Codeg", async () => { + fetchMock.mockResolvedValue( + reply(401, 'Cloudflare-Access resource_metadata="https://x.test/m"') + ) + render() + // The workspace's connection guard offers the reload from there. + await waitFor(() => + expect(router.replace).toHaveBeenCalledWith("/workspace") + ) + expect(localStorage.getItem("codeg_token")).toBe("tok") + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index e5f8ed643e..27c0ebae92 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" import { isDesktop } from "@/lib/platform" +import { codegAuthHeaders, unauthorizedSource } from "@/lib/transport/web-auth" export default function Page() { const router = useRouter() @@ -22,7 +23,7 @@ export default function Page() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: "{}", }) @@ -31,14 +32,18 @@ export default function Page() { router.replace("/workspace") return } - if (res.status === 401) { + if ( + res.status === 401 && + unauthorizedSource(res.headers.get("www-authenticate")) === "codeg" + ) { // Token genuinely rejected → clear it and re-authenticate. localStorage.removeItem("codeg_token") router.replace("/login") return } - // Server reachable but unhealthy (5xx / proxy error). Keep the token - // and enter the app; the in-app reconnect dialog handles recovery + // Server reachable but unhealthy (5xx / proxy error), or a proxy in + // front of Codeg refused (its own session expired). Keep the token + // and enter the app; the in-app connection dialog handles recovery // instead of bouncing a valid session to /login. router.replace("/workspace") }) diff --git a/src/components/connection/web-connection-guard.test.tsx b/src/components/connection/web-connection-guard.test.tsx index 0be1c0b4d5..20134ac39a 100644 --- a/src/components/connection/web-connection-guard.test.tsx +++ b/src/components/connection/web-connection-guard.test.tsx @@ -5,11 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" // Controllable stand-in for the connection store. `vi.hoisted` guarantees the // shared object exists before the hoisted `vi.mock` factory runs. const store = vi.hoisted(() => { - let state: "connected" | "reconnecting" | "unauthorized" = "connected" + type State = + | "connected" + | "reconnecting" + | "unauthorized" + | "proxyUnauthorized" + let state: State = "connected" const listeners = new Set<() => void>() return { getState: () => state, - setState: (s: "connected" | "reconnecting" | "unauthorized") => { + setState: (s: State) => { state = s for (const l of listeners) l() }, @@ -105,6 +110,27 @@ describe("WebConnectionGuard", () => { expect(store.redirectToCodegLogin).toHaveBeenCalledTimes(1) }) + it("offers a reload, not the token login, when the proxy's sign-in expired", () => { + const reload = vi.fn() + vi.stubGlobal("location", { ...window.location, reload }) + renderGuard() + act(() => store.setState("proxyUnauthorized")) + // Surfaced at once, like unauthorized: a rejected session is definitive. + expect(screen.getByText("Sign-in expired")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Reload" })) + expect(reload).toHaveBeenCalledTimes(1) + expect(store.redirectToCodegLogin).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it("still offers the token login from the proxy dialog", () => { + renderGuard() + act(() => store.setState("proxyUnauthorized")) + fireEvent.click(screen.getByRole("button", { name: "Go to login" })) + expect(store.redirectToCodegLogin).toHaveBeenCalledTimes(1) + }) + it("dismisses automatically once the connection recovers", () => { renderGuard() act(() => store.setState("reconnecting")) diff --git a/src/components/connection/web-connection-guard.tsx b/src/components/connection/web-connection-guard.tsx index 95e0bfa307..a2e6c69234 100644 --- a/src/components/connection/web-connection-guard.tsx +++ b/src/components/connection/web-connection-guard.tsx @@ -26,8 +26,8 @@ import { redirectToCodegLogin } from "@/lib/transport/web-auth" // surfacing a modal for every transient drop would flicker. The transport's // state machine flips to "reconnecting" instantly; this delay is purely the // UI deciding when the interruption is worth interrupting the user over. -// `unauthorized` bypasses this — a rejected token is a definitive signal worth -// telling the user about immediately. +// `unauthorized` and `proxyUnauthorized` bypass this — a rejected session is a +// definitive signal worth telling the user about immediately. const RECONNECT_DIALOG_GRACE_MS = 4_000 /** @@ -87,7 +87,12 @@ export function WebConnectionGuard() { const showReconnecting = state === "reconnecting" && graceElapsed const showUnauthorized = state === "unauthorized" - const open = showReconnecting || showUnauthorized + // A proxy in front of Codeg (e.g. Cloudflare Access) rejected the request: + // its session expired, not the Codeg token. A top-level reload lets the + // proxy run its own login flow and keeps the token; "Go to login" stays as + // the way out should the proxy keep refusing after the reload. + const showProxyUnauthorized = state === "proxyUnauthorized" + const open = showReconnecting || showUnauthorized || showProxyUnauthorized if (!open) return null @@ -101,25 +106,38 @@ export function WebConnectionGuard() { > - {showUnauthorized ? ( + {showUnauthorized || showProxyUnauthorized ? ( ) : ( )} - {showUnauthorized - ? t("sessionExpiredTitle") - : t("disconnectedTitle")} + {showProxyUnauthorized + ? t("proxySignInTitle") + : showUnauthorized + ? t("sessionExpiredTitle") + : t("disconnectedTitle")} - {showUnauthorized - ? t("sessionExpiredDescription") - : t("reconnectingDescription")} + {showProxyUnauthorized + ? t("proxySignInDescription") + : showUnauthorized + ? t("sessionExpiredDescription") + : t("reconnectingDescription")} - {showUnauthorized ? ( + {showProxyUnauthorized ? ( + <> + + + + ) : showUnauthorized ? ( diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3b3e49f55d..8e1f30cc72 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5342,6 +5342,8 @@ "reconnectNow": "إعادة الاتصال الآن", "sessionExpiredTitle": "انتهت الجلسة", "sessionExpiredDescription": "لم تعد جلستك صالحة. يُرجى تسجيل الدخول مرة أخرى للمتابعة.", + "proxySignInTitle": "انتهت صلاحية تسجيل الدخول", + "proxySignInDescription": "انتهت صلاحية تسجيل دخولك إلى الوكيل الموجود أمام هذا الخادم. أعد تحميل الصفحة لتسجيل الدخول مجددًا؛ سيتم الاحتفاظ برمز الوصول الخاص بك.", "goToLogin": "الانتقال إلى تسجيل الدخول", "versionChangedTitle": "يعمل الخادم الآن بإصدار Codeg {version}", "versionChangedDescription": "لا تزال هذه الصفحة على الإصدار {version}. أعد تحميلها لاستخدام إصدار الخادم.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 1b05271eb0..4c2ba2cc10 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Jetzt neu verbinden", "sessionExpiredTitle": "Sitzung abgelaufen", "sessionExpiredDescription": "Deine Sitzung ist nicht mehr gültig. Bitte melde dich erneut an, um fortzufahren.", + "proxySignInTitle": "Anmeldung abgelaufen", + "proxySignInDescription": "Deine Anmeldung beim Proxy vor diesem Server ist abgelaufen. Lade die Seite neu, um dich erneut anzumelden; dein Zugriffstoken bleibt erhalten.", "goToLogin": "Zur Anmeldung", "versionChangedTitle": "Auf dem Server läuft jetzt Codeg {version}", "versionChangedDescription": "Diese Seite läuft noch mit {version}. Lade sie neu, um die Version des Servers zu verwenden.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 35288fef1b..bf07f87f9f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Reconnect now", "sessionExpiredTitle": "Session expired", "sessionExpiredDescription": "Your session is no longer valid. Please sign in again to continue.", + "proxySignInTitle": "Sign-in expired", + "proxySignInDescription": "Your sign-in to the proxy in front of this server has expired. Reload the page to sign in again; your access token is kept.", "goToLogin": "Go to login", "versionChangedTitle": "The server is now running Codeg {version}", "versionChangedDescription": "This page is still on {version}. Reload it to use the server's version.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 3aa2be9e01..ac663d1655 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Volver a conectar ahora", "sessionExpiredTitle": "Sesión caducada", "sessionExpiredDescription": "Tu sesión ya no es válida. Inicia sesión de nuevo para continuar.", + "proxySignInTitle": "Inicio de sesión caducado", + "proxySignInDescription": "Tu inicio de sesión en el proxy que está delante de este servidor ha caducado. Recarga la página para volver a iniciar sesión; tu token de acceso se conserva.", "goToLogin": "Ir al inicio de sesión", "versionChangedTitle": "El servidor ahora ejecuta Codeg {version}", "versionChangedDescription": "Esta página sigue en la versión {version}. Recárgala para usar la versión del servidor.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index e2647a8ccf..1d68402c47 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Se reconnecter maintenant", "sessionExpiredTitle": "Session expirée", "sessionExpiredDescription": "Votre session n'est plus valide. Veuillez vous reconnecter pour continuer.", + "proxySignInTitle": "Connexion expirée", + "proxySignInDescription": "Votre connexion au proxy placé devant ce serveur a expiré. Rechargez la page pour vous reconnecter ; votre jeton d'accès est conservé.", "goToLogin": "Aller à la connexion", "versionChangedTitle": "Le serveur exécute désormais Codeg {version}", "versionChangedDescription": "Cette page est encore en version {version}. Rechargez-la pour utiliser la version du serveur.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a45a4fe947..2cbe973ebf 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5342,6 +5342,8 @@ "reconnectNow": "今すぐ再接続", "sessionExpiredTitle": "セッションの有効期限が切れました", "sessionExpiredDescription": "セッションが無効になりました。続行するには再度サインインしてください。", + "proxySignInTitle": "サインインの有効期限切れ", + "proxySignInDescription": "このサーバーの前段にあるプロキシのサインインが期限切れになりました。ページを再読み込みして再度サインインしてください。アクセストークンは保持されます。", "goToLogin": "ログインへ移動", "versionChangedTitle": "サーバーは Codeg {version} で動作しています", "versionChangedDescription": "このページはまだ {version} です。再読み込みするとサーバーのバージョンを使用します。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 84ed15428e..eecdb6434f 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5342,6 +5342,8 @@ "reconnectNow": "지금 다시 연결", "sessionExpiredTitle": "세션이 만료되었습니다", "sessionExpiredDescription": "세션이 더 이상 유효하지 않습니다. 계속하려면 다시 로그인하세요.", + "proxySignInTitle": "로그인 만료", + "proxySignInDescription": "이 서버 앞단 프록시의 로그인이 만료되었습니다. 페이지를 새로고침하여 다시 로그인하세요. 액세스 토큰은 유지됩니다.", "goToLogin": "로그인으로 이동", "versionChangedTitle": "서버가 Codeg {version}(으)로 실행 중입니다", "versionChangedDescription": "이 페이지는 아직 {version}입니다. 새로고침하면 서버의 버전을 사용합니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 9481e44ee7..3e668cb55c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Reconectar agora", "sessionExpiredTitle": "Sessão expirada", "sessionExpiredDescription": "Sua sessão não é mais válida. Faça login novamente para continuar.", + "proxySignInTitle": "Sessão expirada", + "proxySignInDescription": "Sua sessão no proxy à frente deste servidor expirou. Recarregue a página para entrar novamente; seu token de acesso é mantido.", "goToLogin": "Ir para o login", "versionChangedTitle": "O servidor agora executa o Codeg {version}", "versionChangedDescription": "Esta página ainda está na versão {version}. Recarregue-a para usar a versão do servidor.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b4b1a362b0..0f6938ed4b 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5342,6 +5342,8 @@ "reconnectNow": "立即重连", "sessionExpiredTitle": "会话已过期", "sessionExpiredDescription": "登录状态已失效,请重新登录以继续。", + "proxySignInTitle": "登录已过期", + "proxySignInDescription": "此服务器前置代理的登录已过期。请重新加载页面以再次登录;你的访问令牌会被保留。", "goToLogin": "前往登录", "versionChangedTitle": "服务器已运行 Codeg {version}", "versionChangedDescription": "此页面仍是 {version},刷新后即可使用服务器上的版本。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 86567c400d..1866b9dadb 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5342,6 +5342,8 @@ "reconnectNow": "立即重新連線", "sessionExpiredTitle": "工作階段已過期", "sessionExpiredDescription": "登入狀態已失效,請重新登入以繼續。", + "proxySignInTitle": "登入已過期", + "proxySignInDescription": "此伺服器前置代理的登入已過期。請重新載入頁面以再次登入;你的存取權杖會被保留。", "goToLogin": "前往登入", "versionChangedTitle": "伺服器已執行 Codeg {version}", "versionChangedDescription": "此頁面仍是 {version},重新整理後即可使用伺服器上的版本。", diff --git a/src/lib/api.ts b/src/lib/api.ts index 97dd726f8a..a5e4c3ba10 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,7 +6,11 @@ import { isRemoteDesktopMode, notifyRemoteDesktopUnauthorized, } from "./transport" -import { getCodegToken } from "./transport/web-auth" +import { + codegAuthHeaders, + getCodegToken, + unauthorizedSource, +} from "./transport/web-auth" import { notifyWebUnauthorized } from "./transport/web-connection-store" import { getCurrentEffectiveAppLocale } from "./i18n" import { @@ -4323,11 +4327,13 @@ export async function uploadAttachment( const res = await fetch(`${window.location.origin}/api/upload_attachment`, { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: codegAuthHeaders(token), body: form, }) if (res.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) throw new Error("Unauthorized") } if (!res.ok) { @@ -4417,9 +4423,7 @@ async function workspaceFileFetch( isMultipart: boolean ): Promise { const token = getCodegToken() - const headers: Record = { - Authorization: `Bearer ${token}`, - } + const headers = codegAuthHeaders(token) if (!isMultipart) { headers["Content-Type"] = "application/json" } @@ -4429,7 +4433,9 @@ async function workspaceFileFetch( body, }) if (res.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) throw new Error("Unauthorized") } if (!res.ok) { @@ -4487,7 +4493,9 @@ export async function uploadWorkspaceFile( const token = getCodegToken() const xhr = new XMLHttpRequest() xhr.open("POST", `${window.location.origin}/api/upload_workspace_file`) - xhr.setRequestHeader("Authorization", `Bearer ${token}`) + for (const [name, value] of Object.entries(codegAuthHeaders(token))) { + xhr.setRequestHeader(name, value) + } if (args.onProgress) { const onProgress = args.onProgress @@ -4500,7 +4508,9 @@ export async function uploadWorkspaceFile( xhr.onload = () => { if (xhr.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(xhr.getResponseHeader("www-authenticate")) + ) reject(new Error("Unauthorized")) return } @@ -5826,7 +5836,9 @@ export async function uploadBackupWeb( const token = getCodegToken() const xhr = new XMLHttpRequest() xhr.open("POST", `${window.location.origin}/api/backup_upload`) - xhr.setRequestHeader("Authorization", `Bearer ${token}`) + for (const [name, value] of Object.entries(codegAuthHeaders(token))) { + xhr.setRequestHeader(name, value) + } if (onProgress) { xhr.upload.onprogress = (event) => { if (event.lengthComputable) onProgress(event.loaded, event.total) @@ -5834,7 +5846,9 @@ export async function uploadBackupWeb( } xhr.onload = () => { if (xhr.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(xhr.getResponseHeader("www-authenticate")) + ) reject(new Error("Unauthorized")) return } diff --git a/src/lib/transport/web-auth.test.ts b/src/lib/transport/web-auth.test.ts new file mode 100644 index 0000000000..c836628872 --- /dev/null +++ b/src/lib/transport/web-auth.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest" +import { codegAuthHeaders, unauthorizedSource } from "./web-auth" + +// The challenge as fetch reads it from a real 401 response. +const with401 = (challenge?: string) => + new Response(null, { + status: 401, + headers: challenge ? { "WWW-Authenticate": challenge } : {}, + }).headers.get("www-authenticate") + +describe("codegAuthHeaders", () => { + it("carries the bearer token and asks a proxy for a 401 instead of a redirect", () => { + expect(codegAuthHeaders("tok")).toEqual({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) +}) + +describe("unauthorizedSource", () => { + it("attributes a 401 carrying Codeg's challenge to Codeg", () => { + expect(unauthorizedSource(with401('Bearer realm="codeg"'))).toBe("codeg") + }) + + it("matches the challenge case-insensitively, as HTTP auth schemes are", () => { + expect(unauthorizedSource(with401('bearer REALM="codeg"'))).toBe("codeg") + }) + + it("attributes a 401 with another challenge to the proxy", () => { + expect( + unauthorizedSource( + with401('Cloudflare-Access resource_metadata="https://x.test/m"') + ) + ).toBe("proxy") + }) + + it("attributes a 401 with no challenge at all to the proxy", () => { + expect(unauthorizedSource(with401())).toBe("proxy") + }) + + it("does not take another realm's bearer challenge for Codeg's", () => { + expect(unauthorizedSource(with401('Bearer realm="codeg-other"'))).toBe( + "proxy" + ) + }) +}) diff --git a/src/lib/transport/web-auth.ts b/src/lib/transport/web-auth.ts index dde9cc4a2a..a3e9a8bf2c 100644 --- a/src/lib/transport/web-auth.ts +++ b/src/lib/transport/web-auth.ts @@ -7,6 +7,32 @@ const TOKEN_KEY = "codeg_token" +// Codeg marks its own 401 with this bearer challenge (`web/auth.rs`). A 401 +// without it came from something in front of Codeg — typically an +// identity-aware proxy whose session expired — and re-entering the Codeg token +// cannot fix that; a top-level reload lets the proxy run its login flow. +const CODEG_CHALLENGE = /^bearer\s+realm="codeg"(?:\s*,|$)/i + +export type UnauthorizedSource = "codeg" | "proxy" + +export function codegAuthHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + // Asks a proxy in front of Codeg (e.g. Cloudflare Access) to answer an + // expired session with 401 instead of a redirect to its login page on + // another origin, which fetch cannot follow and reports as a network + // error — leaving the UI reconnecting forever. + "X-Requested-With": "XMLHttpRequest", + } +} + +/** Who answered a 401, from its `WWW-Authenticate` header (null if absent). */ +export function unauthorizedSource( + challenge: string | null +): UnauthorizedSource { + return CODEG_CHALLENGE.test((challenge ?? "").trim()) ? "codeg" : "proxy" +} + export function getCodegToken(): string { return localStorage.getItem(TOKEN_KEY) ?? "" } diff --git a/src/lib/transport/web-connection-store.ts b/src/lib/transport/web-connection-store.ts index e4605596ec..3295d72bbd 100644 --- a/src/lib/transport/web-connection-store.ts +++ b/src/lib/transport/web-connection-store.ts @@ -13,6 +13,7 @@ import { detectEnvironment } from "./detect" import { getShellTransport, isRemoteDesktopMode } from "./index" +import type { UnauthorizedSource } from "./web-auth" import type { WebConnState, WebTransport } from "./web-transport" // Module-level constant so `getServerSnapshot` returns a STABLE reference on @@ -68,6 +69,6 @@ export function reconnectWebNow(): void { * `lib/api.ts` that bypasses `WebTransport.call`) into the same unauthorized * dialog state, rather than an abrupt redirect. No-op off the web shell. */ -export function notifyWebUnauthorized(): void { - webTransport()?.markUnauthorized() +export function notifyWebUnauthorized(source: UnauthorizedSource): void { + webTransport()?.markUnauthorized(source) } diff --git a/src/lib/transport/web-transport.test.ts b/src/lib/transport/web-transport.test.ts index f2ecf5e71b..15629bf16c 100644 --- a/src/lib/transport/web-transport.test.ts +++ b/src/lib/transport/web-transport.test.ts @@ -87,7 +87,23 @@ function connectReady() { } const ok200 = () => ({ status: 200, ok: true, json: async () => ({}) }) -const resp401 = () => ({ status: 401, ok: false, json: async () => ({}) }) +// Codeg's own 401 carries its bearer challenge (`web/auth.rs`); a 401 from an +// identity-aware proxy in front of it carries the proxy's challenge instead. +const resp401 = () => ({ + status: 401, + ok: false, + headers: new Headers({ "WWW-Authenticate": 'Bearer realm="codeg"' }), + json: async () => ({}), +}) +const proxy401 = () => ({ + status: 401, + ok: false, + headers: new Headers({ + "WWW-Authenticate": + 'Cloudflare-Access resource_metadata="https://example.test/.well-known/x"', + }), + json: async () => ({}), +}) describe("WebTransport connection state machine", () => { it("starts connected and the first __ready__ does not fire reconnect callbacks", () => { @@ -302,4 +318,66 @@ describe("WebTransport connection state machine", () => { expect(t.getConnectionSnapshot()).toBe("reconnecting") expect(localStorage.getItem("codeg_token")).toBe("tok") // token preserved }) + + it("sends X-Requested-With on health probes so a proxy answers 401, not a redirect", async () => { + const { ws } = connectReady() + fetchMock.mockResolvedValue(ok200()) + + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + + const [, init] = fetchMock.mock.calls[0] + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("enters proxyUnauthorized on a 401 without Codeg's challenge, keeps the token, stops retrying", async () => { + const { t, ws } = connectReady() + fetchMock.mockResolvedValue(proxy401()) + + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + + expect(t.getConnectionSnapshot()).toBe("proxyUnauthorized") + // The Codeg token is still valid; only the proxy's session expired. + expect(localStorage.getItem("codeg_token")).toBe("tok") + + const callsSoFar = fetchMock.mock.calls.length + await vi.advanceTimersByTimeAsync(60_000) + expect(fetchMock.mock.calls.length).toBe(callsSoFar) + }) +}) + +describe("WebTransport.call", () => { + it("sends X-Requested-With alongside the bearer token", async () => { + const t = new WebTransport("http://localhost") + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + text: async () => "{}", + }) + + await t.call("list_things") + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe("http://localhost/api/list_things") + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("classifies a 401 by its challenge: Codeg's → unauthorized, a proxy's → proxyUnauthorized", async () => { + const codeg = new WebTransport("http://localhost") + fetchMock.mockResolvedValueOnce(resp401()) + await expect(codeg.call("x")).rejects.toThrow("Unauthorized") + expect(codeg.getConnectionSnapshot()).toBe("unauthorized") + + const proxied = new WebTransport("http://localhost") + fetchMock.mockResolvedValueOnce(proxy401()) + await expect(proxied.call("x")).rejects.toThrow("Unauthorized") + expect(proxied.getConnectionSnapshot()).toBe("proxyUnauthorized") + }) }) diff --git a/src/lib/transport/web-transport.ts b/src/lib/transport/web-transport.ts index 4b55997e95..ff7be71871 100644 --- a/src/lib/transport/web-transport.ts +++ b/src/lib/transport/web-transport.ts @@ -9,7 +9,12 @@ import type { } from "./types" import { buildCodegWebSocketProtocols } from "./ws-auth" import { parseJsonOrThrow } from "../json-parse-error" -import { getCodegToken } from "./web-auth" +import { + codegAuthHeaders, + getCodegToken, + unauthorizedSource, + type UnauthorizedSource, +} from "./web-auth" // 60s covers the worst-case ACP probe path: some agents (Gemini in // particular) burn 8–10s on the Initialize handshake before session/new @@ -46,7 +51,13 @@ const HEALTH_PROBE_TIMEOUT_MS = 8_000 // `subscribeConnection`/`getConnectionSnapshot` so a single global dialog can // reflect it. Distinct from the per-ACP-agent `ConnectionStatus` — this is // the browser↔server transport link, not an agent session. -export type WebConnState = "connected" | "reconnecting" | "unauthorized" +// "proxyUnauthorized" is a 401 that did not come from Codeg: the session of a +// proxy in front of it expired while the Codeg token is still good. +export type WebConnState = + | "connected" + | "reconnecting" + | "unauthorized" + | "proxyUnauthorized" interface WebEvent { channel: string @@ -162,7 +173,7 @@ export class WebTransport implements Transport { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: JSON.stringify(args ?? {}), signal: controller.signal, @@ -179,8 +190,11 @@ export class WebTransport implements Transport { // Definitive auth failure. Surface the unified unauthorized dialog // rather than an abrupt redirect — a hard `location.href` here would // fight a reconnect dialog that may already be on screen. The user - // re-authenticates from the dialog's "Go to login" action. - this.markUnauthorized() + // re-authenticates from the dialog's "Go to login" action, or reloads + // when the 401 came from a proxy in front of Codeg. + this.markUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) throw new Error("Unauthorized") } if (!res.ok) { @@ -283,11 +297,12 @@ export class WebTransport implements Transport { /** * Enter the unauthorized state: a definitive 401 was observed (token - * rejected). Stop reconnecting and let the dialog prompt re-login. Does NOT - * clear the token — that happens when the user acts on "Go to login" — so a - * spuriously-cached 401 can't silently wipe a still-valid session. + * rejected, or a proxy's session expired). Stop reconnecting and let the + * dialog prompt re-login. Does NOT clear the token — that happens when the + * user acts on "Go to login" — so a spuriously-cached 401 can't silently + * wipe a still-valid session. */ - markUnauthorized(): void { + markUnauthorized(source: UnauthorizedSource = "codeg"): void { if (this.destroyed) return if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) @@ -297,7 +312,7 @@ export class WebTransport implements Transport { // the socket or re-arm the backoff behind the session-expired dialog. this.invalidateProbe() this.teardownWs() - this.setConnState("unauthorized") + this.setConnState(source === "proxy" ? "proxyUnauthorized" : "unauthorized") } // Bump the epoch (so a pending probe's post-await guard bails) and abort its @@ -515,7 +530,7 @@ export class WebTransport implements Transport { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: "{}", signal: controller.signal, @@ -524,8 +539,11 @@ export class WebTransport implements Transport { // a stale completion must not reopen the socket or re-arm the backoff. if (this.destroyed || epoch !== this.probeEpoch) return if (res.status === 401) { - // Server is up and rejected the token → session genuinely expired. - this.markUnauthorized() + // Server (or the proxy in front of it) rejected the request → the + // session genuinely expired. + this.markUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) return } if (res.ok) {