From 34a26cd0ecdee89742e7b785f6782fb708eec335 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 17 Feb 2026 14:16:18 +0000 Subject: [PATCH 1/8] Remove long deprecated option `sso_immediate_redirect` Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/IConfigOptions.ts | 2 -- src/SdkConfig.ts | 13 +------------ src/vector/app.tsx | 4 ++-- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index fa9fd45c0a2..9123078d5d2 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -134,8 +134,6 @@ export interface IConfigOptions { logout_redirect_url?: string; - // sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate - sso_immediate_redirect?: boolean; sso_redirect_options?: ISsoRedirectOptions; custom_translations_url?: string; diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index f4b95fa4f9b..27fc8033291 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { mergeWith } from "lodash"; import { SnakedObject } from "./utils/SnakedObject"; -import { type IConfigOptions, type ISsoRedirectOptions } from "./IConfigOptions"; +import { type IConfigOptions } from "./IConfigOptions"; import { isObject, objectClone } from "./utils/objects"; import { type DeepReadonly, type Defaultize } from "./@types/common"; @@ -141,14 +141,3 @@ export default class SdkConfig { SdkConfig.put(mergeConfig(SdkConfig.get(), cfg)); } } - -export function parseSsoRedirectOptions(config: IConfigOptions): ISsoRedirectOptions { - // Ignore deprecated options if the config is using new ones - if (config.sso_redirect_options) return config.sso_redirect_options; - - // We can cheat here because the default is false anyways - if (config.sso_immediate_redirect) return { immediate: true }; - - // Default: do nothing - return {}; -} diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 84f9b18ec18..4736acec617 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -21,7 +21,7 @@ import type { QueryDict } from "matrix-js-sdk/src/utils"; import PlatformPeg from "../PlatformPeg"; import AutoDiscoveryUtils from "../utils/AutoDiscoveryUtils"; import * as Lifecycle from "../Lifecycle"; -import SdkConfig, { parseSsoRedirectOptions } from "../SdkConfig"; +import SdkConfig from "../SdkConfig"; import { type IConfigOptions } from "../IConfigOptions"; import { SnakedObject } from "../utils/SnakedObject"; import MatrixChat from "../components/structures/MatrixChat"; @@ -83,7 +83,7 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref Date: Tue, 17 Feb 2026 14:18:36 +0000 Subject: [PATCH 2/8] Remove stale experimental comment about Native OIDC support Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- docs/config.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index a77884c2122..7a49b82a96a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -276,8 +276,6 @@ When Element is deployed alongside a homeserver with SSO-only login, some option ## Native OIDC -Native OIDC support is currently in labs and is subject to change. - Static OIDC Client IDs are preferred and can be specified under `oidc_static_clients` as a mapping from `issuer` to configuration object containing `client_id`. Issuer must have a trailing forward slash. As an example: From 88e4ef1e29ca01a74f50fd40646974932f511a14 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 17 Feb 2026 15:01:18 +0000 Subject: [PATCH 3/8] Extract redirectToSso from loadApp Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/vector/app.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 4736acec617..2a4a28386d1 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -56,6 +56,16 @@ function onTokenLoginCompleted(): void { window.history.replaceState(null, "", url.href); } +async function redirectToSso(config: ValidatedServerConfig): Promise { + logger.log("Bypassing app load to redirect to SSO"); + const tempCli = createClient({ + baseUrl: config.hsUrl, + idBaseUrl: config.isUrl, + }); + PlatformPeg.get()!.startSingleSignOn(tempCli, "sso", `/${getScreenFromLocation(window.location).screen}`); + return true; +} + export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref): Promise { // 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. @@ -96,18 +106,15 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref; + if (redirecting) { + return ; + } } const defaultDeviceName = From 8200c4f239c6b38f0c777a9ae50b0bd9932464ea Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 17 Feb 2026 15:16:28 +0000 Subject: [PATCH 4/8] Fix maintaining deeplink when going via auto sso Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/vector/app.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 2a4a28386d1..bd1e98ad999 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -106,6 +106,10 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref Date: Tue, 17 Feb 2026 15:18:10 +0000 Subject: [PATCH 5/8] Improve error Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/Login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Login.ts b/src/Login.ts index d5992af2ab7..34b6513ad1f 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -118,7 +118,7 @@ export default class Login { ); return [oidcFlow]; } catch (error) { - logger.error(error); + logger.error("Failed to get oidc native flow", error); } } From c2acda5c805a2f7f147eda660853cf26540465a1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 17 Feb 2026 15:22:30 +0000 Subject: [PATCH 6/8] Update `sso_redirect_options` to work for Native OIDC Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- docs/config.md | 2 +- src/vector/app.tsx | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/config.md b/docs/config.md index 7a49b82a96a..f7b2fd6e8da 100644 --- a/docs/config.md +++ b/docs/config.md @@ -260,7 +260,7 @@ When Element is deployed alongside a homeserver with SSO-only login, some option 1. `logout_redirect_url`: Optional URL to redirect the user to after they have logged out. Some SSO systems support a page that the user can be sent to in order to log them out of that system too, making logout symmetric between Element and the SSO system. 2. `sso_redirect_options`: Options to define how to handle unauthenticated users. If the object contains `"immediate": true`, then - all unauthenticated users will be automatically redirected to the SSO system to start their login. If instead you'd only like to + all unauthenticated users will be automatically redirected to the SSO/OIDC system to start their login. If instead you'd only like to have users which land on the welcome page to be redirected, use `"on_welcome_page": true`. Additionally, there is an option to redirect anyone landing on the login page, by using `"on_login_page": true`. As an example: ```json diff --git a/src/vector/app.tsx b/src/vector/app.tsx index bd1e98ad999..cc4da3373a6 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details. import "matrix-js-sdk/src/browser-index"; import React, { type ReactElement, StrictMode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import { createClient, AutoDiscovery, type ClientConfig } from "matrix-js-sdk/src/matrix"; +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"; @@ -34,6 +34,8 @@ import { ModuleApi } from "../modules/Api"; import { RoomView } from "../components/structures/RoomView"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; import { ModuleNotificationDecoration } from "../modules/components/ModuleNotificationDecoration"; +import Login from "../Login.ts"; +import { startOidcLogin } from "../utils/oidc/authorize.ts"; logger.log(`Application is running in ${process.env.NODE_ENV} mode`); @@ -58,12 +60,31 @@ function onTokenLoginCompleted(): void { async function redirectToSso(config: ValidatedServerConfig): Promise { logger.log("Bypassing app load to redirect to SSO"); - const tempCli = createClient({ - baseUrl: config.hsUrl, - idBaseUrl: config.isUrl, - }); - PlatformPeg.get()!.startSingleSignOn(tempCli, "sso", `/${getScreenFromLocation(window.location).screen}`); - return true; + + try { + const login = new Login(config.hsUrl, config.isUrl, null, { + delegatedAuthentication: config.delegatedAuthentication, + }); + const flows = await login.getFlows(); + + const nativeOidcFlow = flows.find((flow) => "clientId" in flow); + if (nativeOidcFlow && config.delegatedAuthentication) { + await startOidcLogin(config.delegatedAuthentication, nativeOidcFlow.clientId, config.hsUrl, config.isUrl); + return true; + } + + const flow = flows.find((flow) => flow.type === "m.login.sso" || flow.type === "m.login.cas"); + PlatformPeg.get()!.startSingleSignOn( + login.createTemporaryClient(), + flow?.type === "m.login.cas" ? "cas" : "sso", + `/${getScreenFromLocation(window.location).screen}`, + ); + return true; + } catch (e) { + console.error("Error encountered during sso redirect", e); + } + + return false; } export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref): Promise { @@ -92,7 +113,7 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref Date: Tue, 17 Feb 2026 15:49:34 +0000 Subject: [PATCH 7/8] Update existing test for log changes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- test/unit-tests/components/structures/auth/Login-test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/unit-tests/components/structures/auth/Login-test.tsx b/test/unit-tests/components/structures/auth/Login-test.tsx index 97737a84b77..872ccbd3d01 100644 --- a/test/unit-tests/components/structures/auth/Login-test.tsx +++ b/test/unit-tests/components/structures/auth/Login-test.tsx @@ -394,7 +394,10 @@ describe("Login", function () { // tried to register expect(fetchMock).toHaveFetched(delegatedAuth.registration_endpoint); - expect(logger.error).toHaveBeenCalledWith(new Error(OidcError.DynamicRegistrationFailed)); + expect(logger.error).toHaveBeenCalledWith( + "Failed to get oidc native flow", + new Error(OidcError.DynamicRegistrationFailed), + ); // continued with normal setup expect(mockClient.loginFlows).toHaveBeenCalled(); From f5c0583073b5489920b7a7fc1f9a5e593bf7bfc4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 17 Feb 2026 16:36:45 +0000 Subject: [PATCH 8/8] Add tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- test/unit-tests/vector/app-test.ts | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 test/unit-tests/vector/app-test.ts diff --git a/test/unit-tests/vector/app-test.ts b/test/unit-tests/vector/app-test.ts new file mode 100644 index 00000000000..c2e9eb1cff7 --- /dev/null +++ b/test/unit-tests/vector/app-test.ts @@ -0,0 +1,92 @@ +/** + * @jest-environment jest-fixed-jsdom + * @jest-environment-options {"url": "https://app.element.io/#/room/#room:server"} + */ + +/* +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 fetchMock from "@fetch-mock/jest"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { Crypto } from "@peculiar/webcrypto"; + +import { loadApp } from "../../../src/vector/app.tsx"; +import SdkConfig from "../../../src/SdkConfig.ts"; +import PlatformPeg from "../../../src/PlatformPeg.ts"; +import { mockPlatformPeg, unmockPlatformPeg } from "../../test-utils"; +import { makeDelegatedAuthConfig } from "../../test-utils/oidc"; + +const defaultConfig = { + default_hs_url: "https://synapse", +}; +const issuer = "https://auth.org/"; +const webCrypto = new Crypto(); + +describe("sso_redirect_options", () => { + beforeAll(() => { + Object.defineProperty(window, "crypto", { + value: { + // Stable stub + getRandomValues: (arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) { + arr[i] = i; + } + return arr; + }, + subtle: webCrypto.subtle, + }, + }); + }); + + beforeEach(() => { + SdkConfig.reset(); + mockPlatformPeg({ getDefaultDeviceDisplayName: jest.fn(), startSingleSignOn: jest.fn() }); + }); + + afterAll(() => { + unmockPlatformPeg(); + }); + + describe("immediate", () => { + beforeEach(() => { + SdkConfig.put({ + ...defaultConfig, + sso_redirect_options: { immediate: true }, + // Avoid testing dynamic client registration + oidc_static_clients: { [issuer]: { client_id: "12345" } }, + }); + // Signal we support v1.1 to pass the minimum js-sdk compatibility bar + // Signal we support v1.15 to use stable Native OIDC support + fetchMock.get("https://synapse/_matrix/client/versions", { versions: ["v1.1", "v1.15"] }); + }); + + it("should redirect for legacy SSO", async () => { + fetchMock.getOnce("https://synapse/_matrix/client/v3/login", { + flows: [{ stages: ["m.login.sso"] }], + }); + + const startSingleSignOnSpy = jest.spyOn(PlatformPeg.get()!, "startSingleSignOn"); + + await loadApp({}, jest.fn()); + expect(startSingleSignOnSpy).toHaveBeenCalledWith(expect.any(MatrixClient), "sso", "/room/#room:server"); + }); + + it("should redirect for native OIDC", async () => { + const authConfig = makeDelegatedAuthConfig(issuer); + fetchMock.get("https://synapse/_matrix/client/v1/auth_metadata", authConfig); + fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig); + fetchMock.get(authConfig.jwks_uri!, { keys: [] }); + + const startOidcLoginSpy = jest.spyOn(window.location, "href", "set"); + + await loadApp({}, jest.fn()); + expect(startOidcLoginSpy).toHaveBeenCalledWith( + "https://auth.org/auth?client_id=12345&redirect_uri=https%3A%2F%2Fapp.element.io%2F%3Fno_universal_links%3Dtrue&response_type=code&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AwKpa6hpi3Y&nonce=38QgU2Pomx&state=10000000100040008000100000000000&code_challenge=awE81eIsGff70JahvrTqWRbGKLI10ooyo_Xm1sxuZvU&code_challenge_method=S256&response_mode=query", + ); + }); + }); +});