MFA-authenticated users now remain signed in across browser restarts, matching the session lifetime of password-authenticated users. ## Closes - [CW-7898](https://linear.app/chatwoot/issue/CW-7898/mfa-users-are-logged-out-when-the-browser-session-ends) ## How to reproduce 1. Enable MFA for a user. 2. Sign in and complete OTP or backup-code verification. 3. End the browser session, then reopen the browser and return to Chatwoot. 4. Before this change, the user is sent back to the login page. ## Root cause and evidence Before this change, successful MFA verification wrote `cw_d_session_info` directly through `document.cookie` without an `Expires` or `Max-Age` attribute. This makes it a session cookie: - [MFA cookie creation on the base commit](a4eae9710a/app/javascript/dashboard/components/auth/MfaVerification.vue (L70-L87)) - [MDN session-cookie behavior](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie) - [Chromium cookie persistence documentation](https://chromium.googlesource.com/chromium/src/+/master/net/cookies/README.md) Password login already uses `setAuthCredentials`, which derives a persistent cookie expiry from the authentication response: - [Existing credential helper](a4eae9710a/app/javascript/dashboard/store/utils/api.js (L28-L36)) - [Configured two-month token lifetime](a4eae9710a/config/initializers/devise_token_auth.rb (L8-L10)) This change routes successful MFA verification through the same credential helper and existing verified event. The regression test asserts that MFA credentials use this shared persistence path. Session-cookie restoration is browser and profile dependent. Chromium may persist session cookies when restoring a previous browsing session, so the problem can be masked on some restarts. This PR does not rely on a desktop-versus-mobile distinction. ## How to verify 1. On `develop`, complete an MFA login. 2. Inspect `cw_d_session_info` in browser developer tools. Its expiry is shown as `Session`. 3. Repeat on this branch. 4. Confirm that `cw_d_session_info` has a concrete expiry derived from the authentication response instead of `Session`.
54 lines
1.6 KiB
JavaScript
54 lines
1.6 KiB
JavaScript
import { flushPromises, shallowMount } from '@vue/test-utils';
|
|
import axios from 'axios';
|
|
import { setAuthCredentials } from 'dashboard/store/utils/api';
|
|
import MfaVerification from './MfaVerification.vue';
|
|
|
|
vi.mock('axios');
|
|
|
|
vi.mock('dashboard/store/utils/api', () => ({
|
|
parseAPIErrorResponse: vi.fn(),
|
|
setAuthCredentials: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('dashboard/composables/useAccount', async () => {
|
|
const { ref } = await import('vue');
|
|
return { useAccount: () => ({ isOnChatwootCloud: ref(true) }) };
|
|
});
|
|
|
|
describe('MfaVerification', () => {
|
|
it('stores successful MFA credentials using the persistent auth flow', async () => {
|
|
const response = {
|
|
data: { data: { id: 1 } },
|
|
headers: {
|
|
'access-token': 'token',
|
|
'token-type': 'Bearer',
|
|
client: 'client',
|
|
expiry: '1789084800',
|
|
uid: 'user@example.com',
|
|
},
|
|
};
|
|
axios.post.mockResolvedValue(response);
|
|
|
|
const wrapper = shallowMount(MfaVerification, {
|
|
props: { mfaToken: 'mfa-token' },
|
|
global: { mocks: { $t: key => key } },
|
|
});
|
|
|
|
const otpInputs = wrapper.findAll('input[inputmode="numeric"]');
|
|
await otpInputs[0].setValue('1');
|
|
await otpInputs[1].setValue('2');
|
|
await otpInputs[2].setValue('3');
|
|
await otpInputs[3].setValue('4');
|
|
await otpInputs[4].setValue('5');
|
|
await otpInputs[5].setValue('6');
|
|
await flushPromises();
|
|
|
|
expect(axios.post).toHaveBeenCalledWith('/auth/sign_in', {
|
|
mfa_token: 'mfa-token',
|
|
otp_code: '123456',
|
|
});
|
|
expect(setAuthCredentials).toHaveBeenCalledWith(response);
|
|
expect(wrapper.emitted('verified')).toEqual([[response.data]]);
|
|
});
|
|
});
|