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 01/13] 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) { From c51de289c32b0154f3058e13b10b94281c47014e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Ternstr=C3=B6m?= Date: Mon, 19 Oct 2026 00:00:48 +0200 Subject: [PATCH 02/13] feat(server): optional sign-in through an identity-aware proxy (CODEG_AUTH=proxy-jwt) Behind a proxy that already signs users in (Cloudflare Access in #981), Codeg's token is a second login on top of the proxy's, kept and restored by hand. With CODEG_AUTH=proxy-jwt, codeg-server also admits a request whose assertion header holds a JWT that verifies against the proxy's published keys with the expected iss and aud and an unexpired exp. The token keeps working for clients that bypass the proxy (codeg-cli --url). Token-only stays the default, and the desktop web service is unchanged. It fails closed throughout: a half or misspelled configuration, or proxy settings without the mode, stops startup with exit code 2 naming the variable; a missing, malformed, expired, foreign or unknown-key assertion is a 401; no keys fetched means no admission without the token. The key decides the algorithm (RS256 / ES256), so alg none and HS256 confusion never reach a verifier. Keys are cached for an hour, refreshed early on an unseen kid at most every 30 s, and kept when a refresh fails. Signatures use ring, already in the lock tree through rustls, so no crate is added. The client records when the server admits it without a token, skips the token prompt, opens the event socket without the token sub-protocol, and answers any 401 with the Reload from the previous commit, since there is no token to re-enter. Co-Authored-By: Claude Opus 5.5 --- docs/proxy-auth.md | 73 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/bin/codeg_server.rs | 42 +- src-tauri/src/web/auth.rs | 157 +- src-tauri/src/web/mod.rs | 1 + src-tauri/src/web/proxy_auth.rs | 1270 +++++++++++++++++ src-tauri/src/web/router.rs | 26 +- src/app/login/page.test.tsx | 23 +- src/app/login/page.tsx | 19 +- src/app/page.test.tsx | 19 + src/app/page.tsx | 13 +- .../connection/web-version-notice.test.tsx | 3 + .../connection/web-version-notice.tsx | 8 +- src/lib/transport/web-auth.test.ts | 68 +- src/lib/transport/web-auth.ts | 53 +- src/lib/transport/web-transport.test.ts | 43 + src/lib/transport/web-transport.ts | 23 +- 18 files changed, 1806 insertions(+), 39 deletions(-) create mode 100644 docs/proxy-auth.md create mode 100644 src-tauri/src/web/proxy_auth.rs diff --git a/docs/proxy-auth.md b/docs/proxy-auth.md new file mode 100644 index 0000000000..c8e0a98e6a --- /dev/null +++ b/docs/proxy-auth.md @@ -0,0 +1,73 @@ +# Signing in through an identity-aware proxy + +`codeg-server` normally asks every browser for its access token. When the +server sits behind an identity-aware proxy that already signs users in — such +as [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) +— the token is a second login on top of the proxy's. With +`CODEG_AUTH=proxy-jwt` the server also lets a request in when it carries the +proxy's signed assertion (a JWT), so the browser needs no token. + +The token keeps working in this mode. Clients that do not pass through the +proxy — `codeg-cli --url`, scripts — still use it. + +## Why it is safe + +The server does not trust the header because it is there: anything that can +reach the listener can set a header. It checks the assertion's signature +against the keys the proxy publishes (JWKS), and its issuer, audience and +expiry. A process that can reach the port cannot forge that signature. + +Everything fails closed: + +- `CODEG_AUTH=proxy-jwt` with a setting missing, a misspelled mode, or a proxy + setting without `CODEG_AUTH=proxy-jwt` stops the server at startup (exit + code 2) with the variable named. +- A request whose assertion is missing, malformed, expired, issued for another + audience or by another issuer, signed with an unknown key or an algorithm + other than RS256 / ES256 gets a 401. So does every request without the token + while no keys could be fetched. + +## Configuration + +| Variable | Value | +| --- | --- | +| `CODEG_AUTH` | `proxy-jwt` turns the mode on. Unset or `token`: token only, as before. | +| `CODEG_PROXY_AUTH_HEADER` | The request header holding the assertion. | +| `CODEG_PROXY_AUTH_JWKS_URL` | Where the proxy publishes its signing keys. `https` (plain `http` only for a loopback host). | +| `CODEG_PROXY_AUTH_ISSUER` | The exact `iss` the assertion must carry. | +| `CODEG_PROXY_AUTH_AUDIENCE` | A value the assertion's `aud` must hold. | + +The keys are fetched at startup, kept for an hour, and fetched again early when +an assertion names a key id the server has not seen (a key rotation) — at most +once every 30 seconds. If a fetch fails, the keys already held stay in use. +`exp` and `nbf` allow 60 seconds of clock skew. + +### Cloudflare Access + +With `` your Zero Trust team name and the application's **Application +Audience (AUD) Tag** from its overview page: + +```bash +CODEG_AUTH=proxy-jwt +CODEG_PROXY_AUTH_HEADER=Cf-Access-Jwt-Assertion +CODEG_PROXY_AUTH_JWKS_URL=https://.cloudflareaccess.com/cdn-cgi/access/certs +CODEG_PROXY_AUTH_ISSUER=https://.cloudflareaccess.com +CODEG_PROXY_AUTH_AUDIENCE= +``` + +Bind the server to loopback (`CODEG_HOST=127.0.0.1`) and reach it only through +the tunnel, so every browser request passes Access. + +## In the browser + +Opened through the proxy, the web app finds that the server admits it without a +token and skips the token prompt. When the proxy's session expires, the app +shows **Sign-in expired** with **Reload**: reloading lets the proxy run its +login again, and the app comes back where it was. API calls send +`X-Requested-With: XMLHttpRequest`, which makes Cloudflare Access answer an +expired session with 401 rather than a redirect the browser cannot follow. + +## Scope + +This is a setting of the standalone `codeg-server`. The desktop app's web +service stays token-only. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eb5ef1aed1..69e2f7c797 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1214,6 +1214,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.12.28", + "ring", "rusqlite", "sea-orm", "sea-orm-migration", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4e8395cfe..e35fda0567 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -252,6 +252,9 @@ sha2 = "0.10" # HMAC-SHA256 over a forge webhook's body (GitHub / Gitea signatures) with a # constant-time check. Already in the lock tree as a transitive dependency. hmac = "0.12" +# RS256 / ES256 signature checks for the opt-in proxy-JWT authentication +# (`web/proxy_auth.rs`). Already in the lock tree through rustls. +ring = "0.17" aes-gcm = { version = "0.10", features = ["stream"] } argon2 = "0.5" tempfile = "3" diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 319da5c036..edb60d63ba 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -140,6 +140,17 @@ fn main() -> ExitCode { return ExitCode::from(2); } + // The opt-in proxy-JWT authentication is all-or-nothing: a half-set or + // misspelled configuration aborts here rather than serving with an + // authentication the operator did not mean (see `web::proxy_auth`). + let proxy_auth_config = match codeg_lib::web::proxy_auth::ProxyAuthConfig::from_env() { + Ok(config) => config, + Err(err) => { + tracing::error!("[AUTH][FATAL] {err}; aborting startup."); + return ExitCode::from(2); + } + }; + // Before anything opens the database (see `runtime_lock`): every server // shares the data directory, and waits while a `codeg-cli --standalone` // server has it — which itself takes it alone, or leaves untouched. @@ -205,10 +216,12 @@ fn main() -> ExitCode { .enable_all() .build() .expect("Failed to build tokio runtime") - .block_on(async_main()) + .block_on(async_main(proxy_auth_config)) } -async fn async_main() -> ExitCode { +async fn async_main( + proxy_auth_config: Option, +) -> ExitCode { // Sweep stale ACP binary cache trash (rename-aside fallback artifacts). // Detached OS thread: cannot block startup, panics are caught and dropped, // errors are silenced, no subprocesses spawned. @@ -747,10 +760,33 @@ async fn async_main() -> ExitCode { codeg_lib::web::handlers::files::purge_upload_staging().await; // Build router + let proxy_auth = match proxy_auth_config { + None => None, + Some(config) => match codeg_lib::web::proxy_auth::ProxyJwtAuth::new(config) { + Ok(auth) => { + let config = auth.config(); + tracing::info!( + "[AUTH] proxy-jwt on: header {}, issuer {}, audience {}, keys from {}", + config.header, + config.issuer, + config.audience, + config.jwks_url + ); + auth.prime().await; + Some(Arc::new(auth)) + } + Err(err) => { + tracing::error!("[AUTH][FATAL] proxy-jwt key client: {err}; aborting startup."); + return ExitCode::from(1); + } + }, + }; + let shutdown_signal = state.web_server_state.shutdown_signal(); - let router = codeg_lib::web::router::build_router( + let router = codeg_lib::web::router::build_router_with_proxy_auth( state.clone(), token.clone(), + proxy_auth, static_dir, shutdown_signal, ); diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs index 09a3d4143b..d9d43d4e34 100644 --- a/src-tauri/src/web/auth.rs +++ b/src-tauri/src/web/auth.rs @@ -5,6 +5,9 @@ use axum::{ response::{IntoResponse, Response}, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use std::sync::Arc; + +use super::proxy_auth::{ProxyJwtAuth, Rejection}; pub const WS_EVENT_PROTOCOL: &str = "codeg-events"; const WS_TOKEN_PROTOCOL_PREFIX: &str = "codeg-token."; @@ -32,17 +35,11 @@ fn token_from_ws_protocols(value: &str) -> Option { .and_then(|bytes| String::from_utf8(bytes).ok()) } -pub async fn require_token(request: Request, next: Next, token: String) -> Response { - // 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 unauthorized("Server token is not configured"); - } - +fn carries_token(request: &Request, token: &str) -> bool { if let Some(auth_header) = request.headers().get("authorization") { if let Ok(auth_str) = auth_header.to_str() { if auth_str.strip_prefix("Bearer ").is_some_and(|t| t == token) { - return next.run(request).await; + return true; } } } @@ -50,14 +47,54 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo if let Some(protocol_header) = request.headers().get("sec-websocket-protocol") { if let Ok(protocols) = protocol_header.to_str() { if token_from_ws_protocols(protocols).is_some_and(|t| t == token) { - return next.run(request).await; + return true; } } } + false +} + +pub async fn require_token(request: Request, next: Next, token: String) -> Response { + // 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 unauthorized("Server token is not configured"); + } + if carries_token(&request, &token) { + return next.run(request).await; + } unauthorized("Invalid or missing token") } +/// How the web API authenticates a request: the access token always, and with +/// `CODEG_AUTH=proxy-jwt` also a proxy's verified assertion (see +/// `proxy_auth`). The token keeps working in that mode for clients that do +/// not pass through the proxy (`codeg-cli --url`, scripts). +#[derive(Clone)] +pub struct WebAuth { + pub token: String, + pub proxy: Option>, +} + +pub async fn require_auth(request: Request, next: Next, auth: WebAuth) -> Response { + let Some(proxy) = auth.proxy.as_ref() else { + return require_token(request, next, auth.token).await; + }; + if !auth.token.is_empty() && carries_token(&request, &auth.token) { + return next.run(request).await; + } + match proxy.authenticate(request.headers()).await { + Ok(()) => next.run(request).await, + Err(Rejection::Missing) => unauthorized("Invalid or missing token"), + Err(reason) => { + // The reason only: the assertion is a bearer credential. + tracing::warn!("[AUTH] proxy-jwt assertion refused: {reason}"); + unauthorized("Invalid or missing token") + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -110,6 +147,108 @@ mod tests { assert_eq!(unconfigured.header("www-authenticate"), CODEG_CHALLENGE); } + fn guarded_with(auth: WebAuth) -> axum_test::TestServer { + let app = axum::Router::new() + .route("/api/health", axum::routing::post(|| async { "ok" })) + .layer(axum::middleware::from_fn(move |req, next| { + require_auth(req, next, auth.clone()) + })); + axum_test::TestServer::new(app).unwrap() + } + + use crate::web::proxy_auth::testing::{TestIssuer, HEADER}; + + #[tokio::test] + async fn proxy_mode_admits_a_verified_assertion_without_the_token() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .await + .assert_status_ok(); + } + + #[tokio::test] + async fn proxy_mode_still_admits_the_token() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + server + .post("/api/health") + .authorization_bearer("tok") + .await + .assert_status_ok(); + } + + #[tokio::test] + async fn proxy_mode_refuses_a_missing_or_bad_assertion_with_the_codeg_challenge() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + + let missing = server.post("/api/health").await; + missing.assert_status_unauthorized(); + assert_eq!(missing.header("www-authenticate"), CODEG_CHALLENGE); + + let other_app = server + .post("/api/health") + .add_header(HEADER, issuer.assertion_for("another-app")) + .await; + other_app.assert_status_unauthorized(); + assert_eq!(other_app.header("www-authenticate"), CODEG_CHALLENGE); + + let forged = server + .post("/api/health") + .add_header(HEADER, TestIssuer::new().assertion()) + .await; + forged.assert_status_unauthorized(); + + let wrong_token = server + .post("/api/health") + .authorization_bearer("nope") + .await; + wrong_token.assert_status_unauthorized(); + } + + // Token mode must not grow a second way in: an assertion that would pass + // in proxy mode is ignored. + #[tokio::test] + async fn token_mode_ignores_a_valid_assertion() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: None, + }); + let res = server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .await; + res.assert_status_unauthorized(); + assert_eq!(res.header("www-authenticate"), CODEG_CHALLENGE); + } + + #[tokio::test] + async fn proxy_mode_with_an_empty_token_accepts_no_empty_bearer() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: String::new(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + server + .post("/api/health") + .add_header("authorization", "Bearer ") + .await + .assert_status_unauthorized(); + } + #[tokio::test] async fn an_accepted_request_carries_no_challenge() { let ok = guarded("tok") diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index 449769ab5a..54a8e1831b 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -5,6 +5,7 @@ pub mod compression; pub mod event_bridge; pub mod handlers; pub mod port_probe; +pub mod proxy_auth; pub mod router; pub mod shutdown; pub mod socket_inherit; diff --git a/src-tauri/src/web/proxy_auth.rs b/src-tauri/src/web/proxy_auth.rs new file mode 100644 index 0000000000..b7079a94ee --- /dev/null +++ b/src-tauri/src/web/proxy_auth.rs @@ -0,0 +1,1270 @@ +//! Opt-in authentication by an identity-aware proxy's signed assertion. +//! +//! With `CODEG_AUTH=proxy-jwt`, a request is also let in when a header set by +//! a proxy in front of Codeg (Cloudflare Access sends `Cf-Access-Jwt-Assertion`) +//! holds a JWT whose signature verifies against the proxy's published keys +//! (JWKS) and whose `iss`, `aud`, `exp` and `nbf` match. The browser then needs +//! no Codeg token: the proxy's login is the login. The token keeps working for +//! every other client (`codeg-cli --url`, scripts). +//! +//! Why a signature check and not "trust the header": anything that can reach +//! the listener — any local process when Codeg is bound to loopback — can set +//! a header, but cannot forge the proxy's signature. +//! +//! Fail closed throughout: an incomplete configuration refuses to start the +//! server, and a missing header, an unreachable JWKS with no cached key, an +//! unknown key id, an algorithm other than RS256/ES256 or any claim mismatch +//! rejects the request (the caller answers 401). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use axum::http::{HeaderMap, HeaderName}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use ring::signature::{self, RsaPublicKeyComponents, UnparsedPublicKey}; +use serde_json::{Map, Value}; +use tokio::sync::{Mutex, RwLock}; + +pub const AUTH_MODE_VAR: &str = "CODEG_AUTH"; +pub const AUTH_MODE_PROXY_JWT: &str = "proxy-jwt"; +pub const HEADER_VAR: &str = "CODEG_PROXY_AUTH_HEADER"; +pub const JWKS_URL_VAR: &str = "CODEG_PROXY_AUTH_JWKS_URL"; +pub const ISSUER_VAR: &str = "CODEG_PROXY_AUTH_ISSUER"; +pub const AUDIENCE_VAR: &str = "CODEG_PROXY_AUTH_AUDIENCE"; + +/// A real assertion is about 1 KB; the bound only caps the work an +/// oversized header can cause before it is rejected. +const MAX_ASSERTION_LEN: usize = 16 * 1024; +/// Tolerated clock skew between the proxy and this host for `exp` / `nbf`. +const CLOCK_LEEWAY_SECS: f64 = 60.0; +/// Keys are re-fetched at most this old, so a rotation is picked up even +/// when every request names a key id that is still cached. +const KEYS_MAX_AGE: Duration = Duration::from_secs(60 * 60); +/// At most one JWKS fetch per interval: an assertion naming an unknown key id +/// triggers a refresh, and must not let a caller turn that into a request flood +/// against the proxy's key endpoint. +const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(30); +const JWKS_FETCH_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_JWKS_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyAuthConfig { + pub header: HeaderName, + pub jwks_url: reqwest::Url, + pub issuer: String, + pub audience: String, +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum ConfigError { + #[error("{AUTH_MODE_VAR}={0:?} is not a known mode (expected \"token\" or \"{AUTH_MODE_PROXY_JWT}\")")] + UnknownMode(String), + #[error("{AUTH_MODE_VAR}={AUTH_MODE_PROXY_JWT} needs {0} to be set")] + Missing(&'static str), + #[error("{0} is set but {AUTH_MODE_VAR} is not {AUTH_MODE_PROXY_JWT}; set it, or unset {0}")] + WithoutMode(&'static str), + #[error("{HEADER_VAR}={0:?} is not a valid header name")] + BadHeader(String), + #[error("{JWKS_URL_VAR}={0:?} must be an https URL (http only for a loopback host)")] + BadJwksUrl(String), +} + +impl ProxyAuthConfig { + /// Reads the configuration from the environment. `Ok(None)` is token-only + /// authentication, as before this mode existed. + pub fn from_env() -> Result, ConfigError> { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + pub fn from_lookup(get: impl Fn(&str) -> Option) -> Result, ConfigError> { + let value = |name: &str| { + get(name) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }; + let mode = value(AUTH_MODE_VAR); + match mode.as_deref() { + None | Some("token") => { + // A proxy setting without the mode would leave the operator + // believing the proxy's login is enough while every request + // without the token gets 401: say so at startup instead. + for name in [HEADER_VAR, JWKS_URL_VAR, ISSUER_VAR, AUDIENCE_VAR] { + if value(name).is_some() { + return Err(ConfigError::WithoutMode(name)); + } + } + return Ok(None); + } + Some(AUTH_MODE_PROXY_JWT) => {} + Some(other) => return Err(ConfigError::UnknownMode(other.to_string())), + } + let require = |name: &'static str| value(name).ok_or(ConfigError::Missing(name)); + let header_raw = require(HEADER_VAR)?; + let header = HeaderName::try_from(header_raw.as_str()) + .map_err(|_| ConfigError::BadHeader(header_raw.clone()))?; + let url_raw = require(JWKS_URL_VAR)?; + let jwks_url = reqwest::Url::parse(&url_raw) + .ok() + .filter(jwks_url_allowed) + .ok_or_else(|| ConfigError::BadJwksUrl(url_raw.clone()))?; + Ok(Some(Self { + header, + jwks_url, + issuer: require(ISSUER_VAR)?, + audience: require(AUDIENCE_VAR)?, + })) + } +} + +/// The keys decide what verifies, so they must not come over a channel anyone +/// on the path can rewrite. Plain http is accepted only for loopback (a local +/// key server, tests). +fn jwks_url_allowed(url: &reqwest::Url) -> bool { + match url.scheme() { + "https" => url.host().is_some(), + "http" => match url.host() { + Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + }, + _ => false, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Alg { + Rs256, + Es256, +} + +impl Alg { + fn from_jose(name: &str) -> Option { + match name { + "RS256" => Some(Self::Rs256), + "ES256" => Some(Self::Es256), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +enum PublicKey { + Rsa { + n: Vec, + e: Vec, + }, + /// Uncompressed SEC1 point: 0x04 || x || y. + EcP256 { + point: Vec, + }, +} + +impl PublicKey { + fn alg(&self) -> Alg { + match self { + Self::Rsa { .. } => Alg::Rs256, + Self::EcP256 { .. } => Alg::Es256, + } + } + + fn verify(&self, message: &[u8], sig: &[u8]) -> bool { + match self { + Self::Rsa { n, e } => RsaPublicKeyComponents { n, e } + .verify(&signature::RSA_PKCS1_2048_8192_SHA256, message, sig) + .is_ok(), + Self::EcP256 { point } => { + UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, point) + .verify(message, sig) + .is_ok() + } + } + } +} + +/// The proxy's verification keys by key id. +#[derive(Debug, Clone, Default)] +pub struct KeySet(HashMap); + +impl KeySet { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn contains(&self, kid: &str) -> bool { + self.0.contains_key(kid) + } +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum JwksError { + #[error("the JWKS document is not a JSON object with a \"keys\" array")] + NotAKeySet, + #[error("the JWKS document holds no RS256 or ES256 signing key")] + NoUsableKey, +} + +/// Parses a JWKS document (RFC 7517). Keys this module cannot use — other key +/// types or curves, encryption keys, keys without a `kid`, keys whose `alg` +/// disagrees with their type — are skipped, not fatal, since a proxy may +/// publish more than its signing keys (Cloudflare adds `public_cert(s)` +/// beside `keys`, which is ignored). +pub fn parse_jwks(body: &[u8]) -> Result { + let doc: Value = serde_json::from_slice(body).map_err(|_| JwksError::NotAKeySet)?; + let keys = doc + .get("keys") + .and_then(Value::as_array) + .ok_or(JwksError::NotAKeySet)?; + let set: HashMap = keys + .iter() + .filter_map(Value::as_object) + .filter_map(|jwk| Some((jwk_str(jwk, "kid")?.to_string(), parse_jwk(jwk)?))) + .collect(); + if set.is_empty() { + return Err(JwksError::NoUsableKey); + } + Ok(KeySet(set)) +} + +fn jwk_str<'a>(jwk: &'a Map, name: &str) -> Option<&'a str> { + jwk.get(name) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) +} + +fn jwk_bytes(jwk: &Map, name: &str) -> Option> { + URL_SAFE_NO_PAD + .decode(jwk_str(jwk, name)?) + .ok() + .filter(|b| !b.is_empty()) +} + +fn parse_jwk(jwk: &Map) -> Option { + if jwk.get("use").is_some_and(|u| u.as_str() != Some("sig")) { + return None; + } + let key = match jwk_str(jwk, "kty")? { + "RSA" => PublicKey::Rsa { + n: jwk_bytes(jwk, "n")?, + e: jwk_bytes(jwk, "e")?, + }, + "EC" if jwk_str(jwk, "crv")? == "P-256" => { + let (x, y) = (jwk_bytes(jwk, "x")?, jwk_bytes(jwk, "y")?); + if x.len() != 32 || y.len() != 32 { + return None; + } + let mut point = Vec::with_capacity(65); + point.push(0x04); + point.extend_from_slice(&x); + point.extend_from_slice(&y); + PublicKey::EcP256 { point } + } + _ => return None, + }; + // An `alg` on the key pins it to one algorithm; one that disagrees with the + // key's type is a key this module would use differently from its owner. + if let Some(alg) = jwk.get("alg") { + if alg.as_str().and_then(Alg::from_jose) != Some(key.alg()) { + return None; + } + } + Some(key) +} + +/// Why an assertion was refused. Logged by reason only — never the assertion, +/// which is a bearer credential. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Rejection { + #[error("no assertion header")] + Missing, + #[error("malformed assertion: {0}")] + Malformed(&'static str), + #[error("algorithm not accepted")] + Algorithm, + #[error("unknown key id")] + UnknownKey, + #[error("no verification keys available")] + NoKeys, + #[error("bad signature")] + Signature, + #[error("expired")] + Expired, + #[error("not yet valid")] + NotYetValid, + #[error("wrong issuer")] + Issuer, + #[error("wrong audience")] + Audience, +} + +fn decode_json_object(part: &str, what: &'static str) -> Result, Rejection> { + let bytes = URL_SAFE_NO_PAD + .decode(part) + .map_err(|_| Rejection::Malformed(what))?; + match serde_json::from_slice(&bytes) { + Ok(Value::Object(map)) => Ok(map), + _ => Err(Rejection::Malformed(what)), + } +} + +/// The key id an assertion names, read before its signature is checked so the +/// caller knows whether its cached keys can answer. +fn assertion_kid(assertion: &str) -> Result { + let header = assertion.split('.').next().unwrap_or_default(); + let header = decode_json_object(header, "header")?; + jwk_str(&header, "kid") + .map(str::to_string) + .ok_or(Rejection::Malformed("kid")) +} + +fn numeric_date(claims: &Map, name: &str) -> Result, Rejection> { + match claims.get(name) { + None => Ok(None), + Some(v) => v + .as_f64() + .filter(|t| t.is_finite()) + .map(Some) + .ok_or(Rejection::Malformed("date claim")), + } +} + +/// Verifies a compact-serialized JWS (RFC 7515) and its claims (RFC 7519). +/// `now` is seconds since the Unix epoch. +pub fn verify( + assertion: &str, + keys: &KeySet, + issuer: &str, + audience: &str, + now: u64, +) -> Result<(), Rejection> { + if assertion.len() > MAX_ASSERTION_LEN { + return Err(Rejection::Malformed("too long")); + } + let parts: Vec<&str> = assertion.split('.').collect(); + let [header_b64, claims_b64, sig_b64] = parts.as_slice() else { + return Err(Rejection::Malformed("not three parts")); + }; + + let header = decode_json_object(header_b64, "header")?; + // The algorithm comes from the key, never from the header alone: the + // header only has to agree. That shuts out `none`, HS256 with a public key + // as the secret, and every other algorithm-confusion variant. + let alg = header + .get("alg") + .and_then(Value::as_str) + .and_then(Alg::from_jose) + .ok_or(Rejection::Algorithm)?; + // RFC 7515 §4.1.11: an extension this verifier does not implement must + // reject the token. + if header.contains_key("crit") { + return Err(Rejection::Malformed("crit")); + } + let kid = jwk_str(&header, "kid").ok_or(Rejection::Malformed("kid"))?; + let key = keys.0.get(kid).ok_or(Rejection::UnknownKey)?; + if key.alg() != alg { + return Err(Rejection::Algorithm); + } + + let sig = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| Rejection::Malformed("signature"))?; + let signing_input = &assertion[..header_b64.len() + 1 + claims_b64.len()]; + if !key.verify(signing_input.as_bytes(), &sig) { + return Err(Rejection::Signature); + } + + // Claims are only read once the signature says who wrote them. + let claims = decode_json_object(claims_b64, "claims")?; + let now = now as f64; + let exp = numeric_date(&claims, "exp")?.ok_or(Rejection::Malformed("exp"))?; + if now >= exp + CLOCK_LEEWAY_SECS { + return Err(Rejection::Expired); + } + if let Some(nbf) = numeric_date(&claims, "nbf")? { + if now + CLOCK_LEEWAY_SECS < nbf { + return Err(Rejection::NotYetValid); + } + } + if claims.get("iss").and_then(Value::as_str) != Some(issuer) { + return Err(Rejection::Issuer); + } + let audience_ok = match claims.get("aud") { + Some(Value::String(aud)) => aud == audience, + Some(Value::Array(auds)) => auds.iter().any(|a| a.as_str() == Some(audience)), + _ => false, + }; + if !audience_ok { + return Err(Rejection::Audience); + } + Ok(()) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// The configured proxy authentication with its key cache. +pub struct ProxyJwtAuth { + config: ProxyAuthConfig, + client: reqwest::Client, + keys: RwLock, Instant)>>, + /// Serializes refreshes and remembers the last attempt (success or not) + /// for the rate limit. + last_refresh: Mutex>, + min_refresh_interval: Duration, +} + +impl ProxyJwtAuth { + pub fn new(config: ProxyAuthConfig) -> Result { + let client = reqwest::Client::builder() + .timeout(JWKS_FETCH_TIMEOUT) + // A redirect could lead off the configured (https) origin. + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(Self { + config, + client, + keys: RwLock::new(None), + last_refresh: Mutex::new(None), + min_refresh_interval: MIN_REFRESH_INTERVAL, + }) + } + + pub fn config(&self) -> &ProxyAuthConfig { + &self.config + } + + /// Fetches the keys once at startup so the first request does not wait, + /// and so a wrong URL shows in the log right away. A failure is not fatal: + /// requests are refused until a later fetch succeeds. + pub async fn prime(&self) { + self.refresh().await; + if self.keys.read().await.is_none() { + tracing::warn!( + "[AUTH] proxy-jwt: no verification keys yet from {}; requests without the token are refused until a fetch succeeds", + self.config.jwks_url + ); + } + } + + /// Checks the request's assertion header. + pub async fn authenticate(&self, headers: &HeaderMap) -> Result<(), Rejection> { + let assertion = headers + .get(&self.config.header) + .ok_or(Rejection::Missing)? + .to_str() + .map_err(|_| Rejection::Malformed("header encoding"))?; + if assertion.len() > MAX_ASSERTION_LEN { + return Err(Rejection::Malformed("too long")); + } + let kid = assertion_kid(assertion)?; + let keys = self.keys_for(&kid).await?; + verify( + assertion, + &keys, + &self.config.issuer, + &self.config.audience, + unix_now(), + ) + } + + async fn cached(&self) -> Option<(Arc, Instant)> { + self.keys.read().await.clone() + } + + async fn keys_for(&self, kid: &str) -> Result, Rejection> { + if let Some((keys, fetched)) = self.cached().await { + if keys.contains(kid) && fetched.elapsed() < KEYS_MAX_AGE { + return Ok(keys); + } + } + self.refresh().await; + // A failed refresh keeps the previous keys: they still verify what + // they signed, and `exp` bounds how long such an assertion lives. + self.cached() + .await + .map(|(keys, _)| keys) + .ok_or(Rejection::NoKeys) + } + + async fn refresh(&self) { + let mut last = self.last_refresh.lock().await; + if last.is_some_and(|at| at.elapsed() < self.min_refresh_interval) { + return; + } + *last = Some(Instant::now()); + match self.fetch().await { + Ok(keys) => { + tracing::info!( + "[AUTH] proxy-jwt: loaded {} verification key(s)", + keys.len() + ); + *self.keys.write().await = Some((Arc::new(keys), Instant::now())); + } + Err(reason) => { + tracing::warn!( + "[AUTH] proxy-jwt: fetching keys from {} failed: {reason}", + self.config.jwks_url + ); + } + } + } + + async fn fetch(&self) -> Result { + let response = self + .client + .get(self.config.jwks_url.clone()) + .send() + .await + .map_err(|e| e.without_url().to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + if response + .content_length() + .is_some_and(|len| len > MAX_JWKS_BYTES as u64) + { + return Err("document too large".into()); + } + let body = response + .bytes() + .await + .map_err(|e| e.without_url().to_string())?; + if body.len() > MAX_JWKS_BYTES { + return Err("document too large".into()); + } + parse_jwks(&body).map_err(|e| e.to_string()) + } +} + +/// A test issuer: one ES256 key, its key set preloaded into a +/// `ProxyJwtAuth`, and assertions it signs. For other modules' tests. +#[cfg(test)] +pub(crate) mod testing { + use super::*; + use ring::rand::SystemRandom; + use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING}; + use serde_json::json; + + pub(crate) const HEADER: &str = "cf-access-jwt-assertion"; + pub(crate) const ISSUER: &str = "https://team.example.test"; + pub(crate) const AUDIENCE: &str = "app-audience-tag"; + + pub(crate) struct TestIssuer { + pair: EcdsaKeyPair, + } + + impl TestIssuer { + pub(crate) fn new() -> Self { + let rng = SystemRandom::new(); + let pkcs8 = + EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + let pair = + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng) + .unwrap(); + Self { pair } + } + + /// A `ProxyJwtAuth` that already holds this issuer's key, so no + /// request reaches the (unroutable) key URL. + pub(crate) async fn auth(&self) -> ProxyJwtAuth { + let config = ProxyAuthConfig { + header: HeaderName::from_static(HEADER), + jwks_url: reqwest::Url::parse("http://127.0.0.1:9/certs").unwrap(), + issuer: ISSUER.into(), + audience: AUDIENCE.into(), + }; + let auth = ProxyJwtAuth::new(config).unwrap(); + let p = self.pair.public_key().as_ref(); + let doc = json!({"keys": [{"kid": "k", "kty": "EC", "crv": "P-256", + "x": URL_SAFE_NO_PAD.encode(&p[1..33]), "y": URL_SAFE_NO_PAD.encode(&p[33..65])}]}); + let keys = parse_jwks(doc.to_string().as_bytes()).unwrap(); + *auth.keys.write().await = Some((Arc::new(keys), Instant::now())); + auth + } + + /// A currently valid assertion for `audience`. + pub(crate) fn assertion_for(&self, audience: &str) -> String { + let now = unix_now(); + let header = json!({"alg": "ES256", "kid": "k", "typ": "JWT"}); + let claims = json!({"iss": ISSUER, "aud": [audience], "exp": now + 300, + "iat": now, "nbf": now, "sub": "user"}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + let sig = self + .pair + .sign(&SystemRandom::new(), input.as_bytes()) + .unwrap(); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(sig.as_ref())) + } + + pub(crate) fn assertion(&self) -> String { + self.assertion_for(AUDIENCE) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ring::rand::SystemRandom; + use ring::signature::{ + EcdsaKeyPair, KeyPair, RsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING, RSA_PKCS1_SHA256, + }; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + // TEST-ONLY 2048-bit RSA key (PKCS#8 DER, standard base64), made for these + // tests with `openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -outform + // DER` (ring reads PKCS#8 only, and cannot generate RSA keys, hence a + // fixture). It signs nothing outside this module. + const TEST_ONLY_RSA_PKCS8: &str = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC4iJcjrRiHvy7nrn95DNrBIMOTix+8B6ZIguRZTiymPnfnaPwW5c+81Jg0yaZmm1XP2a181TluHDvRZ5lmjl83glTdlVwJ1I6NDeXY88RXYAX8kkrjEBw3XWClmEZr8e4QdhJguzJ+LJjnHsO23tC+/M1RDI/9oLXNB64GEmjFr6WyoX/c4MsJE/ANAma5lyNXXNJKTBYmN/ie1p4U8tA20qorT+2+cJv+tcQWkfuAKw0xnzfbViYgMv2pdXXM9H1yi/SGnJDU2927DysiKfWX3rbFiY6/z7kx0qQu8u+6Bz+tiPLoCiUY3/uOEGY/lh1+I7UH+uHCT3M5w16gtrwxAgMBAAECggEABL+rGAVv0Kp4VAj3Cc/tXzPvJIBbiDSwMESCCNRMrNiFsGRQvGwcjPDxm8YmrJRJwIgLAV/hUvaSCYD/fxah16VYdr1NuYkJRBL7+7nGLzNu5AGFwNMczsXt40UIZuW6aR0IuwmGMp4GL0ho4+9W8ttBk1jd6bp1gzmz3j8iCBhCWYI+Lfk23e9Lnb8aACMNyPK0a04q8q8c+Z303PLQ8Lw7YW2ep6fNu2zG2DksnKvOL11lYbFi/IIN6FWX+OMVqe5+jp/dzT6gCRZcGGeqJmaGqqBXyMp0+O3t2FeL20BXZD5N+glRSZFpW+8rpzJPSciwjePKsPlYw9eBmCdZmQKBgQDkgTP25hgAYzUV+M4n/0+ioGII316/WnTfPtrE+Y0baXPW8vQtvbiirCB61xEh//+c0Bs1PY+I0y28sm6rCo84F4Ta1CExlIYSHl6Ye1dCKIWlnFKo9hqUVMN1yuEir4ruVm6Vhw3/FhTgaVpOoO8UnTD1ngsF23FxZUQ4OZR0XQKBgQDOvOmFl3laEf+1HF4tsMiw6LGRBlnB1T1rsCQm/dihhEFvd6gDgltSKb/Sxenqcvnmc1bQb1y2DyiPAPgFgaWfq5stQrKPbc71x0ikcn2cu16sWMziH+PH1M0vaYzzjSZhfk/Wr6UyjR+DrT7E6ZF7d/x8sD2kE8rYSS9Q9Orp5QKBgD45CKN06JhCa5Q+GjU+CrtoHQn5eV12Dlk0TnJQ2iHax1aC9gb2oK57XKTWp62wfjR/XslSqCFEEDJQDF67E2Dc1qqYFSnhHdkVOAEhpRM44ZvFQ6UKzxYRNuB+jGvbjWN1WcuR6TfLFYg+Z/79R90/KLPZg3rm1K9iOf9tfoVpAoGBALrvEIyuUZlC6x9dfj78OgvU7DJBNsnQwg/snlPvdoFdcDmVTYOWzHI3s+6m7IME4bgiFJ62A3PPsxHTg3CfMR52cgLz6zdLyDZust2XdMNSnSFjziatRS582eeEHmJfABRpvVFXUx2pg5RyA27T76tf7Brm1WXWS7VUP4eJXhyNAoGBANzRGdONiEnwf7NkVf0MwpJhzNKherecxH5aGwDvt74q+FlA95V1QAuvxTksdF32Hk0x2OmWHTLkqib7HuxTzb0D6GqT1S4WYmmgk74/dvk01yw+XRsTTbx9z5yrQBadICs2Oo85gvzlALX3Akp8RTlKAz853ZufLLmbG3o6qEYC"; + + const ISS: &str = "https://team.example.test"; + const AUD: &str = "app-audience-tag"; + const NOW: u64 = 1_800_000_000; + + fn b64(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) + } + + enum SignerKind { + Rsa(RsaKeyPair), + Ec(EcdsaKeyPair), + } + + struct Signer { + kid: &'static str, + kind: SignerKind, + } + + fn rsa_signer(kid: &'static str) -> Signer { + use base64::engine::general_purpose::STANDARD; + let der = STANDARD.decode(TEST_ONLY_RSA_PKCS8).unwrap(); + Signer { + kid, + kind: SignerKind::Rsa(RsaKeyPair::from_pkcs8(&der).unwrap()), + } + } + + fn ec_signer(kid: &'static str) -> Signer { + let rng = SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng) + .unwrap(); + Signer { + kid, + kind: SignerKind::Ec(pair), + } + } + + impl Signer { + fn jwk(&self) -> Value { + match &self.kind { + SignerKind::Rsa(pair) => { + let c = RsaPublicKeyComponents::>::from(pair.public()); + json!({"kid": self.kid, "kty": "RSA", "alg": "RS256", "use": "sig", + "n": b64(&c.n), "e": b64(&c.e)}) + } + SignerKind::Ec(pair) => { + let p = pair.public_key().as_ref(); + json!({"kid": self.kid, "kty": "EC", "crv": "P-256", "alg": "ES256", + "use": "sig", "x": b64(&p[1..33]), "y": b64(&p[33..65])}) + } + } + } + + fn alg(&self) -> &'static str { + match self.kind { + SignerKind::Rsa(_) => "RS256", + SignerKind::Ec(_) => "ES256", + } + } + + fn sign_with_header(&self, header: &Value, claims: &Value) -> String { + let input = format!( + "{}.{}", + b64(header.to_string().as_bytes()), + b64(claims.to_string().as_bytes()) + ); + let rng = SystemRandom::new(); + let sig = match &self.kind { + SignerKind::Rsa(pair) => { + let mut sig = vec![0; pair.public().modulus_len()]; + pair.sign(&RSA_PKCS1_SHA256, &rng, input.as_bytes(), &mut sig) + .unwrap(); + sig + } + SignerKind::Ec(pair) => { + pair.sign(&rng, input.as_bytes()).unwrap().as_ref().to_vec() + } + }; + format!("{input}.{}", b64(&sig)) + } + + fn sign(&self, claims: &Value) -> String { + let header = json!({"alg": self.alg(), "kid": self.kid, "typ": "JWT"}); + self.sign_with_header(&header, claims) + } + } + + fn keyset(signers: &[&Signer]) -> KeySet { + let doc = json!({"keys": signers.iter().map(|s| s.jwk()).collect::>()}); + parse_jwks(doc.to_string().as_bytes()).unwrap() + } + + // Shaped like a Cloudflare Access application token. + fn claims() -> Value { + json!({"iss": ISS, "aud": [AUD], "exp": NOW + 300, "iat": NOW - 60, + "nbf": NOW - 60, "sub": "user-id", "email": "someone@example.test", + "type": "app"}) + } + + fn check(token: &str, keys: &KeySet) -> Result<(), Rejection> { + verify(token, keys, ISS, AUD, NOW) + } + + // ── verify: what passes ────────────────────────────────────────────── + + #[test] + fn accepts_rs256_and_es256() { + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + let keys = keyset(&[&rsa, &ec]); + assert_eq!(check(&rsa.sign(&claims()), &keys), Ok(())); + assert_eq!(check(&ec.sign(&claims()), &keys), Ok(())); + } + + #[test] + fn accepts_a_string_audience_and_no_nbf() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["aud"] = json!(AUD); + c.as_object_mut().unwrap().remove("nbf"); + assert_eq!(check(&rsa.sign(&c), &keyset(&[&rsa])), Ok(())); + } + + #[test] + fn tolerates_clock_skew_within_the_leeway() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["exp"] = json!(NOW - 30); + c["nbf"] = json!(NOW + 30); + assert_eq!(check(&rsa.sign(&c), &keyset(&[&rsa])), Ok(())); + } + + // ── verify: what is refused ────────────────────────────────────────── + + #[test] + fn refuses_an_expired_assertion() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["exp"] = json!(NOW - 61); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Expired) + ); + } + + #[test] + fn refuses_an_assertion_without_exp() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c.as_object_mut().unwrap().remove("exp"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Malformed("exp")) + ); + } + + #[test] + fn refuses_a_non_numeric_exp() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["exp"] = json!("tomorrow"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Malformed("date claim")) + ); + } + + #[test] + fn refuses_an_assertion_not_yet_valid() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["nbf"] = json!(NOW + 61); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::NotYetValid) + ); + } + + #[test] + fn refuses_another_issuer() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["iss"] = json!("https://other.example.test"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Issuer) + ); + c.as_object_mut().unwrap().remove("iss"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Issuer) + ); + } + + #[test] + fn refuses_another_audience() { + let rsa = rsa_signer("r1"); + let keys = keyset(&[&rsa]); + let mut c = claims(); + c["aud"] = json!(["other-app"]); + assert_eq!(check(&rsa.sign(&c), &keys), Err(Rejection::Audience)); + c["aud"] = json!("other-app"); + assert_eq!(check(&rsa.sign(&c), &keys), Err(Rejection::Audience)); + c.as_object_mut().unwrap().remove("aud"); + assert_eq!(check(&rsa.sign(&c), &keys), Err(Rejection::Audience)); + } + + #[test] + fn refuses_a_tampered_payload() { + let rsa = rsa_signer("r1"); + let token = rsa.sign(&claims()); + let parts: Vec<&str> = token.split('.').collect(); + let mut c = claims(); + c["email"] = json!("attacker@example.test"); + let forged = format!( + "{}.{}.{}", + parts[0], + b64(c.to_string().as_bytes()), + parts[2] + ); + assert_eq!(check(&forged, &keyset(&[&rsa])), Err(Rejection::Signature)); + } + + #[test] + fn refuses_a_signature_by_a_key_not_in_the_set() { + let (trusted, other) = (ec_signer("k1"), ec_signer("k1")); + assert_eq!( + check(&other.sign(&claims()), &keyset(&[&trusted])), + Err(Rejection::Signature) + ); + } + + #[test] + fn refuses_an_unknown_key_id() { + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + assert_eq!( + check(&ec.sign(&claims()), &keyset(&[&rsa])), + Err(Rejection::UnknownKey) + ); + } + + #[test] + fn refuses_alg_none_and_hs256() { + let rsa = rsa_signer("r1"); + let keys = keyset(&[&rsa]); + let body = b64(claims().to_string().as_bytes()); + for alg in ["none", "HS256", "RS512", "PS256"] { + let header = b64(json!({"alg": alg, "kid": "r1"}).to_string().as_bytes()); + assert_eq!( + check(&format!("{header}.{body}."), &keys), + Err(Rejection::Algorithm), + "{alg}" + ); + assert_eq!( + check(&format!("{header}.{body}.c2ln"), &keys), + Err(Rejection::Algorithm), + "{alg}" + ); + } + } + + #[test] + fn refuses_a_header_alg_that_disagrees_with_the_key() { + // ES256 claimed for an RSA key id: the key decides the algorithm. + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + let header = json!({"alg": "ES256", "kid": "r1"}); + let token = ec.sign_with_header(&header, &claims()); + assert_eq!(check(&token, &keyset(&[&rsa])), Err(Rejection::Algorithm)); + } + + #[test] + fn refuses_a_crit_header() { + let rsa = rsa_signer("r1"); + let header = json!({"alg": "RS256", "kid": "r1", "crit": ["exp"]}); + let token = rsa.sign_with_header(&header, &claims()); + assert_eq!( + check(&token, &keyset(&[&rsa])), + Err(Rejection::Malformed("crit")) + ); + } + + #[test] + fn refuses_malformed_structure() { + let rsa = rsa_signer("r1"); + let keys = keyset(&[&rsa]); + let token = rsa.sign(&claims()); + assert_eq!( + check("", &keys), + Err(Rejection::Malformed("not three parts")) + ); + assert_eq!( + check("a.b", &keys), + Err(Rejection::Malformed("not three parts")) + ); + assert_eq!( + check(&format!("{token}.extra"), &keys), + Err(Rejection::Malformed("not three parts")) + ); + assert_eq!(check("!!.b.c", &keys), Err(Rejection::Malformed("header"))); + let no_kid = rsa.sign_with_header(&json!({"alg": "RS256"}), &claims()); + assert_eq!(check(&no_kid, &keys), Err(Rejection::Malformed("kid"))); + let (head, rest) = token.split_once('.').unwrap(); + let (body, _) = rest.split_once('.').unwrap(); + assert_eq!( + check(&format!("{head}.{body}.!!"), &keys), + Err(Rejection::Malformed("signature")) + ); + assert_eq!( + check(&format!("{head}.{body}."), &keys), + Err(Rejection::Signature) + ); + let long = "a".repeat(MAX_ASSERTION_LEN + 1); + assert_eq!(check(&long, &keys), Err(Rejection::Malformed("too long"))); + } + + #[test] + fn refuses_a_signed_payload_that_is_not_an_object() { + let rsa = rsa_signer("r1"); + let token = rsa.sign(&json!(["not", "claims"])); + assert_eq!( + check(&token, &keyset(&[&rsa])), + Err(Rejection::Malformed("claims")) + ); + } + + // ── parse_jwks ─────────────────────────────────────────────────────── + + #[test] + fn jwks_keeps_usable_keys_and_skips_the_rest() { + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + let mut enc = rsa.jwk(); + enc["kid"] = json!("enc"); + enc["use"] = json!("enc"); + let mut mislabeled = ec.jwk(); + mislabeled["kid"] = json!("mislabeled"); + mislabeled["alg"] = json!("RS256"); + let mut no_kid = rsa.jwk(); + no_kid.as_object_mut().unwrap().remove("kid"); + let doc = json!({ + "keys": [rsa.jwk(), ec.jwk(), enc, mislabeled, no_kid, + {"kid": "p384", "kty": "EC", "crv": "P-384", "x": "AA", "y": "AA"}, + {"kid": "oct", "kty": "oct", "k": "c2VjcmV0"}, + {"kid": "short", "kty": "EC", "crv": "P-256", "x": "AAAA", "y": "AAAA"}, + "not an object"], + // Cloudflare publishes the same keys as certificates beside them. + "public_cert": {"kid": "r1", "cert": "-----BEGIN CERTIFICATE-----"}, + "public_certs": [] + }); + let keys = parse_jwks(doc.to_string().as_bytes()).unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.contains("r1") && keys.contains("e1")); + } + + #[test] + fn jwks_without_a_usable_key_is_an_error() { + assert_eq!( + parse_jwks(b"{\"keys\": []}").unwrap_err(), + JwksError::NoUsableKey + ); + assert_eq!( + parse_jwks(b"{\"keys\": {}}").unwrap_err(), + JwksError::NotAKeySet + ); + assert_eq!(parse_jwks(b"").unwrap_err(), JwksError::NotAKeySet); + } + + // ── configuration ──────────────────────────────────────────────────── + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |name| map.get(name).cloned() + } + + fn full() -> Vec<(&'static str, &'static str)> { + vec![ + (AUTH_MODE_VAR, "proxy-jwt"), + (HEADER_VAR, "Cf-Access-Jwt-Assertion"), + ( + JWKS_URL_VAR, + "https://team.example.test/cdn-cgi/access/certs", + ), + (ISSUER_VAR, ISS), + (AUDIENCE_VAR, AUD), + ] + } + + #[test] + fn config_is_off_by_default() { + assert_eq!(ProxyAuthConfig::from_lookup(env(&[])), Ok(None)); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&[(AUTH_MODE_VAR, "token")])), + Ok(None) + ); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&[(AUTH_MODE_VAR, " ")])), + Ok(None) + ); + } + + #[test] + fn config_reads_a_complete_proxy_setup() { + let config = ProxyAuthConfig::from_lookup(env(&full())).unwrap().unwrap(); + assert_eq!(config.header.as_str(), "cf-access-jwt-assertion"); + assert_eq!(config.issuer, ISS); + assert_eq!(config.audience, AUD); + } + + #[test] + fn config_refuses_an_incomplete_proxy_setup() { + for missing in [HEADER_VAR, JWKS_URL_VAR, ISSUER_VAR, AUDIENCE_VAR] { + let pairs: Vec<_> = full().into_iter().filter(|(k, _)| *k != missing).collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&pairs)), + Err(ConfigError::Missing(missing)) + ); + let blank: Vec<_> = full() + .into_iter() + .map(|(k, v)| if k == missing { (k, " ") } else { (k, v) }) + .collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&blank)), + Err(ConfigError::Missing(missing)) + ); + } + } + + #[test] + fn config_refuses_an_unknown_mode_and_settings_without_the_mode() { + assert_eq!( + ProxyAuthConfig::from_lookup(env(&[(AUTH_MODE_VAR, "proxy")])), + Err(ConfigError::UnknownMode("proxy".into())) + ); + let without: Vec<_> = full() + .into_iter() + .filter(|(k, _)| *k != AUTH_MODE_VAR) + .collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&without)), + Err(ConfigError::WithoutMode(HEADER_VAR)) + ); + } + + #[test] + fn config_refuses_a_bad_header_name() { + let pairs: Vec<_> = full() + .into_iter() + .map(|(k, v)| { + if k == HEADER_VAR { + (k, "bad header") + } else { + (k, v) + } + }) + .collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&pairs)), + Err(ConfigError::BadHeader("bad header".into())) + ); + } + + #[test] + fn config_wants_https_for_the_keys_except_on_loopback() { + let with_url = |url: &'static str| { + let pairs: Vec<_> = full() + .into_iter() + .map(|(k, v)| if k == JWKS_URL_VAR { (k, url) } else { (k, v) }) + .collect(); + ProxyAuthConfig::from_lookup(env(&pairs)) + }; + for bad in [ + "http://team.example.test/certs", + "ftp://team.example.test/certs", + "file:///etc/certs.json", + "not a url", + ] { + assert_eq!( + with_url(bad), + Err(ConfigError::BadJwksUrl(bad.into())), + "{bad}" + ); + } + for ok in [ + "http://127.0.0.1:8080/certs", + "http://localhost/certs", + "http://[::1]/certs", + ] { + assert!(with_url(ok).unwrap().is_some(), "{ok}"); + } + } + + // ── key cache ──────────────────────────────────────────────────────── + + struct KeyServer { + url: String, + served: Arc>, + hits: Arc, + } + + async fn key_server(doc: Value) -> KeyServer { + let served = Arc::new(std::sync::Mutex::new(doc)); + let hits = Arc::new(AtomicUsize::new(0)); + let (s, h) = (served.clone(), hits.clone()); + let app = axum::Router::new().route( + "/certs", + axum::routing::get(move || { + let (s, h) = (s.clone(), h.clone()); + async move { + h.fetch_add(1, Ordering::SeqCst); + axum::Json(s.lock().unwrap().clone()) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + KeyServer { url, served, hits } + } + + fn auth_for(url: &str, min_refresh_interval: Duration) -> ProxyJwtAuth { + let config = ProxyAuthConfig::from_lookup(env(&[ + (AUTH_MODE_VAR, "proxy-jwt"), + (HEADER_VAR, "Cf-Access-Jwt-Assertion"), + (JWKS_URL_VAR, url), + (ISSUER_VAR, ISS), + (AUDIENCE_VAR, AUD), + ])) + .unwrap() + .unwrap(); + let mut auth = ProxyJwtAuth::new(config).unwrap(); + auth.min_refresh_interval = min_refresh_interval; + auth + } + + fn live_claims() -> Value { + let now = unix_now(); + let mut c = claims(); + c["exp"] = json!(now + 300); + c["iat"] = json!(now); + c["nbf"] = json!(now); + c + } + + fn with_assertion(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("cf-access-jwt-assertion", token.parse().unwrap()); + headers + } + + #[tokio::test] + async fn fetches_keys_once_and_serves_from_the_cache() { + let rsa = rsa_signer("r1"); + let server = key_server(json!({"keys": [rsa.jwk()]})).await; + let auth = auth_for(&server.url, Duration::ZERO); + let token = rsa.sign(&live_claims()); + + assert_eq!(auth.authenticate(&with_assertion(&token)).await, Ok(())); + assert_eq!(auth.authenticate(&with_assertion(&token)).await, Ok(())); + assert_eq!(server.hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn an_unknown_key_id_refreshes_the_keys_after_a_rotation() { + let (old, new) = (ec_signer("old"), ec_signer("new")); + let server = key_server(json!({"keys": [old.jwk()]})).await; + let auth = auth_for(&server.url, Duration::ZERO); + auth.prime().await; + + *server.served.lock().unwrap() = json!({"keys": [old.jwk(), new.jwk()]}); + let token = new.sign(&live_claims()); + assert_eq!(auth.authenticate(&with_assertion(&token)).await, Ok(())); + assert_eq!(server.hits.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn unknown_key_ids_cannot_drive_a_fetch_per_request() { + let (known, stranger) = (ec_signer("known"), ec_signer("stranger")); + let server = key_server(json!({"keys": [known.jwk()]})).await; + let auth = auth_for(&server.url, Duration::from_secs(3600)); + auth.prime().await; + + let token = stranger.sign(&live_claims()); + for _ in 0..5 { + assert_eq!( + auth.authenticate(&with_assertion(&token)).await, + Err(Rejection::UnknownKey) + ); + } + assert_eq!(server.hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn refuses_everything_while_no_keys_could_be_fetched() { + // Bound and closed again: nothing listens on that port. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", listener.local_addr().unwrap()); + drop(listener); + let auth = auth_for(&url, Duration::ZERO); + let token = rsa_signer("r1").sign(&live_claims()); + assert_eq!( + auth.authenticate(&with_assertion(&token)).await, + Err(Rejection::NoKeys) + ); + } + + #[tokio::test] + async fn keeps_the_last_keys_when_a_refresh_fails() { + let (rsa, stranger) = (rsa_signer("r1"), ec_signer("x")); + let server = key_server(json!({"keys": [rsa.jwk()]})).await; + let auth = auth_for(&server.url, Duration::ZERO); + auth.prime().await; + + // The endpoint now serves garbage; an unknown kid forces a refresh. + *server.served.lock().unwrap() = json!({"keys": []}); + assert_eq!( + auth.authenticate(&with_assertion(&stranger.sign(&live_claims()))) + .await, + Err(Rejection::UnknownKey) + ); + assert_eq!( + auth.authenticate(&with_assertion(&rsa.sign(&live_claims()))) + .await, + Ok(()) + ); + } + + #[tokio::test] + async fn refuses_a_request_without_the_header() { + let auth = auth_for("http://127.0.0.1:9/certs", Duration::ZERO); + assert_eq!( + auth.authenticate(&HeaderMap::new()).await, + Err(Rejection::Missing) + ); + } +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 024058d60d..2df32e35e6 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -18,18 +18,36 @@ use super::{auth, handlers, ws}; use crate::app_state::AppState; use tracing::Instrument; +/// Token-only authentication: the desktop app's web service and the tests. pub fn build_router( state: Arc, token: String, static_dir: std::path::PathBuf, shutdown_signal: Arc, +) -> Router { + build_router_with_proxy_auth(state, token, None, static_dir, shutdown_signal) +} + +/// `proxy_auth` turns on the opt-in proxy-JWT authentication beside the +/// token (`CODEG_AUTH=proxy-jwt`, standalone `codeg-server` only); `None` is +/// the same as `build_router`. +pub fn build_router_with_proxy_auth( + state: Arc, + token: String, + proxy_auth: Option>, + static_dir: std::path::PathBuf, + shutdown_signal: Arc, ) -> Router { let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - let token_for_ws = token.clone(); + let web_auth = auth::WebAuth { + token: token.clone(), + proxy: proxy_auth, + }; + let web_auth_for_ws = web_auth.clone(); let token_for_cli_hello = token.clone(); let api = Router::new() @@ -1983,7 +2001,7 @@ pub fn build_router( // Catch-all .fallback(api_not_found) .layer(middleware::from_fn(move |req, next| { - auth::require_token(req, next, token.clone()) + auth::require_auth(req, next, web_auth.clone()) })); // Public endpoints — no token required. @@ -2045,7 +2063,7 @@ pub fn build_router( // the viewer. `.instrument()` the downstream future; never `.enter()` across // an await, which corrupts the span stack. // - // Layer order: the protected router's `auth::require_token` layer was added + // Layer order: the protected router's `auth::require_auth` layer was added // (above) BEFORE this `.layer()`, and this layer is applied to the MERGED // router, so it is the OUTERMOST layer. The instrumented `next.run(req)` // future therefore wraps routing + auth + handler — auth-reject logs land @@ -2068,7 +2086,7 @@ pub fn build_router( get(crate::web::browser_tunnel::ws_handler), ) .layer(middleware::from_fn(move |req, next| { - auth::require_token(req, next, token_for_ws.clone()) + auth::require_auth(req, next, web_auth_for_ws.clone()) })); // Static file serving. diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx index 3a540a06b3..5fbfede4a7 100644 --- a/src/app/login/page.test.tsx +++ b/src/app/login/page.test.tsx @@ -42,10 +42,14 @@ function submit(token: string) { describe("login page", () => { it("asks a proxy for a 401 instead of a redirect", async () => { - fetchMock.mockResolvedValue(reply(200)) + // The page first asks whether a proxy signed the browser in (no token); + // this server wants one. The form's request follows. + fetchMock + .mockResolvedValueOnce(reply(401, 'Bearer realm="codeg"')) + .mockResolvedValue(reply(200)) submit("tok") await vi.waitFor(() => expect(router.replace).toHaveBeenCalled()) - expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ + expect(fetchMock.mock.calls[1][1].headers).toMatchObject({ Authorization: "Bearer tok", "X-Requested-With": "XMLHttpRequest", }) @@ -72,3 +76,18 @@ describe("login page", () => { ).not.toBeInTheDocument() }) }) + +describe("login page behind a proxy", () => { + it("goes straight to the workspace when the proxy already admits the browser", async () => { + fetchMock.mockResolvedValue(reply(200)) + render( + + + + ) + await vi.waitFor(() => + expect(router.replace).toHaveBeenCalledWith("/workspace") + ) + expect(localStorage.getItem("codeg_proxy_auth")).toBe("1") + }) +}) diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 038182bc16..d27fc50c7f 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,7 +4,11 @@ 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" +import { + codegAuthHeaders, + probeProxyAuthentication, + unauthorizedSource, +} from "@/lib/transport/web-auth" export default function LoginPage() { const router = useRouter() @@ -18,6 +22,19 @@ export default function LoginPage() { document.title = t("documentTitle") }, [t]) + // Signed in through a proxy in front of Codeg (CODEG_AUTH=proxy-jwt), there + // is no token to ask for. + useEffect(() => { + if (isDesktop()) return + let cancelled = false + void probeProxyAuthentication().then((result) => { + if (!cancelled && result === "proxy") router.replace("/workspace") + }) + return () => { + cancelled = true + } + }, [router]) + // Desktop users skip login entirely if (isDesktop()) { router.replace("/workspace") diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index d386fb1ab3..16ac25727e 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -57,3 +57,22 @@ describe("root page token check", () => { expect(localStorage.getItem("codeg_token")).toBe("tok") }) }) + +describe("root page without a token", () => { + beforeEach(() => localStorage.removeItem("codeg_token")) + + it("enters the workspace when a proxy in front of Codeg admits the browser", async () => { + fetchMock.mockResolvedValue(reply(200)) + render() + await waitFor(() => + expect(router.replace).toHaveBeenCalledWith("/workspace") + ) + expect(localStorage.getItem("codeg_proxy_auth")).toBe("1") + }) + + it("asks for the token when the server wants one", async () => { + fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"')) + render() + await waitFor(() => expect(router.replace).toHaveBeenCalledWith("/login")) + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index 27c0ebae92..22883c955d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,11 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" import { isDesktop } from "@/lib/platform" -import { codegAuthHeaders, unauthorizedSource } from "@/lib/transport/web-auth" +import { + codegAuthHeaders, + probeProxyAuthentication, + unauthorizedSource, +} from "@/lib/transport/web-auth" export default function Page() { const router = useRouter() @@ -15,7 +19,12 @@ export default function Page() { // Web mode: validate token before entering app const token = localStorage.getItem("codeg_token") if (!token) { - router.replace("/login") + // A proxy in front of Codeg may have signed the user in already + // (CODEG_AUTH=proxy-jwt); an unreachable server leaves that to the + // in-app connection dialog, as with a token. + void probeProxyAuthentication().then((result) => { + router.replace(result === "unauthorized" ? "/login" : "/workspace") + }) return } // Verify token is still valid diff --git a/src/components/connection/web-version-notice.test.tsx b/src/components/connection/web-version-notice.test.tsx index 2a877a9ccd..11357f79a3 100644 --- a/src/components/connection/web-version-notice.test.tsx +++ b/src/components/connection/web-version-notice.test.tsx @@ -37,6 +37,9 @@ vi.mock("@/lib/transport/web-connection-store", () => ({ })) vi.mock("@/lib/transport/web-auth", () => ({ getCodegToken: mocks.token, + // The component asks for credentials (token or proxy sign-in); here the + // token stands for them. + hasCodegCredentials: () => Boolean(mocks.token()), })) vi.mock("@/lib/updater", () => ({ getRunningServerVersion: mocks.serverVersion, diff --git a/src/components/connection/web-version-notice.tsx b/src/components/connection/web-version-notice.tsx index b4fc9615ff..8b39c332fe 100644 --- a/src/components/connection/web-version-notice.tsx +++ b/src/components/connection/web-version-notice.tsx @@ -8,7 +8,7 @@ import { isOwnServerRestartRunning, subscribeOwnServerRestart, } from "@/lib/updater" -import { getCodegToken } from "@/lib/transport/web-auth" +import { hasCodegCredentials } from "@/lib/transport/web-auth" import { getWebConnectionServerSnapshot, getWebConnectionSnapshot, @@ -51,9 +51,9 @@ export function WebVersionNotice() { // development build is not one, whatever its version says. const pageVersion = process.env.CODEG_APP_VERSION if (!pageVersion || process.env.NODE_ENV !== "production") return - // Without a token the request would be refused, and a refusal reads as an - // expired session. - if (!usesWebTransport() || !getCodegToken()) return + // Without credentials the request would be refused, and a refusal reads + // as an expired session. + if (!usesWebTransport() || !hasCodegCredentials()) return let cancelled = false void getRunningServerVersion().then((serverVersion) => { // An older server does not report its version. diff --git a/src/lib/transport/web-auth.test.ts b/src/lib/transport/web-auth.test.ts index c836628872..ab2d2012f6 100644 --- a/src/lib/transport/web-auth.test.ts +++ b/src/lib/transport/web-auth.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from "vitest" -import { codegAuthHeaders, unauthorizedSource } from "./web-auth" +import { afterEach, describe, expect, it, vi } from "vitest" +import { + codegAuthHeaders, + hasCodegCredentials, + isProxyAuthenticated, + probeProxyAuthentication, + unauthorizedSource, +} from "./web-auth" + +afterEach(() => { + localStorage.clear() + vi.unstubAllGlobals() +}) // The challenge as fetch reads it from a real 401 response. const with401 = (challenge?: string) => @@ -44,3 +55,56 @@ describe("unauthorizedSource", () => { ) }) }) + +describe("proxy authentication (CODEG_AUTH=proxy-jwt)", () => { + it("sends no Authorization header when there is no token", () => { + expect(codegAuthHeaders("")).toEqual({ + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("counts a token or a proxy sign-in as credentials", () => { + expect(hasCodegCredentials()).toBe(false) + localStorage.setItem("codeg_token", "tok") + expect(hasCodegCredentials()).toBe(true) + localStorage.clear() + localStorage.setItem("codeg_proxy_auth", "1") + expect(hasCodegCredentials()).toBe(true) + }) + + it("attributes every 401 to the proxy while signed in through it without a token", () => { + localStorage.setItem("codeg_proxy_auth", "1") + // No token to re-enter: only the proxy's login can help. + expect(unauthorizedSource('Bearer realm="codeg"')).toBe("proxy") + localStorage.setItem("codeg_token", "tok") + expect(unauthorizedSource('Bearer realm="codeg"')).toBe("codeg") + }) + + it("marks the browser proxy-authenticated when the server admits it without a token", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + vi.stubGlobal("fetch", fetchMock) + await expect(probeProxyAuthentication()).resolves.toBe("proxy") + expect(isProxyAuthenticated()).toBe(true) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe("/api/health") + expect(init.headers).not.toHaveProperty("Authorization") + expect(init.headers).toMatchObject({ "X-Requested-With": "XMLHttpRequest" }) + }) + + it("clears the mark when the server wants a token", async () => { + localStorage.setItem("codeg_proxy_auth", "1") + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 401 }) + ) + await expect(probeProxyAuthentication()).resolves.toBe("unauthorized") + expect(isProxyAuthenticated()).toBe(false) + }) + + it("reports an unreachable server without touching the mark", async () => { + localStorage.setItem("codeg_proxy_auth", "1") + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed"))) + await expect(probeProxyAuthentication()).resolves.toBe("unreachable") + expect(isProxyAuthenticated()).toBe(true) + }) +}) diff --git a/src/lib/transport/web-auth.ts b/src/lib/transport/web-auth.ts index a3e9a8bf2c..4955c1b350 100644 --- a/src/lib/transport/web-auth.ts +++ b/src/lib/transport/web-auth.ts @@ -6,6 +6,9 @@ // call site. const TOKEN_KEY = "codeg_token" +// Set when the server admitted this browser without a token: a proxy in front +// of it vouched for the user (`CODEG_AUTH=proxy-jwt` on codeg-server). +const PROXY_AUTH_KEY = "codeg_proxy_auth" // 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 @@ -17,7 +20,9 @@ export type UnauthorizedSource = "codeg" | "proxy" export function codegAuthHeaders(token: string): Record { return { - Authorization: `Bearer ${token}`, + // Signed in through a proxy there is no token, and an empty bearer is + // noise at best. + ...(token ? { 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 @@ -26,13 +31,56 @@ export function codegAuthHeaders(token: string): Record { } } -/** Who answered a 401, from its `WWW-Authenticate` header (null if absent). */ +/** + * Who answered a 401, from its `WWW-Authenticate` header (null if absent). + * Signed in through a proxy without a token, every 401 is the proxy's to + * resolve: there is no token to re-enter, only the proxy's login. + */ export function unauthorizedSource( challenge: string | null ): UnauthorizedSource { + if (isProxyAuthenticated() && !getCodegToken()) return "proxy" return CODEG_CHALLENGE.test((challenge ?? "").trim()) ? "codeg" : "proxy" } +export function isProxyAuthenticated(): boolean { + return localStorage.getItem(PROXY_AUTH_KEY) === "1" +} + +/** A token, or a proxy that admitted this browser without one. */ +export function hasCodegCredentials(): boolean { + return Boolean(getCodegToken()) || isProxyAuthenticated() +} + +/** + * Asks the server whether it admits this browser without a token, which it + * does only when a proxy in front of it vouched for the user. Remembers the + * answer for the transport; an unreachable server leaves it as it was. + */ +export async function probeProxyAuthentication(): Promise< + "proxy" | "unauthorized" | "unreachable" +> { + let res: Response + try { + res = await fetch("/api/health", { + method: "POST", + headers: { "Content-Type": "application/json", ...codegAuthHeaders("") }, + body: "{}", + }) + } catch { + return "unreachable" + } + if (res.ok) { + localStorage.setItem(PROXY_AUTH_KEY, "1") + return "proxy" + } + if (res.status === 401) { + localStorage.removeItem(PROXY_AUTH_KEY) + return "unauthorized" + } + return "unreachable" +} + export function getCodegToken(): string { return localStorage.getItem(TOKEN_KEY) ?? "" } @@ -40,5 +88,6 @@ export function getCodegToken(): string { export function redirectToCodegLogin(): void { if (window.location.pathname.startsWith("/login")) return localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(PROXY_AUTH_KEY) window.location.href = "/login" } diff --git a/src/lib/transport/web-transport.test.ts b/src/lib/transport/web-transport.test.ts index 15629bf16c..ffa69a9c34 100644 --- a/src/lib/transport/web-transport.test.ts +++ b/src/lib/transport/web-transport.test.ts @@ -381,3 +381,46 @@ describe("WebTransport.call", () => { expect(proxied.getConnectionSnapshot()).toBe("proxyUnauthorized") }) }) + +describe("WebTransport signed in through a proxy (no token)", () => { + beforeEach(() => { + localStorage.removeItem("codeg_token") + localStorage.setItem("codeg_proxy_auth", "1") + }) + + it("opens the event socket without a token protocol", () => { + const t = new WebTransport("http://localhost") + t.eventStream() + expect(MockWebSocket.instances).toHaveLength(1) + expect(lastWs().protocols).toEqual(["codeg-events"]) + }) + + it("calls the API without an Authorization header", async () => { + const t = new WebTransport("http://localhost") + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + text: async () => "{}", + }) + await t.call("list_things") + const [, init] = fetchMock.mock.calls[0] + expect(init.headers).not.toHaveProperty("Authorization") + }) + + it("probes health after a drop instead of declaring the session gone", async () => { + const { t, ws } = connectReady() + fetchMock.mockResolvedValue(ok200()) + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + expect(fetchMock).toHaveBeenCalled() + expect(t.getConnectionSnapshot()).toBe("reconnecting") + }) + + it("treats any 401 as the proxy's: a reload is the only way back in", async () => { + const { t, ws } = connectReady() + fetchMock.mockResolvedValue(resp401()) + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + expect(t.getConnectionSnapshot()).toBe("proxyUnauthorized") + }) +}) diff --git a/src/lib/transport/web-transport.ts b/src/lib/transport/web-transport.ts index ff7be71871..fde4044707 100644 --- a/src/lib/transport/web-transport.ts +++ b/src/lib/transport/web-transport.ts @@ -12,6 +12,7 @@ import { parseJsonOrThrow } from "../json-parse-error" import { codegAuthHeaders, getCodegToken, + hasCodegCredentials, unauthorizedSource, type UnauthorizedSource, } from "./web-auth" @@ -224,8 +225,8 @@ export class WebTransport implements Transport { const wrappedHandler = handler as (payload: unknown) => void this.handlers.get(event)!.add(wrappedHandler) - // If WS is not connected but we now have a token, connect - if (!this.ws && getToken()) { + // If WS is not connected but we now have credentials, connect + if (!this.ws && hasCodegCredentials()) { this.connectWs() } @@ -236,7 +237,7 @@ export class WebTransport implements Transport { // ACP `Connected` event after a fast Initialize) are silently dropped // because the broadcaster skips `send` when receiver_count == 0, leaving // the UI permanently stuck on "正在连接". - if (getToken()) { + if (hasCodegCredentials()) { await this.waitForReady() } @@ -348,10 +349,10 @@ export class WebTransport implements Transport { }, } this.eventStreamInstance = new WebEventStream(host) - // If a token is present but the WS hasn't been opened yet (no legacy + // If credentials are present but the WS hasn't been opened yet (no legacy // subscribe has run), trigger the connect now so attach frames sent // by the consumer have a place to land. - if (!this.ws && getToken()) { + if (!this.ws && hasCodegCredentials()) { this.connectWs() } } @@ -369,12 +370,14 @@ export class WebTransport implements Transport { } } - // Returns false when there's no token to connect with (caller decides how to + // Returns false when there are no credentials to connect with (caller decides how to // surface that — probeHealth treats it as unauthorized rather than a silent // dead-end). Returns true once a socket has been created. private connectWs(): boolean { + if (!hasCodegCredentials()) return false + // Empty when a proxy signed this browser in: the socket then carries only + // the event protocol, and the proxy's assertion rides on the upgrade. const token = getToken() - if (!token) return false // Drop any lingering socket before opening a new one. teardownWs detaches // handlers first, so the old socket's async onclose can't fire after we've @@ -512,8 +515,8 @@ export class WebTransport implements Transport { private async probeHealth(): Promise { if (this.destroyed || this.healthProbeInFlight) return const token = getToken() - if (!token) { - // No token to validate — treat as unauthorized rather than spin. + if (!hasCodegCredentials()) { + // No credentials to validate — treat as unauthorized rather than spin. this.markUnauthorized() return } @@ -548,7 +551,7 @@ export class WebTransport implements Transport { } if (res.ok) { // Server up + token valid → rebuild the WS. onopen and the `__ready__` - // frame will flip the dialog back to "connected". If the token vanished + // frame will flip the dialog back to "connected". If the credentials vanished // between probe start and now (e.g. logout in another tab), connectWs // reports false → surface unauthorized rather than a stuck "reconnecting". if (!this.connectWs()) this.markUnauthorized() From 8409e218ddf5666fc38beb6c0054aa6b05f102d3 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:34:16 +0800 Subject: [PATCH 03/13] fix(server): refuse a proxy-admitted request that another site's page sent The proxy's assertion rides on its session cookie, and the browser attaches that cookie to requests other sites' pages make as well: Cloudflare sets its application cookie SameSite=None unless the operator changes it. With CODEG_AUTH=proxy-jwt, any page the signed-in user opened could therefore open /ws/events or /ws/browser-tunnel (a TCP tunnel from the server's side), or post a multipart upload, as that user; neither needs a CORS preflight. A request admitted by an assertion now has to come from Codeg's own page: Sec-Fetch-Site same-origin or none, or, in a browser without Fetch Metadata, an Origin that names the Host or X-Forwarded-Host it was sent to. Anything else gets a 403 and a warning with the reason. The token path is unchanged: another site's page cannot send the token. --- docs/proxy-auth.md | 27 ++++- src-tauri/src/web/auth.rs | 215 +++++++++++++++++++++++++++++++++++++- 2 files changed, 239 insertions(+), 3 deletions(-) diff --git a/docs/proxy-auth.md b/docs/proxy-auth.md index c8e0a98e6a..926153f95f 100644 --- a/docs/proxy-auth.md +++ b/docs/proxy-auth.md @@ -26,6 +26,28 @@ Everything fails closed: audience or by another issuer, signed with an unknown key or an algorithm other than RS256 / ES256 gets a 401. So does every request without the token while no keys could be fetched. +- A request with a valid assertion that another site's page sent gets a 403 + (see below). + +### Requests from other sites + +The assertion rides on the proxy's session cookie, and the browser attaches +that cookie to requests other sites' pages make as well (Cloudflare sets its +application cookie `SameSite=None` unless you change it). Without a further +check, any page the signed-in user opens could use Codeg's WebSockets or upload +endpoints in their name. So a request admitted by an assertion must come from +Codeg's own page: the server refuses it when the browser marks it as sent by +another site (`Sec-Fetch-Site`), or, in a browser that does not send that +header, when its `Origin` names a host other than the one the request was sent +to (`Host`, or `X-Forwarded-Host` from a proxy that rewrites `Host`). The +token is not affected: another site's page cannot send it. + +Chrome sends no `Sec-Fetch-Site` on a WebSocket handshake, so for the event +socket the `Origin` always decides. A proxy between the browser and Codeg must +therefore pass on the browser's `Host`, or put it in `X-Forwarded-Host`. +Cloudflare Tunnel does: it keeps the `Host` unless `httpHostHeader` is set, and +then moves the original to `X-Forwarded-Host`. Otherwise the socket is refused +with a 403, and the server's warning names the `Origin` and the hosts it saw. ## Configuration @@ -56,7 +78,10 @@ CODEG_PROXY_AUTH_AUDIENCE= ``` Bind the server to loopback (`CODEG_HOST=127.0.0.1`) and reach it only through -the tunnel, so every browser request passes Access. +the tunnel, so every browser request passes Access. In the application's +cookie settings, setting **SameSite Attribute** to `Lax` stops the browser from +attaching the cookie to other sites' requests in the first place, apart from a +link the user follows into Codeg. ## In the browser diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs index d9d43d4e34..75479ac2f2 100644 --- a/src-tauri/src/web/auth.rs +++ b/src-tauri/src/web/auth.rs @@ -1,6 +1,6 @@ use axum::{ extract::Request, - http::{header, StatusCode}, + http::{header, HeaderMap, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; @@ -77,6 +77,59 @@ pub struct WebAuth { pub proxy: Option>, } +/// Why a request the proxy's assertion would admit was not sent by Codeg's own +/// page, or `None` when it was. +/// +/// The assertion rides on the proxy's session cookie, and the browser attaches +/// that cookie to requests other sites' pages make as well: Cloudflare sets its +/// application cookie `SameSite=None` unless the operator changes it. Such a +/// page could then open the event or browser-tunnel WebSocket, or post a +/// multipart upload (neither needs a CORS preflight), as the signed-in user. +/// The token cannot travel that way, so only the assertion needs this check. +/// +/// `Sec-Fetch-Site` is set by the browser and a page cannot change it: +/// `same-origin` is Codeg's own page and `none` an address the user typed; +/// `same-site` (a sibling subdomain) and `cross-site` are refused. A browser +/// that sends no Fetch Metadata still sends `Origin` on every WebSocket +/// handshake and every cross-origin POST, so when present it must name the host +/// the request was addressed to: `Host`, or a proxy's `X-Forwarded-Host` — a +/// page cannot set either on a request to another site. A request with neither +/// header is not another site's WebSocket or POST: it comes from a client that +/// is not a browser, or is a cross-site GET from an old browser, whose answer +/// that site cannot read (CORS) and which changes nothing here. +fn cross_origin_initiator(headers: &HeaderMap) -> Option { + if let Some(site) = headers.get("sec-fetch-site") { + let site = site.to_str().unwrap_or_default().trim(); + return (!matches!(site, "same-origin" | "none")) + .then(|| format!("Sec-Fetch-Site: {site}")); + } + let origin = headers.get(header::ORIGIN)?; + let origin = origin.to_str().unwrap_or_default().trim(); + let addressed = reqwest::Url::parse(origin) + .ok() + .filter(|url| matches!(url.scheme(), "http" | "https")) + .and_then(|url| { + let host = url.host_str()?.to_ascii_lowercase(); + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }) + }); + let hosts: Vec = headers + .get_all(header::HOST) + .iter() + .chain(headers.get_all("x-forwarded-host").iter()) + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(|host| host.trim().to_ascii_lowercase()) + .collect(); + if addressed.is_some_and(|addressed| hosts.contains(&addressed)) { + return None; + } + // Both, so a proxy that rewrites `Host` without `X-Forwarded-Host` shows. + Some(format!("Origin: {origin}; sent to: {}", hosts.join(", "))) +} + pub async fn require_auth(request: Request, next: Next, auth: WebAuth) -> Response { let Some(proxy) = auth.proxy.as_ref() else { return require_token(request, next, auth.token).await; @@ -85,7 +138,15 @@ pub async fn require_auth(request: Request, next: Next, auth: WebAuth) -> Respon return next.run(request).await; } match proxy.authenticate(request.headers()).await { - Ok(()) => next.run(request).await, + Ok(()) => match cross_origin_initiator(request.headers()) { + None => next.run(request).await, + Some(initiator) => { + tracing::warn!( + "[AUTH] proxy-jwt: refused a request sent by another site's page ({initiator})" + ); + (StatusCode::FORBIDDEN, "Cross-origin request refused").into_response() + } + }, Err(Rejection::Missing) => unauthorized("Invalid or missing token"), Err(reason) => { // The reason only: the assertion is a bearer credential. @@ -249,6 +310,156 @@ mod tests { .assert_status_unauthorized(); } + async fn proxy_server(issuer: &TestIssuer) -> axum_test::TestServer { + let auth = WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }; + let app = axum::Router::new() + .route("/api/health", axum::routing::post(|| async { "ok" })) + .route("/ws/events", axum::routing::get(|| async { "upgraded" })) + .layer(axum::middleware::from_fn(move |req, next| { + require_auth(req, next, auth.clone()) + })); + axum_test::TestServer::new(app).unwrap() + } + + // The assertion rides on the proxy's session cookie, which the browser + // also attaches to requests that other sites' pages make (Cloudflare's + // application cookie is SameSite=None unless the operator changes it). + #[tokio::test] + async fn proxy_mode_refuses_an_assertion_carried_by_another_sites_request() { + let issuer = TestIssuer::new(); + let server = proxy_server(&issuer).await; + + for site in ["cross-site", "same-site"] { + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-site", site) + .await + .assert_status(StatusCode::FORBIDDEN); + } + // Without Fetch Metadata the Origin decides; `null` is an opaque one. + for origin in ["https://evil.example.test", "null"] { + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", origin) + .await + .assert_status(StatusCode::FORBIDDEN); + } + } + + // A WebSocket handshake is a GET, and the one another site's page opens + // carries the proxy's cookie too. + #[tokio::test] + async fn proxy_mode_refuses_another_sites_websocket_handshake() { + let issuer = TestIssuer::new(); + let server = proxy_server(&issuer).await; + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-mode", "websocket") + .add_header("sec-fetch-site", "cross-site") + .add_header("origin", "https://evil.example.test") + .await + .assert_status(StatusCode::FORBIDDEN); + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", "https://evil.example.test") + .await + .assert_status(StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn proxy_mode_admits_an_assertion_sent_by_codegs_own_page() { + let issuer = TestIssuer::new(); + let server = proxy_server(&issuer).await; + + for site in ["same-origin", "none"] { + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-site", site) + .await + .assert_status_ok(); + } + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-mode", "websocket") + .add_header("sec-fetch-site", "same-origin") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + // Chrome sends no Fetch Metadata on a WebSocket handshake, so its Origin + // decides: against the browser's Host, or, where cloudflared rewrites + // Host (`httpHostHeader`), the X-Forwarded-Host it moves the original to. + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "localhost:3080") + .add_header("x-forwarded-host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + // A browser without Fetch Metadata: its Origin names the host it + // addressed, as `Host` or, behind a proxy that rewrites it, as + // `X-Forwarded-Host`. + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "127.0.0.1:3080") + .add_header("x-forwarded-host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + // Neither header: not a request a page made. + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .await + .assert_status_ok(); + } + + // Another site's page cannot send the token, so the token needs no proof + // of where the request came from, in either mode. + #[tokio::test] + async fn the_token_is_admitted_whoever_sent_the_request() { + let issuer = TestIssuer::new(); + proxy_server(&issuer) + .await + .post("/api/health") + .authorization_bearer("tok") + .add_header("sec-fetch-site", "cross-site") + .await + .assert_status_ok(); + guarded("tok") + .post("/api/health") + .authorization_bearer("tok") + .add_header("sec-fetch-site", "cross-site") + .await + .assert_status_ok(); + } + #[tokio::test] async fn an_accepted_request_carries_no_challenge() { let ok = guarded("tok") From 87a5505bfd6c46c48b64b43190a0306686fe2175 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:34:17 +0800 Subject: [PATCH 04/13] fix(server): answer with proxy keys past their age while they refresh refresh() held the rate-limit mutex across the JWKS fetch, whose timeout is 10 s. Once the keys were an hour old, every request waited behind each refresh attempt, and while the key endpoint could not be reached each attempt waited out the whole timeout. Keys that hold the assertion's key id now answer at once, and a single refresh runs beside the request. An unknown key id (a rotation) still waits for its fetch, as before. The max age becomes a field so tests can make the keys stale. --- docs/proxy-auth.md | 5 +- src-tauri/src/web/proxy_auth.rs | 143 +++++++++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/docs/proxy-auth.md b/docs/proxy-auth.md index 926153f95f..42c6d165df 100644 --- a/docs/proxy-auth.md +++ b/docs/proxy-auth.md @@ -61,8 +61,9 @@ with a 403, and the server's warning names the `Origin` and the hosts it saw. The keys are fetched at startup, kept for an hour, and fetched again early when an assertion names a key id the server has not seen (a key rotation) — at most -once every 30 seconds. If a fetch fails, the keys already held stay in use. -`exp` and `nbf` allow 60 seconds of clock skew. +once every 30 seconds. Past the hour, the keys held keep answering while a fresh +copy is fetched beside the request. If a fetch fails, the keys already held stay +in use. `exp` and `nbf` allow 60 seconds of clock skew. ### Cloudflare Access diff --git a/src-tauri/src/web/proxy_auth.rs b/src-tauri/src/web/proxy_auth.rs index b7079a94ee..1ee65476a3 100644 --- a/src-tauri/src/web/proxy_auth.rs +++ b/src-tauri/src/web/proxy_auth.rs @@ -17,6 +17,7 @@ //! rejects the request (the caller answers 401). use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -420,6 +421,10 @@ pub struct ProxyJwtAuth { /// for the rate limit. last_refresh: Mutex>, min_refresh_interval: Duration, + keys_max_age: Duration, + /// Set while a refresh started for keys past their age is out, so the + /// requests that find them stale meanwhile do not start one each. + refreshing_in_background: AtomicBool, } impl ProxyJwtAuth { @@ -435,6 +440,8 @@ impl ProxyJwtAuth { keys: RwLock::new(None), last_refresh: Mutex::new(None), min_refresh_interval: MIN_REFRESH_INTERVAL, + keys_max_age: KEYS_MAX_AGE, + refreshing_in_background: AtomicBool::new(false), }) } @@ -456,7 +463,7 @@ impl ProxyJwtAuth { } /// Checks the request's assertion header. - pub async fn authenticate(&self, headers: &HeaderMap) -> Result<(), Rejection> { + pub async fn authenticate(self: &Arc, headers: &HeaderMap) -> Result<(), Rejection> { let assertion = headers .get(&self.config.header) .ok_or(Rejection::Missing)? @@ -480,12 +487,21 @@ impl ProxyJwtAuth { self.keys.read().await.clone() } - async fn keys_for(&self, kid: &str) -> Result, Rejection> { + async fn keys_for(self: &Arc, kid: &str) -> Result, Rejection> { if let Some((keys, fetched)) = self.cached().await { - if keys.contains(kid) && fetched.elapsed() < KEYS_MAX_AGE { + if keys.contains(kid) { + // Past their age the keys still verify what they signed: answer + // with them and refresh beside the request, so a slow or + // unreachable key endpoint never holds requests up. A key the + // endpoint has withdrawn is refused once the refresh is in. + if fetched.elapsed() >= self.keys_max_age { + self.refresh_in_background(); + } return Ok(keys); } } + // An unknown key id (a rotation) or no keys yet: nothing cached can + // answer, so this request waits for the fetch. self.refresh().await; // A failed refresh keeps the previous keys: they still verify what // they signed, and `exp` bounds how long such an assertion lives. @@ -495,6 +511,18 @@ impl ProxyJwtAuth { .ok_or(Rejection::NoKeys) } + fn refresh_in_background(self: &Arc) { + if self.refreshing_in_background.swap(true, Ordering::AcqRel) { + return; + } + let this = Arc::clone(self); + tokio::spawn(async move { + this.refresh().await; + this.refreshing_in_background + .store(false, Ordering::Release); + }); + } + async fn refresh(&self) { let mut last = self.last_refresh.lock().await; if last.is_some_and(|at| at.elapsed() < self.min_refresh_interval) { @@ -1133,6 +1161,10 @@ mod tests { } async fn key_server(doc: Value) -> KeyServer { + key_server_answering_after(doc, Duration::ZERO).await + } + + async fn key_server_answering_after(doc: Value, delay: Duration) -> KeyServer { let served = Arc::new(std::sync::Mutex::new(doc)); let hits = Arc::new(AtomicUsize::new(0)); let (s, h) = (served.clone(), hits.clone()); @@ -1142,7 +1174,9 @@ mod tests { let (s, h) = (s.clone(), h.clone()); async move { h.fetch_add(1, Ordering::SeqCst); - axum::Json(s.lock().unwrap().clone()) + tokio::time::sleep(delay).await; + let doc = s.lock().unwrap().clone(); + axum::Json(doc) } }), ); @@ -1186,7 +1220,7 @@ mod tests { async fn fetches_keys_once_and_serves_from_the_cache() { let rsa = rsa_signer("r1"); let server = key_server(json!({"keys": [rsa.jwk()]})).await; - let auth = auth_for(&server.url, Duration::ZERO); + let auth = Arc::new(auth_for(&server.url, Duration::ZERO)); let token = rsa.sign(&live_claims()); assert_eq!(auth.authenticate(&with_assertion(&token)).await, Ok(())); @@ -1198,7 +1232,7 @@ mod tests { async fn an_unknown_key_id_refreshes_the_keys_after_a_rotation() { let (old, new) = (ec_signer("old"), ec_signer("new")); let server = key_server(json!({"keys": [old.jwk()]})).await; - let auth = auth_for(&server.url, Duration::ZERO); + let auth = Arc::new(auth_for(&server.url, Duration::ZERO)); auth.prime().await; *server.served.lock().unwrap() = json!({"keys": [old.jwk(), new.jwk()]}); @@ -1211,7 +1245,7 @@ mod tests { async fn unknown_key_ids_cannot_drive_a_fetch_per_request() { let (known, stranger) = (ec_signer("known"), ec_signer("stranger")); let server = key_server(json!({"keys": [known.jwk()]})).await; - let auth = auth_for(&server.url, Duration::from_secs(3600)); + let auth = Arc::new(auth_for(&server.url, Duration::from_secs(3600))); auth.prime().await; let token = stranger.sign(&live_claims()); @@ -1230,7 +1264,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}/certs", listener.local_addr().unwrap()); drop(listener); - let auth = auth_for(&url, Duration::ZERO); + let auth = Arc::new(auth_for(&url, Duration::ZERO)); let token = rsa_signer("r1").sign(&live_claims()); assert_eq!( auth.authenticate(&with_assertion(&token)).await, @@ -1242,7 +1276,7 @@ mod tests { async fn keeps_the_last_keys_when_a_refresh_fails() { let (rsa, stranger) = (rsa_signer("r1"), ec_signer("x")); let server = key_server(json!({"keys": [rsa.jwk()]})).await; - let auth = auth_for(&server.url, Duration::ZERO); + let auth = Arc::new(auth_for(&server.url, Duration::ZERO)); auth.prime().await; // The endpoint now serves garbage; an unknown kid forces a refresh. @@ -1259,9 +1293,98 @@ mod tests { ); } + /// `auth_for` already holding `signers`' keys, which are past their age. + async fn with_stale_keys(url: &str, signers: &[&Signer]) -> Arc { + let mut auth = auth_for(url, Duration::ZERO); + auth.keys_max_age = Duration::ZERO; + *auth.keys.write().await = Some((Arc::new(keyset(signers)), Instant::now())); + Arc::new(auth) + } + + // Keys past their age still verify what they signed, so the refresh they + // call for must not hold the request up, however slow the key endpoint. + #[tokio::test] + async fn a_stale_key_set_answers_without_waiting_for_its_refresh() { + // Takes connections and never answers: a fetch waits out its timeout. + let blackhole = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", blackhole.local_addr().unwrap()); + let rsa = rsa_signer("r1"); + let auth = with_stale_keys(&url, &[&rsa]).await; + + let answer = tokio::time::timeout( + Duration::from_secs(3), + auth.authenticate(&with_assertion(&rsa.sign(&live_claims()))), + ) + .await; + assert_eq!(answer, Ok(Ok(()))); + } + + // The refresh runs beside the request; once it is in, a key the endpoint + // no longer publishes is refused. + #[tokio::test] + async fn a_stale_key_set_is_refreshed_beside_the_request() { + let (old, new) = (ec_signer("old"), ec_signer("new")); + let server = key_server(json!({"keys": [new.jwk()]})).await; + let auth = with_stale_keys(&server.url, &[&old]).await; + + let signed_by_old = with_assertion(&old.sign(&live_claims())); + assert_eq!(auth.authenticate(&signed_by_old).await, Ok(())); + let deadline = Instant::now() + Duration::from_secs(5); + while !auth + .cached() + .await + .is_some_and(|(keys, _)| keys.contains("new")) + { + assert!(Instant::now() < deadline, "the refresh never landed"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + auth.authenticate(&signed_by_old).await, + Err(Rejection::UnknownKey) + ); + assert_eq!( + auth.authenticate(&with_assertion(&new.sign(&live_claims()))) + .await, + Ok(()) + ); + } + + // Requests that find the keys stale while a refresh is out do not start + // one each. + #[tokio::test] + async fn stale_keys_start_one_refresh_at_a_time() { + let rsa = rsa_signer("r1"); + let server = + key_server_answering_after(json!({"keys": [rsa.jwk()]}), Duration::from_millis(300)) + .await; + let auth = with_stale_keys(&server.url, &[&rsa]).await; + let headers = with_assertion(&rsa.sign(&live_claims())); + + let answers = + futures_util::future::join_all((0..8).map(|_| auth.authenticate(&headers))).await; + assert!( + answers.iter().all(|answer| *answer == Ok(())), + "{answers:?}" + ); + let deadline = Instant::now() + Duration::from_secs(5); + while server.hits.load(Ordering::SeqCst) == 0 { + assert!(Instant::now() < deadline, "no refresh started"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + tokio::time::sleep(Duration::from_millis(600)).await; + assert_eq!(server.hits.load(Ordering::SeqCst), 1); + + // Once that refresh is over, keys found stale again start the next one. + assert_eq!(auth.authenticate(&headers).await, Ok(())); + while server.hits.load(Ordering::SeqCst) < 2 { + assert!(Instant::now() < deadline, "no second refresh started"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + #[tokio::test] async fn refuses_a_request_without_the_header() { - let auth = auth_for("http://127.0.0.1:9/certs", Duration::ZERO); + let auth = Arc::new(auth_for("http://127.0.0.1:9/certs", Duration::ZERO)); assert_eq!( auth.authenticate(&HeaderMap::new()).await, Err(Rejection::Missing) From 7e3d446c64ce3f98f68e5436a02ceee8ac81cb71 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:34:17 +0800 Subject: [PATCH 05/13] fix(server): stop reading a JWKS sent without a length at the size cap The 256 KB cap was checked against Content-Length, and otherwise only after .bytes() had read the whole body, so a response without a length was buffered in full until it ended or the fetch timed out. The body is now read by chunk and the fetch fails as soon as it passes the cap. --- src-tauri/src/web/proxy_auth.rs | 47 ++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/web/proxy_auth.rs b/src-tauri/src/web/proxy_auth.rs index 1ee65476a3..7143e73ae3 100644 --- a/src-tauri/src/web/proxy_auth.rs +++ b/src-tauri/src/web/proxy_auth.rs @@ -547,7 +547,7 @@ impl ProxyJwtAuth { } async fn fetch(&self) -> Result { - let response = self + let mut response = self .client .get(self.config.jwks_url.clone()) .send() @@ -562,12 +562,17 @@ impl ProxyJwtAuth { { return Err("document too large".into()); } - let body = response - .bytes() + // Read by chunk so a body sent without a length stops at the cap too. + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() .await - .map_err(|e| e.without_url().to_string())?; - if body.len() > MAX_JWKS_BYTES { - return Err("document too large".into()); + .map_err(|e| e.without_url().to_string())? + { + if body.len() + chunk.len() > MAX_JWKS_BYTES { + return Err("document too large".into()); + } + body.extend_from_slice(&chunk); } parse_jwks(&body).map_err(|e| e.to_string()) } @@ -1293,6 +1298,36 @@ mod tests { ); } + // The size cap holds for a body sent without a length too: the fetch stops + // reading once the document is past it, instead of keeping all of it until + // the stream ends or the timeout. + #[tokio::test] + async fn an_endless_key_document_is_cut_off_at_the_size_cap() { + let app = axum::Router::new().route( + "/certs", + axum::routing::get(|| async { + let chunks = futures_util::stream::unfold((), |()| async { + tokio::time::sleep(Duration::from_millis(20)).await; + Some(( + Ok::<_, std::io::Error>(axum::body::Bytes::from(vec![b' '; 64 * 1024])), + (), + )) + }); + axum::body::Body::from_stream(chunks) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let auth = Arc::new(auth_for(&url, Duration::ZERO)); + + let fetched = tokio::time::timeout(Duration::from_secs(3), auth.fetch()).await; + assert_eq!( + fetched.map(|r| r.err()), + Ok(Some("document too large".into())) + ); + } + /// `auth_for` already holding `signers`' keys, which are past their age. async fn with_stale_keys(url: &str, signers: &[&Signer]) -> Arc { let mut auth = auth_for(url, Duration::ZERO); From 45c7b683a9232c8190c03d17983e8ea97206178f Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:34:17 +0800 Subject: [PATCH 06/13] fix(server): fetch a loopback JWKS without the HTTP proxy Plain http is accepted only for a key server on this machine, but the JWKS client still took HTTP(S)_PROXY from the environment, so with a proxy set and no NO_PROXY entry the request went to a proxy that would dial its own loopback. --- src-tauri/src/web/proxy_auth.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/web/proxy_auth.rs b/src-tauri/src/web/proxy_auth.rs index 7143e73ae3..a2523d209c 100644 --- a/src-tauri/src/web/proxy_auth.rs +++ b/src-tauri/src/web/proxy_auth.rs @@ -124,16 +124,20 @@ impl ProxyAuthConfig { fn jwks_url_allowed(url: &reqwest::Url) -> bool { match url.scheme() { "https" => url.host().is_some(), - "http" => match url.host() { - Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), - Some(url::Host::Ipv4(ip)) => ip.is_loopback(), - Some(url::Host::Ipv6(ip)) => ip.is_loopback(), - None => false, - }, + "http" => is_loopback(url), _ => false, } } +fn is_loopback(url: &reqwest::Url) -> bool { + match url.host() { + Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Alg { Rs256, @@ -429,11 +433,16 @@ pub struct ProxyJwtAuth { impl ProxyJwtAuth { pub fn new(config: ProxyAuthConfig) -> Result { - let client = reqwest::Client::builder() + let mut client = reqwest::Client::builder() .timeout(JWKS_FETCH_TIMEOUT) // A redirect could lead off the configured (https) origin. - .redirect(reqwest::redirect::Policy::none()) - .build()?; + .redirect(reqwest::redirect::Policy::none()); + // A key server on this machine is not reached through HTTP(S)_PROXY: + // a proxy on another host would dial its own loopback. + if is_loopback(&config.jwks_url) { + client = client.no_proxy(); + } + let client = client.build()?; Ok(Self { config, client, From 96daf78735235309f408a99450dd54cc4058cede Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:34:17 +0800 Subject: [PATCH 07/13] fix(web): open the event socket when a proxy admits a browser that came straight to the workspace Proxy sign-in was detected only on / and /login. A browser that opened /workspace directly (a bookmark, a restored tab, or after the root page could not reach the server) held neither the token nor the proxy mark: its HTTP calls went through on the proxy's assertion, but the event socket was never opened and nothing reported it, so the workspace stayed "connected" without live updates. When a consumer needs the socket and no credential is held, the transport now asks the server. Admitted: remember the mark and connect, and subscribe() still resolves only after __ready__. Refused: nothing changes, the token flow keeps surfacing its own 401s, and the login page (which subscribes too) has nothing to reconnect. Unreachable: asked again on the reconnect backoff, quietly for the same reason. --- src/lib/transport/web-transport.test.ts | 75 +++++++++++++++++++++++++ src/lib/transport/web-transport.ts | 71 +++++++++++++++++++++-- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/lib/transport/web-transport.test.ts b/src/lib/transport/web-transport.test.ts index ffa69a9c34..32acba1af1 100644 --- a/src/lib/transport/web-transport.test.ts +++ b/src/lib/transport/web-transport.test.ts @@ -424,3 +424,78 @@ describe("WebTransport signed in through a proxy (no token)", () => { expect(t.getConnectionSnapshot()).toBe("proxyUnauthorized") }) }) + +// A browser that opens the workspace directly (a bookmark, a restored tab) +// holds neither a token nor the proxy mark, though a proxy in front of the +// server may already vouch for it: HTTP calls then succeed without either, +// so only asking the server can tell whether to open the event socket. +describe("WebTransport opened without any credential", () => { + beforeEach(() => { + localStorage.removeItem("codeg_token") + localStorage.removeItem("codeg_proxy_auth") + }) + + it("asks the server, and opens the socket once a proxy admits the browser", async () => { + fetchMock.mockResolvedValue(ok200()) + const t = new WebTransport("http://localhost") + const handler = vi.fn() + const subscribed = t.subscribe("folders", handler) + await vi.advanceTimersByTimeAsync(0) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe("/api/health") + expect(init.headers).not.toHaveProperty("Authorization") + expect(localStorage.getItem("codeg_proxy_auth")).toBe("1") + expect(MockWebSocket.instances).toHaveLength(1) + expect(lastWs().protocols).toEqual(["codeg-events"]) + + // subscribe() still resolves only once the server-side receiver is in. + lastWs().open() + lastWs().ready() + await subscribed + lastWs().onmessage?.({ + data: JSON.stringify({ channel: "folders", payload: 1 }), + }) + expect(handler).toHaveBeenCalledWith(1) + }) + + it("changes nothing when the server wants a token, and asks only once", async () => { + fetchMock.mockResolvedValue(resp401()) + const t = new WebTransport("http://localhost") + t.eventStream() + await vi.advanceTimersByTimeAsync(0) + await t.subscribe("folders", () => {}) + await vi.advanceTimersByTimeAsync(60_000) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(MockWebSocket.instances).toHaveLength(0) + // No dialog: on the login page there is nothing to sign back in to. + expect(t.getConnectionSnapshot()).toBe("connected") + expect(localStorage.getItem("codeg_proxy_auth")).toBeNull() + }) + + it("asks again, quietly, while the server cannot be reached", async () => { + fetchMock + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValue(ok200()) + const t = new WebTransport("http://localhost") + t.eventStream() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(0) + expect(t.getConnectionSnapshot()).toBe("connected") + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it("stops asking once destroyed", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")) + const t = new WebTransport("http://localhost") + t.eventStream() + await vi.advanceTimersByTimeAsync(0) + t.destroy() + await vi.advanceTimersByTimeAsync(60_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/transport/web-transport.ts b/src/lib/transport/web-transport.ts index fde4044707..9468c21e1d 100644 --- a/src/lib/transport/web-transport.ts +++ b/src/lib/transport/web-transport.ts @@ -13,6 +13,7 @@ import { codegAuthHeaders, getCodegToken, hasCodegCredentials, + probeProxyAuthentication, unauthorizedSource, type UnauthorizedSource, } from "./web-auth" @@ -111,6 +112,14 @@ export class WebTransport implements Transport { // a late 200 can't reopen the socket behind the session-expired dialog. private probeEpoch = 0 private probeController: AbortController | null = null + // Asking the server whether a proxy in front of it admits this browser + // without a token (see `discoverProxyAuth`): the request in flight, the retry + // while the server cannot be reached, how many tries that took, and whether + // the server already said no. + private proxyDiscovery: Promise | null = null + private proxyDiscoveryTimer: ReturnType | null = null + private proxyDiscoveryAttempts = 0 + private proxyDiscoveryRefused = false constructor(baseUrl: string) { this.baseUrl = baseUrl @@ -225,9 +234,11 @@ export class WebTransport implements Transport { const wrappedHandler = handler as (payload: unknown) => void this.handlers.get(event)!.add(wrappedHandler) - // If WS is not connected but we now have credentials, connect - if (!this.ws && hasCodegCredentials()) { - this.connectWs() + // If WS is not connected but we now have credentials, connect. With none, + // ask the server whether a proxy in front of it admits this browser. + if (!this.ws) { + if (hasCodegCredentials()) this.connectWs() + else await this.discoverProxyAuth() } // Gate on the server-side broadcaster receiver actually being subscribed. @@ -351,14 +362,58 @@ export class WebTransport implements Transport { this.eventStreamInstance = new WebEventStream(host) // If credentials are present but the WS hasn't been opened yet (no legacy // subscribe has run), trigger the connect now so attach frames sent - // by the consumer have a place to land. - if (!this.ws && hasCodegCredentials()) { - this.connectWs() + // by the consumer have a place to land. With none, ask the server + // whether a proxy in front of it admits this browser. + if (!this.ws) { + if (hasCodegCredentials()) this.connectWs() + else void this.discoverProxyAuth() } } return this.eventStreamInstance } + // Signed in through a proxy, a browser that opens the workspace directly (a + // bookmark, a restored tab, or the root page could not reach the server) + // holds neither a token nor the proxy mark, and its HTTP calls succeed + // without either, so nothing else would ever open the event socket. Only the + // server can tell, so it is asked when a consumer needs the socket. A refusal + // ends the matter without touching the connection state: the token flow + // surfaces its own 401s, and on the login page there is nothing to reconnect. + // An unreachable server is asked again on the reconnect backoff, quietly for + // the same reason. + private discoverProxyAuth(): Promise { + if ( + this.destroyed || + this.proxyDiscoveryRefused || + this.proxyDiscoveryTimer + ) { + return this.proxyDiscovery ?? Promise.resolve() + } + this.proxyDiscovery ??= probeProxyAuthentication().then((result) => { + this.proxyDiscovery = null + if (this.destroyed) return + if (result === "proxy") { + if (!this.ws) this.connectWs() + return + } + if (result === "unauthorized") { + this.proxyDiscoveryRefused = true + return + } + const shift = Math.min(this.proxyDiscoveryAttempts++, 8) + this.proxyDiscoveryTimer = setTimeout( + () => { + this.proxyDiscoveryTimer = null + if (this.ws) return + if (hasCodegCredentials()) this.connectWs() + else void this.discoverProxyAuth() + }, + Math.min(WS_BACKOFF_INITIAL_MS << shift, WS_BACKOFF_MAX_MS) + ) + }) + return this.proxyDiscovery + } + private sendWsFrame(frame: object): boolean { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false try { @@ -599,6 +654,10 @@ export class WebTransport implements Transport { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } + if (this.proxyDiscoveryTimer) { + clearTimeout(this.proxyDiscoveryTimer) + this.proxyDiscoveryTimer = null + } // Abort any in-flight health probe; the `destroyed` guard already blocks // its post-await branches, but this frees the request promptly. this.invalidateProbe() From 1298d670238f38c3fe00d98278f5f71a00add3d1 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:34:17 +0800 Subject: [PATCH 08/13] fix(web): drop "your access token is kept" from the proxy sign-in message Signed in through the proxy there is no token, and on the login page the token being typed is not stored yet. Without that clause the message is true wherever it shows. --- 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 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 8e1f30cc72..0939b88c37 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5343,7 +5343,7 @@ "sessionExpiredTitle": "انتهت الجلسة", "sessionExpiredDescription": "لم تعد جلستك صالحة. يُرجى تسجيل الدخول مرة أخرى للمتابعة.", "proxySignInTitle": "انتهت صلاحية تسجيل الدخول", - "proxySignInDescription": "انتهت صلاحية تسجيل دخولك إلى الوكيل الموجود أمام هذا الخادم. أعد تحميل الصفحة لتسجيل الدخول مجددًا؛ سيتم الاحتفاظ برمز الوصول الخاص بك.", + "proxySignInDescription": "انتهت صلاحية تسجيل دخولك إلى الوكيل الموجود أمام هذا الخادم. أعد تحميل الصفحة لتسجيل الدخول مجددًا.", "goToLogin": "الانتقال إلى تسجيل الدخول", "versionChangedTitle": "يعمل الخادم الآن بإصدار Codeg {version}", "versionChangedDescription": "لا تزال هذه الصفحة على الإصدار {version}. أعد تحميلها لاستخدام إصدار الخادم.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4c2ba2cc10..2bf3ffe252 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5343,7 +5343,7 @@ "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.", + "proxySignInDescription": "Deine Anmeldung beim Proxy vor diesem Server ist abgelaufen. Lade die Seite neu, um dich erneut anzumelden.", "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 bf07f87f9f..1f210f604e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5343,7 +5343,7 @@ "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.", + "proxySignInDescription": "Your sign-in to the proxy in front of this server has expired. Reload the page to sign in again.", "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 ac663d1655..da9ada5735 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5343,7 +5343,7 @@ "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.", + "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.", "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 1d68402c47..29ec494abb 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5343,7 +5343,7 @@ "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é.", + "proxySignInDescription": "Votre connexion au proxy placé devant ce serveur a expiré. Rechargez la page pour vous reconnecter.", "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 2cbe973ebf..d0d1874836 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5343,7 +5343,7 @@ "sessionExpiredTitle": "セッションの有効期限が切れました", "sessionExpiredDescription": "セッションが無効になりました。続行するには再度サインインしてください。", "proxySignInTitle": "サインインの有効期限切れ", - "proxySignInDescription": "このサーバーの前段にあるプロキシのサインインが期限切れになりました。ページを再読み込みして再度サインインしてください。アクセストークンは保持されます。", + "proxySignInDescription": "このサーバーの前段にあるプロキシのサインインが期限切れになりました。ページを再読み込みして再度サインインしてください。", "goToLogin": "ログインへ移動", "versionChangedTitle": "サーバーは Codeg {version} で動作しています", "versionChangedDescription": "このページはまだ {version} です。再読み込みするとサーバーのバージョンを使用します。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index eecdb6434f..b9b506ab63 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5343,7 +5343,7 @@ "sessionExpiredTitle": "세션이 만료되었습니다", "sessionExpiredDescription": "세션이 더 이상 유효하지 않습니다. 계속하려면 다시 로그인하세요.", "proxySignInTitle": "로그인 만료", - "proxySignInDescription": "이 서버 앞단 프록시의 로그인이 만료되었습니다. 페이지를 새로고침하여 다시 로그인하세요. 액세스 토큰은 유지됩니다.", + "proxySignInDescription": "이 서버 앞단 프록시의 로그인이 만료되었습니다. 페이지를 새로고침하여 다시 로그인하세요.", "goToLogin": "로그인으로 이동", "versionChangedTitle": "서버가 Codeg {version}(으)로 실행 중입니다", "versionChangedDescription": "이 페이지는 아직 {version}입니다. 새로고침하면 서버의 버전을 사용합니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 3e668cb55c..d2047ed7da 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5343,7 +5343,7 @@ "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.", + "proxySignInDescription": "Sua sessão no proxy à frente deste servidor expirou. Recarregue a página para entrar novamente.", "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 0f6938ed4b..f4ad970db3 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5343,7 +5343,7 @@ "sessionExpiredTitle": "会话已过期", "sessionExpiredDescription": "登录状态已失效,请重新登录以继续。", "proxySignInTitle": "登录已过期", - "proxySignInDescription": "此服务器前置代理的登录已过期。请重新加载页面以再次登录;你的访问令牌会被保留。", + "proxySignInDescription": "此服务器前置代理的登录已过期。请重新加载页面以再次登录。", "goToLogin": "前往登录", "versionChangedTitle": "服务器已运行 Codeg {version}", "versionChangedDescription": "此页面仍是 {version},刷新后即可使用服务器上的版本。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 1866b9dadb..e52d82488a 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5343,7 +5343,7 @@ "sessionExpiredTitle": "工作階段已過期", "sessionExpiredDescription": "登入狀態已失效,請重新登入以繼續。", "proxySignInTitle": "登入已過期", - "proxySignInDescription": "此伺服器前置代理的登入已過期。請重新載入頁面以再次登入;你的存取權杖會被保留。", + "proxySignInDescription": "此伺服器前置代理的登入已過期。請重新載入頁面以再次登入。", "goToLogin": "前往登入", "versionChangedTitle": "伺服器已執行 Codeg {version}", "versionChangedDescription": "此頁面仍是 {version},重新整理後即可使用伺服器上的版本。", From d847fc95626127269ded35a7095b9c549d4f6dc0 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:34:17 +0800 Subject: [PATCH 09/13] docs(server): say that the proxy's policy decides who gets in Codeg admits every request the proxy vouches for with a valid assertion for this application, service tokens the policy allows included, so the policy is the user list. --- docs/proxy-auth.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/proxy-auth.md b/docs/proxy-auth.md index 42c6d165df..e3ef864fd6 100644 --- a/docs/proxy-auth.md +++ b/docs/proxy-auth.md @@ -29,6 +29,16 @@ Everything fails closed: - A request with a valid assertion that another site's page sent gets a 403 (see below). +### Who gets in + +Codeg does not choose users in this mode. It admits every request the proxy +vouches for with a valid assertion for this application, including requests +made with a service token the application's policy allows. The proxy's policy +is the user list: keep it to the people who should use this server, such as +your own address or one group, and never `Everyone`. The audience check ties an +assertion to this application, so a sign-in to another application of the same +proxy does not get in. + ### Requests from other sites The assertion rides on the proxy's session cookie, and the browser attaches From 96b81e41ebf7aa8b66a1b08d43981e456de2611d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 19 Oct 2026 23:47:23 +0800 Subject: [PATCH 10/13] fix(web): give up on a proxy sign-in probe that never answers probeProxyAuthentication() had no timeout. The root and login pages wait on it, and subscribe() now does too when no credential is held, so a server that took the request and never answered held them forever. It now counts as unreachable after 8 s, the transport's health probe timeout. The admission test also checks that subscribe() waits for the discovered socket's __ready__. --- src/lib/transport/web-auth.test.ts | 22 ++++++++++++++++++++++ src/lib/transport/web-auth.ts | 9 +++++++++ src/lib/transport/web-transport.test.ts | 8 +++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/lib/transport/web-auth.test.ts b/src/lib/transport/web-auth.test.ts index ab2d2012f6..ce51c0a8b9 100644 --- a/src/lib/transport/web-auth.test.ts +++ b/src/lib/transport/web-auth.test.ts @@ -101,6 +101,28 @@ describe("proxy authentication (CODEG_AUTH=proxy-jwt)", () => { expect(isProxyAuthenticated()).toBe(false) }) + it("gives up on a server that takes the request and never answers", async () => { + vi.useFakeTimers() + try { + vi.stubGlobal( + "fetch", + vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")) + ) + }) + ) + ) + const probe = probeProxyAuthentication() + await vi.advanceTimersByTimeAsync(8_000) + await expect(probe).resolves.toBe("unreachable") + } finally { + vi.useRealTimers() + } + }) + it("reports an unreachable server without touching the mark", async () => { localStorage.setItem("codeg_proxy_auth", "1") vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed"))) diff --git a/src/lib/transport/web-auth.ts b/src/lib/transport/web-auth.ts index 4955c1b350..a4bca51964 100644 --- a/src/lib/transport/web-auth.ts +++ b/src/lib/transport/web-auth.ts @@ -9,6 +9,8 @@ const TOKEN_KEY = "codeg_token" // Set when the server admitted this browser without a token: a proxy in front // of it vouched for the user (`CODEG_AUTH=proxy-jwt` on codeg-server). const PROXY_AUTH_KEY = "codeg_proxy_auth" +// As long as the transport's own health probe waits (`web-transport.ts`). +const PROBE_TIMEOUT_MS = 8_000 // 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 @@ -60,15 +62,22 @@ export function hasCodegCredentials(): boolean { export async function probeProxyAuthentication(): Promise< "proxy" | "unauthorized" | "unreachable" > { + // A server that takes the request and never answers counts as unreachable, + // rather than holding the page, or a subscription waiting on this, forever. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) let res: Response try { res = await fetch("/api/health", { method: "POST", headers: { "Content-Type": "application/json", ...codegAuthHeaders("") }, body: "{}", + signal: controller.signal, }) } catch { return "unreachable" + } finally { + clearTimeout(timeout) } if (res.ok) { localStorage.setItem(PROXY_AUTH_KEY, "1") diff --git a/src/lib/transport/web-transport.test.ts b/src/lib/transport/web-transport.test.ts index 32acba1af1..47e25e47a8 100644 --- a/src/lib/transport/web-transport.test.ts +++ b/src/lib/transport/web-transport.test.ts @@ -439,7 +439,10 @@ describe("WebTransport opened without any credential", () => { fetchMock.mockResolvedValue(ok200()) const t = new WebTransport("http://localhost") const handler = vi.fn() - const subscribed = t.subscribe("folders", handler) + let resolved = false + const subscribed = t.subscribe("folders", handler).then(() => { + resolved = true + }) await vi.advanceTimersByTimeAsync(0) const [url, init] = fetchMock.mock.calls[0] @@ -451,8 +454,11 @@ describe("WebTransport opened without any credential", () => { // subscribe() still resolves only once the server-side receiver is in. lastWs().open() + await vi.advanceTimersByTimeAsync(0) + expect(resolved).toBe(false) lastWs().ready() await subscribed + expect(resolved).toBe(true) lastWs().onmessage?.({ data: JSON.stringify({ channel: "folders", payload: 1 }), }) From 4876460bc5636faa111828c5d1125e384c6ca200 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 20 Oct 2026 00:53:02 +0800 Subject: [PATCH 11/13] test(server): keep the key-refresh tests steady on a slow machine The waits for a background refresh to start or land only have to catch a hang, so they get 30 s instead of 5 s. And the single-flight test no longer assumes the first refresh is over 600 ms after it started: it waits for the refresh flag to clear before the request that should start the next one. A slow machine can then only hide an extra fetch, never fail a correct run. --- src-tauri/src/web/proxy_auth.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/web/proxy_auth.rs b/src-tauri/src/web/proxy_auth.rs index a2523d209c..ace4ac1d49 100644 --- a/src-tauri/src/web/proxy_auth.rs +++ b/src-tauri/src/web/proxy_auth.rs @@ -1373,7 +1373,8 @@ mod tests { let signed_by_old = with_assertion(&old.sign(&live_claims())); assert_eq!(auth.authenticate(&signed_by_old).await, Ok(())); - let deadline = Instant::now() + Duration::from_secs(5); + // Generous: it only has to catch a refresh that never lands. + let deadline = Instant::now() + Duration::from_secs(30); while !auth .cached() .await @@ -1410,15 +1411,22 @@ mod tests { answers.iter().all(|answer| *answer == Ok(())), "{answers:?}" ); - let deadline = Instant::now() + Duration::from_secs(5); + // Generous: it only has to catch a refresh that never starts or ends. + let deadline = Instant::now() + Duration::from_secs(30); while server.hits.load(Ordering::SeqCst) == 0 { assert!(Instant::now() < deadline, "no refresh started"); tokio::time::sleep(Duration::from_millis(10)).await; } + // A second fetch, had one started, would show within this time; a slow + // machine can only hide one, never invent it. tokio::time::sleep(Duration::from_millis(600)).await; assert_eq!(server.hits.load(Ordering::SeqCst), 1); // Once that refresh is over, keys found stale again start the next one. + while auth.refreshing_in_background.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the first refresh never ended"); + tokio::time::sleep(Duration::from_millis(10)).await; + } assert_eq!(auth.authenticate(&headers).await, Ok(())); while server.hits.load(Ordering::SeqCst) < 2 { assert!(Instant::now() < deadline, "no second refresh started"); From 3a937e29c99949ca97bfc4376062a57e1ee43054 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 20 Oct 2026 07:39:41 +0800 Subject: [PATCH 12/13] docs(server): say that SameSite does not keep out a sibling subdomain Browsers apply SameSite by site, the scheme and registrable domain, not by origin, so a page on another subdomain of the same domain sends the proxy's cookie even when it is Lax or Strict. The cross-origin check is what refuses that page, and the docs no longer read as if Lax made it optional. The section also says "another origin" where it meant that, since a same-site sibling is refused too. --- docs/proxy-auth.md | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/docs/proxy-auth.md b/docs/proxy-auth.md index e3ef864fd6..fdd915c698 100644 --- a/docs/proxy-auth.md +++ b/docs/proxy-auth.md @@ -26,8 +26,8 @@ Everything fails closed: audience or by another issuer, signed with an unknown key or an algorithm other than RS256 / ES256 gets a 401. So does every request without the token while no keys could be fetched. -- A request with a valid assertion that another site's page sent gets a 403 - (see below). +- A request with a valid assertion that a page on another origin sent gets a + 403 (see below). ### Who gets in @@ -39,18 +39,27 @@ your own address or one group, and never `Everyone`. The audience check ties an assertion to this application, so a sign-in to another application of the same proxy does not get in. -### Requests from other sites +### Requests from other origins The assertion rides on the proxy's session cookie, and the browser attaches -that cookie to requests other sites' pages make as well (Cloudflare sets its -application cookie `SameSite=None` unless you change it). Without a further -check, any page the signed-in user opens could use Codeg's WebSockets or upload -endpoints in their name. So a request admitted by an assertion must come from -Codeg's own page: the server refuses it when the browser marks it as sent by -another site (`Sec-Fetch-Site`), or, in a browser that does not send that -header, when its `Origin` names a host other than the one the request was sent -to (`Host`, or `X-Forwarded-Host` from a proxy that rewrites `Host`). The -token is not affected: another site's page cannot send it. +that cookie to requests that pages on other origins make as well (Cloudflare +sets its application cookie `SameSite=None` unless you change it). Without a +further check, any page the signed-in user opens could use Codeg's WebSockets +or upload endpoints in their name. So a request admitted by an assertion must +come from Codeg's own page: the server refuses it when the browser marks it as +sent from another origin (`Sec-Fetch-Site`), or, in a browser that does not +send that header, when its `Origin` names a host other than the one the +request was sent to (`Host`, or `X-Forwarded-Host` from a proxy that rewrites +`Host`). The token is not affected: a page on another origin cannot send it. + +`SameSite` does not make this check optional. Browsers apply it by site, which +means the scheme and the registrable domain (`https://example.com`), not by +origin. A page on `wiki.example.com` is same-site with Codeg on +`codeg.example.com`, so its requests carry the cookie even when the cookie is +`Lax` or `Strict`. +Setting `Lax` still keeps the cookie off requests from unrelated sites, but +when anything else is served on a subdomain of the same domain, this check is +what turns its pages away. Chrome sends no `Sec-Fetch-Site` on a WebSocket handshake, so for the event socket the `Origin` always decides. A proxy between the browser and Codeg must @@ -90,9 +99,11 @@ CODEG_PROXY_AUTH_AUDIENCE= Bind the server to loopback (`CODEG_HOST=127.0.0.1`) and reach it only through the tunnel, so every browser request passes Access. In the application's -cookie settings, setting **SameSite Attribute** to `Lax` stops the browser from -attaching the cookie to other sites' requests in the first place, apart from a -link the user follows into Codeg. +cookie settings, setting **SameSite Attribute** to `Lax` keeps the cookie off +requests from unrelated sites, apart from top-level `GET` navigations such as a +link the user follows into Codeg. It does not hold back pages on other +subdomains of the same domain; see +[Requests from other origins](#requests-from-other-origins). ## In the browser From 3c67ba1c17efb07357250e4259c610306d37c077 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 20 Oct 2026 07:39:41 +0800 Subject: [PATCH 13/13] fix(server): name another origin, not another site, in the cross-origin refusal A page on a sibling subdomain is same-site, and the check refuses it as well, so the warning now reads "refused a request sent by a page on another origin (...)". The doc comment and the two test names that said "another site" follow. What is admitted does not change. --- src-tauri/src/web/auth.rs | 40 +++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs index 75479ac2f2..c81cfc517c 100644 --- a/src-tauri/src/web/auth.rs +++ b/src-tauri/src/web/auth.rs @@ -81,11 +81,13 @@ pub struct WebAuth { /// page, or `None` when it was. /// /// The assertion rides on the proxy's session cookie, and the browser attaches -/// that cookie to requests other sites' pages make as well: Cloudflare sets its -/// application cookie `SameSite=None` unless the operator changes it. Such a -/// page could then open the event or browser-tunnel WebSocket, or post a -/// multipart upload (neither needs a CORS preflight), as the signed-in user. -/// The token cannot travel that way, so only the assertion needs this check. +/// that cookie to requests that pages on other origins make as well: +/// Cloudflare sets its application cookie `SameSite=None` unless the operator +/// changes it, and even `Lax` or `Strict` lets a sibling subdomain's page send +/// it, because `SameSite` compares sites, not origins. Such a page could then +/// open the event or browser-tunnel WebSocket, or post a multipart upload +/// (neither needs a CORS preflight), as the signed-in user. The token cannot +/// travel that way, so only the assertion needs this check. /// /// `Sec-Fetch-Site` is set by the browser and a page cannot change it: /// `same-origin` is Codeg's own page and `none` an address the user typed; @@ -93,10 +95,11 @@ pub struct WebAuth { /// that sends no Fetch Metadata still sends `Origin` on every WebSocket /// handshake and every cross-origin POST, so when present it must name the host /// the request was addressed to: `Host`, or a proxy's `X-Forwarded-Host` — a -/// page cannot set either on a request to another site. A request with neither -/// header is not another site's WebSocket or POST: it comes from a client that -/// is not a browser, or is a cross-site GET from an old browser, whose answer -/// that site cannot read (CORS) and which changes nothing here. +/// page cannot set either on a request to another origin. A request with +/// neither header is not a WebSocket or POST from another origin's page: it +/// comes from a client that is not a browser, or is a cross-origin GET from an +/// old browser, whose answer that page cannot read (CORS) and which changes +/// nothing here. fn cross_origin_initiator(headers: &HeaderMap) -> Option { if let Some(site) = headers.get("sec-fetch-site") { let site = site.to_str().unwrap_or_default().trim(); @@ -142,7 +145,7 @@ pub async fn require_auth(request: Request, next: Next, auth: WebAuth) -> Respon None => next.run(request).await, Some(initiator) => { tracing::warn!( - "[AUTH] proxy-jwt: refused a request sent by another site's page ({initiator})" + "[AUTH] proxy-jwt: refused a request sent by a page on another origin ({initiator})" ); (StatusCode::FORBIDDEN, "Cross-origin request refused").into_response() } @@ -325,10 +328,11 @@ mod tests { } // The assertion rides on the proxy's session cookie, which the browser - // also attaches to requests that other sites' pages make (Cloudflare's - // application cookie is SameSite=None unless the operator changes it). + // also attaches to requests that pages on other origins make: Cloudflare's + // application cookie is SameSite=None unless the operator changes it, and + // a sibling subdomain's page (`same-site`) sends it even under Lax or Strict. #[tokio::test] - async fn proxy_mode_refuses_an_assertion_carried_by_another_sites_request() { + async fn proxy_mode_refuses_an_assertion_carried_by_another_origins_request() { let issuer = TestIssuer::new(); let server = proxy_server(&issuer).await; @@ -352,10 +356,10 @@ mod tests { } } - // A WebSocket handshake is a GET, and the one another site's page opens - // carries the proxy's cookie too. + // A WebSocket handshake is a GET, and the one a page on another origin + // opens carries the proxy's cookie too. #[tokio::test] - async fn proxy_mode_refuses_another_sites_websocket_handshake() { + async fn proxy_mode_refuses_another_origins_websocket_handshake() { let issuer = TestIssuer::new(); let server = proxy_server(&issuer).await; server @@ -440,8 +444,8 @@ mod tests { .assert_status_ok(); } - // Another site's page cannot send the token, so the token needs no proof - // of where the request came from, in either mode. + // A page on another origin cannot send the token, so the token needs no + // proof of where the request came from, in either mode. #[tokio::test] async fn the_token_is_admitted_whoever_sent_the_request() { let issuer = TestIssuer::new();