diff --git a/docs/config.md b/docs/config.md index a77884c2122..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 @@ -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: 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/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); } } 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..cc4da3373a6 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -14,14 +14,14 @@ 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"; 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"; @@ -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`); @@ -56,6 +58,35 @@ function onTokenLoginCompleted(): void { window.history.replaceState(null, "", url.href); } +async function redirectToSso(config: ValidatedServerConfig): Promise { + logger.log("Bypassing app load to redirect to SSO"); + + 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 { // 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. @@ -82,8 +113,8 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref; + if (redirecting) { + return ; + } } const defaultDeviceName = snakedConfig.get("default_device_display_name") ?? platform?.getDefaultDeviceDisplayName(); - const initialScreenAfterLogin = getInitialScreenAfterLogin(window.location); - const wrapperOpts: WrapperOpts = { Wrapper: React.Fragment }; ModuleRunner.instance.invoke(WrapperLifecycle.Wrapper, wrapperOpts); 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(); 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", + ); + }); + }); +});