Skip to content

Commit d661485

Browse files
nipunn1313sshadererquhart
authored and
Convex, Inc.
committedMar 14, 2025·
convex-js PR 32: fix auth races for react native (#35392)
Most of this PR is from #29 from @erquhart There's one more commit I added to (1) use `authUpdateAttempted` on `AuthError` instead of needing to string match now that the backend feature is in -- we should double check that we're seeing this field come through on the WS with any repros we have and (2) add even more comments to the tests. (pushed up here because GH permissions are hard) Co-authored-by: Sarah Shader <sarahshader@gmail.com> Co-authored-by: Shawn Erquhart <shawn@erquh.art> GitOrigin-RevId: 446f5ade8dee52d8b8c045e80105c26200458a8c
1 parent 72edd94 commit d661485

File tree

5 files changed

+674
-116
lines changed

5 files changed

+674
-116
lines changed
 

‎src/browser/sync/authentication_manager.ts

+74-17
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Logger } from "../logging.js";
22
import { LocalSyncState } from "./local_state.js";
3-
import { AuthError, Transition } from "./protocol.js";
3+
import { AuthError, IdentityVersion, Transition } from "./protocol.js";
44
import jwtDecode from "jwt-decode";
55

66
// setTimout uses 32 bit integer, so it can only
77
// schedule about 24 days in the future.
88
const MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1000; // 20 days
99

10+
const MAX_TOKEN_CONFIRMATION_ATTEMPTS = 2;
11+
1012
/**
1113
* An async function returning the JWT-encoded OpenID Connect Identity Token
1214
* if available.
@@ -83,21 +85,24 @@ export class AuthenticationManager {
8385
// Shared by the BaseClient so that the auth manager can easily inspect it
8486
private readonly syncState: LocalSyncState;
8587
// Passed down by BaseClient, sends a message to the server
86-
private readonly authenticate: (token: string) => void;
88+
private readonly authenticate: (token: string) => IdentityVersion;
8789
private readonly stopSocket: () => Promise<void>;
88-
private readonly restartSocket: () => void;
90+
private readonly tryRestartSocket: () => void;
8991
private readonly pauseSocket: () => void;
9092
private readonly resumeSocket: () => void;
9193
// Passed down by BaseClient, sends a message to the server
9294
private readonly clearAuth: () => void;
9395
private readonly logger: Logger;
9496
private readonly refreshTokenLeewaySeconds: number;
97+
// Number of times we have attempted to confirm the latest token. We retry up
98+
// to `MAX_TOKEN_CONFIRMATION_ATTEMPTS` times.
99+
private tokenConfirmationAttempts = 0;
95100
constructor(
96101
syncState: LocalSyncState,
97102
callbacks: {
98-
authenticate: (token: string) => void;
103+
authenticate: (token: string) => IdentityVersion;
99104
stopSocket: () => Promise<void>;
100-
restartSocket: () => void;
105+
tryRestartSocket: () => void;
101106
pauseSocket: () => void;
102107
resumeSocket: () => void;
103108
clearAuth: () => void;
@@ -110,7 +115,7 @@ export class AuthenticationManager {
110115
this.syncState = syncState;
111116
this.authenticate = callbacks.authenticate;
112117
this.stopSocket = callbacks.stopSocket;
113-
this.restartSocket = callbacks.restartSocket;
118+
this.tryRestartSocket = callbacks.tryRestartSocket;
114119
this.pauseSocket = callbacks.pauseSocket;
115120
this.resumeSocket = callbacks.resumeSocket;
116121
this.clearAuth = callbacks.clearAuth;
@@ -138,8 +143,6 @@ export class AuthenticationManager {
138143
hasRetried: false,
139144
});
140145
this.authenticate(token.value);
141-
this._logVerbose("resuming WS after auth token fetch");
142-
this.resumeSocket();
143146
} else {
144147
this.setAuthState({
145148
state: "initialRefetch",
@@ -148,6 +151,8 @@ export class AuthenticationManager {
148151
// Try again with `forceRefreshToken: true`
149152
await this.refetchToken();
150153
}
154+
this._logVerbose("resuming WS after auth token fetch");
155+
this.resumeSocket();
151156
}
152157

153158
onTransition(serverMessage: Transition) {
@@ -176,13 +181,24 @@ export class AuthenticationManager {
176181
if (this.authState.state === "waitingForServerConfirmationOfFreshToken") {
177182
this._logVerbose("server confirmed new auth token is valid");
178183
this.scheduleTokenRefetch(this.authState.token);
184+
this.tokenConfirmationAttempts = 0;
179185
if (!this.authState.hadAuth) {
180186
this.authState.config.onAuthChange(true);
181187
}
182188
}
183189
}
184190

185191
onAuthError(serverMessage: AuthError) {
192+
// If the AuthError is not due to updating the token, and we're currently
193+
// waiting on the result of a token update, ignore.
194+
if (
195+
serverMessage.authUpdateAttempted === false &&
196+
(this.authState.state === "waitingForServerConfirmationOfFreshToken" ||
197+
this.authState.state === "waitingForServerConfirmationOfCachedToken")
198+
) {
199+
this._logVerbose("ignoring non-auth token expired error");
200+
return;
201+
}
186202
const { baseVersion } = serverMessage;
187203
// Versioned AuthErrors are ignored if the client advanced to
188204
// a newer auth identity
@@ -201,12 +217,14 @@ export class AuthenticationManager {
201217
// in that we pause the WebSocket so that mutations
202218
// don't retry with bad auth.
203219
private async tryToReauthenticate(serverMessage: AuthError) {
204-
// We only retry once, to avoid infinite retries
220+
this._logVerbose(`attempting to reauthenticate: ${serverMessage.error}`);
205221
if (
206222
// No way to fetch another token, kaboom
207223
this.authState.state === "noAuth" ||
208-
// We failed on a fresh token, trying another one won't help
209-
this.authState.state === "waitingForServerConfirmationOfFreshToken"
224+
// We failed on a fresh token. After a small number of retries, we give up
225+
// and clear the auth state to avoid infinite retries.
226+
(this.authState.state === "waitingForServerConfirmationOfFreshToken" &&
227+
this.tokenConfirmationAttempts >= MAX_TOKEN_CONFIRMATION_ATTEMPTS)
210228
) {
211229
this.logger.error(
212230
`Failed to authenticate: "${serverMessage.error}", check your server auth config`,
@@ -219,7 +237,13 @@ export class AuthenticationManager {
219237
}
220238
return;
221239
}
222-
this._logVerbose("attempting to reauthenticate");
240+
if (this.authState.state === "waitingForServerConfirmationOfFreshToken") {
241+
this.tokenConfirmationAttempts++;
242+
this._logVerbose(
243+
`retrying reauthentication, ${MAX_TOKEN_CONFIRMATION_ATTEMPTS - this.tokenConfirmationAttempts} attempts remaining`,
244+
);
245+
}
246+
223247
await this.stopSocket();
224248
const token = await this.fetchTokenAndGuardAgainstRace(
225249
this.authState.config.fetchToken,
@@ -248,7 +272,7 @@ export class AuthenticationManager {
248272
}
249273
this.setAndReportAuthFailed(this.authState.config.onAuthChange);
250274
}
251-
this.restartSocket();
275+
this.tryRestartSocket();
252276
}
253277

254278
// Force refetch the token and schedule another refetch
@@ -291,12 +315,12 @@ export class AuthenticationManager {
291315
}
292316
this.setAndReportAuthFailed(this.authState.config.onAuthChange);
293317
}
294-
// Resuming in case this refetch was triggered
295-
// by an invalid cached token.
318+
// Restart in case this refetch was triggered via schedule during
319+
// a reauthentication attempt.
296320
this._logVerbose(
297-
"resuming WS after auth token fetch (if currently paused)",
321+
"restarting WS after auth token fetch (if currently stopped)",
298322
);
299-
this.resumeSocket();
323+
this.tryRestartSocket();
300324
}
301325

302326
private scheduleTokenRefetch(token: string) {
@@ -348,6 +372,7 @@ export class AuthenticationManager {
348372
delay = 0;
349373
}
350374
const refetchTokenTimeoutId = setTimeout(() => {
375+
this._logVerbose("running scheduled token refetch");
351376
void this.refetchToken();
352377
}, delay);
353378
this.setAuthState({
@@ -369,9 +394,15 @@ export class AuthenticationManager {
369394
},
370395
) {
371396
const originalConfigVersion = ++this.configVersion;
397+
this._logVerbose(
398+
`fetching token with config version ${originalConfigVersion}`,
399+
);
372400
const token = await fetchToken(fetchArgs);
373401
if (this.configVersion !== originalConfigVersion) {
374402
// This is a stale config
403+
this._logVerbose(
404+
`stale config version, expected ${originalConfigVersion}, got ${this.configVersion}`,
405+
);
375406
return { isFromOutdatedConfig: true };
376407
}
377408
return { isFromOutdatedConfig: false, value: token };
@@ -381,6 +412,7 @@ export class AuthenticationManager {
381412
this.resetAuthState();
382413
// Bump this in case we are mid-token-fetch when we get stopped
383414
this.configVersion++;
415+
this._logVerbose(`config version bumped to ${this.configVersion}`);
384416
}
385417

386418
private setAndReportAuthFailed(
@@ -395,6 +427,31 @@ export class AuthenticationManager {
395427
}
396428

397429
private setAuthState(newAuth: AuthState) {
430+
const authStateForLog =
431+
newAuth.state === "waitingForServerConfirmationOfFreshToken"
432+
? {
433+
hadAuth: newAuth.hadAuth,
434+
state: newAuth.state,
435+
token: `...${newAuth.token.slice(-7)}`,
436+
}
437+
: { state: newAuth.state };
438+
this._logVerbose(
439+
`setting auth state to ${JSON.stringify(authStateForLog)}`,
440+
);
441+
switch (newAuth.state) {
442+
case "waitingForScheduledRefetch":
443+
case "notRefetching":
444+
case "noAuth":
445+
this.tokenConfirmationAttempts = 0;
446+
break;
447+
case "waitingForServerConfirmationOfFreshToken":
448+
case "waitingForServerConfirmationOfCachedToken":
449+
case "initialRefetch":
450+
break;
451+
default: {
452+
const _typeCheck: never = newAuth;
453+
}
454+
}
398455
if (this.authState.state === "waitingForScheduledRefetch") {
399456
clearTimeout(this.authState.refetchTokenTimeoutId);
400457

‎src/browser/sync/client.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,10 @@ export class BaseConvexClient {
278278
authenticate: (token) => {
279279
const message = this.state.setAuth(token);
280280
this.webSocketManager.sendMessage(message);
281+
return message.baseVersion;
281282
},
282283
stopSocket: () => this.webSocketManager.stop(),
283-
restartSocket: () => this.webSocketManager.restart(),
284+
tryRestartSocket: () => this.webSocketManager.tryRestart(),
284285
pauseSocket: () => {
285286
this.webSocketManager.pause();
286287
this.state.pause();

‎src/browser/sync/protocol.ts

+4
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@ export type AuthError = {
293293
type: "AuthError";
294294
error: string;
295295
baseVersion: IdentityVersion;
296+
// True if this error is in response to processing a new `Authenticate` message.
297+
// Other AuthErrors may occur due to executing a function with expired auth and
298+
// should be handled differently.
299+
authUpdateAttempted: boolean;
296300
};
297301
type FatalError = {
298302
type: "FatalError";

‎src/browser/sync/web_socket_manager.ts

+36-11
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,15 @@ export class WebSocketManager {
159159
this.connect();
160160
}
161161

162+
private setSocketState(state: Socket) {
163+
this.socket = state;
164+
this._logVerbose(
165+
`socket state changed: ${this.socket.state}, paused: ${
166+
"paused" in this.socket ? this.socket.paused : undefined
167+
}`,
168+
);
169+
}
170+
162171
private connect() {
163172
if (this.socket.state === "terminated") {
164173
return;
@@ -174,11 +183,11 @@ export class WebSocketManager {
174183

175184
const ws = new this.webSocketConstructor(this.uri);
176185
this._logVerbose("constructed WebSocket");
177-
this.socket = {
186+
this.setSocketState({
178187
state: "connecting",
179188
ws,
180189
paused: "no",
181-
};
190+
});
182191

183192
// Kick off server inactivity timer before WebSocket connection is established
184193
// so we can detect cases where handshake fails.
@@ -191,11 +200,11 @@ export class WebSocketManager {
191200
if (this.socket.state !== "connecting") {
192201
throw new Error("onopen called with socket not in connecting state");
193202
}
194-
this.socket = {
203+
this.setSocketState({
195204
state: "ready",
196205
ws,
197206
paused: this.socket.paused === "yes" ? "uninitialized" : "no",
198-
};
207+
});
199208
this.resetServerInactivityTimeout();
200209
if (this.socket.paused === "no") {
201210
this._hasEverConnected = true;
@@ -261,8 +270,14 @@ export class WebSocketManager {
261270
* @returns Whether the message (might have been) sent.
262271
*/
263272
sendMessage(message: ClientMessage) {
264-
this._logVerbose(`sending message with type ${message.type}`);
265-
273+
const messageForLog = {
274+
type: message.type,
275+
...(message.type === "Authenticate" && message.tokenType === "User"
276+
? {
277+
value: `...${message.value.slice(-7)}`,
278+
}
279+
: {}),
280+
};
266281
if (this.socket.state === "ready" && this.socket.paused === "no") {
267282
const encodedMessage = encodeClientMessage(message);
268283
const request = JSON.stringify(encodedMessage);
@@ -275,8 +290,19 @@ export class WebSocketManager {
275290
this.closeAndReconnect("FailedToSendMessage");
276291
}
277292
// We are not sure if this was sent or not.
293+
this._logVerbose(
294+
`sent message with type ${message.type}: ${JSON.stringify(
295+
messageForLog,
296+
)}`,
297+
);
278298
return true;
279299
}
300+
this._logVerbose(
301+
`message not sent (socket state: ${this.socket.state}, paused: ${"paused" in this.socket ? this.socket.paused : undefined}): ${JSON.stringify(
302+
messageForLog,
303+
)}`,
304+
);
305+
280306
return false;
281307
}
282308

@@ -390,7 +416,7 @@ export class WebSocketManager {
390416
case "connecting":
391417
case "ready": {
392418
const result = this.close();
393-
this.socket = { state: "terminated" };
419+
this.setSocketState({ state: "terminated" });
394420
return result;
395421
}
396422
default: {
@@ -428,17 +454,16 @@ export class WebSocketManager {
428454
* Create a new WebSocket after a previous `stop()`, unless `terminate()` was
429455
* called before.
430456
*/
431-
restart(): void {
457+
tryRestart(): void {
432458
switch (this.socket.state) {
433459
case "stopped":
434460
break;
435461
case "terminated":
436-
// If we're terminating we ignore restart
437-
return;
438462
case "connecting":
439463
case "ready":
440464
case "disconnected":
441-
throw new Error("`restart()` is only valid after `stop()`");
465+
this.logger.logVerbose("Restart called without stopping first");
466+
return;
442467
default: {
443468
// Enforce that the switch-case is exhaustive.
444469
const _: never = this.socket;

0 commit comments

Comments
 (0)