Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getClientSettings, setClientSettings } from "./methods/clientSettings.t
import {
authenticateCloudflareAccess,
installCloudflareAccessCookie,
installCloudflareAccessCredentials,
} from "./methods/cloudflareAccess.ts";
import {
clearConnectionCatalog,
Expand Down Expand Up @@ -62,6 +63,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(clearConnectionCatalog);
yield* ipc.handle(authenticateCloudflareAccess);
yield* ipc.handle(installCloudflareAccessCookie);
yield* ipc.handle(installCloudflareAccessCredentials);

yield* ipc.handle(discoverSshHosts);
yield* ipc.handle(ensureSshEnvironment);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog";
export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog";
export const AUTHENTICATE_CLOUDFLARE_ACCESS_CHANNEL = "desktop:authenticate-cloudflare-access";
export const INSTALL_CLOUDFLARE_ACCESS_COOKIE_CHANNEL = "desktop:install-cloudflare-access-cookie";
export const INSTALL_CLOUDFLARE_ACCESS_CREDENTIALS_CHANNEL =
"desktop:install-cloudflare-access-credentials";
export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
Expand Down
170 changes: 169 additions & 1 deletion apps/desktop/src/ipc/methods/cloudflareAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type * as Electron from "electron";
import { beforeEach, vi } from "vite-plus/test";

import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import { authenticateCloudflareAccess, installCloudflareAccessCookie } from "./cloudflareAccess.ts";
import {
authenticateCloudflareAccess,
installCloudflareAccessCookie,
installCloudflareAccessCredentials,
} from "./cloudflareAccess.ts";
import { normalizeCloudflareAccessOrigin } from "./cloudflareAccessOrigin.ts";

const electronMock = vi.hoisted(() => ({
Expand All @@ -15,12 +19,16 @@ const electronMock = vi.hoisted(() => ({
remove: vi.fn(),
set: vi.fn(),
},
webRequest: {
onBeforeSendHeaders: vi.fn(),
},
}));

vi.mock("electron", () => ({
session: {
defaultSession: {
cookies: electronMock.cookies,
webRequest: electronMock.webRequest,
},
},
}));
Expand All @@ -42,6 +50,8 @@ function makeAuthWindow() {
session: {
cookies: electronMock.cookies,
},
on: vi.fn(),
removeListener: vi.fn(),
},
once: vi.fn(),
removeListener: vi.fn(),
Expand All @@ -51,6 +61,18 @@ function makeAuthWindow() {
};
}

function emitWebContentsEvent(
authWindow: ReturnType<typeof makeAuthWindow>,
eventName: string,
url: string,
) {
const handler = authWindow.webContents.on.mock.calls.find(([name]) => name === eventName)?.[1];
if (typeof handler !== "function") {
throw new Error(`Expected a ${eventName} handler.`);
}
handler({} as Electron.Event, url);
}

function electronWindowLayer(authWindow: Electron.BrowserWindow) {
return ElectronWindow.ElectronWindow.of({
create: () => Effect.succeed(authWindow),
Expand Down Expand Up @@ -151,6 +173,78 @@ describe("desktop Cloudflare Access cookies", () => {
}),
);

it.effect("attaches saved service-token headers through the Electron session", () =>
Effect.gen(function* () {
const rendererHeaders: Record<string, string> = {
"cf-access-client-id": " client-id ",
"cf-access-client-secret": "client-secret",
authorization: "Bearer renderer-controlled",
cookie: "CF_Authorization=renderer-controlled",
};
electronMock.cookies.get.mockResolvedValueOnce([
accessCookie({
value: "stale-cookie",
domain: "app.example.test",
hostOnly: true,
path: "/",
secure: true,
}),
]);
electronMock.cookies.remove.mockResolvedValue(undefined);
yield* installCloudflareAccessCredentials.handler({
host: "https://app.example.test",
headers: rendererHeaders,
clearCookies: true,
});

expect(electronMock.cookies.remove).toHaveBeenCalledWith(
"https://app.example.test/",
"CF_Authorization",
);
const listener = electronMock.webRequest.onBeforeSendHeaders.mock.calls[0]?.[0];
expect(listener).toBeTypeOf("function");
const callback = vi.fn();
listener(
{
url: "wss://app.example.test/ws",
requestHeaders: {
"user-agent": "t3code",
authorization: "Bearer application",
cookie: "application=1",
},
},
callback,
);
expect(callback).toHaveBeenCalledWith({
requestHeaders: {
"user-agent": "t3code",
authorization: "Bearer application",
cookie: "application=1",
"cf-access-client-id": "client-id",
"cf-access-client-secret": "client-secret",
},
});

yield* installCloudflareAccessCredentials.handler({
host: "https://app.example.test",
headers: {},
});
expect(electronMock.cookies.get).toHaveBeenCalledTimes(1);
expect(electronMock.cookies.remove).toHaveBeenCalledTimes(1);
const clearedCallback = vi.fn();
listener(
{
url: "wss://app.example.test/ws",
requestHeaders: { "user-agent": "t3code" },
},
clearedCallback,
);
expect(clearedCallback).toHaveBeenCalledWith({
requestHeaders: { "user-agent": "t3code" },
});
}),
);

it.effect("restores cancelled login cookies with their original scope", () =>
Effect.gen(function* () {
const previousCookies = [
Expand Down Expand Up @@ -218,6 +312,80 @@ describe("desktop Cloudflare Access cookies", () => {
}),
);

it.effect(
"keeps waiting for the Access cookie when Electron aborts the initial load for a redirect",
() =>
Effect.gen(function* () {
const authWindow = makeAuthWindow();
authWindow.loadURL.mockRejectedValue(
Object.assign(new Error("ERR_ABORTED (-3) loading https://app.example.test/"), {
code: "ERR_ABORTED",
errno: -3,
}),
);
electronMock.cookies.get
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([accessCookie({ domain: "app.example.test", hostOnly: true })]);
electronMock.cookies.remove.mockResolvedValue(undefined);

const result = yield* authenticateCloudflareAccess
.handler({ host: "https://app.example.test" })
.pipe(
Effect.provideService(
ElectronWindow.ElectronWindow,
electronWindowLayer(authWindow as unknown as Electron.BrowserWindow),
),
);

expect(result).toEqual({ cookieValue: "access-cookie" });
expect(electronMock.cookies.set).not.toHaveBeenCalled();
expect(authWindow.close).toHaveBeenCalledTimes(1);
}),
);

it.effect(
"keeps waiting when an HTTP response-code load rejection follows the Access login redirect",
() =>
Effect.gen(function* () {
const authWindow = makeAuthWindow();
authWindow.loadURL.mockImplementation(async () => {
emitWebContentsEvent(
authWindow,
"did-redirect-navigation",
"https://team.cloudflareaccess.com/cdn-cgi/access/login/example",
);
throw Object.assign(
new Error("ERR_HTTP_RESPONSE_CODE_FAILURE loading https://app.example.test/"),
{
code: "ERR_HTTP_RESPONSE_CODE_FAILURE",
},
);
});
electronMock.cookies.get
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([accessCookie({ domain: "app.example.test", hostOnly: true })]);
electronMock.cookies.remove.mockResolvedValue(undefined);

const result = yield* authenticateCloudflareAccess
.handler({ host: "https://app.example.test" })
.pipe(
Effect.provideService(
ElectronWindow.ElectronWindow,
electronWindowLayer(authWindow as unknown as Electron.BrowserWindow),
),
);

expect(result).toEqual({ cookieValue: "access-cookie" });
expect(authWindow.webContents.removeListener).toHaveBeenCalledWith(
"did-redirect-navigation",
expect.any(Function),
);
expect(authWindow.close).toHaveBeenCalledTimes(1);
}),
);

it.effect("closes the auth window when pre-login cookie cleanup fails", () =>
Effect.gen(function* () {
const authWindow = makeAuthWindow();
Expand Down
Loading
Loading