feat: let the login page send the token back to the mobile app

This commit is contained in:
LukeGus committed 2026-10-23 15:52:36 -05:00
1 parent 73c15f7c58
commit 3d92725ce6
8 files changed
+242 -23

No files matched your search

+6
View File
@@ -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
+2 -2
View File
@@ -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/*"
+1 -1
View File
@@ -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",
+5 -1
View File
@@ -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<Phase>(
stored?.loggedIn ? "verifying" : "idle-auth",
stored?.loggedIn && !appReturn ? "verifying" : "idle-auth",
);
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
+33 -19
View File
@@ -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<boolean> {
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 || "";
+61
View File
@@ -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, string>,
): 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<string, string>): 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;
}
+41
View File
@@ -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(<Auth onLogin={onLogin} />);
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(<Auth onLogin={onLogin} />);
await waitFor(() => expect(onLogin).toHaveBeenCalled());
expect(appReturn.handOffToApp).not.toHaveBeenCalled();
});
});
+93
View File
@@ -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();
});
});