Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ID-1113, ID-1115 Separate Logout Code and Redirect on Logout #4726

Merged
merged 13 commits into from
Mar 25, 2024
12 changes: 6 additions & 6 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"keycode-js": "^3.1.0",
"lodash": "^4.17.21",
"marked": "^4.0.10",
"oidc-client-ts": "^2.0.4",
"oidc-client-ts": "^2.4.0",
"outdated-browser-rework": "^3.0.1",
"path-to-regexp": "^5.0.0",
"pluralize": "^8.0.0",
Expand Down
2 changes: 2 additions & 0 deletions src/auth/AuthContainer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { h } from 'react-hyperscript-helpers';
import { Logout } from 'src/auth/Logout';
import { fixedSpinnerOverlay } from 'src/components/common';
import { useRoute } from 'src/libs/nav';
import { useStore } from 'src/libs/react-utils';
Expand All @@ -22,6 +23,7 @@ const AuthContainer = ({ children }) => {
const authspinner = () => fixedSpinnerOverlay;

return Utils.cond<ReactNode>(
[name === 'logout-callback', () => h(Logout)],
[signInStatus === 'uninitialized' && !isPublic, authspinner],
[signInStatus === 'signedOut' && !isPublic, () => h(SignIn)],
[userMustRegister, () => h(Register)],
Expand Down
41 changes: 41 additions & 0 deletions src/auth/Logout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { render } from '@testing-library/react';
import React from 'react';
import { Logout } from 'src/auth/Logout';
import { OidcUser } from 'src/auth/oidc-broker';
import { authStore, azureCookieReadyStore, cookieReadyStore, metricStore, oidcStore, userStore } from 'src/libs/state';

type NavExports = typeof import('src/libs/nav');
jest.mock(
'src/libs/nav',
(): NavExports => ({
...jest.requireActual('src/libs/nav'),
getCurrentUrl: jest.fn().mockReturnValue(new URL('https://app.terra.bio')),
getLink: jest.fn().mockImplementation((_) => _),
goToPath: jest.fn(),
})
);

describe('Logout', () => {
it('clears stores after being redirected to', () => {
// Arrange
cookieReadyStore.update(() => true);
azureCookieReadyStore.update((state) => ({ ...state, readyForRuntime: true }));
authStore.update((state) => ({ ...state, cookiesAccepted: true, nihStatusLoaded: true }));
oidcStore.update((state) => ({ ...state, user: {} as OidcUser }));
metricStore.update((state) => ({ ...state, anonymousId: '12345', sessionId: '67890' }));
userStore.update((state) => ({ ...state, enterpriseFeatures: ['github-account-linking'] }));
// Act
render(<Logout />);
// Assert
expect(cookieReadyStore.get()).toBe(false);
expect(azureCookieReadyStore.get().readyForRuntime).toBe(false);
// logout preserves cookiesAccepted
expect(authStore.get().cookiesAccepted).toBe(true);
expect(authStore.get().nihStatusLoaded).toBe(false);
expect(oidcStore.get().user).toBeUndefined();
// logout preserves the anonymousId
expect(metricStore.get().anonymousId).toBe('12345');
expect(metricStore.get().sessionId).toBeUndefined();
expect(userStore.get().enterpriseFeatures).toEqual([]);
});
});
23 changes: 23 additions & 0 deletions src/auth/Logout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { userSignedOut } from 'src/auth/auth-events/signout';
import * as Nav from 'src/libs/nav';

export const logoutCallbackLinkName = 'logout-callback';
export const Logout = () => {
try {
userSignedOut();
} catch (e) {
console.error(e);
}
Nav.goToPath('root');
return <div />;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side effects should go in effect hooks. Also, since we're only interested in the side effect, this doesn't need to render an element.

Suggested change
export const Logout = () => {
try {
userSignedOut();
} catch (e) {
console.error(e);
}
Nav.goToPath('root');
return <div />;
export const Logout = (): ReactNode => {
useEffect(() => {
try {
userSignedOut();
} catch (e) {
console.error(e);
}
Nav.goToPath('root');
}, []);
return null;

};

export const navPaths = [
{
name: logoutCallbackLinkName,
path: '/logout',
component: Logout,
title: 'Logout',
},
];
157 changes: 157 additions & 0 deletions src/auth/auth-events/signout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { DeepPartial } from '@terra-ui-packages/core-utils';
import { sessionTimedOutErrorMessage } from 'src/auth/auth-errors';
import { signOut } from 'src/auth/auth-events/signout';
import { removeUserFromLocalState } from 'src/auth/oidc-broker';
import { Ajax } from 'src/libs/ajax';
import Events from 'src/libs/events';
import * as Nav from 'src/libs/nav';
import { goToPath } from 'src/libs/nav';
import { notify, sessionTimeoutProps } from 'src/libs/notifications';
import { OidcState, oidcStore } from 'src/libs/state';
import { asMockedFn } from 'src/testing/test-utils';

jest.mock('src/auth/oidc-broker');

jest.mock('react-oidc-context');

jest.mock('src/libs/ajax');

type AjaxExports = typeof import('src/libs/ajax');
type AjaxContract = ReturnType<AjaxExports['Ajax']>;

type NotificationExports = typeof import('src/libs/notifications');
jest.mock(
'src/libs/notifications',
(): NotificationExports => ({
...jest.requireActual('src/libs/notifications'),
notify: jest.fn(),
})
);

type NavExports = typeof import('src/libs/nav');
jest.mock('src/libs/nav', (): NavExports => {
return {
...jest.requireActual<NavExports>('src/libs/nav'),
getLink: jest.fn().mockReturnValue({ name: 'logout-callback', query: {} }),
goToPath: jest.fn(),
};
});

jest.mock('src/libs/state', () => {
const state = jest.requireActual('src/libs/state');
return {
...state,
oidcStore: {
...state.oidcStore,
get: jest.fn().mockReturnValue({
...state.oidcStore.get,
userManager: { signoutRedirect: jest.fn() },
}),
},
};
});

describe('signout', () => {
it('sends sign out metrics', async () => {
// Arrange
const captureEventFn = jest.fn();
asMockedFn(Ajax).mockImplementation(
tlangs marked this conversation as resolved.
Show resolved Hide resolved
() =>
({
Metrics: {
captureEvent: captureEventFn,
},
} as DeepPartial<AjaxContract> as AjaxContract)
);

// Act
signOut();
// Assert
expect(captureEventFn).toHaveBeenCalledWith(Events.user.signOut.unspecified, expect.any(Object));
});
it('sends sign out metrics with a specified event', async () => {
// Arrange
const captureEventFn = jest.fn();
asMockedFn(Ajax).mockImplementation(
() =>
({
Metrics: {
captureEvent: captureEventFn,
},
} as DeepPartial<AjaxContract> as AjaxContract)
);

// Act
signOut('idleStatusMonitor');
// Assert
expect(captureEventFn).toHaveBeenCalledWith(Events.user.signOut.idleStatusMonitor, expect.any(Object));
});
it('displays a session expired notification for an expired refresh token', () => {
// Arrange
const notifyFn = jest.fn();
asMockedFn(notify).mockImplementation(notifyFn);
tlangs marked this conversation as resolved.
Show resolved Hide resolved

// Act
signOut('expiredRefreshToken');
// Assert
expect(notifyFn).toHaveBeenCalledWith('info', sessionTimedOutErrorMessage, sessionTimeoutProps);
});
it('displays a session expired notification for an error refreshing tokens', () => {
// Arrange
const notifyFn = jest.fn();
asMockedFn(notify).mockImplementation(notifyFn);

// Act
signOut('errorRefreshingAuthToken');
// Assert
expect(notifyFn).toHaveBeenCalledWith('info', sessionTimedOutErrorMessage, sessionTimeoutProps);
});
it('redirects to the logout callback page', () => {
// Arrange
const signoutRedirectFn = jest.fn();
asMockedFn(oidcStore.get).mockImplementation(
() =>
({
userManager: {
signoutRedirect: signoutRedirectFn,
},
} as unknown as OidcState)
);
asMockedFn(Nav.getLink).mockImplementation(() => 'logout');
// Act
signOut();
// Assert
expect(signoutRedirectFn).toHaveBeenCalledWith({
post_logout_redirect_uri: `${window.location.origin}/logout`,
});
});
it('logs an error and calls userSignedOut if signoutRedirect throws', () => {
// Arrange
const signoutRedirectFn = jest.fn(() => {
throw new Error('test error');
});
const removeUserFromLocalStateFn = jest.fn();
const goToRootFn = jest.fn();
asMockedFn(oidcStore.get).mockImplementation(
() =>
({
userManager: {
signoutRedirect: signoutRedirectFn,
},
} as unknown as OidcState)
);
asMockedFn(removeUserFromLocalState).mockImplementation(removeUserFromLocalStateFn);
asMockedFn(goToPath).mockImplementation(goToRootFn);
const consoleErrorFn = jest.fn();
jest.spyOn(console, 'error').mockImplementation(consoleErrorFn);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jest.spyOn creates and returns a mock, so there's no need to mock it's implementation with a second mock.

Instead, it could be

jest.spyOn(console, 'error').mockImplementation(() => {});
...
expect(console.error).toHaveBeenCalledWith(...);

or

const consoleErrorFn = jest.spyOn(console, 'error').mockImplementation(() => {});
...
expect(consoleErrorFn).toHaveBeenCalledWith(...);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll admit it. Copilot wrote this one 😅

// Act
signOut();
// Assert
expect(consoleErrorFn).toHaveBeenCalledWith(
'Signing out with B2C failed. Falling back on local signout',
expect.any(Error)
);
expect(removeUserFromLocalStateFn).toHaveBeenCalled();
expect(goToRootFn).toHaveBeenCalledWith('root');
});
});
108 changes: 108 additions & 0 deletions src/auth/auth-events/signout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { sessionTimedOutErrorMessage } from 'src/auth/auth-errors';
import { logoutCallbackLinkName } from 'src/auth/Logout';
import { removeUserFromLocalState } from 'src/auth/oidc-broker';
import { Ajax } from 'src/libs/ajax';
import { getSessionStorage } from 'src/libs/browser-storage';
import Events, { MetricsEventName } from 'src/libs/events';
import * as Nav from 'src/libs/nav';
import { notify, sessionTimeoutProps } from 'src/libs/notifications';
import {
authStore,
azureCookieReadyStore,
cookieReadyStore,
MetricState,
metricStore,
oidcStore,
TokenMetadata,
userStore,
} from 'src/libs/state';
import * as Utils from 'src/libs/utils';
import { DEFAULT, getTimestampMetricLabel, switchCase } from 'src/libs/utils';

export type SignOutCause =
| 'requested'
| 'disabled'
| 'declinedTos'
| 'expiredRefreshToken'
| 'errorRefreshingAuthToken'
| 'idleStatusMonitor'
| 'unspecified';

export const signOut = (cause: SignOutCause = 'unspecified'): void => {
// TODO: invalidate runtime cookies https://broadworkbench.atlassian.net/browse/IA-3498
sendSignOutMetrics(cause);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as we're here, since this has caused problems before, would it be worth adding a comment that sendSignOutMetrics should not be awaited, since it will fail if the sign out is due to session expiration?

if (cause === 'expiredRefreshToken' || cause === 'errorRefreshingAuthToken') {
notify('info', sessionTimedOutErrorMessage, sessionTimeoutProps);
}

try {
const userManager = oidcStore.get().userManager;
const redirectUrl = `${window.location.origin}/${Nav.getLink(logoutCallbackLinkName)}`;
// This will redirect to the logout callback page, which calls `userSignedOut` and then redirects to the homepage.
userManager!.signoutRedirect({ post_logout_redirect_uri: redirectUrl });
} catch (e: unknown) {
console.error('Signing out with B2C failed. Falling back on local signout', e);
userSignedOut(true);
Nav.goToPath('root');
}
};

const sendSignOutMetrics = async (cause: SignOutCause): Promise<void> => {
const eventToFire: MetricsEventName = switchCase<SignOutCause, MetricsEventName>(
cause,
['requested', () => Events.user.signOut.requested],
['disabled', () => Events.user.signOut.disabled],
['declinedTos', () => Events.user.signOut.declinedTos],
['expiredRefreshToken', () => Events.user.signOut.expiredRefreshToken],
['errorRefreshingAuthToken', () => Events.user.signOut.errorRefreshingAuthToken],
['idleStatusMonitor', () => Events.user.signOut.idleStatusMonitor],
['unspecified', () => Events.user.signOut.unspecified],
[DEFAULT, () => Events.user.signOut.unspecified]
);
const sessionEndTime: number = Date.now();
const metricStoreState: MetricState = metricStore.get();
const tokenMetadata: TokenMetadata = metricStoreState.authTokenMetadata;

await Ajax().Metrics.captureEvent(eventToFire, {
sessionEndTime: Utils.makeCompleteDate(sessionEndTime),
sessionDurationInSeconds:
metricStoreState.sessionStartTime < 0 ? undefined : (sessionEndTime - metricStoreState.sessionStartTime) / 1000.0,
authTokenCreatedAt: getTimestampMetricLabel(tokenMetadata.createdAt),
authTokenExpiresAt: getTimestampMetricLabel(tokenMetadata.expiresAt),
totalAuthTokensUsedThisSession: metricStoreState.authTokenMetadata.totalTokensUsedThisSession,
totalAuthTokenLoadAttemptsThisSession: metricStoreState.authTokenMetadata.totalTokenLoadAttemptsThisSession,
});
};

export const userSignedOut = (redirectFailed = false) => {
cookieReadyStore.reset();
azureCookieReadyStore.reset();
getSessionStorage().clear();

if (redirectFailed) {
removeUserFromLocalState();
}

const { cookiesAccepted } = authStore.get();

authStore.reset();
authStore.update((state) => ({
...state,
signInStatus: 'signedOut',
// TODO: If allowed, this should be moved to the cookie store
// Load whether a user has input a cookie acceptance in a previous session on this system,
// or whether they input cookie acceptance previously in this session
cookiesAccepted,
}));
oidcStore.update((state) => ({
...state,
user: undefined,
}));
const anonymousId: string | undefined = metricStore.get().anonymousId;
metricStore.reset();
metricStore.update((state) => ({
...state,
anonymousId,
}));
userStore.reset();
};
Loading
Loading