From 81a8ef2f423f8fc12d57332fb5f5841efc6369f3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:18:59 -0700 Subject: [PATCH] Enable WebAuthn in browser sessions --- ...owser-session-registry.persistence.test.ts | 1 + .../browser/browser-session-registry.test.ts | 61 +++++++++++ src/main/browser/browser-session-registry.ts | 10 ++ src/main/browser/browser-webauthn-access.ts | 85 +++++++++++++++ .../attach-main-window-services.test.ts | 101 ++++++++++++++++++ .../window/attach-main-window-services.ts | 8 ++ 6 files changed, 266 insertions(+) create mode 100644 src/main/browser/browser-webauthn-access.ts diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 346d08132..311f52fdc 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -31,6 +31,7 @@ function installModuleMocks( getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'), setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), + setDevicePermissionHandler: vi.fn(), setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), removeListener: vi.fn(), diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index d49995eed..81123befc 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -39,6 +39,7 @@ describe('BrowserSessionRegistry', () => { sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), + setDevicePermissionHandler: vi.fn(), setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), removeListener: vi.fn(), @@ -157,6 +158,7 @@ describe('BrowserSessionRegistry', () => { expect(mockSession.removeListener).toHaveBeenCalledWith('will-download', downloadHandler) expect(mockSession.setPermissionRequestHandler).toHaveBeenLastCalledWith(null) expect(mockSession.setPermissionCheckHandler).toHaveBeenLastCalledWith(null) + expect(mockSession.setDevicePermissionHandler).toHaveBeenLastCalledWith(null) expect(mockSession.setDisplayMediaRequestHandler).toHaveBeenLastCalledWith(null) }) @@ -185,6 +187,7 @@ describe('BrowserSessionRegistry', () => { const mockSession = sessionFromPartitionMock.mock.results[0]?.value expect(mockSession?.setPermissionRequestHandler).toHaveBeenCalled() expect(mockSession?.setPermissionCheckHandler).toHaveBeenCalled() + expect(mockSession?.setDevicePermissionHandler).toHaveBeenCalled() }) it('routes media permission requests through macOS TCC for isolated partitions', async () => { @@ -206,6 +209,64 @@ describe('BrowserSessionRegistry', () => { expect(checkHandler(null, 'notifications', '', {})).toBe(false) }) + it('wires WebAuthn device selection for isolated partitions', () => { + browserSessionRegistry.createProfile('isolated', 'Security Key Test') + const mockSession = sessionFromPartitionMock.mock.results[0]?.value + const devicePermissionHandler = mockSession.setDevicePermissionHandler.mock.calls[0][0] + const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0] + + expect( + devicePermissionHandler({ + deviceType: 'hid', + origin: 'https://github.com', + device: { collections: [{ usagePage: 0xf1d0 }] } + }) + ).toBe(true) + expect( + devicePermissionHandler({ + deviceType: 'hid', + origin: 'http://[::1]:5173', + device: { collections: [{ usagePage: 0xf1d0 }] } + }) + ).toBe(true) + expect( + devicePermissionHandler({ + deviceType: 'hid', + origin: 'https://github.com', + device: { collections: [{ usagePage: 1 }] } + }) + ).toBe(false) + expect(checkHandler(null, 'hid', '', { securityOrigin: 'https://github.com' })).toBe(true) + + const selectHidHandler = mockSession.on.mock.calls.find( + ([eventName]) => eventName === 'select-hid-device' + )?.[1] + const hidCallback = vi.fn() + selectHidHandler( + { preventDefault: vi.fn() }, + { + frame: { url: 'https://github.com' }, + deviceList: [ + { deviceId: 'keyboard', collections: [{ usagePage: 1 }] }, + { deviceId: 'security-key', collections: [{ usagePage: 0xf1d0 }] } + ] + }, + hidCallback + ) + expect(hidCallback).toHaveBeenCalledWith('security-key') + + const selectWebAuthnHandler = mockSession.on.mock.calls.find( + ([eventName]) => eventName === 'select-webauthn-account' + )?.[1] + const webAuthnCallback = vi.fn() + selectWebAuthnHandler( + { preventDefault: vi.fn() }, + { accounts: [{ credentialId: 'credential-1' }] }, + webAuthnCallback + ) + expect(webAuthnCallback).toHaveBeenCalledWith('credential-1') + }) + describe('setupClientHintsOverride', () => { it('overrides sec-ch-ua headers for Edge UA', () => { const onBeforeSendHeaders = vi.fn() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 857c5dc3c..2185ff80d 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -20,6 +20,11 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../sh import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { + allowsBrowserWebAuthnPermission, + clearBrowserWebAuthnAccessHandlers, + installBrowserWebAuthnAccessHandlers +} from './browser-webauthn-access' type BrowserSessionMeta = { defaultSource: BrowserSessionProfile['source'] @@ -533,8 +538,12 @@ class BrowserSessionRegistry { if (permission === 'media') { return hasSystemMediaAccess(details?.mediaType) } + if (allowsBrowserWebAuthnPermission(permission, details)) { + return true + } return autoGranted.has(permission) }) + installBrowserWebAuthnAccessHandlers(sess) sess.setDisplayMediaRequestHandler((_request, callback) => { callback({ video: undefined, audio: undefined }) }) @@ -548,6 +557,7 @@ class BrowserSessionRegistry { // bookkeeping so removed profiles do not leave retained closures behind. this.configuredPartitions.delete(partition) sess.removeListener('will-download', this.handleWillDownload) + clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) sess.setDisplayMediaRequestHandler(null) diff --git a/src/main/browser/browser-webauthn-access.ts b/src/main/browser/browser-webauthn-access.ts new file mode 100644 index 000000000..4f04065e3 --- /dev/null +++ b/src/main/browser/browser-webauthn-access.ts @@ -0,0 +1,85 @@ +import type { Session } from 'electron' + +const FIDO_HID_USAGE_PAGE = 0xf1d0 +const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) + +function isSecureBrowserOrigin(rawOrigin: string | undefined): boolean { + if (!rawOrigin) { + return false + } + try { + const origin = new URL(rawOrigin) + return origin.protocol === 'https:' || LOCALHOST_HOSTNAMES.has(origin.hostname) + } catch { + return false + } +} + +function isFidoHidDevice(device: Electron.HIDDevice | unknown): device is Electron.HIDDevice { + if (!device || typeof device !== 'object') { + return false + } + const collections = (device as { collections?: unknown }).collections + return ( + Array.isArray(collections) && + collections.some((collection) => { + return ( + collection && + typeof collection === 'object' && + (collection as { usagePage?: unknown }).usagePage === FIDO_HID_USAGE_PAGE + ) + }) + ) +} + +export function allowsBrowserWebAuthnPermission( + permission: string, + details?: { securityOrigin?: string } +): boolean { + return permission === 'hid' && isSecureBrowserOrigin(details?.securityOrigin) +} + +function handleBrowserSelectHidDevice( + event: Electron.Event, + details: Electron.SelectHidDeviceDetails, + callback: (deviceId?: string) => void +): void { + event.preventDefault() + if (!isSecureBrowserOrigin(details.frame?.url)) { + callback(undefined) + return + } + const selectedDevice = details.deviceList.find(isFidoHidDevice) + callback(selectedDevice?.deviceId) +} + +function handleBrowserSelectWebAuthnAccount( + event: Electron.Event, + details: Electron.SelectWebauthnAccountDetails, + callback: (credentialId?: string | null) => void +): void { + event.preventDefault() + // Why: Electron cancels discoverable WebAuthn when no listener exists. Pick + // only the unambiguous single-account case until Orca has account-picker UI. + callback(details.accounts.length === 1 ? details.accounts[0].credentialId : null) +} + +export function installBrowserWebAuthnAccessHandlers(browserSession: Session): void { + browserSession.setDevicePermissionHandler((details) => { + return ( + details.deviceType === 'hid' && + isSecureBrowserOrigin(details.origin) && + isFidoHidDevice(details.device) + ) + }) + browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice) + browserSession.on('select-hid-device', handleBrowserSelectHidDevice) + browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount) + browserSession.on('select-webauthn-account', handleBrowserSelectWebAuthnAccount) +} + +export function clearBrowserWebAuthnAccessHandlers(browserSession: Session): void { + browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice) + browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount) + browserSession.setDevicePermissionHandler(null) +} diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 0dcd5b28b..f2870ac5f 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -7,6 +7,7 @@ const { removeListenerMock, setPermissionRequestHandlerMock, setPermissionCheckHandlerMock, + setDevicePermissionHandlerMock, setDisplayMediaRequestHandlerMock, handleMock, removeHandlerMock, @@ -26,6 +27,7 @@ const { removeListenerMock: vi.fn(), setPermissionRequestHandlerMock: vi.fn(), setPermissionCheckHandlerMock: vi.fn(), + setDevicePermissionHandlerMock: vi.fn(), setDisplayMediaRequestHandlerMock: vi.fn(), handleMock: vi.fn(), removeHandlerMock: vi.fn(), @@ -168,6 +170,7 @@ describe('attachMainWindowServices', () => { removeHandlerMock.mockReset() setPermissionRequestHandlerMock.mockReset() setPermissionCheckHandlerMock.mockReset() + setDevicePermissionHandlerMock.mockReset() setDisplayMediaRequestHandlerMock.mockReset() systemPreferencesAskForMediaAccessMock.mockReset() systemPreferencesGetMediaAccessStatusMock.mockReset() @@ -182,6 +185,7 @@ describe('attachMainWindowServices', () => { sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDevicePermissionHandler: setDevicePermissionHandlerMock, setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, on: vi.fn(), removeListener: vi.fn() @@ -370,6 +374,7 @@ describe('attachMainWindowServices', () => { sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDevicePermissionHandler: setDevicePermissionHandlerMock, setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, on: browserSessionOnMock, removeListener: vi.fn() @@ -434,12 +439,107 @@ describe('attachMainWindowServices', () => { }) }) + it('wires browser-session WebAuthn device selection for security keys', () => { + const browserSessionOnMock = vi.fn() + sessionFromPartitionMock.mockReturnValue({ + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDevicePermissionHandler: setDevicePermissionHandlerMock, + setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, + on: browserSessionOnMock, + removeListener: vi.fn() + }) + + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) + + expect(setDevicePermissionHandlerMock).toHaveBeenCalledWith(expect.any(Function)) + const devicePermissionHandler = setDevicePermissionHandlerMock.mock.calls[0][0] as (details: { + deviceType: string + origin: string + device: { collections?: { usagePage?: number }[] } + }) => boolean + expect( + devicePermissionHandler({ + deviceType: 'hid', + origin: 'https://github.com', + device: { collections: [{ usagePage: 0xf1d0 }] } + }) + ).toBe(true) + expect( + devicePermissionHandler({ + deviceType: 'hid', + origin: 'http://[::1]:5173', + device: { collections: [{ usagePage: 0xf1d0 }] } + }) + ).toBe(true) + expect( + devicePermissionHandler({ + deviceType: 'hid', + origin: 'https://github.com', + device: { collections: [{ usagePage: 1 }] } + }) + ).toBe(false) + + const browserCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as ( + wc: unknown, + permission: string, + origin: string, + details?: { securityOrigin?: string } + ) => boolean + expect(browserCheckHandler(null, 'hid', '', { securityOrigin: 'https://github.com' })).toBe( + true + ) + + const selectHidHandler = browserSessionOnMock.mock.calls.find( + ([eventName]) => eventName === 'select-hid-device' + )?.[1] as ( + event: { preventDefault: () => void }, + details: { + deviceList: { deviceId: string; collections?: { usagePage?: number }[] }[] + frame: { url: string } + }, + callback: (deviceId?: string) => void + ) => void + const preventDefault = vi.fn() + const callback = vi.fn() + selectHidHandler( + { preventDefault }, + { + frame: { url: 'https://github.com' }, + deviceList: [ + { deviceId: 'keyboard', collections: [{ usagePage: 1 }] }, + { deviceId: 'security-key', collections: [{ usagePage: 0xf1d0 }] } + ] + }, + callback + ) + + expect(preventDefault).toHaveBeenCalled() + expect(callback).toHaveBeenCalledWith('security-key') + + const selectWebAuthnHandler = browserSessionOnMock.mock.calls.find( + ([eventName]) => eventName === 'select-webauthn-account' + )?.[1] as ( + event: { preventDefault: () => void }, + details: { accounts: { credentialId: string }[] }, + callback: (credentialId?: string | null) => void + ) => void + const webAuthnCallback = vi.fn() + selectWebAuthnHandler( + { preventDefault: vi.fn() }, + { accounts: [{ credentialId: 'credential-1' }] }, + webAuthnCallback + ) + expect(webAuthnCallback).toHaveBeenCalledWith('credential-1') + }) + it('replaces the persistent browser-session download handler on re-attach', () => { const browserSessionOnMock = vi.fn() const browserSessionRemoveListenerMock = vi.fn() sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDevicePermissionHandler: setDevicePermissionHandlerMock, setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, on: browserSessionOnMock, removeListener: browserSessionRemoveListenerMock @@ -463,6 +563,7 @@ describe('attachMainWindowServices', () => { sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDevicePermissionHandler: setDevicePermissionHandlerMock, setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, on: vi.fn(), removeListener: vi.fn() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 72e109992..354f636f0 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -15,6 +15,10 @@ import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' +import { + allowsBrowserWebAuthnPermission, + installBrowserWebAuthnAccessHandlers +} from '../browser/browser-webauthn-access' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { checkForUpdatesFromMenu, @@ -196,8 +200,12 @@ export function attachMainWindowServices( if (permission === 'media') { return hasSystemMediaAccess(details?.mediaType) } + if (allowsBrowserWebAuthnPermission(permission, details)) { + return true + } return false }) + installBrowserWebAuthnAccessHandlers(browserSession) browserSession.setDisplayMediaRequestHandler((_request, callback) => { // Why: arbitrary sites inside Orca should never be able to capture the // desktop or application windows until there is explicit product UX for