Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / api / helpers / withApiHandlers.js
blob3021f3afd7ac0f5070c37f8a0efbd04c7e0a173b
1 import { OFFLINE_RETRY_ATTEMPTS_MAX, OFFLINE_RETRY_DELAY, RETRY_ATTEMPTS_MAX, RETRY_DELAY_MAX } from '../../constants';
2 import { API_CUSTOM_ERROR_CODES, HTTP_ERROR_CODES } from '../../errors';
3 import {
4     getDeviceVerificationHeaders,
5     getUIDHeaderValue,
6     getVerificationHeaders,
7     withUIDHeaders,
8 } from '../../fetch/headers';
9 import { getDateHeader } from '../../fetch/helpers';
10 import { wait } from '../../helpers/promise';
11 import { setRefreshCookies } from '../auth';
12 import { getApiError } from './apiErrorHelper';
13 import { createDeviceHandlers } from './deviceVerificationHandler';
14 import { AppVersionBadError, InactiveSessionError } from './errors';
15 import { createRefreshHandlers, getIsRefreshFailure, refresh } from './refreshHandlers';
16 import { retryHandler } from './retryHandler';
18 /**
19  * Attach a catch handler to every API call to handle 401, 403, and other errors.
20  */
21 export default ({ call, onMissingScopes, onVerification }) => {
22     let loggedOut = false;
23     let appVersionBad = false;
25     const refreshHandler = createRefreshHandlers((UID) => {
26         return refresh(() => call(withUIDHeaders(UID, setRefreshCookies())), 1, RETRY_ATTEMPTS_MAX);
27     });
29     const deviceVerificationHandler = createDeviceHandlers();
31     let UID;
33     const cb = (options) => {
34         const perform = (attempts, maxAttempts) => {
35             if (loggedOut) {
36                 return Promise.reject(InactiveSessionError());
37             }
38             if (appVersionBad) {
39                 return Promise.reject(AppVersionBadError());
40             }
42             return call(options).catch((e) => {
43                 if (loggedOut) {
44                     throw InactiveSessionError();
45                 }
47                 if (maxAttempts && attempts >= maxAttempts) {
48                     throw e;
49                 }
51                 const { status, name, response } = e;
53                 const {
54                     ignoreHandler,
55                     silence = [],
56                     headers,
57                     retriesOnOffline = OFFLINE_RETRY_ATTEMPTS_MAX,
58                     retriesOnTimeout = OFFLINE_RETRY_ATTEMPTS_MAX,
59                     maxRetryWaitSeconds = RETRY_DELAY_MAX,
60                 } = options || {};
62                 if (name === 'OfflineError') {
63                     if (attempts > retriesOnOffline) {
64                         throw e;
65                     }
66                     return wait(OFFLINE_RETRY_DELAY).then(() => perform(attempts + 1, retriesOnOffline));
67                 }
69                 if (name === 'TimeoutError') {
70                     if (attempts > retriesOnTimeout) {
71                         throw e;
72                     }
73                     return perform(attempts + 1, retriesOnTimeout);
74                 }
76                 const ignoreUnauthorized =
77                     Array.isArray(ignoreHandler) && ignoreHandler.includes(HTTP_ERROR_CODES.UNAUTHORIZED);
78                 const requestUID = getUIDHeaderValue(headers) ?? UID;
79                 // Sending a request with a UID but without an authorization header is when the public app makes
80                 // authenticated requests (mostly for persisted sessions), and ignoring "login" or "signup" requests.
81                 if (
82                     status === HTTP_ERROR_CODES.UNAUTHORIZED &&
83                     !ignoreUnauthorized &&
84                     (UID || (requestUID && !headers?.Authorization))
85                 ) {
86                     return refreshHandler(requestUID, getDateHeader(response && response.headers)).then(
87                         () => perform(attempts + 1, RETRY_ATTEMPTS_MAX),
88                         (error) => {
89                             if (getIsRefreshFailure(error)) {
90                                 // Disable any further requests on this session if it was created with a UID and the request was done with the failing UID
91                                 if (UID && requestUID === UID) {
92                                     loggedOut = true;
93                                     // Inactive session error is only thrown when this error was caused by a logged in session requesting through the same UID
94                                     // to have a specific error consumers can use
95                                     throw InactiveSessionError(error);
96                                 }
97                                 // The original 401 error is thrown to make it more clear that this auth & refresh failure
98                                 // was caused by an original auth failure and consumers can just check for 401 instead of 4xx
99                                 throw e;
100                             }
101                             // Otherwise, this is not actually an authentication error, it might have failed because the API responds with 5xx, or because the client is offline etc
102                             // and as such the error from the refresh call is thrown
103                             throw error;
104                         }
105                     );
106                 }
108                 const ignoreUnlock = Array.isArray(ignoreHandler) && ignoreHandler.includes(HTTP_ERROR_CODES.UNLOCK);
109                 if (status === HTTP_ERROR_CODES.UNLOCK && !ignoreUnlock) {
110                     const { Details: { MissingScopes: missingScopes = [] } = {} } = e.data || {};
111                     return onMissingScopes({
112                         scopes: missingScopes,
113                         error: e,
114                         options,
115                     });
116                 }
118                 const ignoreTooManyRequests =
119                     Array.isArray(ignoreHandler) && ignoreHandler.includes(HTTP_ERROR_CODES.TOO_MANY_REQUESTS);
120                 if (status === HTTP_ERROR_CODES.TOO_MANY_REQUESTS && !ignoreTooManyRequests) {
121                     return retryHandler(e, maxRetryWaitSeconds).then(() => perform(attempts + 1, RETRY_ATTEMPTS_MAX));
122                 }
124                 const { code } = getApiError(e);
126                 if (code === API_CUSTOM_ERROR_CODES.APP_VERSION_BAD) {
127                     appVersionBad = true;
128                     throw AppVersionBadError();
129                 }
131                 const ignoreHumanVerification =
132                     Array.isArray(ignoreHandler) &&
133                     ignoreHandler.includes(API_CUSTOM_ERROR_CODES.HUMAN_VERIFICATION_REQUIRED);
134                 if (code === API_CUSTOM_ERROR_CODES.HUMAN_VERIFICATION_REQUIRED && !ignoreHumanVerification) {
135                     const {
136                         Details: {
137                             HumanVerificationToken: captchaToken,
138                             HumanVerificationMethods: methods = [],
139                             Title: title,
140                         } = {},
141                     } = e.data || {};
143                     const onVerify = (token, tokenType) => {
144                         return call({
145                             ...options,
146                             silence:
147                                 silence === true
148                                     ? true
149                                     : [
150                                           ...(Array.isArray(silence) ? silence : []),
151                                           API_CUSTOM_ERROR_CODES.TOKEN_INVALID,
152                                       ],
153                             headers: {
154                                 ...options.headers,
155                                 ...getVerificationHeaders(token, tokenType),
156                             },
157                         });
158                     };
160                     return onVerification({ token: captchaToken, methods, onVerify, title, error: e });
161                 }
163                 const ignoreDeviceVerification =
164                     Array.isArray(ignoreHandler) &&
165                     ignoreHandler.includes(API_CUSTOM_ERROR_CODES.DEVICE_VERIFICATION_REQUIRED);
166                 if (code === API_CUSTOM_ERROR_CODES.DEVICE_VERIFICATION_REQUIRED && !ignoreDeviceVerification) {
167                     const { Details: { ChallengeType: challengeType, ChallengePayload: challengePayload } = {} } =
168                         e.data || {};
169                     const requestUID = getUIDHeaderValue(headers) ?? UID;
170                     return deviceVerificationHandler(requestUID, challengeType, challengePayload)
171                         .then((result) => {
172                             return call({
173                                 ...options,
174                                 headers: {
175                                     ...options.headers,
176                                     ...getDeviceVerificationHeaders(result),
177                                 },
178                             });
179                         })
180                         .catch((error) => {
181                             throw error;
182                         });
183                 }
184                 throw e;
185             });
186         };
188         return perform(1);
189     };
191     Object.defineProperties(cb, {
192         UID: {
193             set(value) {
194                 UID = value;
195             },
196         },
197     });
199     return cb;