convex
Version:
Client for the Convex Cloud
323 lines (322 loc) • 11.5 kB
JavaScript
;
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { jwtDecode } from "jwt-decode";
const MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1e3;
const MAX_TOKEN_CONFIRMATION_ATTEMPTS = 2;
export class AuthenticationManager {
constructor(syncState, callbacks, config) {
__publicField(this, "authState", { state: "noAuth" });
// Used to detect races involving `setConfig` calls
// while a token is being fetched.
__publicField(this, "configVersion", 0);
// Shared by the BaseClient so that the auth manager can easily inspect it
__publicField(this, "syncState");
// Passed down by BaseClient, sends a message to the server
__publicField(this, "authenticate");
__publicField(this, "stopSocket");
__publicField(this, "tryRestartSocket");
__publicField(this, "pauseSocket");
__publicField(this, "resumeSocket");
// Passed down by BaseClient, sends a message to the server
__publicField(this, "clearAuth");
__publicField(this, "logger");
__publicField(this, "refreshTokenLeewaySeconds");
// Number of times we have attempted to confirm the latest token. We retry up
// to `MAX_TOKEN_CONFIRMATION_ATTEMPTS` times.
__publicField(this, "tokenConfirmationAttempts", 0);
this.syncState = syncState;
this.authenticate = callbacks.authenticate;
this.stopSocket = callbacks.stopSocket;
this.tryRestartSocket = callbacks.tryRestartSocket;
this.pauseSocket = callbacks.pauseSocket;
this.resumeSocket = callbacks.resumeSocket;
this.clearAuth = callbacks.clearAuth;
this.logger = config.logger;
this.refreshTokenLeewaySeconds = config.refreshTokenLeewaySeconds;
}
async setConfig(fetchToken, onChange) {
this.resetAuthState();
this._logVerbose("pausing WS for auth token fetch");
this.pauseSocket();
const token = await this.fetchTokenAndGuardAgainstRace(fetchToken, {
forceRefreshToken: false
});
if (token.isFromOutdatedConfig) {
return;
}
if (token.value) {
this.setAuthState({
state: "waitingForServerConfirmationOfCachedToken",
config: { fetchToken, onAuthChange: onChange },
hasRetried: false
});
this.authenticate(token.value);
} else {
this.setAuthState({
state: "initialRefetch",
config: { fetchToken, onAuthChange: onChange }
});
await this.refetchToken();
}
this._logVerbose("resuming WS after auth token fetch");
this.resumeSocket();
}
onTransition(serverMessage) {
if (!this.syncState.isCurrentOrNewerAuthVersion(
serverMessage.endVersion.identity
)) {
return;
}
if (serverMessage.endVersion.identity <= serverMessage.startVersion.identity) {
return;
}
if (this.authState.state === "waitingForServerConfirmationOfCachedToken") {
this._logVerbose("server confirmed auth token is valid");
void this.refetchToken();
this.authState.config.onAuthChange(true);
return;
}
if (this.authState.state === "waitingForServerConfirmationOfFreshToken") {
this._logVerbose("server confirmed new auth token is valid");
this.scheduleTokenRefetch(this.authState.token);
this.tokenConfirmationAttempts = 0;
if (!this.authState.hadAuth) {
this.authState.config.onAuthChange(true);
}
}
}
onAuthError(serverMessage) {
if (serverMessage.authUpdateAttempted === false && (this.authState.state === "waitingForServerConfirmationOfFreshToken" || this.authState.state === "waitingForServerConfirmationOfCachedToken")) {
this._logVerbose("ignoring non-auth token expired error");
return;
}
const { baseVersion } = serverMessage;
if (!this.syncState.isCurrentOrNewerAuthVersion(baseVersion + 1)) {
this._logVerbose("ignoring auth error for previous auth attempt");
return;
}
void this.tryToReauthenticate(serverMessage);
return;
}
// This is similar to `refetchToken` defined below, in fact we
// don't represent them as different states, but it is different
// in that we pause the WebSocket so that mutations
// don't retry with bad auth.
async tryToReauthenticate(serverMessage) {
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. After a small number of retries, we give up
// and clear the auth state to avoid infinite retries.
this.authState.state === "waitingForServerConfirmationOfFreshToken" && this.tokenConfirmationAttempts >= MAX_TOKEN_CONFIRMATION_ATTEMPTS
) {
this.logger.error(
`Failed to authenticate: "${serverMessage.error}", check your server auth config`
);
if (this.syncState.hasAuth()) {
this.syncState.clearAuth();
}
if (this.authState.state !== "noAuth") {
this.setAndReportAuthFailed(this.authState.config.onAuthChange);
}
return;
}
if (this.authState.state === "waitingForServerConfirmationOfFreshToken") {
this.tokenConfirmationAttempts++;
this._logVerbose(
`retrying reauthentication, ${MAX_TOKEN_CONFIRMATION_ATTEMPTS - this.tokenConfirmationAttempts} attempts remaining`
);
}
await this.stopSocket();
const token = await this.fetchTokenAndGuardAgainstRace(
this.authState.config.fetchToken,
{
forceRefreshToken: true
}
);
if (token.isFromOutdatedConfig) {
return;
}
if (token.value && this.syncState.isNewAuth(token.value)) {
this.authenticate(token.value);
this.setAuthState({
state: "waitingForServerConfirmationOfFreshToken",
config: this.authState.config,
token: token.value,
hadAuth: this.authState.state === "notRefetching" || this.authState.state === "waitingForScheduledRefetch"
});
} else {
this._logVerbose("reauthentication failed, could not fetch a new token");
if (this.syncState.hasAuth()) {
this.syncState.clearAuth();
}
this.setAndReportAuthFailed(this.authState.config.onAuthChange);
}
this.tryRestartSocket();
}
// Force refetch the token and schedule another refetch
// before the token expires - an active client should never
// need to reauthenticate.
async refetchToken() {
if (this.authState.state === "noAuth") {
return;
}
this._logVerbose("refetching auth token");
const token = await this.fetchTokenAndGuardAgainstRace(
this.authState.config.fetchToken,
{
forceRefreshToken: true
}
);
if (token.isFromOutdatedConfig) {
return;
}
if (token.value) {
if (this.syncState.isNewAuth(token.value)) {
this.setAuthState({
state: "waitingForServerConfirmationOfFreshToken",
hadAuth: this.syncState.hasAuth(),
token: token.value,
config: this.authState.config
});
this.authenticate(token.value);
} else {
this.setAuthState({
state: "notRefetching",
config: this.authState.config
});
}
} else {
this._logVerbose("refetching token failed");
if (this.syncState.hasAuth()) {
this.clearAuth();
}
this.setAndReportAuthFailed(this.authState.config.onAuthChange);
}
this._logVerbose(
"restarting WS after auth token fetch (if currently stopped)"
);
this.tryRestartSocket();
}
scheduleTokenRefetch(token) {
if (this.authState.state === "noAuth") {
return;
}
const decodedToken = this.decodeToken(token);
if (!decodedToken) {
this.logger.error(
"Auth token is not a valid JWT, cannot refetch the token"
);
return;
}
const { iat, exp } = decodedToken;
if (!iat || !exp) {
this.logger.error(
"Auth token does not have required fields, cannot refetch the token"
);
return;
}
const tokenValiditySeconds = exp - iat;
if (tokenValiditySeconds <= 2) {
this.logger.error(
"Auth token does not live long enough, cannot refetch the token"
);
return;
}
let delay = Math.min(
MAXIMUM_REFRESH_DELAY,
(tokenValiditySeconds - this.refreshTokenLeewaySeconds) * 1e3
);
if (delay <= 0) {
this.logger.warn(
`Refetching auth token immediately, configured leeway ${this.refreshTokenLeewaySeconds}s is larger than the token's lifetime ${tokenValiditySeconds}s`
);
delay = 0;
}
const refetchTokenTimeoutId = setTimeout(() => {
this._logVerbose("running scheduled token refetch");
void this.refetchToken();
}, delay);
this.setAuthState({
state: "waitingForScheduledRefetch",
refetchTokenTimeoutId,
config: this.authState.config
});
this._logVerbose(
`scheduled preemptive auth token refetching in ${delay}ms`
);
}
// Protects against simultaneous calls to `setConfig`
// while we're fetching a token
async fetchTokenAndGuardAgainstRace(fetchToken, fetchArgs) {
const originalConfigVersion = ++this.configVersion;
this._logVerbose(
`fetching token with config version ${originalConfigVersion}`
);
const token = await fetchToken(fetchArgs);
if (this.configVersion !== originalConfigVersion) {
this._logVerbose(
`stale config version, expected ${originalConfigVersion}, got ${this.configVersion}`
);
return { isFromOutdatedConfig: true };
}
return { isFromOutdatedConfig: false, value: token };
}
stop() {
this.resetAuthState();
this.configVersion++;
this._logVerbose(`config version bumped to ${this.configVersion}`);
}
setAndReportAuthFailed(onAuthChange) {
onAuthChange(false);
this.resetAuthState();
}
resetAuthState() {
this.setAuthState({ state: "noAuth" });
}
setAuthState(newAuth) {
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)}`
);
switch (newAuth.state) {
case "waitingForScheduledRefetch":
case "notRefetching":
case "noAuth":
this.tokenConfirmationAttempts = 0;
break;
case "waitingForServerConfirmationOfFreshToken":
case "waitingForServerConfirmationOfCachedToken":
case "initialRefetch":
break;
default: {
const _typeCheck = newAuth;
}
}
if (this.authState.state === "waitingForScheduledRefetch") {
clearTimeout(this.authState.refetchTokenTimeoutId);
this.syncState.markAuthCompletion();
}
this.authState = newAuth;
}
decodeToken(token) {
try {
return jwtDecode(token);
} catch (e) {
this._logVerbose(
`Error decoding token: ${e instanceof Error ? e.message : "Unknown error"}`
);
return null;
}
}
_logVerbose(message) {
this.logger.logVerbose(`${message} [v${this.configVersion}]`);
}
}
//# sourceMappingURL=authentication_manager.js.map