fix(auth): persist MFA sessions across browser restarts (#15379)

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`.
This commit is contained in:
Shivam Mishra
2026-08-11 15:03:49 +05:30
committed by GitHub
parent 0a2293f921
commit 875ea6e88f
2 changed files with 59 additions and 21 deletions

View File

@@ -0,0 +1,53 @@
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]]);
});
});

View File

@@ -3,7 +3,10 @@ import axios from 'axios';
import { ref, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { handleOtpPaste } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import {
parseAPIErrorResponse,
setAuthCredentials,
} from 'dashboard/store/utils/api';
import { useAccount } from 'dashboard/composables/useAccount';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -68,26 +71,8 @@ const handleVerification = async () => {
}
const response = await axios.post('/auth/sign_in', payload);
// Set auth credentials and redirect
if (response.data && response.headers) {
// Store auth credentials in cookies
const authData = {
'access-token': response.headers['access-token'],
'token-type': response.headers['token-type'],
client: response.headers.client,
expiry: response.headers.expiry,
uid: response.headers.uid,
};
// Store in cookies for auth
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/app/';
} else {
emit('verified', response.data);
}
setAuthCredentials(response);
emit('verified', response.data);
} catch (error) {
errorMessage.value =
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');