Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
2 changes: 0 additions & 2 deletions src/IConfigOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/Login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export default class Login {
);
return [oidcFlow];
} catch (error) {
logger.error(error);
logger.error("Failed to get oidc native flow", error);
}
}

Expand Down
13 changes: 1 addition & 12 deletions src/SdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {};
}
58 changes: 44 additions & 14 deletions src/vector/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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`);

Expand All @@ -56,6 +58,35 @@ function onTokenLoginCompleted(): void {
window.history.replaceState(null, "", url.href);
}

async function redirectToSso(config: ValidatedServerConfig): Promise<boolean> {
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<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.
Expand All @@ -82,8 +113,8 @@ 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;
const ssoRedirects = parseSsoRedirectOptions(config);
const isReturningFromSso = !!params.loginToken || (!!params.code && !!params.state);
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.
const isWelcomeOrLanding =
Expand All @@ -96,25 +127,24 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<Ma
if (!autoRedirect && ssoRedirects.on_login_page && isLoginPage) {
autoRedirect = true;
}
if (!hasPossibleToken && !isReturningFromSso && autoRedirect) {
logger.log("Bypassing app load to redirect to SSO");
const tempCli = createClient({
baseUrl: config.validated_server_config!.hsUrl,
idBaseUrl: config.validated_server_config!.isUrl,
});
PlatformPeg.get()!.startSingleSignOn(tempCli, "sso", `/${getScreenFromLocation(window.location).screen}`);

// getInitialScreenAfterLogin has a side effect to write to sessionStorage, perform it before auto-redirect
const initialScreenAfterLogin = getInitialScreenAfterLogin(window.location);

if (!hasPossibleToken && !isReturningFromSso && autoRedirect && config.validated_server_config) {
const redirecting = await redirectToSso(config.validated_server_config);

// We return here because startSingleSignOn() will asynchronously redirect us. We don't
// care to wait for it, and don't want to show any UI while we wait (not even half a welcome
// page). As such, just don't even bother loading the MatrixChat component.
return <React.Fragment />;
if (redirecting) {
return <React.Fragment />;
}
}

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);

Expand Down
5 changes: 4 additions & 1 deletion test/unit-tests/components/structures/auth/Login-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
92 changes: 92 additions & 0 deletions test/unit-tests/vector/app-test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
});
Loading