Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / authentication / persistedSessionStorage.ts
blobc2d61aee03ed161e70345dcfb839977cf64ab674
1 import { stringToUtf8Array } from '@proton/crypto/lib/utils';
2 import isTruthy from '@proton/utils/isTruthy';
3 import noop from '@proton/utils/noop';
5 import { removeLastRefreshDate } from '../api/helpers/refreshStorage';
6 import createListeners from '../helpers/listeners';
7 import { getItem, removeItem, setItem } from '../helpers/storage';
8 import type {
9     DefaultPersistedSession,
10     OfflinePersistedSession,
11     PersistedSession,
12     PersistedSessionBlob,
13     PersistedSessionWithLocalID,
14 } from './SessionInterface';
15 import { InvalidPersistentSessionError } from './error';
16 import { getValidatedLocalID } from './fork/validation';
17 import type { OfflineKey } from './offlineKey';
18 import { getDecryptedBlob, getEncryptedBlob } from './sessionBlobCryptoHelper';
20 // We have business logic relying on this constant, please change with caution!
21 export const STORAGE_PREFIX = 'ps-';
22 const getKey = (localID: number) => `${STORAGE_PREFIX}${localID}`;
24 const sessionRemovalListeners = createListeners<PersistedSession[], Promise<void>>();
26 export const registerSessionRemovalListener = (listener: (persistedSessions: PersistedSession) => Promise<void>) => {
27     sessionRemovalListeners.subscribe(listener);
30 export const getPersistedSession = (localID: number): PersistedSession | undefined => {
31     const itemValue = getItem(getKey(localID));
32     if (!itemValue) {
33         return;
34     }
35     try {
36         const parsedValue = JSON.parse(itemValue);
37         return {
38             UserID: parsedValue.UserID || '',
39             UID: parsedValue.UID || '',
40             blob: parsedValue.blob || '',
41             isSubUser: parsedValue.isSubUser || false,
42             persistent: typeof parsedValue.persistent === 'boolean' ? parsedValue.persistent : true, // Default to true (old behavior)
43             trusted: parsedValue.trusted || false,
44             payloadVersion: parsedValue.payloadVersion || 1,
45             persistedAt: parsedValue.persistedAt || 0,
46             ...(parsedValue.offlineKeySalt
47                 ? {
48                       payloadType: 'offline',
49                       offlineKeySalt: parsedValue.offlineKeySalt,
50                   }
51                 : { payloadType: 'default' }),
52         };
53     } catch (e: any) {
54         return undefined;
55     }
58 export const removePersistedSession = async (localID: number, UID: string) => {
59     const oldSession = getPersistedSession(localID);
60     if (oldSession?.UID) {
61         removeLastRefreshDate(oldSession.UID);
62     }
63     if (!oldSession || (oldSession.UID && UID !== oldSession.UID)) {
64         return;
65     }
66     if (sessionRemovalListeners.length()) {
67         await Promise.all(sessionRemovalListeners.notify(oldSession)).catch(noop);
68     }
69     removeItem(getKey(localID));
72 export const getPersistedSessions = (): PersistedSessionWithLocalID[] => {
73     const localStorageKeys = Object.keys(localStorage);
74     return localStorageKeys
75         .filter((key) => key.startsWith(STORAGE_PREFIX))
76         .map((key) => {
77             const localID = getValidatedLocalID(key.slice(STORAGE_PREFIX.length));
78             if (localID === undefined) {
79                 return;
80             }
81             const result = getPersistedSession(localID);
82             if (!result) {
83                 return;
84             }
85             return {
86                 ...result,
87                 localID,
88             };
89         })
90         .filter(isTruthy);
93 export const getPersistedSessionBlob = (blob: string): PersistedSessionBlob | undefined => {
94     try {
95         const parsedValue = JSON.parse(blob);
96         const keyPassword = parsedValue.keyPassword || '';
97         const offlineKeyPassword = parsedValue.offlineKeyPassword || '';
99         if (parsedValue.offlineKeyPassword) {
100             return {
101                 type: 'offline',
102                 keyPassword,
103                 offlineKeyPassword,
104             };
105         }
107         return {
108             type: 'default',
109             keyPassword,
110         };
111     } catch (e: any) {
112         return undefined;
113     }
116 export const getDecryptedPersistedSessionBlob = async (
117     key: CryptoKey,
118     blob: string,
119     payloadVersion: PersistedSession['payloadVersion']
120 ): Promise<PersistedSessionBlob> => {
121     const decryptedBlob = await getDecryptedBlob(
122         key,
123         blob,
124         payloadVersion === 2 ? stringToUtf8Array('session') : undefined
125     ).catch(() => {
126         throw new InvalidPersistentSessionError('Failed to decrypt persisted blob');
127     });
128     const parsedBlob = getPersistedSessionBlob(decryptedBlob);
129     if (!parsedBlob) {
130         throw new InvalidPersistentSessionError('Failed to parse persisted blob');
131     }
132     return parsedBlob;
135 export const setPersistedSessionWithBlob = async (
136     localID: number,
137     key: CryptoKey,
138     data: {
139         UserID: string;
140         UID: string;
141         keyPassword: string;
142         offlineKey: OfflineKey | undefined;
143         isSubUser: boolean;
144         persistent: boolean;
145         trusted: boolean;
146     }
147 ) => {
148     const payloadVersion =
149         1 as PersistedSession['payloadVersion']; /* Update to 2 when all clients understand it (safe for rollback) */
151     const { clearTextPayloadData, encryptedPayloadData } = ((): {
152         clearTextPayloadData:
153             | Pick<OfflinePersistedSession, 'payloadType' | 'offlineKeySalt'>
154             | Pick<DefaultPersistedSession, 'payloadType'>;
155         encryptedPayloadData: PersistedSessionBlob;
156     } => {
157         if (data.offlineKey) {
158             return {
159                 clearTextPayloadData: {
160                     payloadType: 'offline',
161                     offlineKeySalt: data.offlineKey.salt,
162                 },
163                 encryptedPayloadData: {
164                     keyPassword: data.keyPassword,
165                     offlineKeyPassword: data.offlineKey.password,
166                 },
167             } as const;
168         }
170         return {
171             clearTextPayloadData: {
172                 payloadType: 'default',
173             },
174             encryptedPayloadData: {
175                 keyPassword: data.keyPassword,
176             },
177         } as const;
178     })();
180     const persistedSession: PersistedSession = {
181         UserID: data.UserID,
182         UID: data.UID,
183         isSubUser: data.isSubUser,
184         persistent: data.persistent,
185         trusted: data.trusted,
186         payloadVersion,
187         ...clearTextPayloadData,
188         blob: await getEncryptedBlob(
189             key,
190             JSON.stringify(encryptedPayloadData),
191             payloadVersion === 2 ? stringToUtf8Array('session') : undefined
192         ),
193         persistedAt: Date.now(),
194     };
195     setItem(getKey(localID), JSON.stringify(persistedSession));