diff --git a/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts b/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts index c75088d8454..dd4e02622a7 100644 --- a/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts +++ b/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { + __testing, authenticateCloudflareAccess, installCloudflareAccessCookie, installCloudflareAccessCredentials, @@ -34,6 +35,7 @@ vi.mock("electron", () => ({ })); beforeEach(() => { + __testing.resetCloudflareAccessHeaders(); vi.clearAllMocks(); }); @@ -73,9 +75,16 @@ function emitWebContentsEvent( handler({} as Electron.Event, url); } -function electronWindowLayer(authWindow: Electron.BrowserWindow) { +function electronWindowLayer( + authWindow: Electron.BrowserWindow, + onCreate?: (options: Electron.BrowserWindowConstructorOptions) => void, +) { return ElectronWindow.ElectronWindow.of({ - create: () => Effect.succeed(authWindow), + create: (options) => + Effect.sync(() => { + onCreate?.(options); + return authWindow; + }), main: Effect.succeed(Option.none()), currentMainOrFirst: Effect.succeed(Option.none()), focusedMainOrFirst: Effect.succeed(Option.none()), @@ -173,7 +182,7 @@ describe("desktop Cloudflare Access cookies", () => { }), ); - it.effect("attaches saved service-token headers through the Electron session", () => + it.effect("attaches saved Cloudflare Access transport through the Electron session", () => Effect.gen(function* () { const rendererHeaders: Record = { "cf-access-client-id": " client-id ", @@ -181,16 +190,18 @@ describe("desktop Cloudflare Access cookies", () => { 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, - }), - ]); + const staleCookie = accessCookie({ + value: "stale-cookie", + domain: "app.example.test", + hostOnly: true, + path: "/", + secure: true, + }); + electronMock.cookies.get + .mockResolvedValueOnce([staleCookie]) + .mockResolvedValueOnce([staleCookie]); electronMock.cookies.remove.mockResolvedValue(undefined); + electronMock.cookies.set.mockResolvedValue(undefined); yield* installCloudflareAccessCredentials.handler({ host: "https://app.example.test", headers: rendererHeaders, @@ -225,12 +236,48 @@ describe("desktop Cloudflare Access cookies", () => { }, }); + yield* installCloudflareAccessCredentials.handler({ + host: "https://app.example.test", + headers: { + "cf-access-jwt-assertion": " fresh-access-cookie ", + }, + clearCookies: true, + cookieValue: "fresh-access-cookie", + }); + + expect(electronMock.cookies.set).toHaveBeenCalledWith({ + url: "https://app.example.test/", + name: "CF_Authorization", + value: "fresh-access-cookie", + httpOnly: true, + secure: true, + sameSite: "no_restriction", + }); + const pairingCallback = vi.fn(); + listener( + { + url: "https://app.example.test/.well-known/t3/environment", + requestHeaders: { + "user-agent": "t3code", + Cookie: "application=1; CF_Authorization=stale-cookie", + }, + }, + pairingCallback, + ); + expect(pairingCallback).toHaveBeenCalledWith({ + requestHeaders: { + "user-agent": "t3code", + "cf-access-jwt-assertion": "fresh-access-cookie", + Cookie: "application=1; CF_Authorization=fresh-access-cookie", + }, + }); + yield* installCloudflareAccessCredentials.handler({ host: "https://app.example.test", headers: {}, }); - expect(electronMock.cookies.get).toHaveBeenCalledTimes(1); - expect(electronMock.cookies.remove).toHaveBeenCalledTimes(1); + expect(electronMock.cookies.get).toHaveBeenCalledTimes(2); + expect(electronMock.cookies.remove).toHaveBeenCalledTimes(2); const clearedCallback = vi.fn(); listener( { @@ -245,6 +292,192 @@ describe("desktop Cloudflare Access cookies", () => { }), ); + it.effect("suspends saved Cloudflare Access cookies while reauthenticating", () => + Effect.gen(function* () { + const staleCookie = accessCookie({ + value: "stale-cookie", + domain: "app.example.test", + hostOnly: true, + path: "/", + secure: true, + }); + electronMock.cookies.get.mockResolvedValueOnce([staleCookie]); + electronMock.cookies.remove.mockResolvedValue(undefined); + electronMock.cookies.set.mockResolvedValue(undefined); + + yield* installCloudflareAccessCredentials.handler({ + host: "https://app.example.test", + headers: {}, + clearCookies: true, + cookieValue: "stale-cookie", + }); + + const listener = electronMock.webRequest.onBeforeSendHeaders.mock.calls[0]?.[0]; + expect(listener).toBeTypeOf("function"); + + const authWindow = makeAuthWindow(); + const authNavigationCallback = vi.fn(); + authWindow.loadURL.mockImplementation(async () => { + listener( + { + url: "https://app.example.test/", + requestHeaders: { + Cookie: "application=1", + }, + }, + authNavigationCallback, + ); + return new Promise(() => {}); + }); + electronMock.cookies.get + .mockResolvedValueOnce([staleCookie]) + .mockResolvedValueOnce([staleCookie]) + .mockResolvedValueOnce([ + accessCookie({ + value: "fresh-cookie", + domain: "app.example.test", + hostOnly: true, + path: "/", + secure: true, + }), + ]); + + 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: "fresh-cookie" }); + expect(authNavigationCallback).toHaveBeenCalledWith({ + requestHeaders: { + Cookie: "application=1", + }, + }); + + const postAuthCallback = vi.fn(); + listener( + { + url: "https://app.example.test/.well-known/t3/environment", + requestHeaders: { "user-agent": "t3code" }, + }, + postAuthCallback, + ); + expect(postAuthCallback).toHaveBeenCalledWith({ + requestHeaders: { "user-agent": "t3code" }, + }); + }), + ); + + it.effect("does not restore stale Cloudflare Access headers over a concurrent update", () => + Effect.gen(function* () { + const staleCookie = accessCookie({ + value: "stale-cookie", + domain: "app.example.test", + hostOnly: true, + path: "/", + secure: true, + }); + electronMock.cookies.get.mockResolvedValueOnce([staleCookie]); + electronMock.cookies.remove.mockResolvedValue(undefined); + electronMock.cookies.set.mockResolvedValue(undefined); + + yield* installCloudflareAccessCredentials.handler({ + host: "https://app.example.test", + headers: {}, + clearCookies: true, + cookieValue: "stale-cookie", + }); + + const listener = electronMock.webRequest.onBeforeSendHeaders.mock.calls[0]?.[0]; + expect(listener).toBeTypeOf("function"); + + const suspendedHeaders = __testing.suspendCloudflareAccessHeaders( + "https://app.example.test/", + ); + electronMock.cookies.get.mockResolvedValueOnce([staleCookie]); + yield* installCloudflareAccessCredentials.handler({ + host: "https://app.example.test", + headers: {}, + clearCookies: true, + cookieValue: "fresh-cookie", + }); + __testing.restoreCloudflareAccessHeaders(suspendedHeaders); + + const callback = vi.fn(); + listener( + { + url: "https://app.example.test/.well-known/t3/environment", + requestHeaders: { "user-agent": "t3code" }, + }, + callback, + ); + expect(callback).toHaveBeenCalledWith({ + requestHeaders: { + "user-agent": "t3code", + Cookie: "CF_Authorization=fresh-cookie", + }, + }); + }), + ); + + it.effect("restores the previous Access header rule when cookie installation fails", () => + Effect.gen(function* () { + const staleCookie = accessCookie({ + value: "stale-cookie", + domain: "app.example.test", + hostOnly: true, + path: "/", + secure: true, + }); + electronMock.cookies.get.mockResolvedValueOnce([staleCookie]); + electronMock.cookies.remove.mockResolvedValue(undefined); + electronMock.cookies.set.mockResolvedValue(undefined); + + yield* installCloudflareAccessCredentials.handler({ + host: "https://app.example.test", + headers: {}, + clearCookies: true, + cookieValue: "stale-cookie", + }); + + const listener = electronMock.webRequest.onBeforeSendHeaders.mock.calls[0]?.[0]; + expect(listener).toBeTypeOf("function"); + + const installError = new Error("cookie store rejected fresh cookie"); + electronMock.cookies.get.mockResolvedValueOnce([staleCookie]); + electronMock.cookies.set.mockRejectedValueOnce(installError); + + const exit = yield* Effect.exit( + installCloudflareAccessCredentials.handler({ + host: "https://app.example.test", + headers: {}, + clearCookies: true, + cookieValue: "fresh-cookie", + }), + ); + + expect(exit._tag).toBe("Failure"); + const callback = vi.fn(); + listener( + { + url: "https://app.example.test/.well-known/t3/environment", + requestHeaders: { "user-agent": "t3code" }, + }, + callback, + ); + expect(callback).toHaveBeenCalledWith({ + requestHeaders: { + "user-agent": "t3code", + Cookie: "CF_Authorization=stale-cookie", + }, + }); + }), + ); + it.effect("restores cancelled login cookies with their original scope", () => Effect.gen(function* () { const previousCookies = [ @@ -323,6 +556,7 @@ describe("desktop Cloudflare Access cookies", () => { errno: -3, }), ); + const createdWindows: Electron.BrowserWindowConstructorOptions[] = []; electronMock.cookies.get .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) @@ -334,11 +568,18 @@ describe("desktop Cloudflare Access cookies", () => { .pipe( Effect.provideService( ElectronWindow.ElectronWindow, - electronWindowLayer(authWindow as unknown as Electron.BrowserWindow), + electronWindowLayer(authWindow as unknown as Electron.BrowserWindow, (options) => { + createdWindows.push(options); + }), ), ); expect(result).toEqual({ cookieValue: "access-cookie" }); + expect(createdWindows[0]?.webPreferences).toEqual( + expect.objectContaining({ + session: Electron.session.defaultSession, + }), + ); expect(electronMock.cookies.set).not.toHaveBeenCalled(); expect(authWindow.close).toHaveBeenCalledTimes(1); }), diff --git a/apps/desktop/src/ipc/methods/cloudflareAccess.ts b/apps/desktop/src/ipc/methods/cloudflareAccess.ts index 2b53598acb8..55621b1c161 100644 --- a/apps/desktop/src/ipc/methods/cloudflareAccess.ts +++ b/apps/desktop/src/ipc/methods/cloudflareAccess.ts @@ -16,6 +16,7 @@ import * as DesktopIpc from "../DesktopIpc.ts"; import { normalizeCloudflareAccessOrigin } from "./cloudflareAccessOrigin.ts"; const CLOUDFLARE_ACCESS_COOKIE_NAME = "CF_Authorization"; +const CLOUDFLARE_ACCESS_COOKIE_HEADER_NAME = "Cookie"; const CLOUDFLARE_ACCESS_TRANSPORT_HEADER_NAMES = [ "cf-access-client-id", "cf-access-client-secret", @@ -27,6 +28,7 @@ const CLOUDFLARE_ACCESS_TRANSPORT_HEADER_NAME_SET = new Set( const ACCESS_LOGIN_TIMEOUT_MS = 5 * 60 * 1000; const COOKIE_POLL_INTERVAL_MS = 500; const cloudflareAccessHeaderRules = new Map>>(); +const cloudflareAccessHeaderRuleVersions = new Map(); let cloudflareAccessHeaderHookInstalled = false; export class DesktopCloudflareAccessLoginError extends Schema.TaggedErrorClass()( @@ -205,6 +207,65 @@ function filteredHeaders( ); } +function readHeader( + headers: Readonly>, + name: string, +): string | undefined { + const normalizedName = name.toLowerCase(); + for (const [rawName, rawValue] of Object.entries(headers)) { + if (rawName.toLowerCase() === normalizedName) { + return rawValue; + } + } + return undefined; +} + +function cloudflareAccessCookieHeader(cookieValue: string | undefined): string | undefined { + const value = cookieValue?.trim() ?? ""; + return value.length > 0 ? `${CLOUDFLARE_ACCESS_COOKIE_NAME}=${value}` : undefined; +} + +function mergeCookieHeader(existingHeader: string | undefined, accessCookieHeader: string): string { + const existingCookies = (existingHeader ?? "") + .split(";") + .map((part) => part.trim()) + .filter( + (part) => + part.length > 0 && + !part.toLowerCase().startsWith(`${CLOUDFLARE_ACCESS_COOKIE_NAME.toLowerCase()}=`), + ); + return [...existingCookies, accessCookieHeader].join("; "); +} + +function applyCloudflareAccessHeaders( + requestHeaders: Readonly>, + accessHeaders: Readonly>, +): Readonly> { + const next: Record = { ...requestHeaders }; + const accessCookieHeader = readHeader(accessHeaders, CLOUDFLARE_ACCESS_COOKIE_HEADER_NAME); + + for (const [name, value] of Object.entries(accessHeaders)) { + if (name.toLowerCase() === CLOUDFLARE_ACCESS_COOKIE_HEADER_NAME.toLowerCase()) { + continue; + } + next[name] = value; + } + + if (accessCookieHeader !== undefined) { + for (const name of Object.keys(next)) { + if (name.toLowerCase() === CLOUDFLARE_ACCESS_COOKIE_HEADER_NAME.toLowerCase()) { + delete next[name]; + } + } + next[CLOUDFLARE_ACCESS_COOKIE_HEADER_NAME] = mergeCookieHeader( + readHeader(requestHeaders, CLOUDFLARE_ACCESS_COOKIE_HEADER_NAME), + accessCookieHeader, + ); + } + + return next; +} + function installCloudflareAccessHeaderHook(session: Electron.Session) { if (cloudflareAccessHeaderHookInstalled) { return; @@ -218,32 +279,85 @@ function installCloudflareAccessHeaderHook(session: Electron.Session) { return; } callback({ - requestHeaders: { - ...details.requestHeaders, - ...headers, - }, + requestHeaders: applyCloudflareAccessHeaders(details.requestHeaders, headers), }); }); } +function bumpCloudflareAccessHeaderRuleVersion(origin: string): number { + const nextVersion = (cloudflareAccessHeaderRuleVersions.get(origin) ?? 0) + 1; + cloudflareAccessHeaderRuleVersions.set(origin, nextVersion); + return nextVersion; +} + function configureCloudflareAccessHeaders( session: Electron.Session, origin: string, headers: Readonly>, + cookieValue?: string, ) { const requestOriginKey = cloudflareAccessRuleOrigin(origin); if (requestOriginKey === null) { return; } - const nextHeaders = filteredHeaders(headers); + const cookieHeader = cloudflareAccessCookieHeader(cookieValue); + const nextHeaders = { + ...filteredHeaders(headers), + ...(cookieHeader + ? { + [CLOUDFLARE_ACCESS_COOKIE_HEADER_NAME]: cookieHeader, + } + : {}), + }; if (Object.keys(nextHeaders).length === 0) { cloudflareAccessHeaderRules.delete(requestOriginKey); + bumpCloudflareAccessHeaderRuleVersion(requestOriginKey); return; } cloudflareAccessHeaderRules.set(requestOriginKey, nextHeaders); + bumpCloudflareAccessHeaderRuleVersion(requestOriginKey); installCloudflareAccessHeaderHook(session); } +interface SuspendedCloudflareAccessHeaders { + readonly requestOriginKey: string; + readonly headers: Readonly> | undefined; + readonly version: number; +} + +function suspendCloudflareAccessHeaders( + origin: string, +): SuspendedCloudflareAccessHeaders | undefined { + const requestOriginKey = cloudflareAccessRuleOrigin(origin); + if (requestOriginKey === null) { + return undefined; + } + const previousHeaders = cloudflareAccessHeaderRules.get(requestOriginKey); + cloudflareAccessHeaderRules.delete(requestOriginKey); + return { + requestOriginKey, + headers: previousHeaders, + version: bumpCloudflareAccessHeaderRuleVersion(requestOriginKey), + }; +} + +function restoreCloudflareAccessHeaders(suspended: SuspendedCloudflareAccessHeaders | undefined) { + if (suspended === undefined) { + return; + } + if ( + cloudflareAccessHeaderRules.has(suspended.requestOriginKey) || + (cloudflareAccessHeaderRuleVersions.get(suspended.requestOriginKey) ?? 0) !== suspended.version + ) { + return; + } + if (suspended.headers === undefined || Object.keys(suspended.headers).length === 0) { + return; + } + cloudflareAccessHeaderRules.set(suspended.requestOriginKey, suspended.headers); + bumpCloudflareAccessHeaderRuleVersion(suspended.requestOriginKey); +} + function errorText(cause: unknown): string { if (cause instanceof Error) { return `${cause.name} ${cause.message}`; @@ -289,6 +403,7 @@ function isLoadUrlRedirectInterruption( function captureCloudflareAccessCookie(options: { readonly origin: string; readonly parent: Electron.BrowserWindow | undefined; + readonly session: Electron.Session; readonly createWindow: ElectronWindow.ElectronWindow["Service"]["create"]; }) { return Effect.tryPromise({ @@ -304,18 +419,20 @@ function captureCloudflareAccessCookie(options: { modal: false, ...(options.parent ? { parent: options.parent } : {}), webPreferences: { + session: options.session, sandbox: true, contextIsolation: true, nodeIntegration: false, }, }), ); - const session = authWindow.webContents.session; + const session = options.session; const abort = new AbortController(); let closeHandler: (() => void) | undefined; let observedCloudflareAccessLoginNavigation = false; let capturedReplacementCookie = false; let previousAccessCookies: ReadonlyArray = []; + const suspendedAccessHeaders = suspendCloudflareAccessHeaders(options.origin); const observeNavigation = (_event: Electron.Event, navigationUrl: string) => { if (isCloudflareAccessLoginUrl(navigationUrl)) { observedCloudflareAccessLoginNavigation = true; @@ -405,6 +522,7 @@ function captureCloudflareAccessCookie(options: { try { if (!capturedReplacementCookie) { await restoreAccessCookies(session, options.origin, previousAccessCookies); + restoreCloudflareAccessHeaders(suspendedAccessHeaders); } } finally { abort.abort(); @@ -452,6 +570,7 @@ export const authenticateCloudflareAccess = DesktopIpc.makeIpcMethod({ const cookieValue = yield* captureCloudflareAccessCookie({ origin, parent: Option.getOrUndefined(parent), + session: Electron.session.defaultSession, createWindow: electronWindow.create, }); return { cookieValue }; @@ -507,12 +626,21 @@ export const installCloudflareAccessCredentials = DesktopIpc.makeIpcMethod({ try: async () => { const session = Electron.session.defaultSession; const cookieValue = input.cookieValue?.trim() ?? ""; - if (input.clearCookies === true || cookieValue.length > 0) { - await clearAccessCookies(session, origin); - } - configureCloudflareAccessHeaders(session, origin, input.headers); - if (cookieValue.length > 0) { - await installAccessCookie(session, origin, cookieValue); + const installingCookie = cookieValue.length > 0; + const suspendedAccessHeaders = installingCookie + ? suspendCloudflareAccessHeaders(origin) + : undefined; + try { + if (input.clearCookies === true || installingCookie) { + await clearAccessCookies(session, origin); + } + if (installingCookie) { + await installAccessCookie(session, origin, cookieValue); + } + configureCloudflareAccessHeaders(session, origin, input.headers, cookieValue); + } catch (cause) { + restoreCloudflareAccessHeaders(suspendedAccessHeaders); + throw cause; } }, catch: (cause) => @@ -526,3 +654,13 @@ export const installCloudflareAccessCredentials = DesktopIpc.makeIpcMethod({ }); }), }); + +export const __testing = { + resetCloudflareAccessHeaders: () => { + cloudflareAccessHeaderRules.clear(); + cloudflareAccessHeaderRuleVersions.clear(); + cloudflareAccessHeaderHookInstalled = false; + }, + restoreCloudflareAccessHeaders, + suspendCloudflareAccessHeaders, +}; diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index f315b8da1de..2ed964f62dc 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -255,6 +255,58 @@ describe("connection onboarding", () => { }), ); + it.effect("installs a captured desktop Access cookie before pairing requests", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const installedHeaders: Array<{ + readonly httpBaseUrl: string; + readonly headers: Readonly>; + readonly clearCookies?: boolean; + readonly cookieValue?: string; + }> = []; + let requestCountAtInstall: number | undefined; + const installerLayer = Layer.succeed( + CloudflareAccessCookieInstaller, + CloudflareAccessCookieInstaller.of({ + supportsRequestHeaders: true, + install: () => Effect.void, + installRequestHeaders: (input) => + Effect.sync(() => { + requestCountAtInstall = calls.length; + installedHeaders.push(input); + }), + }), + ); + + yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + cloudflareAccessCookie: "cf-access-cookie", + }).pipe(Effect.provide(pairingTestLayer(calls, undefined, installerLayer))); + + expect(installedHeaders).toEqual([ + { + httpBaseUrl: "https://remote.example.test/", + headers: { + "cf-access-jwt-assertion": "cf-access-cookie", + cookie: "CF_Authorization=cf-access-cookie", + }, + clearCookies: true, + cookieValue: "cf-access-cookie", + }, + ]); + expect(requestCountAtInstall).toBe(0); + for (const call of calls) { + expect(call.init.headers).toEqual( + expect.objectContaining({ + "cf-access-jwt-assertion": "cf-access-cookie", + cookie: "CF_Authorization=cf-access-cookie", + }), + ); + } + }), + ); + it.effect("persists Cloudflare Access service-token credentials from pairing urls", () => Effect.gen(function* () { enableHeaderCapableWebSocketRuntime();