Preserve profiles across HttpOnly session upgrades

Capture and resume the pre-upgrade browser token before Accounts.config replaces local storage with its in-memory store. This prevents the existing token poll from logging the user out and discarding reactive profile settings after upgrading from v11.39, while completing migration to the native HttpOnly cookie flow. Add static positive and negative guards plus a browser upgrade regression covering identity and board preferences beyond the poll boundary.

Thanks to Alishara and xet7 !

Fixes #6677,
This commit is contained in:
Lauri Ojansivu committed 2026-09-07 13:22:50 +03:00
1 parent eabe5f8f61
commit 9f47f00cf6
3 files changed
+52 -14

No files matched your search

+14 -9
View File
@@ -70,16 +70,21 @@ import { Utils } from '/client/lib/utils';
// Custom head tags
// Meteor 3's native session flow keeps the persistent resume token in an
// HttpOnly cookie and only the active tab's credential in memory. Configure it
// before Accounts startup; the server has the same options in accounts-common.
// AccountsClient is constructed before application modules run. Preserve a
// pre-upgrade local token before switching its storage to memory: config()
// replaces the storage object immediately, while the already-started 3-second
// token poll otherwise sees an empty store and logs the user out. That race
// removed the reactive user document (name, avatar, theme and preferences),
// caused repeated login redirects in some browsers and kept Tracker busy
// (#6677). A successful one-time resume also creates the native HttpOnly cookie;
// installations which already have that cookie use the ordinary cookie resume.
const legacyResumeToken = Accounts._storedLoginToken();
Accounts.config({ clientStorage: 'none', useHttpOnlyCookies: true });
// AccountsClient is constructed before this application startup module runs.
// Its constructor therefore cannot see the option above and skips its one-time
// cookie resume. Start that public resume path now, after enabling it, so a
// reload or board-view navigation restores the session from the HttpOnly
// cookie instead of returning a private-board user to Sign In (#6654).
Accounts.loginWithCookie();
if (legacyResumeToken) {
Accounts.loginWithToken(legacyResumeToken);
} else {
Accounts.loginWithCookie();
}
// Subscribe to per-user small publications
Meteor.startup(() => {
+8 -5
View File
@@ -21,11 +21,14 @@ for (const [where, source] of [['client', client], ['server', server]]) {
assert.doesNotMatch(client, /document\.cookie\s*=/,
'client JavaScript must never create a readable authentication cookie');
assert.doesNotMatch(client, /_storedLoginToken|_storeLoginToken/,
'custom token copying must not bypass the native in-memory flow');
assert.match(client,
/Accounts\.config\(\{ clientStorage: 'none', useHttpOnlyCookies: true \}\);[\s\S]*Accounts\.loginWithCookie\(\);/,
'the cookie resume must start after the late client configuration (#6654)');
/const legacyResumeToken = Accounts\._storedLoginToken\(\);[\s\S]*Accounts\.config\(\{ clientStorage: 'none', useHttpOnlyCookies: true \}\);/,
'an upgrade must read its old token before config replaces local storage');
assert.doesNotMatch(client, /Accounts\._storeLoginToken/,
'application code must never write directly to Accounts token storage');
assert.match(client,
/if \(legacyResumeToken\) \{\s*Accounts\.loginWithToken\(legacyResumeToken\);\s*\} else \{\s*Accounts\.loginWithCookie\(\);\s*\}/,
'an upgrade resumes once from its old token and a cookie-native client uses its cookie');
assert.doesNotMatch(client, /localStorage\.getItem\(['"]Meteor\.loginToken/,
'reload recovery must not restore the old JavaScript-readable token flow');
assert.match(headerLogin, /\['Path=\/', 'SameSite=Lax', 'HttpOnly'\]/,
@@ -33,4 +36,4 @@ assert.match(headerLogin, /\['Path=\/', 'SameSite=Lax', 'HttpOnly'\]/,
assert.match(headerLogin, /cookieBase\.push\('Secure'\)/,
'HTTPS header login must retain Secure in addition to HttpOnly');
console.log('httpOnlySessionCookie: 10 assertions passed');
console.log('httpOnlySessionCookie: 11 assertions passed');
@@ -68,6 +68,36 @@ test.describe('Stability & connectivity', () => {
await expect(boardPage.locator('[name="username"]')).toHaveCount(0);
});
test('#6677: an upgraded local-token session keeps its profile and preferences', async ({ page, user, board }) => {
// Recreate the exact upgrade boundary: v11.39 left the resume token in
// localStorage and had no HttpOnly cookie yet.
await page.context().clearCookies();
await page.goto(BASE_URL);
await page.evaluate(({ userId, token }) => {
localStorage.setItem('Meteor.userId', userId);
localStorage.setItem('Meteor.loginToken', token);
localStorage.setItem('Meteor.loginTokenExpires', new Date(Date.now() + 86400000).toISOString());
}, { userId: user.id, token: user.token });
await page.goto(`${BASE_URL}/b/${board.boardId}/${board.slug}`);
await page.waitForFunction(() => typeof Meteor !== 'undefined', null, { timeout: 10_000 });
await expect.poll(() => page.evaluate(() => ({
id: Meteor.userId(),
fullname: Meteor.user()?.profile?.fullname,
view: Meteor.user()?.profile?.boardView,
})), { timeout: 10_000 }).toEqual({
id: user.id,
fullname: 'E2E Test User',
view: 'board-view-swimlanes',
});
// The old poll fired every three seconds; waiting beyond it is the negative
// assertion that the empty in-memory store no longer logs the user out.
await page.waitForTimeout(3_500);
await expect.poll(() => page.evaluate(() => Meteor.userId())).toBe(user.id);
await expect(page.locator('.board-canvas')).toBeVisible();
});
test('login link is visible on 5 consecutive fresh page loads', async ({ page }) => {
for (let i = 0; i < 5; i++) {
await page.goto(`${BASE_URL}/sign-in`, { waitUntil: 'networkidle' });