From f418903046d198e1c6326c2647352bd7718c8a08 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Mon, 28 Sep 2026 18:46:57 +0200 Subject: [PATCH 1/7] feat: add passkey functionality [WPB-27034] --- README.md | 10 + bin/build-tools/lib/Config.ts | 2 + bin/build-tools/lib/build-macos.test.ts | 4 + bin/build-tools/lib/build-macos.ts | 33 ++- bin/build-tools/lib/commonConfig.test.ts | 2 + bin/passkey-diagnostics.cjs | 147 ++++++++++++++ bin/passkey-diagnostics.test.cjs | 93 +++++++++ electron/css/text-prompt.css | 66 ++++++ electron/html/text-prompt.html | 25 +++ electron/src/auth/TextPrompt.test.main.ts | 165 +++++++++++++++ electron/src/auth/TextPrompt.ts | 156 ++++++++++++++ electron/src/auth/WebAuthn.test.main.ts | 107 ++++++++++ electron/src/auth/WebAuthn.ts | 87 ++++++++ electron/src/locale/en-US.json | 1 + electron/src/locale/index.ts | 46 ++--- electron/src/mainProcess.ts | 93 ++++----- electron/src/preload/preload-sso.ts | 60 ++++++ electron/src/preload/preload-text-prompt.ts | 56 +++++ electron/src/preload/preload-webview.ts | 7 + electron/src/settings/config.ts | 1 + electron/src/sso/SingleSignOn.test.main.ts | 206 ++++++++++++++++++- electron/src/sso/SingleSignOn.ts | 214 +++++++++----------- electron/wire.json | 1 + jenkins/macOS.groovy | 80 +++++--- jenkins/windows.groovy | 7 +- package.json | 4 +- resources/macos/entitlements/parent.plist | 4 + yarn.lock | 76 +++++-- 28 files changed, 1498 insertions(+), 255 deletions(-) create mode 100644 bin/passkey-diagnostics.cjs create mode 100644 bin/passkey-diagnostics.test.cjs create mode 100644 electron/css/text-prompt.css create mode 100644 electron/html/text-prompt.html create mode 100644 electron/src/auth/TextPrompt.test.main.ts create mode 100644 electron/src/auth/TextPrompt.ts create mode 100644 electron/src/auth/WebAuthn.test.main.ts create mode 100644 electron/src/auth/WebAuthn.ts create mode 100644 electron/src/preload/preload-sso.ts create mode 100644 electron/src/preload/preload-text-prompt.ts diff --git a/README.md b/README.md index 3f016ee10b1..507179579d5 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,16 @@ yarn build:win yarn build:linux ``` +Signed macOS builds require a provisioning profile because WebAuthn uses a restricted keychain-access-group entitlement. Provide the profile through `MACOS_PROVISIONING_PROFILE`; the build embeds it in the application before applying the final signature. Jenkins expects Secret file credentials named `MACOS_PROVISIONING_PROFILE` and `MACOS_PROVISIONING_PROFILE_INTERNAL` for the corresponding bundle identifiers. + +For passkey diagnostics, search Jenkins **Console Output** for `[Passkeys]`. Both pipelines log the Node/Electron versions and fail on an incompatible Node version. The macOS Jenkins job needs a NodeJS tool named `node-v23.0.0` under **Manage Jenkins → Tools**. The macOS job also checks the built app's signature, runtime keychain group, signed entitlements, embedded profile authorization, bundle identifier, profile expiry, and whether the signing certificate is valid and included in the profile. `BUILD CHECKS PASSED` confirms these build prerequisites; it does not confirm a successful login. A `FAIL` message identifies the failed check. If credentials or Node setup fail earlier, follow the setup message immediately above that failure. + +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. + +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. Register a passkey once in this new shared session after upgrading: existing credentials in account-specific partitions are not migrated. 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]`. Startup logs confirm configuration, and 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. Touch ID credentials configured here are device-bound; existing iCloud/Safari passkeys are not validated by the Jenkins checks. + ### Other Linux targets If you would like to build for another Linux target, run the following command: diff --git a/bin/build-tools/lib/Config.ts b/bin/build-tools/lib/Config.ts index 8823354ac76..2f284d4a395 100644 --- a/bin/build-tools/lib/Config.ts +++ b/bin/build-tools/lib/Config.ts @@ -41,6 +41,7 @@ export interface CommonConfig { supportUrl: string; updateUrl?: string; version: string; + webAuthnKeychainAccessGroup: string; websiteUrl: string; } @@ -62,6 +63,7 @@ export interface MacOSConfig { electronMirror: string | null; notarizeAppleId: string | null; notarizeApplePassword: string | null; + provisioningProfile: string | null; } export interface WindowsConfig { diff --git a/bin/build-tools/lib/build-macos.test.ts b/bin/build-tools/lib/build-macos.test.ts index f3f983438c1..1bb29306187 100644 --- a/bin/build-tools/lib/build-macos.test.ts +++ b/bin/build-tools/lib/build-macos.test.ts @@ -33,12 +33,14 @@ describe('build-macos', () => { const certNameInstaller = generateUUID(); const notarizeAppleId = generateUUID(); const notarizeApplePassword = generateUUID(); + const provisioningProfile = __filename; process.env.MACOS_BUNDLE_ID = bundleId; process.env.MACOS_CERTIFICATE_NAME_APPLICATION = certNameApplication; process.env.MACOS_CERTIFICATE_NAME_INSTALLER = certNameInstaller; process.env.MACOS_NOTARIZE_APPLE_ID = notarizeAppleId; process.env.MACOS_NOTARIZE_APPLE_PASSWORD = notarizeApplePassword; + process.env.MACOS_PROVISIONING_PROFILE = provisioningProfile; const {macOSConfig} = await buildMacOSConfig(wireJsonPath, envFilePath); @@ -47,12 +49,14 @@ describe('build-macos', () => { assert.strictEqual(macOSConfig.certNameInstaller, certNameInstaller); assert.strictEqual(macOSConfig.notarizeAppleId, notarizeAppleId); assert.strictEqual(macOSConfig.notarizeApplePassword, notarizeApplePassword); + assert.strictEqual(macOSConfig.provisioningProfile, provisioningProfile); delete process.env.MACOS_BUNDLE_ID; delete process.env.MACOS_CERTIFICATE_NAME_APPLICATION; delete process.env.MACOS_CERTIFICATE_NAME_INSTALLER; delete process.env.MACOS_NOTARIZE_APPLE_ID; delete process.env.MACOS_NOTARIZE_APPLE_PASSWORD; + delete process.env.MACOS_PROVISIONING_PROFILE; }); }); }); diff --git a/bin/build-tools/lib/build-macos.ts b/bin/build-tools/lib/build-macos.ts index f0c6f45e01d..f4a29c53a3b 100755 --- a/bin/build-tools/lib/build-macos.ts +++ b/bin/build-tools/lib/build-macos.ts @@ -56,6 +56,7 @@ export async function buildMacOSConfig( electronMirror: null, notarizeAppleId: null, notarizeApplePassword: null, + provisioningProfile: null, }; const macOSConfig: MacOSConfig = { @@ -67,8 +68,20 @@ export async function buildMacOSConfig( electronMirror: process.env.MACOS_ELECTRON_MIRROR_URL || macOSDefaultConfig.electronMirror, notarizeAppleId: process.env.MACOS_NOTARIZE_APPLE_ID || macOSDefaultConfig.notarizeAppleId, notarizeApplePassword: process.env.MACOS_NOTARIZE_APPLE_PASSWORD || macOSDefaultConfig.notarizeApplePassword, + provisioningProfile: process.env.MACOS_PROVISIONING_PROFILE || macOSDefaultConfig.provisioningProfile, }; + if (macOSConfig.certNameApplication) { + if (!macOSConfig.provisioningProfile) { + throw new Error( + 'MACOS_PROVISIONING_PROFILE is required when signing the app because the WebAuthn keychain access group is a restricted entitlement.', + ); + } + if (!(await fs.pathExists(macOSConfig.provisioningProfile))) { + throw new Error(`macOS provisioning profile not found at "${macOSConfig.provisioningProfile}".`); + } + } + if (macOSConfig.appleExportComplianceCode) { plistEntries['ITSAppUsesNonExemptEncryption'] = true; plistEntries['ITSEncryptionExportComplianceCode'] = macOSConfig.appleExportComplianceCode; @@ -94,7 +107,7 @@ export async function buildMacOSConfig( }, 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, @@ -115,6 +128,7 @@ export async function buildMacOSConfig( entitlements: 'resources/macos/entitlements/parent.plist', }), identity: macOSConfig.certNameApplication, + provisioningProfile: macOSConfig.provisioningProfile || undefined, }; } @@ -181,9 +195,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 +211,21 @@ export async function manualMacOSSign( const mainEntitlements = 'resources/macos/entitlements/parent.plist'; if (macOSConfig.certNameApplication) { + if (!macOSConfig.provisioningProfile) { + throw new Error('Cannot sign the macOS app without MACOS_PROVISIONING_PROFILE.'); + } + + const embeddedProvisioningProfile = path.join(appFile, 'Contents', 'embedded.provisionprofile'); + await fs.copy(macOSConfig.provisioningProfile, embeddedProvisioningProfile); + // Jenkins secret files are owner-only; the installed profile must be readable by app users. + await fs.chmod(embeddedProvisioningProfile, 0o644); + logger.log(`Embedded provisioning profile in "${embeddedProvisioningProfile}".`); + 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/commonConfig.test.ts b/bin/build-tools/lib/commonConfig.test.ts index 7b73ceb36db..6133b3344fd 100644 --- a/bin/build-tools/lib/commonConfig.test.ts +++ b/bin/build-tools/lib/commonConfig.test.ts @@ -126,6 +126,7 @@ describe('commonConfig', () => { raygunApiKey: generateUUID(), supportUrl: generateUUID(), updateUrl: generateUUID(), + webAuthnKeychainAccessGroup: generateUUID(), websiteUrl: generateUUID(), }; @@ -152,6 +153,7 @@ describe('commonConfig', () => { assert.strictEqual(commonConfig.raygunApiKey, wireJson.raygunApiKey); assert.strictEqual(commonConfig.supportUrl, wireJson.supportUrl); assert.strictEqual(commonConfig.updateUrl, wireJson.updateUrl); + assert.strictEqual(commonConfig.webAuthnKeychainAccessGroup, wireJson.webAuthnKeychainAccessGroup); assert.strictEqual(commonConfig.websiteUrl, wireJson.websiteUrl); await fs.remove(tempDir); diff --git a/bin/passkey-diagnostics.cjs b/bin/passkey-diagnostics.cjs new file mode 100644 index 00000000000..d9f3414592d --- /dev/null +++ b/bin/passkey-diagnostics.cjs @@ -0,0 +1,147 @@ +// Only log selected build metadata; never dump provisioning profiles or credentials. +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const {X509Certificate} = require('node:crypto'); +const {execFileSync} = require('node:child_process'); + +const log = message => console.log(`[Passkeys] ${message}`); +const check = (condition, message) => { + if (!condition) { + throw new Error(message); + } + log(`PASS: ${message}`); +}; + +function runtime() { + const [major, minor] = process.versions.node.split('.').map(Number); + log(`Node ${process.versions.node}; platform ${process.platform}; architecture ${process.arch}`); + log(`Requested Electron ${require('../package.json').devDependencies.electron}`); + check(major > 22 || (major === 22 && minor >= 12), 'Node must be >=22.12.0 for Electron 43.'); + log('Build checks do not perform a passkey login. A signed app must still be tested on a user device.'); +} + +function command(executable, args, input) { + try { + return execFileSync(executable, args, {input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']}); + } catch { + // Command output may contain the provisioning profile. Do not include it in errors. + throw new Error(`${path.basename(executable)} ${args[0]} failed. Check the app signature or provisioning profile.`); + } +} + +function plist(data) { + // Profiles contain dates and certificate data, which plutil cannot convert to JSON. + return require('plist').parse(command('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', '-'], data)); +} + +function matches(pattern, value) { + return ( + typeof pattern === 'string' && + (pattern === value || (pattern.endsWith('*') && value.startsWith(pattern.slice(0, -1)))) + ); +} + +function validate({bundleId, group, entitlements, profile}, now = new Date()) { + check(typeof group === 'string' && group.length > 0, 'Runtime keychain access group is configured.'); + check( + entitlements['keychain-access-groups']?.includes(group), + 'Signed app entitlement must include the runtime keychain access group.', + ); + const teams = profile.TeamIdentifier || []; + check( + teams.some(team => group.startsWith(`${team}.`)), + 'Profile team must match the keychain access group.', + ); + const allowed = profile.Entitlements || {}; + check( + (allowed['keychain-access-groups'] || []).some(pattern => matches(pattern, group)), + 'Provisioning profile must authorize the keychain access group.', + ); + const appId = allowed['com.apple.application-identifier'] || allowed['application-identifier']; + check( + (profile.ApplicationIdentifierPrefix || teams).some(prefix => matches(appId, `${prefix}.${bundleId}`)), + 'Provisioning profile must authorize the built app bundle identifier.', + ); + check(new Date(profile.ExpirationDate) > now, 'Provisioning profile must not be expired.'); + log(`Profile expires: ${profile.ExpirationDate}`); +} + +function validateSigningCertificate(profile, certificateData, now = new Date()) { + const certificate = new X509Certificate(certificateData); + check( + (profile.DeveloperCertificates || []).some(data => new X509Certificate(data).raw.equals(certificate.raw)), + 'App signing certificate must be included in the provisioning profile.', + ); + check( + new Date(certificate.validFrom) <= now && now < new Date(certificate.validTo), + 'App signing certificate must be within its validity period.', + ); + log(`Signing certificate expires: ${certificate.validTo}`); +} + +function macos() { + const buildDir = path.resolve('wrap/build'); + check(fs.existsSync(buildDir), 'Build output directory must exist.'); + const apps = fs + .readdirSync(buildDir, {withFileTypes: true}) + .filter(entry => entry.isDirectory()) + .flatMap(entry => { + const dir = path.join(buildDir, entry.name); + return fs + .readdirSync(dir) + .filter(name => name.endsWith('.app')) + .map(name => path.join(dir, name)); + }); + check( + apps.length === 1, + `Expected exactly one built .app; found ${apps.length}. Clean wrap/build if there are stale builds.`, + ); + const app = apps[0]; + log(`Inspecting built app: ${path.relative(process.cwd(), app)}`); + const contents = path.join(app, 'Contents'); + const info = plist(fs.readFileSync(path.join(contents, 'Info.plist'))); + log(`Bundle identifier: ${info.CFBundleIdentifier}; version: ${info.CFBundleShortVersionString}`); + const resources = path.join(contents, 'Resources'); + const asar = path.join(resources, 'app.asar'); + const wireJson = fs.existsSync(asar) + ? require('@electron/asar').extractFile(asar, 'electron/wire.json').toString() + : fs.readFileSync(path.join(resources, 'app', 'electron', 'wire.json'), 'utf8'); + const group = JSON.parse(wireJson).webAuthnKeychainAccessGroup; + log(`Built runtime keychain group: ${group || '(missing)'}`); + const embeddedProfile = path.join(contents, 'embedded.provisionprofile'); + check(fs.existsSync(embeddedProfile), 'App must contain Contents/embedded.provisionprofile.'); + const profile = plist(command('/usr/bin/security', ['cms', '-D', '-i', embeddedProfile])); + log('PASS: Embedded provisioning profile decoded.'); + command('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]); + log('PASS: App signature verified (--deep --strict).'); + const entitlements = plist(command('/usr/bin/codesign', ['--display', '--entitlements', ':-', app])); + validate({bundleId: info.CFBundleIdentifier, group, entitlements, profile}); + const certificateDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'wire-signing-check-')); + try { + const prefix = path.join(certificateDirectory, 'certificate'); + // This optional argument must use '=' or codesign treats the prefix as another app path. + command('/usr/bin/codesign', ['--display', `--extract-certificates=${prefix}`, app]); + validateSigningCertificate(profile, fs.readFileSync(`${prefix}0`)); + } finally { + fs.rmSync(certificateDirectory, {recursive: true, force: true}); + } + log('BUILD CHECKS PASSED: Passkey signing prerequisites are present.'); + log('MANUAL TEST REQUIRED: Launch this signed app with --enable-logging and complete passkey login.'); + log('Touch ID credentials are device-bound; these checks do not verify iCloud/Safari passkeys or IdP acceptance.'); +} + +if (require.main === module) { + try { + if (process.argv[2] === 'macos') { + macos(); + } else { + runtime(); + } + } catch (error) { + console.error(`[Passkeys] FAIL: ${error.message}`); + process.exitCode = 1; + } +} + +module.exports = {validate, plist, validateSigningCertificate}; diff --git a/bin/passkey-diagnostics.test.cjs b/bin/passkey-diagnostics.test.cjs new file mode 100644 index 00000000000..6d4545db1d4 --- /dev/null +++ b/bin/passkey-diagnostics.test.cjs @@ -0,0 +1,93 @@ +const assert = require('node:assert/strict'); +const {test} = require('node:test'); +const {validate, plist} = require('./passkey-diagnostics.cjs'); + +const now = new Date('2026-09-25T00:00:00Z'); +function fixture() { + return { + bundleId: 'com.example.app', + group: 'TEAM.com.example.app.webauthn', + entitlements: {'keychain-access-groups': ['TEAM.com.example.app.webauthn']}, + profile: { + TeamIdentifier: ['TEAM'], + ApplicationIdentifierPrefix: ['TEAM'], + ExpirationDate: '2027-09-25T00:00:00Z', + Entitlements: { + 'com.apple.application-identifier': 'TEAM.com.example.app', + 'keychain-access-groups': ['TEAM.com.example.app.webauthn'], + }, + }, + }; +} + +test('accepts matching signing prerequisites', () => { + assert.doesNotThrow(() => validate(fixture(), now)); +}); + +test('reads a real plist containing expiry dates and certificate data', {skip: process.platform !== 'darwin'}, () => { + const source = fixture().profile; + source.ExpirationDate = new Date(source.ExpirationDate); + source.DeveloperCertificates = [Buffer.from('fixture certificate')]; + const decoded = plist(require('plist').build(source)); + assert.strictEqual(decoded.ExpirationDate.toISOString(), '2027-09-25T00:00:00.000Z'); + assert.deepStrictEqual(decoded.TeamIdentifier, ['TEAM']); + validate({...fixture(), profile: decoded}, now); +}); + +test('accepts provisioning wildcard authorization', () => { + const data = fixture(); + data.profile.Entitlements['keychain-access-groups'] = ['TEAM.*']; + data.profile.Entitlements['com.apple.application-identifier'] = 'TEAM.com.example.*'; + assert.doesNotThrow(() => validate(data, now)); +}); + +test('rejects a runtime group absent from the signed entitlement', () => { + const data = fixture(); + data.entitlements['keychain-access-groups'] = []; + assert.throws(() => validate(data, now), /Signed app entitlement/); +}); + +test('rejects a group not authorized by the profile', () => { + const data = fixture(); + data.profile.Entitlements['keychain-access-groups'] = ['TEAM.other.*']; + assert.throws(() => validate(data, now), /authorize the keychain/); +}); + +test('rejects a profile for another bundle', () => { + const data = fixture(); + data.bundleId = 'com.example.other'; + assert.throws(() => validate(data, now), /bundle identifier/); +}); + +test('rejects expired profiles', () => { + const data = fixture(); + data.profile.ExpirationDate = '2026-09-24T00:00:00Z'; + assert.throws(() => validate(data, now), /expired/); +}); + +test('rejects a profile from another team', () => { + const data = fixture(); + data.profile.TeamIdentifier = ['OTHER']; + assert.throws(() => validate(data, now), /Profile team/); +}); + +// Use public certificate data only; no signing identity or private key is needed. +const {X509Certificate} = require('node:crypto'); +const {validateSigningCertificate} = require('./passkey-diagnostics.cjs'); +const certificate = new X509Certificate(require('node:tls').rootCertificates[0]); +const certificateProfile = {DeveloperCertificates: [certificate.raw]}; +const validDate = new Date((Date.parse(certificate.validFrom) + Date.parse(certificate.validTo)) / 2); + +test('accepts the signing certificate authorized by the profile', () => { + assert.doesNotThrow(() => validateSigningCertificate(certificateProfile, certificate.raw, validDate)); +}); + +test('rejects a signing certificate absent from the profile', () => { + assert.throws(() => validateSigningCertificate({DeveloperCertificates: []}, certificate.raw, validDate), /included/); +}); + +test('rejects expired or not yet valid signing certificates', () => { + for (const date of [new Date(Date.parse(certificate.validFrom) - 1), new Date(certificate.validTo)]) { + assert.throws(() => validateSigningCertificate(certificateProfile, certificate.raw, date), /validity period/); + } +}); 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..9e808130d13 --- /dev/null +++ b/electron/src/auth/TextPrompt.test.main.ts @@ -0,0 +1,165 @@ +/* + * 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('closes the dialog when the requesting window is destroyed', async () => { + // executeJavaScript cannot return from a renderer that has been destroyed. + void parent.webContents.executeJavaScript(`prompt('Label')`).catch(() => null); + const win = await dialog(); + parent.destroy(); + await waitFor(() => (win.isDestroyed() ? true : undefined)); + assert.ok(win.isDestroyed()); + }); + + 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..1f70d2bf954 --- /dev/null +++ b/electron/src/auth/TextPrompt.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, 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); + // Never deliver a value to a different document after navigation. + event.returnValue = !contents.isDestroyed() && !frame.detached && frame.url === requestUrl ? value : null; + if (prompt && !prompt.isDestroyed()) { + prompt.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..9621c567130 --- /dev/null +++ b/electron/src/auth/WebAuthn.test.main.ts @@ -0,0 +1,107 @@ +/* + * 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'; + +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('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..34c84fd72f0 --- /dev/null +++ b/electron/src/auth/WebAuthn.ts @@ -0,0 +1,87 @@ +/* + * 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: 'Sign in with a passkey', + message: `Choose an account for ${relyingPartyId}`, + buttons: [ + ...accounts.map( + (account, index) => + [account.displayName, account.name].filter(Boolean).join(' — ') || `Account ${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/locale/en-US.json b/electron/src/locale/en-US.json index e5da1bc371f..30d0ec305d1 100644 --- a/electron/src/locale/en-US.json +++ b/electron/src/locale/en-US.json @@ -108,6 +108,7 @@ "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.", + "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..26e03788868 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 { @@ -101,15 +102,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); @@ -140,6 +132,24 @@ const customDownloadPath = settings.restore(SettingsType.DOW const appHomePath = (path: string) => `${app.getPath('home')}\\${path}`; const isInternalBuild = (): boolean => config.environment === 'internal'; +const configureWebAuthn = (): void => { + if (!EnvironmentUtil.platform.IS_MAC_OS) { + logger.info(`[Passkeys] Skipping macOS Touch ID configuration on ${process.platform}; using platform defaults.`); + return; + } + + logger.info( + `[Passkeys] Configuring Touch ID: Electron ${process.versions.electron}; keychain group ${config.webAuthnKeychainAccessGroup}.`, + ); + app.configureWebAuthn({ + touchID: { + keychainAccessGroup: config.webAuthnKeychainAccessGroup, + promptReason: 'sign in to $1', + }, + }); + logger.info('[Passkeys] Touch ID configuration applied. This does not verify keychain access or a successful login.'); +}; + if (customDownloadPath) { electronDl({ directory: appHomePath(customDownloadPath), @@ -514,6 +524,7 @@ const handleAppEvents = (): void => { // System Menu, Tray Icon & Show window app.on('ready', async () => { + configureWebAuthn(); let regionalLocale: string | undefined; try { regionalLocale = app.getSystemLocale(); @@ -620,7 +631,6 @@ class ElectronWrapperInit { closeSSOWindow = () => { if (this.ssoWindow) { this.ssoWindow?.close(); - this.ssoWindow = null; } }; @@ -640,12 +650,30 @@ 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'}; + } + // Native window.open inherits the account's Chromium session. Create an + // independent window so every account uses the same passkey partition. + const options = SingleSignOn.getSingleSignOnLoginWindowOptions(main, details.url); + const popup = new BrowserWindow(options); + const flow = new SingleSignOn(popup, 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 +689,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 +703,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 +733,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..08951042a03 --- /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}) => { + completion = ipcRenderer.invoke('wire:sso-complete', message?.type).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..c7230f81adf 100644 --- a/electron/src/preload/preload-webview.ts +++ b/electron/src/preload/preload-webview.ts @@ -47,6 +47,13 @@ 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}) => { + logger.info('[Passkeys] Received verified SSO result after cookie handoff.'); + window.dispatchEvent(new MessageEvent('message', {origin: result.origin, data: {type: result.type}})); +}); + function subscribeToThemeChange(): void { function updateWebAppTheme(): void { if (WebAppEvents.PROPERTIES.UPDATE.INTERFACE) { diff --git a/electron/src/settings/config.ts b/electron/src/settings/config.ts index 91e9a28a053..d6a32f07a59 100644 --- a/electron/src/settings/config.ts +++ b/electron/src/settings/config.ts @@ -37,6 +37,7 @@ interface WireJson { supportUrl: string; updateUrl: string; version: string; + webAuthnKeychainAccessGroup: string; websiteUrl: string; } diff --git a/electron/src/sso/SingleSignOn.test.main.ts b/electron/src/sso/SingleSignOn.test.main.ts index 553cf23b740..59e3c1b98e8 100644 --- a/electron/src/sso/SingleSignOn.test.main.ts +++ b/electron/src/sso/SingleSignOn.test.main.ts @@ -17,16 +17,212 @@ * */ +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('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('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((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..09bbf4d50a4 100644 --- a/electron/src/sso/SingleSignOn.ts +++ b/electron/src/sso/SingleSignOn.ts @@ -17,23 +17,13 @@ * */ -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 {registerTextPrompt} from '../auth/TextPrompt'; import {writeBoundedLogMessage} from '../logging/desktopLogWriter'; import {ENABLE_LOGGING, getLogger} from '../logging/getLogger'; import {getLogDirectory, getSsoLogPath} from '../logging/logPaths'; @@ -47,10 +37,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 +50,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 +71,14 @@ export class SingleSignOn { } public readonly init = async (): Promise => { - // Create a ephemeral and isolated session - this.session = session.fromPartition(SingleSignOn.SSO_SESSION_NAME, {cache: false}); + // 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)); @@ -99,13 +90,34 @@ export class SingleSignOn { }); 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) => { + 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; + } + this.completion ??= this.finalizeLogin(type); + 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 +132,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 +187,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,88 +200,45 @@ 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}); + await toSession.cookies.set({url: cookieUrl, ...cookie}); } } await toSession.cookies.flushStore(); - } - - 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); + SingleSignOn.logger.info('[Passkeys] Wire authentication cookie transferred to the requesting account.'); } private readonly finalizeLogin = async (type: string): Promise => { @@ -296,7 +249,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) { @@ -317,12 +270,29 @@ export class SingleSignOn { 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}); } 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/wire.json b/electron/wire.json index 7ad73dae295..790a14626e1 100644 --- a/electron/wire.json +++ b/electron/wire.json @@ -21,5 +21,6 @@ "supportUrl": "https://support.wire.com", "updateUrl": "https://wire-app.wire.com/win/prod/", "version": "3.44.0", + "webAuthnKeychainAccessGroup": "EDF3JCE8BC.com.wearezeta.zclient.mac.webauthn", "websiteUrl": "https://wire.com" } diff --git a/jenkins/macOS.groovy b/jenkins/macOS.groovy index 4a03bf05996..096ad60f057 100644 --- a/jenkins/macOS.groovy +++ b/jenkins/macOS.groovy @@ -8,8 +8,10 @@ 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' + echo '[Passkeys] Resolving Jenkins NodeJS tool node-v23.0.0. If missing, configure it under Manage Jenkins > Tools.' + def NODE = tool name: 'node-v23.0.0', type: 'nodejs' def privateAPIResult = '' + def provisioningProfileCredential = (!production && !custom && !wireGov) ? 'MACOS_PROVISIONING_PROFILE_INTERNAL' : 'MACOS_PROVISIONING_PROFILE' def jenkinsbot_secret = '' withCredentials([string(credentialsId: "${params.JENKINSBOT_SECRET}", variable: 'JENKINSBOT_SECRET')]) { @@ -39,40 +41,62 @@ node("macos") { stage('Build') { try { - withCredentials([string(credentialsId: 'MACOS_KEYCHAIN_PASSWORD', variable: 'MACOS_KEYCHAIN_PASSWORD')]) { - sh 'security unlock-keychain -p \"$MACOS_KEYCHAIN_PASSWORD\" /Users/jenkins/Library/Keychains/login.keychain-db' + // Optional String parameter: exact SHA-1 of an installed app-signing identity. + def applicationCertificate = (params.MACOS_CERTIFICATE_NAME_APPLICATION ?: '').trim() + def buildEnvironment = ["PATH+NODE=${NODE}/bin"] + if (applicationCertificate) { + if (!(applicationCertificate ==~ /[0-9a-fA-F]{40}/)) { + error('[Passkeys] MACOS_CERTIFICATE_NAME_APPLICATION must be a 40-character SHA-1 fingerprint, or blank to use the existing configuration.') + } + applicationCertificate = applicationCertificate.toUpperCase() + buildEnvironment.add("MACOS_CERTIFICATE_NAME_APPLICATION=${applicationCertificate}") + echo "[Passkeys] App-signing certificate override: ${applicationCertificate} (installed identity)." } - withEnv(["PATH+NODE=${NODE}/bin"]) { - sh 'node -v' - sh 'npm -v' - sh 'npm install -g yarn' - sh 'yarn' - if (production) { - withCredentials([string(credentialsId: 'APPLE_EXPORT_COMPLIANCE_CODE', variable: 'APPLE_EXPORT_COMPLIANCE_CODE')]) { + echo "[Passkeys] Using provisioning credential ${provisioningProfileCredential}. If binding fails, add this Secret file credential in Jenkins." + withCredentials([ + string(credentialsId: 'MACOS_KEYCHAIN_PASSWORD', variable: 'MACOS_KEYCHAIN_PASSWORD'), + file(credentialsId: provisioningProfileCredential, variable: 'MACOS_PROVISIONING_PROFILE'), + ]) { + sh 'security unlock-keychain -p \"$MACOS_KEYCHAIN_PASSWORD\" /Users/jenkins/Library/Keychains/login.keychain-db' + withEnv(buildEnvironment) { + sh 'node bin/passkey-diagnostics.cjs' + sh 'npm -v' + sh 'npm install -g yarn' + sh 'yarn' + echo '[Passkeys] Checking that the supplied provisioning profile can be decoded.' + sh 'security cms -D -i "$MACOS_PROVISIONING_PROFILE" >/dev/null' + echo '[Passkeys] PASS: Provisioning profile decoded. Building and signing the app next.' + if (production) { + withCredentials([string(credentialsId: 'APPLE_EXPORT_COMPLIANCE_CODE', variable: 'APPLE_EXPORT_COMPLIANCE_CODE')]) { + sh 'yarn build:macos' + } + + echo 'Checking for private Apple APIs ...' + privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/Wire-mas-universal/Wire.app"', returnStdout: true + echo privateAPIResult + } else if (custom) { sh 'yarn build:macos' + } else if (wireGov) { + sh 'yarn build:macos:wire-gov' + + echo 'Checking for private Apple APIs ...' + privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireGov-mas-universal/WireGov.app"', returnStdout: true + echo privateAPIResult + } else { + // internal + sh 'yarn build:macos:internal' + + echo 'Checking for private Apple APIs ...' + privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireInternal-mas-universal/WireInternal.app"', returnStdout: true + echo privateAPIResult } - echo 'Checking for private Apple APIs ...' - privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/Wire-mas-universal/Wire.app"', returnStdout: true - echo privateAPIResult - } else if (custom) { - sh 'yarn build:macos' - } else if (wireGov) { - sh 'yarn build:macos:wire-gov' - - echo 'Checking for private Apple APIs ...' - privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireGov-mas-universal/WireGov.app"', returnStdout: true - echo privateAPIResult - } else { - // internal - sh 'yarn build:macos:internal' - - echo 'Checking for private Apple APIs ...' - privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireInternal-mas-universal/WireInternal.app"', returnStdout: true - echo privateAPIResult + echo '[Passkeys] Verifying the built app, embedded profile, and signed keychain entitlement.' + sh 'node bin/passkey-diagnostics.cjs macos' } } } catch(e) { + echo '[Passkeys] BUILD FAILED: See the first failed command or [Passkeys] FAIL above. Passkey readiness has not been established.' currentBuild.result = 'FAILED' wireSend secret: "${jenkinsbot_secret}", message: "🍏 **${JOB_NAME} ${version} build failed**\n${BUILD_URL}" throw e diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index 48712b1fcea..32cc5f32279 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -8,7 +8,8 @@ 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' + echo '[Passkeys] Resolving Jenkins NodeJS tool node-v23.0.0. If missing, configure it under Manage Jenkins > Tools.' + def NODE = tool name: 'node-v23.0.0', type: 'nodejs' def jenkinsbot_secret = '' @@ -37,7 +38,8 @@ node('windows') { stage('Build') { try { withEnv(["PATH+NODE=${NODE}", 'npm_config_target_arch=x64']) { - bat 'node -v' + bat 'node bin/passkey-diagnostics.cjs' + echo '[Passkeys] Windows uses the OS authenticator; macOS provisioning and Touch ID checks do not apply.' bat 'npm -v' bat 'npm install -g yarn' bat 'yarn' @@ -48,6 +50,7 @@ node('windows') { } else { bat 'yarn build:win:internal' } + echo '[Passkeys] App build completed. MANUAL TEST REQUIRED: Test passkey login on Windows; a successful build does not verify authentication.' } } catch (e) { currentBuild.result = 'FAILED' diff --git a/package.json b/package.json index ffd7d55d42c..bfe2d985eb3 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", @@ -207,7 +207,7 @@ "start:prod": "yarn start --env=https://app.wire.com", "start:fed": "yarn start --env=https://webapp.${FED}.wire.link/ ", "test": "yarn test:types && yarn prestart && yarn build:ts:tests && yarn test:react && yarn test:main && yarn test:renderer && yarn test:bin", - "test:bin": "mocha --require .babel-register.js \"bin/**/*.test?(.main).ts\"", + "test:bin": "node --test bin/passkey-diagnostics.test.cjs && mocha --require .babel-register.js \"bin/**/*.test?(.main).ts\"", "test:e2e": "playwright test", "test:main": "electron-mocha --require .babel-register.js \"electron/src/**/*.test?(.main).ts\" --no-sandbox", "test:renderer": "electron-mocha --renderer --require .babel-register.js \"electron/src/**/*.test?(.renderer).ts\" --no-sandbox --window-config electron/test/mocha-window-config.json", diff --git a/resources/macos/entitlements/parent.plist b/resources/macos/entitlements/parent.plist index 91d7cb8059f..57a191b73d9 100644 --- a/resources/macos/entitlements/parent.plist +++ b/resources/macos/entitlements/parent.plist @@ -16,5 +16,9 @@ com.apple.security.application-groups EDF3JCE8BC.com.wearezeta.zclient.mac + keychain-access-groups + + EDF3JCE8BC.com.wearezeta.zclient.mac.webauthn + diff --git a/yarn.lock b/yarn.lock index b5668143f3f..871b7009b7a 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: @@ -20251,10 +20284,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 +20298,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 +21204,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 From 022b5086422593631efe592135be06c2216c83f8 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Tue, 29 Sep 2026 14:04:15 +0200 Subject: [PATCH 2/7] fix: update jenkins job to support names and fingerprints --- jenkins/macOS.groovy | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/jenkins/macOS.groovy b/jenkins/macOS.groovy index 096ad60f057..d78aeef9cea 100644 --- a/jenkins/macOS.groovy +++ b/jenkins/macOS.groovy @@ -41,14 +41,20 @@ node("macos") { stage('Build') { try { - // Optional String parameter: exact SHA-1 of an installed app-signing identity. + // Supports existing certificate-name dropdowns and exact SHA-1 choices. def applicationCertificate = (params.MACOS_CERTIFICATE_NAME_APPLICATION ?: '').trim() def buildEnvironment = ["PATH+NODE=${NODE}/bin"] if (applicationCertificate) { - if (!(applicationCertificate ==~ /[0-9a-fA-F]{40}/)) { - error('[Passkeys] MACOS_CERTIFICATE_NAME_APPLICATION must be a 40-character SHA-1 fingerprint, or blank to use the existing configuration.') + def isFingerprint = applicationCertificate ==~ /[0-9a-fA-F]{40}/ + // Signing commands currently quote the identity in a shell command. + // Keep dropdown names intact while excluding quotes and control characters. + def isCertificateName = applicationCertificate ==~ /(?:Apple Distribution|Apple Development|Mac Developer|Mac App Distribution|3rd Party Mac Developer Application|Developer ID Application): [A-Za-z0-9 .,&_-]+ \([A-Z0-9]{10}\)/ + if (!isFingerprint && !isCertificateName) { + error('[Passkeys] MACOS_CERTIFICATE_NAME_APPLICATION must be an app-signing certificate name, a 40-character SHA-1 fingerprint, or blank to use the existing configuration.') + } + if (isFingerprint) { + applicationCertificate = applicationCertificate.toUpperCase() } - applicationCertificate = applicationCertificate.toUpperCase() buildEnvironment.add("MACOS_CERTIFICATE_NAME_APPLICATION=${applicationCertificate}") echo "[Passkeys] App-signing certificate override: ${applicationCertificate} (installed identity)." } From 78a34de2afdf9bd8b008738cd1e6ae040ffb2aa4 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Tue, 29 Sep 2026 16:55:21 +0200 Subject: [PATCH 3/7] fix: update test and fix piepline issues --- bin/build-tools/lib/build-macos.test.ts | 72 ++++++++++++++++++- bin/build-tools/lib/build-macos.ts | 37 ++++++++-- bin/build-tools/lib/build-windows-msi.test.ts | 8 +++ electron/src/auth/TextPrompt.test.main.ts | 9 --- electron/src/auth/TextPrompt.ts | 15 ++-- 5 files changed, 123 insertions(+), 18 deletions(-) diff --git a/bin/build-tools/lib/build-macos.test.ts b/bin/build-tools/lib/build-macos.test.ts index 1bb29306187..55de0781df7 100644 --- a/bin/build-tools/lib/build-macos.test.ts +++ b/bin/build-tools/lib/build-macos.test.ts @@ -16,10 +16,13 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ +import fs from 'fs-extra'; + import * as assert from 'assert'; +import os from 'os'; import * as path from 'path'; -import {buildMacOSConfig} from './build-macos'; +import {buildMacOSConfig, embedProvisioningProfile} from './build-macos'; import {generateUUID} from '../../bin-utils'; const wireJsonPath = path.join(__dirname, '../../../electron/wire.json'); @@ -60,3 +63,70 @@ describe('build-macos', () => { }); }); }); + +describe('embedProvisioningProfile', () => { + let directory: string; + let buildDir: string; + let appFile: string; + let profile: string; + + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-profile-test-')); + buildDir = path.join(directory, 'build'); + appFile = path.join(buildDir, 'Wire.app'); + profile = path.join(directory, 'source.provisionprofile'); + await fs.ensureDir(path.join(appFile, 'Contents')); + await fs.writeFile(profile, 'profile fixture', {mode: 0o600}); + }); + + afterEach(async () => { + await fs.remove(directory); + }); + + it('embeds a readable profile and replaces an existing profile', async () => { + const destination = await embedProvisioningProfile(appFile, profile, buildDir); + assert.strictEqual(await fs.readFile(destination, 'utf8'), 'profile fixture'); + if (process.platform !== 'win32') { + assert.strictEqual((await fs.stat(destination)).mode & 0o777, 0o644); + } + await fs.writeFile(profile, 'replacement'); + await embedProvisioningProfile(appFile, profile, buildDir); + assert.strictEqual(await fs.readFile(destination, 'utf8'), 'replacement'); + }); + + it('rejects traversal to a sibling whose name shares the build-directory prefix', async () => { + const outside = path.join(directory, 'build-other', 'Wire.app'); + await fs.ensureDir(path.join(outside, 'Contents')); + await assert.rejects( + embedProvisioningProfile(path.join(buildDir, '..', 'build-other', 'Wire.app'), profile, buildDir), + /inside the build directory/, + ); + assert.strictEqual(await fs.pathExists(path.join(outside, 'Contents', 'embedded.provisionprofile')), false); + }); + + it('rejects a Contents symlink that escapes the build directory', async () => { + const outside = path.join(directory, 'outside'); + await fs.ensureDir(outside); + await fs.remove(path.join(appFile, 'Contents')); + await fs.symlink(outside, path.join(appFile, 'Contents'), 'junction'); + await assert.rejects(embedProvisioningProfile(appFile, profile, buildDir), /inside the build directory/); + assert.strictEqual((await fs.readdir(outside)).length, 0); + }); + + it('does not overwrite a file through a destination symlink', async () => { + const outside = path.join(directory, 'outside'); + await fs.writeFile(outside, 'unchanged'); + await fs.symlink(outside, path.join(appFile, 'Contents', 'embedded.provisionprofile')); + await assert.rejects(embedProvisioningProfile(appFile, profile, buildDir), /regular file/); + assert.strictEqual(await fs.readFile(outside, 'utf8'), 'unchanged'); + }); + + it('replaces a dangling link without writing to its target', async () => { + const outside = path.join(directory, 'absent'); + const destination = path.join(appFile, 'Contents', 'embedded.provisionprofile'); + await fs.symlink(outside, destination); + await embedProvisioningProfile(appFile, profile, buildDir); + assert.strictEqual((await fs.lstat(destination)).isSymbolicLink(), false); + assert.strictEqual(await fs.pathExists(outside), false); + }); +}); diff --git a/bin/build-tools/lib/build-macos.ts b/bin/build-tools/lib/build-macos.ts index f4a29c53a3b..e4e5df22210 100755 --- a/bin/build-tools/lib/build-macos.ts +++ b/bin/build-tools/lib/build-macos.ts @@ -201,6 +201,34 @@ export async function buildMacOSWrapper( } } +export async function embedProvisioningProfile(appFile: string, profile: string, buildDir: string): Promise { + const buildRoot = await fs.realpath(buildDir); + const contents = await fs.realpath(path.join(appFile, 'Contents')); + const relative = path.relative(buildRoot, contents); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error('Provisioning profile destination must be inside the build directory.'); + } + + const destination = path.join(contents, 'embedded.provisionprofile'); + // Do not follow a pre-existing destination link when copying or changing permissions. + if (await fs.pathExists(destination)) { + if (!(await fs.lstat(destination)).isFile()) { + throw new Error('Provisioning profile destination must be a regular file.'); + } + } + // Replace the directory entry rather than following it (including dangling symlinks). + const temporary = await fs.mkdtemp(path.join(contents, '.provisionprofile-')); + try { + const stagedProfile = path.join(temporary, 'profile'); + await fs.copyFile(profile, stagedProfile); + await fs.chmod(stagedProfile, 0o644); + await fs.rename(stagedProfile, destination); + } finally { + await fs.remove(temporary); + } + return destination; +} + export async function manualMacOSSign( appFile: string, pkgFile: string, @@ -215,10 +243,11 @@ export async function manualMacOSSign( throw new Error('Cannot sign the macOS app without MACOS_PROVISIONING_PROFILE.'); } - const embeddedProvisioningProfile = path.join(appFile, 'Contents', 'embedded.provisionprofile'); - await fs.copy(macOSConfig.provisioningProfile, embeddedProvisioningProfile); - // Jenkins secret files are owner-only; the installed profile must be readable by app users. - await fs.chmod(embeddedProvisioningProfile, 0o644); + const embeddedProvisioningProfile = await embedProvisioningProfile( + appFile, + macOSConfig.provisioningProfile, + commonConfig.buildDir, + ); logger.log(`Embedded provisioning profile in "${embeddedProvisioningProfile}".`); const filesToSign = [ 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/src/auth/TextPrompt.test.main.ts b/electron/src/auth/TextPrompt.test.main.ts index 9e808130d13..d31610c4c5c 100644 --- a/electron/src/auth/TextPrompt.test.main.ts +++ b/electron/src/auth/TextPrompt.test.main.ts @@ -138,15 +138,6 @@ describe('SSO website text prompt', function () { assert.strictEqual(BrowserWindow.getAllWindows().filter(win => win !== parent).length, 0); }); - it('closes the dialog when the requesting window is destroyed', async () => { - // executeJavaScript cannot return from a renderer that has been destroyed. - void parent.webContents.executeJavaScript(`prompt('Label')`).catch(() => null); - const win = await dialog(); - parent.destroy(); - await waitFor(() => (win.isDestroyed() ? true : undefined)); - assert.ok(win.isDestroyed()); - }); - it('cancels an open prompt when the requesting page navigates', async () => { const result = parent.webContents.executeJavaScript(`prompt('Label')`).catch(() => null); const win = await dialog(); diff --git a/electron/src/auth/TextPrompt.ts b/electron/src/auth/TextPrompt.ts index 1f70d2bf954..33fa962e5bf 100644 --- a/electron/src/auth/TextPrompt.ts +++ b/electron/src/auth/TextPrompt.ts @@ -78,11 +78,18 @@ export function registerTextPrompt(parent: BrowserWindow): void { contents.removeListener('did-start-navigation', navigated); contents.removeListener('render-process-gone', cancelled); parent.removeListener('closed', cancelled); - // Never deliver a value to a different document after navigation. - event.returnValue = !contents.isDestroyed() && !frame.detached && frame.url === requestUrl ? value : null; - if (prompt && !prompt.isDestroyed()) { - prompt.destroy(); + // 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(); From bcd7ca5fa856bc9b27852d4d4f9db4212ee59167 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Fri, 2 Oct 2026 14:23:17 +0200 Subject: [PATCH 4/7] feat: add native passkey support for macos --- bin/build-tools/lib/build-macos.test.ts | 44 ++++- bin/build-tools/lib/build-macos.ts | 39 ++++- electron/src/lib/CoreProtocol.test.main.ts | 7 + electron/src/lib/CoreProtocol.ts | 14 +- electron/src/mainProcess.ts | 17 +- .../src/sso/BrowserSingleSignOn.test.main.ts | 155 ++++++++++++++++++ electron/src/sso/BrowserSingleSignOn.ts | 146 +++++++++++++++++ electron/src/sso/MacWebAuthentication.ts | 115 +++++++++++++ .../src/sso/browserSsoCallback.test.main.ts | 106 ++++++++++++ electron/src/sso/browserSsoCallback.ts | 147 +++++++++++++++++ package.json | 1 + yarn.lock | 34 ++++ 12 files changed, 811 insertions(+), 14 deletions(-) create mode 100644 electron/src/sso/BrowserSingleSignOn.test.main.ts create mode 100644 electron/src/sso/BrowserSingleSignOn.ts create mode 100644 electron/src/sso/MacWebAuthentication.ts create mode 100644 electron/src/sso/browserSsoCallback.test.main.ts create mode 100644 electron/src/sso/browserSsoCallback.ts diff --git a/bin/build-tools/lib/build-macos.test.ts b/bin/build-tools/lib/build-macos.test.ts index 55de0781df7..c00b3c415cf 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,6 +14,7 @@ * * 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 fs from 'fs-extra'; @@ -23,6 +24,7 @@ import os from 'os'; import * as path from 'path'; import {buildMacOSConfig, embedProvisioningProfile} from './build-macos'; + import {generateUUID} from '../../bin-utils'; const wireJsonPath = path.join(__dirname, '../../../electron/wire.json'); @@ -30,6 +32,46 @@ 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'); + } + } 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 e4e5df22210..09740da17a9 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, '../../../'); @@ -93,17 +98,29 @@ 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$/, + // Local signing inputs are not runtime resources. The selected profile is + // embedded explicitly in Contents before signing. + /\/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, @@ -250,6 +267,22 @@ export async function manualMacOSSign( ); logger.log(`Embedded provisioning profile in "${embeddedProvisioningProfile}".`); + // 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) { + await promisify(execFile)('codesign', [ + '--force', + '--sign', + macOSConfig.certNameApplication, + path.join(appFile, addon), + ]); + } + 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', diff --git a/electron/src/lib/CoreProtocol.test.main.ts b/electron/src/lib/CoreProtocol.test.main.ts index 69a045a6bda..8239decf7ec 100644 --- a/electron/src/lib/CoreProtocol.test.main.ts +++ b/electron/src/lib/CoreProtocol.test.main.ts @@ -59,6 +59,13 @@ 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('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..6df1f27c524 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'; @@ -51,13 +50,20 @@ export class CustomProtocolHandler { !url.startsWith(CORE_PROTOCOL_PREFIX) || url.length > CORE_PROTOCOL_MAX_LENGTH ) { - showErrorDialog(`Invalid deep link "${shortenText(url || '', CORE_PROTOCOL_MAX_LENGTH)}."`); + 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 (route.host === START_SSO_FLOW) { logger.info('Deep link is a SSO link, triggering SSO login ...'); await this.handleSSOLogin(route); @@ -72,8 +78,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/mainProcess.ts b/electron/src/mainProcess.ts index 26e03788868..f8e41010883 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -80,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'; @@ -614,7 +615,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'); @@ -657,11 +658,15 @@ class ElectronWrapperInit { this.ssoWindow.focus(); return {action: 'deny'}; } - // Native window.open inherits the account's Chromium session. Create an - // independent window so every account uses the same passkey partition. - const options = SingleSignOn.getSingleSignOnLoginWindowOptions(main, details.url); - const popup = new BrowserWindow(options); - const flow = new SingleSignOn(popup, sender, lifecycle.getAccountId(sender), details.url); + 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(); diff --git a/electron/src/sso/BrowserSingleSignOn.test.main.ts b/electron/src/sso/BrowserSingleSignOn.test.main.ts new file mode 100644 index 00000000000..78d70d05d60 --- /dev/null +++ b/electron/src/sso/BrowserSingleSignOn.test.main.ts @@ -0,0 +1,155 @@ +/* + * 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'; + +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 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}) => 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 => { + callback = resolve; + }), + cancel: () => { + events.push('cancel'); + }, + }; + }, + ); + flow.onClose = () => { + events.push('close'); + }; + const complete = (wrongState = 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}:`); + callback(url.toString()); + }; + return {flow, complete, events, sender}; +} + +describe('browser SSO lifecycle', () => { + 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('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..4bd0313ce0a --- /dev/null +++ b/electron/src/sso/BrowserSingleSignOn.ts @@ -0,0 +1,146 @@ +/* + * 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 {startMacWebAuthentication, WebAuthenticationRequest} from './MacWebAuthentication'; + +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); + // 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 { + // 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): 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}); + } + } + + 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 => { + // AuthenticationServices owns the browser window and its presentation. + if (!this.parent.isDestroyed()) { + this.parent.focus(); + } + }; +} diff --git a/electron/src/sso/MacWebAuthentication.ts b/electron/src/sso/MacWebAuthentication.ts new file mode 100644 index 00000000000..a7d006befc4 --- /dev/null +++ b/electron/src/sso/MacWebAuthentication.ts @@ -0,0 +1,115 @@ +/* + * 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; +} + +// 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 authentication = new NobjcLibrary( + '/System/Library/Frameworks/AuthenticationServices.framework/AuthenticationServices', + ); + const nsWindow = fromPointer(parent.getNativeWindowHandle()).window(); + // 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 Error('Browser authentication cancelled or failed.')); + } 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, + 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/browserSsoCallback.test.main.ts b/electron/src/sso/browserSsoCallback.test.main.ts new file mode 100644 index 00000000000..baaf4ffe480 --- /dev/null +++ b/electron/src/sso/browserSsoCallback.test.main.ts @@ -0,0 +1,106 @@ +/* + * 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('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()); + 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..9f678af290f --- /dev/null +++ b/electron/src/sso/browserSsoCallback.ts @@ -0,0 +1,147 @@ +/* + * 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'; + +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 { + 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 !== '/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/package.json b/package.json index bfe2d985eb3..54c45d013e9 100644 --- a/package.json +++ b/package.json @@ -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 871b7009b7a..ea77f174b3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15772,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" @@ -15781,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" @@ -16040,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" @@ -21248,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 @@ -21267,6 +21299,8 @@ __metadata: webpack: 5.106.2 webpack-cli: 5.1.4 dependenciesMeta: + objc-js: + optional: true registry-js: optional: true languageName: unknown From 060c1e4816b2152547b6473eaee9cb1f68ad8ce4 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Mon, 5 Oct 2026 11:57:34 +0200 Subject: [PATCH 5/7] feat: support mac auth with external browser --- README.md | 8 +-- .../src/sso/BrowserSingleSignOn.test.main.ts | 17 +++++++ electron/src/sso/BrowserSingleSignOn.ts | 8 +-- electron/src/sso/MacWebAuthentication.ts | 50 +++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 507179579d5..0184f8db932 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,13 @@ Signed macOS builds require a provisioning profile because WebAuthn uses a restr For passkey diagnostics, search Jenkins **Console Output** for `[Passkeys]`. Both pipelines log the Node/Electron versions and fail on an incompatible Node version. The macOS Jenkins job needs a NodeJS tool named `node-v23.0.0` under **Manage Jenkins → Tools**. The macOS job also checks the built app's signature, runtime keychain group, signed entitlements, embedded profile authorization, bundle identifier, profile expiry, and whether the signing certificate is valid and included in the profile. `BUILD CHECKS PASSED` confirms these build prerequisites; it does not confirm a successful login. A `FAIL` message identifies the failed check. If credentials or Node setup fail earlier, follow the setup message immediately above that failure. -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 macOS, desktop SSO uses the system browser authentication session. See [macOS browser SSO](docs/macos-browser-sso.md) for the callback contract, packaging requirements, and security considerations. Windows and Linux continue using embedded SSO windows; the standalone webapp is unchanged. -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. Register a passkey once in this new shared session after upgrading: existing credentials in account-specific partitions are not migrated. 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. +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. -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]`. Startup logs confirm configuration, and 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. Touch ID credentials configured here are device-bound; existing iCloud/Safari passkeys are not validated by the Jenkins checks. +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. Register a passkey once in this new shared session after upgrading: existing credentials in account-specific partitions are not migrated. 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, startup logs confirm configuration, and 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. Touch ID credentials configured here are device-bound; existing iCloud/Safari passkeys are not validated by the Jenkins checks. ### Other Linux targets diff --git a/electron/src/sso/BrowserSingleSignOn.test.main.ts b/electron/src/sso/BrowserSingleSignOn.test.main.ts index 78d70d05d60..592e69887e1 100644 --- a/electron/src/sso/BrowserSingleSignOn.test.main.ts +++ b/electron/src/sso/BrowserSingleSignOn.test.main.ts @@ -66,6 +66,10 @@ function setup(failCookie = false) { cancel: () => { events.push('cancel'); }, + focus: () => { + events.push('focus browser'); + return true; + }, }; }, ); @@ -95,6 +99,19 @@ describe('browser SSO lifecycle', () => { 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(); diff --git a/electron/src/sso/BrowserSingleSignOn.ts b/electron/src/sso/BrowserSingleSignOn.ts index 4bd0313ce0a..80e27f96395 100644 --- a/electron/src/sso/BrowserSingleSignOn.ts +++ b/electron/src/sso/BrowserSingleSignOn.ts @@ -138,9 +138,11 @@ export class BrowserSingleSignOn { } public focus = (): void => { - // AuthenticationServices owns the browser window and its presentation. - if (!this.parent.isDestroyed()) { - this.parent.focus(); + 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 index a7d006befc4..a53b616453f 100644 --- a/electron/src/sso/MacWebAuthentication.ts +++ b/electron/src/sso/MacWebAuthentication.ts @@ -27,6 +27,7 @@ const presentationAnchors = new Map(); export interface WebAuthenticationRequest { result: Promise; cancel: () => void; + focus: () => boolean; } // Lazy-load the macOS-only addon. Only public AuthenticationServices APIs are used. @@ -38,10 +39,29 @@ export function startMacWebAuthentication( ): 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, @@ -100,6 +120,36 @@ export function startMacWebAuthentication( }); 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); From ae97422fb6b92e20bb563f7e27486196bc59af31 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Mon, 5 Oct 2026 22:38:22 +0200 Subject: [PATCH 6/7] fix: remove unwanted passkey changes --- README.md | 10 +- bin/build-tools/lib/Config.ts | 2 - bin/build-tools/lib/build-macos.test.ts | 76 +---------- bin/build-tools/lib/build-macos.ts | 56 +-------- bin/build-tools/lib/commonConfig.test.ts | 2 - bin/passkey-diagnostics.cjs | 147 ---------------------- bin/passkey-diagnostics.test.cjs | 93 -------------- electron/src/mainProcess.ts | 19 --- electron/src/settings/config.ts | 1 - electron/wire.json | 1 - jenkins/macOS.groovy | 84 ++++--------- jenkins/windows.groovy | 5 +- package.json | 2 +- resources/macos/entitlements/parent.plist | 4 - 14 files changed, 36 insertions(+), 466 deletions(-) delete mode 100644 bin/passkey-diagnostics.cjs delete mode 100644 bin/passkey-diagnostics.test.cjs diff --git a/README.md b/README.md index 0184f8db932..a19edf33797 100644 --- a/README.md +++ b/README.md @@ -129,17 +129,17 @@ yarn build:win yarn build:linux ``` -Signed macOS builds require a provisioning profile because WebAuthn uses a restricted keychain-access-group entitlement. Provide the profile through `MACOS_PROVISIONING_PROFILE`; the build embeds it in the application before applying the final signature. Jenkins expects Secret file credentials named `MACOS_PROVISIONING_PROFILE` and `MACOS_PROVISIONING_PROFILE_INTERNAL` for the corresponding bundle identifiers. +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. -For passkey diagnostics, search Jenkins **Console Output** for `[Passkeys]`. Both pipelines log the Node/Electron versions and fail on an incompatible Node version. The macOS Jenkins job needs a NodeJS tool named `node-v23.0.0` under **Manage Jenkins → Tools**. The macOS job also checks the built app's signature, runtime keychain group, signed entitlements, embedded profile authorization, bundle identifier, profile expiry, and whether the signing certificate is valid and included in the profile. `BUILD CHECKS PASSED` confirms these build prerequisites; it does not confirm a successful login. A `FAIL` message identifies the failed check. If credentials or Node setup fail earlier, follow the setup message immediately above that failure. +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. -On macOS, desktop SSO uses the system browser authentication session. See [macOS browser SSO](docs/macos-browser-sso.md) for the callback contract, packaging requirements, and security considerations. Windows and Linux continue using embedded SSO windows; the standalone webapp is unchanged. +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. Register a passkey once in this new shared session after upgrading: existing credentials in account-specific partitions are not migrated. 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. +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, startup logs confirm configuration, and 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. Touch ID credentials configured here are device-bound; existing iCloud/Safari passkeys are not validated by the Jenkins checks. +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 diff --git a/bin/build-tools/lib/Config.ts b/bin/build-tools/lib/Config.ts index 2f284d4a395..8823354ac76 100644 --- a/bin/build-tools/lib/Config.ts +++ b/bin/build-tools/lib/Config.ts @@ -41,7 +41,6 @@ export interface CommonConfig { supportUrl: string; updateUrl?: string; version: string; - webAuthnKeychainAccessGroup: string; websiteUrl: string; } @@ -63,7 +62,6 @@ export interface MacOSConfig { electronMirror: string | null; notarizeAppleId: string | null; notarizeApplePassword: string | null; - provisioningProfile: string | null; } export interface WindowsConfig { diff --git a/bin/build-tools/lib/build-macos.test.ts b/bin/build-tools/lib/build-macos.test.ts index c00b3c415cf..88ea6ff98b8 100644 --- a/bin/build-tools/lib/build-macos.test.ts +++ b/bin/build-tools/lib/build-macos.test.ts @@ -17,13 +17,10 @@ * */ -import fs from 'fs-extra'; - import * as assert from 'assert'; -import os from 'os'; import * as path from 'path'; -import {buildMacOSConfig, embedProvisioningProfile} from './build-macos'; +import {buildMacOSConfig} from './build-macos'; import {generateUUID} from '../../bin-utils'; @@ -78,14 +75,12 @@ describe('build-macos', () => { const certNameInstaller = generateUUID(); const notarizeAppleId = generateUUID(); const notarizeApplePassword = generateUUID(); - const provisioningProfile = __filename; process.env.MACOS_BUNDLE_ID = bundleId; process.env.MACOS_CERTIFICATE_NAME_APPLICATION = certNameApplication; process.env.MACOS_CERTIFICATE_NAME_INSTALLER = certNameInstaller; process.env.MACOS_NOTARIZE_APPLE_ID = notarizeAppleId; process.env.MACOS_NOTARIZE_APPLE_PASSWORD = notarizeApplePassword; - process.env.MACOS_PROVISIONING_PROFILE = provisioningProfile; const {macOSConfig} = await buildMacOSConfig(wireJsonPath, envFilePath); @@ -94,81 +89,12 @@ describe('build-macos', () => { assert.strictEqual(macOSConfig.certNameInstaller, certNameInstaller); assert.strictEqual(macOSConfig.notarizeAppleId, notarizeAppleId); assert.strictEqual(macOSConfig.notarizeApplePassword, notarizeApplePassword); - assert.strictEqual(macOSConfig.provisioningProfile, provisioningProfile); delete process.env.MACOS_BUNDLE_ID; delete process.env.MACOS_CERTIFICATE_NAME_APPLICATION; delete process.env.MACOS_CERTIFICATE_NAME_INSTALLER; delete process.env.MACOS_NOTARIZE_APPLE_ID; delete process.env.MACOS_NOTARIZE_APPLE_PASSWORD; - delete process.env.MACOS_PROVISIONING_PROFILE; }); }); }); - -describe('embedProvisioningProfile', () => { - let directory: string; - let buildDir: string; - let appFile: string; - let profile: string; - - beforeEach(async () => { - directory = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-profile-test-')); - buildDir = path.join(directory, 'build'); - appFile = path.join(buildDir, 'Wire.app'); - profile = path.join(directory, 'source.provisionprofile'); - await fs.ensureDir(path.join(appFile, 'Contents')); - await fs.writeFile(profile, 'profile fixture', {mode: 0o600}); - }); - - afterEach(async () => { - await fs.remove(directory); - }); - - it('embeds a readable profile and replaces an existing profile', async () => { - const destination = await embedProvisioningProfile(appFile, profile, buildDir); - assert.strictEqual(await fs.readFile(destination, 'utf8'), 'profile fixture'); - if (process.platform !== 'win32') { - assert.strictEqual((await fs.stat(destination)).mode & 0o777, 0o644); - } - await fs.writeFile(profile, 'replacement'); - await embedProvisioningProfile(appFile, profile, buildDir); - assert.strictEqual(await fs.readFile(destination, 'utf8'), 'replacement'); - }); - - it('rejects traversal to a sibling whose name shares the build-directory prefix', async () => { - const outside = path.join(directory, 'build-other', 'Wire.app'); - await fs.ensureDir(path.join(outside, 'Contents')); - await assert.rejects( - embedProvisioningProfile(path.join(buildDir, '..', 'build-other', 'Wire.app'), profile, buildDir), - /inside the build directory/, - ); - assert.strictEqual(await fs.pathExists(path.join(outside, 'Contents', 'embedded.provisionprofile')), false); - }); - - it('rejects a Contents symlink that escapes the build directory', async () => { - const outside = path.join(directory, 'outside'); - await fs.ensureDir(outside); - await fs.remove(path.join(appFile, 'Contents')); - await fs.symlink(outside, path.join(appFile, 'Contents'), 'junction'); - await assert.rejects(embedProvisioningProfile(appFile, profile, buildDir), /inside the build directory/); - assert.strictEqual((await fs.readdir(outside)).length, 0); - }); - - it('does not overwrite a file through a destination symlink', async () => { - const outside = path.join(directory, 'outside'); - await fs.writeFile(outside, 'unchanged'); - await fs.symlink(outside, path.join(appFile, 'Contents', 'embedded.provisionprofile')); - await assert.rejects(embedProvisioningProfile(appFile, profile, buildDir), /regular file/); - assert.strictEqual(await fs.readFile(outside, 'utf8'), 'unchanged'); - }); - - it('replaces a dangling link without writing to its target', async () => { - const outside = path.join(directory, 'absent'); - const destination = path.join(appFile, 'Contents', 'embedded.provisionprofile'); - await fs.symlink(outside, destination); - await embedProvisioningProfile(appFile, profile, buildDir); - assert.strictEqual((await fs.lstat(destination)).isSymbolicLink(), false); - assert.strictEqual(await fs.pathExists(outside), false); - }); -}); diff --git a/bin/build-tools/lib/build-macos.ts b/bin/build-tools/lib/build-macos.ts index 09740da17a9..a9f7563629c 100755 --- a/bin/build-tools/lib/build-macos.ts +++ b/bin/build-tools/lib/build-macos.ts @@ -61,7 +61,6 @@ export async function buildMacOSConfig( electronMirror: null, notarizeAppleId: null, notarizeApplePassword: null, - provisioningProfile: null, }; const macOSConfig: MacOSConfig = { @@ -73,20 +72,8 @@ export async function buildMacOSConfig( electronMirror: process.env.MACOS_ELECTRON_MIRROR_URL || macOSDefaultConfig.electronMirror, notarizeAppleId: process.env.MACOS_NOTARIZE_APPLE_ID || macOSDefaultConfig.notarizeAppleId, notarizeApplePassword: process.env.MACOS_NOTARIZE_APPLE_PASSWORD || macOSDefaultConfig.notarizeApplePassword, - provisioningProfile: process.env.MACOS_PROVISIONING_PROFILE || macOSDefaultConfig.provisioningProfile, }; - if (macOSConfig.certNameApplication) { - if (!macOSConfig.provisioningProfile) { - throw new Error( - 'MACOS_PROVISIONING_PROFILE is required when signing the app because the WebAuthn keychain access group is a restricted entitlement.', - ); - } - if (!(await fs.pathExists(macOSConfig.provisioningProfile))) { - throw new Error(`macOS provisioning profile not found at "${macOSConfig.provisioningProfile}".`); - } - } - if (macOSConfig.appleExportComplianceCode) { plistEntries['ITSAppUsesNonExemptEncryption'] = true; plistEntries['ITSEncryptionExportComplianceCode'] = macOSConfig.appleExportComplianceCode; @@ -111,8 +98,7 @@ export async function buildMacOSConfig( /\$electron\/src$/, /\/bin$/, /\/jenkins$/, - // Local signing inputs are not runtime resources. The selected profile is - // embedded explicitly in Contents before signing. + // Signing inputs are not runtime resources. /\/resources\/macos\/.*\.(?:p12|pfx|cer|provisionprofile|mobileprovision|key|p8)$/i, ], name: commonConfig.name, @@ -145,7 +131,6 @@ export async function buildMacOSConfig( entitlements: 'resources/macos/entitlements/parent.plist', }), identity: macOSConfig.certNameApplication, - provisioningProfile: macOSConfig.provisioningProfile || undefined, }; } @@ -218,34 +203,6 @@ export async function buildMacOSWrapper( } } -export async function embedProvisioningProfile(appFile: string, profile: string, buildDir: string): Promise { - const buildRoot = await fs.realpath(buildDir); - const contents = await fs.realpath(path.join(appFile, 'Contents')); - const relative = path.relative(buildRoot, contents); - if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new Error('Provisioning profile destination must be inside the build directory.'); - } - - const destination = path.join(contents, 'embedded.provisionprofile'); - // Do not follow a pre-existing destination link when copying or changing permissions. - if (await fs.pathExists(destination)) { - if (!(await fs.lstat(destination)).isFile()) { - throw new Error('Provisioning profile destination must be a regular file.'); - } - } - // Replace the directory entry rather than following it (including dangling symlinks). - const temporary = await fs.mkdtemp(path.join(contents, '.provisionprofile-')); - try { - const stagedProfile = path.join(temporary, 'profile'); - await fs.copyFile(profile, stagedProfile); - await fs.chmod(stagedProfile, 0o644); - await fs.rename(stagedProfile, destination); - } finally { - await fs.remove(temporary); - } - return destination; -} - export async function manualMacOSSign( appFile: string, pkgFile: string, @@ -256,17 +213,6 @@ export async function manualMacOSSign( const mainEntitlements = 'resources/macos/entitlements/parent.plist'; if (macOSConfig.certNameApplication) { - if (!macOSConfig.provisioningProfile) { - throw new Error('Cannot sign the macOS app without MACOS_PROVISIONING_PROFILE.'); - } - - const embeddedProvisioningProfile = await embedProvisioningProfile( - appFile, - macOSConfig.provisioningProfile, - commonConfig.buildDir, - ); - logger.log(`Embedded provisioning profile in "${embeddedProvisioningProfile}".`); - // 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', { diff --git a/bin/build-tools/lib/commonConfig.test.ts b/bin/build-tools/lib/commonConfig.test.ts index 6133b3344fd..7b73ceb36db 100644 --- a/bin/build-tools/lib/commonConfig.test.ts +++ b/bin/build-tools/lib/commonConfig.test.ts @@ -126,7 +126,6 @@ describe('commonConfig', () => { raygunApiKey: generateUUID(), supportUrl: generateUUID(), updateUrl: generateUUID(), - webAuthnKeychainAccessGroup: generateUUID(), websiteUrl: generateUUID(), }; @@ -153,7 +152,6 @@ describe('commonConfig', () => { assert.strictEqual(commonConfig.raygunApiKey, wireJson.raygunApiKey); assert.strictEqual(commonConfig.supportUrl, wireJson.supportUrl); assert.strictEqual(commonConfig.updateUrl, wireJson.updateUrl); - assert.strictEqual(commonConfig.webAuthnKeychainAccessGroup, wireJson.webAuthnKeychainAccessGroup); assert.strictEqual(commonConfig.websiteUrl, wireJson.websiteUrl); await fs.remove(tempDir); diff --git a/bin/passkey-diagnostics.cjs b/bin/passkey-diagnostics.cjs deleted file mode 100644 index d9f3414592d..00000000000 --- a/bin/passkey-diagnostics.cjs +++ /dev/null @@ -1,147 +0,0 @@ -// Only log selected build metadata; never dump provisioning profiles or credentials. -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const {X509Certificate} = require('node:crypto'); -const {execFileSync} = require('node:child_process'); - -const log = message => console.log(`[Passkeys] ${message}`); -const check = (condition, message) => { - if (!condition) { - throw new Error(message); - } - log(`PASS: ${message}`); -}; - -function runtime() { - const [major, minor] = process.versions.node.split('.').map(Number); - log(`Node ${process.versions.node}; platform ${process.platform}; architecture ${process.arch}`); - log(`Requested Electron ${require('../package.json').devDependencies.electron}`); - check(major > 22 || (major === 22 && minor >= 12), 'Node must be >=22.12.0 for Electron 43.'); - log('Build checks do not perform a passkey login. A signed app must still be tested on a user device.'); -} - -function command(executable, args, input) { - try { - return execFileSync(executable, args, {input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']}); - } catch { - // Command output may contain the provisioning profile. Do not include it in errors. - throw new Error(`${path.basename(executable)} ${args[0]} failed. Check the app signature or provisioning profile.`); - } -} - -function plist(data) { - // Profiles contain dates and certificate data, which plutil cannot convert to JSON. - return require('plist').parse(command('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', '-'], data)); -} - -function matches(pattern, value) { - return ( - typeof pattern === 'string' && - (pattern === value || (pattern.endsWith('*') && value.startsWith(pattern.slice(0, -1)))) - ); -} - -function validate({bundleId, group, entitlements, profile}, now = new Date()) { - check(typeof group === 'string' && group.length > 0, 'Runtime keychain access group is configured.'); - check( - entitlements['keychain-access-groups']?.includes(group), - 'Signed app entitlement must include the runtime keychain access group.', - ); - const teams = profile.TeamIdentifier || []; - check( - teams.some(team => group.startsWith(`${team}.`)), - 'Profile team must match the keychain access group.', - ); - const allowed = profile.Entitlements || {}; - check( - (allowed['keychain-access-groups'] || []).some(pattern => matches(pattern, group)), - 'Provisioning profile must authorize the keychain access group.', - ); - const appId = allowed['com.apple.application-identifier'] || allowed['application-identifier']; - check( - (profile.ApplicationIdentifierPrefix || teams).some(prefix => matches(appId, `${prefix}.${bundleId}`)), - 'Provisioning profile must authorize the built app bundle identifier.', - ); - check(new Date(profile.ExpirationDate) > now, 'Provisioning profile must not be expired.'); - log(`Profile expires: ${profile.ExpirationDate}`); -} - -function validateSigningCertificate(profile, certificateData, now = new Date()) { - const certificate = new X509Certificate(certificateData); - check( - (profile.DeveloperCertificates || []).some(data => new X509Certificate(data).raw.equals(certificate.raw)), - 'App signing certificate must be included in the provisioning profile.', - ); - check( - new Date(certificate.validFrom) <= now && now < new Date(certificate.validTo), - 'App signing certificate must be within its validity period.', - ); - log(`Signing certificate expires: ${certificate.validTo}`); -} - -function macos() { - const buildDir = path.resolve('wrap/build'); - check(fs.existsSync(buildDir), 'Build output directory must exist.'); - const apps = fs - .readdirSync(buildDir, {withFileTypes: true}) - .filter(entry => entry.isDirectory()) - .flatMap(entry => { - const dir = path.join(buildDir, entry.name); - return fs - .readdirSync(dir) - .filter(name => name.endsWith('.app')) - .map(name => path.join(dir, name)); - }); - check( - apps.length === 1, - `Expected exactly one built .app; found ${apps.length}. Clean wrap/build if there are stale builds.`, - ); - const app = apps[0]; - log(`Inspecting built app: ${path.relative(process.cwd(), app)}`); - const contents = path.join(app, 'Contents'); - const info = plist(fs.readFileSync(path.join(contents, 'Info.plist'))); - log(`Bundle identifier: ${info.CFBundleIdentifier}; version: ${info.CFBundleShortVersionString}`); - const resources = path.join(contents, 'Resources'); - const asar = path.join(resources, 'app.asar'); - const wireJson = fs.existsSync(asar) - ? require('@electron/asar').extractFile(asar, 'electron/wire.json').toString() - : fs.readFileSync(path.join(resources, 'app', 'electron', 'wire.json'), 'utf8'); - const group = JSON.parse(wireJson).webAuthnKeychainAccessGroup; - log(`Built runtime keychain group: ${group || '(missing)'}`); - const embeddedProfile = path.join(contents, 'embedded.provisionprofile'); - check(fs.existsSync(embeddedProfile), 'App must contain Contents/embedded.provisionprofile.'); - const profile = plist(command('/usr/bin/security', ['cms', '-D', '-i', embeddedProfile])); - log('PASS: Embedded provisioning profile decoded.'); - command('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]); - log('PASS: App signature verified (--deep --strict).'); - const entitlements = plist(command('/usr/bin/codesign', ['--display', '--entitlements', ':-', app])); - validate({bundleId: info.CFBundleIdentifier, group, entitlements, profile}); - const certificateDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'wire-signing-check-')); - try { - const prefix = path.join(certificateDirectory, 'certificate'); - // This optional argument must use '=' or codesign treats the prefix as another app path. - command('/usr/bin/codesign', ['--display', `--extract-certificates=${prefix}`, app]); - validateSigningCertificate(profile, fs.readFileSync(`${prefix}0`)); - } finally { - fs.rmSync(certificateDirectory, {recursive: true, force: true}); - } - log('BUILD CHECKS PASSED: Passkey signing prerequisites are present.'); - log('MANUAL TEST REQUIRED: Launch this signed app with --enable-logging and complete passkey login.'); - log('Touch ID credentials are device-bound; these checks do not verify iCloud/Safari passkeys or IdP acceptance.'); -} - -if (require.main === module) { - try { - if (process.argv[2] === 'macos') { - macos(); - } else { - runtime(); - } - } catch (error) { - console.error(`[Passkeys] FAIL: ${error.message}`); - process.exitCode = 1; - } -} - -module.exports = {validate, plist, validateSigningCertificate}; diff --git a/bin/passkey-diagnostics.test.cjs b/bin/passkey-diagnostics.test.cjs deleted file mode 100644 index 6d4545db1d4..00000000000 --- a/bin/passkey-diagnostics.test.cjs +++ /dev/null @@ -1,93 +0,0 @@ -const assert = require('node:assert/strict'); -const {test} = require('node:test'); -const {validate, plist} = require('./passkey-diagnostics.cjs'); - -const now = new Date('2026-09-25T00:00:00Z'); -function fixture() { - return { - bundleId: 'com.example.app', - group: 'TEAM.com.example.app.webauthn', - entitlements: {'keychain-access-groups': ['TEAM.com.example.app.webauthn']}, - profile: { - TeamIdentifier: ['TEAM'], - ApplicationIdentifierPrefix: ['TEAM'], - ExpirationDate: '2027-09-25T00:00:00Z', - Entitlements: { - 'com.apple.application-identifier': 'TEAM.com.example.app', - 'keychain-access-groups': ['TEAM.com.example.app.webauthn'], - }, - }, - }; -} - -test('accepts matching signing prerequisites', () => { - assert.doesNotThrow(() => validate(fixture(), now)); -}); - -test('reads a real plist containing expiry dates and certificate data', {skip: process.platform !== 'darwin'}, () => { - const source = fixture().profile; - source.ExpirationDate = new Date(source.ExpirationDate); - source.DeveloperCertificates = [Buffer.from('fixture certificate')]; - const decoded = plist(require('plist').build(source)); - assert.strictEqual(decoded.ExpirationDate.toISOString(), '2027-09-25T00:00:00.000Z'); - assert.deepStrictEqual(decoded.TeamIdentifier, ['TEAM']); - validate({...fixture(), profile: decoded}, now); -}); - -test('accepts provisioning wildcard authorization', () => { - const data = fixture(); - data.profile.Entitlements['keychain-access-groups'] = ['TEAM.*']; - data.profile.Entitlements['com.apple.application-identifier'] = 'TEAM.com.example.*'; - assert.doesNotThrow(() => validate(data, now)); -}); - -test('rejects a runtime group absent from the signed entitlement', () => { - const data = fixture(); - data.entitlements['keychain-access-groups'] = []; - assert.throws(() => validate(data, now), /Signed app entitlement/); -}); - -test('rejects a group not authorized by the profile', () => { - const data = fixture(); - data.profile.Entitlements['keychain-access-groups'] = ['TEAM.other.*']; - assert.throws(() => validate(data, now), /authorize the keychain/); -}); - -test('rejects a profile for another bundle', () => { - const data = fixture(); - data.bundleId = 'com.example.other'; - assert.throws(() => validate(data, now), /bundle identifier/); -}); - -test('rejects expired profiles', () => { - const data = fixture(); - data.profile.ExpirationDate = '2026-09-24T00:00:00Z'; - assert.throws(() => validate(data, now), /expired/); -}); - -test('rejects a profile from another team', () => { - const data = fixture(); - data.profile.TeamIdentifier = ['OTHER']; - assert.throws(() => validate(data, now), /Profile team/); -}); - -// Use public certificate data only; no signing identity or private key is needed. -const {X509Certificate} = require('node:crypto'); -const {validateSigningCertificate} = require('./passkey-diagnostics.cjs'); -const certificate = new X509Certificate(require('node:tls').rootCertificates[0]); -const certificateProfile = {DeveloperCertificates: [certificate.raw]}; -const validDate = new Date((Date.parse(certificate.validFrom) + Date.parse(certificate.validTo)) / 2); - -test('accepts the signing certificate authorized by the profile', () => { - assert.doesNotThrow(() => validateSigningCertificate(certificateProfile, certificate.raw, validDate)); -}); - -test('rejects a signing certificate absent from the profile', () => { - assert.throws(() => validateSigningCertificate({DeveloperCertificates: []}, certificate.raw, validDate), /included/); -}); - -test('rejects expired or not yet valid signing certificates', () => { - for (const date of [new Date(Date.parse(certificate.validFrom) - 1), new Date(certificate.validTo)]) { - assert.throws(() => validateSigningCertificate(certificateProfile, certificate.raw, date), /validity period/); - } -}); diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index f8e41010883..0dbc1d5dab6 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -133,24 +133,6 @@ const customDownloadPath = settings.restore(SettingsType.DOW const appHomePath = (path: string) => `${app.getPath('home')}\\${path}`; const isInternalBuild = (): boolean => config.environment === 'internal'; -const configureWebAuthn = (): void => { - if (!EnvironmentUtil.platform.IS_MAC_OS) { - logger.info(`[Passkeys] Skipping macOS Touch ID configuration on ${process.platform}; using platform defaults.`); - return; - } - - logger.info( - `[Passkeys] Configuring Touch ID: Electron ${process.versions.electron}; keychain group ${config.webAuthnKeychainAccessGroup}.`, - ); - app.configureWebAuthn({ - touchID: { - keychainAccessGroup: config.webAuthnKeychainAccessGroup, - promptReason: 'sign in to $1', - }, - }); - logger.info('[Passkeys] Touch ID configuration applied. This does not verify keychain access or a successful login.'); -}; - if (customDownloadPath) { electronDl({ directory: appHomePath(customDownloadPath), @@ -525,7 +507,6 @@ const handleAppEvents = (): void => { // System Menu, Tray Icon & Show window app.on('ready', async () => { - configureWebAuthn(); let regionalLocale: string | undefined; try { regionalLocale = app.getSystemLocale(); diff --git a/electron/src/settings/config.ts b/electron/src/settings/config.ts index d6a32f07a59..91e9a28a053 100644 --- a/electron/src/settings/config.ts +++ b/electron/src/settings/config.ts @@ -37,7 +37,6 @@ interface WireJson { supportUrl: string; updateUrl: string; version: string; - webAuthnKeychainAccessGroup: string; websiteUrl: string; } diff --git a/electron/wire.json b/electron/wire.json index 790a14626e1..7ad73dae295 100644 --- a/electron/wire.json +++ b/electron/wire.json @@ -21,6 +21,5 @@ "supportUrl": "https://support.wire.com", "updateUrl": "https://wire-app.wire.com/win/prod/", "version": "3.44.0", - "webAuthnKeychainAccessGroup": "EDF3JCE8BC.com.wearezeta.zclient.mac.webauthn", "websiteUrl": "https://wire.com" } diff --git a/jenkins/macOS.groovy b/jenkins/macOS.groovy index d78aeef9cea..49bc12e8ee2 100644 --- a/jenkins/macOS.groovy +++ b/jenkins/macOS.groovy @@ -8,10 +8,8 @@ node("macos") { def custom = params.CUSTOM def wireGov = params.WIRE_GOV def skipNotarization = params.containsKey('SKIP_NOTARIZATION') ? params.SKIP_NOTARIZATION : true - echo '[Passkeys] Resolving Jenkins NodeJS tool node-v23.0.0. If missing, configure it under Manage Jenkins > Tools.' def NODE = tool name: 'node-v23.0.0', type: 'nodejs' def privateAPIResult = '' - def provisioningProfileCredential = (!production && !custom && !wireGov) ? 'MACOS_PROVISIONING_PROFILE_INTERNAL' : 'MACOS_PROVISIONING_PROFILE' def jenkinsbot_secret = '' withCredentials([string(credentialsId: "${params.JENKINSBOT_SECRET}", variable: 'JENKINSBOT_SECRET')]) { @@ -41,68 +39,40 @@ node("macos") { stage('Build') { try { - // Supports existing certificate-name dropdowns and exact SHA-1 choices. - def applicationCertificate = (params.MACOS_CERTIFICATE_NAME_APPLICATION ?: '').trim() - def buildEnvironment = ["PATH+NODE=${NODE}/bin"] - if (applicationCertificate) { - def isFingerprint = applicationCertificate ==~ /[0-9a-fA-F]{40}/ - // Signing commands currently quote the identity in a shell command. - // Keep dropdown names intact while excluding quotes and control characters. - def isCertificateName = applicationCertificate ==~ /(?:Apple Distribution|Apple Development|Mac Developer|Mac App Distribution|3rd Party Mac Developer Application|Developer ID Application): [A-Za-z0-9 .,&_-]+ \([A-Z0-9]{10}\)/ - if (!isFingerprint && !isCertificateName) { - error('[Passkeys] MACOS_CERTIFICATE_NAME_APPLICATION must be an app-signing certificate name, a 40-character SHA-1 fingerprint, or blank to use the existing configuration.') - } - if (isFingerprint) { - applicationCertificate = applicationCertificate.toUpperCase() - } - buildEnvironment.add("MACOS_CERTIFICATE_NAME_APPLICATION=${applicationCertificate}") - echo "[Passkeys] App-signing certificate override: ${applicationCertificate} (installed identity)." - } - echo "[Passkeys] Using provisioning credential ${provisioningProfileCredential}. If binding fails, add this Secret file credential in Jenkins." - withCredentials([ - string(credentialsId: 'MACOS_KEYCHAIN_PASSWORD', variable: 'MACOS_KEYCHAIN_PASSWORD'), - file(credentialsId: provisioningProfileCredential, variable: 'MACOS_PROVISIONING_PROFILE'), - ]) { + withCredentials([string(credentialsId: 'MACOS_KEYCHAIN_PASSWORD', variable: 'MACOS_KEYCHAIN_PASSWORD')]) { sh 'security unlock-keychain -p \"$MACOS_KEYCHAIN_PASSWORD\" /Users/jenkins/Library/Keychains/login.keychain-db' - withEnv(buildEnvironment) { - sh 'node bin/passkey-diagnostics.cjs' - sh 'npm -v' - sh 'npm install -g yarn' - sh 'yarn' - echo '[Passkeys] Checking that the supplied provisioning profile can be decoded.' - sh 'security cms -D -i "$MACOS_PROVISIONING_PROFILE" >/dev/null' - echo '[Passkeys] PASS: Provisioning profile decoded. Building and signing the app next.' - if (production) { - withCredentials([string(credentialsId: 'APPLE_EXPORT_COMPLIANCE_CODE', variable: 'APPLE_EXPORT_COMPLIANCE_CODE')]) { - sh 'yarn build:macos' - } - - echo 'Checking for private Apple APIs ...' - privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/Wire-mas-universal/Wire.app"', returnStdout: true - echo privateAPIResult - } else if (custom) { + } + withEnv(["PATH+NODE=${NODE}/bin"]) { + sh 'node -v' + sh 'npm -v' + sh 'npm install -g yarn' + sh 'yarn' + if (production) { + withCredentials([string(credentialsId: 'APPLE_EXPORT_COMPLIANCE_CODE', variable: 'APPLE_EXPORT_COMPLIANCE_CODE')]) { sh 'yarn build:macos' - } else if (wireGov) { - sh 'yarn build:macos:wire-gov' - - echo 'Checking for private Apple APIs ...' - privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireGov-mas-universal/WireGov.app"', returnStdout: true - echo privateAPIResult - } else { - // internal - sh 'yarn build:macos:internal' - - echo 'Checking for private Apple APIs ...' - privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireInternal-mas-universal/WireInternal.app"', returnStdout: true - echo privateAPIResult } - echo '[Passkeys] Verifying the built app, embedded profile, and signed keychain entitlement.' - sh 'node bin/passkey-diagnostics.cjs macos' + echo 'Checking for private Apple APIs ...' + privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/Wire-mas-universal/Wire.app"', returnStdout: true + echo privateAPIResult + } else if (custom) { + sh 'yarn build:macos' + } else if (wireGov) { + sh 'yarn build:macos:wire-gov' + + echo 'Checking for private Apple APIs ...' + privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireGov-mas-universal/WireGov.app"', returnStdout: true + echo privateAPIResult + } else { + // internal + sh 'yarn build:macos:internal' + + echo 'Checking for private Apple APIs ...' + privateAPIResult = sh script: 'bin/macos-check_private_apis.sh "wrap/build/WireInternal-mas-universal/WireInternal.app"', returnStdout: true + echo privateAPIResult } } } catch(e) { - echo '[Passkeys] BUILD FAILED: See the first failed command or [Passkeys] FAIL above. Passkey readiness has not been established.' currentBuild.result = 'FAILED' wireSend secret: "${jenkinsbot_secret}", message: "🍏 **${JOB_NAME} ${version} build failed**\n${BUILD_URL}" throw e diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index 32cc5f32279..0aa7354f2c6 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -8,7 +8,6 @@ node('windows') { def production = params.PRODUCTION def custom = params.CUSTOM def wireGov = params.WIRE_GOV - echo '[Passkeys] Resolving Jenkins NodeJS tool node-v23.0.0. If missing, configure it under Manage Jenkins > Tools.' def NODE = tool name: 'node-v23.0.0', type: 'nodejs' def jenkinsbot_secret = '' @@ -38,8 +37,7 @@ node('windows') { stage('Build') { try { withEnv(["PATH+NODE=${NODE}", 'npm_config_target_arch=x64']) { - bat 'node bin/passkey-diagnostics.cjs' - echo '[Passkeys] Windows uses the OS authenticator; macOS provisioning and Touch ID checks do not apply.' + bat 'node -v' bat 'npm -v' bat 'npm install -g yarn' bat 'yarn' @@ -50,7 +48,6 @@ node('windows') { } else { bat 'yarn build:win:internal' } - echo '[Passkeys] App build completed. MANUAL TEST REQUIRED: Test passkey login on Windows; a successful build does not verify authentication.' } } catch (e) { currentBuild.result = 'FAILED' diff --git a/package.json b/package.json index 54c45d013e9..71f8a4a174c 100644 --- a/package.json +++ b/package.json @@ -208,7 +208,7 @@ "start:prod": "yarn start --env=https://app.wire.com", "start:fed": "yarn start --env=https://webapp.${FED}.wire.link/ ", "test": "yarn test:types && yarn prestart && yarn build:ts:tests && yarn test:react && yarn test:main && yarn test:renderer && yarn test:bin", - "test:bin": "node --test bin/passkey-diagnostics.test.cjs && mocha --require .babel-register.js \"bin/**/*.test?(.main).ts\"", + "test:bin": "mocha --require .babel-register.js \"bin/**/*.test?(.main).ts\"", "test:e2e": "playwright test", "test:main": "electron-mocha --require .babel-register.js \"electron/src/**/*.test?(.main).ts\" --no-sandbox", "test:renderer": "electron-mocha --renderer --require .babel-register.js \"electron/src/**/*.test?(.renderer).ts\" --no-sandbox --window-config electron/test/mocha-window-config.json", diff --git a/resources/macos/entitlements/parent.plist b/resources/macos/entitlements/parent.plist index 57a191b73d9..91d7cb8059f 100644 --- a/resources/macos/entitlements/parent.plist +++ b/resources/macos/entitlements/parent.plist @@ -16,9 +16,5 @@ com.apple.security.application-groups EDF3JCE8BC.com.wearezeta.zclient.mac - keychain-access-groups - - EDF3JCE8BC.com.wearezeta.zclient.mac.webauthn - From 8bd6fc75f5a0c56d00673760b4832bd3cffeeecf Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 8 Oct 2026 12:35:20 +0200 Subject: [PATCH 7/7] fix: handle PR comments --- bin/build-tools/lib/build-macos.test.ts | 1 + bin/build-tools/lib/build-macos.ts | 16 ++-- electron/src/auth/WebAuthn.test.main.ts | 23 ++++++ electron/src/auth/WebAuthn.ts | 7 +- electron/src/lib/CoreProtocol.test.main.ts | 13 ++++ electron/src/lib/CoreProtocol.ts | 11 +-- electron/src/locale/en-US.json | 3 + electron/src/preload/preload-sso.ts | 4 +- electron/src/preload/preload-webview.ts | 16 +++- .../src/sso/BrowserSingleSignOn.test.main.ts | 48 +++++++++++- electron/src/sso/BrowserSingleSignOn.ts | 16 +++- electron/src/sso/MacWebAuthentication.ts | 13 +++- electron/src/sso/SingleSignOn.test.main.ts | 73 +++++++++++++++++++ electron/src/sso/SingleSignOn.ts | 25 ++++--- .../src/sso/browserSsoCallback.test.main.ts | 13 ++++ electron/src/sso/browserSsoCallback.ts | 11 ++- electron/src/sso/ssoResult.ts | 43 +++++++++++ 17 files changed, 296 insertions(+), 40 deletions(-) create mode 100644 electron/src/sso/ssoResult.ts diff --git a/bin/build-tools/lib/build-macos.test.ts b/bin/build-tools/lib/build-macos.test.ts index 88ea6ff98b8..9102a86822d 100644 --- a/bin/build-tools/lib/build-macos.test.ts +++ b/bin/build-tools/lib/build-macos.test.ts @@ -58,6 +58,7 @@ describe('build-macos', () => { ), ); assert.strictEqual(packagerConfig.platform, 'mas'); + assert.ok((packagerConfig.extendInfo as Record).NSAudioCaptureUsageDescription); } } finally { for (const key of Object.keys(process.env)) { diff --git a/bin/build-tools/lib/build-macos.ts b/bin/build-tools/lib/build-macos.ts index a9f7563629c..83c87540a7c 100755 --- a/bin/build-tools/lib/build-macos.ts +++ b/bin/build-tools/lib/build-macos.ts @@ -50,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 = { @@ -220,12 +222,14 @@ export async function manualMacOSSign( followSymbolicLinks: false, }); for (const addon of addons) { - await promisify(execFile)('codesign', [ - '--force', - '--sign', - macOSConfig.certNameApplication, - path.join(appFile, addon), - ]); + 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.`); diff --git a/electron/src/auth/WebAuthn.test.main.ts b/electron/src/auth/WebAuthn.test.main.ts index 9621c567130..28005e599b5 100644 --- a/electron/src/auth/WebAuthn.test.main.ts +++ b/electron/src/auth/WebAuthn.test.main.ts @@ -25,6 +25,8 @@ import {EventEmitter} from 'events'; import {registerWebAuthnAccountPicker} from './WebAuthn'; +import * as locale from '../locale'; + describe('WebAuthn account picker', () => { let session: EventEmitter; let showDialog: SinonStub; @@ -74,6 +76,27 @@ describe('WebAuthn account picker', () => { 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); diff --git a/electron/src/auth/WebAuthn.ts b/electron/src/auth/WebAuthn.ts index 34c84fd72f0..d1c90d63b9d 100644 --- a/electron/src/auth/WebAuthn.ts +++ b/electron/src/auth/WebAuthn.ts @@ -57,12 +57,13 @@ export function registerWebAuthnAccountPicker(session: Session): void { logger.info('[Passkeys] Showing account picker.'); const {response} = await dialog.showMessageBox(parent, { type: 'question', - title: 'Sign in with a passkey', - message: `Choose an account for ${relyingPartyId}`, + title: getText('passkeyPickerTitle'), + message: getText('passkeyPickerMessage', {domain: relyingPartyId}), buttons: [ ...accounts.map( (account, index) => - [account.displayName, account.name].filter(Boolean).join(' — ') || `Account ${index + 1}`, + [account.displayName, account.name].filter(Boolean).join(' — ') || + getText('passkeyPickerAccount', {number: String(index + 1)}), ), getText('promptCancel'), ], diff --git a/electron/src/lib/CoreProtocol.test.main.ts b/electron/src/lib/CoreProtocol.test.main.ts index 8239decf7ec..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; @@ -66,6 +67,18 @@ describe('dispatchDeepLink', () => { 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 6df1f27c524..75a3c6eaf62 100644 --- a/electron/src/lib/CoreProtocol.ts +++ b/electron/src/lib/CoreProtocol.ts @@ -45,11 +45,7 @@ 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 - ) { + if (typeof url === 'undefined' || !url.startsWith(CORE_PROTOCOL_PREFIX)) { showErrorDialog('Invalid deep link.'); logger.info('Invalid deep link, ignoring'); return; @@ -64,6 +60,11 @@ export class CustomProtocolHandler { 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); diff --git a/electron/src/locale/en-US.json b/electron/src/locale/en-US.json index 30d0ec305d1..0c28fb0c8cb 100644 --- a/electron/src/locale/en-US.json +++ b/electron/src/locale/en-US.json @@ -108,6 +108,9 @@ "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", diff --git a/electron/src/preload/preload-sso.ts b/electron/src/preload/preload-sso.ts index 08951042a03..083d0eb54fa 100644 --- a/electron/src/preload/preload-sso.ts +++ b/electron/src/preload/preload-sso.ts @@ -22,8 +22,8 @@ import {contextBridge, ipcRenderer} from 'electron'; let completion: Promise = Promise.resolve(); contextBridge.exposeInMainWorld('__wireSsoOpener', { - postMessage: (message: {type?: unknown}) => { - completion = ipcRenderer.invoke('wire:sso-complete', message?.type).catch(() => false); + postMessage: (message: {type?: unknown; payload?: unknown}) => { + completion = ipcRenderer.invoke('wire:sso-complete', message?.type, message?.payload).catch(() => false); return completion; }, close: () => { diff --git a/electron/src/preload/preload-webview.ts b/electron/src/preload/preload-webview.ts index c7230f81adf..52009a862aa 100644 --- a/electron/src/preload/preload-webview.ts +++ b/electron/src/preload/preload-webview.ts @@ -49,10 +49,18 @@ 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}) => { - logger.info('[Passkeys] Received verified SSO result after cookie handoff.'); - window.dispatchEvent(new MessageEvent('message', {origin: result.origin, data: {type: result.type}})); -}); +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 { diff --git a/electron/src/sso/BrowserSingleSignOn.test.main.ts b/electron/src/sso/BrowserSingleSignOn.test.main.ts index 592e69887e1..17ae4024b76 100644 --- a/electron/src/sso/BrowserSingleSignOn.test.main.ts +++ b/electron/src/sso/BrowserSingleSignOn.test.main.ts @@ -23,18 +23,24 @@ 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}) => events.push(result.type), + send: (_channel: string, result: {type: string}) => { + results.push(result); + events.push(result.type); + }, session: { cookies: { set: async () => { @@ -60,7 +66,8 @@ function setup(failCookie = false) { callbackUrl = new URL(url).searchParams.get('success_redirect')!; scheme = callbackScheme; return { - result: new Promise(resolve => { + result: new Promise((resolve, rejectResult) => { + reject = rejectResult; callback = resolve; }), cancel: () => { @@ -76,7 +83,7 @@ function setup(failCookie = false) { flow.onClose = () => { events.push('close'); }; - const complete = (wrongState = false) => { + 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'); @@ -84,12 +91,45 @@ function setup(failCookie = false) { 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}; + 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(); diff --git a/electron/src/sso/BrowserSingleSignOn.ts b/electron/src/sso/BrowserSingleSignOn.ts index 80e27f96395..8fa79bddf2e 100644 --- a/electron/src/sso/BrowserSingleSignOn.ts +++ b/electron/src/sso/BrowserSingleSignOn.ts @@ -22,7 +22,8 @@ import {BrowserWindow, WebContents} from 'electron'; import {randomUUID} from 'crypto'; import {createBrowserSsoRequest, parseBrowserSsoCallback, SSO_TIMEOUT_MS} from './browserSsoCallback'; -import {startMacWebAuthentication, WebAuthenticationRequest} from './MacWebAuthentication'; +import {BrowserAuthenticationError, startMacWebAuthentication, WebAuthenticationRequest} from './MacWebAuthentication'; +import type {SsoPayload} from './ssoResult'; import {getLogger} from '../logging/getLogger'; import {config} from '../settings/config'; @@ -65,6 +66,10 @@ export class BrowserSingleSignOn { 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'; @@ -88,7 +93,10 @@ export class BrowserSingleSignOn { } logger.info('[SSO] Wire session cookie installed in the requesting account.'); this.sendResult('AUTH_SUCCESS'); - } catch { + } 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}.`); @@ -100,10 +108,10 @@ export class BrowserSingleSignOn { } } - private sendResult(type: string): void { + 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}); + this.sender.send('wire:sso-result', {origin: this.loginUrl.slice(0, index), type, ...(payload ? {payload} : {})}); } } diff --git a/electron/src/sso/MacWebAuthentication.ts b/electron/src/sso/MacWebAuthentication.ts index a53b616453f..6f554fe3951 100644 --- a/electron/src/sso/MacWebAuthentication.ts +++ b/electron/src/sso/MacWebAuthentication.ts @@ -30,6 +30,17 @@ export interface WebAuthenticationRequest { 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( @@ -96,7 +107,7 @@ export function startMacWebAuthentication( finished = true; if (error || !callback) { // Never propagate NSError text: it can contain the authentication URL. - reject(new Error('Browser authentication cancelled or failed.')); + reject(new BrowserAuthenticationError(error ? Number(error.code()) : undefined)); } else { try { resolve(callback.absoluteString().toString()); diff --git a/electron/src/sso/SingleSignOn.test.main.ts b/electron/src/sso/SingleSignOn.test.main.ts index 59e3c1b98e8..fa4d3958dc0 100644 --- a/electron/src/sso/SingleSignOn.test.main.ts +++ b/electron/src/sso/SingleSignOn.test.main.ts @@ -47,6 +47,36 @@ describe('SingleSignOn', () => { }); }); + 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'}}); @@ -136,6 +166,47 @@ describe('SingleSignOn', () => { }); 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')); @@ -214,6 +285,8 @@ describe('SingleSignOn', () => { 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); }); diff --git a/electron/src/sso/SingleSignOn.ts b/electron/src/sso/SingleSignOn.ts index 09bbf4d50a4..c4173328da7 100644 --- a/electron/src/sso/SingleSignOn.ts +++ b/electron/src/sso/SingleSignOn.ts @@ -23,6 +23,8 @@ import {Maybe} from 'true-myth'; import * as path from 'path'; import {URL} from 'url'; +import {parseSsoPayload, SsoPayload} from './ssoResult'; + import {registerTextPrompt} from '../auth/TextPrompt'; import {writeBoundedLogMessage} from '../logging/desktopLogWriter'; import {ENABLE_LOGGING, getLogger} from '../logging/getLogger'; @@ -71,6 +73,7 @@ export class SingleSignOn { } public readonly init = async (): Promise => { + 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()) { @@ -89,10 +92,9 @@ export class SingleSignOn { callback({cancel: false, requestHeaders}); }); - this.setupBrowserWindow(); registerTextPrompt(this.ssoWindow!); const popup = this.ssoWindow!; - popup.webContents.ipc.handle('wire:sso-complete', async (event, type: unknown) => { + popup.webContents.ipc.handle('wire:sso-complete', async (event, type: unknown, rawPayload: unknown) => { if ( event.senderFrame !== popup.webContents.mainFrame || typeof type !== 'string' || @@ -104,7 +106,11 @@ export class SingleSignOn { SingleSignOn.logger.warn('[Passkeys] Rejected SSO result from an unexpected origin.'); return false; } - this.completion ??= this.finalizeLogin(type); + const payload = parseSsoPayload(rawPayload); + if (rawPayload !== undefined && !payload) { + return false; + } + this.completion ??= this.finalizeLogin(type, payload); await this.completion; return true; }); @@ -232,16 +238,15 @@ export class SingleSignOn { } for (const cookie of cookies) { - if (cookie.domain) { - await toSession.cookies.set({url: cookieUrl, ...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 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); @@ -260,10 +265,10 @@ 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) { @@ -279,7 +284,7 @@ export class SingleSignOn { 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}); + this.senderWebContents.send('wire:sso-result', {origin, type, ...(payload ? {payload} : {})}); } private async wipeSessionData() { diff --git a/electron/src/sso/browserSsoCallback.test.main.ts b/electron/src/sso/browserSsoCallback.test.main.ts index baaf4ffe480..cdd142cf93d 100644 --- a/electron/src/sso/browserSsoCallback.test.main.ts +++ b/electron/src/sso/browserSsoCallback.test.main.ts @@ -46,6 +46,16 @@ describe('browser SSO callback', () => { 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', @@ -58,6 +68,9 @@ describe('browser SSO callback', () => { 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); diff --git a/electron/src/sso/browserSsoCallback.ts b/electron/src/sso/browserSsoCallback.ts index 9f678af290f..cf527bb885a 100644 --- a/electron/src/sso/browserSsoCallback.ts +++ b/electron/src/sso/browserSsoCallback.ts @@ -19,6 +19,8 @@ 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 { @@ -56,7 +58,7 @@ export function parseBrowserSsoCallback( scheme: string, state: string, expiresAt: number, -): CookiesSetDetails { +): CookiesSetDetails | {type: 'AUTH_ERROR'; payload: SsoPayload} { if (Date.now() >= expiresAt || callback.length > 16384) { throw new Error('Expired or oversized SSO callback.'); } @@ -72,6 +74,13 @@ export function parseBrowserSsoCallback( ) { 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.'); } 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[]} : {})}; +}