Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / auth / session.ts
blob4d9235f66113a66f0585d6931e1e13c79297d866
1 /* Inspired from packages/shared/lib/authentication/persistedSessionHelper.ts */
2 import { stringToUtf8Array } from '@proton/crypto/lib/utils';
3 import { type OfflineConfig, getOfflineVerifier } from '@proton/pass/lib/cache/crypto';
4 import type { Api, Maybe, MaybeNull } from '@proton/pass/types';
5 import { getErrorMessage } from '@proton/pass/utils/errors/get-error-message';
6 import { prop } from '@proton/pass/utils/fp/lens';
7 import { getLocalKey, getLocalSessions, setCookies } from '@proton/shared/lib/api/auth';
8 import { InactiveSessionError } from '@proton/shared/lib/api/helpers/errors';
9 import { getUser } from '@proton/shared/lib/api/user';
10 import { getClientKey } from '@proton/shared/lib/authentication/clientKey';
11 import { InvalidPersistentSessionError } from '@proton/shared/lib/authentication/error';
12 import type { LocalKeyResponse, LocalSessionResponse } from '@proton/shared/lib/authentication/interface';
13 import { getDecryptedBlob, getEncryptedBlob } from '@proton/shared/lib/authentication/sessionBlobCryptoHelper';
14 import { stringToUint8Array } from '@proton/shared/lib/helpers/encoding';
15 import type { User as UserType } from '@proton/shared/lib/interfaces';
16 import getRandomString from '@proton/utils/getRandomString';
18 import { SESSION_DIGEST_VERSION, digestSession, getSessionDigestVersion } from './integrity';
19 import type { LockMode } from './lock/types';
20 import type { AuthOptions, AuthServiceConfig } from './service';
21 import type { AuthStore } from './store';
23 export type AuthSessionVersion = 1 | 2;
24 export const SESSION_VERSION: AuthSessionVersion = 1;
26 export type AuthSession = {
27     AccessToken: string;
28     cookies?: boolean;
29     encryptedOfflineKD?: string;
30     extraPassword?: boolean;
31     keyPassword: string;
32     lastUsedAt?: number;
33     LocalID?: number;
34     lockMode: LockMode;
35     lockTTL?: number;
36     offlineConfig?: OfflineConfig;
37     offlineKD?: string;
38     offlineVerifier?: string;
39     payloadVersion?: AuthSessionVersion;
40     persistent?: boolean;
41     RefreshTime?: number;
42     RefreshToken: string;
43     sessionLockToken?: string;
44     UID: string;
45     unlockRetryCount?: number;
46     userData?: string;
47     UserID: string;
50 /** The following values of the `AuthSession` are locally stored in
51  * an encrypted blob using the BE local key for the user's session */
52 export type EncryptedSessionKeys = 'keyPassword' | 'offlineKD' | 'sessionLockToken';
53 export type EncryptedAuthSession = Omit<AuthSession, EncryptedSessionKeys> & { blob: string };
54 export type DecryptedAuthSessionBlob = Pick<AuthSession, EncryptedSessionKeys> & { digest?: string };
56 export const SESSION_KEYS: (keyof AuthSession)[] = [
57     'AccessToken',
58     'cookies',
59     'extraPassword',
60     'keyPassword',
61     'LocalID',
62     'lockMode',
63     'lockTTL',
64     'offlineConfig',
65     'offlineKD',
66     'offlineVerifier',
67     'payloadVersion',
68     'persistent',
69     'RefreshToken',
70     'sessionLockToken',
71     'UID',
72     'UserID',
75 export const getSessionEncryptionTag = (version?: AuthSessionVersion): Maybe<Uint8Array> =>
76     version === 2 ? stringToUtf8Array('session') : undefined;
78 /* Given a local session key, encrypts sensitive session components of
79  * the `AuthSession` before persisting. Additionally stores a SHA-256
80  * integrity digest of the session data to validate when resuming */
81 export const encryptPersistedSessionWithKey = async (session: AuthSession, clientKey: CryptoKey): Promise<string> => {
82     const { keyPassword, offlineKD, payloadVersion = SESSION_VERSION, sessionLockToken, ...rest } = session;
83     const digest = await digestSession(session, SESSION_DIGEST_VERSION);
84     const blob: DecryptedAuthSessionBlob = { keyPassword, offlineKD, sessionLockToken, digest };
86     const value: EncryptedAuthSession = {
87         ...rest,
88         blob: await getEncryptedBlob(clientKey, JSON.stringify(blob), getSessionEncryptionTag(payloadVersion)),
89         payloadVersion,
90     };
92     return JSON.stringify(value);
95 /** Synchronizes an `AuthSession` with the latest auth data from the authentication store */
96 export const syncAuthSession = (session: AuthSession, authStore: AuthStore): AuthSession => ({
97     ...session,
98     AccessToken: authStore.getAccessToken() ?? session.AccessToken,
99     RefreshTime: authStore.getRefreshTime() ?? session.RefreshTime,
100     RefreshToken: authStore.getRefreshToken() ?? session.RefreshToken,
101     UID: authStore.getUID() ?? session.UID,
102     cookies: authStore.getCookieAuth() ?? session.cookies,
103     persistent: authStore.getPersistent() ?? session.persistent,
106 /** Retrieves the current local key to decrypt the persisted session */
107 export const getPersistedSessionKey = async (api: Api, authStore: AuthStore): Promise<CryptoKey> => {
108     const clientKey =
109         authStore.getClientKey() ??
110         (
111             await api<LocalKeyResponse>({
112                 ...getLocalKey(),
113                 silence: true,
114             })
115         ).ClientKey;
117     authStore.setClientKey(clientKey);
118     return getClientKey(clientKey);
121 export const decryptSessionBlob = async (
122     clientKey: CryptoKey,
123     blob: string,
124     payloadVersion: AuthSessionVersion
125 ): Promise<DecryptedAuthSessionBlob> => {
126     try {
127         const decryptedBlob = await getDecryptedBlob(clientKey, blob, getSessionEncryptionTag(payloadVersion));
128         const parsedValue = JSON.parse(decryptedBlob);
130         if (!parsedValue.keyPassword) throw new Error('Missing `keyPassword`');
132         return {
133             keyPassword: parsedValue.keyPassword,
134             offlineKD: parsedValue.offlineKD,
135             sessionLockToken: parsedValue.sessionLockToken,
136             digest: parsedValue.digest,
137         };
138     } catch (err) {
139         throw new InvalidPersistentSessionError(getErrorMessage(err));
140     }
143 export type ResumeSessionResult = {
144     clientKey: CryptoKey;
145     repersist: boolean;
146     session: AuthSession;
149 /** Resumes an encrypted session by decrypting the session blob.
150  * - Ensure the authentication store has been hydrated.
151  * - Session tokens may be refreshed during this sequence.
152  * - If applicable, upgrades the session to cookie-based auth. */
153 export const resumeSession = async (
154     persistedSession: EncryptedAuthSession,
155     localID: Maybe<number>,
156     config: AuthServiceConfig,
157     options: AuthOptions
158 ): Promise<ResumeSessionResult> => {
159     const { api, authStore, onSessionInvalid } = config;
160     const { blob, ...session } = persistedSession;
161     const { UID } = session;
163     const cookieUpgrade = authStore.shouldCookieUpgrade(persistedSession);
165     try {
166         const [clientKey, { User }] = await Promise.all([
167             getPersistedSessionKey(api, authStore),
168             api<{ User: UserType }>(getUser()),
169         ]);
171         if (!persistedSession || persistedSession.UserID !== User.ID) throw InactiveSessionError();
173         const payloadVersion = session.payloadVersion ?? SESSION_VERSION;
174         const decryptedBlob = await decryptSessionBlob(clientKey, blob, payloadVersion);
176         if (decryptedBlob.digest) {
177             const version = getSessionDigestVersion(decryptedBlob.digest);
178             const digest = await digestSession(persistedSession, version);
179             if (digest !== decryptedBlob.digest) throw new InvalidPersistentSessionError();
180         }
182         if (cookieUpgrade) {
183             /** Upgrade the session to cookie-based authentication.
184              * This occurs after a successful token-based API call to ensure
185              * tokens are refreshed before setting the refresh cookies.
186              * We assume the session was persistent for this upgrade.*/
187             const RefreshToken = authStore.getRefreshToken()!;
188             await api(setCookies({ UID, RefreshToken, State: getRandomString(24), Persistent: true }));
189             authStore.setAccessToken('');
190             authStore.setCookieAuth(true);
191             authStore.setPersistent(true);
192             authStore.setRefreshToken('');
193         }
195         /** Synchronize the session with the auth store to capture any mutations
196          * that may have occurred during the resumption process (e.g., token refresh
197          * or cookie upgrade). This ensures the returned session contains the most
198          * up-to-date authentication data. */
199         const syncedSession = syncAuthSession({ ...session, ...decryptedBlob }, authStore);
201         return {
202             clientKey,
203             repersist: cookieUpgrade || !decryptedBlob.digest,
204             session: syncedSession,
205         };
206     } catch (error: unknown) {
207         if (onSessionInvalid) {
208             return onSessionInvalid(error, {
209                 localID,
210                 invalidSession: persistedSession,
211                 retry: (session) => resumeSession(session, localID, config, options),
212             });
213         }
215         throw error;
216     }
219 export const getActiveSessions = (api: Api): Promise<MaybeNull<LocalSessionResponse[]>> =>
220     api<{ Sessions: LocalSessionResponse[] }>(getLocalSessions())
221         .then(prop('Sessions'))
222         .catch(() => null);
224 export const migrateSession = async (authStore: AuthStore): Promise<boolean> => {
225     let migrated = false;
227     const offlineKD = authStore.getOfflineKD();
228     const offlineConfig = authStore.getOfflineConfig();
229     const offlineVerifier = authStore.getOfflineVerifier();
231     /** Create the `offlineVerifier` if it hasn't been generated (<1.18.0) */
232     if (offlineKD && offlineConfig && !offlineVerifier) {
233         authStore.setOfflineVerifier(await getOfflineVerifier(stringToUint8Array(offlineKD)));
234         migrated = true;
235     }
237     return migrated;