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 { SignOutPage } from 'src/auth/signout/SignOutPage';
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 === 'signout-callback', () => h(SignOutPage)],
[signInStatus === 'uninitialized' && !isPublic, authspinner],
[signInStatus === 'signedOut' && !isPublic, () => h(SignIn)],
[userMustRegister, () => h(Register)],
Expand Down
85 changes: 2 additions & 83 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,29 @@
import { DEFAULT, switchCase } from '@terra-ui-packages/core-utils';
import { parseJSON } from 'date-fns/fp';
import jwtDecode, { JwtPayload } from 'jwt-decode';
import _ from 'lodash/fp';
import { sessionTimedOutErrorMessage } from 'src/auth/auth-errors';
import {
B2cIdTokenClaims,
getCurrentOidcUser,
initializeOidcUserManager,
oidcSignIn,
OidcSignInArgs,
OidcUser,
revokeTokens,
} from 'src/auth/oidc-broker';
import { cookiesAcceptedKey } from 'src/components/CookieWarning';
import { Ajax } from 'src/libs/ajax';
import { fetchOk } from 'src/libs/ajax/ajax-common';
import { SamUserAttributes } from 'src/libs/ajax/User';
import { getSessionStorage } from 'src/libs/browser-storage';
import { withErrorIgnoring, withErrorReporting } from 'src/libs/error';
import Events, { captureAppcuesEvent, MetricsEventName } from 'src/libs/events';
import * as Nav from 'src/libs/nav';
import { clearNotification, notify, sessionTimeoutProps } from 'src/libs/notifications';
import { clearNotification, sessionTimeoutProps } from 'src/libs/notifications';
import { getLocalPref, getLocalPrefForUserId, setLocalPref } from 'src/libs/prefs';
import allProviders from 'src/libs/providers';
import {
asyncImportJobStore,
AuthState,
authStore,
azureCookieReadyStore,
cookieReadyStore,
getTerraUser,
MetricState,
metricStore,
oidcStore,
requesterPaysProjectStore,
Expand All @@ -42,6 +35,7 @@ import {
workspaceStore,
} from 'src/libs/state';
import * as Utils from 'src/libs/utils';
import { getTimestampMetricLabel } from 'src/libs/utils';
import { v4 as uuid } from 'uuid';

export const getAuthToken = (): string | undefined => {
Expand All @@ -56,42 +50,6 @@ export const getAuthTokenFromLocalStorage = async (): Promise<string | undefined
return oidcUser?.access_token;
};

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

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 sendRetryMetric = () => {
Ajax().Metrics.captureEvent(Events.user.authToken.load.retry, {});
};
Expand All @@ -100,42 +58,6 @@ export const sendAuthTokenDesyncMetric = () => {
Ajax().Metrics.captureEvent(Events.user.authToken.desync, {});
};

export const signOut = (cause: SignOutCause = 'unspecified'): void => {
sendSignOutMetrics(cause);
if (cause === 'expiredRefreshToken' || cause === 'errorRefreshingAuthToken') {
notify('info', sessionTimedOutErrorMessage, sessionTimeoutProps);
}
// TODO: invalidate runtime cookies https://broadworkbench.atlassian.net/browse/IA-3498
cookieReadyStore.reset();
azureCookieReadyStore.reset();
getSessionStorage().clear();

revokeTokens();

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();
};

export const signIn = async (includeBillingScope = false): Promise<OidcUser> => {
// Here, we update `userJustSignedIn` to true, so that we that the user became authenticated via the "Sign In" button.
// This is necessary to differentiate signing in vs reloading or opening a new tab.
Expand Down Expand Up @@ -325,9 +247,6 @@ const getOldAuthTokenLabels = (oldAuthTokenMetadata: TokenMetadata) => {
oldAuthTokenMetadata.createdAt < 0 ? undefined : Date.now() - oldAuthTokenMetadata.createdAt / 1000.0,
};
};
const getTimestampMetricLabel = (timestamp: number): string | undefined => {
return timestamp < 0 ? undefined : Utils.formatTimestampInSeconds(timestamp);
};

export const hasBillingScope = (): boolean => authStore.get().hasGcpBillingScopeThroughB2C === true;

Expand Down
3 changes: 2 additions & 1 deletion src/auth/oidc-broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const getOidcConfig = () => {
const metadata = {
authorization_endpoint: `${getConfig().orchestrationUrlRoot}/oauth2/authorize`,
token_endpoint: `${getConfig().orchestrationUrlRoot}/oauth2/token`,
end_session_endpoint: `${getConfig().orchestrationUrlRoot}/oauth2/logout`,
};
return {
authority: `${getConfig().orchestrationUrlRoot}/oauth2/authorize`,
Expand Down Expand Up @@ -105,7 +106,7 @@ export const oidcSignIn = async (args: OidcSignInArgs): Promise<OidcUser | null>
authInstance.signinSilent(extraArgs);
};

export const revokeTokens = async (): Promise<void> => {
export const removeUserFromLocalState = async (): Promise<void> => {
// send back auth instance, so we can use it for remove and clear stale state
const auth: AuthContextProps = getAuthInstance();
auth.removeUser();
Expand Down
41 changes: 41 additions & 0 deletions src/auth/signout/SignOutPage.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 { OidcUser } from 'src/auth/oidc-broker';
import { SignOutPage } from 'src/auth/signout/SignOutPage';
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('SignOutPage', () => {
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(<SignOutPage />);
// 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([]);
});
});
25 changes: 25 additions & 0 deletions src/auth/signout/SignOutPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { userSignedOut } from 'src/auth/signout/sign-out';
import * as Nav from 'src/libs/nav';

export const signOutCallbackLinkName = 'signout-callback';
export const SignOutPage = () => {
useEffect(() => {
try {
userSignedOut();
} catch (e) {
console.error(e);
}
Nav.goToPath('root');
}, []);
return null;
};

export const navPaths = [
{
name: signOutCallbackLinkName,
path: '/signout',
component: SignOutPage,
title: 'SignOut',
},
];
Loading
Loading