Skip to content

Commit 8d69d60

Browse files
Merge pull request #52727 from zirgulis/fix-reauthentication
[NoQA] Fix reauthentication
2 parents 26526c6 + 96f986d commit 8d69d60

File tree

8 files changed

+255
-210
lines changed

8 files changed

+255
-210
lines changed

src/libs/Authentication.ts

+33-44
Original file line numberDiff line numberDiff line change
@@ -62,55 +62,44 @@ function reauthenticate(command = ''): Promise<void> {
6262
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
6363
partnerUserID: credentials?.autoGeneratedLogin,
6464
partnerUserSecret: credentials?.autoGeneratedPassword,
65-
})
66-
.then((response) => {
67-
if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) {
68-
// If authentication fails, then the network can be unpaused
69-
NetworkStore.setIsAuthenticating(false);
65+
}).then((response) => {
66+
if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) {
67+
// When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they
68+
// have a spotty connection and will need to retry reauthenticate when they come back online. Error so it can be handled by the retry mechanism.
69+
throw new Error('Unable to retry Authenticate request');
70+
}
7071

71-
// When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they
72-
// have a spotty connection and will need to try to reauthenticate when they come back online. We will error so it
73-
// can be handled by callers of reauthenticate().
74-
throw new Error('Unable to retry Authenticate request');
75-
}
76-
77-
// If authentication fails and we are online then log the user out
78-
if (response.jsonCode !== 200) {
79-
const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response);
80-
NetworkStore.setIsAuthenticating(false);
81-
Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {
82-
command,
83-
error: errorMessage,
84-
});
85-
redirectToSignIn(errorMessage);
86-
return;
87-
}
72+
// If authentication fails and we are online then log the user out
73+
if (response.jsonCode !== 200) {
74+
const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response);
75+
NetworkStore.setIsAuthenticating(false);
76+
Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {
77+
command,
78+
error: errorMessage,
79+
});
80+
redirectToSignIn(errorMessage);
81+
return;
82+
}
8883

89-
// If we reauthenticated due to an expired delegate token, restore the delegate's original account.
90-
// This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as.
91-
if (Delegate.isConnectedAsDelegate()) {
92-
Log.info('Reauthenticated while connected as a delegate. Restoring original account.');
93-
Delegate.restoreDelegateSession(response);
94-
return;
95-
}
84+
// If we reauthenticated due to an expired delegate token, restore the delegate's original account.
85+
// This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as.
86+
if (Delegate.isConnectedAsDelegate()) {
87+
Log.info('Reauthenticated while connected as a delegate. Restoring original account.');
88+
Delegate.restoreDelegateSession(response);
89+
return;
90+
}
9691

97-
// Update authToken in Onyx and in our local variables so that API requests will use the new authToken
98-
updateSessionAuthTokens(response.authToken, response.encryptedAuthToken);
92+
// Update authToken in Onyx and in our local variables so that API requests will use the new authToken
93+
updateSessionAuthTokens(response.authToken, response.encryptedAuthToken);
9994

100-
// Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into
101-
// reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not
102-
// enough to do the updateSessionAuthTokens() call above.
103-
NetworkStore.setAuthToken(response.authToken ?? null);
95+
// Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into
96+
// reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not
97+
// enough to do the updateSessionAuthTokens() call above.
98+
NetworkStore.setAuthToken(response.authToken ?? null);
10499

105-
// The authentication process is finished so the network can be unpaused to continue processing requests
106-
NetworkStore.setIsAuthenticating(false);
107-
})
108-
.catch((error) => {
109-
// In case the authenticate call throws error, we need to sign user out as most likely they are missing credentials
110-
NetworkStore.setIsAuthenticating(false);
111-
Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {error});
112-
redirectToSignIn('passwordForm.error.fallback');
113-
});
100+
// The authentication process is finished so the network can be unpaused to continue processing requests
101+
NetworkStore.setIsAuthenticating(false);
102+
});
114103
}
115104

116105
export {reauthenticate, Authenticate};

src/libs/Middleware/Reauthentication.ts

+32-3
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,61 @@
1+
import redirectToSignIn from '@libs/actions/SignInRedirect';
12
import * as Authentication from '@libs/Authentication';
23
import Log from '@libs/Log';
34
import * as MainQueue from '@libs/Network/MainQueue';
45
import * as NetworkStore from '@libs/Network/NetworkStore';
6+
import type {RequestError} from '@libs/Network/SequentialQueue';
57
import NetworkConnection from '@libs/NetworkConnection';
68
import * as Request from '@libs/Request';
9+
import RequestThrottle from '@libs/RequestThrottle';
710
import CONST from '@src/CONST';
811
import type Middleware from './types';
912

1013
// We store a reference to the active authentication request so that we are only ever making one request to authenticate at a time.
1114
let isAuthenticating: Promise<void> | null = null;
1215

16+
const reauthThrottle = new RequestThrottle('Re-authentication');
17+
1318
function reauthenticate(commandName?: string): Promise<void> {
1419
if (isAuthenticating) {
1520
return isAuthenticating;
1621
}
1722

18-
isAuthenticating = Authentication.reauthenticate(commandName)
23+
isAuthenticating = retryReauthenticate(commandName)
1924
.then((response) => {
20-
isAuthenticating = null;
2125
return response;
2226
})
2327
.catch((error) => {
24-
isAuthenticating = null;
2528
throw error;
29+
})
30+
.finally(() => {
31+
isAuthenticating = null;
2632
});
2733

2834
return isAuthenticating;
2935
}
3036

37+
function retryReauthenticate(commandName?: string): Promise<void> {
38+
return Authentication.reauthenticate(commandName).catch((error: RequestError) => {
39+
return reauthThrottle
40+
.sleep(error, 'Authenticate')
41+
.then(() => retryReauthenticate(commandName))
42+
.catch(() => {
43+
NetworkStore.setIsAuthenticating(false);
44+
Log.hmmm('Redirecting to Sign In because we failed to reauthenticate after multiple attempts', {error});
45+
redirectToSignIn('passwordForm.error.fallback');
46+
});
47+
});
48+
}
49+
50+
// Used in tests to reset the reauthentication state
51+
function resetReauthentication(): void {
52+
// Resets the authentication state flag to allow new reauthentication flows to start fresh
53+
isAuthenticating = null;
54+
55+
// Clears any pending reauth timeouts set by reauthThrottle.sleep()
56+
reauthThrottle.clear();
57+
}
58+
3159
const Reauthentication: Middleware = (response, request, isFromSequentialQueue) =>
3260
response
3361
.then((data) => {
@@ -118,3 +146,4 @@ const Reauthentication: Middleware = (response, request, isFromSequentialQueue)
118146
});
119147

120148
export default Reauthentication;
149+
export {reauthenticate, resetReauthentication, reauthThrottle};

src/libs/Network/SequentialQueue.ts

+22-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx';
22
import * as ActiveClientManager from '@libs/ActiveClientManager';
33
import Log from '@libs/Log';
44
import * as Request from '@libs/Request';
5-
import * as RequestThrottle from '@libs/RequestThrottle';
5+
import RequestThrottle from '@libs/RequestThrottle';
66
import * as PersistedRequests from '@userActions/PersistedRequests';
77
import * as QueuedOnyxUpdates from '@userActions/QueuedOnyxUpdates';
88
import CONST from '@src/CONST';
@@ -28,6 +28,7 @@ resolveIsReadyPromise?.();
2828
let isSequentialQueueRunning = false;
2929
let currentRequestPromise: Promise<void> | null = null;
3030
let isQueuePaused = false;
31+
const sequentialQueueRequestThrottle = new RequestThrottle('SequentialQueue');
3132

3233
/**
3334
* Puts the queue into a paused state so that no requests will be processed
@@ -99,7 +100,7 @@ function process(): Promise<void> {
99100

100101
Log.info('[SequentialQueue] Removing persisted request because it was processed successfully.', false, {request: requestToProcess});
101102
PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess);
102-
RequestThrottle.clear();
103+
sequentialQueueRequestThrottle.clear();
103104
return process();
104105
})
105106
.catch((error: RequestError) => {
@@ -108,17 +109,18 @@ function process(): Promise<void> {
108109
if (error.name === CONST.ERROR.REQUEST_CANCELLED || error.message === CONST.ERROR.DUPLICATE_RECORD) {
109110
Log.info("[SequentialQueue] Removing persisted request because it failed and doesn't need to be retried.", false, {error, request: requestToProcess});
110111
PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess);
111-
RequestThrottle.clear();
112+
sequentialQueueRequestThrottle.clear();
112113
return process();
113114
}
114115
PersistedRequests.rollbackOngoingRequest();
115-
return RequestThrottle.sleep(error, requestToProcess.command)
116+
return sequentialQueueRequestThrottle
117+
.sleep(error, requestToProcess.command)
116118
.then(process)
117119
.catch(() => {
118120
Onyx.update(requestToProcess.failureData ?? []);
119121
Log.info('[SequentialQueue] Removing persisted request because it failed too many times.', false, {error, request: requestToProcess});
120122
PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess);
121-
RequestThrottle.clear();
123+
sequentialQueueRequestThrottle.clear();
122124
return process();
123125
});
124126
});
@@ -271,5 +273,19 @@ function waitForIdle(): Promise<unknown> {
271273
return isReadyPromise;
272274
}
273275

274-
export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause, process};
276+
/**
277+
* Clear any pending requests during test runs
278+
* This is to prevent previous requests interfering with other tests
279+
*/
280+
function resetQueue(): void {
281+
isSequentialQueueRunning = false;
282+
currentRequestPromise = null;
283+
isQueuePaused = false;
284+
isReadyPromise = new Promise((resolve) => {
285+
resolveIsReadyPromise = resolve;
286+
});
287+
resolveIsReadyPromise?.();
288+
}
289+
290+
export {flush, getCurrentRequest, isRunning, isPaused, push, waitForIdle, pause, unpause, process, resetQueue, sequentialQueueRequestThrottle};
275291
export type {RequestError};

src/libs/Network/index.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,28 @@ import pkg from '../../../package.json';
66
import * as MainQueue from './MainQueue';
77
import * as SequentialQueue from './SequentialQueue';
88

9+
// React Native uses a number for the timer id, but Web/NodeJS uses a Timeout object
10+
let processQueueInterval: NodeJS.Timeout | number;
11+
912
// We must wait until the ActiveClientManager is ready so that we ensure only the "leader" tab processes any persisted requests
1013
ActiveClientManager.isReady().then(() => {
1114
SequentialQueue.flush();
1215

1316
// Start main queue and process once every n ms delay
14-
setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
17+
processQueueInterval = setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
1518
});
1619

20+
/**
21+
* Clear any existing intervals during test runs
22+
* This is to prevent previous intervals interfering with other tests
23+
*/
24+
function clearProcessQueueInterval() {
25+
if (!processQueueInterval) {
26+
return;
27+
}
28+
clearInterval(processQueueInterval);
29+
}
30+
1731
/**
1832
* Perform a queued post request
1933
*/
@@ -55,7 +69,4 @@ function post(command: string, data: Record<string, unknown> = {}, type = CONST.
5569
});
5670
}
5771

58-
export {
59-
// eslint-disable-next-line import/prefer-default-export
60-
post,
61-
};
72+
export {post, clearProcessQueueInterval};

src/libs/RequestThrottle.ts

+46-30
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,57 @@ import Log from './Log';
33
import type {RequestError} from './Network/SequentialQueue';
44
import {generateRandomInt} from './NumberUtils';
55

6-
let requestWaitTime = 0;
7-
let requestRetryCount = 0;
6+
class RequestThrottle {
7+
private requestWaitTime = 0;
88

9-
function clear() {
10-
requestWaitTime = 0;
11-
requestRetryCount = 0;
12-
Log.info(`[RequestThrottle] in clear()`);
13-
}
9+
private requestRetryCount = 0;
10+
11+
private timeoutID?: NodeJS.Timeout;
12+
13+
private name: string;
1414

15-
function getRequestWaitTime() {
16-
if (requestWaitTime) {
17-
requestWaitTime = Math.min(requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS);
18-
} else {
19-
requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS);
15+
constructor(name: string) {
16+
this.name = name;
2017
}
21-
return requestWaitTime;
22-
}
2318

24-
function getLastRequestWaitTime() {
25-
return requestWaitTime;
26-
}
19+
clear() {
20+
this.requestWaitTime = 0;
21+
this.requestRetryCount = 0;
22+
if (this.timeoutID) {
23+
Log.info(`[RequestThrottle - ${this.name}] clearing timeoutID: ${String(this.timeoutID)}`);
24+
clearTimeout(this.timeoutID);
25+
this.timeoutID = undefined;
26+
}
27+
Log.info(`[RequestThrottle - ${this.name}] cleared`);
28+
}
2729

28-
function sleep(error: RequestError, command: string): Promise<void> {
29-
requestRetryCount++;
30-
return new Promise((resolve, reject) => {
31-
if (requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) {
32-
const currentRequestWaitTime = getRequestWaitTime();
33-
Log.info(
34-
`[RequestThrottle] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${requestRetryCount}. Wait time: ${currentRequestWaitTime}`,
35-
);
36-
setTimeout(resolve, currentRequestWaitTime);
37-
return;
30+
getRequestWaitTime() {
31+
if (this.requestWaitTime) {
32+
this.requestWaitTime = Math.min(this.requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS);
33+
} else {
34+
this.requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS);
3835
}
39-
reject();
40-
});
36+
return this.requestWaitTime;
37+
}
38+
39+
getLastRequestWaitTime() {
40+
return this.requestWaitTime;
41+
}
42+
43+
sleep(error: RequestError, command: string): Promise<void> {
44+
this.requestRetryCount++;
45+
return new Promise((resolve, reject) => {
46+
if (this.requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) {
47+
const currentRequestWaitTime = this.getRequestWaitTime();
48+
Log.info(
49+
`[RequestThrottle - ${this.name}] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${this.requestRetryCount}. Wait time: ${currentRequestWaitTime}`,
50+
);
51+
this.timeoutID = setTimeout(resolve, currentRequestWaitTime);
52+
} else {
53+
reject();
54+
}
55+
});
56+
}
4157
}
4258

43-
export {clear, getRequestWaitTime, sleep, getLastRequestWaitTime};
59+
export default RequestThrottle;

tests/actions/ReportTest.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe('actions/Report', () => {
5555
const promise = Onyx.clear().then(jest.useRealTimers);
5656
if (getIsUsingFakeTimers()) {
5757
// flushing pending timers
58-
// Onyx.clear() promise is resolved in batch which happends after the current microtasks cycle
58+
// Onyx.clear() promise is resolved in batch which happens after the current microtasks cycle
5959
setImmediate(jest.runOnlyPendingTimers);
6060
}
6161

tests/unit/APITest.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import HttpUtils from '@src/libs/HttpUtils';
88
import * as MainQueue from '@src/libs/Network/MainQueue';
99
import * as NetworkStore from '@src/libs/Network/NetworkStore';
1010
import * as SequentialQueue from '@src/libs/Network/SequentialQueue';
11+
import {sequentialQueueRequestThrottle} from '@src/libs/Network/SequentialQueue';
1112
import * as Request from '@src/libs/Request';
12-
import * as RequestThrottle from '@src/libs/RequestThrottle';
1313
import ONYXKEYS from '@src/ONYXKEYS';
1414
import type ReactNativeOnyxMock from '../../__mocks__/react-native-onyx';
1515
import * as TestHelper from '../utils/TestHelper';
@@ -47,6 +47,7 @@ beforeEach(() => {
4747
MainQueue.clear();
4848
HttpUtils.cancelPendingRequests();
4949
PersistedRequests.clear();
50+
sequentialQueueRequestThrottle.clear();
5051
NetworkStore.checkRequiredData();
5152

5253
// Wait for any Log command to finish and Onyx to fully clear
@@ -242,7 +243,7 @@ describe('APITests', () => {
242243

243244
// We let the SequentialQueue process again after its wait time
244245
return new Promise((resolve) => {
245-
setTimeout(resolve, RequestThrottle.getLastRequestWaitTime());
246+
setTimeout(resolve, sequentialQueueRequestThrottle.getLastRequestWaitTime());
246247
});
247248
})
248249
.then(() => {
@@ -255,7 +256,7 @@ describe('APITests', () => {
255256

256257
// We let the SequentialQueue process again after its wait time
257258
return new Promise((resolve) => {
258-
setTimeout(resolve, RequestThrottle.getLastRequestWaitTime());
259+
setTimeout(resolve, sequentialQueueRequestThrottle.getLastRequestWaitTime());
259260
}).then(waitForBatchedUpdates);
260261
})
261262
.then(() => {

0 commit comments

Comments
 (0)