Skip to content

fix auth races for react native #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 52 additions & 9 deletions src/browser/sync/authentication_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import jwtDecode from "jwt-decode";
// schedule about 24 days in the future.
const MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1000; // 20 days

const TOKEN_CONFIRMATION_RETRIES = 2;

/**
* An async function returning the JWT-encoded OpenID Connect Identity Token
* if available.
Expand Down Expand Up @@ -92,6 +94,7 @@ export class AuthenticationManager {
private readonly clearAuth: () => void;
private readonly logger: Logger;
private readonly refreshTokenLeewaySeconds: number;
private tokenConfirmationRetries = TOKEN_CONFIRMATION_RETRIES;
constructor(
syncState: LocalSyncState,
callbacks: {
Expand Down Expand Up @@ -138,8 +141,6 @@ export class AuthenticationManager {
hasRetried: false,
});
this.authenticate(token.value);
this._logVerbose("resuming WS after auth token fetch");
this.resumeSocket();
} else {
this.setAuthState({
state: "initialRefetch",
Expand All @@ -148,6 +149,8 @@ export class AuthenticationManager {
// Try again with `forceRefreshToken: true`
await this.refetchToken();
}
this._logVerbose("resuming WS after auth token fetch");
this.resumeSocket();
}

onTransition(serverMessage: Transition) {
Expand Down Expand Up @@ -176,13 +179,26 @@ export class AuthenticationManager {
if (this.authState.state === "waitingForServerConfirmationOfFreshToken") {
this._logVerbose("server confirmed new auth token is valid");
this.scheduleTokenRefetch(this.authState.token);
this.tokenConfirmationRetries = TOKEN_CONFIRMATION_RETRIES;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: It was unclear to me whether this was supposed to be tries remaining or tries attempted. maybe this.tokenConfirmationAttempts and set it at 0 initially?

if (!this.authState.hadAuth) {
this.authState.config.onAuthChange(true);
}
}
}

onAuthError(serverMessage: AuthError) {
// If auth error comes from a query/mutation/action and the client
// is waiting for the server to confirm a token, ignore.
// TODO: This shouldn't rely on a specific error text, make less brittle.
// May require backend changes.
if (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a server-side change where the AuthError should have a field authUpdateAttempted: boolean. It's true when we're attempting to update to a new auth token, and false otherwise, so I believe you can switch this condition to checking for authUpdateAttempted is false

serverMessage.error === "Convex token identity expired" &&
(this.authState.state === "waitingForServerConfirmationOfFreshToken" ||
this.authState.state === "waitingForServerConfirmationOfCachedToken")
) {
this._logVerbose("ignoring non-auth token expired error");
return;
}
const { baseVersion } = serverMessage;
// Versioned AuthErrors are ignored if the client advanced to
// a newer auth identity
Expand All @@ -206,12 +222,13 @@ export class AuthenticationManager {
// in that we pause the WebSocket so that mutations
// don't retry with bad auth.
private async tryToReauthenticate(serverMessage: AuthError) {
// We only retry once, to avoid infinite retries
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add this comment back? I understand that there are retries now to deal with interruptions during the re-authentication, but I think the point that we should really only be calling tryToReauthenticate a fixed number of times seems nice to document

this._logVerbose(`attempting to reauthenticate: ${serverMessage.error}`);
if (
// No way to fetch another token, kaboom
this.authState.state === "noAuth" ||
// We failed on a fresh token, trying another one won't help
this.authState.state === "waitingForServerConfirmationOfFreshToken"
(this.authState.state === "waitingForServerConfirmationOfFreshToken" &&
this.tokenConfirmationRetries <= 0)
) {
this.logger.error(
`Failed to authenticate: "${serverMessage.error}", check your server auth config`,
Expand All @@ -224,7 +241,13 @@ export class AuthenticationManager {
}
return;
}
this._logVerbose("attempting to reauthenticate");
if (this.authState.state === "waitingForServerConfirmationOfFreshToken") {
this.tokenConfirmationRetries--;
this._logVerbose(
`retrying reauthentication, ${this.tokenConfirmationRetries} retries remaining`,
);
}

await this.stopSocket();
const token = await this.fetchTokenAndGuardAgainstRace(
this.authState.config.fetchToken,
Expand Down Expand Up @@ -296,12 +319,12 @@ export class AuthenticationManager {
}
this.setAndReportAuthFailed(this.authState.config.onAuthChange);
}
// Resuming in case this refetch was triggered
// by an invalid cached token.
// Restart in case this refetch was triggered via schedule during
// a reauthentication attempt.
this._logVerbose(
"resuming WS after auth token fetch (if currently paused)",
"restarting WS after auth token fetch (if currently stopped)",
);
this.resumeSocket();
this.restartSocket();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also be resetting this.tokenConfirmationRetries?

}

private scheduleTokenRefetch(token: string) {
Expand Down Expand Up @@ -353,6 +376,7 @@ export class AuthenticationManager {
delay = 0;
}
const refetchTokenTimeoutId = setTimeout(() => {
this._logVerbose("running scheduled token refetch");
void this.refetchToken();
}, delay);
this.setAuthState({
Expand All @@ -374,9 +398,15 @@ export class AuthenticationManager {
},
) {
const originalConfigVersion = ++this.configVersion;
this._logVerbose(
`fetching token with config version ${originalConfigVersion}`,
);
const token = await fetchToken(fetchArgs);
if (this.configVersion !== originalConfigVersion) {
// This is a stale config
this._logVerbose(
`stale config version, expected ${originalConfigVersion}, got ${this.configVersion}`,
);
return { isFromOutdatedConfig: true };
}
return { isFromOutdatedConfig: false, value: token };
Expand All @@ -386,6 +416,7 @@ export class AuthenticationManager {
this.resetAuthState();
// Bump this in case we are mid-token-fetch when we get stopped
this.configVersion++;
this._logVerbose(`config version bumped to ${this.configVersion}`);
}

private setAndReportAuthFailed(
Expand All @@ -400,6 +431,18 @@ export class AuthenticationManager {
}

private setAuthState(newAuth: AuthState) {
const authStateForLog =
newAuth.state === "waitingForServerConfirmationOfFreshToken"
? {
hadAuth: newAuth.hadAuth,
state: newAuth.state,
token: `...${newAuth.token.slice(-7)}`,
}
: { state: newAuth.state };
this._logVerbose(
`setting auth state to ${JSON.stringify(authStateForLog)}`,
);

if (this.authState.state === "waitingForScheduledRefetch") {
clearTimeout(this.authState.refetchTokenTimeoutId);

Expand Down
45 changes: 35 additions & 10 deletions src/browser/sync/web_socket_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ export class WebSocketManager {
this.connect();
}

private setSocketState(state: Socket) {
this.socket = state;
this._logVerbose(
`socket state changed: ${this.socket.state}, paused: ${
"paused" in this.socket ? this.socket.paused : undefined
}`,
);
}

private connect() {
if (this.socket.state === "terminated") {
return;
Expand All @@ -173,11 +182,11 @@ export class WebSocketManager {

const ws = new this.webSocketConstructor(this.uri);
this._logVerbose("constructed WebSocket");
this.socket = {
this.setSocketState({
state: "connecting",
ws,
paused: "no",
};
});

// Kick off server inactivity timer before WebSocket connection is established
// so we can detect cases where handshake fails.
Expand All @@ -190,11 +199,11 @@ export class WebSocketManager {
if (this.socket.state !== "connecting") {
throw new Error("onopen called with socket not in connecting state");
}
this.socket = {
this.setSocketState({
state: "ready",
ws,
paused: this.socket.paused === "yes" ? "uninitialized" : "no",
};
});
this.resetServerInactivityTimeout();
if (this.socket.paused === "no") {
this.onOpen({
Expand Down Expand Up @@ -259,8 +268,14 @@ export class WebSocketManager {
* @returns Whether the message (might have been) sent.
*/
sendMessage(message: ClientMessage) {
this._logVerbose(`sending message with type ${message.type}`);

const messageForLog = {
type: message.type,
...(message.type === "Authenticate" && message.tokenType === "User"
? {
value: `...${message.value.slice(-7)}`,
}
: {}),
};
if (this.socket.state === "ready" && this.socket.paused === "no") {
const encodedMessage = encodeClientMessage(message);
const request = JSON.stringify(encodedMessage);
Expand All @@ -273,8 +288,19 @@ export class WebSocketManager {
this.closeAndReconnect("FailedToSendMessage");
}
// We are not sure if this was sent or not.
this._logVerbose(
`sent message with type ${message.type}: ${JSON.stringify(
messageForLog,
)}`,
);
return true;
}
this._logVerbose(
`message not sent (socket state: ${this.socket.state}, paused: ${"paused" in this.socket ? this.socket.paused : undefined}): ${JSON.stringify(
messageForLog,
)}`,
);

return false;
}

Expand Down Expand Up @@ -388,7 +414,7 @@ export class WebSocketManager {
case "connecting":
case "ready": {
const result = this.close();
this.socket = { state: "terminated" };
this.setSocketState({ state: "terminated" });
return result;
}
default: {
Expand Down Expand Up @@ -431,12 +457,11 @@ export class WebSocketManager {
case "stopped":
break;
case "terminated":
// If we're terminating we ignore restart
return;
case "connecting":
case "ready":
case "disconnected":
throw new Error("`restart()` is only valid after `stop()`");
this.logger.warn("Restart called without stopping first");
return;
default: {
// Enforce that the switch-case is exhaustive.
const _: never = this.socket;
Expand Down
Loading