Update sso_redirect_options to work for Native OIDC (#32537)
* Remove long deprecated option `sso_immediate_redirect` Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove stale experimental comment about Native OIDC support Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Extract redirectToSso from loadApp Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix maintaining deeplink when going via auto sso Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve error Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update `sso_redirect_options` to work for Native OIDC Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update existing test for log changes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
+1
-3
@@ -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
|
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.
|
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
|
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
|
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:
|
redirect anyone landing on the login page, by using `"on_login_page": true`. As an example:
|
||||||
```json
|
```json
|
||||||
@@ -276,8 +276,6 @@ When Element is deployed alongside a homeserver with SSO-only login, some option
|
|||||||
|
|
||||||
## Native OIDC
|
## 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`.
|
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:
|
Issuer must have a trailing forward slash. As an example:
|
||||||
|
|
||||||
|
|||||||
@@ -134,8 +134,6 @@ export interface IConfigOptions {
|
|||||||
|
|
||||||
logout_redirect_url?: string;
|
logout_redirect_url?: string;
|
||||||
|
|
||||||
// sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate
|
|
||||||
sso_immediate_redirect?: boolean;
|
|
||||||
sso_redirect_options?: ISsoRedirectOptions;
|
sso_redirect_options?: ISsoRedirectOptions;
|
||||||
|
|
||||||
custom_translations_url?: string;
|
custom_translations_url?: string;
|
||||||
|
|||||||
+1
-1
@@ -118,7 +118,7 @@ export default class Login {
|
|||||||
);
|
);
|
||||||
return [oidcFlow];
|
return [oidcFlow];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error("Failed to get oidc native flow", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-12
@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import { mergeWith } from "lodash";
|
import { mergeWith } from "lodash";
|
||||||
|
|
||||||
import { SnakedObject } from "./utils/SnakedObject";
|
import { SnakedObject } from "./utils/SnakedObject";
|
||||||
import { type IConfigOptions, type ISsoRedirectOptions } from "./IConfigOptions";
|
import { type IConfigOptions } from "./IConfigOptions";
|
||||||
import { isObject, objectClone } from "./utils/objects";
|
import { isObject, objectClone } from "./utils/objects";
|
||||||
import { type DeepReadonly, type Defaultize } from "./@types/common";
|
import { type DeepReadonly, type Defaultize } from "./@types/common";
|
||||||
|
|
||||||
@@ -141,14 +141,3 @@ export default class SdkConfig {
|
|||||||
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
|
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 {};
|
|
||||||
}
|
|
||||||
|
|||||||
+44
-14
@@ -14,14 +14,14 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import "matrix-js-sdk/src/browser-index";
|
import "matrix-js-sdk/src/browser-index";
|
||||||
import React, { type ReactElement, StrictMode } from "react";
|
import React, { type ReactElement, StrictMode } from "react";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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 { WrapperLifecycle, type WrapperOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WrapperLifecycle";
|
||||||
|
|
||||||
import type { QueryDict } from "matrix-js-sdk/src/utils";
|
import type { QueryDict } from "matrix-js-sdk/src/utils";
|
||||||
import PlatformPeg from "../PlatformPeg";
|
import PlatformPeg from "../PlatformPeg";
|
||||||
import AutoDiscoveryUtils from "../utils/AutoDiscoveryUtils";
|
import AutoDiscoveryUtils from "../utils/AutoDiscoveryUtils";
|
||||||
import * as Lifecycle from "../Lifecycle";
|
import * as Lifecycle from "../Lifecycle";
|
||||||
import SdkConfig, { parseSsoRedirectOptions } from "../SdkConfig";
|
import SdkConfig from "../SdkConfig";
|
||||||
import { type IConfigOptions } from "../IConfigOptions";
|
import { type IConfigOptions } from "../IConfigOptions";
|
||||||
import { SnakedObject } from "../utils/SnakedObject";
|
import { SnakedObject } from "../utils/SnakedObject";
|
||||||
import MatrixChat from "../components/structures/MatrixChat";
|
import MatrixChat from "../components/structures/MatrixChat";
|
||||||
@@ -34,6 +34,8 @@ import { ModuleApi } from "../modules/Api";
|
|||||||
import { RoomView } from "../components/structures/RoomView";
|
import { RoomView } from "../components/structures/RoomView";
|
||||||
import RoomAvatar from "../components/views/avatars/RoomAvatar";
|
import RoomAvatar from "../components/views/avatars/RoomAvatar";
|
||||||
import { ModuleNotificationDecoration } from "../modules/components/ModuleNotificationDecoration";
|
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`);
|
logger.log(`Application is running in ${process.env.NODE_ENV} mode`);
|
||||||
|
|
||||||
@@ -56,6 +58,35 @@ function onTokenLoginCompleted(): void {
|
|||||||
window.history.replaceState(null, "", url.href);
|
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> {
|
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.
|
// 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.
|
// the builtins module or init.tsx) causes a circular dependency.
|
||||||
@@ -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
|
// Before we continue, let's see if we're supposed to do an SSO redirect
|
||||||
const [userId] = await Lifecycle.getStoredSessionOwner();
|
const [userId] = await Lifecycle.getStoredSessionOwner();
|
||||||
const hasPossibleToken = !!userId;
|
const hasPossibleToken = !!userId;
|
||||||
const isReturningFromSso = !!params.loginToken;
|
const isReturningFromSso = !!params.loginToken || (!!params.code && !!params.state);
|
||||||
const ssoRedirects = parseSsoRedirectOptions(config);
|
const ssoRedirects = config.sso_redirect_options || {};
|
||||||
let autoRedirect = ssoRedirects.immediate === true;
|
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.
|
// XXX: This path matching is a bit brittle, but better to do it early instead of in the app code.
|
||||||
const isWelcomeOrLanding =
|
const isWelcomeOrLanding =
|
||||||
@@ -96,25 +127,24 @@ export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<Ma
|
|||||||
if (!autoRedirect && ssoRedirects.on_login_page && isLoginPage) {
|
if (!autoRedirect && ssoRedirects.on_login_page && isLoginPage) {
|
||||||
autoRedirect = true;
|
autoRedirect = true;
|
||||||
}
|
}
|
||||||
if (!hasPossibleToken && !isReturningFromSso && autoRedirect) {
|
|
||||||
logger.log("Bypassing app load to redirect to SSO");
|
// getInitialScreenAfterLogin has a side effect to write to sessionStorage, perform it before auto-redirect
|
||||||
const tempCli = createClient({
|
const initialScreenAfterLogin = getInitialScreenAfterLogin(window.location);
|
||||||
baseUrl: config.validated_server_config!.hsUrl,
|
|
||||||
idBaseUrl: config.validated_server_config!.isUrl,
|
if (!hasPossibleToken && !isReturningFromSso && autoRedirect && config.validated_server_config) {
|
||||||
});
|
const redirecting = await redirectToSso(config.validated_server_config);
|
||||||
PlatformPeg.get()!.startSingleSignOn(tempCli, "sso", `/${getScreenFromLocation(window.location).screen}`);
|
|
||||||
|
|
||||||
// We return here because startSingleSignOn() will asynchronously redirect us. We don't
|
// 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
|
// 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.
|
// page). As such, just don't even bother loading the MatrixChat component.
|
||||||
return <React.Fragment />;
|
if (redirecting) {
|
||||||
|
return <React.Fragment />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultDeviceName =
|
const defaultDeviceName =
|
||||||
snakedConfig.get("default_device_display_name") ?? platform?.getDefaultDeviceDisplayName();
|
snakedConfig.get("default_device_display_name") ?? platform?.getDefaultDeviceDisplayName();
|
||||||
|
|
||||||
const initialScreenAfterLogin = getInitialScreenAfterLogin(window.location);
|
|
||||||
|
|
||||||
const wrapperOpts: WrapperOpts = { Wrapper: React.Fragment };
|
const wrapperOpts: WrapperOpts = { Wrapper: React.Fragment };
|
||||||
ModuleRunner.instance.invoke(WrapperLifecycle.Wrapper, wrapperOpts);
|
ModuleRunner.instance.invoke(WrapperLifecycle.Wrapper, wrapperOpts);
|
||||||
|
|
||||||
|
|||||||
@@ -394,7 +394,10 @@ describe("Login", function () {
|
|||||||
|
|
||||||
// tried to register
|
// tried to register
|
||||||
expect(fetchMock).toHaveFetched(delegatedAuth.registration_endpoint);
|
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
|
// continued with normal setup
|
||||||
expect(mockClient.loginFlows).toHaveBeenCalled();
|
expect(mockClient.loginFlows).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user