mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: let the login page send the token back to the mobile app
This commit is contained in:
1 parent
73c15f7c58
commit
3d92725ce6
8 files changed
+242
-23
No files matched your search
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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 || "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user