Skip to content

Commit 7c5512d

Browse files
ggazzoclaude
andcommitted
fix(sdk): re-emit reset on SDK reconnect so Meteor's onReconnect runs
Meteor's accounts-base registers a per-call DDP.onReconnect handler in callLoginMethod (accounts_client.js:292) that retries login with the latest stored token and calls makeClientLoggedOut on failure. With our stub keeping Meteor's connection permanently 'connected', that handler never fires when the underlying SDK socket reconnects, so server-side force-logouts (resetUserE2EKey → ws.terminate) leave the user with stale credentials and no automatic recovery. Listen for the SDK's 'connected' event in stubMeteorStream and fire 'reset' on every subsequent reconnect — Meteor's _streamHandlers.onReset then drives _callOnReconnectAndSendAppropriateOutstandingMethods, which in turn invokes the onReconnect callback. That covers both branches: - if localStorage was rotated by a concurrent flow, the resume retry succeeds and setUserId fires; - if the token is genuinely stale, the resume retry fails and Meteor's own makeClientLoggedOut clears creds and routes to /login. Now that Meteor's flow handles the recovery, simplify the sdk.account.loginWithToken wrap to just swallow the auth-error rejection (so DDPSDK's `void` auto-relogin doesn't surface as an unhandled rejection / pageError) — no more deferred cleanup, no retry-through-Meteor duplication. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db2a823 commit 7c5512d

2 files changed

Lines changed: 41 additions & 68 deletions

File tree

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

Lines changed: 14 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -135,21 +135,6 @@ export const ensureConnectedAndAuthenticated = async (): Promise<void> => {
135135
}
136136
};
137137

138-
const forceClientLoggedOut = (sdk: DDPSDK): void => {
139-
try {
140-
Accounts._unstoreLoginToken();
141-
} catch {
142-
// ignore
143-
}
144-
try {
145-
(Meteor.connection as unknown as { setUserId: (uid: string | null) => void }).setUserId(null);
146-
} catch {
147-
// ignore
148-
}
149-
sdk.account.uid = undefined;
150-
sdk.account.user = undefined;
151-
};
152-
153138
const isAuthError = (error: unknown): boolean => {
154139
if (!error || typeof error !== 'object') return false;
155140
const e = error as { error?: unknown; reason?: unknown };
@@ -236,65 +221,26 @@ if (typeof window !== 'undefined') {
236221
// (nothing rotated it mid-flight) AND
237222
// - the SDK account didn't get refreshed by a successful adopt while
238223
// we were awaiting (sdk.account.uid still maps to this token's user)
224+
// Wrap account.loginWithToken so the SDK's auto-relogin rejection (called
225+
// with `void` in DDPSDK.create) doesn't surface as an unhandled rejection
226+
// (window.onunhandledrejection → pageError). The actual recovery from a
227+
// failed auto-relogin is now driven by Meteor's `DDP.onReconnect`
228+
// callback (registered by `callLoginMethod`), which fires after
229+
// stubMeteorStream re-emits `reset` on each SDK 'connected' event. That
230+
// callback retries login with the latest stored token and calls
231+
// `makeClientLoggedOut` on failure — no need to duplicate that logic.
239232
const account = sdk.account as unknown as { loginWithToken: (token: string) => Promise<unknown> };
240233
const originalLogin = account.loginWithToken.bind(sdk.account);
241234
account.loginWithToken = async (token: string) => {
242-
const triedWithUid = sdk.account.uid;
243235
try {
244236
return await originalLogin(token);
245237
} catch (error) {
246-
if (!isAuthError(error)) throw error;
247-
// Defer the recovery so a concurrent fresh login (e.g. SAML's
248-
// post-redirect resume, password login from the form) has a chance
249-
// to rotate the stored token and SDK account state. After the
250-
// delay, if state is still stuck on the same token+uid we tried
251-
// with, retry the login through Meteor with whatever's currently
252-
// in localStorage. Two outcomes:
253-
// - if localStorage was rotated by a concurrent flow (or a test's
254-
// loginByUserState re-added the same token to mongo via
255-
// $addToSet AFTER the server's unsetLoginTokens), the retry
256-
// hits Meteor's normal success path and setUserId fires;
257-
// - if the token is still genuinely stale (real force-logout, no
258-
// concurrent recovery), Meteor's login callback receives the
259-
// auth error and we call makeClientLoggedOut to drive the user
260-
// to /login.
261-
// This avoids the race where _pollStoredLoginToken short-circuits
262-
// because the stored token didn't visibly change, but the
263-
// underlying mongo token was wiped+re-added.
264-
setTimeout(() => {
265-
const stillSameStored = readStoredLoginToken() === token;
266-
const accountStillReflectsThisToken = sdk.account.uid === triedWithUid;
267-
console.warn('[ddpSdk] auth-error cleanup tick', {
268-
stillSameStored,
269-
accountStillReflectsThisToken,
270-
triedWithUid,
271-
currentUid: sdk.account.uid,
272-
});
273-
if (!stillSameStored || !accountStillReflectsThisToken) return;
274-
const currentToken = readStoredLoginToken();
275-
if (!currentToken) {
276-
console.warn('[ddpSdk] auth-error: no stored token, forcing logout');
277-
forceClientLoggedOut(sdk);
278-
return;
279-
}
280-
console.warn('[ddpSdk] auth-error: retrying Meteor.loginWithToken');
281-
try {
282-
(Meteor as unknown as { loginWithToken: (token: string, cb: (err: unknown) => void) => void }).loginWithToken(
283-
currentToken,
284-
(err) => {
285-
if (err) {
286-
console.warn('[ddpSdk] retry Meteor.loginWithToken rejected', err);
287-
forceClientLoggedOut(sdk);
288-
} else {
289-
console.warn('[ddpSdk] retry Meteor.loginWithToken succeeded');
290-
}
291-
},
292-
);
293-
} catch (e) {
294-
console.warn('[ddpSdk] retry Meteor.loginWithToken threw', e);
295-
forceClientLoggedOut(sdk);
296-
}
297-
}, 500);
238+
if (isAuthError(error)) {
239+
// Meteor's onReconnect path will retry through stubMeteorStream
240+
// with the current localStorage token; nothing for us to do here
241+
// beyond not letting the rejection escape.
242+
return undefined;
243+
}
298244
throw error;
299245
}
300246
};

apps/meteor/client/meteor/overrides/stubMeteorStream.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,30 @@ queueMicrotask(() => {
211211
console.warn('[stubMeteorStream] failed to bootstrap connected state', err);
212212
}
213213
});
214+
215+
// When the underlying SDK socket reconnects (e.g. after a server-side
216+
// ws.terminate from force-logout in microservices), Meteor's connection sees
217+
// no transport event because the stub keeps reporting 'connected'. As a
218+
// result, Meteor's normal reconnect machinery — `_streamHandlers.onReset` →
219+
// `_callOnReconnectAndSendAppropriateOutstandingMethods` → DDP.onReconnect
220+
// callbacks → the per-call `_reconnectStopper` that retries login with the
221+
// latest stored token (and calls `makeClientLoggedOut` on failure) — never
222+
// fires. The user is left with stale credentials.
223+
//
224+
// Fire `reset` on every subsequent SDK 'connected' event so accounts-base's
225+
// onReconnect callback retries login with whatever's currently in
226+
// localStorage. The first connect is handled by the queueMicrotask above;
227+
// skip it here.
228+
const sdk = getDdpSdk();
229+
let firstConnectHandled = false;
230+
sdk.connection.on('connected', () => {
231+
if (!firstConnectHandled) {
232+
firstConnectHandled = true;
233+
return;
234+
}
235+
try {
236+
fire('reset');
237+
} catch (err) {
238+
console.warn('[stubMeteorStream] reset on SDK reconnect failed', err);
239+
}
240+
});

0 commit comments

Comments
 (0)