Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 23196d4

Browse files
Kerryhughnsandybalaam
authored
OIDC: Redirect to delegated auth provider when signing out (#11432)
* util for account url * test cases * disable multi session selection on device list * remove sign out all from context menus when oidc-aware * comment * remove unused param * redirect to auth provider when signing out * open auth provider in new tab, refresh sessions on return * correct comment * fix bad copy paste * try to make sonar happy * Update for latest revision of MSCs * Update SessionManagerTab-test.tsx * Make InteractiveAuthCallback async and await it --------- Co-authored-by: Hugh Nimmo-Smith <[email protected]> Co-authored-by: Hugh Nimmo-Smith <[email protected]> Co-authored-by: Andy Balaam <[email protected]>
1 parent 5c1b62c commit 23196d4

File tree

9 files changed

+199
-44
lines changed

9 files changed

+199
-44
lines changed

src/components/structures/InteractiveAuth.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ type InteractiveAuthCallbackSuccess<T> = (
3535
success: true,
3636
response: T,
3737
extra?: { emailSid?: string; clientSecret?: string },
38-
) => void;
39-
type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => void;
38+
) => Promise<void>;
39+
type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => Promise<void>;
4040
export type InteractiveAuthCallback<T> = InteractiveAuthCallbackSuccess<T> & InteractiveAuthCallbackFailure;
4141

4242
export interface InteractiveAuthProps<T> {
@@ -141,15 +141,15 @@ export default class InteractiveAuthComponent<T> extends React.Component<Interac
141141
public componentDidMount(): void {
142142
this.authLogic
143143
.attemptAuth()
144-
.then((result) => {
144+
.then(async (result) => {
145145
const extra = {
146146
emailSid: this.authLogic.getEmailSid(),
147147
clientSecret: this.authLogic.getClientSecret(),
148148
};
149-
this.props.onAuthFinished(true, result, extra);
149+
await this.props.onAuthFinished(true, result, extra);
150150
})
151-
.catch((error) => {
152-
this.props.onAuthFinished(false, error);
151+
.catch(async (error) => {
152+
await this.props.onAuthFinished(false, error);
153153
logger.error("Error during user-interactive auth:", error);
154154
if (this.unmounted) {
155155
return;
@@ -251,12 +251,12 @@ export default class InteractiveAuthComponent<T> extends React.Component<Interac
251251
this.props.onStagePhaseChange?.(this.state.authStage ?? null, newPhase || 0);
252252
};
253253

254-
private onStageCancel = (): void => {
255-
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
254+
private onStageCancel = async (): Promise<void> => {
255+
await this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
256256
};
257257

258-
private onAuthStageFailed = (e: Error): void => {
259-
this.props.onAuthFinished(false, e);
258+
private onAuthStageFailed = async (e: Error): Promise<void> => {
259+
await this.props.onAuthFinished(false, e);
260260
};
261261

262262
private setEmailSid = (sid: string): void => {

src/components/views/dialogs/DeactivateAccountDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
110110
this.setState({ bodyText, continueText, continueKind });
111111
};
112112

113-
private onUIAuthFinished: InteractiveAuthCallback<Awaited<ReturnType<MatrixClient["deactivateAccount"]>>> = (
113+
private onUIAuthFinished: InteractiveAuthCallback<Awaited<ReturnType<MatrixClient["deactivateAccount"]>>> = async (
114114
success,
115115
result,
116116
) => {

src/components/views/dialogs/InteractiveAuthDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export default class InteractiveAuthDialog<T> extends React.Component<Interactiv
116116
};
117117
}
118118

119-
private onAuthFinished: InteractiveAuthCallback<T> = (success, result): void => {
119+
private onAuthFinished: InteractiveAuthCallback<T> = async (success, result): Promise<void> => {
120120
if (success) {
121121
this.props.onFinished(true, result);
122122
} else {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { useState } from "react";
18+
19+
import { _t } from "../../../../languageHandler";
20+
import BaseDialog from "../BaseDialog";
21+
import { getOidcLogoutUrl } from "../../../../utils/oidc/getOidcLogoutUrl";
22+
import AccessibleButton from "../../elements/AccessibleButton";
23+
24+
export interface OidcLogoutDialogProps {
25+
delegatedAuthAccountUrl: string;
26+
deviceId: string;
27+
onFinished(ok?: boolean): void;
28+
}
29+
30+
/**
31+
* Handle logout of OIDC sessions other than the current session
32+
* - ask for user confirmation to open the delegated auth provider
33+
* - open the auth provider in a new tab
34+
* - wait for the user to return and close the modal, we assume the user has completed sign out of the session in auth provider UI
35+
* and trigger a refresh of the session list
36+
*/
37+
export const OidcLogoutDialog: React.FC<OidcLogoutDialogProps> = ({
38+
delegatedAuthAccountUrl,
39+
deviceId,
40+
onFinished,
41+
}) => {
42+
const [hasOpenedLogoutLink, setHasOpenedLogoutLink] = useState(false);
43+
const logoutUrl = getOidcLogoutUrl(delegatedAuthAccountUrl, deviceId);
44+
45+
return (
46+
<BaseDialog onFinished={onFinished} title={_t("Sign out")} contentId="mx_Dialog_content">
47+
<div className="mx_Dialog_content" id="mx_Dialog_content">
48+
{_t("You will be redirected to your server's authentication provider to complete sign out.")}
49+
</div>
50+
<div className="mx_Dialog_buttons">
51+
{hasOpenedLogoutLink ? (
52+
<AccessibleButton kind="primary" onClick={() => onFinished(true)}>
53+
{_t("Close")}
54+
</AccessibleButton>
55+
) : (
56+
<>
57+
<AccessibleButton kind="secondary" onClick={() => onFinished(false)}>
58+
{_t("Cancel")}
59+
</AccessibleButton>
60+
<AccessibleButton
61+
element="a"
62+
onClick={() => setHasOpenedLogoutLink(true)}
63+
kind="primary"
64+
href={logoutUrl}
65+
target="_blank"
66+
>
67+
{_t("Continue")}
68+
</AccessibleButton>
69+
</>
70+
)}
71+
</div>
72+
</BaseDialog>
73+
);
74+
};

src/components/views/settings/devices/deleteDevices.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const deleteDevicesWithInteractiveAuth = async (
4040
try {
4141
await makeDeleteRequest(matrixClient, deviceIds)(null);
4242
// no interactive auth needed
43-
onFinished(true, undefined);
43+
await onFinished(true, undefined);
4444
} catch (error) {
4545
if (!(error instanceof MatrixError) || error.httpStatus !== 401 || !error.data?.flows) {
4646
// doesn't look like an interactive-auth failure

src/components/views/settings/tabs/user/SessionManagerTab.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { FilterVariation } from "../../devices/filter";
4040
import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionHeading";
4141
import { SettingsSection } from "../../shared/SettingsSection";
4242
import { getDelegatedAuthAccountUrl } from "../../../../../utils/oidc/getDelegatedAuthAccountUrl";
43+
import { OidcLogoutDialog } from "../../../dialogs/oidc/OidcLogoutDialog";
4344

4445
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
4546
const { finished } = Modal.createDialog(QuestionDialog, {
@@ -61,9 +62,20 @@ const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean>
6162
return !!confirmed;
6263
};
6364

65+
const confirmDelegatedAuthSignOut = async (delegatedAuthAccountUrl: string, deviceId: string): Promise<boolean> => {
66+
const { finished } = Modal.createDialog(OidcLogoutDialog, {
67+
deviceId,
68+
delegatedAuthAccountUrl,
69+
});
70+
const [confirmed] = await finished;
71+
72+
return !!confirmed;
73+
};
74+
6475
const useSignOut = (
6576
matrixClient: MatrixClient,
6677
onSignoutResolvedCallback: () => Promise<void>,
78+
delegatedAuthAccountUrl?: string,
6779
): {
6880
onSignOutCurrentDevice: () => void;
6981
onSignOutOtherDevices: (deviceIds: ExtendedDevice["device_id"][]) => Promise<void>;
@@ -85,19 +97,44 @@ const useSignOut = (
8597
if (!deviceIds.length) {
8698
return;
8799
}
88-
const userConfirmedSignout = await confirmSignOut(deviceIds.length);
89-
if (!userConfirmedSignout) {
100+
// we can only sign out exactly one OIDC-aware device at a time
101+
// we should not encounter this
102+
if (delegatedAuthAccountUrl && deviceIds.length !== 1) {
103+
logger.warn("Unexpectedly tried to sign out multiple OIDC-aware devices.");
90104
return;
91105
}
92106

107+
// delegated auth logout flow confirms and signs out together
108+
// so only confirm if we are NOT doing a delegated auth sign out
109+
if (!delegatedAuthAccountUrl) {
110+
const userConfirmedSignout = await confirmSignOut(deviceIds.length);
111+
if (!userConfirmedSignout) {
112+
return;
113+
}
114+
}
115+
93116
try {
94117
setSigningOutDeviceIds([...signingOutDeviceIds, ...deviceIds]);
95-
await deleteDevicesWithInteractiveAuth(matrixClient, deviceIds, async (success): Promise<void> => {
118+
119+
const onSignOutFinished = async (success: boolean): Promise<void> => {
96120
if (success) {
97121
await onSignoutResolvedCallback();
98122
}
99123
setSigningOutDeviceIds(signingOutDeviceIds.filter((deviceId) => !deviceIds.includes(deviceId)));
100-
});
124+
};
125+
126+
if (delegatedAuthAccountUrl) {
127+
const [deviceId] = deviceIds;
128+
try {
129+
setSigningOutDeviceIds([...signingOutDeviceIds, deviceId]);
130+
const success = await confirmDelegatedAuthSignOut(delegatedAuthAccountUrl, deviceId);
131+
await onSignOutFinished(success);
132+
} catch (error) {
133+
logger.error("Error deleting OIDC-aware sessions", error);
134+
}
135+
} else {
136+
await deleteDevicesWithInteractiveAuth(matrixClient, deviceIds, onSignOutFinished);
137+
}
101138
} catch (error) {
102139
logger.error("Error deleting sessions", error);
103140
setSigningOutDeviceIds(signingOutDeviceIds.filter((deviceId) => !deviceIds.includes(deviceId)));
@@ -200,6 +237,7 @@ const SessionManagerTab: React.FC = () => {
200237
const { onSignOutCurrentDevice, onSignOutOtherDevices, signingOutDeviceIds } = useSignOut(
201238
matrixClient,
202239
onSignoutResolvedCallback,
240+
delegatedAuthAccountUrl,
203241
);
204242

205243
useEffect(

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3285,6 +3285,7 @@
32853285
"Not a valid Security Key": "Not a valid Security Key",
32863286
"Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.",
32873287
"If you've forgotten your Security Key you can <button>set up new recovery options</button>": "If you've forgotten your Security Key you can <button>set up new recovery options</button>",
3288+
"You will be redirected to your server's authentication provider to complete sign out.": "You will be redirected to your server's authentication provider to complete sign out.",
32883289
"Send custom account data event": "Send custom account data event",
32893290
"Send custom room account data event": "Send custom room account data event",
32903291
"Event Type": "Event Type",

src/utils/oidc/getOidcLogoutUrl.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
/**
18+
* Create a delegated auth account management URL with logout params as per MSC3824 and MSC2965
19+
* https://github.com/matrix-org/matrix-spec-proposals/blob/hughns/sso-redirect-action/proposals/3824-oidc-aware-clients.md#definition-of-oidc-aware
20+
* https://github.com/sandhose/matrix-doc/blob/msc/sandhose/oidc-discovery/proposals/2965-oidc-discovery.md#account-management-url-parameters
21+
*/
22+
export const getOidcLogoutUrl = (delegatedAuthAccountUrl: string, deviceId: string): string => {
23+
const logoutUrl = new URL(delegatedAuthAccountUrl);
24+
logoutUrl.searchParams.set("action", "session_end");
25+
logoutUrl.searchParams.set("device_id", deviceId);
26+
27+
return logoutUrl.toString();
28+
};

test/components/views/settings/tabs/user/SessionManagerTab-test.tsx

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ import { getClientInformationEventType } from "../../../../../../src/utils/devic
5555

5656
mockPlatformPeg();
5757

58+
// to restore later
59+
const realWindowLocation = window.location;
60+
5861
describe("<SessionManagerTab />", () => {
5962
const aliceId = "@alice:server.org";
6063
const deviceId = "alices_device";
@@ -166,7 +169,7 @@ describe("<SessionManagerTab />", () => {
166169
confirm = true,
167170
): Promise<void> => {
168171
// modal has sleeps in rendering process :(
169-
await sleep(100);
172+
await screen.findByRole("dialog");
170173
const buttonId = confirm ? "dialog-primary-button" : "dialog-cancel-button";
171174
fireEvent.click(getByTestId(buttonId));
172175

@@ -227,11 +230,23 @@ describe("<SessionManagerTab />", () => {
227230
}
228231
});
229232

233+
// @ts-ignore allow delete of non-optional prop
234+
delete window.location;
235+
// @ts-ignore ugly mocking
236+
window.location = {
237+
href: "https://localhost/",
238+
origin: "https://localhost/",
239+
};
240+
230241
// sometimes a verification modal is in modal state when these tests run
231242
// make sure the coast is clear
232243
await clearAllModals();
233244
});
234245

246+
afterAll(() => {
247+
window.location = realWindowLocation;
248+
});
249+
235250
it("renders spinner while devices load", () => {
236251
const { container } = render(getComponent());
237252
expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy();
@@ -858,7 +873,7 @@ describe("<SessionManagerTab />", () => {
858873

859874
await flushPromises();
860875
// modal rendering has some weird sleeps
861-
await sleep(100);
876+
await screen.findByRole("dialog");
862877

863878
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
864879
[alicesMobileDevice.device_id],
@@ -1082,26 +1097,20 @@ describe("<SessionManagerTab />", () => {
10821097
});
10831098

10841099
describe("other devices", () => {
1085-
// signing out a single device still works
1086-
// this test will be updated once redirect to MAS is added
1087-
// https://github.com/vector-im/element-web/issues/26000
1088-
it("deletes a device when interactive auth is not required", async () => {
1089-
mockClient.deleteMultipleDevices.mockResolvedValue({});
1090-
mockClient.getDevices
1091-
.mockResolvedValueOnce({
1092-
devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice],
1093-
})
1094-
// pretend it was really deleted on refresh
1095-
.mockResolvedValueOnce({
1096-
devices: [alicesDevice, alicesOlderMobileDevice],
1097-
});
1100+
it("opens delegated auth provider to sign out a single device", async () => {
1101+
mockClient.getDevices.mockResolvedValue({
1102+
devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice],
1103+
});
10981104

10991105
const { getByTestId } = render(getComponent());
11001106

11011107
await act(async () => {
11021108
await flushPromises();
11031109
});
11041110

1111+
// reset call count
1112+
mockClient.getDevices.mockClear();
1113+
11051114
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
11061115

11071116
const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`);
@@ -1110,23 +1119,28 @@ describe("<SessionManagerTab />", () => {
11101119
) as Element;
11111120
fireEvent.click(signOutButton);
11121121

1113-
await confirmSignout(getByTestId);
1114-
1115-
// sign out button is disabled with spinner
1122+
await screen.findByRole("dialog");
11161123
expect(
1117-
(
1118-
deviceDetails.querySelector('[data-testid="device-detail-sign-out-cta"]') as Element
1119-
).getAttribute("aria-disabled"),
1120-
).toEqual("true");
1121-
// delete called
1122-
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
1123-
[alicesMobileDevice.device_id],
1124-
undefined,
1124+
screen.getByText(
1125+
"You will be redirected to your server's authentication provider to complete sign out.",
1126+
),
1127+
).toBeInTheDocument();
1128+
// correct link to auth provider
1129+
expect(screen.getByText("Continue")).toHaveAttribute(
1130+
"href",
1131+
`https://issuer.org/account?action=session_end&device_id=${alicesMobileDevice.device_id}`,
11251132
);
11261133

1134+
// go to the link
1135+
fireEvent.click(screen.getByText("Continue"));
1136+
await flushPromises();
1137+
1138+
// come back from the link and close the modal
1139+
fireEvent.click(screen.getByText("Close"));
1140+
11271141
await flushPromises();
11281142

1129-
// devices refreshed
1143+
// devices were refreshed
11301144
expect(mockClient.getDevices).toHaveBeenCalled();
11311145
});
11321146

0 commit comments

Comments
 (0)