diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 510198ad..eb0ce4b1 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -346,6 +346,7 @@ const CONNECT_LEVEL_FIELDS = new Set([ "enableTelnet", "sshPort", "rdpPort", + "rdpAuthType", "vncPort", "telnetPort", "defaultPath", diff --git a/src/backend/hosts/guacamole/rdp-settings.ts b/src/backend/hosts/guacamole/rdp-settings.ts index 63ada8e1..305c153c 100644 --- a/src/backend/hosts/guacamole/rdp-settings.ts +++ b/src/backend/hosts/guacamole/rdp-settings.ts @@ -34,3 +34,31 @@ export function resolveRdpDomain( ? promptedDomain : storedDomain; } + +type SharedRdpAuthResolution = + | { source: "personal-override" } + | { source: "owner-shared"; authType: string } + | { source: "secretless" } + | { source: "required" } + | null; + +export function resolveRdpAuthTypeForConnect({ + storedAuthType, + credentialId, + sharedResolution, +}: { + storedAuthType?: string | null; + credentialId?: number | null; + sharedResolution?: SharedRdpAuthResolution; +}): string { + if (sharedResolution === undefined) { + return storedAuthType || (credentialId ? "credential" : "direct"); + } + if (sharedResolution?.source === "personal-override") return "credential"; + if (sharedResolution?.source === "owner-shared") { + return sharedResolution.authType; + } + return sharedResolution?.source === "secretless" && storedAuthType === "none" + ? "none" + : "direct"; +} diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index e5374eda..c8634d90 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -6,7 +6,10 @@ import { withDriveSettings } from "./drive-settings.js"; import { guacLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; -import { resolveRecipientSharedHostAuthentication } from "../../utils/shared-host-auth-resolver.js"; +import { + resolveRecipientSharedHostAuthentication, + type RecipientSharedHostAuthResolution, +} from "../../utils/shared-host-auth-resolver.js"; import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js"; import net from "net"; import crypto from "crypto"; @@ -25,7 +28,11 @@ import { getRequestMeta, } from "../../utils/audit-logger.js"; import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js"; -import { buildRdpSettings, resolveRdpDomain } from "./rdp-settings.js"; +import { + buildRdpSettings, + resolveRdpAuthTypeForConnect, + resolveRdpDomain, +} from "./rdp-settings.js"; import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js"; const router = express.Router(); @@ -322,6 +329,7 @@ router.post( const hostRecord = host as Record; const hostRepository = hostResolutionRepository; const isSharedConnection = host.userId !== userId; + let sharedAuthResolution: RecipientSharedHostAuthResolution | null = null; if (isSharedConnection) { // Recipients never read the owner's raw secrets; wipe them and use @@ -341,6 +349,7 @@ router.post( userId, connectionType as AuthOverrideProtocol, ); + sharedAuthResolution = resolution; const auth = resolution.source === "personal-override" ? { ...resolution.credential, domain: null } @@ -448,10 +457,11 @@ router.post( let username: string; let password: string; - const rdpAuthTypeForConnect = isSharedConnection - ? null - : (host.rdpAuthType as string) || - (host.rdpCredentialId ? "credential" : "direct"); + const rdpAuthTypeForConnect = resolveRdpAuthTypeForConnect({ + storedAuthType: host.rdpAuthType as string | null, + credentialId: host.rdpCredentialId as number | null, + sharedResolution: isSharedConnection ? sharedAuthResolution : undefined, + }); switch (connectionType) { case "rdp": diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts index c489cc45..50939dbc 100644 --- a/src/backend/tests/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -394,6 +394,7 @@ describe("sanitizeHostForRecipient", () => { { ...sharedHost, permissionLevel: "connect", + rdpAuthType: "none", authOverrides: { ssh: { credentialId: 9, @@ -408,6 +409,7 @@ describe("sanitizeHostForRecipient", () => { expect(result.ip).toBe("10.0.0.42"); expect(result.enableRdp).toBe(true); expect(result.rdpPort).toBe(3389); + expect(result.rdpAuthType).toBe("none"); expect(result.permissionLevel).toBe("connect"); expect(result.shareSshAuth).toBe(true); expect(result.authOverrides).toEqual({ diff --git a/src/backend/tests/hosts/guacamole/rdp-settings.test.ts b/src/backend/tests/hosts/guacamole/rdp-settings.test.ts index 193c0414..d945a839 100644 --- a/src/backend/tests/hosts/guacamole/rdp-settings.test.ts +++ b/src/backend/tests/hosts/guacamole/rdp-settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildRdpSettings, + resolveRdpAuthTypeForConnect, resolveRdpDomain, } from "../../../hosts/guacamole/rdp-settings.js"; @@ -52,3 +53,32 @@ describe("buildRdpSettings", () => { expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED"); }); }); + +describe("resolveRdpAuthTypeForConnect", () => { + it("keeps prompt-on-connect authentication for a secretless recipient", () => { + expect( + resolveRdpAuthTypeForConnect({ + storedAuthType: "none", + sharedResolution: { source: "secretless" }, + }), + ).toBe("none"); + }); + + it("uses a recipient override instead of prompting", () => { + expect( + resolveRdpAuthTypeForConnect({ + storedAuthType: "none", + sharedResolution: { source: "personal-override" }, + }), + ).toBe("credential"); + }); + + it("uses authentication shared by the owner", () => { + expect( + resolveRdpAuthTypeForConnect({ + storedAuthType: "credential", + sharedResolution: { source: "owner-shared", authType: "credential" }, + }), + ).toBe("credential"); + }); +}); diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 223dcfa0..eb6f2758 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -46,6 +46,7 @@ import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal. import type { SSHHost } from "@/types"; import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; import { resolveConnectionDefaults } from "@/lib/connection-defaults"; +import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt"; interface GuacamoleAppProps { hostId?: string; @@ -162,6 +163,7 @@ interface GuacamoleAppInnerProps { | "domain" | "guacamoleConfig" | "rdpAuthType" + | "authOverrides" | "syncId" | "ip" | "rdpPort" @@ -230,8 +232,11 @@ const GuacamoleAppInner = React.forwardRef< const resolvedProtocolForConnect = (protocol ?? hostConfig.connectionType ?? "rdp") as "rdp" | "vnc" | "telnet"; - const needsCredentialPrompt = - resolvedProtocolForConnect === "rdp" && hostConfig.rdpAuthType === "none"; + const needsCredentialPrompt = needsRdpCredentialPrompt({ + protocol: resolvedProtocolForConnect, + rdpAuthType: hostConfig.rdpAuthType, + authOverrides: hostConfig.authOverrides, + }); const [promptedCredentials, setPromptedCredentials] = useState<{ username: string; diff --git a/src/ui/features/guacamole/rdp-credential-prompt.ts b/src/ui/features/guacamole/rdp-credential-prompt.ts new file mode 100644 index 00000000..746e5ffa --- /dev/null +++ b/src/ui/features/guacamole/rdp-credential-prompt.ts @@ -0,0 +1,17 @@ +import type { HostAuthOverrides } from "@/types/auth-protocols"; + +export function needsRdpCredentialPrompt({ + protocol, + rdpAuthType, + authOverrides, +}: { + protocol: "rdp" | "vnc" | "telnet"; + rdpAuthType?: string; + authOverrides?: HostAuthOverrides; +}): boolean { + return ( + protocol === "rdp" && + rdpAuthType === "none" && + !authOverrides?.rdp?.credentialId + ); +} diff --git a/src/ui/tests/features/guacamole/rdp-credential-prompt.test.ts b/src/ui/tests/features/guacamole/rdp-credential-prompt.test.ts new file mode 100644 index 00000000..ff892683 --- /dev/null +++ b/src/ui/tests/features/guacamole/rdp-credential-prompt.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt"; + +describe("needsRdpCredentialPrompt", () => { + it("prompts recipients when RDP has no saved authentication", () => { + expect( + needsRdpCredentialPrompt({ protocol: "rdp", rdpAuthType: "none" }), + ).toBe(true); + }); + + it("does not prompt when the recipient has a personal override", () => { + expect( + needsRdpCredentialPrompt({ + protocol: "rdp", + rdpAuthType: "none", + authOverrides: { + rdp: { credentialId: 7, required: false, ownerAuthShared: true }, + }, + }), + ).toBe(false); + }); + + it("does not prompt for other protocols", () => { + expect( + needsRdpCredentialPrompt({ protocol: "vnc", rdpAuthType: "none" }), + ).toBe(false); + }); +});