Support build-time specified protocol scheme for oidc callback (#2285)

This commit is contained in:
Michael Telatynski
2025-05-22 11:40:28 +01:00
committed by GitHub
parent 468d2249d1
commit ec4c610158
8 changed files with 181 additions and 116 deletions

View File

@@ -1,6 +1,6 @@
import * as os from "node:os"; import * as os from "node:os";
import * as fs from "node:fs"; import * as fs from "node:fs";
import { Configuration as BaseConfiguration } from "electron-builder"; import { type Configuration as BaseConfiguration, type Protocol } from "electron-builder";
/** /**
* This script has different outputs depending on your os platform. * This script has different outputs depending on your os platform.
@@ -16,9 +16,13 @@ import { Configuration as BaseConfiguration } from "electron-builder";
* Passes $ED_DEBIAN_CHANGELOG to build.deb.fpm if specified * Passes $ED_DEBIAN_CHANGELOG to build.deb.fpm if specified
*/ */
const DEFAULT_APP_ID = "im.riot.app";
const NIGHTLY_APP_ID = "im.riot.nightly"; const NIGHTLY_APP_ID = "im.riot.nightly";
const NIGHTLY_DEB_NAME = "element-nightly"; const NIGHTLY_DEB_NAME = "element-nightly";
const DEFAULT_PROTOCOL_SCHEME = "io.element.desktop";
const NIGHTLY_PROTOCOL_SCHEME = "io.element.nightly";
interface Pkg { interface Pkg {
name: string; name: string;
productName: string; productName: string;
@@ -33,7 +37,11 @@ type Writable<T> = NonNullable<
const pkg: Pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); const pkg: Pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
interface Configuration extends BaseConfiguration { interface Configuration extends BaseConfiguration {
extraMetadata: Partial<Pick<Pkg, "version">> & Omit<Pkg, "version">; extraMetadata: Partial<Pick<Pkg, "version">> &
Omit<Pkg, "version"> & {
electron_appId: string;
electron_protocol: string;
};
linux: BaseConfiguration["linux"]; linux: BaseConfiguration["linux"];
win: BaseConfiguration["win"]; win: BaseConfiguration["win"];
mac: BaseConfiguration["mac"]; mac: BaseConfiguration["mac"];
@@ -50,7 +58,7 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
// Make all fuses required to ensure they are all explicitly specified // Make all fuses required to ensure they are all explicitly specified
electronFuses: Required<Configuration["electronFuses"]>; electronFuses: Required<Configuration["electronFuses"]>;
} = { } = {
appId: "im.riot.app", appId: DEFAULT_APP_ID,
asarUnpack: "**/*.node", asarUnpack: "**/*.node",
electronFuses: { electronFuses: {
enableCookieEncryption: true, enableCookieEncryption: true,
@@ -85,6 +93,8 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
name: pkg.name, name: pkg.name,
productName: pkg.productName, productName: pkg.productName,
description: pkg.description, description: pkg.description,
electron_appId: DEFAULT_APP_ID,
electron_protocol: DEFAULT_PROTOCOL_SCHEME,
}, },
linux: { linux: {
target: ["tar.gz", "deb"], target: ["tar.gz", "deb"],
@@ -142,12 +152,10 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
directories: { directories: {
output: "dist", output: "dist",
}, },
protocols: [ protocols: {
{ name: "element",
name: "element", schemes: [DEFAULT_PROTOCOL_SCHEME, "element"],
schemes: ["io.element.desktop", "element"], },
},
],
nativeRebuilder: "sequential", nativeRebuilder: "sequential",
nodeGypRebuild: false, nodeGypRebuild: false,
npmRebuild: true, npmRebuild: true,
@@ -170,11 +178,12 @@ if (process.env.ED_SIGNTOOL_SUBJECT_NAME && process.env.ED_SIGNTOOL_THUMBPRINT)
if (process.env.ED_NIGHTLY) { if (process.env.ED_NIGHTLY) {
config.deb.fpm = []; // Clear the fpm as the breaks deb fields don't apply to nightly config.deb.fpm = []; // Clear the fpm as the breaks deb fields don't apply to nightly
config.appId = NIGHTLY_APP_ID; config.appId = config.extraMetadata.electron_appId = NIGHTLY_APP_ID;
config.extraMetadata.productName += " Nightly"; config.extraMetadata.productName += " Nightly";
config.extraMetadata.name += "-nightly"; config.extraMetadata.name += "-nightly";
config.extraMetadata.description += " (nightly unstable build)"; config.extraMetadata.description += " (nightly unstable build)";
config.deb.fpm.push("--name", NIGHTLY_DEB_NAME); config.deb.fpm.push("--name", NIGHTLY_DEB_NAME);
(config.protocols as Protocol).schemes[0] = config.extraMetadata.electron_protocol = NIGHTLY_PROTOCOL_SCHEME;
let version = process.env.ED_NIGHTLY; let version = process.env.ED_NIGHTLY;
if (os.platform() === "win32") { if (os.platform() === "win32") {

View File

@@ -13,11 +13,13 @@ import { type AppLocalization } from "../language-helper.js";
// global type extensions need to use var for whatever reason // global type extensions need to use var for whatever reason
/* eslint-disable no-var */ /* eslint-disable no-var */
declare global { declare global {
type IConfigOptions = Record<string, any>;
var mainWindow: BrowserWindow | null; var mainWindow: BrowserWindow | null;
var appQuitting: boolean; var appQuitting: boolean;
var appLocalization: AppLocalization; var appLocalization: AppLocalization;
var launcher: AutoLaunch; var launcher: AutoLaunch;
var vectorConfig: Record<string, any>; var vectorConfig: IConfigOptions;
var trayConfig: { var trayConfig: {
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
icon_path: string; icon_path: string;

26
src/build-config.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { type JsonObject, loadJsonFile } from "./utils.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
interface BuildConfig {
appId: string;
protocol: string;
}
export function readBuildConfig(): BuildConfig {
const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject;
return {
appId: (packageJson["electron_appId"] as string) || "im.riot.app",
protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop",
};
}

View File

@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
// Squirrel on windows starts the app with various flags as hooks to tell us when we've been installed/uninstalled etc. // Squirrel on windows starts the app with various flags as hooks to tell us when we've been installed/uninstalled etc.
import "./squirrelhooks.js"; import "./squirrelhooks.js";
import { app, BrowserWindow, Menu, autoUpdater, protocol, dialog, type Input, type Event, session } from "electron"; import { app, BrowserWindow, Menu, autoUpdater, dialog, type Input, type Event, session, protocol } from "electron";
// eslint-disable-next-line n/file-extension-in-import // eslint-disable-next-line n/file-extension-in-import
import * as Sentry from "@sentry/electron/main"; import * as Sentry from "@sentry/electron/main";
import AutoLaunch from "auto-launch"; import AutoLaunch from "auto-launch";
@@ -28,12 +28,13 @@ import Store from "./store.js";
import { buildMenuTemplate } from "./vectormenu.js"; import { buildMenuTemplate } from "./vectormenu.js";
import webContentsHandler from "./webcontents-handler.js"; import webContentsHandler from "./webcontents-handler.js";
import * as updater from "./updater.js"; import * as updater from "./updater.js";
import { getProfileFromDeeplink, protocolInit } from "./protocol.js"; import ProtocolHandler from "./protocol.js";
import { _t, AppLocalization } from "./language-helper.js"; import { _t, AppLocalization } from "./language-helper.js";
import { setDisplayMediaCallback } from "./displayMediaCallback.js"; import { setDisplayMediaCallback } from "./displayMediaCallback.js";
import { setupMacosTitleBar } from "./macos-titlebar.js"; import { setupMacosTitleBar } from "./macos-titlebar.js";
import { type Json, loadJsonFile } from "./utils.js"; import { type Json, loadJsonFile } from "./utils.js";
import { setupMediaAuth } from "./media-auth.js"; import { setupMediaAuth } from "./media-auth.js";
import { readBuildConfig } from "./build-config.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -72,10 +73,13 @@ function isRealUserDataDir(d: string): boolean {
return fs.existsSync(path.join(d, "IndexedDB")); return fs.existsSync(path.join(d, "IndexedDB"));
} }
const buildConfig = readBuildConfig();
const protocolHandler = new ProtocolHandler(buildConfig.protocol);
// check if we are passed a profile in the SSO callback url // check if we are passed a profile in the SSO callback url
let userDataPath: string; let userDataPath: string;
const userDataPathInProtocol = getProfileFromDeeplink(argv["_"]); const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]);
if (userDataPathInProtocol) { if (userDataPathInProtocol) {
userDataPath = userDataPathInProtocol; userDataPath = userDataPathInProtocol;
} else if (argv["profile-dir"]) { } else if (argv["profile-dir"]) {
@@ -311,7 +315,7 @@ if (!gotLock) {
} }
// do this after we know we are the primary instance of the app // do this after we know we are the primary instance of the app
protocolInit(); protocolHandler.initialise(userDataPath);
// Register the scheme the app is served from as 'standard' // Register the scheme the app is served from as 'standard'
// which allows things like relative URLs and IndexedDB to // which allows things like relative URLs and IndexedDB to
@@ -616,4 +620,4 @@ app.on("second-instance", (ev, commandLine, workingDirectory) => {
// It must also match the ID found in 'electron-builder' // It must also match the ID found in 'electron-builder'
// in order to get the title and icon to show up correctly. // in order to get the title and icon to show up correctly.
// Ref: https://stackoverflow.com/a/77314604/3525780 // Ref: https://stackoverflow.com/a/77314604/3525780
app.setAppUserModelId("im.riot.app"); app.setAppUserModelId(buildConfig.appId);

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron"; import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron";
import IpcMainEvent = Electron.IpcMainEvent; import IpcMainEvent = Electron.IpcMainEvent;
import { recordSSOSession } from "./protocol.js";
import { randomArray } from "./utils.js"; import { randomArray } from "./utils.js";
import { Settings } from "./settings.js"; import { Settings } from "./settings.js";
import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js";
@@ -96,9 +95,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
global.mainWindow.focus(); global.mainWindow.focus();
} }
break; break;
case "getConfig":
ret = global.vectorConfig;
break;
case "navigateBack": case "navigateBack":
if (global.mainWindow.webContents.canGoBack()) { if (global.mainWindow.webContents.canGoBack()) {
global.mainWindow.webContents.goBack(); global.mainWindow.webContents.goBack();
@@ -135,10 +132,6 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
ret = global.mainWindow.webContents.session.availableSpellCheckerLanguages; ret = global.mainWindow.webContents.session.availableSpellCheckerLanguages;
break; break;
case "startSSOFlow":
recordSSOSession(args[0]);
break;
case "getPickleKey": case "getPickleKey":
try { try {
ret = await store.getSecret(`${args[0]}|${args[1]}`); ret = await store.getSecret(`${args[0]}|${args[1]}`);
@@ -248,3 +241,5 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
reply: ret, reply: ret,
}); });
}); });
ipcMain.handle("getConfig", () => global.vectorConfig);

View File

@@ -49,4 +49,16 @@ contextBridge.exposeInMainWorld("electron", {
} }
ipcRenderer.send(channel, ...args); ipcRenderer.send(channel, ...args);
}, },
async initialise(): Promise<{
protocol: string;
sessionId: string;
config: IConfigOptions;
}> {
const [{ protocol, sessionId }, config] = await Promise.all([
ipcRenderer.invoke("getProtocol"),
ipcRenderer.invoke("getConfig"),
]);
return { protocol, sessionId, config };
},
}); });

View File

@@ -6,119 +6,136 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { app } from "electron"; import { app, ipcMain } from "electron";
import { URL } from "node:url"; import { URL } from "node:url";
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { randomUUID } from "node:crypto";
const LEGACY_PROTOCOL = "element"; const LEGACY_PROTOCOL = "element";
const PROTOCOL = "io.element.desktop";
const SEARCH_PARAM = "element-desktop-ssoid"; const SEARCH_PARAM = "element-desktop-ssoid";
const STORE_FILE_NAME = "sso-sessions.json"; const STORE_FILE_NAME = "sso-sessions.json";
// we getPath userData before electron-main changes it, so this is the default value // we getPath userData before electron-main changes it, so this is the default value
const storePath = path.join(app.getPath("userData"), STORE_FILE_NAME); const storePath = path.join(app.getPath("userData"), STORE_FILE_NAME);
function processUrl(url: string): void { export default class ProtocolHandler {
if (!global.mainWindow) return; private readonly store: Record<string, string> = {};
private readonly sessionId: string;
const parsed = new URL(url); public constructor(private readonly protocol: string) {
// sanity check: we only register for the one protocol, so we shouldn't // get all args except `hidden` as it'd mean the app would not get focused
// be getting anything else unless the user is forcing a URL to open // XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking
// with the Element app. // --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url
if (parsed.protocol !== `${PROTOCOL}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) { const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden");
console.log("Ignoring unexpected protocol: ", parsed.protocol); if (app.isPackaged) {
return; app.setAsDefaultProtocolClient(this.protocol, process.execPath, args);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args);
} else if (process.platform === "win32") {
// on Mac/Linux this would just cause the electron binary to open
// special handler for running without being packaged, e.g `electron .` by passing our app path to electron
app.setAsDefaultProtocolClient(this.protocol, process.execPath, [app.getAppPath(), ...args]);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]);
}
if (process.platform === "darwin") {
// Protocol handler for macos
app.on("open-url", (ev, url) => {
ev.preventDefault();
this.processUrl(url);
});
} else {
// Protocol handler for win32/Linux
app.on("second-instance", (ev, commandLine) => {
const url = commandLine[commandLine.length - 1];
if (!url.startsWith(`${this.protocol}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return;
this.processUrl(url);
});
}
this.store = this.readStore();
this.sessionId = randomUUID();
ipcMain.handle("getProtocol", this.onGetProtocol);
} }
const urlToLoad = new URL("vector://vector/webapp/"); private readonly onGetProtocol = (): { protocol: string; sessionId: string } => {
// ignore anything other than the search (used for SSO login redirect) return {
// and the hash (for general element deep links) protocol: this.protocol,
// There's no reason to allow anything else, particularly other paths, sessionId: this.sessionId,
// since this would allow things like the internal jitsi wrapper to };
// be loaded, which would get the app stuck on that page and generally };
// be a bit strange and confusing.
urlToLoad.search = parsed.search;
urlToLoad.hash = parsed.hash;
console.log("Opening URL: ", urlToLoad.href); private processUrl(url: string): void {
void global.mainWindow.loadURL(urlToLoad.href); if (!global.mainWindow) return;
}
function readStore(): Record<string, string> { const parsed = new URL(url);
try { // sanity check: we only register for the one protocol, so we shouldn't
const s = fs.readFileSync(storePath, { encoding: "utf8" }); // be getting anything else unless the user is forcing a URL to open
const o = JSON.parse(s); // with the Element app.
return typeof o === "object" ? o : {}; if (parsed.protocol !== `${this.protocol}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) {
} catch { console.log("Ignoring unexpected protocol: ", parsed.protocol);
return {}; return;
}
const urlToLoad = new URL("vector://vector/webapp/");
// ignore anything other than the search (used for SSO login redirect)
// and the hash (for general element deep links)
// There's no reason to allow anything else, particularly other paths,
// since this would allow things like the internal jitsi wrapper to
// be loaded, which would get the app stuck on that page and generally
// be a bit strange and confusing.
urlToLoad.search = parsed.search;
urlToLoad.hash = parsed.hash;
console.log("Opening URL: ", urlToLoad.href);
void global.mainWindow.loadURL(urlToLoad.href);
} }
}
function writeStore(data: Record<string, string>): void { private readStore(): Record<string, string> {
fs.writeFileSync(storePath, JSON.stringify(data)); try {
} const s = fs.readFileSync(storePath, { encoding: "utf8" });
const o = JSON.parse(s);
export function recordSSOSession(sessionID: string): void { return typeof o === "object" ? o : {};
const userDataPath = app.getPath("userData"); } catch {
const store = readStore(); return {};
for (const key in store) {
// ensure each instance only has one (the latest) session ID to prevent the file growing unbounded
if (store[key] === userDataPath) {
delete store[key];
break;
} }
} }
store[sessionID] = userDataPath;
writeStore(store);
}
export function getProfileFromDeeplink(args: string[]): string | undefined { private writeStore(): void {
// check if we are passed a profile in the SSO callback url fs.writeFileSync(storePath, JSON.stringify(this.store));
const deeplinkUrl = args.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${LEGACY_PROTOCOL}://`)); }
if (deeplinkUrl?.includes(SEARCH_PARAM)) {
const parsedUrl = new URL(deeplinkUrl); public initialise(userDataPath: string): void {
if (parsedUrl.protocol === `${PROTOCOL}:` || parsedUrl.protocol === `${LEGACY_PROTOCOL}:`) { for (const key in this.store) {
const store = readStore(); // ensure each instance only has one (the latest) session ID to prevent the file growing unbounded
let ssoID = parsedUrl.searchParams.get(SEARCH_PARAM); if (this.store[key] === userDataPath) {
if (!ssoID) { delete this.store[key];
// In OIDC, we must shuttle the value in the `state` param rather than `element-desktop-ssoid` break;
// We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ` }
ssoID = parsedUrl.searchParams.get("state")!.split(`:${SEARCH_PARAM}:`)[1]; }
this.store[this.sessionId] = userDataPath;
this.writeStore();
}
public getProfileFromDeeplink(args: string[]): string | undefined {
// check if we are passed a profile in the SSO callback url
const deeplinkUrl = args.find(
(arg) => arg.startsWith(`${this.protocol}://`) || arg.startsWith(`${LEGACY_PROTOCOL}://`),
);
if (deeplinkUrl?.includes(SEARCH_PARAM)) {
const parsedUrl = new URL(deeplinkUrl);
if (parsedUrl.protocol === `${this.protocol}:` || parsedUrl.protocol === `${LEGACY_PROTOCOL}:`) {
const store = this.readStore();
let sessionId = parsedUrl.searchParams.get(SEARCH_PARAM);
if (!sessionId) {
// In OIDC, we must shuttle the value in the `state` param rather than `element-desktop-ssoid`
// We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ`
sessionId = parsedUrl.searchParams.get("state")!.split(`:${SEARCH_PARAM}:`)[1];
}
console.log("Forwarding to profile: ", store[sessionId]);
return store[sessionId];
} }
console.log("Forwarding to profile: ", store[ssoID]);
return store[ssoID];
} }
} }
} }
export function protocolInit(): void {
// get all args except `hidden` as it'd mean the app would not get focused
// XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking
// --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url
const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden");
if (app.isPackaged) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args);
} else if (process.platform === "win32") {
// on Mac/Linux this would just cause the electron binary to open
// special handler for running without being packaged, e.g `electron .` by passing our app path to electron
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [app.getAppPath(), ...args]);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]);
}
if (process.platform === "darwin") {
// Protocol handler for macos
app.on("open-url", function (ev, url) {
ev.preventDefault();
processUrl(url);
});
} else {
// Protocol handler for win32/Linux
app.on("second-instance", (ev, commandLine) => {
const url = commandLine[commandLine.length - 1];
if (!url.startsWith(`${PROTOCOL}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return;
processUrl(url);
});
}
}

View File

@@ -23,7 +23,7 @@ export async function randomArray(size: number): Promise<string> {
type JsonValue = null | string | number; type JsonValue = null | string | number;
type JsonArray = Array<JsonValue | JsonObject | JsonArray>; type JsonArray = Array<JsonValue | JsonObject | JsonArray>;
interface JsonObject { export interface JsonObject {
[key: string]: JsonObject | JsonArray | JsonValue; [key: string]: JsonObject | JsonArray | JsonValue;
} }
export type Json = JsonArray | JsonObject; export type Json = JsonArray | JsonObject;