Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / shared / lib / authentication / persistedSessionHelper.ts
blob29a0e9b3f556fecbccede40d1dcf68541d098e98
1 import isTruthy from '@proton/utils/isTruthy';
2 import noop from '@proton/utils/noop';
4 import { getLocalKey, getLocalSessions, revoke, setLocalKey } from '../api/auth';
5 import { getIs401Error } from '../api/helpers/apiErrorHelper';
6 import { getUIDApi } from '../api/helpers/customConfig';
7 import { InactiveSessionError } from '../api/helpers/errors';
8 import { getUser } from '../api/user';
9 import { withUIDHeaders } from '../fetch/headers';
10 import { captureMessage } from '../helpers/sentry';
11 import type { Api, User as tsUser } from '../interfaces';
12 import { appMode } from '../webpack.constants';
13 import type { PersistedSessionWithLocalID } from './SessionInterface';
14 import { generateClientKey, getClientKey } from './clientKey';
15 import { InvalidPersistentSessionError } from './error';
16 import type { LocalKeyResponse, LocalSessionResponse } from './interface';
17 import type { OfflineKey } from './offlineKey';
18 import { generateOfflineKey } from './offlineKey';
19 import {
20     getDecryptedPersistedSessionBlob,
21     getPersistedSession,
22     getPersistedSessions,
23     removePersistedSession,
24     setPersistedSessionWithBlob,
25 } from './persistedSessionStorage';
27 export type ResumedSessionResult = {
28     UID: string;
29     LocalID: number;
30     keyPassword?: string;
31     User: tsUser;
32     persistent: boolean;
33     trusted: boolean;
34     clientKey: string;
35     offlineKey: OfflineKey | undefined;
38 export const logRemoval = (e: any = {}, UID: string, context: string) => {
39     if (e.status === 401) {
40         return;
41     }
42     captureMessage(`Removing session due to `, {
43         extra: {
44             reason: `${e.name} - ${e.message} - ${e.status || 0}`,
45             UID,
46             context,
47         },
48     });
51 export const resumeSession = async ({ api, localID }: { api: Api; localID: number }): Promise<ResumedSessionResult> => {
52     const persistedSession = getPersistedSession(localID);
53     if (!persistedSession) {
54         throw new InvalidPersistentSessionError('Missing persisted session or UID');
55     }
56     const {
57         UID: persistedUID,
58         UserID: persistedUserID,
59         blob: persistedSessionBlobString,
60         persistent,
61         trusted,
62         payloadVersion,
63     } = persistedSession;
65     try {
66         const [ClientKey, latestUser] = await Promise.all([
67             api<LocalKeyResponse>(withUIDHeaders(persistedUID, getLocalKey())).then(({ ClientKey }) => ClientKey),
68             api<{ User: tsUser }>(withUIDHeaders(persistedUID, getUser())).then(({ User }) => User),
69         ]);
70         if (persistedUserID !== latestUser.ID) {
71             throw InactiveSessionError();
72         }
73         let keyPassword: undefined | string;
74         let offlineKey: undefined | OfflineKey;
75         if (persistedSessionBlobString && ClientKey) {
76             const key = await getClientKey(ClientKey);
77             const decryptedBlob = await getDecryptedPersistedSessionBlob(
78                 key,
79                 persistedSessionBlobString,
80                 payloadVersion
81             );
82             keyPassword = decryptedBlob.keyPassword;
83             if (
84                 decryptedBlob.type === 'offline' &&
85                 persistedSession.payloadType === 'offline' &&
86                 persistedSession.offlineKeySalt
87             ) {
88                 offlineKey = {
89                     password: decryptedBlob.offlineKeyPassword,
90                     salt: persistedSession.offlineKeySalt,
91                 };
92             }
93         }
94         return {
95             UID: persistedUID,
96             LocalID: localID,
97             keyPassword,
98             User: latestUser,
99             persistent,
100             trusted,
101             clientKey: ClientKey,
102             offlineKey,
103         };
104     } catch (e: any) {
105         if (getIs401Error(e)) {
106             logRemoval(e, persistedUID, 'resume 401');
107             await removePersistedSession(localID, persistedUID).catch(noop);
108             throw new InvalidPersistentSessionError('Session invalid');
109         }
110         if (e instanceof InvalidPersistentSessionError) {
111             logRemoval(e, persistedUID, 'invalid blob');
112             await api(withUIDHeaders(persistedUID, revoke())).catch(noop);
113             await removePersistedSession(localID, persistedUID).catch(noop);
114             throw e;
115         }
116         throw e;
117     }
120 interface PersistSessionWithPasswordArgs {
121     api: Api;
122     clearKeyPassword: string;
123     keyPassword: string | undefined;
124     offlineKey?: OfflineKey;
125     User: tsUser;
126     UID: string;
127     LocalID: number;
128     persistent: boolean;
129     trusted: boolean;
130     mode?: 'sso' | 'standalone';
133 export const persistSession = async ({
134     api,
135     clearKeyPassword,
136     keyPassword = '',
137     offlineKey: maybeOfflineKey,
138     User,
139     UID,
140     LocalID,
141     persistent,
142     trusted,
143     mode = appMode,
144 }: PersistSessionWithPasswordArgs) => {
145     const { serializedData, key } = await generateClientKey();
146     await api<LocalKeyResponse>(setLocalKey(serializedData));
148     let offlineKey = maybeOfflineKey;
150     if (mode === 'sso') {
151         if (clearKeyPassword && !offlineKey) {
152             offlineKey = await generateOfflineKey(clearKeyPassword);
153         }
154         await setPersistedSessionWithBlob(LocalID, key, {
155             UID,
156             UserID: User.ID,
157             keyPassword,
158             isSubUser: !!User.OrganizationPrivateKey,
159             persistent,
160             trusted,
161             offlineKey,
162         });
163     }
165     return { clientKey: serializedData, offlineKey };
168 export const getActiveSessionByUserID = (UserID: string, isSubUser: boolean) => {
169     return getPersistedSessions().find((persistedSession) => {
170         const isSameUserID = persistedSession.UserID === UserID;
171         const isSameSubUser = persistedSession.isSubUser === isSubUser;
172         return isSameUserID && isSameSubUser;
173     });
176 export interface LocalSessionPersisted {
177     remote: LocalSessionResponse;
178     persisted: PersistedSessionWithLocalID;
181 const getNonExistingSessions = async (
182     api: Api,
183     persistedSessions: PersistedSessionWithLocalID[],
184     localSessions: LocalSessionPersisted[]
185 ): Promise<LocalSessionPersisted[]> => {
186     const localSessionsSet = new Set(
187         localSessions.map((localSessionPersisted) => localSessionPersisted.persisted.localID)
188     );
190     const nonExistingSessions = persistedSessions.filter((persistedSession) => {
191         return !localSessionsSet.has(persistedSession.localID);
192     }, []);
194     if (!nonExistingSessions.length) {
195         return [];
196     }
198     const result = await Promise.all(
199         nonExistingSessions.map(async (persistedSession) => {
200             const result = await api<{ User: tsUser }>(withUIDHeaders(persistedSession.UID, getUser())).catch((e) => {
201                 if (getIs401Error(e)) {
202                     logRemoval(e, persistedSession.UID, 'non-existing-sessions');
203                     removePersistedSession(persistedSession.localID, persistedSession.UID).catch(noop);
204                 }
205             });
206             if (!result?.User) {
207                 return undefined;
208             }
209             const User = result.User;
210             const remoteSession: LocalSessionResponse = {
211                 Username: User.Name,
212                 DisplayName: User.DisplayName,
213                 PrimaryEmail: User.Email,
214                 UserID: User.ID,
215                 LocalID: persistedSession.localID,
216             };
217             return {
218                 remote: remoteSession,
219                 persisted: persistedSession,
220             };
221         })
222     );
224     return result.filter(isTruthy);
227 export const getActiveLocalSession = async (api: Api) => {
228     const { Sessions = [] } = await api<{ Sessions: LocalSessionResponse[] }>(getLocalSessions());
230     const persistedSessions = getPersistedSessions();
231     const persistedSessionsMap = Object.fromEntries(
232         persistedSessions.map((persistedSession) => [persistedSession.localID, persistedSession])
233     );
235     // The returned sessions have to exist in localstorage to be able to activate
236     const maybeActiveSessions = Sessions.map((remoteSession) => {
237         return {
238             persisted: persistedSessionsMap[remoteSession.LocalID],
239             remote: remoteSession,
240         };
241     }).filter((value): value is LocalSessionPersisted => !!value.persisted);
243     const nonExistingSessions = await getNonExistingSessions(api, persistedSessions, maybeActiveSessions);
244     if (nonExistingSessions.length) {
245         captureMessage('Unexpected non-existing sessions', {
246             extra: {
247                 length: nonExistingSessions.length,
248                 ids: nonExistingSessions.map((session) => ({
249                     id: `${session.remote.Username || session.remote.PrimaryEmail || session.remote.UserID}`,
250                     lid: session.remote.LocalID,
251                 })),
252             },
253         });
254     }
256     return [...maybeActiveSessions, ...nonExistingSessions];
259 export enum GetActiveSessionType {
260     Switch,
261     AutoPick,
264 export type GetActiveSessionsResult =
265     | {
266           session?: ResumedSessionResult;
267           sessions: LocalSessionPersisted[];
268           type: GetActiveSessionType.Switch;
269       }
270     | {
271           session: ResumedSessionResult;
272           sessions: LocalSessionPersisted[];
273           type: GetActiveSessionType.AutoPick;
274       };
276 const pickSessionByEmail = async ({
277     api,
278     email,
279     session,
280     sessions,
281 }: {
282     api: Api;
283     email: string;
284     session?: ResumedSessionResult;
285     sessions: LocalSessionPersisted[];
286 }) => {
287     const lowerCaseEmail = email.toLowerCase();
289     const matchingSession = sessions.find((session) => session.remote.PrimaryEmail?.toLowerCase() === lowerCaseEmail);
291     if (!matchingSession) {
292         if (session) {
293             const uidApi = getUIDApi(session.UID, api);
294             const result = await uidApi<{
295                 Sessions: LocalSessionResponse[];
296             }>(getLocalSessions({ Email: lowerCaseEmail }));
297             const remoteSessions = result?.Sessions || [];
298             const remoteLocalIDMap = remoteSessions.reduce<{ [key: string]: LocalSessionResponse }>((acc, value) => {
299                 acc[value.LocalID] = value;
300                 return acc;
301             }, {});
302             const firstMatchingSessionByLocalID = sessions.find(({ remote }) =>
303                 Boolean(remoteLocalIDMap[remote.LocalID])
304             );
305             if (firstMatchingSessionByLocalID) {
306                 return resumeSession({ api, localID: firstMatchingSessionByLocalID.remote.LocalID });
307             }
308             const firstMatchingSession = remoteSessions[0];
309             if (firstMatchingSession && firstMatchingSession.LocalID !== undefined) {
310                 return resumeSession({ api, localID: firstMatchingSession.LocalID });
311             }
312         }
313         return;
314     }
316     if (matchingSession.persisted.localID === session?.LocalID) {
317         return session;
318     }
320     return resumeSession({ api, localID: matchingSession.remote.LocalID });
323 export const maybePickSessionByEmail = async ({
324     api,
325     localID,
326     email,
327     result,
328 }: {
329     api: Api;
330     localID?: number;
331     email?: string;
332     result: GetActiveSessionsResult;
333 }): Promise<GetActiveSessionsResult> => {
334     const { session, sessions } = result;
336     // The email selector is used in case there's no localID or if the requested localID did not exist
337     if (email && (localID === undefined || localID !== session?.LocalID)) {
338         // Ignore if it fails, worse case the user will have to pick the account.
339         const maybeMatchingResumedSession = await pickSessionByEmail({
340             api,
341             email,
342             session,
343             sessions,
344         }).catch(noop);
346         if (maybeMatchingResumedSession) {
347             // Increase ordered priority to the requested session
348             const sortedSessions = [
349                 ...sessions.filter((a) => a.remote.LocalID === maybeMatchingResumedSession.LocalID),
350                 ...sessions.filter((a) => a.remote.LocalID !== maybeMatchingResumedSession.LocalID),
351             ];
353             return {
354                 session: maybeMatchingResumedSession,
355                 sessions: sortedSessions,
356                 type: GetActiveSessionType.AutoPick,
357             };
358         }
360         // If a matching email could not be found, fallback to switch since it's unsure which account the user should use
361         return { session, sessions, type: GetActiveSessionType.Switch };
362     }
364     return result;
367 export const getActiveSessions = async ({
368     api,
369     localID,
370     email,
371 }: {
372     api: Api;
373     localID?: number;
374     email?: string;
375 }): Promise<GetActiveSessionsResult> => {
376     let persistedSessions = getPersistedSessions();
378     if (localID !== undefined) {
379         // Increase ordered priority to the specified local ID
380         persistedSessions = [
381             ...persistedSessions.filter((a) => a.localID === localID),
382             ...persistedSessions.filter((a) => a.localID !== localID),
383         ];
384     }
386     for (const persistedSession of persistedSessions) {
387         try {
388             const session = await resumeSession({ api, localID: persistedSession.localID });
389             const sessions = await getActiveLocalSession(getUIDApi(session.UID, api));
391             const hasOnlyOneSessionAndUnspecifiedLocalID = localID === undefined && sessions.length === 1;
392             // This is technically incorrect, but users have bookmarked sessions with expired local ids, so in the case of 1 session on account
393             // we still autopick the session even if a specific local id is requested.
394             // TODO: We need to improve this, specifically the scenarios when account has lost a session but the session still exists on subdomains.
395             const hasOnlyOneSession = sessions.length === 1;
397             const type =
398                 session && (hasOnlyOneSession || hasOnlyOneSessionAndUnspecifiedLocalID || localID === session.LocalID)
399                     ? GetActiveSessionType.AutoPick
400                     : GetActiveSessionType.Switch;
402             return await maybePickSessionByEmail({ api, localID, email, result: { session, sessions, type } });
403         } catch (e: any) {
404             if (e instanceof InvalidPersistentSessionError || getIs401Error(e)) {
405                 // Session expired, try another session
406                 continue;
407             }
408             // If a network error, throw here to show the error screen
409             throw e;
410         }
411     }
413     return {
414         session: undefined,
415         sessions: [],
416         type: GetActiveSessionType.Switch,
417     };
420 export const maybeResumeSessionByUser = async (
421     api: Api,
422     User: tsUser,
423     isSubUser: boolean = !!User.OrganizationPrivateKey
424 ) => {
425     const maybePersistedSession = getActiveSessionByUserID(User.ID, isSubUser);
426     if (!maybePersistedSession) {
427         return;
428     }
429     try {
430         return await resumeSession({ api, localID: maybePersistedSession.localID });
431     } catch (e: any) {
432         if (!(e instanceof InvalidPersistentSessionError)) {
433             throw e;
434         }
435     }