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';
20 getDecryptedPersistedSessionBlob,
23 removePersistedSession,
24 setPersistedSessionWithBlob,
25 } from './persistedSessionStorage';
27 export type ResumedSessionResult = {
35 offlineKey: OfflineKey | undefined;
38 export const logRemoval = (e: any = {}, UID: string, context: string) => {
39 if (e.status === 401) {
42 captureMessage(`Removing session due to `, {
44 reason: `${e.name} - ${e.message} - ${e.status || 0}`,
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');
58 UserID: persistedUserID,
59 blob: persistedSessionBlobString,
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),
70 if (persistedUserID !== latestUser.ID) {
71 throw InactiveSessionError();
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(
79 persistedSessionBlobString,
82 keyPassword = decryptedBlob.keyPassword;
84 decryptedBlob.type === 'offline' &&
85 persistedSession.payloadType === 'offline' &&
86 persistedSession.offlineKeySalt
89 password: decryptedBlob.offlineKeyPassword,
90 salt: persistedSession.offlineKeySalt,
101 clientKey: ClientKey,
105 if (getIs401Error(e)) {
106 logRemoval(e, persistedUID, 'resume 401');
107 await removePersistedSession(localID, persistedUID).catch(noop);
108 throw new InvalidPersistentSessionError('Session invalid');
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);
120 interface PersistSessionWithPasswordArgs {
122 clearKeyPassword: string;
123 keyPassword: string | undefined;
124 offlineKey?: OfflineKey;
130 mode?: 'sso' | 'standalone';
133 export const persistSession = async ({
137 offlineKey: maybeOfflineKey,
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);
154 await setPersistedSessionWithBlob(LocalID, key, {
158 isSubUser: !!User.OrganizationPrivateKey,
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;
176 export interface LocalSessionPersisted {
177 remote: LocalSessionResponse;
178 persisted: PersistedSessionWithLocalID;
181 const getNonExistingSessions = async (
183 persistedSessions: PersistedSessionWithLocalID[],
184 localSessions: LocalSessionPersisted[]
185 ): Promise<LocalSessionPersisted[]> => {
186 const localSessionsSet = new Set(
187 localSessions.map((localSessionPersisted) => localSessionPersisted.persisted.localID)
190 const nonExistingSessions = persistedSessions.filter((persistedSession) => {
191 return !localSessionsSet.has(persistedSession.localID);
194 if (!nonExistingSessions.length) {
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);
209 const User = result.User;
210 const remoteSession: LocalSessionResponse = {
212 DisplayName: User.DisplayName,
213 PrimaryEmail: User.Email,
215 LocalID: persistedSession.localID,
218 remote: remoteSession,
219 persisted: persistedSession,
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])
235 // The returned sessions have to exist in localstorage to be able to activate
236 const maybeActiveSessions = Sessions.map((remoteSession) => {
238 persisted: persistedSessionsMap[remoteSession.LocalID],
239 remote: remoteSession,
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', {
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,
256 return [...maybeActiveSessions, ...nonExistingSessions];
259 export enum GetActiveSessionType {
264 export type GetActiveSessionsResult =
266 session?: ResumedSessionResult;
267 sessions: LocalSessionPersisted[];
268 type: GetActiveSessionType.Switch;
271 session: ResumedSessionResult;
272 sessions: LocalSessionPersisted[];
273 type: GetActiveSessionType.AutoPick;
276 const pickSessionByEmail = async ({
284 session?: ResumedSessionResult;
285 sessions: LocalSessionPersisted[];
287 const lowerCaseEmail = email.toLowerCase();
289 const matchingSession = sessions.find((session) => session.remote.PrimaryEmail?.toLowerCase() === lowerCaseEmail);
291 if (!matchingSession) {
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;
302 const firstMatchingSessionByLocalID = sessions.find(({ remote }) =>
303 Boolean(remoteLocalIDMap[remote.LocalID])
305 if (firstMatchingSessionByLocalID) {
306 return resumeSession({ api, localID: firstMatchingSessionByLocalID.remote.LocalID });
308 const firstMatchingSession = remoteSessions[0];
309 if (firstMatchingSession && firstMatchingSession.LocalID !== undefined) {
310 return resumeSession({ api, localID: firstMatchingSession.LocalID });
316 if (matchingSession.persisted.localID === session?.LocalID) {
320 return resumeSession({ api, localID: matchingSession.remote.LocalID });
323 export const maybePickSessionByEmail = async ({
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({
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),
354 session: maybeMatchingResumedSession,
355 sessions: sortedSessions,
356 type: GetActiveSessionType.AutoPick,
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 };
367 export const getActiveSessions = async ({
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),
386 for (const persistedSession of persistedSessions) {
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;
398 session && (hasOnlyOneSession || hasOnlyOneSessionAndUnspecifiedLocalID || localID === session.LocalID)
399 ? GetActiveSessionType.AutoPick
400 : GetActiveSessionType.Switch;
402 return await maybePickSessionByEmail({ api, localID, email, result: { session, sessions, type } });
404 if (e instanceof InvalidPersistentSessionError || getIs401Error(e)) {
405 // Session expired, try another session
408 // If a network error, throw here to show the error screen
416 type: GetActiveSessionType.Switch,
420 export const maybeResumeSessionByUser = async (
423 isSubUser: boolean = !!User.OrganizationPrivateKey
425 const maybePersistedSession = getActiveSessionByUserID(User.ID, isSubUser);
426 if (!maybePersistedSession) {
430 return await resumeSession({ api, localID: maybePersistedSession.localID });
432 if (!(e instanceof InvalidPersistentSessionError)) {