diff --git a/CHANGELOG.md b/CHANGELOG.md index 865948a4e..1f7e4da47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 26.10.6 + +### Added + +- The mobile app can sign in through your browser with any login method, including passkeys and security keys + ## 26.10.5 ### Added diff --git a/package-lock.json b/package-lock.json index c5bcb6f90..1096643f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.10.5", + "version": "26.10.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.10.5", + "version": "26.10.6", "hasInstallScript": true, "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 994cdc1d2..a3c0c0b67 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.10.5", + "version": "26.10.6", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/src/main.tsx b/src/main.tsx index 76b2f4f14..4afeeddf4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,6 +12,7 @@ import { ThemeProvider } from "@/components/theme-provider"; import "./ui/i18n/i18n"; import { isElectron } from "@/lib/electron"; import { isEmbeddedAuthFrame } from "@/lib/embedded-frame"; +import { captureAppReturn } from "@/lib/app-return"; import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume"; import { getEmbeddedServerFailure } from "@/lib/embedded-server-status"; import { Toaster } from "@/components/sonner"; @@ -168,8 +169,11 @@ function App() { // Keeps the window controls the system draws in the colors of the theme. useTitleBarOverlayColors(); const stored = getStoredAuth(); + // A sign in for the mobile app always shows the login form, so the app gets + // its own fresh session. + const [appReturn] = useState(captureAppReturn); const [phase, setPhase] = useState( - stored?.loggedIn ? "verifying" : "idle-auth", + stored?.loggedIn && !appReturn ? "verifying" : "idle-auth", ); const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); const [verifyRetryCount, setVerifyRetryCount] = useState(0); diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index f5ef9a746..8e243e650 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -1,9 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ import { + isEmbeddedAuthFrame, isInElectronWebView, isInMobileWebView, type EmbeddedFrameWindow, } from "@/lib/embedded-frame"; +import { handOffToApp, pendingAppReturn } from "@/lib/app-return"; import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; @@ -145,6 +147,13 @@ export function storeAuth(username: string) { ); } +/** A browser login the mobile app asked for goes back to the app. */ +async function handOffLogin(token?: string | null): Promise { + if (!pendingAppReturn() || isEmbeddedAuthFrame()) return false; + const value = token || (await getCurrentToken()); + return handOffToApp(value ? { token: value } : { error: "no_token" }); +} + type AuthView = "login" | "register" | "reset" | "second-factor" | "external"; type ResetStep = "email" | "code" | "newpass"; @@ -331,8 +340,9 @@ export function Auth({ onLogin }: AuthProps) { if (proxySigninHandledRef.current || isElectron()) return; proxySigninHandledRef.current = true; requestTrustedProxyLogin() - .then((result) => { + .then(async (result) => { if (!result.enabled || !result.success) return; + if (await handOffLogin()) return; storeAuth(result.username || ""); onLogin( result.username || "", @@ -625,24 +635,27 @@ export function Auth({ onLogin }: AuthProps) { } return; } - getUserInfo() - .then((meRes) => { - storeAuth(meRes.username || ""); - clearDesktopManualLogout(); - onLogin( - meRes.username || "", - meRes.userId || undefined, - !!meRes.is_admin, - ); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .catch(() => { - toast.error(t("errors.failedUserInfo")); - }); + void handOffLogin(urlParams.get("token")).then((handedOff) => { + if (handedOff) return; + getUserInfo() + .then((meRes) => { + storeAuth(meRes.username || ""); + clearDesktopManualLogout(); + onLogin( + meRes.username || "", + meRes.userId || undefined, + !!meRes.is_admin, + ); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + }) + .catch(() => { + toast.error(t("errors.failedUserInfo")); + }); + }); } }, [onLogin, t]); @@ -746,6 +759,7 @@ export function Auth({ onLogin }: AuthProps) { setWebviewAuthSuccess(true); return; } + if (await handOffLogin(res?.token)) return; const meRes = await getUserInfo(); const name = meRes.username || res.username || options.fallbackUsername || ""; diff --git a/src/ui/lib/app-return.ts b/src/ui/lib/app-return.ts new file mode 100644 index 000000000..2fddc0049 --- /dev/null +++ b/src/ui/lib/app-return.ts @@ -0,0 +1,61 @@ +/** + * Sign in for the Termix mobile app in a real browser. The app opens the + * login page with ?return_to=termix-mobile://..., and after any login method + * the page sends the token back to that address. Only the app's own scheme + * is accepted, so the value can never send a token to a website. + */ + +const STORAGE_KEY = "termix-app-return"; +const PARAM = "return_to"; +const APP_RETURN = /^termix-mobile:\/\/[A-Za-z0-9._~/-]*$/; + +export function isAppReturn(value: unknown): value is string { + return typeof value === "string" && APP_RETURN.test(value); +} + +/** Reads return_to from the address bar once and keeps it for this tab. */ +export function captureAppReturn(): string | null { + try { + const url = new URL(window.location.href); + const value = url.searchParams.get(PARAM); + if (value !== null) { + url.searchParams.delete(PARAM); + window.history.replaceState({}, document.title, url.toString()); + if (isAppReturn(value)) sessionStorage.setItem(STORAGE_KEY, value); + else sessionStorage.removeItem(STORAGE_KEY); + } + return pendingAppReturn(); + } catch { + return null; + } +} + +export function pendingAppReturn(): string | null { + try { + const value = sessionStorage.getItem(STORAGE_KEY); + return isAppReturn(value) ? value : null; + } catch { + return null; + } +} + +export function appReturnUrl( + base: string, + params: Record, +): string { + const query = new URLSearchParams(params).toString(); + return query ? `${base}?${query}` : base; +} + +/** Sends the result to the app. False when no app asked for it. */ +export function handOffToApp(params: Record): boolean { + const base = pendingAppReturn(); + if (!base) return false; + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // The redirect still goes out. + } + window.location.replace(appReturnUrl(base, params)); + return true; +} diff --git a/src/ui/tests/auth/Auth.test.tsx b/src/ui/tests/auth/Auth.test.tsx index 1c2ba0dfc..86928fca3 100644 --- a/src/ui/tests/auth/Auth.test.tsx +++ b/src/ui/tests/auth/Auth.test.tsx @@ -29,6 +29,12 @@ const mainAxios = vi.hoisted(() => ({ })); vi.mock("@/main-axios", () => mainAxios); + +const appReturn = vi.hoisted(() => ({ + pendingAppReturn: vi.fn((): string | null => null), + handOffToApp: vi.fn(() => true), +})); +vi.mock("@/lib/app-return", () => appReturn); vi.mock("@/i18n/i18n", () => ({ changeAppLanguage: vi.fn(async (code: string) => code), normalizeLanguageCode: vi.fn((code: string | null) => code || "en"), @@ -184,3 +190,38 @@ describe("Auth local desktop flow", () => { expect(mainAxios.requestDesktopAutoSession).not.toHaveBeenCalled(); }); }); + +describe("Auth sign in for the mobile app", () => { + it("sends a trusted proxy login back to the app instead of opening Termix", async () => { + appReturn.pendingAppReturn.mockReturnValue("termix-mobile://oidc-callback"); + mainAxios.requestTrustedProxyLogin.mockResolvedValue({ + enabled: true, + success: true, + username: "alice", + }); + mainAxios.getCurrentToken.mockResolvedValue("tok-1"); + const onLogin = vi.fn(); + + render(); + + await waitFor(() => + expect(appReturn.handOffToApp).toHaveBeenCalledWith({ token: "tok-1" }), + ); + expect(onLogin).not.toHaveBeenCalled(); + }); + + it("signs in normally when no app asked", async () => { + appReturn.pendingAppReturn.mockReturnValue(null); + mainAxios.requestTrustedProxyLogin.mockResolvedValue({ + enabled: true, + success: true, + username: "alice", + }); + const onLogin = vi.fn(); + + render(); + + await waitFor(() => expect(onLogin).toHaveBeenCalled()); + expect(appReturn.handOffToApp).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/tests/lib/app-return.test.ts b/src/ui/tests/lib/app-return.test.ts new file mode 100644 index 000000000..f27f33578 --- /dev/null +++ b/src/ui/tests/lib/app-return.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + appReturnUrl, + captureAppReturn, + handOffToApp, + isAppReturn, + pendingAppReturn, +} from "@/lib/app-return"; + +function visit(path: string) { + window.history.replaceState({}, "", path); +} + +beforeEach(() => { + sessionStorage.clear(); + visit("/"); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("isAppReturn", () => { + it("accepts only the mobile app scheme", () => { + expect(isAppReturn("termix-mobile://oidc-callback")).toBe(true); + expect(isAppReturn("https://evil.example/cb")).toBe(false); + expect(isAppReturn("http://localhost/oidc-callback")).toBe(false); + expect(isAppReturn("javascript:alert(1)")).toBe(false); + expect(isAppReturn("termix-mobile-evil://x")).toBe(false); + expect(isAppReturn("termix-mobile://cb?next=https://evil")).toBe(false); + expect(isAppReturn("termix-mobile://cb#x")).toBe(false); + expect(isAppReturn(null)).toBe(false); + }); +}); + +describe("captureAppReturn", () => { + it("keeps a valid return address and removes it from the address bar", () => { + visit("/?return_to=termix-mobile%3A%2F%2Foidc-callback&x=1"); + expect(captureAppReturn()).toBe("termix-mobile://oidc-callback"); + expect(window.location.search).toBe("?x=1"); + expect(pendingAppReturn()).toBe("termix-mobile://oidc-callback"); + }); + + it("drops anything else", () => { + sessionStorage.setItem("termix-app-return", "termix-mobile://old"); + visit("/?return_to=https%3A%2F%2Fevil.example"); + expect(captureAppReturn()).toBeNull(); + expect(pendingAppReturn()).toBeNull(); + }); + + it("keeps a stored value across a redirect back with no param", () => { + sessionStorage.setItem( + "termix-app-return", + "termix-mobile://oidc-callback", + ); + visit("/?success=true"); + expect(captureAppReturn()).toBe("termix-mobile://oidc-callback"); + }); + + it("ignores a stored value that was tampered with", () => { + sessionStorage.setItem("termix-app-return", "https://evil.example"); + expect(pendingAppReturn()).toBeNull(); + }); +}); + +describe("handOffToApp", () => { + it("does nothing when no app asked", () => { + expect(handOffToApp({ token: "t" })).toBe(false); + }); + + it("builds the return address with the token", () => { + expect( + appReturnUrl("termix-mobile://oidc-callback", { token: "a b" }), + ).toBe("termix-mobile://oidc-callback?token=a+b"); + }); + + it("redirects once and forgets the address", () => { + sessionStorage.setItem( + "termix-app-return", + "termix-mobile://oidc-callback", + ); + const replace = vi.fn(); + vi.spyOn(window, "location", "get").mockReturnValue({ + ...window.location, + replace, + } as Location); + expect(handOffToApp({ token: "t1" })).toBe(true); + expect(replace).toHaveBeenCalledWith( + "termix-mobile://oidc-callback?token=t1", + ); + expect(pendingAppReturn()).toBeNull(); + }); +});