-
Notifications
You must be signed in to change notification settings - Fork 21
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
Changes from 5 commits
66cfdc7
c2ec60e
9d34969
39c1ba5
d1cc9a6
63602f2
eb9c3ec
e8a9e0f
de032a0
81ad969
d5ab381
da5b7c7
fbe66da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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([]); | ||
}); | ||
}); |
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 />; | ||
}; | ||
|
||
export const navPaths = [ | ||
{ | ||
name: logoutCallbackLinkName, | ||
path: '/logout', | ||
component: Logout, | ||
title: 'Logout', | ||
}, | ||
]; |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Instead, it could be jest.spyOn(console, 'error').mockImplementation(() => {});
...
expect(console.error).toHaveBeenCalledWith(...); or const consoleErrorFn = jest.spyOn(console, 'error').mockImplementation(() => {});
...
expect(consoleErrorFn).toHaveBeenCalledWith(...); There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'); | ||
}); | ||
}); |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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(); | ||
}; |
There was a problem hiding this comment.
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.