1 import metrics from '@proton/metrics';
13 } from '@proton/shared/lib/api/auth';
14 import { getApiError, getIs401Error } from '@proton/shared/lib/api/helpers/apiErrorHelper';
15 import { createRefreshHandlers, getIsRefreshFailure, refresh } from '@proton/shared/lib/api/helpers/refreshHandlers';
16 import { createOnceHandler } from '@proton/shared/lib/apiHandlers';
17 import type { ChallengePayload } from '@proton/shared/lib/authentication/interface';
18 import { HTTP_ERROR_CODES } from '@proton/shared/lib/errors';
19 import { getUIDHeaderValue, withAuthHeaders, withUIDHeaders } from '@proton/shared/lib/fetch/headers';
20 import { getDateHeader } from '@proton/shared/lib/fetch/helpers';
21 import { createPromise, wait } from '@proton/shared/lib/helpers/promise';
22 import { setUID } from '@proton/shared/lib/helpers/sentry';
23 import { getItem, removeItem, setItem } from '@proton/shared/lib/helpers/sessionStorage';
24 import type { Api } from '@proton/shared/lib/interfaces';
25 import getRandomString from '@proton/utils/getRandomString';
26 import noop from '@proton/utils/noop';
28 const unAuthStorageKey = 'ua_uid';
30 const setupComplete = Symbol('setup complete');
32 UID: string | undefined;
33 auth: { set: boolean; id: any; finalised: boolean };
36 abortController: AbortController;
37 setup: null | typeof setupComplete | Promise<void>;
38 challenge: ReturnType<typeof createPromise<ChallengePayload | undefined>>;
41 api: undefined as any,
42 abortController: new AbortController(),
43 challenge: createPromise<ChallengePayload | undefined>(),
44 auth: { set: false, id: {}, finalised: false },
49 export const updateUID = (UID: string) => {
50 setItem(unAuthStorageKey, UID);
53 metrics.setAuthHeaders(UID);
56 context.auth.set = false;
57 context.auth.finalised = false;
59 context.abortController = new AbortController();
62 export const init = createOnceHandler(async () => {
63 context.abortController.abort();
65 const challengePromise = context.challenge.promise.catch(noop);
66 const challengePayload = await Promise.race([challengePromise, wait(300)]);
68 const response = await context.api<Response>({
69 ...createSession(challengePayload ? { Payload: challengePayload } : undefined),
72 // This is here because it's required for clients that aren't in the min version
73 // And we won't put e.g. the standalone login for apps there
74 'x-enforce-unauthsession': true,
79 const { UID, AccessToken, RefreshToken } = await response.json();
81 ...withAuthHeaders(UID, AccessToken, setCookies({ UID, RefreshToken, State: getRandomString(24) })),
87 if (!challengePayload) {
89 .then((challengePayload) => {
90 if (!challengePayload) {
95 ...withUIDHeaders(UID, payload(challengePayload)),
96 ignoreHandler: [HTTP_ERROR_CODES.UNAUTHORIZED],
107 export const refreshHandler = createRefreshHandlers((UID: string) => {
111 ...withUIDHeaders(UID, setRefreshCookies()),
112 ignoreHandler: [HTTP_ERROR_CODES.UNAUTHORIZED],
119 if (getIsRefreshFailure(e)) {
120 return init().then((result) => {
128 export const setup = async () => {
129 const oldUID = getItem(unAuthStorageKey);
137 const clearTabPersistedUID = () => {
138 removeItem(unAuthStorageKey);
141 const authConfig = auth({} as any, true);
142 const mnemonicAuthConfig = authMnemonic('', true);
143 const auth2FAConfig = auth2FA({ TwoFactorCode: '' });
144 const localKeyConfig = setLocalKey('');
146 const initSetup = (): Promise<void> | undefined => {
147 if (context.setup === setupComplete) {
150 if (context.setup === null) {
151 context.setup = setup()
153 context.setup = setupComplete;
156 context.setup = null;
159 return context.setup;
162 export const apiCallback: Api = async (config: any) => {
164 const UID = context.UID;
166 return context.api(config);
169 // Note: requestUID !== UID means that this is an API request that is using an already established session, so we ignore unauth here.
170 const requestUID = getUIDHeaderValue(config.headers) ?? UID;
171 if (requestUID !== UID) {
172 return context.api(config);
175 // If an unauthenticated session attempts to signs in, the unauthenticated session has to be discarded so it's not
176 // accidentally re-used for another session. We do this before the response has returned to avoid race conditions,
177 // e.g. a user refreshing the page before the response has come back.
178 const isAuthUrl = [authConfig.url, mnemonicAuthConfig.url].includes(config.url);
180 clearTabPersistedUID();
183 const abortController = context.abortController;
184 const otherAbortCb = () => {
185 abortController.abort();
187 config.signal?.addEventListener('abort', otherAbortCb);
188 const id = {}; // Unique symbol for this run
191 // This is set BEFORE the API calls finishes. This might give false positives (when the credentials are incorrect) but
192 // it'll ensure that the session is reset if a user hits the back button before the auth call finishes and credentials are correct.
193 // It's also reset in case the credentials are incorrect, but that assumes that one user can only trigger one auth process at a time.
195 context.auth.set = true;
196 context.auth.id = id;
199 if (config.url === localKeyConfig.url) {
200 context.auth.finalised = true;
203 const result = await context.api(
204 withUIDHeaders(UID, {
206 signal: abortController.signal,
208 HTTP_ERROR_CODES.UNAUTHORIZED,
209 ...(Array.isArray(config.ignoreHandler) ? config.ignoreHandler : []),
212 config.silence === true
214 : [HTTP_ERROR_CODES.UNAUTHORIZED, ...(Array.isArray(config.silence) ? config.silence : [])],
220 if (isAuthUrl && context.auth.id === id) {
221 context.auth.set = false;
223 if (config.url === localKeyConfig.url && context.auth.id === id) {
224 context.auth.finalised = false;
226 if (getIs401Error(e)) {
227 const { code } = getApiError(e);
228 // Don't attempt to refresh on 2fa 401 failures since the session has become invalidated.
229 // NOTE: Only one the PASSWORD_WRONG_ERROR code, since 401 is also triggered on session expiration.
230 if (config.url === auth2FAConfig.url && code === PASSWORD_WRONG_ERROR) {
233 return await refreshHandler(UID, getDateHeader(e?.response?.headers)).then(() => {
234 return apiCallback(config);
239 config.signal?.removeEventListener('abort', otherAbortCb);
243 export const setApi = (api: Api) => {
247 export const setChallenge = (data: ChallengePayload | undefined) => {
248 context.challenge.resolve(data);
251 export const startUnAuthFlow = createOnceHandler(async () => {
252 if (!(context.auth.set && context.UID)) {
256 // Avoid deleting the session if it's been fully finalised and persisted
257 if (context.auth.set && context.UID && context.auth.finalised) {
262 // Abort all previous request to prevent it triggering 401 and refresh
263 context.abortController.abort();
267 withUIDHeaders(context.UID, {
270 ignoreHandler: [HTTP_ERROR_CODES.UNAUTHORIZED],