Fix OIDC login callback handling on Element Desktop (#33332)

* Fix OIDC login callback handling on Element Desktop

* Add unit tests

* Iterate

* Fix lcov reporter

* Fix coverage paths

* Fix coverage upload

* Ensure `.test.ts` files don't get included in the desktop package

* Fix coverage artifact name

* Delint

* Tidy coverage name

* Improve coverage
This commit is contained in:
Michael Telatynski
2026-04-29 14:05:38 +00:00
committed by GitHub
parent 5ff302539e
commit 6aee85aef5
11 changed files with 395 additions and 32 deletions
+7
View File
@@ -86,5 +86,12 @@ module.exports = {
"@typescript-eslint/no-non-null-assertion": "off",
},
},
{
files: ["src/**/*.test.ts", "electron-builder.ts", "vitest.config.ts"],
extends: ["plugin:matrix-org/typescript"],
parserOptions: {
project: ["tsconfig.node.json"],
},
},
],
};
+2 -1
View File
@@ -52,6 +52,7 @@ interface Variant extends Metadata {
}
type Writable<T> = NonNullable<
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
T extends Function ? T : T extends object ? { -readonly [K in keyof T]: Writable<T[K]> } : T
>;
@@ -74,7 +75,7 @@ if (process.env.VARIANT_PATH) {
}
for (const key in variant) {
console.log(`${key}: ${variant[key]}`);
console.log(`${key}: ${variant[key as keyof Variant]}`);
}
interface Configuration extends BaseConfiguration {
+9 -2
View File
@@ -32,8 +32,9 @@
"lint": "pnpm lint:types && pnpm lint:js",
"lint:js": "eslint --max-warnings 0 src hak playwright scripts",
"lint:js-fix": "eslint --fix --max-warnings 0 src hak playwright scripts && prettier --log-level=warn --write .",
"lint:types": "pnpm lint:types:src && pnpm lint:types:test && pnpm lint:types:scripts && pnpm lint:types:hak",
"lint:types": "pnpm lint:types:src && pnpm lint:types:node && pnpm lint:types:test && pnpm lint:types:scripts && pnpm lint:types:hak",
"lint:types:src": "tsc --noEmit",
"lint:types:node": "tsc --noEmit -p tsconfig.node.json",
"lint:types:test": "tsc --noEmit -p playwright/tsconfig.json",
"lint:types:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"lint:types:hak": "tsc --noEmit -p hak/tsconfig.json",
@@ -49,6 +50,7 @@
"docker:install": "scripts/in-docker.sh pnpm install",
"clean": "rimraf webapp.asar dist packages deploys lib",
"hak": "node scripts/hak/index.ts",
"test:unit": "vitest",
"test:playwright": "nx test:playwright --",
"test:playwright:open": "nx test:playwright -- --ui",
"test:playwright:screenshots": "nx test:playwright:screenshots --",
@@ -79,6 +81,7 @@
"@types/pacote": "^11.1.1",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^4.1.5",
"app-builder-lib": "26.9.0",
"chokidar": "^5.0.0",
"detect-libc": "^2.0.0",
@@ -95,12 +98,16 @@
"eslint-plugin-unicorn": "^56.0.0",
"glob": "^13.0.0",
"matrix-web-i18n": "catalog:",
"memfs": "^4.57.2",
"mkdirp": "^3.0.0",
"pacote": "^21.0.0",
"prettier": "^3.0.0",
"rimraf": "^6.0.0",
"tar": "^7.5.8",
"typescript": "6.0.3"
"typescript": "6.0.3",
"vite": "^8.0.9",
"vitest": "^4.1.5",
"vitest-sonar-reporter": "^3.0.0"
},
"hakDependencies": {
"matrix-seshat": "4.2.0"
+87
View File
@@ -0,0 +1,87 @@
/*
Copyright 2026 Element Creations 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 { expect, describe, it, beforeEach, vi } from "vitest";
import { fs as memfs, vol } from "memfs";
import ProtocolHandler from "./protocol.js";
const TEST_PROTOCOL = "test.proto";
const TEST_SESSION_ID = "test_session_id";
const USER_DATA_DIR = "/Users/name/Library/Application Support/Element";
vi.mock("node:fs", () => ({ default: memfs }));
vi.mock("electron", () => ({
app: {
getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"),
on: vi.fn(),
},
ipcMain: {
handle: vi.fn(),
},
}));
beforeEach(() => {
// Reset the state of the in-memory fs
vol.reset();
});
describe("ProtocolHandler", () => {
describe("getProfileFromDeeplink", () => {
const handler = new ProtocolHandler(TEST_PROTOCOL);
beforeEach(() => {
vol.fromJSON(
{
"./sso-sessions.json": JSON.stringify({ [TEST_SESSION_ID]: USER_DATA_DIR }),
},
USER_DATA_DIR,
);
});
it("should handle legacy SSO URIs", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`element://vector/webapp/?element-desktop-ssoid=${TEST_SESSION_ID}`,
]),
).toBe(USER_DATA_DIR);
});
it("should handle OIDC URIs with response_mode=query", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`${TEST_PROTOCOL}:/vector/webapp/?no_universal_links=true&code=DEADBEEF&state=foobar:element-desktop-ssoid:${TEST_SESSION_ID}`,
]),
).toBe(USER_DATA_DIR);
});
it("should handle OIDC URIs with response_mode=fragment", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`${TEST_PROTOCOL}:/vector/webapp/?no_universal_links=true#code=DEADBEEF&state=foobar:element-desktop-ssoid:${TEST_SESSION_ID}`,
]),
).toBe(USER_DATA_DIR);
});
it("should handle malformed OIDC URIs gracefully", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`${TEST_PROTOCOL}:/vector/webapp/?no_universal_links=true#code=DEADBEEF:element-desktop-ssoid:${TEST_SESSION_ID}`,
]),
).toBeUndefined();
});
it("should handle unrelated URIs gracefully", () => {
expect(handler.getProfileFromDeeplink(["Element.app", `${TEST_PROTOCOL}:/vector/webapp/`])).toBeUndefined();
expect(handler.getProfileFromDeeplink(["Element.app", `test.unrelated:/vector/webapp/`])).toBeUndefined();
});
});
});
+21 -4
View File
@@ -97,7 +97,8 @@ export default class ProtocolHandler {
const s = fs.readFileSync(storePath, { encoding: "utf8" });
const o = JSON.parse(s);
return typeof o === "object" ? o : {};
} catch {
} catch (e) {
console.warn("Unable to read protocol store, starting with empty store: ", e);
return {};
}
}
@@ -130,10 +131,26 @@ export default class ProtocolHandler {
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];
// We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ`.
// The OIDC flow may have used response_mode=fragment or query, so we need to handle both cases.
let searchParams = parsedUrl.searchParams;
if (parsedUrl.hash.includes("=")) {
const [params] = parsedUrl.hash.substring(1).split("?", 2);
searchParams = new URLSearchParams(params);
}
const state = searchParams.get("state");
if (state) {
sessionId = state.split(`:${SEARCH_PARAM}:`)[1];
}
}
console.log("Forwarding to profile: ", store[sessionId]);
if (!sessionId) {
console.warn("Unable to read session ID in deeplink url:", deeplinkUrl);
return undefined;
}
console.log("Forwarding to profile:", store[sessionId]);
return store[sessionId];
}
}
+2 -1
View File
@@ -14,5 +14,6 @@
"lib": ["es2022", "es2024.promise"],
"strict": true
},
"include": ["./src/**/*.ts", "./src/**/*.cts"]
"include": ["./src/**/*.ts", "./src/**/*.cts"],
"exclude": ["./src/**/*.test.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"resolveJsonModule": true,
"module": "nodenext",
"moduleResolution": "NodeNext",
"target": "es2022",
"sourceMap": false,
"typeRoots": [],
"types": [],
"skipLibCheck": true,
"noEmit": true,
"strict": true
},
"include": ["./electron-builder.ts", "./vitest.config.ts", "./src/**/*.d.ts", "./src/**/*.test.ts"]
}
+64
View File
@@ -0,0 +1,64 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { defineConfig } from "vitest/config";
import { type UserConfig } from "vite";
import { type Reporter } from "vitest/reporters";
import { env } from "node:process";
const reporters: NonNullable<UserConfig["test"]>["reporters"] = [["default"]];
const slowTestReporter: Reporter = {
onTestRunEnd(testModules, unhandledErrors, reason) {
const tests = testModules
.flatMap((m) => Array.from(m.children.allTests()))
.filter((test) => test.diagnostic()?.slow);
tests.sort((x, y) => x.diagnostic()!.duration! - y.diagnostic()!.duration!);
tests.reverse();
if (tests.length > 0) {
console.warn("Slowest 10 tests:");
}
for (const t of tests.slice(0, 10)) {
console.warn(`${t.module.moduleId} > ${t.fullName}: ${t.diagnostic()?.duration.toFixed(0)}ms`);
}
},
};
// if we're running under GHA, enable the GHA & Sonar reporters
if (env["GITHUB_ACTIONS"] !== undefined) {
reporters.push(["github-actions", { silent: false }]);
reporters.push([
"vitest-sonar-reporter",
{
outputFile: "coverage/sonar-report.xml",
onWritePath: (path): string => `apps/desktop/${path}`,
},
]);
// if we're running against the develop branch, also enable the slow test reporter
if (env["GITHUB_REF"] == "refs/heads/develop") {
reporters.push(slowTestReporter);
}
}
export default defineConfig({
test: {
coverage: {
provider: "v8",
include: ["src/**/*"],
// The coverage report currently chokes on this file as it doesn't process it as TypeScript
exclude: ["src/preload.cts"],
reporter: [["lcov", { projectRoot: "../../" }]],
},
environment: "node",
reporters,
globals: true,
pool: "threads",
include: ["src/**/*.test.ts"],
},
});