Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('Activation service - `CLIENT_INIT`', () => {

authStore = createAuthStore(createMemoryStore());
authStore.setLocalID(123);
authStore.setLockPasswordOnLaunch(false);
exposeAuthStore(authStore);

connectivity = {
Expand Down Expand Up @@ -153,10 +154,12 @@ describe('Activation service - `CLIENT_INIT`', () => {
test('should pass `retryable: false`: CLIENT_INIT must never bootstrap the alarm chain', async () => {
ctx.status = AppStatus.IDLE;
await handler(initMessage('popup'), {});
expect(ctx.service.auth.init).toHaveBeenCalledWith({
forceLock: expect.any(Boolean),
retryable: false,
});
expect(ctx.service.auth.init).toHaveBeenCalledWith(
expect.objectContaining({
forceLock: expect.any(Boolean),
retryable: false,
})
);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { ForkType } from '@proton/shared/lib/authentication/fork/constants';
import { APPS, SSO_PATHS } from '@proton/shared/lib/constants';
import noop from '@proton/utils/noop';

import { shouldForceLock } from './auth/auth.utils';
import { getForceLockOptions } from './auth/auth.utils';

type ActivationServiceState = {
updateAvailable: MaybeNull<string>;
Expand Down Expand Up @@ -92,7 +92,8 @@ export const createActivationService = () => {
/* set `forceLock` flag for subsequent authentication inits to
* account for startup artifically force locking the session */
await ctx.service.storage.local.setItem('forceLock', true);
const loggedIn = await ctx.service.auth.init({ forceLock: true, retryable: true });

const loggedIn = await ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: true });

if (ENV === 'development' && RESUME_FALLBACK) {
if (!loggedIn) {
Expand Down Expand Up @@ -123,7 +124,7 @@ export const createActivationService = () => {
void ctx.service.injection.updateScripts();
ctx.service.spotlight.onUpdate();

return ctx.service.auth.init({ forceLock: await shouldForceLock(), retryable: true });
return ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: true });
}

/** NOTE: Safari might trigger the `install` event when clearing the
Expand Down Expand Up @@ -225,7 +226,7 @@ export const createActivationService = () => {
})();

/** NOTE: `retryable: false` -> don't start resume chain from client inits */
if (shouldResume) void ctx.service.auth.init({ forceLock: await shouldForceLock(), retryable: false });
if (shouldResume) void ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: false });

/** Dispatch a wakeup action for client app receivers. Tracking the wakeup's request metadata
* can be consumed in the UI to infer wakeup result - see `wakeup.saga.ts` no need for any redux
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('Extension AuthService', () => {
api.subscribe = jest.fn();
api.idle = jest.fn().mockResolvedValue(undefined);
authStore = createAuthStore(createMemoryStore());
authStore.setLockPasswordOnLaunch(false);

connectivity = {
online: true,
Expand Down Expand Up @@ -736,6 +737,7 @@ describe('Extension AuthService', () => {
});
jest.spyOn(auth.alarms, 'scheduleAutoResume').mockResolvedValue(undefined);
auth.init = jest.fn().mockResolvedValue(true);
ctx.service.auth = auth;
authStore.setLocalID(123);
auth.listen();
WorkerMessageBroker.ports.query.mockReturnValue([]);
Expand Down Expand Up @@ -786,11 +788,13 @@ describe('Extension AuthService', () => {
await alarmListener();

expect(ctx.service.store.dispatch).not.toHaveBeenCalled();
expect(auth.init).toHaveBeenCalledWith({
forceLock: expect.any(Boolean),
retryable: true,
silence: true,
});
expect(auth.init).toHaveBeenCalledWith(
expect.objectContaining({
forceLock: expect.any(Boolean),
retryable: true,
silence: true,
})
);
});

test('should call `auth.init` when client is ERRORED', async () => {
Expand Down Expand Up @@ -842,11 +846,13 @@ describe('Extension AuthService', () => {
connectivity.status = ConnectivityStatus.ONLINE;
});
await alarmListener();
expect(auth.init).toHaveBeenCalledWith({
forceLock: expect.any(Boolean),
retryable: true,
silence: true,
});
expect(auth.init).toHaveBeenCalledWith(
expect.objectContaining({
forceLock: expect.any(Boolean),
retryable: true,
silence: true,
})
);
expect(auth.alarms.scheduleAutoResume).not.toHaveBeenCalled();
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import noop from '@proton/utils/noop';

import type { AuthAlarms } from './auth.alarms';
import { createAuthAlarms } from './auth.alarms';
import { isOfflineModeEnabled, shouldForceLock, validateExtensionForkPayload } from './auth.utils';
import { getForceLockOptions, isOfflineModeEnabled, shouldForceLock, validateExtensionForkPayload } from './auth.utils';

export interface ExtensionAuthService extends AuthService {
/** Starts extension specific listeners. Moved outside
Expand Down Expand Up @@ -385,7 +385,7 @@ export const createAuthService = (api: Api, authStore: AuthStore) => {
}) as ExtensionAuthService;

const handleInit = withContext<MessageHandlerCallback<WorkerMessageType.AUTH_INIT>>(async (ctx, { options }) => {
options.forceLock = await shouldForceLock();
Object.assign(options, await getForceLockOptions());
await ctx.service.auth.init(options);
return ctx.getState();
});
Expand Down Expand Up @@ -565,8 +565,7 @@ export const createAuthService = (api: Api, authStore: AuthStore) => {
}

if (forceResume) {
const forceLock = await shouldForceLock();
return authService.init({ forceLock, retryable: true, silence: true });
return authService.init({ ...(await getForceLockOptions()), retryable: true, silence: true });
}

logger.debug(`[AuthService] dropped auto resume [${status}]`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { WorkerContextInterface } from 'proton-pass-extension/app/worker/co
import { PassFeature } from '@proton/pass/types/api/features';
import { epochToMs, getEpoch } from '@proton/pass/utils/time/epoch';

import { isOfflineModeEnabled, shouldForceLock } from './auth.utils';
import { getForceLockOptions, isOfflineModeEnabled, shouldForceLock } from './auth.utils';

describe('auth.utils', () => {
let ctx: WorkerContextInterface;
Expand Down Expand Up @@ -127,4 +127,61 @@ describe('auth.utils', () => {
expect(await shouldForceLock()).toBe(false);
});
});

describe('`getForceLockOptions`', () => {
const setup = (persistedSession: any, forceLock = false) => {
ctx = {
authStore: {
getLocalID: jest.fn().mockReturnValue(0),
getLockPasswordOnLaunch: jest.fn().mockReturnValue(undefined),
getLockLastExtendTime: jest.fn().mockReturnValue(undefined),
getLockTTL: jest.fn().mockReturnValue(undefined),
},
service: {
auth: {
config: { getPersistedSession: jest.fn().mockResolvedValue(persistedSession) },
alarms: { autoLockAlarm: { when: jest.fn().mockResolvedValue(undefined) } },
},
storage: {
local: {
getItem: jest.fn(async (key) => (key === 'forceLock' ? forceLock : undefined)),
setItem: jest.fn(),
},
},
},
} as any;
WorkerContext.set(ctx);
};

test.each([
{
name: 'password launch lock is flagged',
persistedSession: {
lockPasswordOnLaunch: true,
offlineConfig: {},
offlineVerifier: 'offline-verifier',
},
forceLock: true,
expected: { forceLock: true, forcePasswordLock: true },
},
{
name: 'password launch lock state is unknown',
persistedSession: null,
expected: { forceLock: true, forcePasswordLock: true },
},
{
name: 'password launch lock is explicitly disabled',
persistedSession: { lockPasswordOnLaunch: false },
expected: { forceLock: false },
},
{
name: 'launch blob exists even if the outer flag is disabled',
persistedSession: { launchPasswordBlob: 'password-blob', lockPasswordOnLaunch: false },
expected: { forceLock: true, forcePasswordLock: true },
},
])('should handle $name', async ({ persistedSession, forceLock, expected }) => {
setup(persistedSession, forceLock);
expect(await getForceLockOptions()).toEqual(expected);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { withContext } from 'proton-pass-extension/app/worker/context/inject';

import { requiresLaunchPasswordUnlock } from '@proton/pass/lib/auth/session';
import { PassFeature } from '@proton/pass/types/api/features';
import type { RequiredProps } from '@proton/pass/types/utils';
import { epochToMs, getEpoch } from '@proton/pass/utils/time/epoch';
Expand Down Expand Up @@ -36,6 +37,29 @@ export const shouldForceLock = withContext<() => Promise<boolean>>(async (ctx) =
}
});

/** Builds auth init options from the protected session launch-lock flag.
* Unknown state is treated as enabled; only an explicit `false` disables it. */
export const getForceLockOptions = withContext(
async (ctx): Promise<{ forceLock: boolean; forcePasswordLock?: boolean }> => {
const authStore = ctx.authStore ?? ctx.service.auth?.config?.authStore;
const localID = authStore?.getLocalID?.();
const persistedSession = await ctx.service.auth?.config?.getPersistedSession?.(localID).catch(() => undefined);
const persistedForcePasswordLock = persistedSession
? requiresLaunchPasswordUnlock(persistedSession)
: undefined;
const memoryForcePasswordLock = authStore?.hasOfflineComponents?.()
? authStore.getLockPasswordOnLaunch?.() !== false
: true;
const forcePasswordLock = persistedForcePasswordLock ?? memoryForcePasswordLock;
const forceLock = (await shouldForceLock()) || forcePasswordLock;

return {
forceLock,
...(forcePasswordLock ? { forcePasswordLock: true } : {}),
};
}
);

export const isOfflineModeEnabled = withContext<() => Promise<boolean>>(async (ctx) => {
try {
const { features } = await ctx.service.featureFlags.resolve();
Expand Down
38 changes: 38 additions & 0 deletions applications/pass/src/lib/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ jest.useFakeTimers();

describe('AuthService', () => {
beforeEach(() => {
(global as any).DESKTOP_BUILD = false;
(global as any).EXTENSION_BUILD = false;
authStore.clear();
jest.clearAllMocks();

Expand Down Expand Up @@ -250,6 +252,42 @@ describe('AuthService', () => {
expect(app.setStatus).toHaveBeenCalledWith(AppStatus.PASSWORD_LOCKED);
});

test('desktop launch password lock uses auth session flag', async () => {
(global as any).DESKTOP_BUILD = true;

getPersistedSession.mockImplementationOnce(async () => ({
...MOCK_PERSISTED_SESSION,
lockMode: LockMode.NONE,
lockPasswordOnLaunch: true,
}));

const options: AuthOptions = {};
const result = await authService.init(options);

expect(result).toBe(false);
expect(options.forceLock).toBe(true);
expect(resumeSession).not.toHaveBeenCalled();
expect(app.setStatus).toHaveBeenCalledWith(AppStatus.PASSWORD_LOCKED);
});

test('desktop launch password lock uses protected blob over outer flag', async () => {
(global as any).DESKTOP_BUILD = true;

getPersistedSession.mockImplementationOnce(async () => ({
...MOCK_PERSISTED_SESSION,
launchPasswordBlob: 'password-blob',
lockMode: LockMode.NONE,
lockPasswordOnLaunch: false,
}));

const options: AuthOptions = {};
const result = await authService.init(options);

expect(result).toBe(false);
expect(resumeSession).not.toHaveBeenCalled();
expect(app.setStatus).toHaveBeenCalledWith(AppStatus.PASSWORD_LOCKED);
});

test('should set `forceLock` to true with empty localID manipulation', async () => {
/** When user has valid session but URL has empty/malformed localID,
* clear auth store and force lock to prevent unauthorized access */
Expand Down
9 changes: 8 additions & 1 deletion applications/pass/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,14 @@ export const createAuthService = ({

const offlineEnabled = (await core.settings.resolve(localID))?.offlineEnabled ?? false;
const offline = !connectivity.online;
const initialLockedStatus = getInitialLockedAppStatus(authStore, { offlineEnabled, offline });
const initialLockedStatus = getInitialLockedAppStatus(authStore, {
offlineEnabled,
offline,
passwordOnLaunch:
DESKTOP_BUILD &&
(Boolean(persistedSession?.launchPasswordBlob) ||
authStore.getLockPasswordOnLaunch() !== false),
});

if (initialLockedStatus) {
authStore.setPassword(undefined);
Expand Down
22 changes: 21 additions & 1 deletion packages/pass/components/Settings/LockSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FC } from 'react';

import { c } from 'ttag';

import Checkbox from '@proton/components/components/input/Checkbox';
import RadioGroup from '@proton/components/components/input/RadioGroup';
import { useOnline } from '@proton/pass/components/Core/ConnectivityProvider';
import { LockTTLField } from '@proton/pass/components/Lock/LockTTLField';
Expand All @@ -15,7 +16,16 @@ type Props = { noTTL?: boolean };

export const LockSetup: FC<Props> = ({ noTTL = false }) => {
const online = useOnline();
const { setLockMode, setLockTTL, lock, biometrics, extensionBiometrics, password } = useLockSetup();
const {
setLockMode,
setLockTTL,
setPasswordOnLaunch,
lock,
biometrics,
extensionBiometrics,
password,
passwordOnLaunch,
} = useLockSetup();
const passwordTypeSwitch = usePasswordTypeSwitch();

/**
Expand Down Expand Up @@ -155,6 +165,16 @@ export const LockSetup: FC<Props> = ({ noTTL = false }) => {
</>
}
/>
{(EXTENSION_BUILD || DESKTOP_BUILD) && (
<Checkbox
className="mt-4"
checked={passwordOnLaunch}
disabled={!online || lock.loading}
onChange={() => setPasswordOnLaunch(!passwordOnLaunch)}
>
{c('Label').t`Require password on launch`}
</Checkbox>
)}
</>
)}
</>
Expand Down
Loading