Switch OIDC to response_mode=fragment (#33100)

* Refactor: kill off `parseQs` in favour of URLSearchParams

* Consolidate app-load url parameter handling

* Switch to responseMode=fragment
This commit is contained in:
Michael Telatynski
2026-04-15 09:35:02 +00:00
committed by GitHub
parent 5475edbbc5
commit de4a1e6d35
16 changed files with 309 additions and 206 deletions
+17 -19
View File
@@ -17,7 +17,6 @@ import { logger } from "matrix-js-sdk/src/logger";
import { AutoDiscovery, type ClientConfig } from "matrix-js-sdk/src/matrix";
import { WrapperLifecycle, type WrapperOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WrapperLifecycle";
import type { QueryDict } from "matrix-js-sdk/src/utils";
import PlatformPeg from "../PlatformPeg";
import AutoDiscoveryUtils from "../utils/AutoDiscoveryUtils";
import * as Lifecycle from "../Lifecycle";
@@ -27,8 +26,8 @@ import { SnakedObject } from "../utils/SnakedObject";
import MatrixChat from "../components/structures/MatrixChat";
import { type ValidatedServerConfig } from "../utils/ValidatedServerConfig";
import { ModuleRunner } from "../modules/ModuleRunner";
import { parseQs } from "./url_utils";
import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing";
import { type URLParams } from "./url_utils.ts";
import { UserFriendlyError } from "../languageHandler";
import { ModuleApi } from "../modules/Api";
import { RoomView } from "../components/structures/RoomView";
@@ -41,20 +40,22 @@ logger.log(`Application is running in ${process.env.NODE_ENV} mode`);
window.matrixLogger = logger;
function onTokenLoginCompleted(): void {
// if we did a token login, we're now left with the token, hs and is
// url as query params in the url;
// if we did an oidc authorization code flow login, we're left with the auth code and state
// as query params in the url;
// a little nasty but let's redirect to clear them.
function onTokenLoginCompleted(urlParams: URLParams, fragmentAfterLogin: string): void {
const url = new URL(window.location.href);
url.searchParams.delete("no_universal_links");
url.searchParams.delete("loginToken");
url.searchParams.delete("state");
url.searchParams.delete("code");
// if we did a token login, we're now left with the login token as query param in the url; clear it out
for (const param in { ...urlParams.legacy_sso }) {
url.searchParams.delete(param);
}
logger.log(`Redirecting to ${url.href} to drop delegated authentication params from queryparams`);
// Added by OIDC auth to avoid being hijacked by Element X on macOS
url.searchParams.delete("no_universal_links");
// if we did an oidc authorization code flow login, we're left with the auth code and state in the fragment in the url,
// we clear it out by using the fragmentAfterLogin
url.hash = fragmentAfterLogin;
logger.log(`Redirecting to ${url.href} to drop authentication params from url`);
window.history.replaceState(null, "", url.href);
}
@@ -87,7 +88,7 @@ async function redirectToSso(config: ValidatedServerConfig): Promise<boolean> {
return false;
}
export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<MatrixChat>): Promise<ReactElement> {
export async function loadApp(urlParams: URLParams, matrixChatRef: React.Ref<MatrixChat>): Promise<ReactElement> {
// XXX: This lives here because certain components import so many things that importing it in a sensible place (eg.
// the builtins module or init.tsx) causes a circular dependency.
ModuleApi.instance.builtins.setComponents({
@@ -99,8 +100,6 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<Ma
initRouting();
const platform = PlatformPeg.get();
const params = parseQs(window.location);
const urlWithoutQuery = window.location.protocol + "//" + window.location.host + window.location.pathname;
logger.log("Vector starting at " + urlWithoutQuery);
@@ -113,7 +112,7 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<Ma
// Before we continue, let's see if we're supposed to do an SSO redirect
const [userId] = await Lifecycle.getStoredSessionOwner();
const hasPossibleToken = !!userId;
const isReturningFromSso = !!params.loginToken || (!!params.code && !!params.state);
const isReturningFromSso = !!urlParams.legacy_sso || !!urlParams.oidc;
const ssoRedirects = config.sso_redirect_options || {};
let autoRedirect = ssoRedirects.immediate === true;
// XXX: This path matching is a bit brittle, but better to do it early instead of in the app code.
@@ -155,8 +154,7 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<Ma
ref={matrixChatRef}
onNewScreen={onNewScreen}
config={config}
realQueryParams={params}
startingFragmentQueryParams={fragParams}
urlParams={urlParams}
enableGuest={!config.disable_guests}
onTokenLoginCompleted={onTokenLoginCompleted}
initialScreenAfterLogin={initialScreenAfterLogin}
+4 -4
View File
@@ -14,7 +14,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { shouldPolyfill as shouldPolyFillIntlSegmenter } from "@formatjs/intl-segmenter/should-polyfill.js";
// These are things that can run before the skin loads - be careful not to reference the react-sdk though.
import { parseQsFromFragment } from "./url_utils";
import { parseAppUrl } from "./url_utils";
import "./modernizr.cjs";
// Import shared components CSS
@@ -136,13 +136,13 @@ async function start(): Promise<void> {
// give rageshake a chance to load/fail, we don't actually assert rageshake loads, we allow it to fail if no IDB
await settled(rageshakePromise);
const fragparts = parseQsFromFragment(window.location);
const parsedUrl = parseAppUrl(window.location);
// don't try to redirect to the native apps if we're
// verifying a 3pid (but after we've loaded the config)
// or if the user is following a deep link
// (https://github.com/element-hq/element-web/issues/7378)
const preventRedirect = fragparts.params.client_secret || fragparts.location.length > 0;
const preventRedirect = !!parsedUrl.params.threepid || parsedUrl.location.length > 0;
if (!preventRedirect) {
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
@@ -232,7 +232,7 @@ async function start(): Promise<void> {
// Finally, load the app. All of the other react-sdk imports are in this file which causes the skinner to
// run on the components.
await loadApp(fragparts.params);
await loadApp(parsedUrl.params);
} catch (err) {
logger.error(err);
// Like the compatibility page, AWOOOOOGA at the user
+3 -3
View File
@@ -13,7 +13,6 @@ import React, { StrictMode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { ModuleLoader } from "@element-hq/element-web-module-api";
import type { QueryDict } from "matrix-js-sdk/src/utils";
import * as languageHandler from "../languageHandler";
import SettingsStore from "../settings/SettingsStore";
import PlatformPeg from "../PlatformPeg";
@@ -26,6 +25,7 @@ import PWAPlatform from "./platform/PWAPlatform";
import WebPlatform from "./platform/WebPlatform";
import { initRageshake, initRageshakeStore } from "./rageshakesetup";
import { ModuleApi } from "../modules/Api.ts";
import { type URLParams } from "./url_utils.ts";
export const rageshakePromise = initRageshake();
@@ -86,7 +86,7 @@ export async function loadTheme(): Promise<void> {
return setTheme();
}
export async function loadApp(fragParams: QueryDict): Promise<void> {
export async function loadApp(urlParams: URLParams): Promise<void> {
// load app.js async so that its code is not executed immediately and we can catch any exceptions
const module = await import(
/* webpackChunkName: "element-web-app" */
@@ -96,7 +96,7 @@ export async function loadApp(fragParams: QueryDict): Promise<void> {
function setWindowMatrixChat(matrixChat: MatrixChat): void {
window.matrixChat = matrixChat;
}
const app = await module.loadApp(fragParams, setWindowMatrixChat);
const app = await module.loadApp(urlParams, setWindowMatrixChat);
const root = createRoot(document.getElementById("matrixchat")!);
root.render(app);
}
+2 -4
View File
@@ -16,7 +16,6 @@ import dis from "../../dispatcher/dispatcher";
import { hideToast as hideUpdateToast, showToast as showUpdateToast } from "../../toasts/UpdateToast";
import { Action } from "../../dispatcher/actions";
import { type CheckUpdatesPayload } from "../../dispatcher/payloads/CheckUpdatesPayload";
import { parseQs } from "../url_utils";
import { _t } from "../../languageHandler";
import ToastStore from "../../stores/ToastStore.ts";
import GenericToast from "../../components/views/toasts/GenericToast.tsx";
@@ -174,8 +173,8 @@ export default class WebPlatform extends BasePlatform {
// cache-control: nocache HTTP header set, but Firefox doesn't always obey it :/
console.log("startUpdater, current version is " + getNormalizedAppVersion(WebPlatform.VERSION));
void this.pollForUpdate((version: string, newVersion: string) => {
const query = parseQs(location);
if (query.updated) {
const url = new URL(window.location.href);
if (url.searchParams.has("updated")) {
console.log("Update reloaded but still on an old version, stopping");
// We just reloaded already and are still on the old version!
// Show the toast rather than reload in a loop.
@@ -184,7 +183,6 @@ export default class WebPlatform extends BasePlatform {
}
// Set updated as a cachebusting query param and reload the page.
const url = new URL(window.location.href);
url.searchParams.set("updated", newVersion);
console.log("Update reloading to " + url.toString());
window.location.href = url.toString();
+8 -3
View File
@@ -11,15 +11,20 @@ Please see LICENSE files in the repository root for full details.
import { logger } from "matrix-js-sdk/src/logger";
import { type QueryDict } from "matrix-js-sdk/src/utils";
import { parseQsFromFragment } from "./url_utils";
import { parseQsFromFragment, searchParamsToQueryDict } from "./url_utils";
let lastLocationHashSet: string | null = null;
export function getScreenFromLocation(location: Location): { screen: string; params: QueryDict } {
export interface IScreen {
screen: string;
params: QueryDict;
}
export function getScreenFromLocation(location: Location): IScreen {
const fragparts = parseQsFromFragment(location);
return {
screen: fragparts.location.substring(1),
params: fragparts.params,
params: fragparts.params ? searchParamsToQueryDict(fragparts.params) : {},
};
}
+113 -16
View File
@@ -5,32 +5,129 @@ 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.
*/
import { type QueryDict, decodeParams } from "matrix-js-sdk/src/utils";
import { type QueryDict } from "matrix-js-sdk/src/utils";
// We want to support some name / value pairs in the fragment
// so we're re-using query string like format
//
export function parseQsFromFragment(location: Location): { location: string; params: QueryDict } {
// so we're re-using query string like format, where we accept a `?key=value&key2=value2` string at the end of the hash
// but we also accept a hash like `key=value&key2=value2` for compatibility with oAuth response_mode = fragment
export function parseQsFromFragment(url: Location | URL): { location: string; params?: URLSearchParams } {
// if we have a fragment, it will start with '#', which we need to drop.
// (if we don't, this will return '').
const fragment = location.hash.substring(1);
const fragment = url.hash.substring(1);
// our fragment may contain a query-param-like section. we need to fish
// this out *before* URI-decoding because the params may contain ? and &
// characters which are only URI-encoded once.
const hashparts = fragment.split("?");
const [main, query] = fragment.split("?", 2);
const result = {
location: decodeURIComponent(hashparts[0]),
params: <QueryDict>{},
};
if (hashparts.length > 1) {
result.params = decodeParams(hashparts[1]);
// Handle oAuth-style fragment parameters
if (main.includes("=")) {
return {
location: "",
params: new URLSearchParams(main),
};
}
return result;
return {
location: decodeURIComponent(main),
params: query ? new URLSearchParams(query) : undefined,
};
}
export function parseQs(location: Location): QueryDict {
return decodeParams(location.search.substring(1));
/**
* Convert a URLSearchParams object to QueryDict
* Any keys with multiple values will be grouped into an array
* @param params the URLSearchParams to convert
*/
export function searchParamsToQueryDict(params: URLSearchParams): QueryDict {
const queryDict: QueryDict = {};
for (const key of params.keys()) {
const val = params.getAll(key);
queryDict[key] = val.length === 1 ? val[0] : val;
}
return queryDict;
}
const urlParameterConfig = {
// Query string params for legacy SSO login, added by the Matrix homeserver
legacy_sso: {
keys: ["loginToken"],
location: "query",
},
// Fragment params for OIDC login, added by the Identity Provider
oidc: {
keys: ["code", "state"],
location: "fragment",
},
// Fragment params relating to 3pid (email) invites, added in url within the invite email itself
threepid: {
keys: ["client_secret", "session_id", "hs_url", "is_url", "sid"],
location: "fragment",
},
// XXX: unclear where, if anywhere, this is set
defaults: {
keys: ["defaultUsername"],
location: "fragment",
},
// XXX: Fragment params seemingly relating to 3pid invites, though the code in the area doubts they are ever specified
guest: {
keys: ["guest_user_id", "guest_access_token"],
location: "fragment",
},
} as const satisfies Record<
string,
{
keys: string[];
// Query params live in the query string, in the middle of the URL, after a `?`, in a `key=value` format, delimited by `&`.
// Fragment params live in the fragment string, at the end of the URL, after a `?`, in a `key=value` format, delimited by `&`.
location: "query" | "fragment";
}
>;
export type URLParams = Partial<{
-readonly [K in keyof typeof urlParameterConfig]: Partial<{
[P in (typeof urlParameterConfig)[K]["keys"][number]]: string;
}>;
}>;
/**
* Utility to parse parameters held in the app's URL.
* Currently focusing only on at-load URL parameters.
* @param url - the URL to parse.
* @return an object keyed by the groups defined in {@link urlParameterConfig} with values for each key listed,
* sourced from the location (query/fragment/either) specified. If no parameters in a group are found the entire group
* will be omitted from the returned object to simplify presence checking.
*/
export function parseAppUrl(url: Location | URL): {
location: string;
params: URLParams;
} {
const queryParams = new URLSearchParams(url.search);
const parsedFragment = parseQsFromFragment(url);
const urlParams: Partial<URLParams> = {};
for (const group in urlParameterConfig) {
const groupKey = group as keyof URLParams;
const groupConfig = urlParameterConfig[groupKey];
const params = groupConfig.location === "fragment" ? parsedFragment.params : queryParams;
if (!params) continue; // no params
const target: Record<string, string> = {};
for (const k of groupConfig.keys) {
const key = k as (typeof groupConfig)["keys"][number];
const value = params.get(key);
if (value !== null) {
target[key] = value;
}
}
if (Object.keys(target).length > 0) {
urlParams[groupKey] = target;
}
}
return { params: urlParams as URLParams, location: parsedFragment.location };
}