Enable WebAuthn in browser sessions
This commit is contained in:
parent
9325b0cbb0
commit
81a8ef2f42
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue