From 1ce9f43224563b8442c7856bfc1f8a22a7a86e87 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:46:42 +0100 Subject: [PATCH] fix desktop cloudflare access remotes --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + .../src/ipc/methods/cloudflareAccess.test.ts | 170 ++++++++++++++- .../src/ipc/methods/cloudflareAccess.ts | 194 ++++++++++++++++++ apps/desktop/src/preload.ts | 2 + .../ConnectionsSettings.logic.test.ts | 83 +++++++- .../settings/ConnectionsSettings.logic.ts | 127 ++++++++++++ .../settings/ConnectionsSettings.tsx | 119 +++++------ apps/web/src/connection/onboarding.ts | 14 +- apps/web/src/connection/platform.ts | 48 +++++ .../src/connection/onboarding.test.ts | 183 ++++++++++++++--- .../src/connection/onboarding.ts | 85 +++++++- .../src/connection/resolver.test.ts | 150 ++++++++++++++ .../client-runtime/src/connection/resolver.ts | 81 +++++++- .../src/platform/capabilities.ts | 8 + packages/contracts/src/ipc.ts | 18 ++ packages/shared/src/remote.test.ts | 19 ++ packages/shared/src/remote.ts | 36 +++- 18 files changed, 1229 insertions(+), 112 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 7f586d7a437..ee428057590 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -5,6 +5,7 @@ import { getClientSettings, setClientSettings } from "./methods/clientSettings.t import { authenticateCloudflareAccess, installCloudflareAccessCookie, + installCloudflareAccessCredentials, } from "./methods/cloudflareAccess.ts"; import { clearConnectionCatalog, @@ -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); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 14c0e89a666..b31d658cf47 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -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"; diff --git a/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts b/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts index f5ac8479055..c75088d8454 100644 --- a/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts +++ b/apps/desktop/src/ipc/methods/cloudflareAccess.test.ts @@ -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(() => ({ @@ -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, }, }, })); @@ -42,6 +50,8 @@ function makeAuthWindow() { session: { cookies: electronMock.cookies, }, + on: vi.fn(), + removeListener: vi.fn(), }, once: vi.fn(), removeListener: vi.fn(), @@ -51,6 +61,18 @@ function makeAuthWindow() { }; } +function emitWebContentsEvent( + authWindow: ReturnType, + 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), @@ -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 = { + "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 = [ @@ -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(); diff --git a/apps/desktop/src/ipc/methods/cloudflareAccess.ts b/apps/desktop/src/ipc/methods/cloudflareAccess.ts index e90ad01b4b5..2b53598acb8 100644 --- a/apps/desktop/src/ipc/methods/cloudflareAccess.ts +++ b/apps/desktop/src/ipc/methods/cloudflareAccess.ts @@ -1,5 +1,6 @@ import { DesktopCloudflareAccessCookieInstallInputSchema, + DesktopCloudflareAccessCredentialsInstallInputSchema, DesktopCloudflareAccessLoginInputSchema, DesktopCloudflareAccessLoginResultSchema, } from "@t3tools/contracts"; @@ -15,8 +16,18 @@ import * as DesktopIpc from "../DesktopIpc.ts"; import { normalizeCloudflareAccessOrigin } from "./cloudflareAccessOrigin.ts"; const CLOUDFLARE_ACCESS_COOKIE_NAME = "CF_Authorization"; +const CLOUDFLARE_ACCESS_TRANSPORT_HEADER_NAMES = [ + "cf-access-client-id", + "cf-access-client-secret", + "cf-access-jwt-assertion", +] as const; +const CLOUDFLARE_ACCESS_TRANSPORT_HEADER_NAME_SET = new Set( + CLOUDFLARE_ACCESS_TRANSPORT_HEADER_NAMES, +); const ACCESS_LOGIN_TIMEOUT_MS = 5 * 60 * 1000; const COOKIE_POLL_INTERVAL_MS = 500; +const cloudflareAccessHeaderRules = new Map>>(); +let cloudflareAccessHeaderHookInstalled = false; export class DesktopCloudflareAccessLoginError extends Schema.TaggedErrorClass()( "DesktopCloudflareAccessLoginError", @@ -145,6 +156,136 @@ function installAccessCookie( }); } +function isCloudflareAccessLoginUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + (url.hostname === "cloudflareaccess.com" || url.hostname.endsWith(".cloudflareaccess.com")) && + url.pathname.startsWith("/cdn-cgi/access/login") + ); + } catch { + return false; + } +} + +function cloudflareAccessRuleOrigin(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + return url.origin; + } catch { + return null; + } +} + +function filteredHeaders( + headers: Readonly>, +): Readonly> { + const normalizedHeaders = new Map(); + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = rawName.toLowerCase(); + if (!CLOUDFLARE_ACCESS_TRANSPORT_HEADER_NAME_SET.has(name)) { + continue; + } + const value = rawValue?.trim() ?? ""; + if (value.length > 0) { + normalizedHeaders.set(name, value); + } + } + return Object.fromEntries( + CLOUDFLARE_ACCESS_TRANSPORT_HEADER_NAMES.flatMap((name) => { + const value = normalizedHeaders.get(name); + return value === undefined ? [] : [[name, value] as const]; + }), + ); +} + +function installCloudflareAccessHeaderHook(session: Electron.Session) { + if (cloudflareAccessHeaderHookInstalled) { + return; + } + cloudflareAccessHeaderHookInstalled = true; + session.webRequest.onBeforeSendHeaders((details, callback) => { + const origin = cloudflareAccessRuleOrigin(details.url); + const headers = origin === null ? undefined : cloudflareAccessHeaderRules.get(origin); + if (headers === undefined || Object.keys(headers).length === 0) { + callback({ requestHeaders: details.requestHeaders }); + return; + } + callback({ + requestHeaders: { + ...details.requestHeaders, + ...headers, + }, + }); + }); +} + +function configureCloudflareAccessHeaders( + session: Electron.Session, + origin: string, + headers: Readonly>, +) { + const requestOriginKey = cloudflareAccessRuleOrigin(origin); + if (requestOriginKey === null) { + return; + } + const nextHeaders = filteredHeaders(headers); + if (Object.keys(nextHeaders).length === 0) { + cloudflareAccessHeaderRules.delete(requestOriginKey); + return; + } + cloudflareAccessHeaderRules.set(requestOriginKey, nextHeaders); + installCloudflareAccessHeaderHook(session); +} + +function errorText(cause: unknown): string { + if (cause instanceof Error) { + return `${cause.name} ${cause.message}`; + } + return String(cause); +} + +function errorField(cause: unknown, field: string): string { + if (cause === null || typeof cause !== "object" || !(field in cause)) { + return ""; + } + const value = (cause as Record)[field]; + return typeof value === "string" || typeof value === "number" ? String(value) : ""; +} + +function isLoadUrlRedirectInterruption( + cause: unknown, + observedCloudflareAccessLoginNavigation: boolean, +): boolean { + const text = errorText(cause); + const code = errorField(cause, "code"); + const errno = errorField(cause, "errno"); + const errorCode = errorField(cause, "errorCode"); + const combined = `${code} ${errno} ${errorCode} ${text}`; + if ( + combined.includes("ERR_ABORTED") || + errno === "-3" || + errorCode === "-3" || + /(^|[^\d])-3([^\d]|$)/u.test(text) + ) { + return true; + } + if ( + combined.includes("ERR_HTTP_RESPONSE_CODE_FAILURE") && + (observedCloudflareAccessLoginNavigation || + text.includes("cloudflareaccess.com/cdn-cgi/access/login")) + ) { + return true; + } + return false; +} + function captureCloudflareAccessCookie(options: { readonly origin: string; readonly parent: Electron.BrowserWindow | undefined; @@ -172,8 +313,17 @@ function captureCloudflareAccessCookie(options: { const session = authWindow.webContents.session; const abort = new AbortController(); let closeHandler: (() => void) | undefined; + let observedCloudflareAccessLoginNavigation = false; let capturedReplacementCookie = false; let previousAccessCookies: ReadonlyArray = []; + const observeNavigation = (_event: Electron.Event, navigationUrl: string) => { + if (isCloudflareAccessLoginUrl(navigationUrl)) { + observedCloudflareAccessLoginNavigation = true; + } + }; + authWindow.webContents.on("did-start-navigation", observeNavigation); + authWindow.webContents.on("will-redirect", observeNavigation); + authWindow.webContents.on("did-redirect-navigation", observeNavigation); try { previousAccessCookies = (await readAccessCookies(session, options.origin)).filter( @@ -233,6 +383,9 @@ function captureCloudflareAccessCookie(options: { const waitForLoadFailure = authWindow.loadURL(options.origin).then( () => new Promise(() => {}), (cause: unknown) => { + if (isLoadUrlRedirectInterruption(cause, observedCloudflareAccessLoginNavigation)) { + return new Promise(() => {}); + } throw new DesktopCloudflareAccessLoginError({ reason: "authentication", detail: "Could not open the Cloudflare Access sign-in page.", @@ -255,6 +408,9 @@ function captureCloudflareAccessCookie(options: { } } finally { abort.abort(); + authWindow.webContents.removeListener("did-start-navigation", observeNavigation); + authWindow.webContents.removeListener("will-redirect", observeNavigation); + authWindow.webContents.removeListener("did-redirect-navigation", observeNavigation); if (closeHandler !== undefined) { authWindow.removeListener("closed", closeHandler); } @@ -332,3 +488,41 @@ export const installCloudflareAccessCookie = DesktopIpc.makeIpcMethod({ }); }), }); + +export const installCloudflareAccessCredentials = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.INSTALL_CLOUDFLARE_ACCESS_CREDENTIALS_CHANNEL, + payload: DesktopCloudflareAccessCredentialsInstallInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.cloudflareAccess.installCredentials")(function* (input) { + const origin = yield* Effect.try({ + try: () => normalizeCloudflareAccessOrigin(input.host), + catch: (cause) => + new DesktopCloudflareAccessLoginError({ + reason: "configuration", + detail: "Enter a valid remote host before installing Cloudflare Access credentials.", + cause, + }), + }); + yield* Effect.tryPromise({ + 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); + } + }, + catch: (cause) => + isDesktopCloudflareAccessLoginError(cause) + ? cause + : new DesktopCloudflareAccessLoginError({ + reason: "authentication", + detail: "Could not install the Cloudflare Access credentials.", + cause, + }), + }); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 96e687d73c0..2140a46f840 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -55,6 +55,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.AUTHENTICATE_CLOUDFLARE_ACCESS_CHANNEL, input), installCloudflareAccessCookie: (input) => ipcRenderer.invoke(IpcChannels.INSTALL_CLOUDFLARE_ACCESS_COOKIE_CHANNEL, input), + installCloudflareAccessCredentials: (input) => + ipcRenderer.invoke(IpcChannels.INSTALL_CLOUDFLARE_ACCESS_CREDENTIALS_CHANNEL, input), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e6428..abdc5ec23ed 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,6 +1,11 @@ import type { DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + parsePairingUrlFields, + parseRemotePairingHostChange, + parseRemotePairingFields, +} from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { enabled: false, @@ -73,3 +78,79 @@ describe("applyWslEnableSelection", () => { expect(state).toMatchObject({ enabled: true, wslOnly: true }); }); }); + +describe("remote pairing field parsing", () => { + it("preserves Cloudflare Access service-token fields from pairing URL fragments", () => { + expect( + parsePairingUrlFields( + "https://remote.example.test/pair#token=pairing-token&cf_access_client_id=client-id&cf_access_client_secret=client-secret", + ), + ).toEqual({ + host: "https://remote.example.test/", + pairingCode: "pairing-token", + cloudflareAccessClientId: "client-id", + cloudflareAccessClientSecret: "client-secret", + }); + }); + + it("clears stale Cloudflare Access fields when host changes to a manual host", () => { + expect(parseRemotePairingHostChange("remote.example.test")).toEqual({ + host: "remote.example.test", + cloudflareAccessToken: "", + cloudflareAccessClientId: "", + cloudflareAccessClientSecret: "", + }); + }); + + it("repopulates Cloudflare Access fields when host changes to a pairing URL", () => { + expect( + parseRemotePairingHostChange( + "https://remote.example.test/pair#token=pairing-token&cf_access_client_id=client-id&cf_access_client_secret=client-secret", + ), + ).toEqual({ + host: "https://remote.example.test/", + pairingCode: "pairing-token", + cloudflareAccessToken: "", + cloudflareAccessClientId: "client-id", + cloudflareAccessClientSecret: "client-secret", + }); + }); + + it("preserves Cloudflare Access JWT fields from hosted pairing URL fragments", () => { + expect( + parsePairingUrlFields( + "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.test%3A443#token=pairing-token&cf_access_token=cf-jwt", + ), + ).toEqual({ + host: "https://desktop.tailnet.test/", + pairingCode: "pairing-token", + cloudflareAccessToken: "cf-jwt", + }); + }); + + it("passes explicit Cloudflare Access service-token fields with manual host pairing", () => { + expect( + parseRemotePairingFields({ + host: "remote.example.test", + pairingCode: "pairing-token", + cloudflareAccessClientId: " client-id ", + cloudflareAccessClientSecret: " client-secret ", + }), + ).toEqual({ + host: "remote.example.test", + pairingCode: "pairing-token", + cloudflareAccessClientId: "client-id", + cloudflareAccessClientSecret: "client-secret", + }); + }); + + it("rejects incomplete explicit Cloudflare Access service-token fields", () => { + expect(() => + parseRemotePairingFields({ + host: "remote.example.test", + pairingCode: "pairing-token", + cloudflareAccessClientId: "client-id", + }), + ).toThrowError("Enter both Cloudflare Access service token fields."); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff..bdf6708d7a4 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,7 +1,134 @@ import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; type WslEnableBridge = Pick; +export interface RemotePairingFields { + readonly host: string; + readonly pairingCode: string; + readonly cloudflareAccessToken?: string; + readonly cloudflareAccessClientId?: string; + readonly cloudflareAccessClientSecret?: string; +} + +export interface RemotePairingFieldInput { + readonly host: string; + readonly pairingCode: string; + readonly cloudflareAccessToken?: string; + readonly cloudflareAccessClientId?: string; + readonly cloudflareAccessClientSecret?: string; +} + +export interface RemotePairingHostChangeFields { + readonly host: string; + readonly pairingCode?: string; + readonly cloudflareAccessToken: string; + readonly cloudflareAccessClientId: string; + readonly cloudflareAccessClientSecret: string; +} + +const optionalTrimmed = (value: string | undefined): string | undefined => { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : undefined; +}; + +function explicitCloudflareAccessFields( + input: Pick< + RemotePairingFieldInput, + "cloudflareAccessToken" | "cloudflareAccessClientId" | "cloudflareAccessClientSecret" + >, +): Omit { + const cloudflareAccessToken = optionalTrimmed(input.cloudflareAccessToken); + const cloudflareAccessClientId = optionalTrimmed(input.cloudflareAccessClientId); + const cloudflareAccessClientSecret = optionalTrimmed(input.cloudflareAccessClientSecret); + if ( + (cloudflareAccessClientId !== undefined && cloudflareAccessClientSecret === undefined) || + (cloudflareAccessClientId === undefined && cloudflareAccessClientSecret !== undefined) + ) { + throw new Error("Enter both Cloudflare Access service token fields."); + } + return { + ...(cloudflareAccessToken ? { cloudflareAccessToken } : {}), + ...(cloudflareAccessClientId && cloudflareAccessClientSecret + ? { cloudflareAccessClientId, cloudflareAccessClientSecret } + : {}), + }; +} + +function currentOrigin(): string { + return typeof window === "undefined" ? "https://app.t3.codes" : window.location.origin; +} + +export function parsePairingUrlFields(input: string): RemotePairingFields | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const urlLikeInput = + /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") + ? trimmed + : `https://${trimmed}`; + const url = new URL(urlLikeInput, currentOrigin()); + const target = resolveRemotePairingTarget({ pairingUrl: url.toString() }); + return { + host: target.httpBaseUrl, + pairingCode: target.credential, + ...(target.cloudflareAccessToken + ? { cloudflareAccessToken: target.cloudflareAccessToken } + : {}), + ...(target.cloudflareAccessClientId && target.cloudflareAccessClientSecret + ? { + cloudflareAccessClientId: target.cloudflareAccessClientId, + cloudflareAccessClientSecret: target.cloudflareAccessClientSecret, + } + : {}), + }; + } catch { + return null; + } +} + +export function parseRemotePairingHostChange(input: string): RemotePairingHostChangeFields { + const parsedPairingUrl = parsePairingUrlFields(input); + if (parsedPairingUrl) { + return { + host: parsedPairingUrl.host, + pairingCode: parsedPairingUrl.pairingCode, + cloudflareAccessToken: parsedPairingUrl.cloudflareAccessToken ?? "", + cloudflareAccessClientId: parsedPairingUrl.cloudflareAccessClientId ?? "", + cloudflareAccessClientSecret: parsedPairingUrl.cloudflareAccessClientSecret ?? "", + }; + } + + return { + host: input, + cloudflareAccessToken: "", + cloudflareAccessClientId: "", + cloudflareAccessClientSecret: "", + }; +} + +export function parseRemotePairingFields(input: RemotePairingFieldInput): RemotePairingFields { + const cloudflareAccess = explicitCloudflareAccessFields(input); + const parsedPairingUrl = parsePairingUrlFields(input.host); + if (parsedPairingUrl) { + return { + ...parsedPairingUrl, + ...cloudflareAccess, + }; + } + + const host = input.host.trim(); + const pairingCode = input.pairingCode.trim(); + if (!host) { + throw new Error("Enter a backend host."); + } + if (!pairingCode) { + throw new Error("Enter a pairing code."); + } + return { host, pairingCode, ...cloudflareAccess }; +} + export async function applyWslEnableSelection(input: { readonly bridge: WslEnableBridge; readonly mode: "both" | "wsl-only"; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b5223af5faf..8f495c4db3e 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -49,7 +49,12 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + parsePairingUrlFields, + parseRemotePairingHostChange, + parseRemotePairingFields, +} from "./ConnectionsSettings.logic"; import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; import { SettingsPageContainer, @@ -102,8 +107,7 @@ import { MenuTrigger, } from "../ui/menu"; import { Textarea } from "../ui/textarea"; -import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; -import { readHostedPairingRequest } from "../../hostedPairing"; +import { setPairingTokenOnUrl } from "../../pairingUrl"; import { createServerPairingCredential, revokeOtherServerClientSessions, @@ -377,55 +381,6 @@ function parseManualDesktopSshTarget(input: { }; } -function parsePairingUrlFields( - input: string, -): { readonly host: string; readonly pairingCode: string } | null { - const trimmed = input.trim(); - if (!trimmed) return null; - - try { - const urlLikeInput = - /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") - ? trimmed - : `https://${trimmed}`; - const url = new URL(urlLikeInput, window.location.origin); - const hostedPairingRequest = readHostedPairingRequest(url); - if (hostedPairingRequest) { - return { - host: hostedPairingRequest.host, - pairingCode: hostedPairingRequest.token, - }; - } - - const pairingCode = getPairingTokenFromUrl(url); - if (!pairingCode) return null; - return { - host: url.origin, - pairingCode, - }; - } catch { - return null; - } -} - -function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): { - readonly host: string; - readonly pairingCode: string; -} { - const parsedPairingUrl = parsePairingUrlFields(input.host); - if (parsedPairingUrl) return parsedPairingUrl; - - const host = input.host.trim(); - const pairingCode = input.pairingCode.trim(); - if (!host) { - throw new Error("Enter a backend host."); - } - if (!pairingCode) { - throw new Error("Enter a pairing code."); - } - return { host, pairingCode }; -} - function formatDesktopSshConnectionError(error: unknown): string { const fallback = "Failed to connect SSH host."; const rawMessage = error instanceof Error ? error.message : fallback; @@ -2094,6 +2049,11 @@ export function ConnectionsSettings() { readonly host: string; readonly cookie: string; } | null>(null); + const [savedBackendCloudflareAccessToken, setSavedBackendCloudflareAccessToken] = useState(""); + const [savedBackendCloudflareAccessClientId, setSavedBackendCloudflareAccessClientId] = + useState(""); + const [savedBackendCloudflareAccessClientSecret, setSavedBackendCloudflareAccessClientSecret] = + useState(""); const [isAuthenticatingCloudflareAccess, setIsAuthenticatingCloudflareAccess] = useState(false); const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); @@ -2486,6 +2446,9 @@ export function ConnectionsSettings() { setSavedBackendHost(""); setSavedBackendPairingCode(""); setSavedBackendCloudflareAccess(null); + setSavedBackendCloudflareAccessToken(""); + setSavedBackendCloudflareAccessClientId(""); + setSavedBackendCloudflareAccessClientSecret(""); setSavedBackendSshHost(""); setSavedBackendSshUsername(""); setSavedBackendSshPort(""); @@ -2506,6 +2469,9 @@ export function ConnectionsSettings() { remotePairingInput = parseRemotePairingFields({ host: savedBackendHost, pairingCode: savedBackendPairingCode, + cloudflareAccessToken: savedBackendCloudflareAccessToken, + cloudflareAccessClientId: savedBackendCloudflareAccessClientId, + cloudflareAccessClientSecret: savedBackendCloudflareAccessClientSecret, }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to add backend."; @@ -2549,6 +2515,9 @@ export function ConnectionsSettings() { setSavedBackendHost(""); setSavedBackendPairingCode(""); setSavedBackendCloudflareAccess(null); + setSavedBackendCloudflareAccessToken(""); + setSavedBackendCloudflareAccessClientId(""); + setSavedBackendCloudflareAccessClientSecret(""); setSavedBackendSshHost(""); setSavedBackendSshUsername(""); setSavedBackendSshPort(""); @@ -2563,6 +2532,9 @@ export function ConnectionsSettings() { connectPairing, connectSshEnvironment, savedBackendCloudflareAccess, + savedBackendCloudflareAccessClientId, + savedBackendCloudflareAccessClientSecret, + savedBackendCloudflareAccessToken, savedBackendHost, savedBackendMode, savedBackendPairingCode, @@ -2698,14 +2670,15 @@ export function ConnectionsSettings() { [setDefaultAdvertisedEndpointKey], ); const handleSavedBackendHostChange = useCallback((value: string) => { + const hostChange = parseRemotePairingHostChange(value); setSavedBackendCloudflareAccess(null); - const parsedPairingUrl = parsePairingUrlFields(value); - if (parsedPairingUrl) { - setSavedBackendHost(parsedPairingUrl.host); - setSavedBackendPairingCode(parsedPairingUrl.pairingCode); - return; + setSavedBackendCloudflareAccessToken(hostChange.cloudflareAccessToken); + setSavedBackendCloudflareAccessClientId(hostChange.cloudflareAccessClientId); + setSavedBackendCloudflareAccessClientSecret(hostChange.cloudflareAccessClientSecret); + setSavedBackendHost(hostChange.host); + if (hostChange.pairingCode !== undefined) { + setSavedBackendPairingCode(hostChange.pairingCode); } - setSavedBackendHost(value); }, []); const renderConnectionModeCard = (input: { @@ -2779,6 +2752,36 @@ export function ConnectionsSettings() { Paste a full pairing URL here to fill both fields automatically. +
+ + Cloudflare Access service token + +
+ + +
+
{desktopBridge?.authenticateCloudflareAccess ? (