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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user