1 import { c } from 'ttag';
3 import type { CreateNotificationOptions } from '@proton/components';
4 import { DEFAULT_LOCK_TTL } from '@proton/pass/constants';
5 import { PassErrorCode } from '@proton/pass/lib/api/errors';
6 import { type RefreshSessionData } from '@proton/pass/lib/api/refresh';
7 import { getOfflineComponents, getOfflineVerifier } from '@proton/pass/lib/cache/crypto';
8 import type { Maybe, MaybeNull, MaybePromise } from '@proton/pass/types';
9 import { type Api } from '@proton/pass/types';
10 import { NotificationKey } from '@proton/pass/types/worker/notification';
11 import { getErrorMessage } from '@proton/pass/utils/errors/get-error-message';
12 import { pipe, tap } from '@proton/pass/utils/fp/pipe';
13 import { asyncLock } from '@proton/pass/utils/fp/promises';
14 import { withCallCount } from '@proton/pass/utils/fp/with-call-count';
15 import { logger } from '@proton/pass/utils/logger';
16 import { getEpoch } from '@proton/pass/utils/time/epoch';
17 import { revoke, setLocalKey } from '@proton/shared/lib/api/auth';
18 import { getApiError, getApiErrorMessage } from '@proton/shared/lib/api/helpers/apiErrorHelper';
19 import { generateClientKey } from '@proton/shared/lib/authentication/clientKey';
20 import type { LocalKeyResponse } from '@proton/shared/lib/authentication/interface';
21 import { stringToUint8Array } from '@proton/shared/lib/helpers/encoding';
22 import { loadCryptoWorker } from '@proton/shared/lib/helpers/setupCryptoWorker';
23 import noop from '@proton/utils/noop';
25 import type { PullForkCall } from './fork';
27 type ConsumeForkPayload,
28 type RequestForkOptions,
29 type RequestForkResult,
33 import { checkSessionLock } from './lock/session/lock.requests';
34 import type { Lock, LockAdapter, LockCreateDTO } from './lock/types';
35 import { LockMode } from './lock/types';
38 getPasswordVerification,
39 registerExtraPassword,
42 verifyOfflinePassword,
47 type EncryptedAuthSession,
48 type ResumeSessionResult,
49 encryptPersistedSessionWithKey,
50 getPersistedSessionKey,
55 import { type AuthStore } from './store';
57 export type AuthOptions = {
58 /** `forceLock` will locally lock the session upon resuming */
60 /** If `true`, will re-persist session on login */
61 forcePersist?: boolean;
62 /** If `true`, session resuming should be retried */
64 /** If `true`, the session is considered unlocked */
68 export interface AuthServiceConfig {
70 /** Store holding the active session data */
73 /** Override the default pull fork call. This is mostly
74 * required for safari extensions which will not include
75 * cookies set by account when requesting a fork sw-side. */
76 pullFork?: PullForkCall;
78 /** The in-memory session is used to store the session data securely.
79 * It allows resuming a session without any API calls to re-authenticate.
80 * In most cases you can omit the implementation and rely on the `authStore` */
81 getMemorySession?: () => MaybePromise<any>;
82 /** The persisted session will be parsed and decrypted to extract the
83 * session data. Requires an API call to retrieve the local key. */
84 getPersistedSession: (localID: Maybe<number>) => MaybePromise<MaybeNull<EncryptedAuthSession>>;
85 /** Implement any service initialization logic in this hook. Should return
86 * a boolean flag indicating wether user was authorized or not. */
87 onInit: (options: AuthOptions) => Promise<boolean>;
88 /** Called when authorization sequence starts: this can happen when consuming a
89 * session fork or when trying to resume a session. */
90 onLoginStart?: () => void;
91 /** Called whenever a user is successfully authenticated. This can happen
92 * after consuming a fork or resuming a session. */
93 onLoginComplete?: (userID: string, localID: Maybe<number>) => void;
94 /** Called when logout sequence starts before the authentication store is cleared */
95 onLogoutStart?: () => void;
96 /** Called whenever a user is unauthenticated. This will be triggered any time
97 * the `logout` function is called (either via user action or when an inactive
98 * session is detected). The `broadcast` flag indicates wether we should
99 * broadcast the unauthorized session to other clients. */
100 onLogoutComplete?: (userID: Maybe<string>, localID: Maybe<number>, broadcast: boolean) => void;
101 /** Called immediately after a fork has been successfully consumed. At this
102 * point the user is not fully logged in yet. */
103 onForkConsumed?: (session: AuthSession, payload: ConsumeForkPayload) => MaybePromise<void>;
104 /** Called when a fork could not be successfully consumed. This can happen
105 * if the fork data is invalid */
106 onForkInvalid?: () => void;
107 /** Handle the result of a fork request call. Can be used to redirect the
108 * user automatically when requesting a fork from account. */
109 onForkRequest?: (result: RequestForkResult) => void;
110 /** Called when an invalid persistent session error is thrown during a
111 * session resuming sequence. It will get called with the invalid session
112 * and the localID being resumed for retry mechanisms */
116 localID: Maybe<number>;
117 invalidSession: EncryptedAuthSession;
118 retry: (session: EncryptedAuthSession) => Promise<ResumeSessionResult>;
120 ) => MaybePromise<ResumeSessionResult>;
121 /* Called when no persisted session or in-memory session can be used to
122 * resume a session. */
123 onSessionEmpty?: () => void;
124 /** Called when a session is locked either through user action or when a
125 * locked session is detected. The `broadcast` flag indicates wether we should
126 * broadcast the locked session to other clients. */
127 onLocked?: (mode: LockMode, localID: Maybe<number>, broadcast: boolean, userInitiated: boolean) => void;
128 /** Callback when session lock is created, updated or deleted */
129 onLockUpdate?: (lock: Lock, localID: Maybe<number>, broadcast: boolean) => MaybePromise<void>;
130 /** Called with the `sessionLockToken` when session is successfully unlocked */
131 onUnlocked?: (mode: LockMode, token: Maybe<string>, localID: Maybe<number>) => Promise<void>;
132 /** Implement encrypted local session persistence using this hook. Called on every
133 * successful consumed fork or unlocked session. */
134 onSessionPersist?: (encryptedSession: string) => MaybePromise<void>;
135 /** Called when resuming the session failed for any reason excluding inactive
137 onSessionFailure?: (options: AuthOptions) => MaybePromise<void>;
138 /** Called when session tokens have been refreshed. The`broadcast` flag indicates
139 * wether we should broadcast the refresh session data to other clients. */
140 onSessionRefresh?: (localId: Maybe<number>, data: RefreshSessionData, broadcast: boolean) => MaybePromise<void>;
141 /** Implement how you want to handle notifications emitted from the service */
142 onNotification?: (notification: CreateNotificationOptions) => void;
143 /** Triggered when extra password is required */
144 onMissingScope?: () => void;
147 export const createAuthService = (config: AuthServiceConfig) => {
148 const { api, authStore } = config;
150 const adapters = new Map<LockMode, LockAdapter>();
152 const getLockAdapter = (mode: LockMode): LockAdapter => {
153 const adapter = adapters.get(mode);
154 if (!adapter) throw new Error(`Lock adapter not found for "${mode}"`);
158 const authService = {
159 init: asyncLock(async (options: AuthOptions) => {
160 logger.info(`[AuthService] Initialization start`);
161 return config.onInit(options).catch((err) => {
162 logger.warn(`[AuthService] Initialization failure`, err);
163 config.onNotification?.({ type: 'error', text: getErrorMessage(err) });
168 /** Stores the initial configuration object passed to the
169 * auth service factory function. Useful if you want to trigger
170 * certain handlers outside of the auth service flow. */
173 registerLockAdapter: (mode: LockMode, adapter: LockAdapter) => adapters.set(mode, adapter),
175 login: async (session: AuthSession, options: AuthOptions) => {
176 config.onLoginStart?.();
179 if (!authStore.validSession(session)) {
181 throw new Error('Invalid session');
184 authStore.setSession(session);
187 const migrated = await migrateSession(authStore);
188 if (migrated || options.forcePersist) await authService.persistSession().catch(noop);
190 const lockMode = authStore.getLockMode();
192 if (options?.forceLock && lockMode !== LockMode.NONE) {
193 await authService.lock(lockMode, { soft: true, broadcast: false });
197 if (!options?.unlocked) {
198 const sessionLock = await checkSessionLock();
199 const sessionLockRegistered = sessionLock.mode === LockMode.SESSION;
200 const sessionLocked = sessionLock.locked;
202 const hasToken = authStore.getLockToken() !== undefined;
203 const needsToken = sessionLockRegistered && !hasToken;
204 const overrideLock = sessionLockRegistered ? lockMode !== sessionLock.mode : false;
205 const shouldLockSession = overrideLock || sessionLocked || needsToken;
207 if (shouldLockSession) {
208 logger.info(`[AuthService] Locked session [locked=${sessionLocked},token=${hasToken}]`);
209 await authService.lock(LockMode.SESSION, { soft: true, broadcast: false });
214 const { code } = getApiError(err);
215 if (code === PassErrorCode.MISSING_SCOPE) return false;
217 logger.warn(`[AuthService] Logging in session failed`, err);
218 config.onNotification?.({ text: c('Warning').t`Your session could not be resumed.`, type: 'error' });
219 await config?.onSessionFailure?.({ forceLock: true, retryable: true });
223 logger.info(`[AuthService] User is authorized`);
224 config.onLoginComplete?.(authStore.getUserID()!, authStore.getLocalID());
229 logout: async (options: { soft: boolean; broadcast?: boolean }) => {
230 config.onLogoutStart?.();
232 const localID = authStore.getLocalID();
233 const userID = authStore.getUserID();
235 if (!options?.soft) await api({ ...revoke(), silence: true }).catch(noop);
236 logger.info(`[AuthService] User is not authorized`);
240 authService.resumeSession.resetCount();
242 config.onLogoutComplete?.(userID, localID, options.broadcast ?? true);
247 consumeFork: async (payload: ConsumeForkPayload, apiUrl?: string): Promise<boolean> => {
249 config.onLoginStart?.();
250 const { session, Scopes } = await consumeFork({ api, apiUrl, payload, pullFork: config.pullFork });
251 const validScope = Scopes.includes('pass');
254 /** If the scope is invalid then the user must unlock with his
255 * pass extra password in order to continue the login sequence.
256 * Clear any offline components provided by account as they will
257 * need to be recomputed against the extra password */
258 delete session.offlineConfig;
259 delete session.offlineKD;
262 await config.onForkConsumed?.(session, payload);
264 const loggedIn = validScope && (await authService.login(session, {}));
267 authStore.setSession(session);
268 authStore.setExtraPassword(true);
269 config.onMissingScope?.();
272 const locked = authStore.getLocked();
274 /** Persist the session only on successful login. If the forked session is
275 * locked, persist eitherway to avoid requiring a new fork consumption if
276 * user does not unlock immediately (reset api state for persisting). */
277 if (locked) await api.reset();
278 if (loggedIn || locked) await authService.persistSession({ regenerateClientKey: true });
281 } catch (error: unknown) {
282 const reason = error instanceof Error ? ` (${getApiErrorMessage(error) ?? error?.message})` : '';
284 config.onNotification?.({
285 text: c('Warning').t`Your session could not be authorized.` + reason,
289 config.onForkInvalid?.();
290 await authService.logout({ soft: true, broadcast: false });
296 requestFork: (options: RequestForkOptions): RequestForkResult => {
297 const result = requestFork(options);
298 config.onForkRequest?.(result);
303 createLock: async (payload: LockCreateDTO) => {
304 if (payload.mode === LockMode.NONE) return;
306 const adapter = getLockAdapter(payload.mode);
307 const localID = authStore.getLocalID();
308 const sessionLockRegistered = authStore.getLockMode() === LockMode.SESSION;
310 /** If we're creating a new lock over an
311 * active API session lock - delete it first */
312 const onBeforeCreate = sessionLockRegistered
314 if (!payload.current) throw new Error('Invalid lock creation');
315 const lock = await getLockAdapter(LockMode.SESSION).delete(payload.current.secret);
316 void config.onLockUpdate?.(lock, localID, false);
320 const lock = await adapter.create(payload, onBeforeCreate);
321 void config.onLockUpdate?.(lock, localID, true);
324 deleteLock: async (mode: LockMode, secret: string) => {
325 if (mode === LockMode.NONE) return;
327 const adapter = getLockAdapter(mode);
328 const lock = await adapter.delete(secret);
329 const localID = authStore.getLocalID();
331 void config.onLockUpdate?.(lock, localID, true);
336 options: { broadcast?: boolean; soft: boolean; userInitiated?: boolean }
337 ): Promise<Lock> => {
338 const adapter = getLockAdapter(mode);
339 const localID = authStore.getLocalID();
340 const broadcast = options.broadcast ?? false;
342 config.onLocked?.(mode, localID, broadcast, options.userInitiated ?? false);
343 const lock = await adapter.lock(options);
348 unlock: async (mode: LockMode, secret: string): Promise<void> => {
349 if (mode === LockMode.NONE) return;
352 const adapter = getLockAdapter(mode);
353 const token = await adapter.unlock(secret);
354 const localID = authStore.getLocalID();
355 await adapter.check();
357 await config.onUnlocked?.(mode, token, localID);
359 /** error is thrown for clients to consume */
360 logger.warn(`[AuthService] Unlock failure [mode=${mode}]`, error);
365 checkLock: async (): Promise<Lock> => {
366 const mode = authStore.getLockMode();
367 if (mode === LockMode.NONE) return { mode: LockMode.NONE, locked: false };
369 /** If we have a TTL and lastExtendTime - check early
370 * if the TTL has been reached and lock accordingly */
371 const ttl = authStore.getLockTTL();
372 const lastExtendTime = authStore.getLockLastExtendTime();
374 if (ttl && lastExtendTime) {
375 const now = getEpoch();
376 const diff = now - (lastExtendTime ?? 0);
377 if (diff > ttl) return authService.lock(mode, { soft: true, broadcast: true });
380 const adapter = getLockAdapter(mode);
381 const lock = await adapter.check();
382 const localID = authStore.getLocalID();
384 await config.onLockUpdate?.(lock, localID, false);
388 /** Passing the `regenerateClientKey` option will generate
389 * a new local key and update it back-end side. Ideally, this
390 * should only happen after consuming a fork. */
391 persistSession: async (options?: { regenerateClientKey: boolean }) => {
393 const session = authStore.getSession();
394 if (!authStore.validSession(session)) throw new Error('Trying to persist invalid session');
396 const clientKey = await (async () => {
397 if (options?.regenerateClientKey) {
398 const { serializedData, key } = await generateClientKey();
399 await api<LocalKeyResponse>(setLocalKey(serializedData));
400 authStore.setClientKey(serializedData);
404 return getPersistedSessionKey(api, authStore);
407 logger.info('[AuthService] Persisting session');
409 /* If the clientKey resolution sequence triggered a refresh,
410 * make sure we persist the session with the new tokens */
411 session.lastUsedAt = getEpoch();
412 const syncedSession = syncAuthSession(session, authStore);
413 const encryptedSession = await encryptPersistedSessionWithKey(syncedSession, clientKey);
415 await config.onSessionPersist?.(encryptedSession);
417 logger.warn(`[AuthService] Persisting session failure`, error);
421 resumeSession: withCallCount(
423 async (localID: Maybe<number>, options: AuthOptions): Promise<boolean> => {
425 const memorySession = await config.getMemorySession?.();
426 const persistedSession = await config.getPersistedSession(localID);
428 /** If we have an in-memory decrypted AuthSession - use it to
429 * login without making any other API requests. Authorizing
430 * from in-memory session does not account for force lock, rather
431 * when locking the in-memory session should be cleared */
432 if (memorySession && authStore.validSession(memorySession)) {
433 logger.info(`[AuthService] Resuming in-memory session [lock=${options.forceLock}]`);
434 return await authService.login(memorySession, {});
437 /** If we have no persisted session to resume from, exit early */
438 if (!persistedSession) {
439 logger.info(`[AuthService] No persisted session found`);
440 config.onSessionEmpty?.();
444 logger.info(`[AuthService] Resuming persisted session [lock=${options.forceLock ?? false}]`);
445 config.onLoginStart?.();
447 /** Partially configure the auth store before resume sequence. `keyPassword`
448 * and `sessionLockToken` may be still encrypted at this point */
449 authStore.setSession(persistedSession);
452 const result = await resumeSession(persistedSession, localID, config, options);
454 logger.info(`[AuthService] Session successfully resumed`);
455 options.forcePersist = options.forcePersist || result.repersist;
456 return await authService.login(result.session, options);
457 } catch (error: unknown) {
458 if (error instanceof Error) {
459 const message = getApiErrorMessage(error) ?? error?.message;
460 const reason = message ? ` (${message})` : '';
461 const text = c('Warning').t`Your session could not be resumed.` + reason;
462 logger.warn(`[AuthService] Resuming session failed ${reason}`);
463 config.onNotification?.({ text, type: 'error' });
466 /** If a session fails to resume due to reasons other than being locked,
467 * inactive, or offline, the sessionFailure callback should trigger the
468 * resuming process. Session errors will be managed by the API listener. */
469 const { sessionLocked, sessionInactive } = api.getState();
470 const sessionFailure = !(sessionLocked || sessionInactive);
471 if (sessionFailure) await config.onSessionFailure?.(options);
477 /** Reset the internal resume session count when session
478 * resuming succeeds */
479 if (resumed) authService.resumeSession.resetCount();
484 /** Password confirmation can either be verified offline or online.
485 * In `srp` mode, we will verify the user's password through SRP
486 * (two-password mode not supported yet). If the user has an offline
487 * config, we compare the `offlineKD` with the derived argon2 hash */
488 confirmPassword: async (password: string, mode?: PasswordVerification): Promise<boolean> => {
490 await loadCryptoWorker();
492 switch (mode ?? getPasswordVerification(authStore)) {
493 case PasswordVerification.LOCAL: {
494 const offlineConfig = authStore.getOfflineConfig()!;
495 const offlineVerifier = authStore.getOfflineVerifier()!;
496 return await verifyOfflinePassword(password, { offlineConfig, offlineVerifier });
499 case PasswordVerification.EXTRA_PASSWORD: {
500 await verifyExtraPassword({ password });
502 const { offlineConfig, offlineKD } = await getOfflineComponents(password);
504 authStore.setOfflineConfig(offlineConfig);
505 authStore.setOfflineKD(offlineKD);
506 authStore.setOfflineVerifier(await getOfflineVerifier(stringToUint8Array(offlineKD)));
508 /** Online extra password verification will happen on
509 * first login after a successful fork. At this point
510 * we can enable the password lock automatically. */
513 [LockMode.NONE, LockMode.BIOMETRICS].includes(authStore.getLockMode())
515 authStore.setLockMode(LockMode.PASSWORD);
516 authStore.setLockTTL(DEFAULT_LOCK_TTL);
519 await authService.persistSession({ regenerateClientKey: true });
524 case PasswordVerification.SRP: {
525 return await verifyPassword({ password });
529 logger.warn(`[AuthService] failed password confirmation (${getErrorMessage(error)})`);
534 registerExtraPassword: async (password: string): Promise<boolean> => {
535 /** Compute the offline components in order to update the auth store on successful
536 * extra password registration : this will affect any password locks or offline mode
537 * setting. Users will now have to unlock the client with the extra password */
538 const { offlineConfig, offlineKD } = await getOfflineComponents(password);
539 await registerExtraPassword({ password });
541 /* Clear biometrics */
542 if (authStore.getLockMode() === LockMode.BIOMETRICS) {
543 authStore.setEncryptedOfflineKD(undefined);
544 authStore.setLockMode(LockMode.PASSWORD);
545 await authService.config.onLockUpdate?.(
546 { mode: LockMode.PASSWORD, locked: false, ttl: authStore.getLockTTL() },
547 authStore.getLocalID(),
552 authStore.setExtraPassword(true);
553 authStore.setOfflineConfig(offlineConfig);
554 authStore.setOfflineKD(offlineKD);
555 authStore.setOfflineVerifier(await getOfflineVerifier(stringToUint8Array(offlineKD)));
557 await authService.persistSession();
561 removeExtraPassword: async (password: string) => {
563 if (authStore.getLockMode() === LockMode.BIOMETRICS) {
564 authStore.setEncryptedOfflineKD(undefined);
565 await authService.config.onLockUpdate?.(
566 { mode: LockMode.PASSWORD, locked: false },
567 authStore.getLocalID(),
572 await verifyExtraPassword({ password });
573 await removeExtraPassword();
574 await authService.logout({ soft: true, broadcast: true });
578 api.subscribe(async (event) => {
579 /** Ensure we have an active session before processing API events*/
580 if (authStore.hasSession()) {
581 switch (event.type) {
583 if (event.status === 'inactive') {
585 config.onNotification?.({
586 text: c('Warning').t`Your session is inactive.`,
590 await authService.logout({ soft: true, broadcast: true });
593 if (event.status === 'locked') {
594 const locked = authStore.getLocked();
597 config.onNotification?.({
598 key: NotificationKey.LOCK,
599 text: c('Warning').t`Your session was locked.`,
604 await authService.lock(LockMode.SESSION, { soft: true, broadcast: true });
607 if (event.status === 'not-allowed') {
608 if (event.error?.includes('two-factor-authentication-2fa')) {
609 config.onNotification?.({
611 key: NotificationKey.ORG_MISSING_2FA,
617 await authService.logout({ soft: true, broadcast: true });
620 if (event.status === 'missing-scope') config.onMissingScope?.();
626 const { data } = event;
627 if (authStore.getUID() === data.UID) {
628 /** The `onSessionRefresh` callback is invoked to persist the new tokens.
629 * If this callback throws an error, it is crucial to avoid updating the
630 * authentication store data. This precaution prevents potential inconsistencies
631 * between the store and persisted data. The provisional refresh token is confirmed
632 * only upon the initial use of the new access token. */
633 await config.onSessionRefresh?.(authStore.getLocalID(), data, true);
634 authStore.setSession(data);
646 export type AuthService = ReturnType<typeof createAuthService>;