Skip to content

Commit efdefa6

Browse files
authored
regression: redirect to login when stored token is expired (#40870)
1 parent 67b1720 commit efdefa6

3 files changed

Lines changed: 65 additions & 8 deletions

File tree

apps/meteor/client/lib/sdk/ddpSdk.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,22 +151,41 @@ export const ensureConnectedAndAuthenticated = async (): Promise<void> => {
151151
// latter dispatches a `logout` method which itself races against
152152
// parallel re-auth flows in CI's parallel-shard environment and
153153
// kicked otherwise-healthy tests out.
154-
removeStoredItem(STORAGE_KEYS.USER_ID);
155-
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
156-
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN_EXPIRES);
157-
Meteor.connection.setUserId(null);
154+
clearStoredCredentials();
158155
return;
159156
}
160157
console.warn('[ddpSdk] loginWithToken failed', error);
161158
}
162159
};
163160

164-
const isAuthError = (error: unknown): boolean => {
161+
/**
162+
* Drop the local session credentials without dispatching Meteor's `logout`
163+
* method. Nulling the connection userId propagates through the
164+
* Accounts.connection.userId() Tracker.autorun (see overrides/userAndUsers.ts)
165+
* into the userIdStore, so `useUserId()` becomes undefined and the router falls
166+
* through to LoginPage. We avoid `Meteor.logout()` on purpose: it dispatches a
167+
* `logout` method that races parallel re-auth flows (fresh registration,
168+
* Meteor's own resume) and has kicked otherwise-healthy sessions/tests out.
169+
*/
170+
export const clearStoredCredentials = (): void => {
171+
removeStoredItem(STORAGE_KEYS.USER_ID);
172+
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
173+
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN_EXPIRES);
174+
Meteor.connection.setUserId(null);
175+
};
176+
177+
export const isAuthError = (error: unknown): boolean => {
165178
if (!error || typeof error !== 'object') return false;
166-
const e = error as { error?: unknown; reason?: unknown };
179+
const e = error as { error?: unknown; reason?: unknown; status?: unknown; statusCode?: unknown };
167180
return (
168181
e.error === 401 ||
169182
e.error === 403 ||
183+
// REST-shaped failures (e.g. sdk.rest.get('/v1/me'), userData stream
184+
// `nosub`) surface the HTTP status instead of a DDP `error` code.
185+
e.status === 401 ||
186+
e.status === 403 ||
187+
e.statusCode === 401 ||
188+
e.statusCode === 403 ||
170189
e.reason === 'User not found' ||
171190
e.reason === 'Login token expired' ||
172191
e.reason === 'You are not allowed to use this token'

apps/meteor/client/meteor/overrides/ddpOverREST.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
22

33
import { sdk } from '../../../app/utils/client/lib/SDKClient';
44
import { parseDDP, stringifyDDP } from '../../lib/sdk/ddpProtocol';
5+
import { clearStoredCredentials } from '../../lib/sdk/ddpSdk';
56
import { getUserId } from '../../lib/user';
67

78
const bypassMethods: string[] = ['setUserStatus', 'logout'];
@@ -102,6 +103,21 @@ const withDDPOverREST = (_send: (this: Meteor.IMeteorConnection, message: Meteor
102103
.catch((error: unknown) => {
103104
const e = (error ?? {}) as { error?: unknown; reason?: unknown; message?: unknown };
104105

106+
// Check if it's a session expiration error and clear credentials.
107+
const isExpiredSessionError =
108+
(typeof e.error === 'string' && e.error === 'You must be logged in to do this.') ||
109+
(typeof e.message === 'string' && e.message === 'You must be logged in to do this.') ||
110+
(typeof e.reason === 'string' && e.reason === 'You must be logged in to do this.');
111+
112+
if (isExpiredSessionError) {
113+
console.warn('[ddpOverREST] Expired session detected, clearing credentials', { method: message.method, error });
114+
try {
115+
clearStoredCredentials();
116+
} catch (cleanupError) {
117+
console.warn('[ddpOverREST] Failed to clean up expired session', cleanupError);
118+
}
119+
}
120+
105121
// method.call / method.callAnon encode the original Meteor error
106122
// inside `body.message` as a DDP `result` frame (mountResult in
107123
// app/api/server/v1/misc.ts). Forward it untouched so the original

apps/meteor/client/startup/startup.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import type { UserStatus } from '@rocket.chat/core-typings';
33
import 'highlight.js/styles/github.css';
44
import { sdk } from '../../app/utils/client/lib/SDKClient';
55
import { onLoggedIn } from '../lib/loggedIn';
6-
import { ensureConnectedAndAuthenticated, getDdpSdk } from '../lib/sdk/ddpSdk';
6+
import { clearStoredCredentials, ensureConnectedAndAuthenticated, getDdpSdk, isAuthError } from '../lib/sdk/ddpSdk';
77
import { isSdkTransportEnabled } from '../lib/sdk/sdkTransportEnabled';
8+
import { STORAGE_KEYS, getStoredItem } from '../lib/sdk/storage';
89
import { userIdStore } from '../lib/user';
910
import { removeLocalUserData, synchronizeUserData } from '../lib/userData';
1011
import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent';
@@ -47,12 +48,33 @@ if (!sdkTransportEnabled) {
4748
// of userIdStore, so without sequencing the sub races the auth and
4849
// hits the rejection on every re-login. Await the SDK auth here so
4950
// the sub fires authenticated.
51+
const tokenBeforeSync = getStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
5052
try {
5153
await ensureConnectedAndAuthenticated();
5254
} catch {
5355
// non-fatal: sdk.stream queues until DDPSDK eventually auths
5456
}
55-
const user = await synchronizeUserData(uid);
57+
58+
let user: Awaited<ReturnType<typeof synchronizeUserData>>;
59+
try {
60+
user = await synchronizeUserData(uid);
61+
} catch (error) {
62+
// When the stored token is expired/revoked server-side, the userData
63+
// stream sub + /v1/me come back 401/403, so synchronizeUserData throws
64+
// and useUserDataSyncReady never flips true. useMainReady then stays
65+
// false forever (uid is still truthy from the stale credentials), so
66+
// Preload sits on PageLoading — and because Preload WRAPS
67+
// AuthenticationCheck, the user never reaches LoginPage and the
68+
// workspace looks stuck "loading"/grayed-out. Escalate the dead-session
69+
// signal to a credential wipe so userId drops to null and the router
70+
// falls through to the login screen. Token-stable guard: only clear
71+
// when localStorage still holds the token we synced with, so a parallel
72+
// re-auth that already rotated the token isn't kicked out.
73+
if (isAuthError(error) && getStoredItem(STORAGE_KEYS.LOGIN_TOKEN) === tokenBeforeSync) {
74+
clearStoredCredentials();
75+
}
76+
throw error;
77+
}
5678
if (!user) return;
5779

5880
const utcOffset = -new Date().getTimezoneOffset() / 60;

0 commit comments

Comments
 (0)