Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / api / refresh.ts
blob8f99dadded0a498c469ddbbb3ab3b46d550e326d
1 import type { AuthSession } from '@proton/pass/lib/auth/session';
2 import type { ApiAuth, ApiCallFn, ApiOptions, Maybe, MaybePromise } from '@proton/pass/types';
3 import { AuthMode } from '@proton/pass/types';
4 import { asyncLock } from '@proton/pass/utils/fp/promises';
5 import { logger } from '@proton/pass/utils/logger';
6 import { setRefreshCookies as refreshTokens, setRefreshCookies } from '@proton/shared/lib/api/auth';
7 import { InactiveSessionError } from '@proton/shared/lib/api/helpers/errors';
8 import { retryHandler } from '@proton/shared/lib/api/helpers/retryHandler';
9 import type { RefreshSessionResponse } from '@proton/shared/lib/authentication/interface';
10 import { OFFLINE_RETRY_ATTEMPTS_MAX, OFFLINE_RETRY_DELAY, RETRY_ATTEMPTS_MAX } from '@proton/shared/lib/constants';
11 import { HTTP_ERROR_CODES } from '@proton/shared/lib/errors';
12 import { withAuthHeaders, withUIDHeaders } from '@proton/shared/lib/fetch/headers';
13 import { getDateHeader } from '@proton/shared/lib/fetch/helpers';
14 import { wait } from '@proton/shared/lib/helpers/promise';
15 import randomIntFromInterval from '@proton/utils/randomIntFromInterval';
17 type RefreshCookieResponse = { LocalID?: number; RefreshCounter: number; RefreshTime: number; UID: string };
19 export type RefreshSessionData = Pick<AuthSession, 'UID' | 'AccessToken' | 'RefreshToken' | 'RefreshTime' | 'cookies'>;
20 export type RefreshHandler = (response: Response, options: ApiOptions) => Promise<void>;
21 export type OnRefreshCallback = (response: RefreshSessionData) => MaybePromise<void>;
23 export type DynamicRefreshResult =
24     | { type: AuthMode.COOKIE; response: { json: () => Promise<RefreshCookieResponse> } }
25     | { type: AuthMode.TOKEN; response: { json: () => Promise<RefreshSessionResponse> } };
27 type CreateRefreshHandlerConfig = { call: ApiCallFn; getAuth: () => Maybe<ApiAuth>; onRefresh: OnRefreshCallback };
29 /** Handle refresh token. Happens when the access token has expired.
30  * Multiple calls can fail, so this ensures the refresh route is called once.
31  * Needs to re-handle errors here for that reason. */
32 const refreshFactory = (call: ApiCallFn, getAuth: () => Maybe<ApiAuth>) => {
33     const next = async (maxAttempts: number, attempt: number = 1): Promise<DynamicRefreshResult> => {
34         const auth = getAuth();
35         if (auth === undefined) throw InactiveSessionError();
37         try {
38             return {
39                 type: auth.type,
40                 response: await call(
41                     auth.type === AuthMode.COOKIE
42                         ? withUIDHeaders(auth.UID, setRefreshCookies())
43                         : withAuthHeaders(
44                               auth.UID,
45                               auth.AccessToken,
46                               refreshTokens({ RefreshToken: auth.RefreshToken })
47                           )
48                 ),
49             };
50         } catch (error: any) {
51             if (attempt >= maxAttempts) throw error;
52             const { status, name } = error;
54             if (['OfflineError', 'TimeoutError'].includes(name)) {
55                 if (attempt > OFFLINE_RETRY_ATTEMPTS_MAX) throw error;
56                 await wait(name === 'OfflineError' ? OFFLINE_RETRY_DELAY : 0);
57                 return next(OFFLINE_RETRY_ATTEMPTS_MAX, attempt + 1);
58             }
60             if (status === HTTP_ERROR_CODES.TOO_MANY_REQUESTS) {
61                 await retryHandler(error);
62                 return next(maxAttempts, attempt + 1);
63             }
65             throw error;
66         }
67     };
69     return next;
72 export const refreshHandlerFactory = (config: CreateRefreshHandlerConfig) =>
73     asyncLock<RefreshHandler>(
74         async (response, options) => {
75             /** Override the default `getAuth` if an `auth` option was passed
76              * to the underlying API request that triggered the refresh. */
77             const getAuth = () => options.auth ?? config.getAuth();
78             const refresh = refreshFactory(config.call, getAuth);
80             const responseDate = getDateHeader(response.headers);
81             const lastRefreshDate = getAuth()?.RefreshTime;
83             if (lastRefreshDate === undefined || +(responseDate ?? new Date()) > lastRefreshDate) {
84                 const result = await refresh(RETRY_ATTEMPTS_MAX);
85                 const RefreshTime = +(getDateHeader(response.headers) ?? new Date());
87                 const refreshData = await (async (): Promise<RefreshSessionData> => {
88                     switch (result.type) {
89                         case AuthMode.TOKEN: {
90                             const { AccessToken, RefreshToken, UID } = await result.response.json();
91                             return { UID, AccessToken, RefreshToken, RefreshTime };
92                         }
93                         case AuthMode.COOKIE: {
94                             const { UID } = await result.response.json();
95                             return { UID, AccessToken: '', RefreshToken: '', RefreshTime, cookies: true };
96                         }
97                     }
98                 })();
100                 logger.info('[API] Successfully refreshed session tokens');
102                 await config.onRefresh(refreshData);
103                 await wait(randomIntFromInterval(500, 2000));
104             }
105         },
106         { key: (_, options) => options.auth?.UID ?? config.getAuth()?.UID ?? '' }
107     );