Skip to content

Commit ce2b728

Browse files
leedongweic298lee
authored andcommitted
feat(saml2): Redirect to SLO URL on the frontend (#77036)
Related to #77033 If a SLO URL is returned by the logout API, we will redirect the browser to that URL. This is needed for Single-Logout to work, because we need the browser to clear the cookies that are attached to the IdP's domain.
1 parent 94286a6 commit ce2b728

File tree

9 files changed

+54
-56
lines changed

9 files changed

+54
-56
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {redirect} from 'react-router-dom';
2+
3+
import {waitFor} from 'sentry-test/reactTestingLibrary';
4+
5+
import {logout} from './account';
6+
7+
jest.mock('react-router-dom');
8+
9+
describe('logout', () => {
10+
it('has can logout', async function () {
11+
const mock = MockApiClient.addMockResponse({
12+
url: '/auth/',
13+
method: 'DELETE',
14+
});
15+
16+
const api = new MockApiClient();
17+
logout(api);
18+
19+
await waitFor(() => expect(mock).toHaveBeenCalled());
20+
await waitFor(() => expect(redirect).toHaveBeenCalled());
21+
});
22+
});

static/app/actionCreators/account.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {redirect} from 'react-router-dom';
2+
13
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
24
import {Client} from 'sentry/api';
35
import ConfigStore from 'sentry/stores/configStore';
@@ -46,8 +48,11 @@ export function updateUser(user: User | ChangeAvatarUser) {
4648
ConfigStore.set('user', {...previousUser, ...user, options});
4749
}
4850

49-
export function logout(api: Client) {
50-
return api.requestPromise('/auth/', {method: 'DELETE'});
51+
export async function logout(api: Client, redirectUrl = '/auth/login/') {
52+
const data = await api.requestPromise('/auth/', {method: 'DELETE'});
53+
54+
// If there's a URL for SAML Single-logout, redirect back to IdP
55+
redirect(data?.sloUrl || redirectUrl);
5156
}
5257

5358
export function removeAuthenticator(api: Client, userId: string, authId: string) {

static/app/components/modals/sudoModal.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,22 +222,13 @@ function SudoModal({
222222
return authLoginPath;
223223
};
224224

225-
const handleLogout = async () => {
226-
try {
227-
await logout(api);
228-
} catch {
229-
// ignore errors
230-
}
231-
window.location.assign(getAuthLoginPath());
232-
};
233-
234225
const renderBodyContent = () => {
235226
const user = ConfigStore.get('user');
236227
const isSelfHosted = ConfigStore.get('isSelfHosted');
237228
const validateSUForm = ConfigStore.get('validateSUForm');
238229

239230
if (errorType === ErrorCodes.INVALID_SSO_SESSION) {
240-
handleLogout();
231+
logout(api, getAuthLoginPath());
241232
return null;
242233
}
243234

static/app/components/narrowLayout.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@ function NarrowLayout({maxWidth, showLogout, children}: Props) {
2121
return () => document.body.classList.remove('narrow');
2222
}, []);
2323

24-
async function handleLogout() {
25-
await logout(api);
26-
window.location.assign('/auth/login');
24+
function handleLogout() {
25+
logout(api);
2726
}
2827

2928
return (

static/app/components/sidebar/index.spec.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {UserFixture} from 'sentry-fixture/user';
77

88
import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
99

10+
import {logout} from 'sentry/actionCreators/account';
1011
import {OnboardingContextProvider} from 'sentry/components/onboarding/onboardingContext';
1112
import SidebarContainer from 'sentry/components/sidebar';
1213
import ConfigStore from 'sentry/stores/configStore';
@@ -16,6 +17,7 @@ import localStorage from 'sentry/utils/localStorage';
1617
import {useLocation} from 'sentry/utils/useLocation';
1718
import * as incidentsHook from 'sentry/utils/useServiceIncidents';
1819

20+
jest.mock('sentry/actionCreators/account');
1921
jest.mock('sentry/utils/useServiceIncidents');
2022
jest.mock('sentry/utils/useLocation');
2123

@@ -122,23 +124,14 @@ describe('Sidebar', function () {
122124
});
123125

124126
it('has can logout', async function () {
125-
const mock = MockApiClient.addMockResponse({
126-
url: '/auth/',
127-
method: 'DELETE',
128-
status: 204,
129-
});
130-
jest.spyOn(window.location, 'assign').mockImplementation(() => {});
131-
132127
renderSidebar({
133128
organization: OrganizationFixture({access: ['member:read']}),
134129
});
135130

136131
await userEvent.click(await screen.findByTestId('sidebar-dropdown'));
137132
await userEvent.click(screen.getByTestId('sidebar-signout'));
138133

139-
await waitFor(() => expect(mock).toHaveBeenCalled());
140-
141-
expect(window.location.assign).toHaveBeenCalledWith('/auth/login/');
134+
await waitFor(() => expect(logout).toHaveBeenCalled());
142135
});
143136

144137
it('can toggle help menu', async function () {

static/app/components/sidebar/sidebarDropdown/index.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,6 @@ export default function SidebarDropdown({orientation, collapsed, hideOrgLinks}:
4444
const user = useUser();
4545
const {projects} = useProjects();
4646

47-
const handleLogout = async () => {
48-
await logout(api);
49-
window.location.assign('/auth/login/');
50-
};
51-
5247
const hasOrganization = !!org;
5348
const hasUser = !!user;
5449

@@ -59,6 +54,10 @@ export default function SidebarDropdown({orientation, collapsed, hideOrgLinks}:
5954
const hasTeamRead = org?.access?.includes('team:read');
6055
const canCreateOrg = ConfigStore.get('features').has('organizations:create');
6156

57+
function handleLogout() {
58+
logout(api);
59+
}
60+
6261
// Avatar to use: Organization --> user --> Sentry
6362
const avatar =
6463
hasOrganization || hasUser ? (

static/app/components/superuserStaffAccessForm.tsx

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React, {Component} from 'react';
22
import styled from '@emotion/styled';
3-
import trimEnd from 'lodash/trimEnd';
43

54
import {logout} from 'sentry/actionCreators/account';
65
import type {Client} from 'sentry/api';
@@ -163,21 +162,17 @@ class SuperuserStaffAccessForm extends Component<Props, State> {
163162
});
164163
};
165164

166-
handleLogout = async () => {
167-
const {api} = this.props;
168-
try {
169-
await logout(api);
170-
} catch {
171-
// ignore errors
172-
}
173-
const authLoginPath = `/auth/login/?next=${encodeURIComponent(window.location.href)}`;
174-
const {superuserUrl} = window.__initialData.links;
175-
if (window.__initialData?.customerDomain && superuserUrl) {
176-
const redirectURL = `${trimEnd(superuserUrl, '/')}${authLoginPath}`;
177-
window.location.assign(redirectURL);
178-
return;
179-
}
180-
window.location.assign(authLoginPath);
165+
handleLogout = () => {
166+
const {superuserUrl} = window.__initialData?.links;
167+
const urlOrigin =
168+
window.__initialData?.customerDomain && superuserUrl
169+
? superuserUrl
170+
: window.location.origin;
171+
172+
const nextUrl = new URL('/auth/login/', urlOrigin);
173+
nextUrl.searchParams.set('next', window.location.href);
174+
175+
logout(this.props.api, nextUrl.toString());
181176
};
182177

183178
async getAuthenticators() {

static/app/views/acceptOrganizationInvite/index.spec.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,8 @@ describe('AcceptOrganizationInvite', function () {
245245
);
246246

247247
expect(screen.getByTestId('existing-member')).toBeInTheDocument();
248-
249248
await userEvent.click(screen.getByTestId('existing-member-link'));
250-
251-
expect(logout).toHaveBeenCalled();
252-
await waitFor(() => expect(window.location.replace).toHaveBeenCalled());
249+
await waitFor(() => expect(logout).toHaveBeenCalled());
253250
});
254251

255252
it('shows right options for logged in user and optional SSO', function () {
@@ -294,9 +291,7 @@ describe('AcceptOrganizationInvite', function () {
294291

295292
expect(screen.getByTestId('existing-member')).toBeInTheDocument();
296293
await userEvent.click(screen.getByTestId('existing-member-link'));
297-
298-
expect(logout).toHaveBeenCalled();
299-
await waitFor(() => expect(window.location.replace).toHaveBeenCalled());
294+
await waitFor(() => expect(logout).toHaveBeenCalled());
300295
});
301296

302297
it('shows 2fa warning', function () {

static/app/views/acceptOrganizationInvite/index.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,9 @@ class AcceptOrganizationInvite extends DeprecatedAsyncView<Props, State> {
6161
return t('Accept Organization Invite');
6262
}
6363

64-
handleLogout = async (e: React.MouseEvent) => {
64+
handleLogout = (e: React.MouseEvent) => {
6565
e.preventDefault();
66-
await logout(this.api);
67-
window.location.replace('/auth/login/');
66+
logout(this.api);
6867
};
6968

7069
handleAcceptInvite = async () => {

0 commit comments

Comments
 (0)