mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import type {
|
||||
LoginMethodUIProps,
|
||||
SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const mainAxios = vi.hoisted(() => ({
|
||||
loginUser: vi.fn(),
|
||||
registerUser: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
getRegistrationAllowed: vi.fn(),
|
||||
getPasswordLoginAllowed: vi.fn(),
|
||||
getPasswordResetAllowed: vi.fn(),
|
||||
getSetupRequired: vi.fn(),
|
||||
initiatePasswordReset: vi.fn(),
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
isElectron: vi.fn(),
|
||||
getCurrentToken: vi.fn(),
|
||||
getOidcSilentLoginDefault: vi.fn(),
|
||||
requestDesktopAutoSession: vi.fn(),
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
const authMethodsApi = vi.hoisted(() => ({
|
||||
getLoginMethods: vi.fn(),
|
||||
submitLoginMethod: vi.fn(),
|
||||
startLoginRedirect: vi.fn(),
|
||||
verifySecondFactor: vi.fn(),
|
||||
challengeSecondFactor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => mainAxios);
|
||||
vi.mock("@/api/auth-methods-api", () => authMethodsApi);
|
||||
vi.mock("@/plugin-host/loader", () => ({
|
||||
startPreLoginPlugins: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/i18n/i18n", () => ({
|
||||
changeAppLanguage: vi.fn(async (code: string) => code),
|
||||
normalizeLanguageCode: vi.fn((code: string | null) => code || "en"),
|
||||
rememberLoginLanguage: vi.fn((code: string) => code),
|
||||
}));
|
||||
vi.mock("../../auth/silent-signin", () => ({
|
||||
removeSilentSigninFromSearch: vi.fn(),
|
||||
shouldTriggerSilentSignin: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.name === "string" ? `${key}:${opts.name}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { Auth } from "../../auth/Auth";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
} from "@/plugin-host/auth-registry";
|
||||
|
||||
const OriginalResizeObserver = globalThis.ResizeObserver;
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.ResizeObserver = OriginalResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.history.replaceState({}, "", "/");
|
||||
mainAxios.isElectron.mockReturnValue(false);
|
||||
mainAxios.getRegistrationAllowed.mockResolvedValue({ allowed: true });
|
||||
mainAxios.getPasswordLoginAllowed.mockResolvedValue({ allowed: true });
|
||||
mainAxios.getPasswordResetAllowed.mockResolvedValue(true);
|
||||
mainAxios.getSetupRequired.mockResolvedValue({ setup_required: false });
|
||||
mainAxios.getOidcSilentLoginDefault.mockResolvedValue({ enabled: false });
|
||||
mainAxios.requestTrustedProxyLogin.mockResolvedValue({ enabled: false });
|
||||
mainAxios.getUserInfo.mockResolvedValue({
|
||||
username: "alice",
|
||||
userId: "u1",
|
||||
is_admin: false,
|
||||
});
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "password",
|
||||
pluginId: "core",
|
||||
kind: "form",
|
||||
labelKey: "auth.password",
|
||||
instances: [{ id: "password", label: "Password" }],
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
kind: "redirect",
|
||||
labelKey: "auth.loginWithSso",
|
||||
instances: [{ id: "3", label: "Keycloak" }],
|
||||
},
|
||||
{
|
||||
id: "corp-sso",
|
||||
pluginId: "corp",
|
||||
kind: "redirect",
|
||||
labelKey: "corp.sso",
|
||||
instances: [{ id: "a", label: "Corp" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
async function openExternalTab() {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0),
|
||||
);
|
||||
fireEvent.click(screen.getAllByText("auth.external")[0]);
|
||||
}
|
||||
|
||||
describe("login methods on the login screen", () => {
|
||||
it("lists every enabled method, core and plugin", async () => {
|
||||
await openExternalTab();
|
||||
expect(screen.getByText("auth.loginWithProvider:Keycloak")).toBeTruthy();
|
||||
// A plugin redirect method with no UI of its own gets a plain button.
|
||||
expect(screen.getByText("auth.loginWithProvider:Corp")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a plugin redirect login through the generic endpoint", async () => {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, replace, search: "" },
|
||||
});
|
||||
authMethodsApi.startLoginRedirect.mockResolvedValue("https://corp/auth");
|
||||
await openExternalTab();
|
||||
fireEvent.click(screen.getByText("auth.loginWithProvider:Corp"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.startLoginRedirect).toHaveBeenCalledWith(
|
||||
"corp-sso",
|
||||
expect.objectContaining({ instanceId: "a" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("https://corp/auth"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a plugin's form UI and runs its second factor", async () => {
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "corp-form",
|
||||
pluginId: "corp",
|
||||
kind: "form",
|
||||
labelKey: "corp.form",
|
||||
instances: [],
|
||||
},
|
||||
]);
|
||||
const disposeMethod = registerLoginMethod({
|
||||
id: "corp-form",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.form",
|
||||
component: ({ submit }: LoginMethodUIProps) => (
|
||||
<button onClick={() => void submit({ token: "abc" })}>
|
||||
corp sign in
|
||||
</button>
|
||||
),
|
||||
});
|
||||
const disposeFactor = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.pin",
|
||||
component: ({ verify }: SecondFactorUIProps) => (
|
||||
<button onClick={() => void verify({ pin: "1234" })}>send pin</button>
|
||||
),
|
||||
});
|
||||
authMethodsApi.submitLoginMethod.mockResolvedValue({
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
temp_token: "pending-1",
|
||||
second_factors: [{ id: "pin", pluginId: "corp", labelKey: "corp.pin" }],
|
||||
});
|
||||
authMethodsApi.verifySecondFactor.mockResolvedValue({
|
||||
success: true,
|
||||
username: "alice",
|
||||
});
|
||||
const onLogin = vi.fn();
|
||||
try {
|
||||
render(<Auth onLogin={onLogin} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0),
|
||||
);
|
||||
fireEvent.click(screen.getAllByText("auth.external")[0]);
|
||||
fireEvent.click(await screen.findByText("corp sign in"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.submitLoginMethod).toHaveBeenCalledWith(
|
||||
"corp-form",
|
||||
expect.objectContaining({ token: "abc" }),
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("send pin"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith(
|
||||
"pin",
|
||||
expect.objectContaining({ pin: "1234", temp_token: "pending-1" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onLogin).toHaveBeenCalledWith("alice", "u1", false),
|
||||
);
|
||||
} finally {
|
||||
disposeMethod();
|
||||
disposeFactor();
|
||||
}
|
||||
});
|
||||
|
||||
it("picks up the second factor step after a redirect login", async () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/?second_factor=1&second_factors=totp",
|
||||
);
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
const input = await screen.findByPlaceholderText("000000");
|
||||
// Backup codes are letters and digits, and are accepted now.
|
||||
fireEvent.change(input, { target: { value: "abcd-2345" } });
|
||||
expect((input as HTMLInputElement).value).toBe("ABCD2345");
|
||||
authMethodsApi.verifySecondFactor.mockResolvedValue({ success: true });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("totp", {
|
||||
totp_code: "ABCD2345",
|
||||
rememberMe: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ElectronLoginForm } from "../../auth/ElectronLoginForm";
|
||||
|
||||
@@ -23,4 +23,35 @@ describe("ElectronLoginForm", () => {
|
||||
expect(permissions).toContain("publickey-credentials-get");
|
||||
expect(permissions).toContain("publickey-credentials-create");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"auth_component",
|
||||
"totp_auth_component",
|
||||
"passkey_auth_component",
|
||||
"method_auth_component",
|
||||
])("accepts a login hand-off from %s", async (source) => {
|
||||
const onAuthSuccess = vi.fn();
|
||||
render(
|
||||
<ElectronLoginForm
|
||||
serverUrl="https://termix.example.com"
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
onChangeServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const frame = screen.getByTitle(
|
||||
"Server Authentication",
|
||||
) as HTMLIFrameElement;
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
data: {
|
||||
type: "AUTH_SUCCESS",
|
||||
platform: "desktop",
|
||||
source,
|
||||
token: "jwt-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({ getSshAuthProviders: vi.fn() }));
|
||||
vi.mock("@/api/auth-methods-api", () => api);
|
||||
|
||||
import {
|
||||
invalidateSshAuthProviders,
|
||||
useSshAuthProviders,
|
||||
} from "@/hooks/useSshAuthProviders";
|
||||
import { registerSshAuthEditor } from "@/plugin-host/auth-registry";
|
||||
|
||||
function summary(type: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type,
|
||||
labelKey: `hosts.filterAuth${type}`,
|
||||
pluginId: "core",
|
||||
fields: [],
|
||||
credentialType: false,
|
||||
needsUserInteraction: false,
|
||||
supportsBackground: true,
|
||||
available: true,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
invalidateSshAuthProviders();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useSshAuthProviders", () => {
|
||||
it("lists the server's types, flags ones whose plugin is off, and merges editors", async () => {
|
||||
api.getSshAuthProviders.mockResolvedValue([
|
||||
summary("password", { credentialType: true }),
|
||||
summary("corp", { pluginId: "corp" }),
|
||||
summary("gone", {
|
||||
available: false,
|
||||
pluginId: "gone-plugin",
|
||||
missingPlugin: { id: "gone-plugin", name: "Gone" },
|
||||
}),
|
||||
]);
|
||||
const dispose = registerSshAuthEditor({
|
||||
id: "corp",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp:title",
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useSshAuthProviders());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.providers.map((p) => p.type)).toEqual([
|
||||
"password",
|
||||
"corp",
|
||||
"gone",
|
||||
]);
|
||||
expect(result.current.find("corp")?.editorTitleKey).toBe("corp:title");
|
||||
expect(result.current.find("gone")).toMatchObject({
|
||||
available: false,
|
||||
missingPlugin: { name: "Gone" },
|
||||
});
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers the built-in types while the server has not answered", () => {
|
||||
api.getSshAuthProviders.mockReturnValue(new Promise(() => {}));
|
||||
const { result } = renderHook(() => useSshAuthProviders());
|
||||
expect(result.current.loaded).toBe(false);
|
||||
expect(result.current.providers.map((p) => p.type)).toEqual([
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"agent",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUserSecondFactors: vi.fn(),
|
||||
resetUserSecondFactors: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/auth-methods-api", () => api);
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.plugin ? `${key}:${opts.plugin}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { AdminSecondFactorsSection } from "@/sidebar/AdminSecondFactorsSection";
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("AdminSecondFactorsSection", () => {
|
||||
it("shows a factor whose plugin is off and resets after confirming", async () => {
|
||||
api.getUserSecondFactors.mockResolvedValue([
|
||||
{
|
||||
pluginId: "core",
|
||||
factorId: "totp",
|
||||
labelKey: "auth.totpFactor",
|
||||
available: true,
|
||||
},
|
||||
{ pluginId: "yubi", factorId: "otp", labelKey: null, available: false },
|
||||
]);
|
||||
api.resetUserSecondFactors.mockResolvedValue(undefined);
|
||||
const onReset = vi.fn();
|
||||
const requestConfirm = vi.fn((_message: string, onConfirm: () => void) =>
|
||||
onConfirm(),
|
||||
);
|
||||
render(
|
||||
<AdminSecondFactorsSection
|
||||
userId="u1"
|
||||
username="alice"
|
||||
heading={<h2>factors</h2>}
|
||||
requestConfirm={requestConfirm}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("admin.secondFactorUnavailable:yubi"),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("admin.resetSecondFactors"));
|
||||
expect(requestConfirm).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(api.resetUserSecondFactors).toHaveBeenCalledWith("u1"),
|
||||
);
|
||||
expect(onReset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -69,11 +69,12 @@ describe("sanitizeCredentialSidebarPreferences", () => {
|
||||
it("filters out invalid entries from filter arrays instead of rejecting them", () => {
|
||||
const result = sanitizeCredentialSidebarPreferences({
|
||||
filters: {
|
||||
type: ["key", "bogus"],
|
||||
type: ["key", "corp-token", "Bad Type!", 3],
|
||||
tags: ["prod", 42, null],
|
||||
},
|
||||
});
|
||||
expect(result.filters.type).toEqual(["key"]);
|
||||
// Plugins add credential types, so any well-formed id is kept.
|
||||
expect(result.filters.type).toEqual(["key", "corp-token"]);
|
||||
expect(result.filters.tags).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -118,12 +118,13 @@ describe("sanitizeHostSidebarPreferences", () => {
|
||||
const result = sanitizeHostSidebarPreferences({
|
||||
filters: {
|
||||
status: ["online", "bogus", "pinned"],
|
||||
authType: ["key", "nope"],
|
||||
authType: ["key", "corp-sso", "Not A Type!", 7],
|
||||
tags: ["prod", 42, null],
|
||||
},
|
||||
});
|
||||
expect(result.filters.status).toEqual(["online", "pinned"]);
|
||||
expect(result.filters.authType).toEqual(["key"]);
|
||||
// Plugins add auth types, so any well-formed id is kept.
|
||||
expect(result.filters.authType).toEqual(["key", "corp-sso"]);
|
||||
expect(result.filters.tags).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user