mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-03 20:42:28 +01:00
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:
parent
9cc80c5f36
commit
7f408bd6cf
@ -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
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
48
test/unit-tests/utils/oidc/urls-test.ts
Normal file
48
test/unit-tests/utils/oidc/urls-test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user