diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ff79ebaa9adf..40e2397aadc6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -36,6 +36,9 @@ const ONYXKEYS = { /** Boolean flag set whenever we are searching for reports in the server */ RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + /** Boolean flag indicating a SignInWithShortLivedAuthToken request is in flight. RAM-only so an interrupted request never persists a stuck `true` to IndexedDB and blocks future reauth attempts. */ + RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN: 'isAuthenticatingWithShortLivedToken', + /** Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe */ PERSISTED_REQUESTS: 'networkRequestQueue', PERSISTED_ONGOING_REQUESTS: 'networkOngoingRequestQueue', @@ -1557,6 +1560,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean; + [ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.REPORT_LAST_VISIT_TIMES]: OnyxTypes.ReportLastVisitTimes; [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; diff --git a/src/libs/AppState/index.ts b/src/libs/AppState/index.ts index cd12ce459b15..613e90e25d9e 100644 --- a/src/libs/AppState/index.ts +++ b/src/libs/AppState/index.ts @@ -12,6 +12,7 @@ import type {ExtraLoadingContext, GlobalStateSnapshot, NavigationStateInfo, Netw let currentSession: OnyxEntry; let currentNetwork: OnyxEntry; +let currentIsAuthenticatingWithShortLivedToken = false; // We have opted for connectWithoutView here as this is strictly non-UI and only for logging. Onyx.connectWithoutView({ @@ -29,6 +30,14 @@ Onyx.connectWithoutView({ }, }); +// We have opted for connectWithoutView here as this is strictly non-UI and only for logging. +Onyx.connectWithoutView({ + key: ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, + callback: (value) => { + currentIsAuthenticatingWithShortLivedToken = !!value; + }, +}); + /** * Captures current navigation state. */ @@ -54,12 +63,11 @@ function captureNavigationState(): NavigationStateInfo { function captureSessionState(): SessionStateInfo { // Check multiple authentication states to get complete picture const isSessionLoading = !!currentSession?.loading; - const isAuthenticatingWithShortLivedToken = !!currentSession?.isAuthenticatingWithShortLivedToken; const isAuthenticatingFromNetworkStore = isAuthenticatingNetworkStore(); return { isSessionLoading, - isAuthenticatingWithShortLivedToken, + isAuthenticatingWithShortLivedToken: currentIsAuthenticatingWithShortLivedToken, isAuthenticatingFromNetworkStore, }; } diff --git a/src/libs/Reauthentication.ts b/src/libs/Reauthentication.ts index 3a4fffbeb27f..6cedba8598e5 100644 --- a/src/libs/Reauthentication.ts +++ b/src/libs/Reauthentication.ts @@ -36,7 +36,6 @@ let isSupportAuthTokenUsed = false; Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (value) => { - isAuthenticatingWithShortLivedToken = !!value?.isAuthenticatingWithShortLivedToken; isSupportAuthTokenUsed = !!value?.isSupportAuthTokenUsed; Sentry.setUser({ @@ -46,6 +45,14 @@ Onyx.connectWithoutView({ }, }); +// Kept on a RAM-only key so an interrupted SignIn cannot persist a stuck `true` to IndexedDB and block all future reauth attempts. +Onyx.connectWithoutView({ + key: ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, + callback: (value) => { + isAuthenticatingWithShortLivedToken = !!value; + }, +}); + let account: OnyxEntry; // Authentication lib is not connected to any changes on the UI // So it is okay to use connectWithoutView here. diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 1e13afd7785f..914e579ba9d8 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -155,7 +155,9 @@ function setSupportAuthToken(supportAuthToken: string, email: string, accountID: } function getShortLivedLoginParams(isSupportAuthTokenUsed = false, isSAML = false) { - const optimisticData: Array> = [ + const optimisticData: Array< + OnyxUpdate + > = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -171,14 +173,19 @@ function getShortLivedLoginParams(isSupportAuthTokenUsed = false, isSAML = false value: { signedInWithShortLivedAuthToken: true, signedInWithSAML: isSAML, - isAuthenticatingWithShortLivedToken: true, isSupportAuthTokenUsed, }, }, + // Kept on a RAM-only key so an interrupted SignIn cannot persist a stuck `true` to IndexedDB and block all future reauth attempts. + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, + value: true, + }, ]; // Subsequently, we revert it back to the default value of 'signedInWithShortLivedAuthToken' in 'finallyData' to ensure the user is logged out on refresh - const finallyData: Array> = [ + const finallyData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -193,9 +200,13 @@ function getShortLivedLoginParams(isSupportAuthTokenUsed = false, isSAML = false signedInWithShortLivedAuthToken: null, signedInWithSAML: isSAML, isSupportAuthTokenUsed: null, - isAuthenticatingWithShortLivedToken: false, }, }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, + value: false, + }, ]; const failureData: Array> = []; diff --git a/src/setup/index.ts b/src/setup/index.ts index 455c516cc730..9eee0b0fc3de 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -65,6 +65,7 @@ export default function () { ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, + ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts index 00fa15c86080..b997d7503d2b 100644 --- a/src/types/onyx/Session.ts +++ b/src/types/onyx/Session.ts @@ -37,9 +37,6 @@ type Session = { /** User signed in with short lived token */ signedInWithShortLivedAuthToken?: boolean; - /** Indicates whether the user is re-authenticating with shortLivedToken */ - isAuthenticatingWithShortLivedToken?: boolean; - /** User signed in with SAML */ signedInWithSAML?: boolean; diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index fac013420337..1792d690ca3d 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -81,6 +81,43 @@ describe('Session', () => { redirectToSignInSpy.mockRestore(); }); + test('reauthenticate aborts when RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN is true', async () => { + // Given a SignIn with short lived token is currently in flight + await Onyx.set(ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, true); + await waitForBatchedUpdates(); + + const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve()); + + // When reauthenticate is called + const result = await reauthenticate('TestCommand'); + await waitForBatchedUpdates(); + + // Then it aborts cleanly without redirecting to sign in + expect(result).toBe(false); + expect(redirectToSignInSpy).not.toHaveBeenCalled(); + + redirectToSignInSpy.mockRestore(); + }); + + test('reauthenticate proceeds even when a legacy session.isAuthenticatingWithShortLivedToken=true is persisted (recovers stuck users)', async () => { + // Given a session in Onyx that still carries the legacy stuck flag from before the RAM-only migration. + // The Session type no longer declares the field, so cast to write the legacy shape. + await Onyx.merge(ONYXKEYS.SESSION, {isAuthenticatingWithShortLivedToken: true} as unknown as Session); + await waitForBatchedUpdates(); + + const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve()); + + // When reauthenticate is called with no credentials stored + const result = await reauthenticate('TestCommand'); + await waitForBatchedUpdates(); + + // Then the legacy persisted flag does NOT block reauth. Reauth proceeds, finds no credentials, and redirects to sign in. + expect(result).toBe(false); + expect(redirectToSignInSpy).toHaveBeenCalledWith('No credentials available'); + + redirectToSignInSpy.mockRestore(); + }); + test('Authenticate is called with saved credentials when a session expires', async () => { // Given a test user and set of authToken with subscriptions to session and credentials const TEST_USER_LOGIN = 'test@testguy.com';