Support for stable MSC4191 account management action parameter (#31701)

* Support for stable MSC4191 account management action parameter

* Pass accountManagementActionsSupported around and refactor name

* Iterate

* Iterate

* Attempt to improve clarity of action fallback

* Use name "actions supported" consistently

* Update test cases for revised default behaviour
This commit is contained in:
Hugh Nimmo-Smith 2026-01-14 14:53:44 +00:00 committed by GitHub
parent 9cc80c5f36
commit 7f408bd6cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 164 additions and 35 deletions

View File

@ -34,7 +34,8 @@ interface Props {
onSignOutCurrentDevice: () => void;
signOutAllOtherSessions?: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
delegatedAuthAccountUrl?: string;
accountManagementEndpoint?: string;
accountManagementActionsSupported?: string[];
}
type CurrentDeviceSectionHeadingProps = Pick<
@ -91,7 +92,8 @@ const CurrentDeviceSection: React.FC<Props> = ({
onSignOutCurrentDevice,
signOutAllOtherSessions,
saveDeviceName,
delegatedAuthAccountUrl,
accountManagementEndpoint,
accountManagementActionsSupported,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
@ -128,7 +130,8 @@ const CurrentDeviceSection: React.FC<Props> = ({
onSignOutDevice={onSignOutCurrentDevice}
saveDeviceName={saveDeviceName}
className="mx_CurrentDeviceSection_deviceDetails"
delegatedAuthAccountUrl={delegatedAuthAccountUrl}
accountManagementEndpoint={accountManagementEndpoint}
accountManagementActionsSupported={accountManagementActionsSupported}
isCurrentDevice
/>
) : (

View File

@ -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<Props> = ({
supportsMSC3881,
className,
isCurrentDevice,
delegatedAuthAccountUrl,
accountManagementEndpoint,
accountManagementActionsSupported,
}) => {
const metadata: MetadataTable[] = [
{
@ -124,7 +126,7 @@ const DeviceDetails: React.FC<Props> = ({
isCurrentDevice={isCurrentDevice}
/>
</section>
{!delegatedAuthAccountUrl && (
{!accountManagementEndpoint && (
<section className="mx_DeviceDetails_section">
<p className="mx_DeviceDetails_sectionHeading">{_t("settings|sessions|details_heading")}</p>
{metadata.map(({ heading, values, id }, index) => (
@ -175,12 +177,16 @@ const DeviceDetails: React.FC<Props> = ({
</section>
)}
<section className="mx_DeviceDetails_section">
{delegatedAuthAccountUrl && !isCurrentDevice ? (
{accountManagementEndpoint && !isCurrentDevice ? (
<AccessibleButton
element="a"
onClick={null}
kind="link_inline"
href={getManageDeviceUrl(delegatedAuthAccountUrl, device.device_id)}
href={getManageDeviceUrl(
accountManagementEndpoint,
accountManagementActionsSupported,
device.device_id,
)}
target="_blank"
data-testid="device-detail-sign-out-cta"
>

View File

@ -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<HTMLDivElement>;
}
@ -175,7 +176,8 @@ const DeviceListItem: React.FC<{
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
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}
/>
)}
</li>
@ -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}
/>
))}
</ol>

View File

@ -60,7 +60,8 @@ const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean>
const useSignOut = (
matrixClient: MatrixClient,
onSignoutResolvedCallback: () => Promise<void>,
delegatedAuthAccountUrl?: string,
accountManagementEndpoint?: string,
accountManagementActionsSupported?: string[],
): {
onSignOutCurrentDevice: () => void;
onSignOutOtherDevices: (deviceIds: ExtendedDevice["device_id"][]) => Promise<void>;
@ -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<boolean>();
@ -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 && (
<SettingsSubsection
@ -331,7 +336,8 @@ const SessionManagerTab: React.FC<{
setPushNotifications={setPushNotifications}
ref={filteredDeviceListRef}
supportsMSC3881={supportsMSC3881}
delegatedAuthAccountUrl={delegatedAuthAccountUrl}
accountManagementEndpoint={accountManagement?.endpoint}
accountManagementActionsSupported={accountManagement?.actionsSupported}
/>
</SettingsSubsection>
)}

View File

@ -33,6 +33,7 @@ export class OidcClientStore {
private initialisingOidcClientPromise: Promise<void> | 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,

View File

@ -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();
};

View File

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

View File

@ -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",
);
});
});
});