Update selected item color in Pass menu
[ProtonMail-WebClient.git] / packages / pass / lib / auth / fork.ts
blob6c5551b7bdc333cada20f09780f99e80d6b36ff2
1 import { c } from 'ttag';
3 import { ARGON2_PARAMS } from '@proton/crypto/lib';
4 import { importKey } from '@proton/crypto/lib/subtle/aesGcm';
5 import type { TabId } from '@proton/pass/types';
6 import { type Api, AuthMode, type MaybeNull } from '@proton/pass/types';
7 import { getErrorMessage } from '@proton/pass/utils/errors/get-error-message';
8 import { getEpoch } from '@proton/pass/utils/time/epoch';
9 import { pullForkSession, setRefreshCookies as refreshTokens, setCookies } from '@proton/shared/lib/api/auth';
10 import { getUser } from '@proton/shared/lib/api/user';
11 import { getAppHref } from '@proton/shared/lib/apps/helper';
12 import { getWelcomeToText } from '@proton/shared/lib/apps/text';
13 import { InvalidForkConsumeError } from '@proton/shared/lib/authentication/error';
14 import { getForkDecryptedBlob } from '@proton/shared/lib/authentication/fork/blob';
15 import { ForkSearchParameters, type ForkType } from '@proton/shared/lib/authentication/fork/constants';
16 import { getValidatedForkType, getValidatedRawKey } from '@proton/shared/lib/authentication/fork/validation';
17 import type { PullForkResponse, RefreshSessionResponse } from '@proton/shared/lib/authentication/interface';
18 import type { APP_NAMES } from '@proton/shared/lib/constants';
19 import { APPS, MAIL_APP_NAME, PASS_APP_NAME, SSO_PATHS } from '@proton/shared/lib/constants';
20 import { withAuthHeaders, withUIDHeaders } from '@proton/shared/lib/fetch/headers';
21 import { encodeBase64URL, uint8ArrayToString } from '@proton/shared/lib/helpers/encoding';
22 import type { User } from '@proton/shared/lib/interfaces';
23 import getRandomString from '@proton/utils/getRandomString';
25 import { AUTH_MODE } from './flags';
26 import { LockMode } from './lock/types';
27 import { type AuthSession, type AuthSessionVersion, SESSION_VERSION } from './session';
28 import { encodeUserData } from './store';
30 export type RequestForkOptions = {
31     app: APP_NAMES;
32     host?: string;
33     localID?: number;
34     forkType?: ForkType;
35     prompt?: 'login';
36     payloadType?: 'offline';
37     payloadVersion?: AuthSessionVersion;
39 export type RequestForkResult = { state: string; url: string };
41 export const requestFork = ({
42     app,
43     host = getAppHref('/', APPS.PROTONACCOUNT),
44     localID,
45     forkType,
46     prompt = 'login',
47     payloadType = 'offline',
48     payloadVersion,
49 }: RequestForkOptions): RequestForkResult => {
50     const state = encodeBase64URL(uint8ArrayToString(crypto.getRandomValues(new Uint8Array(32))));
52     const searchParams = new URLSearchParams();
53     searchParams.append(ForkSearchParameters.App, app);
54     searchParams.append(ForkSearchParameters.State, state);
55     searchParams.append(ForkSearchParameters.Independent, '0');
56     if (prompt === 'login') {
57         searchParams.append(ForkSearchParameters.Prompt, 'login'); /* force re-auth */
58         searchParams.append(ForkSearchParameters.PromptType, 'offline'); /* compute offline params */
59     }
60     if (payloadType === 'offline') {
61         searchParams.append(ForkSearchParameters.PayloadType, payloadType); /* offline payload */
62     }
63     if (payloadVersion === 2) searchParams.append(ForkSearchParameters.PayloadVersion, `${payloadVersion}`);
64     if (localID !== undefined) searchParams.append(ForkSearchParameters.LocalID, `${localID}`);
65     if (forkType) searchParams.append(ForkSearchParameters.ForkType, forkType);
67     return { url: `${host}${SSO_PATHS.AUTHORIZE}?${searchParams.toString()}`, state };
70 export type PullForkCall = (payload: ConsumeForkPayload) => Promise<PullForkResponse>;
71 export type ConsumedFork = { session: AuthSession; Scopes: string[] };
72 export type ConsumeForkParameters = ReturnType<typeof getConsumeForkParameters>;
74 export type ConsumeForkOptions = {
75     apiUrl?: string;
76     payload: ConsumeForkPayload;
77     api: Api;
78     pullFork?: PullForkCall;
81 export type ConsumeForkPayload =
82     | {
83           mode: 'sso';
84           key?: Uint8Array;
85           localState: MaybeNull<string>;
86           payloadVersion: AuthSessionVersion;
87           persistent: boolean;
88           selector: string;
89           state: string;
90       }
91     | {
92           mode: 'secure';
93           keyPassword: string;
94           persistent: boolean;
95           selector: string;
96           state: string;
97           tabId: TabId;
98           /** FIXME: support passing offline key components
99            * when consuming a "secure" extension fork */
100       };
103  * If `keyPassword` is not provided to `ConsumeForkOptions`, it will attempt to recover it from
104  * the `Payload` property of the `PullForkResponse`. `keyPassword` will always be omitted when
105  * retrieving the fork options from the url parameters. This is not the case when using secure
106  * extension messaging where `keyPassword` can safely be passed.
107  * ⚠️ Only validates the fork state in SSO mode.
108  */
109 export const consumeFork = async (options: ConsumeForkOptions): Promise<ConsumedFork> => {
110     const { payload, apiUrl, api } = options;
111     const cookies = AUTH_MODE === AuthMode.COOKIE;
113     const validFork =
114         (payload.mode === 'secure' || (payload.localState !== null && payload.key)) &&
115         payload.selector &&
116         payload.state;
118     if (!validFork) throw new InvalidForkConsumeError('Invalid fork state');
120     const pullFork: PullForkCall =
121         options.pullFork ??
122         (({ selector }) => {
123             const pullForkParams = pullForkSession(selector);
124             pullForkParams.url = apiUrl ? `${apiUrl}/${pullForkParams.url}` : pullForkParams.url;
125             return api<PullForkResponse>(pullForkParams);
126         });
128     const { UID, RefreshToken, LocalID, Payload, Scopes } = await pullFork(payload);
129     const refresh = await api<RefreshSessionResponse>(withUIDHeaders(UID, refreshTokens({ RefreshToken })));
130     const { User } = await api<{ User: User }>(withAuthHeaders(UID, refresh.AccessToken, getUser()));
132     if (cookies) {
133         await api(
134             withAuthHeaders(
135                 UID,
136                 refresh.AccessToken,
137                 setCookies({
138                     UID,
139                     RefreshToken: refresh.RefreshToken,
140                     State: getRandomString(24),
141                     Persistent: payload.persistent,
142                 })
143             )
144         );
145     }
147     const data =
148         payload.mode === 'secure'
149             ? { keyPassword: payload.keyPassword, payloadVersion: SESSION_VERSION }
150             : await (async () => {
151                   try {
152                       const { payloadVersion, key } = payload;
153                       const clientKey = await importKey(key!);
154                       const decryptedBlob = await getForkDecryptedBlob(clientKey, Payload, payloadVersion);
155                       if (!decryptedBlob?.keyPassword) throw new Error('Missing `keyPassword`');
157                       return {
158                           keyPassword: decryptedBlob.keyPassword,
159                           payloadVersion,
160                           ...(decryptedBlob.type === 'offline'
161                               ? {
162                                     offlineConfig: {
163                                         salt: atob(decryptedBlob.offlineKeySalt),
164                                         params: ARGON2_PARAMS.RECOMMENDED,
165                                     },
166                                     offlineKD: atob(decryptedBlob.offlineKeyPassword),
167                                 }
168                               : {}),
169                       };
170                   } catch (err) {
171                       throw new InvalidForkConsumeError(getErrorMessage(err));
172                   }
173               })();
175     const session: AuthSession = {
176         ...data,
177         UID,
178         LocalID,
179         UserID: User.ID,
180         userData: encodeUserData(User.Email, User.DisplayName),
181         lastUsedAt: getEpoch(),
182         AccessToken: cookies ? '' : refresh.AccessToken,
183         RefreshToken: cookies ? '' : refresh.RefreshToken,
184         lockMode: LockMode.NONE,
185         persistent: payload.persistent,
186         cookies,
187     };
189     return { session, Scopes };
192 export enum AccountForkResponse {
193     CONFLICT,
194     SUCCESS,
195     ERROR,
198 export const getAccountForkResponsePayload = (type: AccountForkResponse, error?: any) => {
199     const additionalMessage = getErrorMessage(error);
201     const payload = (() => {
202         switch (type) {
203             case AccountForkResponse.CONFLICT: {
204                 return {
205                     title: c('Error').t`Authentication error`,
206                     message: c('Info')
207                         .t`It seems you are already logged in to ${PASS_APP_NAME}. If you're trying to login with a different account, please logout from the extension first.`,
208                 };
209             }
210             case AccountForkResponse.SUCCESS: {
211                 return {
212                     title: getWelcomeToText(PASS_APP_NAME),
213                     message: c('Info')
214                         .t`More than a password manager, ${PASS_APP_NAME} protects your password and your personal email address via email aliases. Powered by the same technology behind ${MAIL_APP_NAME}, your data is end-to-end encrypted and is only accessible by you.`,
215                 };
216             }
217             case AccountForkResponse.ERROR: {
218                 return {
219                     title: c('Error').t`Something went wrong`,
220                     message: c('Warning').t`Unable to sign in to ${PASS_APP_NAME}. ${additionalMessage}`,
221                 };
222             }
223         }
224     })();
226     return { payload };
229 export const getConsumeForkParameters = () => {
230     const sliceIndex = window.location.hash.lastIndexOf('#') + 1;
231     const hashParams = new URLSearchParams(window.location.hash.slice(sliceIndex));
232     const selector = hashParams.get(ForkSearchParameters.Selector) || '';
233     const state = hashParams.get(ForkSearchParameters.State) || '';
234     const base64StringKey = hashParams.get(ForkSearchParameters.Base64Key) || '';
235     const type = hashParams.get(ForkSearchParameters.ForkType) || '';
236     const persistent = hashParams.get(ForkSearchParameters.Persistent) || '';
237     const trusted = hashParams.get(ForkSearchParameters.Trusted) || '';
238     const payloadVersion = hashParams.get(ForkSearchParameters.PayloadVersion) || '';
239     const payloadType = hashParams.get(ForkSearchParameters.PayloadType) || '';
241     return {
242         state: state.slice(0, 100),
243         selector,
244         key: base64StringKey.length ? getValidatedRawKey(base64StringKey) : undefined,
245         type: getValidatedForkType(type),
246         persistent: persistent === '1',
247         trusted: trusted === '1',
248         payloadVersion: payloadVersion === '2' ? 2 : 1,
249         payloadType: payloadType === 'offline' ? payloadType : 'default',
250     } as const;