diff --git a/src/components/views/settings/devices/CurrentDeviceSection.tsx b/src/components/views/settings/devices/CurrentDeviceSection.tsx index c88649d831..5eee6be407 100644 --- a/src/components/views/settings/devices/CurrentDeviceSection.tsx +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -34,7 +34,8 @@ interface Props { onSignOutCurrentDevice: () => void; signOutAllOtherSessions?: () => void; saveDeviceName: (deviceName: string) => Promise; - delegatedAuthAccountUrl?: string; + accountManagementEndpoint?: string; + accountManagementActionsSupported?: string[]; } type CurrentDeviceSectionHeadingProps = Pick< @@ -91,7 +92,8 @@ const CurrentDeviceSection: React.FC = ({ onSignOutCurrentDevice, signOutAllOtherSessions, saveDeviceName, - delegatedAuthAccountUrl, + accountManagementEndpoint, + accountManagementActionsSupported, }) => { const [isExpanded, setIsExpanded] = useState(false); @@ -128,7 +130,8 @@ const CurrentDeviceSection: React.FC = ({ onSignOutDevice={onSignOutCurrentDevice} saveDeviceName={saveDeviceName} className="mx_CurrentDeviceSection_deviceDetails" - delegatedAuthAccountUrl={delegatedAuthAccountUrl} + accountManagementEndpoint={accountManagementEndpoint} + accountManagementActionsSupported={accountManagementActionsSupported} isCurrentDevice /> ) : ( diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 55fa8cca32..eb0a82910f 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -32,7 +32,8 @@ interface Props { supportsMSC3881?: boolean; className?: string; isCurrentDevice?: boolean; - delegatedAuthAccountUrl?: string; + accountManagementEndpoint?: string; + accountManagementActionsSupported?: string[]; } interface MetadataTable { @@ -69,7 +70,8 @@ const DeviceDetails: React.FC = ({ supportsMSC3881, className, isCurrentDevice, - delegatedAuthAccountUrl, + accountManagementEndpoint, + accountManagementActionsSupported, }) => { const metadata: MetadataTable[] = [ { @@ -124,7 +126,7 @@ const DeviceDetails: React.FC = ({ isCurrentDevice={isCurrentDevice} /> - {!delegatedAuthAccountUrl && ( + {!accountManagementEndpoint && (

{_t("settings|sessions|details_heading")}

{metadata.map(({ heading, values, id }, index) => ( @@ -175,12 +177,16 @@ const DeviceDetails: React.FC = ({
)}
- {delegatedAuthAccountUrl && !isCurrentDevice ? ( + {accountManagementEndpoint && !isCurrentDevice ? ( diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 84283ab5b4..dc4f010782 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -46,7 +46,8 @@ interface Props { * Removes session info as that can be seen in the account management * Changes sign out button to be a manage button */ - delegatedAuthAccountUrl?: string; + accountManagementEndpoint?: string; + accountManagementActionsSupported?: string[]; ref?: Ref; } @@ -175,7 +176,8 @@ const DeviceListItem: React.FC<{ setPushNotifications: (deviceId: string, enabled: boolean) => Promise; supportsMSC3881?: boolean | undefined; isSelectDisabled?: boolean; - delegatedAuthAccountUrl?: string; + accountManagementEndpoint?: string; + accountManagementActionsSupported?: string[]; }> = ({ device, pusher, @@ -191,7 +193,8 @@ const DeviceListItem: React.FC<{ toggleSelected, supportsMSC3881, isSelectDisabled, - delegatedAuthAccountUrl, + accountManagementEndpoint, + accountManagementActionsSupported, }) => { const tileContent = ( <> @@ -227,7 +230,8 @@ const DeviceListItem: React.FC<{ setPushNotifications={setPushNotifications} supportsMSC3881={supportsMSC3881} className="mx_FilteredDeviceList_deviceDetails" - delegatedAuthAccountUrl={delegatedAuthAccountUrl} + accountManagementEndpoint={accountManagementEndpoint} + accountManagementActionsSupported={accountManagementActionsSupported} /> )} @@ -254,7 +258,8 @@ export const FilteredDeviceList = ({ setPushNotifications, setSelectedDeviceIds, supportsMSC3881, - delegatedAuthAccountUrl, + accountManagementEndpoint, + accountManagementActionsSupported, ref, }: Props): JSX.Element => { const sortedDevices = getFilteredSortedDevices(devices, filter); @@ -314,7 +319,7 @@ export const FilteredDeviceList = ({ selectedDeviceCount={selectedDeviceIds.length} isAllSelected={isAllSelected} toggleSelectAll={toggleSelectAll} - isSelectDisabled={!!delegatedAuthAccountUrl} + isSelectDisabled={!!accountManagementEndpoint} > {selectedDeviceIds.length ? ( <> @@ -364,7 +369,7 @@ export const FilteredDeviceList = ({ isExpanded={expandedDeviceIds.includes(device.device_id)} isSigningOut={signingOutDeviceIds.includes(device.device_id)} isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)} - isSelectDisabled={!!delegatedAuthAccountUrl} + isSelectDisabled={!!accountManagementEndpoint} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} onSignOutDevice={() => onSignOutDevices([device.device_id])} saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)} @@ -376,7 +381,8 @@ export const FilteredDeviceList = ({ setPushNotifications={setPushNotifications} toggleSelected={() => toggleSelection(device.device_id)} supportsMSC3881={supportsMSC3881} - delegatedAuthAccountUrl={delegatedAuthAccountUrl} + accountManagementEndpoint={accountManagementEndpoint} + accountManagementActionsSupported={accountManagementActionsSupported} /> ))} diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index ab5b941cde..8cf234ca12 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -60,7 +60,8 @@ const confirmSignOut = async (sessionsToSignOutCount: number): Promise const useSignOut = ( matrixClient: MatrixClient, onSignoutResolvedCallback: () => Promise, - delegatedAuthAccountUrl?: string, + accountManagementEndpoint?: string, + accountManagementActionsSupported?: string[], ): { onSignOutCurrentDevice: () => void; onSignOutOtherDevices: (deviceIds: ExtendedDevice["device_id"][]) => Promise; @@ -92,9 +93,9 @@ const useSignOut = ( try { setSigningOutDeviceIds((signingOutDeviceIds) => [...signingOutDeviceIds, ...deviceIds]); - if (delegatedAuthAccountUrl) { + if (accountManagementEndpoint) { const [deviceId] = deviceIds; - const url = getManageDeviceUrl(delegatedAuthAccountUrl, deviceId); + const url = getManageDeviceUrl(accountManagementEndpoint, accountManagementActionsSupported, deviceId); window.open(url, "_blank"); } else { const deferredSuccess = Promise.withResolvers(); @@ -151,12 +152,14 @@ const SessionManagerTab: React.FC<{ * delegated auth provider. * See https://github.com/matrix-org/matrix-spec-proposals/pull/3824 */ - const delegatedAuthAccountUrl = useAsyncMemo(async () => { + const accountManagement = useAsyncMemo(async () => { await sdkContext.oidcClientStore.readyPromise; // wait for the store to be ready - return sdkContext.oidcClientStore.accountManagementEndpoint; + return { + endpoint: sdkContext.oidcClientStore.accountManagementEndpoint, + actionsSupported: sdkContext.oidcClientStore.accountManagementActionsSupported, + }; }, [sdkContext.oidcClientStore]); - const disableMultipleSignout = !!delegatedAuthAccountUrl; - + const disableMultipleSignout = !!accountManagement?.endpoint; const userId = matrixClient?.getUserId(); const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined; const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); @@ -232,7 +235,8 @@ const SessionManagerTab: React.FC<{ const { onSignOutCurrentDevice, onSignOutOtherDevices, signingOutDeviceIds } = useSignOut( matrixClient, onSignoutResolvedCallback, - delegatedAuthAccountUrl, + accountManagement?.endpoint, + accountManagement?.actionsSupported, ); useEffect( @@ -297,7 +301,8 @@ const SessionManagerTab: React.FC<{ onSignOutCurrentDevice={onSignOutCurrentDevice} signOutAllOtherSessions={signOutAllOtherSessions} otherSessionsCount={otherSessionsCount} - delegatedAuthAccountUrl={delegatedAuthAccountUrl} + accountManagementEndpoint={accountManagement?.endpoint} + accountManagementActionsSupported={accountManagement?.actionsSupported} /> {shouldShowOtherSessions && ( )} diff --git a/src/stores/oidc/OidcClientStore.ts b/src/stores/oidc/OidcClientStore.ts index 1edfb4b59c..4b53089d57 100644 --- a/src/stores/oidc/OidcClientStore.ts +++ b/src/stores/oidc/OidcClientStore.ts @@ -33,6 +33,7 @@ export class OidcClientStore { private initialisingOidcClientPromise: Promise | undefined; private authenticatedIssuer?: string; // set only in OIDC-native mode private _accountManagementEndpoint?: string; + private _accountManagementActionsSupported?: string[]; /** * Promise which resolves once this store is read to use, which may mean there is no OIDC client if we're in legacy mode, * or we just have the account management endpoint if running in OIDC-aware mode. @@ -51,7 +52,11 @@ export class OidcClientStore { // We are not in OIDC Native mode, as we have no locally stored issuer. Check if the server delegates auth to OIDC. try { const authMetadata = await this.matrixClient.getAuthMetadata(); - this.setAccountManagementEndpoint(authMetadata.account_management_uri, authMetadata.issuer); + this.setAccountManagementEndpoint( + authMetadata.account_management_uri, + authMetadata.issuer, + authMetadata.account_management_actions_supported, + ); } catch (e) { console.log("Auth issuer not found", e); } @@ -65,7 +70,11 @@ export class OidcClientStore { return !!this.authenticatedIssuer; } - private setAccountManagementEndpoint(endpoint: string | undefined, issuer: string): void { + private setAccountManagementEndpoint( + endpoint: string | undefined, + issuer: string, + actionsSupported?: string[], + ): void { // if no account endpoint is configured default to the issuer const url = new URL(endpoint ?? issuer); const idToken = getStoredOidcIdToken(); @@ -73,12 +82,17 @@ export class OidcClientStore { url.searchParams.set("id_token_hint", idToken); } this._accountManagementEndpoint = url.toString(); + this._accountManagementActionsSupported = actionsSupported; } public get accountManagementEndpoint(): string | undefined { return this._accountManagementEndpoint; } + public get accountManagementActionsSupported(): string[] | undefined { + return this._accountManagementActionsSupported; + } + /** * Revokes provided access and refresh tokens with the configured OIDC provider * @param accessToken @@ -151,7 +165,11 @@ export class OidcClientStore { try { const clientId = getStoredOidcClientId(); const authMetadata = await discoverAndValidateOIDCIssuerWellKnown(this.authenticatedIssuer); - this.setAccountManagementEndpoint(authMetadata.account_management_uri, authMetadata.issuer); + this.setAccountManagementEndpoint( + authMetadata.account_management_uri, + authMetadata.issuer, + authMetadata.account_management_actions_supported, + ); this.oidcClient = new OidcClient({ authority: authMetadata.issuer, signingKeys: authMetadata.signingKeys ?? undefined, diff --git a/src/utils/oidc/urls.ts b/src/utils/oidc/urls.ts index fe364441a1..fc93ad0801 100644 --- a/src/utils/oidc/urls.ts +++ b/src/utils/oidc/urls.ts @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. @@ -8,14 +9,14 @@ Please see LICENSE files in the repository root for full details. enum Action { Profile = "org.matrix.profile", - SessionsList = "org.matrix.sessions_list", - SessionView = "org.matrix.session_view", - SessionEnd = "org.matrix.session_end", + DevicesList = "org.matrix.devices_list", + DeviceView = "org.matrix.device_view", + DeviceDelete = "org.matrix.device_delete", AccountDeactivate = "org.matrix.account_deactivate", CrossSigningReset = "org.matrix.cross_signing_reset", } -const getUrl = (authUrl: string, action: Action): URL => { +const getUrl = (authUrl: string, action: Action | string): URL => { const url = new URL(authUrl); url.searchParams.set("action", action); return url; @@ -25,8 +26,36 @@ const getUrl = (authUrl: string, action: Action): URL => { * Create a delegated auth account management URL with logout params as per MSC4191 * https://github.com/matrix-org/matrix-spec-proposals/blob/quenting/account-deeplink/proposals/4191-account-deeplink.md#possible-actions */ -export const getManageDeviceUrl = (delegatedAuthAccountUrl: string, deviceId: string): string => { - const url = getUrl(delegatedAuthAccountUrl, Action.SessionView); +export const getManageDeviceUrl = ( + accountManagementEndpoint: string, + accountManagementActionsSupported: string[] | undefined, + deviceId: string, +): string => { + let action: string | undefined; + + // pick the action= parameter that the server supports: + if (accountManagementActionsSupported?.includes(Action.DeviceView)) { + // stable action + action = Action.DeviceView; + } else if (accountManagementActionsSupported?.includes("org.matrix.session_view")) { + // unstable action from earlier version of MSC4191, can be removed once stable is widely supported + action = "org.matrix.session_view"; + } else if (accountManagementActionsSupported?.includes("session_view")) { + // unstable action from earlier version of MSC4191, can be removed once stable is widely supported + action = "session_view"; + } + if (!action) { + if (accountManagementActionsSupported) { + // the server gave a list of supported actions, but none we know about: + // send the stable action anyway + action = Action.DeviceView; + } else { + // the server did not provide a list of supported actions: + // to be backwards compatible, use the value that we used to always send + action = "org.matrix.session_view"; + } + } + const url = getUrl(accountManagementEndpoint, action); url.searchParams.set("device_id", deviceId); return url.toString(); }; diff --git a/test/unit-tests/stores/oidc/OidcClientStore-test.ts b/test/unit-tests/stores/oidc/OidcClientStore-test.ts index 164f90f531..4ab2743ad7 100644 --- a/test/unit-tests/stores/oidc/OidcClientStore-test.ts +++ b/test/unit-tests/stores/oidc/OidcClientStore-test.ts @@ -26,6 +26,7 @@ describe("OidcClientStore", () => { const clientId = "test-client-id"; const authConfig = makeDelegatedAuthConfig(); const account = authConfig.issuer + "account"; + const accountManagementActionsSupported = ["action1", "action2"]; const mockClient = getMockClientWithEventEmitter({ getAuthMetadata: jest.fn(), @@ -41,6 +42,7 @@ describe("OidcClientStore", () => { .mockResolvedValue({ ...authConfig, account_management_uri: account, + account_management_actions_supported: accountManagementActionsSupported, authorization_endpoint: "authorization-endpoint", token_endpoint: "token-endpoint", }); @@ -130,6 +132,15 @@ describe("OidcClientStore", () => { expect(store.accountManagementEndpoint).toEqual(account); }); + it("should set account management actions supported when configured", async () => { + const store = new OidcClientStore(mockClient); + + // @ts-ignore private property + await store.getOidcClient(); + + expect(store.accountManagementActionsSupported).toEqual(accountManagementActionsSupported); + }); + it("should set account management endpoint to issuer when not configured", async () => { mocked(discoverAndValidateOIDCIssuerWellKnown) .mockClear() @@ -244,10 +255,12 @@ describe("OidcClientStore", () => { mockClient.getAuthMetadata.mockResolvedValue({ ...authConfig, account_management_uri: account, + account_management_actions_supported: accountManagementActionsSupported, }); const store = new OidcClientStore(mockClient); await store.readyPromise; expect(store.accountManagementEndpoint).toBe(account); + expect(store.accountManagementActionsSupported).toEqual(accountManagementActionsSupported); }); }); }); diff --git a/test/unit-tests/utils/oidc/urls-test.ts b/test/unit-tests/utils/oidc/urls-test.ts new file mode 100644 index 0000000000..072202d0b6 --- /dev/null +++ b/test/unit-tests/utils/oidc/urls-test.ts @@ -0,0 +1,48 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { getManageDeviceUrl } from "../../../../src/utils/oidc/urls"; + +describe("OIDC urls", () => { + const accountManagementEndpoint = "https://auth.com/manage"; + const deviceId = "DEVICEID1234"; + + describe("getManageDeviceUrl()", () => { + it("prefers stable action", async () => { + expect( + getManageDeviceUrl( + accountManagementEndpoint, + ["org.matrix.session_view", "session_view", "org.matrix.device_view"], + deviceId, + ), + ).toEqual("https://auth.com/manage?action=org.matrix.device_view&device_id=DEVICEID1234"); + }); + it("defaults to stable action when no known action is supported", async () => { + expect(getManageDeviceUrl(accountManagementEndpoint, [], deviceId)).toEqual( + "https://auth.com/manage?action=org.matrix.device_view&device_id=DEVICEID1234", + ); + expect(getManageDeviceUrl(accountManagementEndpoint, ["foo"], deviceId)).toEqual( + "https://auth.com/manage?action=org.matrix.device_view&device_id=DEVICEID1234", + ); + }); + it("defaults to backwards compatible action when no supported actions are provided", async () => { + expect(getManageDeviceUrl(accountManagementEndpoint, undefined, deviceId)).toEqual( + "https://auth.com/manage?action=org.matrix.session_view&device_id=DEVICEID1234", + ); + }); + it("uses unstable org.matrix.session_view", async () => { + expect(getManageDeviceUrl(accountManagementEndpoint, ["org.matrix.session_view"], deviceId)).toEqual( + "https://auth.com/manage?action=org.matrix.session_view&device_id=DEVICEID1234", + ); + }); + it("uses unstable session_view", async () => { + expect(getManageDeviceUrl(accountManagementEndpoint, ["session_view"], deviceId)).toEqual( + "https://auth.com/manage?action=session_view&device_id=DEVICEID1234", + ); + }); + }); +});