Enable WebAuthn in browser sessions

This commit is contained in:
Neil 2026-06-02 15:18:59 -07:00 committed by GitHub
parent 9325b0cbb0
commit 81a8ef2f42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 266 additions and 0 deletions

View File

@ -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(),

View File

@ -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()

View File

@ -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)

View File

@ -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)
}

View File

@ -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()

View File

@ -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