Skip to content

Commit

Permalink
Merge pull request #56604 from Expensify/michal-signout-redirect
Browse files Browse the repository at this point in the history
[InternalQA] Redirect to OldDot to clear cookies
  • Loading branch information
mjasikowski authored Mar 5, 2025
2 parents e96b274 + 9b6fda0 commit 237759d
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 82 deletions.
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,7 @@ const CONST = {
ADMIN_DOMAINS_URL: 'admin_domains',
INBOX: 'inbox',
POLICY_CONNECTIONS_URL: (policyID: string) => `policy?param={"policyID":"${policyID}"}#connections`,
SIGN_OUT: 'signout',
},

EXPENSIFY_POLICY_DOMAIN,
Expand Down
4 changes: 2 additions & 2 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ const WRITE_COMMANDS = {
VERIFY_IDENTITY: 'VerifyIdentity',
ACCEPT_WALLET_TERMS: 'AcceptWalletTerms',
ANSWER_QUESTIONS_FOR_WALLET: 'AnswerQuestionsForWallet',
LOG_OUT: 'LogOut',
REQUEST_ACCOUNT_VALIDATION_LINK: 'RequestAccountValidationLink',
REQUEST_NEW_VALIDATE_CODE: 'RequestNewValidateCode',
SIGN_IN_WITH_APPLE: 'SignInWithApple',
Expand Down Expand Up @@ -533,7 +532,6 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.VERIFY_IDENTITY]: Parameters.VerifyIdentityParams;
[WRITE_COMMANDS.ACCEPT_WALLET_TERMS]: Parameters.AcceptWalletTermsParams;
[WRITE_COMMANDS.ANSWER_QUESTIONS_FOR_WALLET]: Parameters.AnswerQuestionsForWalletParams;
[WRITE_COMMANDS.LOG_OUT]: Parameters.LogOutParams;
[WRITE_COMMANDS.REQUEST_ACCOUNT_VALIDATION_LINK]: Parameters.RequestAccountValidationLinkParams;
[WRITE_COMMANDS.REQUEST_NEW_VALIDATE_CODE]: Parameters.RequestNewValidateCodeParams;
[WRITE_COMMANDS.SIGN_IN_WITH_APPLE]: Parameters.BeginAppleSignInParams;
Expand Down Expand Up @@ -1091,6 +1089,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = {

// PayMoneyRequestOnSearch only works online (pattern C) and we need to play the success sound only when the request is successful
PAY_MONEY_REQUEST_ON_SEARCH: 'PayMoneyRequestOnSearch',
LOG_OUT: 'LogOut',
} as const;

type SideEffectRequestCommand = ValueOf<typeof SIDE_EFFECT_REQUEST_COMMANDS>;
Expand All @@ -1111,6 +1110,7 @@ type SideEffectRequestCommandParameters = {
[SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP]: Parameters.ConnectPolicyToQuickBooksDesktopParams;
[SIDE_EFFECT_REQUEST_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY]: Parameters.BankAccountCreateCorpayParams;
[SIDE_EFFECT_REQUEST_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH]: Parameters.PayMoneyRequestOnSearchParams;
[SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT]: Parameters.LogOutParams;
};

type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters;
Expand Down
147 changes: 76 additions & 71 deletions src/libs/actions/Session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import * as PersistedRequests from '@libs/actions/PersistedRequests';
import {resolveDuplicationConflictAction} from '@libs/actions/RequestConflictUtils';
import * as API from '@libs/API';
import type {
AuthenticatePusherParams,
Expand All @@ -25,6 +24,7 @@ import type {
} from '@libs/API/parameters';
import type SignInUserParams from '@libs/API/parameters/SignInUserParams';
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import asyncOpenURL from '@libs/asyncOpenURL';
import * as Authentication from '@libs/Authentication';
import * as ErrorUtils from '@libs/ErrorUtils';
import Fullstory from '@libs/Fullstory';
Expand Down Expand Up @@ -56,6 +56,7 @@ import type {HybridAppRoute, Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type Credentials from '@src/types/onyx/Credentials';
import type Response from '@src/types/onyx/Response';
import type Session from '@src/types/onyx/Session';
import type {AutoAuthState} from '@src/types/onyx/Session';
import clearCache from './clearCache';
Expand Down Expand Up @@ -178,7 +179,7 @@ function signInWithSupportAuthToken(authToken: string) {
/**
* Clears the Onyx store and redirects user to the sign in page
*/
function signOut() {
function signOut(): Promise<void | Response> {
Log.info('Flushing logs before signing out', true, {}, true);
const params = {
// Send current authToken because we will immediately clear it once triggering this command
Expand All @@ -189,14 +190,8 @@ function signOut() {
shouldRetry: false,
};

API.write(
WRITE_COMMANDS.LOG_OUT,
params,
{},
{
checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.LOG_OUT),
},
);
// eslint-disable-next-line rulesdir/no-api-side-effects-method
return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, params, {});
}

/**
Expand Down Expand Up @@ -228,71 +223,81 @@ function isExpiredSession(sessionCreationDate: number): boolean {
function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, killHybridApp = true) {
Log.info('Redirecting to Sign In because signOut() was called');
hideContextMenu(false);
if (!isAnonymousUser()) {
// In the HybridApp, we want the Old Dot to handle the sign out process
if (NativeModules.HybridAppModule && killHybridApp) {
NativeModules.HybridAppModule.closeReactNativeApp(true, false);
return;
}
// We'll only call signOut if we're not stashing the session and this is not a supportal session,
// otherwise we'll call the API to invalidate the autogenerated credentials used for infinite
// session.
const isSupportal = isSupportAuthToken();
if (!isSupportal && !shouldStashSession) {
signOut();
}

// The function redirectToSignIn will clear the whole storage, so let's create our onyx params
// updates for the credentials before we call it
let onyxSetParams = {};

// If we are not currently using a support token, and we received stashSession as true, we need to
// store the credentials so the user doesn't need to login again after they finish their supportal
// action. This needs to computed before we call `redirectToSignIn`
if (!isSupportal && shouldStashSession) {
onyxSetParams = {
[ONYXKEYS.STASHED_CREDENTIALS]: credentials,
[ONYXKEYS.STASHED_SESSION]: session,
};
}
// If this is a supportal token, and we've received the parameters to stashSession as true, and
// we already have a stashedSession, that means we are supportaled, currently supportaling
// into another account and we want to keep the stashed data from the original account.
if (isSupportal && shouldStashSession && hasStashedSession()) {
onyxSetParams = {
[ONYXKEYS.STASHED_CREDENTIALS]: stashedCredentials,
[ONYXKEYS.STASHED_SESSION]: stashedSession,
};
}
// Now if this is a supportal access, we do not want to stash the current session and we have a
// stashed session, then we need to restore the stashed session instead of completely logging out
if (isSupportal && !shouldStashSession && hasStashedSession()) {
onyxSetParams = {
[ONYXKEYS.CREDENTIALS]: stashedCredentials,
[ONYXKEYS.SESSION]: stashedSession,
};
}
if (isSupportal && !shouldStashSession && !hasStashedSession()) {
Log.info('No stashed session found for supportal access, clearing the session');
if (isAnonymousUser()) {
if (!Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) {
if (shouldResetToHome) {
Navigation.resetToHome();
}
Navigation.navigate(ROUTES.SIGN_IN_MODAL);
Linking.getInitialURL().then((url) => {
const reportID = getReportIDFromLink(url);
if (reportID) {
Onyx.merge(ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID, reportID);
}
});
}
redirectToSignIn().then(() => {
return;
}

// In the HybridApp, we want the Old Dot to handle the sign out process
if (NativeModules.HybridAppModule && killHybridApp) {
NativeModules.HybridAppModule.closeReactNativeApp(true, false);
return;
}
// We'll only call signOut if we're not stashing the session and this is not a supportal session,
// otherwise we'll call the API to invalidate the autogenerated credentials used for infinite
// session.
const isSupportal = isSupportAuthToken();
const signOutPromise: Promise<void | Response> = !isSupportal && !shouldStashSession ? signOut() : Promise.resolve();

// The function redirectToSignIn will clear the whole storage, so let's create our onyx params
// updates for the credentials before we call it
let onyxSetParams = {};

// If we are not currently using a support token, and we received stashSession as true, we need to
// store the credentials so the user doesn't need to login again after they finish their supportal
// action. This needs to computed before we call `redirectToSignIn`
if (!isSupportal && shouldStashSession) {
onyxSetParams = {
[ONYXKEYS.STASHED_CREDENTIALS]: credentials,
[ONYXKEYS.STASHED_SESSION]: session,
};
}
// If this is a supportal token, and we've received the parameters to stashSession as true, and
// we already have a stashedSession, that means we are supportaled, currently supportaling
// into another account and we want to keep the stashed data from the original account.
if (isSupportal && shouldStashSession && hasStashedSession()) {
onyxSetParams = {
[ONYXKEYS.STASHED_CREDENTIALS]: stashedCredentials,
[ONYXKEYS.STASHED_SESSION]: stashedSession,
};
}
// Now if this is a supportal access, we do not want to stash the current session and we have a
// stashed session, then we need to restore the stashed session instead of completely logging out
if (isSupportal && !shouldStashSession && hasStashedSession()) {
onyxSetParams = {
[ONYXKEYS.CREDENTIALS]: stashedCredentials,
[ONYXKEYS.SESSION]: stashedSession,
};
}
if (isSupportal && !shouldStashSession && !hasStashedSession()) {
Log.info('No stashed session found for supportal access, clearing the session');
}

// Wait for signOut (if called), then redirect and update Onyx.
signOutPromise
.then((response) => {
Onyx.multiSet(onyxSetParams);
});
} else {
if (Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) {
return;
}
if (shouldResetToHome) {
Navigation.resetToHome();
}
Navigation.navigate(ROUTES.SIGN_IN_MODAL);
Linking.getInitialURL().then((url) => {
const reportID = getReportIDFromLink(url);
if (reportID) {
Onyx.merge(ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID, reportID);

if (response?.hasOldDotAuthCookies) {
Log.info('Redirecting to OldDot sign out');
asyncOpenURL(redirectToSignIn(), `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${CONST.OLDDOT_URLS.SIGN_OUT}`, true, true);
} else {
redirectToSignIn();
}
});
}
})
.catch((error: string) => Log.warn('Error during sign out process:', error));
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/types/onyx/Response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ type Response = {

/** The ID of the original user (returned when in delegate mode) */
requesterID?: number;

/** If there are httponly OldDot authentication cookies stored */
hasOldDotAuthCookies?: boolean;
};

export default Response;
80 changes: 71 additions & 9 deletions tests/actions/SessionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import type {OnyxEntry} from 'react-native-onyx';
import {confirmReadyToOpenApp, openApp, reconnectApp} from '@libs/actions/App';
import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager';
import {getAll as getAllPersistedRequests} from '@libs/actions/PersistedRequests';
// eslint-disable-next-line no-restricted-syntax
import * as SignInRedirect from '@libs/actions/SignInRedirect';
import {WRITE_COMMANDS} from '@libs/API/types';
import asyncOpenURL from '@libs/asyncOpenURL';
import HttpUtils from '@libs/HttpUtils';
import PushNotification from '@libs/Notification/PushNotification';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
import '@libs/Notification/PushNotification/subscribePushNotification';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import * as SessionUtil from '@src/libs/actions/Session';
import {signOutAndRedirectToSignIn} from '@src/libs/actions/Session';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Credentials, Session} from '@src/types/onyx';
import * as TestHelper from '../utils/TestHelper';
Expand All @@ -23,6 +28,9 @@ HttpUtils.xhr = jest.fn<typeof HttpUtils.xhr>();
// Mocked to ensure push notifications are subscribed/unsubscribed as the session changes
jest.mock('@libs/Notification/PushNotification');

// Mocked to check SignOutAndRedirectToSignIn behavior
jest.mock('@libs/asyncOpenURL');

Onyx.init({
keys: ONYXKEYS,
});
Expand Down Expand Up @@ -207,23 +215,77 @@ describe('Session', () => {
expect(getAllPersistedRequests().length).toBe(0);
});

test('LogOut should replace same requests from the queue instead of adding new one', async () => {
test('SignOut should return a promise with response containing hasOldDotAuthCookies', async () => {
await TestHelper.signInWithTestUser();
await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});

SessionUtil.signOut();
SessionUtil.signOut();
SessionUtil.signOut();
SessionUtil.signOut();
SessionUtil.signOut();
(HttpUtils.xhr as jest.MockedFunction<typeof HttpUtils.xhr>)
// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
Promise.resolve({
jsonCode: CONST.JSON_CODE.SUCCESS,
hasOldDotAuthCookies: true,
}),
);

const signOutPromise = SessionUtil.signOut();

await waitForBatchedUpdates();
expect(signOutPromise).toBeInstanceOf(Promise);

expect(getAllPersistedRequests().length).toBe(1);
expect(getAllPersistedRequests().at(0)?.command).toBe(WRITE_COMMANDS.LOG_OUT);
expect(await signOutPromise).toStrictEqual({
jsonCode: CONST.JSON_CODE.SUCCESS,
hasOldDotAuthCookies: true,
});

await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});

expect(getAllPersistedRequests().length).toBe(0);
});

test('SignOutAndRedirectToSignIn should redirect to OldDot when LogOut returns truthy hasOldDotAuthCookies', async () => {
await TestHelper.signInWithTestUser();
await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});

(HttpUtils.xhr as jest.MockedFunction<typeof HttpUtils.xhr>)
// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
Promise.resolve({
jsonCode: CONST.JSON_CODE.SUCCESS,
hasOldDotAuthCookies: true,
}),
);

const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve());

signOutAndRedirectToSignIn();

await waitForBatchedUpdates();

expect(asyncOpenURL).toHaveBeenCalledWith(Promise.resolve(), `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${CONST.OLDDOT_URLS.SIGN_OUT}`, true, true);
expect(redirectToSignInSpy).toHaveBeenCalled();
jest.clearAllMocks();
});

test('SignOutAndRedirectToSignIn should not redirect to OldDot when LogOut return falsy hasOldDotAuthCookies', async () => {
await TestHelper.signInWithTestUser();
await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});

(HttpUtils.xhr as jest.MockedFunction<typeof HttpUtils.xhr>)
// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
Promise.resolve({
jsonCode: CONST.JSON_CODE.SUCCESS,
}),
);

const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve());

signOutAndRedirectToSignIn();

await waitForBatchedUpdates();

expect(asyncOpenURL).not.toHaveBeenCalled();
expect(redirectToSignInSpy).toHaveBeenCalled();
jest.clearAllMocks();
});
});

0 comments on commit 237759d

Please sign in to comment.