diff --git a/README.md b/README.md index 3f016ee10b1..a19edf33797 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,18 @@ yarn build:win yarn build:linux ``` +On macOS, desktop SSO uses `ASWebAuthenticationSession` through the optional `objc-js` bridge. Authentication runs in a supporting default browser, with Safari as fallback. Windows and Linux retain embedded SSO; the standalone webapp is unchanged. + +The macOS flow follows Wire iOS's existing backend contract: a validated callback returns a session cookie to the initiating account. Callback URLs and cookies must not be logged. This is not a single-use-code/PKCE exchange. + +macOS packaging unpacks and signs the native bridge using the existing app-signing identity. Browser SSO does not require the former WebAuthn keychain-group entitlement or additional provisioning-profile credentials. Both Jenkins jobs use the configured `node-v23.0.0` tool for the upgraded Electron runtime. + +Embedded SSO windows support website `window.prompt()` requests through a local text dialog. This covers passkey labels requested by Keycloak and other providers using the same browser API, without changing the identity provider's theme. The dialog shows the requesting origin, returns entered text on OK and `null` on cancellation, and closes when the requesting page navigates or closes. Prompt messages and entered values are not logged. This addresses prompt compatibility; each provider's complete login flow still needs testing. + +On Windows and Linux, SSO opens in an independent window using the shared persistent `persist:wire-sso` session. The backend completion callback is checked against the expected origin before copying its Wire login cookie into the requesting account. SSO website storage is cleared on close and before a new login, while passkey session preferences remain. Removing one sub-app/account does not remove this shared session. Reuse requires the same identity-provider account and relying-party ID; unrelated providers still need separate credentials. Deleting the entire desktop user-data directory or the credential in Keycloak is different. In a signed build, test registration, restart, account removal/re-addition, and login from another sub-app. + +To test authentication, launch the signed app with `--enable-logging` and search its `logs/YYYY-MM-DD/electron.log` (inside Electron's user-data directory) for `[Passkeys]`. For macOS browser SSO, search for `[SSO]` to follow session startup, callback validation failures, and cookie installation. Browser authentication does not use the embedded account picker. For embedded WebAuthn, account-picker logs show requests, selection, cancellation, or failure without account names or credential IDs. An account-selection event only occurs when the authenticator needs a choice: its absence does not prove WebAuthn failed. Complete login against the intended identity provider to verify the result. Build checks do not establish authenticator compatibility; test the signed app against the intended IdP. + ### Other Linux targets If you would like to build for another Linux target, run the following command: diff --git a/bin/build-tools/lib/build-macos.test.ts b/bin/build-tools/lib/build-macos.test.ts index f3f983438c1..9102a86822d 100644 --- a/bin/build-tools/lib/build-macos.test.ts +++ b/bin/build-tools/lib/build-macos.test.ts @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2019 Wire Swiss GmbH + * Copyright (C) 2026 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,12 +14,14 @@ * * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. + * */ import * as assert from 'assert'; import * as path from 'path'; import {buildMacOSConfig} from './build-macos'; + import {generateUUID} from '../../bin-utils'; const wireJsonPath = path.join(__dirname, '../../../electron/wire.json'); @@ -27,6 +29,47 @@ const envFilePath = path.join(__dirname, '../../../.env.defaults'); describe('build-macos', () => { describe('buildMacOSConfig', () => { + it('packages the browser authentication bridge for every macOS variant', async () => { + const original = {...process.env}; + try { + process.env.MACOS_CERTIFICATE_NAME_APPLICATION = ''; + process.env.ENABLE_ASAR = 'true'; + for (const environment of ['internal', 'production', 'wire-gov']) { + process.env.APP_ENV = environment; + const {packagerConfig} = await buildMacOSConfig(wireJsonPath, envFilePath); + assert.strictEqual((packagerConfig.asar as {unpack: string}).unpack, '**/*.node'); + assert.ok(packagerConfig.osxUniversal?.x64ArchFiles); + assert.strictEqual( + (packagerConfig.ignore as RegExp[]).some(pattern => pattern.test('/node_modules/objc-js/dist/index.js')), + false, + ); + for (const name of [ + 'certificate.cer', + 'identity.p12', + 'identity.pfx', + 'private.key', + 'profile.provisionprofile', + ]) { + assert.ok((packagerConfig.ignore as RegExp[]).some(pattern => pattern.test(`/resources/macos/${name}`))); + } + assert.ok( + !(packagerConfig.ignore as RegExp[]).some(pattern => + pattern.test('/resources/macos/entitlements/parent.plist'), + ), + ); + assert.strictEqual(packagerConfig.platform, 'mas'); + assert.ok((packagerConfig.extendInfo as Record).NSAudioCaptureUsageDescription); + } + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in original)) { + delete process.env[key]; + } + } + Object.assign(process.env, original); + } + }); + it('honors environment variables', async () => { const bundleId = generateUUID(); const certNameApplication = generateUUID(); diff --git a/bin/build-tools/lib/build-macos.ts b/bin/build-tools/lib/build-macos.ts index f0c6f45e01d..83c87540a7c 100755 --- a/bin/build-tools/lib/build-macos.ts +++ b/bin/build-tools/lib/build-macos.ts @@ -20,12 +20,17 @@ import {flatAsync as buildPkg} from '@electron/osx-sign'; import electronPackager, {ArchOption} from 'electron-packager'; import fs from 'fs-extra'; +import globby from 'globby'; + +import {execFile} from 'child_process'; import path from 'path'; +import {promisify} from 'util'; -import {backupFiles, execAsync, getLogger, restoreFiles} from '../../bin-utils'; import {flipElectronFuses, getCommonConfig} from './commonConfig'; import {CommonConfig, MacOSConfig} from './Config'; +import {backupFiles, execAsync, getLogger, restoreFiles} from '../../bin-utils'; + const libraryName = path.basename(__filename).replace('.ts', ''); const logger = getLogger('build-tools', libraryName); const mainDir = path.resolve(__dirname, '../../../'); @@ -45,6 +50,8 @@ export async function buildMacOSConfig( const envFileResolved = path.resolve(envFilePath); const plistInfoResolved = path.resolve('resources/macos/Info.plist.json'); const plistEntries = await fs.readJson(plistInfoResolved); + // Brand configuration can replace Info.plist.json during yarn configure. + plistEntries.NSAudioCaptureUsageDescription ||= 'Allow Wire to share system audio during screen sharing.'; const {commonConfig} = await getCommonConfig(envFileResolved, wireJsonResolved); const macOSDefaultConfig: MacOSConfig = { @@ -80,21 +87,32 @@ export async function buildMacOSConfig( appCopyright: commonConfig.copyright, appVersion: commonConfig.version, arch: architecture, - asar: commonConfig.enableAsar, + asar: commonConfig.enableAsar ? {unpack: '**/*.node'} : false, buildVersion: commonConfig.buildNumber, darwinDarkModeSupport: true, dir: '.', extendInfo: plistEntries, helperBundleId: `${macOSConfig.bundleId}.helper`, icon: 'resources/macos/logo.icns', - ignore: [/\/electron\/renderer\/src$/, /\/\.yarn$/, /\$electron\/src$/, /\/bin$/, /\/jenkins$/], + ignore: [ + /\/electron\/renderer\/src$/, + /\/\.yarn$/, + /\$electron\/src$/, + /\/bin$/, + /\/jenkins$/, + // Signing inputs are not runtime resources. + /\/resources\/macos\/.*\.(?:p12|pfx|cer|provisionprofile|mobileprovision|key|p8)$/i, + ], name: commonConfig.name, osxUniversal: { mergeASARs: true, + // Both input apps contain the same two architecture-specific prebuilds. + // Preserve them as-is; node-gyp-build selects the appropriate one at runtime. + x64ArchFiles: '**/objc-js/prebuilds/**/*.node', }, out: commonConfig.buildDir, overwrite: true, - platform: 'mas', // Mac App Store + platform: 'mas', // Mac App Store protocols: [{name: `${commonConfig.name} Core Protocol`, schemes: [commonConfig.customProtocolName]}], prune: true, quiet: false, @@ -181,9 +199,10 @@ export async function buildMacOSWrapper( } } catch (error) { logger.error(error); + throw error; + } finally { + await restoreFiles(backup); } - - await restoreFiles(backup); } export async function manualMacOSSign( @@ -196,13 +215,29 @@ export async function manualMacOSSign( const mainEntitlements = 'resources/macos/entitlements/parent.plist'; if (macOSConfig.certNameApplication) { + // Native addons must be signed before the outer app signature. ASAR cannot + // hold loadable Mach-O binaries; packaging extracts these to app.asar.unpacked. + const addons = await globby('Contents/Resources/{app.asar.unpacked,app}/node_modules/**/*.node', { + cwd: appFile, + followSymbolicLinks: false, + }); + for (const addon of addons) { + if (path.isAbsolute(addon) || addon.split(/[\\/]/).includes('..') || !addon.startsWith('Contents/Resources/')) { + throw new Error('Native addon must be inside the app resources directory.'); + } + // globby returns relative paths without following directory symlinks. + // Sign within the app instead of joining a discovered path to the build root. + await promisify(execFile)('codesign', ['--force', '--sign', macOSConfig.certNameApplication, addon], { + cwd: appFile, + }); + } + logger.log(`[SSO] Signed ${addons.length} native addon binaries.`); + const filesToSign = [ 'Frameworks/Electron Framework.framework/Versions/A/Electron Framework', 'Frameworks/Electron Framework.framework/Versions/A/Libraries/libEGL.dylib', 'Frameworks/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib', 'Frameworks/Electron Framework.framework/Versions/A/Libraries/libGLESv2.dylib', - 'Frameworks/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libEGL.dylib', - 'Frameworks/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libGLESv2.dylib', 'Frameworks/Electron Framework.framework/Versions/A/Libraries/libvk_swiftshader.dylib', 'Frameworks/Electron Framework.framework/', `Frameworks/${commonConfig.name} Helper.app/Contents/MacOS/${commonConfig.name} Helper`, diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index 86b742321bc..136df9c2ffe 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -51,6 +51,14 @@ describe('build-windows-msi', () => { }); describe('buildWindowsMsiConfig', () => { + beforeEach(() => { + // Keep production defaults independent of local branding and prior tests. + process.env.APP_ENV = 'production'; + process.env.APP_NAME = 'Wire'; + process.env.WIN_MSI_MANUFACTURER = ''; + process.env.WIN_MSI_UPGRADE_CODE = ''; + }); + it('builds a per-machine MSI with a stable production upgrade identity', async () => { const {builderConfig, windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath, true); diff --git a/electron/css/text-prompt.css b/electron/css/text-prompt.css new file mode 100644 index 00000000000..a56fc6102a4 --- /dev/null +++ b/electron/css/text-prompt.css @@ -0,0 +1,66 @@ +/* Match the existing desktop proxy dialog, with space for provider-supplied text. */ +body { + box-sizing: border-box; + padding: 24px; + text-align: left; +} +form { + display: flex; + flex-direction: column; + width: 100%; + max-height: 100%; + overflow: auto; +} +h1 { + margin: 0 0 12px; + font-size: 24px; +} +#origin { + flex-shrink: 0; + overflow-wrap: anywhere; + padding-bottom: 16px; + border-bottom: 1px solid #e1e7eb; +} +#message { + display: block; + width: auto; + margin: 0; + text-align: left; + max-height: 90px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +input { + box-sizing: border-box; + flex-shrink: 0; + width: 100%; + margin-top: 16px; + font: inherit; +} +.buttons { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + gap: 8px; +} +button { + width: auto; + min-width: 80px; + min-height: 32px; + margin: 0; + padding: 8px 16px; +} +#ok { + background-color: #3879d9; + color: #fff; +} +#cancel { + background-color: #ddd; + color: #000; +} +input:focus-visible, +button:focus-visible { + outline: 2px solid #3879d9; + outline-offset: 2px; +} diff --git a/electron/html/text-prompt.html b/electron/html/text-prompt.html new file mode 100644 index 00000000000..0828151dac0 --- /dev/null +++ b/electron/html/text-prompt.html @@ -0,0 +1,25 @@ + + + + + + + + + + +
+

+

+ + +
+ + +
+
+ + diff --git a/electron/src/auth/TextPrompt.test.main.ts b/electron/src/auth/TextPrompt.test.main.ts new file mode 100644 index 00000000000..d31610c4c5c --- /dev/null +++ b/electron/src/auth/TextPrompt.test.main.ts @@ -0,0 +1,156 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {app, BrowserWindow} from 'electron'; +import {stub} from 'sinon'; + +import * as assert from 'assert'; +import {createServer, Server} from 'http'; +import path from 'path'; + +import {registerTextPrompt} from './TextPrompt'; + +const root = path.resolve(__dirname, '../../..'); +const waitFor = async (get: () => T | undefined): Promise => { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const value = get(); + if (value) { + return value; + } + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error('Timed out waiting for text prompt'); +}; + +describe('SSO website text prompt', function () { + this.timeout(15000); + let server: Server; + let origin: string; + let parent: BrowserWindow; + let appPath: ReturnType; + + before(async () => { + appPath = stub(app, 'getAppPath').returns(root); + server = createServer((_request, response) => response.end('Test IdP')); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as {port: number}; + origin = `http://127.0.0.1:${address.port}`; + }); + after(async () => { + appPath.restore(); + await new Promise(resolve => server.close(() => resolve())); + }); + beforeEach(async () => { + parent = new BrowserWindow({ + show: false, + webPreferences: { + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + preload: path.join(root, 'electron/dist/preload/preload-sso.js'), + }, + }); + registerTextPrompt(parent); + await parent.loadURL(origin); + }); + afterEach(() => { + if (!parent.isDestroyed()) { + parent.destroy(); + } + }); + + const dialog = async (): Promise => { + const win = await waitFor(() => + BrowserWindow.getAllWindows().find(win => win.webContents.getURL().endsWith('/html/text-prompt.html')), + ); + await waitFor(() => (!win.webContents.isLoading() ? win : undefined)); + // The preload populates the form asynchronously via a scoped IPC handler. + await waitFor(asyncReady(win)); + return win; + }; + const asyncReady = (win: BrowserWindow): (() => BrowserWindow | undefined) => { + let ready = false; + void win.webContents + .executeJavaScript( + `new Promise(resolve => { + const timer = setInterval(() => { + if (document.querySelector('#origin').textContent) { clearInterval(timer); resolve(true); } + }, 10); + })`, + ) + .then(() => { + ready = true; + }); + return () => (ready ? win : undefined); + }; + + it('returns user text synchronously to any IdP, preserving the default and displaying its origin', async () => { + const result = parent.webContents.executeJavaScript(`prompt('Name passkey', 'Default label')`); + const win = await dialog(); + const values = await win.webContents.executeJavaScript(`({origin: document.querySelector('#origin').textContent, + message: document.querySelector('#message').textContent, value: document.querySelector('#value').value, + markup: document.querySelector('#message b') !== null})`); + assert.deepStrictEqual(values, {origin, message: 'Name passkey', value: 'Default label', markup: false}); + await win.webContents.executeJavaScript( + `document.querySelector('#value').value = 'My Mac'; document.querySelector('form').requestSubmit()`, + ); + assert.strictEqual(await result, 'My Mac'); + }); + + for (const action of ['cancel', 'escape', 'close', 'empty']) { + it(`handles ${action} with browser prompt semantics`, async () => { + const result = parent.webContents.executeJavaScript(`prompt('Label', 'Initial')`); + const win = await dialog(); + if (action === 'close') { + win.close(); + } else { + const script = + action === 'cancel' + ? `document.querySelector('#cancel').click()` + : action === 'escape' + ? `window.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape'}))` + : `document.querySelector('#value').value = ''; document.querySelector('form').requestSubmit()`; + await win.webContents.executeJavaScript(script); + } + assert.strictEqual(await result, action === 'empty' ? '' : null); + }); + } + + it('rejects oversized requests without opening a dialog', async () => { + assert.strictEqual(await parent.webContents.executeJavaScript(`prompt('x'.repeat(4097), '')`), null); + assert.strictEqual(BrowserWindow.getAllWindows().filter(win => win !== parent).length, 0); + }); + + it('cancels an open prompt when the requesting page navigates', async () => { + const result = parent.webContents.executeJavaScript(`prompt('Label')`).catch(() => null); + const win = await dialog(); + await parent.loadURL(`${origin}/redirected`); + assert.ok(win.isDestroyed()); + assert.strictEqual(await result, null); + }); + + it('supports prompts after an IdP redirect', async () => { + await parent.loadURL(`${origin}/another-provider`); + const result = parent.webContents.executeJavaScript(`prompt('Second provider')`); + const win = await dialog(); + await win.webContents.executeJavaScript(`document.querySelector('#cancel').click()`); + assert.strictEqual(await result, null); + }); +}); diff --git a/electron/src/auth/TextPrompt.ts b/electron/src/auth/TextPrompt.ts new file mode 100644 index 00000000000..33fa962e5bf --- /dev/null +++ b/electron/src/auth/TextPrompt.ts @@ -0,0 +1,163 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {app, BrowserWindow, IpcMainEvent} from 'electron'; + +import path from 'path'; +import {pathToFileURL} from 'url'; + +import {getText} from '../locale'; +import {getLogger} from '../logging/getLogger'; +import {config} from '../settings/config'; + +const logger = getLogger('TextPrompt'); +const registered = new WeakSet(); +const limit = 4096; + +// Synchronous browser prompt semantics, with an independent local dialog renderer. +export function registerTextPrompt(parent: BrowserWindow): void { + if (registered.has(parent)) { + return; + } + registered.add(parent); + const contents = parent.webContents; + let active = false; + contents.ipc.on('wire:text-prompt', (event: IpcMainEvent, message: unknown, defaultValue: unknown) => { + const frame = event.senderFrame; + let origin: string; + try { + const url = new URL(frame?.url || ''); + if (!['https:', 'http:'].includes(url.protocol)) { + throw new Error('Unsupported prompt origin'); + } + origin = url.origin; + } catch { + event.returnValue = null; + return; + } + if ( + active || + parent.isDestroyed() || + frame !== contents.mainFrame || + typeof message !== 'string' || + typeof defaultValue !== 'string' || + message.length > limit || + defaultValue.length > limit + ) { + event.returnValue = null; + return; + } + active = true; + const requestUrl = frame.url; + const base = path.join(app.getAppPath(), config.electronDirectory); + const page = pathToFileURL(path.join(base, 'html/text-prompt.html')).href; + let prompt: BrowserWindow | undefined; + let finished = false; + const finish = (value: string | null = null): void => { + if (finished) { + return; + } + finished = true; + active = false; + contents.removeListener('did-start-navigation', navigated); + contents.removeListener('render-process-gone', cancelled); + parent.removeListener('closed', cancelled); + // A synchronous IPC reply must not target a renderer/frame being torn down. + if (!contents.isDestroyed() && !frame.detached && frame.url === requestUrl) { + event.returnValue = value; + } + // Parent destruction can also close this modal. Avoid re-entering native + // window destruction from a closed/render-process-gone callback on Linux. + const dialog = prompt; + setImmediate(() => { + if (dialog && !dialog.isDestroyed()) { + dialog.destroy(); + } + }); + logger.info('[Passkeys] Website text prompt closed.'); + }; + const cancelled = (): void => finish(); + const navigated = (_event: Electron.Event, _url: string, _inPlace: boolean, mainFrame: boolean): void => { + if (mainFrame) { + finish(); + } + }; + try { + prompt = new BrowserWindow({ + parent, + modal: true, + show: false, + width: 480, + height: 380, + resizable: false, + minimizable: false, + maximizable: false, + title: getText('textPromptTitle'), + webPreferences: { + preload: path.join(base, 'dist/preload/preload-text-prompt.js'), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + partition: 'wire-text-prompt', + webviewTag: false, + }, + }); + const dialog = prompt; + dialog.setMenuBarVisibility(false); + dialog.webContents.setWindowOpenHandler(() => ({action: 'deny'})); + dialog.webContents.on('will-navigate', event => event.preventDefault()); + dialog.webContents.on('will-redirect', event => event.preventDefault()); + const isDialog = (sender: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent): boolean => + sender.senderFrame === dialog.webContents.mainFrame && sender.senderFrame?.url === page; + dialog.webContents.ipc.handle('wire:text-prompt:init', event => + isDialog(event) + ? { + title: getText('textPromptTitle'), + origin, + message, + defaultValue, + ok: getText('promptOK'), + cancel: getText('promptCancel'), + } + : null, + ); + dialog.webContents.ipc.on('wire:text-prompt:result', (event, value: unknown) => { + if (isDialog(event)) { + finish(typeof value === 'string' && value.length <= limit ? value : null); + } + }); + dialog.on('closed', cancelled); + dialog.webContents.on('render-process-gone', cancelled); + contents.on('did-start-navigation', navigated); + contents.on('render-process-gone', cancelled); + parent.on('closed', cancelled); + void dialog + .loadURL(page) + .then(() => { + if (!finished && !dialog.isDestroyed()) { + dialog.show(); + } + }) + .catch(cancelled); + logger.info('[Passkeys] Showing website text prompt.'); + } catch { + finish(); + } + }); +} diff --git a/electron/src/auth/WebAuthn.test.main.ts b/electron/src/auth/WebAuthn.test.main.ts new file mode 100644 index 00000000000..28005e599b5 --- /dev/null +++ b/electron/src/auth/WebAuthn.test.main.ts @@ -0,0 +1,130 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {BrowserWindow, dialog, Session, webContents, WebFrameMain} from 'electron'; +import {restore, spy, stub, SinonStub} from 'sinon'; + +import * as assert from 'assert'; +import {EventEmitter} from 'events'; + +import {registerWebAuthnAccountPicker} from './WebAuthn'; + +import * as locale from '../locale'; + +describe('WebAuthn account picker', () => { + let session: EventEmitter; + let showDialog: SinonStub; + let frame: {detached: boolean; url: string}; + + const accounts = [ + {credentialId: 'first', displayName: 'Alice', name: 'alice@example.com'}, + {credentialId: 'second', displayName: 'Bob', name: 'bob@example.com'}, + ]; + + beforeEach(() => { + session = new EventEmitter(); + frame = {detached: false, url: 'https://example.com/login'}; + stub(webContents, 'fromFrame').returns({isDestroyed: () => false} as Electron.WebContents); + stub(BrowserWindow, 'fromWebContents').returns({isDestroyed: () => false} as BrowserWindow); + showDialog = stub(dialog, 'showMessageBox').resolves({response: 1, checkboxChecked: false}); + registerWebAuthnAccountPicker(session as unknown as Session); + }); + + afterEach(() => restore()); + + async function selectAccount(requestFrame: WebFrameMain | null = frame as WebFrameMain) { + const callback = spy(); + await session.listeners('select-webauthn-account')[0]( + {}, + {frame: requestFrame, relyingPartyId: 'example.com', accounts}, + callback, + ); + assert.strictEqual(callback.callCount, 1); + return callback.firstCall.args[0]; + } + + it('registers once per session while covering separate partitions', () => { + registerWebAuthnAccountPicker(session as unknown as Session); + const otherSession = new EventEmitter(); + registerWebAuthnAccountPicker(otherSession as unknown as Session); + assert.strictEqual(session.listenerCount('select-webauthn-account'), 1); + assert.strictEqual(otherSession.listenerCount('select-webauthn-account'), 1); + }); + + it('returns the selected credential and displays the relying party and accounts', async () => { + assert.strictEqual(await selectAccount(), 'second'); + const options = showDialog.firstCall.args[1]; + assert.strictEqual(options.message, 'Choose an account for example.com'); + assert.deepStrictEqual(options.buttons.slice(0, 2), ['Alice — alice@example.com', 'Bob — bob@example.com']); + assert.strictEqual(options.cancelId, 2); + assert.strictEqual(options.defaultId, 2); + }); + + it('uses locale keys for the title, relying-party message and unnamed account', async () => { + const translate = stub(locale, 'getText').callsFake( + (key, replacements) => `${key}:${Object.values(replacements || {}).join(',')}`, + ); + const callback = spy(); + await session.listeners('select-webauthn-account')[0]( + {}, + { + frame, + relyingPartyId: 'example.com', + accounts: [{credentialId: 'unnamed'}], + }, + callback, + ); + const options = showDialog.firstCall.args[1]; + assert.strictEqual(options.title, 'passkeyPickerTitle:'); + assert.strictEqual(options.message, 'passkeyPickerMessage:example.com'); + assert.deepStrictEqual(options.buttons, ['passkeyPickerAccount:1', 'promptCancel:']); + assert.ok(translate.called); + }); + + it('cancels when the user dismisses the picker', async () => { + showDialog.resolves({response: 2}); + assert.strictEqual(await selectAccount(), undefined); + }); + + it('cancels when the requesting frame no longer exists', async () => { + assert.strictEqual(await selectAccount(null), undefined); + assert.ok(showDialog.notCalled); + }); + + it('cancels when the frame navigates while the picker is open', async () => { + showDialog.callsFake(async () => { + frame.url = 'https://example.com/other'; + return {response: 0}; + }); + assert.strictEqual(await selectAccount(), undefined); + }); + + it('cancels when the frame is detached while the picker is open', async () => { + showDialog.callsFake(async () => { + frame.detached = true; + return {response: 0}; + }); + assert.strictEqual(await selectAccount(), undefined); + }); + + it('cancels exactly once when the dialog fails', async () => { + showDialog.rejects(new Error('Dialog unavailable')); + assert.strictEqual(await selectAccount(), undefined); + }); +}); diff --git a/electron/src/auth/WebAuthn.ts b/electron/src/auth/WebAuthn.ts new file mode 100644 index 00000000000..d1c90d63b9d --- /dev/null +++ b/electron/src/auth/WebAuthn.ts @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {BrowserWindow, dialog, Session, webContents} from 'electron'; + +import {getText} from '../locale'; +import {getLogger} from '../logging/getLogger'; + +const logger = getLogger('WebAuthn'); +const registeredSessions = new WeakSet(); + +export function registerWebAuthnAccountPicker(session: Session): void { + if (registeredSessions.has(session)) { + return; + } + registeredSessions.add(session); + logger.info('[Passkeys] Account picker registered for an authentication-capable session.'); + + session.on('select-webauthn-account', async (_event, details, callback) => { + let credentialId: string | undefined; + let outcome = 'cancelled: dialog failed'; + try { + const {frame, accounts, relyingPartyId} = details; + logger.info(`[Passkeys] Account-selection request received; available accounts: ${accounts.length}.`); + if (!frame || frame.detached || accounts.length === 0) { + outcome = 'cancelled: no active requesting frame or no accounts'; + return; + } + const contents = webContents.fromFrame(frame); + if (!contents || contents.isDestroyed()) { + outcome = 'cancelled: requesting web contents no longer exist'; + return; + } + const parent = BrowserWindow.fromWebContents(contents); + if (!parent || parent.isDestroyed()) { + outcome = 'cancelled: requesting window no longer exists'; + return; + } + const requestUrl = frame.url; + const cancelId = accounts.length; + logger.info('[Passkeys] Showing account picker.'); + const {response} = await dialog.showMessageBox(parent, { + type: 'question', + title: getText('passkeyPickerTitle'), + message: getText('passkeyPickerMessage', {domain: relyingPartyId}), + buttons: [ + ...accounts.map( + (account, index) => + [account.displayName, account.name].filter(Boolean).join(' — ') || + getText('passkeyPickerAccount', {number: String(index + 1)}), + ), + getText('promptCancel'), + ], + cancelId, + defaultId: cancelId, + noLink: true, + }); + outcome = 'cancelled: requesting page closed or navigated'; + if (!contents.isDestroyed() && !frame.detached && frame.url === requestUrl) { + credentialId = accounts[response]?.credentialId; + outcome = credentialId + ? 'account selected; returning choice to authenticator (login result not yet known)' + : 'cancelled: picker dismissed or no valid selection'; + } + } catch { + logger.error('[Passkeys] Account picker failed; cancelling request.'); + } finally { + logger.info(`[Passkeys] ${outcome}.`); + callback(credentialId); + } + }); +} diff --git a/electron/src/lib/CoreProtocol.test.main.ts b/electron/src/lib/CoreProtocol.test.main.ts index 69a045a6bda..d03f77cfc25 100644 --- a/electron/src/lib/CoreProtocol.test.main.ts +++ b/electron/src/lib/CoreProtocol.test.main.ts @@ -23,6 +23,7 @@ import * as assert from 'assert'; import {CustomProtocolHandler} from './CoreProtocol'; import {EVENT_TYPE} from './eventType'; +import * as dialogs from './showDialog'; let protocolHandler: CustomProtocolHandler; @@ -59,6 +60,25 @@ describe('dispatchDeepLink', () => { assert.ok(sendActionSpy.calledWith(EVENT_TYPE.ACCOUNT.SSO_LOGIN, 'wire-13266298-4ac8-44b5-8281-dfb9e95fab5c')); }); + it('does not forward system authentication cookie callbacks to the renderer', async () => { + sendActionSpy.resetHistory(); + await protocolHandler['dispatchDeepLink']('wire://login/success?cookie=zuid%3Dsecret&validation_token=state'); + assert.strictEqual(sendActionSpy.called, false); + assert.strictEqual(protocolHandler.hashLocation, ''); + }); + + it('silently ignores oversized system callbacks while rejecting oversized ordinary links', async () => { + sendActionSpy.resetHistory(); + const errorDialog = spy(); + replace(dialogs, 'showErrorDialog', errorDialog); + await protocolHandler['dispatchDeepLink'](`wire://login/success?cookie=${'x'.repeat(16000)}`); + assert.strictEqual(errorDialog.called, false); + assert.strictEqual(sendActionSpy.called, false); + assert.strictEqual(protocolHandler.hashLocation, ''); + await protocolHandler['dispatchDeepLink'](`wire://conversation/${'x'.repeat(1100)}`); + assert.strictEqual(errorDialog.calledOnce, true); + }); + it('forwards start login events', async () => { await protocolHandler['dispatchDeepLink']('wire://start-login'); assert.ok(sendActionSpy.calledWith(EVENT_TYPE.ACTION.START_LOGIN)); diff --git a/electron/src/lib/CoreProtocol.ts b/electron/src/lib/CoreProtocol.ts index 89cf8a79b92..75a3c6eaf62 100644 --- a/electron/src/lib/CoreProtocol.ts +++ b/electron/src/lib/CoreProtocol.ts @@ -22,7 +22,6 @@ import {app, ipcMain} from 'electron'; import * as path from 'path'; import {URL} from 'url'; -import {shortenText} from './ElectronUtil'; import {EVENT_TYPE} from './eventType'; import {showErrorDialog} from '../lib/showDialog'; @@ -46,18 +45,26 @@ export class CustomProtocolHandler { private async dispatchDeepLink(url?: string): Promise { logger.info('Dispatching deep link ...'); try { - if ( - typeof url === 'undefined' || - !url.startsWith(CORE_PROTOCOL_PREFIX) || - url.length > CORE_PROTOCOL_MAX_LENGTH - ) { - showErrorDialog(`Invalid deep link "${shortenText(url || '', CORE_PROTOCOL_MAX_LENGTH)}."`); + if (typeof url === 'undefined' || !url.startsWith(CORE_PROTOCOL_PREFIX)) { + showErrorDialog('Invalid deep link.'); logger.info('Invalid deep link, ignoring'); return; } const route = new URL(url); + if (route.host === 'login') { + // ASWebAuthenticationSession owns SSO callbacks. Never forward a cookie + // callback to a renderer/hash route or log its query string. + logger.info('Ignoring SSO callback outside the system authentication session.'); + return; + } + + if (url.length > CORE_PROTOCOL_MAX_LENGTH) { + showErrorDialog('Invalid deep link.'); + return; + } + if (route.host === START_SSO_FLOW) { logger.info('Deep link is a SSO link, triggering SSO login ...'); await this.handleSSOLogin(route); @@ -72,8 +79,8 @@ export class CustomProtocolHandler { logger.info('Triggering hash location change ...'); this.forwardHashLocation(route); } - } catch (error: any) { - logger.error(error); + } catch { + logger.warn('Unable to process deep link.'); } } diff --git a/electron/src/locale/en-US.json b/electron/src/locale/en-US.json index e5da1bc371f..0c28fb0c8cb 100644 --- a/electron/src/locale/en-US.json +++ b/electron/src/locale/en-US.json @@ -108,6 +108,10 @@ "wrapperAddAccountErrorTitleSingular": "Account already active", "wrapperAddAccountErrorMessagePlural": "You can only be logged in with {{maximumAccounts}} accounts at once. Log out from one to add another.", "wrapperAddAccountErrorMessageSingular": "You can only be logged in with one account at once. Log out from this one to add another.", + "passkeyPickerTitle": "Sign in with a passkey", + "passkeyPickerMessage": "Choose an account for {domain}", + "passkeyPickerAccount": "Account {number}", + "textPromptTitle": "Website prompt", "promptOK": "OK", "promptCancel": "Cancel", "promptWarning": "Warning", diff --git a/electron/src/locale/index.ts b/electron/src/locale/index.ts index 08e278bb104..cef6772cd6f 100644 --- a/electron/src/locale/index.ts +++ b/electron/src/locale/index.ts @@ -63,30 +63,30 @@ const parseLocale = (locale: string): SupportedI18nLanguage => { const getSystemLocale = (): SupportedI18nLanguage => parseLocale(app.getLocale().substring(0, 2)); export const LANGUAGES: SupportedI18nLanguageObject = { - cs, - da, - de, - el, + cs: {...en, ...cs}, + da: {...en, ...da}, + de: {...en, ...de}, + el: {...en, ...el}, en, - es, - et, - fi, - fr, - hr, - hu, - it, - lt, - nl, - pl, - pt, - ro, - ru, - si, - sk, - sl, - tr, - uk, - zh, + es: {...en, ...es}, + et: {...en, ...et}, + fi: {...en, ...fi}, + fr: {...en, ...fr}, + hr: {...en, ...hr}, + hu: {...en, ...hu}, + it: {...en, ...it}, + lt: {...en, ...lt}, + nl: {...en, ...nl}, + pl: {...en, ...pl}, + pt: {...en, ...pt}, + ro: {...en, ...ro}, + ru: {...en, ...ru}, + si: {...en, ...si}, + sk: {...en, ...sk}, + sl: {...en, ...sl}, + tr: {...en, ...tr}, + uk: {...en, ...uk}, + zh: {...en, ...zh}, }; export const supportedSpellCheckLanguages: Record = { diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index fc4c45ba975..0dbc1d5dab6 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -46,6 +46,7 @@ import {URL, pathToFileURL} from 'url'; import {WebAppEvents} from '@wireapp/webapp-events'; import * as ProxyAuth from './auth/ProxyAuth'; +import {registerWebAuthnAccountPicker} from './auth/WebAuthn'; import {getPictureInPictureCallWindowOptions, isPictureInPictureCallWindow} from './calling/PictureInPictureCall'; import {initializeFirstInstance} from './lib/applicationBootstrap'; import { @@ -79,6 +80,7 @@ import {OriginValidator} from './runtime/OriginValidator'; import {config} from './settings/config'; import {settings} from './settings/ConfigurationPersistence'; import {SettingsType} from './settings/SettingsType'; +import {BrowserSingleSignOn} from './sso/BrowserSingleSignOn'; import {SingleSignOn} from './sso/SingleSignOn'; import {initMacAutoUpdater} from './update/macosAutoUpdater'; import {AboutWindow} from './window/AboutWindow'; @@ -101,15 +103,6 @@ const mainProcessFireAndForgetInvoker = createFireAndForgetInvoker({ }); const configuredUserDataPath = getConfiguredPortableUserDataPath(); -type OpenLinkInNewWindowParameters = { - accountId: Maybe; - browserWindow: BrowserWindow; - frameName: string; - options: BrowserWindowConstructorOptions; - senderWebContents: WebContents; - url: string; -}; - remoteMain.initialize(); const APP_PATH = path.join(app.getAppPath(), config.electronDirectory); @@ -603,7 +596,7 @@ const applyProxySettings = async (authenticatedProxyDetails: URL, webContents: E class ElectronWrapperInit { logger: logdown.Logger; - ssoWindow: SingleSignOn | null; + ssoWindow: SingleSignOn | BrowserSingleSignOn | null; constructor() { this.logger = getLogger('ElectronWrapperInit'); @@ -620,7 +613,6 @@ class ElectronWrapperInit { closeSSOWindow = () => { if (this.ssoWindow) { this.ssoWindow?.close(); - this.ssoWindow = null; } }; @@ -640,12 +632,34 @@ class ElectronWrapperInit { webviewProtection(): void { const openLinkInNewWindowHandler = ( details: HandlerDetails, + sender: WebContents, ): {action: 'deny'} | {action: 'allow'; overrideBrowserWindowOptions?: BrowserWindowConstructorOptions} => { if (SingleSignOn.isSingleSignOnLoginWindow(details.frameName)) { - return { - action: 'allow', - overrideBrowserWindowOptions: SingleSignOn.getSingleSignOnLoginWindowOptions(main, details.url), + if (this.ssoWindow) { + this.ssoWindow.focus(); + return {action: 'deny'}; + } + const flow = + process.platform === 'darwin' + ? new BrowserSingleSignOn(main, sender, details.url) + : new SingleSignOn( + new BrowserWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(main, details.url)), + sender, + lifecycle.getAccountId(sender), + details.url, + ); + this.ssoWindow = flow; + flow.onClose = () => { + this.sendSSOWindowCloseEvent(); + if (this.ssoWindow === flow) { + this.ssoWindow = null; + } }; + void flow.init().catch(() => { + this.logger.warn('Unable to initialize SSO window.'); + flow.close(); + }); + return {action: 'deny'}; } if (isPictureInPictureCallWindow(details.frameName)) { @@ -661,30 +675,6 @@ class ElectronWrapperInit { return {action: 'deny'}; }; - function openLinkInNewWindow( - electronWrapperInitialization: ElectronWrapperInit, - parameters: OpenLinkInNewWindowParameters, - ): Promise | void { - if (SingleSignOn.isSingleSignOnLoginWindow(parameters.frameName)) { - const singleSignOn = new SingleSignOn( - parameters.browserWindow, - parameters.senderWebContents, - parameters.accountId, - parameters.url, - parameters.options, - ).init(); - - return new Promise(() => { - singleSignOn - .then(sso => { - electronWrapperInitialization.ssoWindow = sso; - electronWrapperInitialization.ssoWindow.onClose = electronWrapperInitialization.sendSSOWindowCloseEvent; - }) - .catch(error => console.info(error)); - }); - } - } - // Keeping this Function for future use const willNavigateInWebview = (event: ElectronEvent, url: string, baseUrl: string): void => { // Ensure navigation is to an allowed domain @@ -699,6 +689,7 @@ class ElectronWrapperInit { const enableSpellChecking = settings.restore(SettingsType.ENABLE_SPELL_CHECKING, true); app.on('web-contents-created', async (_webviewEvent: ElectronEvent, contents: WebContents) => { + registerWebAuthnAccountPicker(contents.session); remoteMain.enable(contents); // disable new Windows by default on everything contents.setWindowOpenHandler(() => { @@ -728,19 +719,7 @@ class ElectronWrapperInit { await applyProxySettings(proxyInfoArg, contents); } // Open webview links outside of the app - contents.setWindowOpenHandler(openLinkInNewWindowHandler); - contents.on('did-create-window', async (win, windowCreationDetails) => { - const {frameName, options, url} = windowCreationDetails; - - await openLinkInNewWindow(this, { - accountId: lifecycle.getAccountId(contents), - browserWindow: win, - frameName, - options, - senderWebContents: contents, - url, - }); - }); + contents.setWindowOpenHandler(details => openLinkInNewWindowHandler(details, contents)); contents.on('will-navigate', (event: ElectronEvent, url: string) => { willNavigateInWebview(event, url, contents.getURL()); }); diff --git a/electron/src/preload/preload-sso.ts b/electron/src/preload/preload-sso.ts new file mode 100644 index 00000000000..083d0eb54fa --- /dev/null +++ b/electron/src/preload/preload-sso.ts @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +// Sandboxed preload: only Electron's limited built-in module is available here. +import {contextBridge, ipcRenderer} from 'electron'; + +let completion: Promise = Promise.resolve(); +contextBridge.exposeInMainWorld('__wireSsoOpener', { + postMessage: (message: {type?: unknown; payload?: unknown}) => { + completion = ipcRenderer.invoke('wire:sso-complete', message?.type, message?.payload).catch(() => false); + return completion; + }, + close: () => { + void completion.then(() => ipcRenderer.send('wire:sso-close')); + }, +}); +contextBridge.executeInMainWorld({ + func: () => { + if (!window.opener) { + const bridge = ( + window as unknown as { + __wireSsoOpener: {postMessage: (message: unknown) => void; close: () => void}; + } + ).__wireSsoOpener; + // Provide only the backend's completion API, not access to another account. + Object.defineProperty(window, 'opener', { + value: {postMessage: (message: unknown) => bridge.postMessage(message)}, + }); + window.close = () => bridge.close(); + } + }, +}); + +contextBridge.exposeInMainWorld('__wireTextPrompt', (message = '', defaultValue = ''): string | null => { + return ipcRenderer.sendSync('wire:text-prompt', String(message), String(defaultValue)); +}); +contextBridge.executeInMainWorld({ + func: () => { + window.prompt = (message = '', defaultValue = '') => { + const bridge = window as unknown as {__wireTextPrompt: (message: string, value: string) => string | null}; + return bridge.__wireTextPrompt(String(message), String(defaultValue)); + }; + }, +}); diff --git a/electron/src/preload/preload-text-prompt.ts b/electron/src/preload/preload-text-prompt.ts new file mode 100644 index 00000000000..ce2e4448bcf --- /dev/null +++ b/electron/src/preload/preload-text-prompt.ts @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {ipcRenderer} from 'electron'; + +window.addEventListener('DOMContentLoaded', async () => { + const data = await ipcRenderer.invoke('wire:text-prompt:init'); + if (!data) { + window.close(); + return; + } + document.title = data.title; + document.querySelector('#title')!.textContent = data.title; + const input = document.querySelector('#value')!; + document.querySelector('#origin')!.textContent = data.origin; + document.querySelector('#message')!.textContent = data.message; + document.querySelector('#ok')!.textContent = data.ok; + document.querySelector('#cancel')!.textContent = data.cancel; + input.value = data.defaultValue; + let sent = false; + const finish = (value: string | null): void => { + if (!sent) { + sent = true; + ipcRenderer.send('wire:text-prompt:result', value); + } + }; + document.querySelector('form')!.addEventListener('submit', event => { + event.preventDefault(); + finish(input.value); + }); + document.querySelector('#cancel')!.addEventListener('click', () => finish(null)); + window.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.preventDefault(); + finish(null); + } + }); + input.focus(); + input.select(); +}); diff --git a/electron/src/preload/preload-webview.ts b/electron/src/preload/preload-webview.ts index 227d1d6647d..52009a862aa 100644 --- a/electron/src/preload/preload-webview.ts +++ b/electron/src/preload/preload-webview.ts @@ -47,6 +47,21 @@ type Theme = 'dark' | 'default'; const logger = getLogger(path.basename(__filename)); +// Only the main process sends this after validating the SSO backend and copying +// its authentication cookie into this account's session. +ipcRenderer.on( + 'wire:sso-result', + (_event, result: {origin: string; type: string; payload?: {label: string; errors?: string[]}}) => { + logger.info('[Passkeys] Received verified SSO result after cookie handoff.'); + window.dispatchEvent( + new MessageEvent('message', { + origin: result.origin, + data: {type: result.type, ...(result.payload ? {payload: result.payload} : {})}, + }), + ); + }, +); + function subscribeToThemeChange(): void { function updateWebAppTheme(): void { if (WebAppEvents.PROPERTIES.UPDATE.INTERFACE) { diff --git a/electron/src/sso/BrowserSingleSignOn.test.main.ts b/electron/src/sso/BrowserSingleSignOn.test.main.ts new file mode 100644 index 00000000000..17ae4024b76 --- /dev/null +++ b/electron/src/sso/BrowserSingleSignOn.test.main.ts @@ -0,0 +1,212 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {BrowserWindow, WebContents} from 'electron'; + +import * as assert from 'assert'; +import {EventEmitter} from 'events'; + +import {BrowserSingleSignOn} from './BrowserSingleSignOn'; +import {BrowserAuthenticationError} from './MacWebAuthentication'; + +const loginUrl = 'https://backend.example/sso/initiate-login/11111111-1111-1111-1111-111111111111'; + +function setup(failCookie = false) { + const events: string[] = []; + let callback: (value: string) => void = () => {}; + let reject: (error: Error) => void = () => {}; + const results: unknown[] = []; + let callbackUrl = ''; + let scheme = ''; + const parent = Object.assign(new EventEmitter(), {isDestroyed: () => false, focus: () => {}}); + const sender = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + send: (_channel: string, result: {type: string}) => { + results.push(result); + events.push(result.type); + }, + session: { + cookies: { + set: async () => { + events.push('set'); + if (failCookie) { + throw new Error('secret cookie error'); + } + }, + flushStore: async () => { + events.push('flush'); + }, + remove: async () => { + events.push('remove'); + }, + }, + }, + }); + const flow = new BrowserSingleSignOn( + parent as unknown as BrowserWindow, + sender as unknown as WebContents, + loginUrl, + (_parent, url, callbackScheme) => { + callbackUrl = new URL(url).searchParams.get('success_redirect')!; + scheme = callbackScheme; + return { + result: new Promise((resolve, rejectResult) => { + reject = rejectResult; + callback = resolve; + }), + cancel: () => { + events.push('cancel'); + }, + focus: () => { + events.push('focus browser'); + return true; + }, + }; + }, + ); + flow.onClose = () => { + events.push('close'); + }; + const complete = (wrongState = false, failure = false) => { + const url = new URL(callbackUrl); + url.searchParams.set('cookie', 'zuid=secret; Path=/access; HttpOnly; Secure'); + url.searchParams.set('userid', '11111111-1111-1111-1111-111111111111'); + if (wrongState) { + url.searchParams.set('validation_token', 'wrong'); + } + assert.strictEqual(url.protocol, `${scheme}:`); + if (failure) { + url.pathname = '/failure'; + url.searchParams.set('label', 'forbidden'); + } + callback(url.toString()); + }; + return { + flow, + complete, + events, + sender, + results, + fail: (code: number) => reject(new BrowserAuthenticationError(code)), + }; +} + +describe('browser SSO lifecycle', () => { + it('closes cancellation without reporting an authentication error, but reports other native errors', async () => { + for (const code of [1, 2]) { + const {flow, fail, events} = setup(); + const pending = flow.init(); + fail(code); + await pending; + assert.strictEqual(events.includes('AUTH_ERROR'), code !== 1); + assert.strictEqual(events.filter(event => event === 'close').length, 1); + } + }); + + it('forwards a correlated backend failure label without installing a cookie', async () => { + const {flow, complete, events, results} = setup(); + const pending = flow.init(); + complete(false, true); + await pending; + assert.deepStrictEqual(results, [ + {origin: 'https://backend.example', type: 'AUTH_ERROR', payload: {label: 'forbidden'}}, + ]); + assert.ok(!events.includes('set')); + }); + + it('flushes the account cookie before reporting success and closes once', async () => { + const {flow, complete, events} = setup(); + const pending = flow.init(); + complete(); + await pending; + flow.close(); + assert.deepStrictEqual(events, ['set', 'flush', 'AUTH_SUCCESS', 'cancel', 'close']); + }); + + it('focuses the existing browser request without restarting authentication', async () => { + const {flow, complete, events} = setup(); + const pending = flow.init(); + flow.focus(); + flow.focus(); + assert.deepStrictEqual(events, ['focus browser', 'focus browser']); + complete(); + await pending; + flow.focus(); + assert.strictEqual(events.filter(event => event === 'focus browser').length, 2); + assert.strictEqual(events.filter(event => event === 'AUTH_SUCCESS').length, 1); + }); + + it('does not write cookies for an uncorrelated callback', async () => { + const {flow, complete, events} = setup(); + const pending = flow.init(); + complete(true); + await pending; + assert.deepStrictEqual(events, ['AUTH_ERROR', 'cancel', 'close']); + }); + + it('does not report success if cookie installation fails', async () => { + const {flow, complete, events} = setup(true); + const pending = flow.init(); + complete(); + await pending; + assert.ok(events.includes('AUTH_ERROR')); + assert.ok(!events.includes('AUTH_SUCCESS')); + }); + + it('removes a newly installed cookie if persistence fails', async () => { + const {flow, complete, events, sender} = setup(); + sender.session.cookies.flushStore = async () => { + throw new Error('persistence failure'); + }; + const pending = flow.init(); + complete(); + await pending; + assert.deepStrictEqual(events, ['set', 'remove', 'AUTH_ERROR', 'cancel', 'close']); + }); + + it('ignores late callbacks after account removal or navigation', async () => { + for (const event of ['destroyed', 'did-start-navigation']) { + const {flow, complete, events, sender} = setup(); + const pending = flow.init(); + sender.emit(event, {}, 'https://other.example', false, true); + complete(); + await pending; + assert.deepStrictEqual(events, ['cancel', 'close']); + } + }); + + it('removes an in-flight cookie and delays releasing the flow when cancelled during installation', async () => { + const {flow, complete, events, sender} = setup(); + let finishSet: () => void = () => {}; + sender.session.cookies.set = () => + new Promise(resolve => { + finishSet = resolve; + }); + const pending = flow.init(); + complete(); + await Promise.resolve(); + flow.close(); + assert.ok(!events.includes('close')); + finishSet(); + await pending; + assert.ok(events.includes('remove')); + assert.ok(!events.includes('AUTH_SUCCESS')); + assert.strictEqual(events.filter(event => event === 'close').length, 1); + }); +}); diff --git a/electron/src/sso/BrowserSingleSignOn.ts b/electron/src/sso/BrowserSingleSignOn.ts new file mode 100644 index 00000000000..8fa79bddf2e --- /dev/null +++ b/electron/src/sso/BrowserSingleSignOn.ts @@ -0,0 +1,156 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {BrowserWindow, WebContents} from 'electron'; + +import {randomUUID} from 'crypto'; + +import {createBrowserSsoRequest, parseBrowserSsoCallback, SSO_TIMEOUT_MS} from './browserSsoCallback'; +import {BrowserAuthenticationError, startMacWebAuthentication, WebAuthenticationRequest} from './MacWebAuthentication'; +import type {SsoPayload} from './ssoResult'; + +import {getLogger} from '../logging/getLogger'; +import {config} from '../settings/config'; + +const logger = getLogger('BrowserSingleSignOn'); + +export class BrowserSingleSignOn { + public onClose = () => {}; + private request?: WebAuthenticationRequest; + private closed = false; + private installingCookie = false; + private notifiedClose = false; + private timer?: ReturnType; + + constructor( + private readonly parent: BrowserWindow, + private readonly sender: WebContents, + private readonly loginUrl: string, + private readonly authenticate = startMacWebAuthentication, + ) {} + + public async init(): Promise { + const state = randomUUID(); + const expiresAt = Date.now() + SSO_TIMEOUT_MS; + const scheme = config.customProtocolName; + let stage = 'starting'; + try { + const url = createBrowserSsoRequest(this.loginUrl, scheme, state); + this.sender.once('destroyed', this.close); + this.sender.on('did-start-navigation', this.onNavigation); + this.parent.once('closed', this.close); + this.timer = setTimeout(this.close, SSO_TIMEOUT_MS); + logger.info('[SSO] Starting macOS browser authentication.'); + this.request = this.authenticate(this.parent, url.toString(), scheme); + logger.info('[SSO] Native browser authentication start returned; awaiting completion.'); + stage = 'waiting for browser'; + const callback = await this.request.result; + stage = 'validating callback'; + if (this.closed || this.sender.isDestroyed()) { + return; + } + const cookie = parseBrowserSsoCallback(callback, url, scheme, state, expiresAt); + if ('type' in cookie) { + this.sendResult(cookie.type, cookie.payload); + return; + } + // The system session delivers the callback directly to this request. Never + // accept these cookies via generic open-url or renderer IPC handlers. + stage = 'installing session cookie'; + this.installingCookie = true; + let installed = false; + try { + await this.sender.session.cookies.set(cookie); + installed = true; + await this.sender.session.cookies.flushStore(); + if (this.closed || this.sender.isDestroyed()) { + throw new Error('Login cancelled during cookie installation.'); + } + } catch { + if (installed) { + // A failed/abandoned attempt must not leave a usable new session. + await this.sender.session.cookies.remove(cookie.url, 'zuid'); + } + throw new Error('Unable to install the SSO cookie.'); + } finally { + this.installingCookie = false; + } + logger.info('[SSO] Wire session cookie installed in the requesting account.'); + this.sendResult('AUTH_SUCCESS'); + } catch (error) { + if (error instanceof BrowserAuthenticationError && error.cancelled) { + return; + } + // Callback and native errors can include reusable credentials: log neither. + if (!this.closed) { + logger.warn(`[SSO] Browser authentication failed or was cancelled while ${stage}.`); + this.sendResult('AUTH_ERROR'); + } + } finally { + this.close(); + this.notifyClose(); + } + } + + private sendResult(type: string, payload?: SsoPayload): void { + if (!this.sender.isDestroyed()) { + const index = this.loginUrl.indexOf('/sso/initiate-login/'); + this.sender.send('wire:sso-result', {origin: this.loginUrl.slice(0, index), type, ...(payload ? {payload} : {})}); + } + } + + private onNavigation = (_event: unknown, _url: string, isInPlace: boolean, isMainFrame: boolean) => { + if (isMainFrame && !isInPlace) { + this.close(); + } + }; + + public close = (): void => { + if (this.closed) { + return; + } + this.closed = true; + clearTimeout(this.timer); + this.request?.cancel(); + this.request = undefined; + this.sender.removeListener('destroyed', this.close); + this.sender.removeListener('did-start-navigation', this.onNavigation); + this.parent.removeListener('closed', this.close); + if (!this.installingCookie) { + this.notifyClose(); + } + }; + + private notifyClose(): void { + if (this.notifiedClose) { + return; + } + this.notifiedClose = true; + this.onClose(); + } + + public focus = (): void => { + if (this.closed) { + return; + } + if (this.request && !this.request.focus()) { + logger.warn('[SSO] Unable to bring the authentication browser forward. Switch to the browser to continue.'); + } + }; +} diff --git a/electron/src/sso/MacWebAuthentication.ts b/electron/src/sso/MacWebAuthentication.ts new file mode 100644 index 00000000000..6f554fe3951 --- /dev/null +++ b/electron/src/sso/MacWebAuthentication.ts @@ -0,0 +1,176 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {BrowserWindow} from 'electron'; +// Keep the optional macOS addon out of Linux/Windows TypeScript resolution. +type NobjcObject = {[selector: string]: (...args: any[]) => any}; + +let presentationProviderClass: NobjcObject | undefined; +const presentationAnchors = new Map(); + +export interface WebAuthenticationRequest { + result: Promise; + cancel: () => void; + focus: () => boolean; +} + +export class BrowserAuthenticationError extends Error { + constructor(public readonly code?: number) { + super('Browser authentication cancelled or failed.'); + } + + get cancelled(): boolean { + // ASWebAuthenticationSessionErrorCodeCanceledLogin. + return this.code === 1; + } +} + +// Lazy-load the macOS-only addon. Only public AuthenticationServices APIs are used. +// Electron already runs the main Cocoa event loop; do not start a second run loop. +export function startMacWebAuthentication( + parent: BrowserWindow, + url: string, + scheme: string, +): WebAuthenticationRequest { + const {NobjcLibrary, NobjcClass, getPointer, fromPointer, typedBlock} = require('objc-js'); + const foundation = new NobjcLibrary('/System/Library/Frameworks/Foundation.framework/Foundation'); + const appKit = new NobjcLibrary('/System/Library/Frameworks/AppKit.framework/AppKit'); + const authentication = new NobjcLibrary( + '/System/Library/Frameworks/AuthenticationServices.framework/AuthenticationServices', + ); + const nsWindow = fromPointer(parent.getNativeWindowHandle()).window(); + // Resolve the browser once, so changing the default during login cannot send + // the continue action to a different app. This reads metadata only. + let browserIdentifier: NobjcObject | undefined; + try { + const browserURL = appKit.NSWorkspace.sharedWorkspace().URLForApplicationToOpenURL$( + foundation.NSURL.URLWithString$(foundation.NSString.stringWithUTF8String$(url)), + ); + const bundle = browserURL ? foundation.NSBundle.bundleWithURL$(browserURL) : null; + const capabilities = bundle?.objectForInfoDictionaryKey$( + foundation.NSString.stringWithUTF8String$('ASWebAuthenticationSessionWebBrowserSupportCapabilities'), + ); + const supported = capabilities?.objectForKey$(foundation.NSString.stringWithUTF8String$('IsSupported')); + browserIdentifier = supported?.boolValue() + ? bundle.bundleIdentifier() + : foundation.NSString.stringWithUTF8String$('com.apple.Safari'); + } catch { + // A focus lookup failure must not prevent authentication from starting. + } + // presentationContextProvider is weak on the Apple side. Retain the provider, + // session and block in this closure until completion/cancellation. + // objc-js protocol delegates always dispatch through a TSFN in Electron, + // even on the JS thread. Apple's synchronous anchor request inside start() + // then waits on that same blocked thread. A public NSObject subclass uses + // the bridge's synchronous main-thread dispatch path instead. + presentationProviderClass ??= NobjcClass.define({ + name: 'WireWebAuthenticationPresentationProvider', + superclass: 'NSObject', + protocols: ['ASWebAuthenticationPresentationContextProviding'], + methods: { + 'presentationAnchorForWebAuthenticationSession:': { + types: '@@:@', + implementation: (self: NobjcObject) => presentationAnchors.get(getPointer(self).toString('hex')) ?? null, + }, + }, + }); + const provider = presentationProviderClass!.alloc().init(); + const providerKey = getPointer(provider).toString('hex'); + presentationAnchors.set(providerKey, nsWindow); + let nativeSession: NobjcObject | undefined; + let finished = false; + let rejectResult: (error: Error) => void = () => {}; + const result = new Promise((resolve, reject) => { + rejectResult = reject; + const handler = typedBlock( + {returns: 'v', args: ['@', '@']}, + (callback: NobjcObject | null, error: NobjcObject | null) => { + if (finished) { + return; + } + finished = true; + if (error || !callback) { + // Never propagate NSError text: it can contain the authentication URL. + reject(new BrowserAuthenticationError(error ? Number(error.code()) : undefined)); + } else { + try { + resolve(callback.absoluteString().toString()); + } catch { + reject(new Error('Invalid browser authentication callback.')); + } + } + }, + ); + nativeSession = authentication.ASWebAuthenticationSession.alloc().initWithURL$callbackURLScheme$completionHandler$( + foundation.NSURL.URLWithString$(foundation.NSString.stringWithUTF8String$(url)), + foundation.NSString.stringWithUTF8String$(scheme), + handler, + ); + nativeSession!.setPresentationContextProvider$(provider); + nativeSession!.setPrefersEphemeralWebBrowserSession$(true); + if (!nativeSession!.start()) { + finished = true; + reject(new Error('Unable to start browser authentication.')); + } + }); + return { + result, + focus: () => { + if (finished || parent.isDestroyed()) { + return false; + } + try { + // Before the browser opens, macOS may be asking for consent on Wire. + if (nsWindow.attachedSheet()) { + parent.focus(); + return true; + } + if (!browserIdentifier) { + return false; + } + const browsers = appKit.NSRunningApplication.runningApplicationsWithBundleIdentifier$(browserIdentifier); + if (!browsers.count()) { + return false; + } + const browser = browsers.objectAtIndex$(0); + const application = appKit.NSApplication.sharedApplication(); + // Cooperative activation on macOS 14+, legacy activation on older OSs. + if (application.respondsToSelector$('yieldActivationToApplication:')) { + application.yieldActivationToApplication$(browser); + } + // NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps. + // Do not reopen the login URL or start a second authentication request. + return Boolean(browser.activateWithOptions$(3)); + } catch { + return false; + } + }, + cancel: () => { + // Reference provider explicitly so it remains alive for the entire request. + nativeSession?.setPresentationContextProvider$(provider); + if (!finished) { + finished = true; + nativeSession?.cancel(); + rejectResult(new Error('Browser authentication cancelled.')); + } + nativeSession = undefined; + presentationAnchors.delete(providerKey); + }, + }; +} diff --git a/electron/src/sso/SingleSignOn.test.main.ts b/electron/src/sso/SingleSignOn.test.main.ts index 553cf23b740..fa4d3958dc0 100644 --- a/electron/src/sso/SingleSignOn.test.main.ts +++ b/electron/src/sso/SingleSignOn.test.main.ts @@ -17,16 +17,285 @@ * */ +import {app, BrowserWindow, session} from 'electron'; +import {stub} from 'sinon'; +import {Maybe} from 'true-myth'; + import * as assert from 'assert'; +import {createServer} from 'http'; +import * as path from 'path'; import {SingleSignOn} from './SingleSignOn'; describe('SingleSignOn', () => { - describe('generateSecret', () => { - it('generates a secret of a specified size', async () => { - const size = 24; - const loginAuthorizationSecret = await SingleSignOn['generateSecret'](size); - assert.strictEqual(loginAuthorizationSecret.length, size * 2); + describe('shared passkey session', () => { + const windows: BrowserWindow[] = []; + const createWindow = (options: Electron.BrowserWindowConstructorOptions) => { + const window = new BrowserWindow({...options, show: false}); + windows.push(window); + return window; + }; + + afterEach(() => { + windows + .splice(0) + .reverse() + .forEach(window => { + if (!window.isDestroyed()) { + window.destroy(); + } + }); + }); + + it('releases a failed initialization so another login can open', async () => { + const parent = createWindow({}); + const popup = createWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(parent, 'https://idp.test')); + const flow = new SingleSignOn(popup, parent.webContents, Maybe.nothing(), 'https://idp.test'); + let active: SingleSignOn | null = flow; + const closed = new Promise(resolve => { + flow.onClose = () => { + active = null; + resolve(); + }; + }); + const clear = stub(popup.webContents.session, 'clearStorageData').rejects(new Error('storage unavailable')); + try { + await assert.rejects(flow.init(), /storage unavailable/); + flow.close(); + await closed; + assert.strictEqual(active, null); + } finally { + clear.restore(); + } + const nextPopup = createWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(parent, 'data:text/html,login')); + const next = new SingleSignOn(nextPopup, parent.webContents, Maybe.nothing(), 'data:text/html,login'); + const nextClosed = new Promise(resolve => { + next.onClose = resolve; + }); + await next.init(); + next.close(); + await nextClosed; + }); + + it('uses the same persistent SSO session for windows opened by separate accounts', async () => { + const firstAccount = createWindow({webPreferences: {partition: 'sso-test-account-one'}}); + const secondAccount = createWindow({webPreferences: {partition: 'sso-test-account-two'}}); + const firstLogin = createWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(firstAccount, 'https://idp.test')); + const secondLogin = createWindow( + SingleSignOn.getSingleSignOnLoginWindowOptions(secondAccount, 'https://idp.test'), + ); + const shared = firstLogin.webContents.session; + + assert.strictEqual(shared, secondLogin.webContents.session); + assert.notStrictEqual(shared, firstAccount.webContents.session); + assert.notStrictEqual(shared, secondAccount.webContents.session); + assert.notStrictEqual(shared, session.defaultSession); + assert.strictEqual(shared.isPersistent(), true); + + await shared.cookies.set({url: 'https://idp.test', name: 'sso-test', value: 'present'}); + await firstAccount.webContents.session.clearStorageData(); + assert.strictEqual((await shared.cookies.get({name: 'sso-test'})).length, 1); + await shared.clearStorageData(); + }); + + it('clears IdP cookies without replacing the persistent SSO session', async () => { + const parent = createWindow({}); + const login = createWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(parent, 'https://idp.test')); + const shared = login.webContents.session; + const storagePath = shared.storagePath; + await shared.cookies.set({url: 'https://idp.test', name: 'sso-test', value: 'present'}); + const flow = Object.create(SingleSignOn.prototype) as SingleSignOn; + flow['session'] = shared; + await flow['wipeSessionData'](); + + assert.strictEqual((await shared.cookies.get({name: 'sso-test'})).length, 0); + login.destroy(); + const nextLogin = createWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(parent, 'https://idp.test')); + assert.strictEqual(nextLogin.webContents.session, shared); + assert.strictEqual(nextLogin.webContents.session.storagePath, storagePath); + }); + + it('completes independent SSO and preserves the account cookie after popup cleanup', async function () { + this.timeout(15000); + const server = createServer((_request, response) => { + response.setHeader('Set-Cookie', 'zuid=test-login; Path=/access; HttpOnly; SameSite=Lax'); + response.end(``); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as {port: number}).port; + const backend = `http://127.0.0.1:${port}/`; + const appPath = stub(app, 'getAppPath').returns(path.resolve(__dirname, '../../..')); + const parent = createWindow({webPreferences: {partition: 'independent-sso-account'}}); + const options = SingleSignOn.getSingleSignOnLoginWindowOptions(parent, backend); + const popup = createWindow(options); + const shared = popup.webContents.session; + let result!: {origin: string; type: string}; + const send = stub(parent.webContents, 'send').callsFake((channel, value) => { + if (channel === 'wire:sso-result') { + result = value; + } + }); + const flow = new SingleSignOn(popup, parent.webContents, Maybe.nothing(), `${backend}/sso/initiate-login/test`); + const closed = new Promise(resolve => { + flow.onClose = resolve; + }); + try { + assert.notStrictEqual(shared, parent.webContents.session); + await flow.init(); + await closed; + assert.deepStrictEqual(result, {origin: backend, type: 'AUTH_SUCCESS'}); + assert.strictEqual( + (await parent.webContents.session.cookies.get({url: `${backend}access`, name: 'zuid'})).length, + 1, + ); + assert.strictEqual((await shared.cookies.get({name: 'zuid'})).length, 0); + // Removing the sub-app clears only its partition. A replacement sub-app's + // SSO window still uses the same persistent authentication session. + await parent.webContents.session.clearStorageData(); + const replacement = createWindow({webPreferences: {partition: 'replacement-sso-account'}}); + const next = createWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(replacement, backend)); + assert.strictEqual(next.webContents.session, shared); + assert.strictEqual(next.webContents.session.isPersistent(), true); + } finally { + send.restore(); + appPath.restore(); + flow.close(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + }); + + describe('independent SSO callback validation', () => { + it('preserves validated backend errors through the popup preload', async function () { + this.timeout(15000); + const server = createServer((_request, response) => response.end('SSO')); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const backend = `http://127.0.0.1:${(server.address() as {port: number}).port}`; + const appPath = stub(app, 'getAppPath').returns(path.resolve(__dirname, '../../..')); + const parent = new BrowserWindow({show: false}); + const popup = new BrowserWindow(SingleSignOn.getSingleSignOnLoginWindowOptions(parent, backend)); + const send = stub(parent.webContents, 'send'); + const flow = new SingleSignOn(popup, parent.webContents, Maybe.nothing(), `${backend}/sso/initiate-login/test`); + const closed = new Promise(resolve => { + flow.onClose = resolve; + }); + try { + await flow.init(); + assert.strictEqual( + await popup.webContents.executeJavaScript( + "__wireSsoOpener.postMessage({type:'AUTH_ERROR', payload:{label:'forbidden', errors:[42]}})", + ), + false, + ); + await popup.webContents.executeJavaScript( + "__wireSsoOpener.postMessage({type:'AUTH_ERROR', payload:{label:'forbidden', errors:['denied'], extra:'discard'}})", + ); + assert.ok( + send.calledOnceWithExactly('wire:sso-result', { + origin: backend, + type: 'AUTH_ERROR', + payload: {label: 'forbidden', errors: ['denied']}, + }), + ); + } finally { + flow.close(); + await closed; + parent.destroy(); + send.restore(); + appPath.restore(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + + it('rejects other origins and invalid result types, and reports a missing login cookie', async function () { + this.timeout(15000); + const server = createServer((_request, response) => response.end('SSO test')); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const backend = `http://127.0.0.1:${(server.address() as {port: number}).port}`; + const appPath = stub(app, 'getAppPath').returns(path.resolve(__dirname, '../../..')); + const parent = new BrowserWindow({show: false, webPreferences: {partition: 'callback-test-account'}}); + const options = SingleSignOn.getSingleSignOnLoginWindowOptions(parent, backend); + const popup = new BrowserWindow(options); + const send = stub(parent.webContents, 'send'); + const flow = new SingleSignOn(popup, parent.webContents, Maybe.nothing(), `${backend}/sso/initiate-login/test`); + const closed = new Promise(resolve => { + flow.onClose = resolve; + }); + try { + await flow.init(); + assert.strictEqual( + await popup.webContents.executeJavaScript("__wireSsoOpener.postMessage({type:'INVALID'})"), + false, + ); + await popup.loadURL('data:text/html,Other origin'); + assert.strictEqual( + await popup.webContents.executeJavaScript("__wireSsoOpener.postMessage({type:'AUTH_SUCCESS'})"), + false, + ); + assert.strictEqual(send.called, false); + await popup.loadURL(`${backend}/sso/initiate-login/test`); + await popup.webContents.executeJavaScript("__wireSsoOpener.postMessage({type:'AUTH_SUCCESS'})"); + assert.strictEqual( + send.calledOnceWithExactly('wire:sso-result', {origin: backend, type: 'AUTH_ERROR_COOKIE'}), + true, + ); + flow.close(); + await closed; + } finally { + flow.close(); + parent.destroy(); + send.restore(); + appPath.restore(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + }); + + describe('SSO cookie handoff', () => { + const source = () => session.fromPartition('sso-cookie-test-source'); + const target = () => session.fromPartition('sso-cookie-test-target'); + const backend = new URL('https://backend.test/sso/initiate-login/test'); + + afterEach(async () => { + await source().clearStorageData(); + await target().clearStorageData(); + }); + + it('copies the backend login cookie before SSO cleanup, without copying IdP or other backend cookies', async () => { + await source().cookies.set({ + url: 'https://backend.test/access', + name: 'zuid', + value: 'test-login', + path: '/access', + secure: true, + httpOnly: true, + }); + await source().cookies.set({url: 'https://other.test', name: 'zuid', value: 'other-login', secure: true}); + await source().cookies.set({ + url: 'https://backend.test', + name: 'KEYCLOAK_SESSION', + value: 'idp-login', + secure: true, + }); + + await SingleSignOn['copyCookies'](source(), target(), backend); + await source().clearStorageData(); + const cookies = await target().cookies.get({url: 'https://backend.test/access'}); + assert.strictEqual(cookies.length, 1); + assert.strictEqual(cookies[0].name, 'zuid'); + assert.strictEqual(cookies[0].value, 'test-login'); + assert.strictEqual(cookies[0].httpOnly, true); + assert.strictEqual(cookies[0].hostOnly, true); + assert.strictEqual((await target().cookies.get({url: 'https://child.backend.test/access'})).length, 0); + assert.strictEqual((await target().cookies.get({url: 'https://other.test'})).length, 0); + }); + + it('rejects success without a cookie for the requesting backend', async () => { + await source().cookies.set({url: 'https://other.test', name: 'zuid', value: 'other-login', secure: true}); + await assert.rejects( + SingleSignOn['copyCookies'](source(), target(), backend), + /without a Wire authentication cookie/, + ); }); }); }); diff --git a/electron/src/sso/SingleSignOn.ts b/electron/src/sso/SingleSignOn.ts index 88871b4bb2c..c4173328da7 100644 --- a/electron/src/sso/SingleSignOn.ts +++ b/electron/src/sso/SingleSignOn.ts @@ -17,23 +17,15 @@ * */ -import { - BrowserWindow, - BrowserWindowConstructorOptions, - Event as ElectronEvent, - ProtocolRequest, - Session, - session, - WebContents, - HandlerDetails, -} from 'electron'; +import {app, BrowserWindow, Event as ElectronEvent, Session, session, WebContents, HandlerDetails} from 'electron'; import {Maybe} from 'true-myth'; -import * as crypto from 'crypto'; import * as path from 'path'; import {URL} from 'url'; -import {executeJavaScriptWithoutResult} from '../lib/ElectronUtil'; +import {parseSsoPayload, SsoPayload} from './ssoResult'; + +import {registerTextPrompt} from '../auth/TextPrompt'; import {writeBoundedLogMessage} from '../logging/desktopLogWriter'; import {ENABLE_LOGGING, getLogger} from '../logging/getLogger'; import {getLogDirectory, getSsoLogPath} from '../logging/logPaths'; @@ -47,10 +39,9 @@ const argv = minimist(process.argv.slice(1)); export class SingleSignOn { private static readonly ALLOWED_BACKEND_ORIGINS = config.backendOrigins; private static readonly SINGLE_SIGN_ON_FRAME_NAME = 'WIRE_SSO'; - private static readonly SSO_PROTOCOL = `${config.customProtocolName}-sso`; - private static readonly SSO_PROTOCOL_HOST = 'response'; - private static readonly SSO_PROTOCOL_RESPONSE_SIZE_LIMIT = 255; - private static readonly SSO_SESSION_NAME = 'sso'; + // Shared across accounts, but separate from their removable webview partitions. + // Electron stores the Touch ID metadata secret in this persistent session. + private static readonly SSO_SESSION_NAME = 'persist:wire-sso'; private static readonly MAX_LENGTH_ORIGIN_DOMAIN = 255; private static readonly MAX_LENGTH_ORIGIN = 'https://'.length + SingleSignOn.MAX_LENGTH_ORIGIN_DOMAIN; private static readonly logger = getLogger(path.basename(__filename)); @@ -61,24 +52,20 @@ export class SingleSignOn { AUTH_SUCCESS: 'AUTH_SUCCESS', }; - public static loginAuthorizationSecret: string | undefined; - private session: Session | undefined; private ssoWindow: BrowserWindow | undefined; private readonly senderWebContents: WebContents; private readonly accountId: Maybe; - private readonly windowOptions: BrowserWindowConstructorOptions; private readonly windowOriginUrl: URL; public onClose = () => {}; + private completion: Promise | undefined; constructor( ssoWindow: BrowserWindow, senderWebContents: WebContents, accountId: Maybe, windowOriginURL: string, - windowOptions: BrowserWindowConstructorOptions, ) { - this.windowOptions = windowOptions; this.ssoWindow = ssoWindow; this.senderWebContents = senderWebContents; this.accountId = accountId; @@ -86,8 +73,15 @@ export class SingleSignOn { } public readonly init = async (): Promise => { - // Create a ephemeral and isolated session - this.session = session.fromPartition(SingleSignOn.SSO_SESSION_NAME, {cache: false}); + this.setupBrowserWindow(); + // Configure the actual popup session and cookie cleanup. + this.session = this.ssoWindow!.webContents.session; + if (this.session === this.senderWebContents.session || !this.session.isPersistent()) { + throw new Error('SSO requires a separate persistent session.'); + } + SingleSignOn.logger.info('[Passkeys] Using shared persistent SSO session, separate from account data.'); + // Discard website state left by an interrupted login, retaining preferences. + await this.session.clearStorageData(); // Disable browser permissions (microphone, camera...) this.session.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); @@ -98,14 +92,38 @@ export class SingleSignOn { callback({cancel: false, requestHeaders}); }); - this.setupBrowserWindow(); - - // Register protocol - // Note: we need to create the window before otherwise it does not work - await SingleSignOn.registerProtocol(this.session, type => this.finalizeLogin(type)); + registerTextPrompt(this.ssoWindow!); + const popup = this.ssoWindow!; + popup.webContents.ipc.handle('wire:sso-complete', async (event, type: unknown, rawPayload: unknown) => { + if ( + event.senderFrame !== popup.webContents.mainFrame || + typeof type !== 'string' || + !['AUTH_SUCCESS', 'AUTH_ERROR', 'AUTH_ERROR_COOKIE'].includes(type) + ) { + return false; + } + if (new URL(event.senderFrame.url).origin !== this.windowOriginUrl.origin) { + SingleSignOn.logger.warn('[Passkeys] Rejected SSO result from an unexpected origin.'); + return false; + } + const payload = parseSsoPayload(rawPayload); + if (rawPayload !== undefined && !payload) { + return false; + } + this.completion ??= this.finalizeLogin(type, payload); + await this.completion; + return true; + }); + popup.webContents.ipc.on('wire:sso-close', event => { + if (event.senderFrame === popup.webContents.mainFrame) { + this.close(); + } + }); + this.senderWebContents.once('destroyed', this.close); // Show the window(s) await this.ssoWindow?.loadURL(this.windowOriginUrl.toString()); + this.ssoWindow?.show(); if (typeof argv[config.ARGUMENT.DEVTOOLS] !== 'undefined') { this.ssoWindow?.webContents.openDevTools({mode: 'detach'}); @@ -120,22 +138,19 @@ export class SingleSignOn { } const ssoWindow = this.ssoWindow; - if (this.windowOptions.webPreferences) { - // Discard old preload URL - delete this.windowOptions.webPreferences.preload; - } - ssoWindow.once('closed', async () => { - if (this.session) { - await this.wipeSessionData(); - const unregisterSuccess = SingleSignOn.unregisterProtocol(this.session); - if (!unregisterSuccess) { - throw new Error('Failed to unregister protocol'); + this.senderWebContents.removeListener('destroyed', this.close); + try { + if (this.session) { + await this.wipeSessionData(); } + } catch { + SingleSignOn.logger.warn('Unable to clear SSO website data. It will be cleared before the next login.'); + } finally { + this.session = undefined; + this.ssoWindow = undefined; + this.onClose(); } - this.onClose(); - this.session = undefined; - this.ssoWindow = undefined; }); // Prevent title updates @@ -178,20 +193,7 @@ export class SingleSignOn { } close = () => { - (async () => { - if (this.session) { - await this.wipeSessionData(); - const unregisterSuccess = SingleSignOn.unregisterProtocol(this.session); - if (!unregisterSuccess) { - console.error('Failed to unregister protocol'); - } - } - this.ssoWindow?.close(); - this.session = undefined; - this.ssoWindow = undefined; - })() - .then(console.info) - .catch(console.info); + this.ssoWindow?.close(); }; focus = () => { @@ -204,91 +206,47 @@ export class SingleSignOn { public static getSingleSignOnLoginWindowOptions = ( parent: BrowserWindow, origin: string, - ): Electron.BrowserWindowConstructorOptions => - WindowUtil.getNewWindowOptions({ + ): Electron.BrowserWindowConstructorOptions => { + const options = WindowUtil.getNewWindowOptions({ title: SingleSignOn.getWindowTitle(origin), parent, width: 480, height: 600, }); + return { + ...options, + show: false, + webPreferences: { + ...options.webPreferences, + session: session.fromPartition(SingleSignOn.SSO_SESSION_NAME, {cache: false}), + partition: SingleSignOn.SSO_SESSION_NAME, + preload: path.join(app.getAppPath(), config.electronDirectory, 'dist/preload/preload-sso.js'), + }, + }; + }; // Returns an empty string if the origin is a Wire backend public static getWindowTitle = (origin: string): string => SingleSignOn.ALLOWED_BACKEND_ORIGINS.includes(origin) ? '' : origin; private static async copyCookies(fromSession: Session, toSession: Session, url: URL): Promise { - const cookies = await fromSession.cookies.get({name: 'zuid'}); + // Use /access, since the login cookie may have a path that excludes /sso. + const cookieUrl = new URL('/access', url.origin).toString(); + const cookies = await fromSession.cookies.get({name: 'zuid', url: cookieUrl}); + if (cookies.length === 0) { + throw new Error('SSO completed without a Wire authentication cookie.'); + } for (const cookie of cookies) { - if (cookie.domain) { - await toSession.cookies.set({url: url.toString(), ...cookie}); - } + const {name, value, path, secure, httpOnly, expirationDate, sameSite} = cookie; + await toSession.cookies.set({url: cookieUrl, name, value, path, secure, httpOnly, expirationDate, sameSite}); } await toSession.cookies.flushStore(); + SingleSignOn.logger.info('[Passkeys] Wire authentication cookie transferred to the requesting account.'); } - private static generateSecret(length: number): Promise { - return new Promise((resolve, reject) => { - crypto.randomBytes(length, (error, bytes) => (error ? reject(error) : resolve(bytes.toString('hex')))); - }); - } - - private static async registerProtocol(session: Session, finalizeLogin: (type: string) => void): Promise { - // Generate a new secret to authenticate the custom protocol (wire-sso) - SingleSignOn.loginAuthorizationSecret = await SingleSignOn.generateSecret(24); - - const handleRequest = (request: ProtocolRequest): void => { - try { - const requestURL = new URL(request.url); - - if (requestURL.protocol !== `${SingleSignOn.SSO_PROTOCOL}:`) { - throw new Error('Protocol is invalid'); - } - - if (requestURL.hostname !== SingleSignOn.SSO_PROTOCOL_HOST) { - throw new Error('Host is invalid'); - } - - if (typeof SingleSignOn.loginAuthorizationSecret !== 'string') { - throw new Error('Secret has not be set or has been consumed'); - } - - if (requestURL.searchParams.get('secret') !== SingleSignOn.loginAuthorizationSecret) { - throw new Error('Secret is invalid'); - } - - const type = requestURL.searchParams.get('type'); - - if (typeof type !== 'string') { - throw new Error('Response is empty'); - } - - if (type.length > SingleSignOn.SSO_PROTOCOL_RESPONSE_SIZE_LIMIT) { - throw new Error('Response type is too long'); - } - - finalizeLogin(type); - } catch (error) { - SingleSignOn.logger.error(error); - } - }; - - const isRegistered = session.protocol.isProtocolRegistered(SingleSignOn.SSO_PROTOCOL); - - if (!isRegistered) { - const registerSuccess = session.protocol.registerStringProtocol(SingleSignOn.SSO_PROTOCOL, handleRequest); - if (!registerSuccess) { - throw new Error('Failed to register protocol.'); - } - } - } - - private static unregisterProtocol(session: Session): boolean { - return session.protocol.unregisterProtocol(SingleSignOn.SSO_PROTOCOL); - } - - private readonly finalizeLogin = async (type: string): Promise => { + private readonly finalizeLogin = async (type: string, payload?: SsoPayload): Promise => { if (type === SingleSignOn.RESPONSE_TYPES.AUTH_SUCCESS) { if (!this.session) { await this.dispatchResponse(SingleSignOn.RESPONSE_TYPES.AUTH_ERROR_SESS_NOT_AVAILABLE); @@ -296,7 +254,7 @@ export class SingleSignOn { return; } - // Set cookies from ephemeral session to the default one + // Copy the Wire authentication cookies to the requesting account's session. try { await SingleSignOn.copyCookies(this.session, this.senderWebContents.session, this.windowOriginUrl); } catch (error) { @@ -307,22 +265,39 @@ export class SingleSignOn { } } - await this.dispatchResponse(type); + await this.dispatchResponse(type, payload); }; - private async dispatchResponse(type: string): Promise { + private async dispatchResponse(type: string, payload?: SsoPayload): Promise { // Ensure guest window provided type is valid const isTypeValid = /^[A-Z_]{1,255}$/g; if (isTypeValid.test(type) === false) { throw new Error('Invalid type detected, aborting.'); } - // Fake postMessage to the webview - const snippet = `window.dispatchEvent(new MessageEvent('message', {origin: '${this.windowOriginUrl.origin}', data: {type: '${type}'}}))`; - await executeJavaScriptWithoutResult(snippet, this.senderWebContents); + if (this.senderWebContents.isDestroyed()) { + return; + } + // Preserve the backend base URL format used by the webapp (which may include + // a trailing slash). Native origin checks above always use URL.origin. + const marker = '/sso/initiate-login/'; + const originalUrl = this.windowOriginUrl.toString(); + const index = originalUrl.indexOf(marker); + const origin = index >= 0 ? originalUrl.slice(0, index) : this.windowOriginUrl.origin; + this.senderWebContents.send('wire:sso-result', {origin, type, ...(payload ? {payload} : {})}); } private async wipeSessionData() { + // Chromium's native window.open popup can inherit the opener's session even + // when BrowserWindow options request another partition. Never wipe account + // storage here: doing so removes the login cookie immediately after success. + if (this.senderWebContents && this.session === this.senderWebContents.session) { + SingleSignOn.logger.info('[Passkeys] Preserved account session when closing native SSO popup.'); + return; + } + // Remove website storage/cookies, preserving session preferences (including + // Electron's WebAuthn metadata secret) and the keychain credentials. await this.session?.clearStorageData(undefined); + SingleSignOn.logger.info('[Passkeys] Cleared SSO website data; retained shared passkey session preferences.'); } } diff --git a/electron/src/sso/browserSsoCallback.test.main.ts b/electron/src/sso/browserSsoCallback.test.main.ts new file mode 100644 index 00000000000..cdd142cf93d --- /dev/null +++ b/electron/src/sso/browserSsoCallback.test.main.ts @@ -0,0 +1,119 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import * as assert from 'assert'; + +import {createBrowserSsoRequest, parseBrowserSsoCallback} from './browserSsoCallback'; + +const backend = new URL('https://backend.example/sso/initiate-login/11111111-1111-1111-1111-111111111111'); +const state = 'test-state'; +const expiry = () => Date.now() + 60000; +const callback = (cookie = 'zuid=test-session; Path=/access; Secure; HttpOnly') => { + const url = new URL('wire://login/success'); + url.searchParams.set('validation_token', state); + url.searchParams.set('userid', '11111111-1111-1111-1111-111111111111'); + url.searchParams.set('cookie', cookie); + return url; +}; +const parse = (url: URL) => parseBrowserSsoCallback(url.toString(), backend, 'wire', state, expiry()); + +describe('browser SSO callback', () => { + it('uses the iOS backend redirect templates and replaces caller-supplied redirects', () => { + const url = createBrowserSsoRequest(`${backend}?success_redirect=https://evil.example`, 'wire', state); + assert.ok(url.searchParams.get('success_redirect')!.includes('cookie=$cookie&userid=$userid')); + assert.ok(url.searchParams.get('error_redirect')!.includes('label=$label')); + const success = new URL(url.searchParams.get('success_redirect')!); + assert.strictEqual(success.searchParams.get('cookie'), '$cookie'); + assert.strictEqual(success.searchParams.get('userid'), '$userid'); + assert.strictEqual(success.searchParams.get('validation_token'), state); + assert.strictEqual(success.origin, 'null'); + assert.strictEqual(success.host, 'login'); + }); + + it('preserves validated failure labels and rejects duplicate labels or wrong state', () => { + const url = new URL(`wire://login/failure?label=forbidden&validation_token=${state}`); + assert.deepStrictEqual(parse(url), {type: 'AUTH_ERROR', payload: {label: 'forbidden'}}); + url.searchParams.set('validation_token', 'wrong'); + assert.throws(() => parse(url)); + url.searchParams.set('validation_token', state); + url.searchParams.append('label', 'other'); + assert.throws(() => parse(url)); + }); + + it('rejects non-HTTPS, credentials and non-SSO initiation URLs', () => { + for (const url of [ + 'http://backend.example/sso/initiate-login/x', + 'https://user:pass@backend.example/sso/initiate-login/x', + 'https://backend.example/other', + ]) { + assert.throws(() => createBrowserSsoRequest(url, 'wire', state)); + } + }); + + it('accepts only the Wire cookie and scopes it to the initiating backend', () => { + const cookie = parse(callback()); + if ('type' in cookie) { + throw new Error('Expected a success cookie'); + } + assert.strictEqual(cookie.url, 'https://backend.example/access'); + assert.strictEqual(cookie.value, 'test-session'); + assert.strictEqual(cookie.domain, undefined); + assert.strictEqual(cookie.secure, true); + assert.strictEqual(cookie.httpOnly, true); + }); + + it('rejects wrong state, duplicate state, wrong scheme, host, path, expired and oversized callbacks', () => { + for (const mutate of [ + (url: URL) => url.searchParams.set('validation_token', 'other'), + (url: URL) => url.searchParams.append('validation_token', state), + (url: URL) => { + url.protocol = 'other:'; + }, + (url: URL) => { + url.host = 'other'; + }, + (url: URL) => { + url.pathname = '/failure'; + }, + (url: URL) => url.searchParams.append('cookie', 'zuid=other'), + ]) { + const url = callback(); + mutate(url); + assert.throws(() => parse(url)); + } + assert.throws(() => parseBrowserSsoCallback(callback().toString(), backend, 'wire', state, Date.now() - 1)); + assert.throws(() => parseBrowserSsoCallback('x'.repeat(16385), backend, 'wire', state, expiry())); + }); + + it('rejects foreign domains, unrelated cookies, header injection, invalid paths and expiry', () => { + for (const cookie of [ + 'other=value', + 'zuid=value; Domain=evil.example', + 'zuid=value; SameSite=__proto__', + 'zuid=value; Path=/other', + 'zuid=value\r\nSet-Cookie: other=value', + 'zuid=value; Max-Age=0', + 'zuid=value; Expires=invalid', + 'zuid=value; Path=/; Path=/access', + 'zuid=value; Expires=Thu, 01 Jan 1970 00:00:00 GMT', + ]) { + assert.throws(() => parse(callback(cookie))); + } + }); +}); diff --git a/electron/src/sso/browserSsoCallback.ts b/electron/src/sso/browserSsoCallback.ts new file mode 100644 index 00000000000..cf527bb885a --- /dev/null +++ b/electron/src/sso/browserSsoCallback.ts @@ -0,0 +1,156 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {CookiesSetDetails} from 'electron'; + +import {parseSsoPayload, SsoPayload} from './ssoResult'; + +export const SSO_TIMEOUT_MS = 30 * 60 * 1000; + +export function createBrowserSsoRequest(loginUrl: string, scheme: string, state: string): URL { + const url = new URL(loginUrl); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.hash || + !/^\/sso\/initiate-login\/[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(url.pathname) || + !/^[a-z][a-z0-9+.-]*$/.test(scheme) + ) { + throw new Error('Invalid SSO login URL.'); + } + const success = new URL(`${scheme}://login/success`); + success.searchParams.set('cookie', '$cookie'); + success.searchParams.set('userid', '$userid'); + success.searchParams.set('validation_token', state); + const failure = new URL(`${scheme}://login/failure`); + failure.searchParams.set('label', '$label'); + failure.searchParams.set('validation_token', state); + // Keep literal placeholders inside the redirect, as iOS does. Encode only the + // outer query; double-encoding the dollar sign breaks backend substitution. + url.searchParams.set( + 'success_redirect', + success.toString().replace('%24cookie', '$cookie').replace('%24userid', '$userid'), + ); + url.searchParams.set('error_redirect', failure.toString().replace('%24label', '$label')); + return url; +} + +export function parseBrowserSsoCallback( + callback: string, + backend: URL, + scheme: string, + state: string, + expiresAt: number, +): CookiesSetDetails | {type: 'AUTH_ERROR'; payload: SsoPayload} { + if (Date.now() >= expiresAt || callback.length > 16384) { + throw new Error('Expired or oversized SSO callback.'); + } + const url = new URL(callback); + if ( + url.protocol !== `${scheme}:` || + url.host !== 'login' || + url.username || + url.password || + url.hash || + url.searchParams.getAll('validation_token').length !== 1 || + url.searchParams.get('validation_token') !== state + ) { + throw new Error('Invalid SSO callback.'); + } + if (url.pathname === '/failure') { + const payload = parseSsoPayload({label: url.searchParams.get('label')}); + if (url.searchParams.getAll('label').length !== 1 || !payload) { + throw new Error('Invalid SSO failure callback.'); + } + return {type: 'AUTH_ERROR', payload}; + } + if (url.pathname !== '/success') { + throw new Error('SSO authentication failed.'); + } + if ( + url.searchParams.getAll('cookie').length !== 1 || + url.searchParams.getAll('userid').length !== 1 || + !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(url.searchParams.get('userid') || '') + ) { + throw new Error('Invalid SSO callback fields.'); + } + const header = url.searchParams.get('cookie')!; + if (/[\r\n\0]/.test(header)) { + throw new Error('Invalid SSO cookie.'); + } + const [pair, ...attributes] = header.split(';').map(part => part.trim()); + const match = /^zuid=([\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+)$/.exec(pair); + if (!match) { + throw new Error('Missing Wire session cookie.'); + } + const cookie: CookiesSetDetails = { + url: new URL('/access', backend.origin).toString(), + name: 'zuid', + value: match[1], + path: '/access', + secure: true, + httpOnly: true, + }; + const seen = new Set(); + for (const attribute of attributes) { + const index = attribute.indexOf('='); + const name = (index < 0 ? attribute : attribute.slice(0, index)).toLowerCase(); + const value = index < 0 ? '' : attribute.slice(index + 1); + if (seen.has(name)) { + throw new Error('Duplicate cookie attribute.'); + } + seen.add(name); + if (name === 'domain' && value.replace(/^\./, '').toLowerCase() !== backend.hostname.toLowerCase()) { + throw new Error('Unexpected cookie domain.'); + } + // Store host-only, even if the backend supplied a Domain attribute. + if (name === 'path') { + if (value !== '/' && value !== '/access') { + throw new Error('Unexpected cookie path.'); + } + cookie.path = value; + } + if (name === 'expires') { + cookie.expirationDate = Date.parse(value) / 1000; + if (!Number.isFinite(cookie.expirationDate)) { + throw new Error('Invalid cookie expiry.'); + } + } + if (name === 'samesite') { + const sameSite = {none: 'no_restriction', lax: 'lax', strict: 'strict'} as const; + if (!Object.prototype.hasOwnProperty.call(sameSite, value.toLowerCase())) { + throw new Error('Invalid SameSite attribute.'); + } + cookie.sameSite = sameSite[value.toLowerCase() as keyof typeof sameSite]; + } + } + const maxAge = attributes.find(attribute => /^max-age=/i.test(attribute)); + if (maxAge) { + const age = maxAge.slice(maxAge.indexOf('=') + 1); + if (!/^\d+$/.test(age) || !Number.isSafeInteger(Number(age))) { + throw new Error('Invalid cookie Max-Age.'); + } + cookie.expirationDate = Date.now() / 1000 + Number(age); + } + if (cookie.expirationDate !== undefined && cookie.expirationDate <= Date.now() / 1000) { + throw new Error('Expired SSO cookie.'); + } + return cookie; +} diff --git a/electron/src/sso/ssoResult.ts b/electron/src/sso/ssoResult.ts new file mode 100644 index 00000000000..3b461c87b4d --- /dev/null +++ b/electron/src/sso/ssoResult.ts @@ -0,0 +1,43 @@ +/* + * Wire + * Copyright (C) 2018 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export interface SsoPayload { + label: string; + errors?: string[]; +} + +// Accept only bounded backend error fields, never arbitrary page-supplied objects. +export function parseSsoPayload(value: unknown): SsoPayload | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const {label, errors} = value as {label?: unknown; errors?: unknown}; + if (typeof label !== 'string' || !/^[a-zA-Z0-9_-]{1,255}$/.test(label)) { + return undefined; + } + if ( + errors !== undefined && + (!Array.isArray(errors) || + errors.length > 32 || + !errors.every(error => typeof error === 'string' && error.length <= 4096)) + ) { + return undefined; + } + return {label, ...(errors !== undefined ? {errors: errors as string[]} : {})}; +} diff --git a/jenkins/macOS.groovy b/jenkins/macOS.groovy index 4a03bf05996..49bc12e8ee2 100644 --- a/jenkins/macOS.groovy +++ b/jenkins/macOS.groovy @@ -8,7 +8,7 @@ node("macos") { def custom = params.CUSTOM def wireGov = params.WIRE_GOV def skipNotarization = params.containsKey('SKIP_NOTARIZATION') ? params.SKIP_NOTARIZATION : true - def NODE = tool name: 'node-v18.18.0', type: 'nodejs' + def NODE = tool name: 'node-v23.0.0', type: 'nodejs' def privateAPIResult = '' def jenkinsbot_secret = '' diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index 48712b1fcea..0aa7354f2c6 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -8,7 +8,7 @@ node('windows') { def production = params.PRODUCTION def custom = params.CUSTOM def wireGov = params.WIRE_GOV - def NODE = tool name: 'node-v18.18.0', type: 'nodejs' + def NODE = tool name: 'node-v23.0.0', type: 'nodejs' def jenkinsbot_secret = '' diff --git a/package.json b/package.json index ffd7d55d42c..71f8a4a174c 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "cross-env": "7.0.3", "css-loader": "7.1.4", "dotenv": "16.6.0", - "electron": "38.8.6", + "electron": "43.1.1", "electron-builder": "25.1.8", "electron-mocha": "12.3.1", "electron-packager": "17.1.2", @@ -137,6 +137,7 @@ "main": "electron/dist/main.js", "name": "wire-desktop", "optionalDependencies": { + "objc-js": "1.5.0", "registry-js": "1.16.1" }, "prettier": "@wireapp/prettier-config", diff --git a/yarn.lock b/yarn.lock index b5668143f3f..ea77f174b3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2397,6 +2397,13 @@ __metadata: languageName: node linkType: hard +"@electron-internal/extract-zip@npm:^1.0.1": + version: 1.0.5 + resolution: "@electron-internal/extract-zip@npm:1.0.5" + checksum: 941b78e62fc44e68ca6bc7f38f59c29064d1c3a007db3422087c4392292205721820f95f81fa2f16da0661dece00ed5f45892917a2a71e5f925e9fda9d96515f + languageName: node + linkType: hard + "@electron/asar@npm:^3.2.1": version: 3.2.4 resolution: "@electron/asar@npm:3.2.4" @@ -2456,6 +2463,24 @@ __metadata: languageName: node linkType: hard +"@electron/get@npm:^5.0.0": + version: 5.1.0 + resolution: "@electron/get@npm:5.1.0" + dependencies: + debug: ^4.1.1 + env-paths: ^3.0.0 + graceful-fs: ^4.2.11 + progress: ^2.0.3 + semver: ^7.6.3 + sumchecker: ^3.0.1 + undici: ^7.24.4 + dependenciesMeta: + undici: + optional: true + checksum: 88b85ec30689c6743c2d9655d68dd1b82d9e7c814b47cea87193cdf3482e48391421c4e268f8ed572b267fe2e4228788689288f7ea9d64e21a4bc15b38499d23 + languageName: node + linkType: hard + "@electron/notarize@npm:2.5.0": version: 2.5.0 resolution: "@electron/notarize@npm:2.5.0" @@ -4850,12 +4875,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^22.7.7": - version: 22.13.9 - resolution: "@types/node@npm:22.13.9" +"@types/node@npm:^24.9.0": + version: 24.13.3 + resolution: "@types/node@npm:24.13.3" dependencies: - undici-types: ~6.20.0 - checksum: d36ae841fa20aa01aefecfeb9363cbc9a5d7ede711fd6bdd9e872975987d6ce2720d4196c8cc7d2c53b3353a121250f96550873f18a73477de86b4198b25bab5 + undici-types: ~7.18.0 + checksum: e3f2142e02dd7b44885bf16d5438e7d510c5917a272011ac594665fe38f453dea810cb4c8cd989d6cbaf769d46dbaf017eee4bdbf4246737f2b6c6774e093332 languageName: node linkType: hard @@ -9363,16 +9388,17 @@ __metadata: languageName: node linkType: hard -"electron@npm:38.8.6": - version: 38.8.6 - resolution: "electron@npm:38.8.6" +"electron@npm:43.1.1": + version: 43.1.1 + resolution: "electron@npm:43.1.1" dependencies: - "@electron/get": ^2.0.0 - "@types/node": ^22.7.7 - extract-zip: ^2.0.1 + "@electron-internal/extract-zip": ^1.0.1 + "@electron/get": ^5.0.0 + "@types/node": ^24.9.0 bin: electron: cli.js - checksum: 6a146667eb70b52439d59ba4fff24d0afa23d1f5080232ae950e411c7bdc30978df7be5fd3a869c7d93e1c3cb365fd8003fed60ba79868e648e184ca15e32c4d + install-electron: install.js + checksum: cfd85485f59bb95e2d41473a9832ec7177f1033b4ab697d60754f040344c481ad7ef0521afc3b350007b9eb8165d321ea51290c2567bb067fcce23ddf209ad9f languageName: node linkType: hard @@ -9499,6 +9525,13 @@ __metadata: languageName: node linkType: hard +"env-paths@npm:^3.0.0": + version: 3.0.0 + resolution: "env-paths@npm:3.0.0" + checksum: b2b0a0d0d9931a13d279c22ed94d78648a1cc5f408f05d47ff3e0c1616f0aa0c38fb33deec5e5be50497225d500607d57f9c8652c4d39c2f2b7608cd45768128 + languageName: node + linkType: hard + "envinfo@npm:^7.7.3": version: 7.8.1 resolution: "envinfo@npm:7.8.1" @@ -11145,7 +11178,7 @@ __metadata: languageName: node linkType: hard -"extract-zip@npm:^2.0.0, extract-zip@npm:^2.0.1": +"extract-zip@npm:^2.0.0": version: 2.0.1 resolution: "extract-zip@npm:2.0.1" dependencies: @@ -15739,6 +15772,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^8.5.0": + version: 8.9.2 + resolution: "node-addon-api@npm:8.9.2" + dependencies: + node-gyp: latest + checksum: 820b3099b7d27c555fd9d98c986feab52d7eaf2e4dac1bce927857368baa0f2a3f31db5bd5112acd45222fd33a453b3baf8bf78771a0643a4b712e09a33f0fdf + languageName: node + linkType: hard + "node-api-version@npm:^0.2.0": version: 0.2.1 resolution: "node-api-version@npm:0.2.1" @@ -15748,6 +15790,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.8.4": + version: 4.8.4 + resolution: "node-gyp-build@npm:4.8.4" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 8b81ca8ffd5fa257ad8d067896d07908a36918bc84fb04647af09d92f58310def2d2b8614d8606d129d9cd9b48890a5d2bec18abe7fcff54818f72bedd3a7d74 + languageName: node + linkType: hard + "node-gyp@npm:^9.0.0": version: 9.4.1 resolution: "node-gyp@npm:9.4.1" @@ -16007,6 +16060,17 @@ __metadata: languageName: node linkType: hard +"objc-js@npm:1.5.0": + version: 1.5.0 + resolution: "objc-js@npm:1.5.0" + dependencies: + node-addon-api: ^8.5.0 + node-gyp: latest + node-gyp-build: ^4.8.4 + conditions: os=darwin + languageName: node + linkType: hard + "object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -20251,10 +20315,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.20.0": - version: 6.20.0 - resolution: "undici-types@npm:6.20.0" - checksum: b7bc50f012dc6afbcce56c9fd62d7e86b20a62ff21f12b7b5cbf1973b9578d90f22a9c7fe50e638e96905d33893bf2f9f16d98929c4673c2480de05c6c96ea8b +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 23da306c8366574adec305b06a8519ab5c7d09e3f5d16c1a98709a34fae17da09ec95198f30f86c00055e02efa8bfcc843e84e8aebeb9b8d6bb3e06afccae07a languageName: node linkType: hard @@ -20265,6 +20329,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^7.24.4": + version: 7.29.0 + resolution: "undici@npm:7.29.0" + checksum: b3327e05259e66c61d3e05242bf63899c68d277302208f4fc1c7c0b880a9ba82651b8b3215584ffd424d8c8410f0114b6c3a89e6af604c703dc33009daa99ed3 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0" @@ -21164,7 +21235,7 @@ __metadata: cross-env: 7.0.3 css-loader: 7.1.4 dotenv: 16.6.0 - electron: 38.8.6 + electron: 43.1.1 electron-builder: 25.1.8 electron-dl: ^3.5.2 electron-mocha: 12.3.1 @@ -21208,6 +21279,7 @@ __metadata: nock: 13.5.6 nyc: 15.1.0 oazapfts: 7.5.0 + objc-js: 1.5.0 prettier: 2.8.8 react: 18.3.1 react-dom: 18.3.1 @@ -21227,6 +21299,8 @@ __metadata: webpack: 5.106.2 webpack-cli: 5.1.4 dependenciesMeta: + objc-js: + optional: true registry-js: optional: true languageName: unknown