2023-06-15 15:53:21 +00:00
|
|
|
import { EventEmitter } from "events";
|
2023-06-16 17:50:47 +00:00
|
|
|
import decodeJwt from "jwt-decode";
|
2023-06-15 15:53:21 +00:00
|
|
|
import { CloudApi } from "./cloud";
|
|
|
|
|
import { ApiError } from "./generated";
|
|
|
|
|
import { dataStore, DataStore } from "./dataStore";
|
|
|
|
|
import { rootLogger } from "./logger";
|
|
|
|
|
|
|
|
|
|
export type StorageData = {
|
|
|
|
|
auth: { [endpoint: string]: { jwt: string } };
|
|
|
|
|
};
|
|
|
|
|
|
2023-06-23 06:43:55 +00:00
|
|
|
type JWT = { token: string; payload: { email: string; exp: number } };
|
|
|
|
|
|
2023-06-15 15:53:21 +00:00
|
|
|
export class Auth extends EventEmitter {
|
|
|
|
|
static readonly authPageUrl = "https://app.tabbyml.com/account/device-token";
|
2023-06-23 06:43:55 +00:00
|
|
|
static readonly tokenStrategy = {
|
|
|
|
|
polling: {
|
|
|
|
|
// polling token after auth url generated
|
|
|
|
|
interval: 5000, // polling token every 5 seconds
|
|
|
|
|
timeout: 5 * 60 * 1000, // stop polling after trying for 5 min
|
|
|
|
|
},
|
|
|
|
|
refresh: {
|
|
|
|
|
// refresh token 30 min before token expires
|
|
|
|
|
// assume a new token expires in 1 day, much longer than 30 min
|
|
|
|
|
beforeExpire: 30 * 60 * 1000,
|
|
|
|
|
maxTry: 5, // try to refresh token 5 times
|
|
|
|
|
retryDelay: 2000, // retry after 2 seconds
|
|
|
|
|
},
|
|
|
|
|
};
|
2023-06-15 15:53:21 +00:00
|
|
|
|
|
|
|
|
private readonly logger = rootLogger.child({ component: "Auth" });
|
|
|
|
|
readonly endpoint: string;
|
|
|
|
|
readonly dataStore: DataStore | null = null;
|
|
|
|
|
private pollingTokenTimer: ReturnType<typeof setInterval> | null = null;
|
2023-06-23 06:43:55 +00:00
|
|
|
private stopPollingTokenTimer: ReturnType<typeof setTimeout> | null = null;
|
2023-06-15 15:53:21 +00:00
|
|
|
private refreshTokenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
private authApi: CloudApi | null = null;
|
2023-06-23 06:43:55 +00:00
|
|
|
private jwt: JWT | null = null;
|
2023-06-15 15:53:21 +00:00
|
|
|
|
|
|
|
|
static async create(options: { endpoint: string; dataStore?: DataStore }): Promise<Auth> {
|
|
|
|
|
const auth = new Auth(options);
|
|
|
|
|
await auth.load();
|
|
|
|
|
return auth;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
constructor(options: { endpoint: string; dataStore?: DataStore }) {
|
|
|
|
|
super();
|
|
|
|
|
this.endpoint = options.endpoint;
|
|
|
|
|
this.dataStore = options.dataStore || dataStore;
|
|
|
|
|
|
2023-06-16 17:43:47 +00:00
|
|
|
// From tabby endpoint: http[s]://{namespace}.app.tabbyml.com/tabby[/]
|
2023-06-15 15:53:21 +00:00
|
|
|
// To auth endpoint: http[s]://{namespace}.app.tabbyml.com/api
|
|
|
|
|
const authApiBase = this.endpoint.replace(/\/tabby\/?$/, "/api");
|
|
|
|
|
this.authApi = new CloudApi({ BASE: authApiBase });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get token(): string | null {
|
2023-06-16 17:50:47 +00:00
|
|
|
return this.jwt?.token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get user(): string | null {
|
|
|
|
|
return this.jwt?.payload.email;
|
2023-06-15 15:53:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async load(): Promise<void> {
|
|
|
|
|
if (!this.dataStore) return;
|
|
|
|
|
try {
|
|
|
|
|
await this.dataStore.load();
|
|
|
|
|
const storedJwt = this.dataStore.data["auth"]?.[this.endpoint]?.jwt;
|
2023-06-16 17:50:47 +00:00
|
|
|
if (typeof storedJwt === "string" && this.jwt?.token !== storedJwt) {
|
2023-06-15 15:53:21 +00:00
|
|
|
this.logger.debug({ storedJwt }, "Load jwt from data store.");
|
2023-06-23 06:43:55 +00:00
|
|
|
const jwt: JWT = {
|
2023-06-16 17:50:47 +00:00
|
|
|
token: storedJwt,
|
|
|
|
|
payload: decodeJwt(storedJwt),
|
|
|
|
|
};
|
2023-06-23 06:43:55 +00:00
|
|
|
// refresh token if it is about to expire or has expired
|
|
|
|
|
if (jwt.payload.exp * 1000 - Date.now() < Auth.tokenStrategy.refresh.beforeExpire) {
|
|
|
|
|
this.jwt = await this.refreshToken(jwt);
|
|
|
|
|
await this.save();
|
|
|
|
|
} else {
|
|
|
|
|
this.jwt = jwt;
|
|
|
|
|
}
|
2023-06-15 15:53:21 +00:00
|
|
|
this.scheduleRefreshToken();
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.debug({ error }, "Error when loading auth");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async save(): Promise<void> {
|
|
|
|
|
if (!this.dataStore) return;
|
|
|
|
|
try {
|
|
|
|
|
if (this.jwt) {
|
2023-06-16 17:50:47 +00:00
|
|
|
if (this.dataStore.data["auth"]?.[this.endpoint]?.jwt === this.jwt.token) return;
|
|
|
|
|
this.dataStore.data["auth"] = { ...this.dataStore.data["auth"], [this.endpoint]: { jwt: this.jwt.token } };
|
2023-06-15 15:53:21 +00:00
|
|
|
} else {
|
|
|
|
|
if (typeof this.dataStore.data["auth"]?.[this.endpoint] === "undefined") return;
|
|
|
|
|
delete this.dataStore.data["auth"][this.endpoint];
|
|
|
|
|
}
|
|
|
|
|
await this.dataStore.save();
|
|
|
|
|
this.logger.debug("Save changes to data store.");
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error({ error }, "Error when saving auth");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async reset(): Promise<void> {
|
|
|
|
|
if (this.jwt) {
|
|
|
|
|
this.jwt = null;
|
|
|
|
|
await this.save();
|
|
|
|
|
}
|
|
|
|
|
if (this.refreshTokenTimer) {
|
|
|
|
|
clearTimeout(this.refreshTokenTimer);
|
|
|
|
|
this.refreshTokenTimer = null;
|
|
|
|
|
}
|
|
|
|
|
if (this.pollingTokenTimer) {
|
|
|
|
|
clearInterval(this.pollingTokenTimer);
|
|
|
|
|
this.pollingTokenTimer = null;
|
|
|
|
|
}
|
2023-06-23 06:43:55 +00:00
|
|
|
if (this.stopPollingTokenTimer) {
|
|
|
|
|
clearTimeout(this.stopPollingTokenTimer);
|
|
|
|
|
this.stopPollingTokenTimer = null;
|
|
|
|
|
}
|
2023-06-15 15:53:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async requestToken(): Promise<string> {
|
|
|
|
|
try {
|
|
|
|
|
await this.reset();
|
2023-06-23 06:43:55 +00:00
|
|
|
this.logger.debug("Start to request device token");
|
2023-06-16 17:43:47 +00:00
|
|
|
const deviceToken = await this.authApi.api.deviceToken({ auth_url: this.endpoint });
|
2023-06-15 15:53:21 +00:00
|
|
|
this.logger.debug({ deviceToken }, "Request device token response");
|
|
|
|
|
const authUrl = new URL(Auth.authPageUrl);
|
|
|
|
|
authUrl.searchParams.append("code", deviceToken.data.code);
|
|
|
|
|
this.schedulePollingToken(deviceToken.data.code);
|
|
|
|
|
return authUrl.toString();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error({ error }, "Error when requesting token");
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-23 06:43:55 +00:00
|
|
|
private async refreshToken(jwt: JWT, retry = 0): Promise<JWT> {
|
|
|
|
|
try {
|
|
|
|
|
this.logger.debug({ retry }, "Start to refresh token");
|
|
|
|
|
const refreshedJwt = await this.authApi.api.deviceTokenRefresh(jwt.token);
|
|
|
|
|
this.logger.debug({ refreshedJwt }, "Refresh token response");
|
|
|
|
|
return {
|
|
|
|
|
token: refreshedJwt.data.jwt,
|
|
|
|
|
payload: decodeJwt(refreshedJwt.data.jwt),
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof ApiError && [401, 403, 405].indexOf(error.status) !== -1) {
|
|
|
|
|
this.logger.debug({ error }, "Error when refreshing jwt");
|
|
|
|
|
} else {
|
|
|
|
|
// unknown error, retry a few times
|
|
|
|
|
this.logger.error({ error }, "Unknown error when refreshing jwt");
|
|
|
|
|
if (retry < Auth.tokenStrategy.refresh.maxTry) {
|
|
|
|
|
this.logger.debug("Retry refreshing jwt");
|
|
|
|
|
return this.refreshToken(jwt, retry + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw { ...error, retry };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async schedulePollingToken(code: string) {
|
2023-06-15 15:53:21 +00:00
|
|
|
this.pollingTokenTimer = setInterval(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.authApi.api.deviceTokenAccept({ code });
|
|
|
|
|
this.logger.debug({ response }, "Poll jwt response");
|
2023-06-16 17:50:47 +00:00
|
|
|
this.jwt = {
|
|
|
|
|
token: response.data.jwt,
|
|
|
|
|
payload: decodeJwt(response.data.jwt),
|
|
|
|
|
};
|
2023-06-15 15:53:21 +00:00
|
|
|
await this.save();
|
|
|
|
|
this.scheduleRefreshToken();
|
|
|
|
|
super.emit("updated", this.jwt);
|
|
|
|
|
clearInterval(this.pollingTokenTimer);
|
|
|
|
|
this.pollingTokenTimer = null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof ApiError && [401, 403, 405].indexOf(error.status) !== -1) {
|
|
|
|
|
this.logger.debug({ error }, "Expected error when polling jwt");
|
|
|
|
|
} else {
|
|
|
|
|
// unknown error but still keep polling
|
|
|
|
|
this.logger.error({ error }, "Error when polling jwt");
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-06-23 06:43:55 +00:00
|
|
|
}, Auth.tokenStrategy.polling.interval);
|
|
|
|
|
this.stopPollingTokenTimer = setTimeout(() => {
|
|
|
|
|
if (this.pollingTokenTimer) {
|
|
|
|
|
clearInterval(this.pollingTokenTimer);
|
|
|
|
|
this.pollingTokenTimer = null;
|
|
|
|
|
}
|
|
|
|
|
}, Auth.tokenStrategy.polling.timeout);
|
2023-06-15 15:53:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private scheduleRefreshToken() {
|
|
|
|
|
if (this.refreshTokenTimer) {
|
|
|
|
|
clearTimeout(this.refreshTokenTimer);
|
|
|
|
|
this.refreshTokenTimer = null;
|
|
|
|
|
}
|
|
|
|
|
if (!this.jwt) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2023-06-16 17:50:47 +00:00
|
|
|
|
2023-06-23 06:43:55 +00:00
|
|
|
const refreshDelay = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
this.jwt.payload.exp * 1000 - Auth.tokenStrategy.refresh.beforeExpire - Date.now()
|
|
|
|
|
);
|
|
|
|
|
this.logger.debug({ refreshDelay }, "Schedule refresh token");
|
2023-06-15 15:53:21 +00:00
|
|
|
this.refreshTokenTimer = setTimeout(async () => {
|
2023-06-23 06:43:55 +00:00
|
|
|
this.jwt = await this.refreshToken(this.jwt);
|
|
|
|
|
await this.save();
|
|
|
|
|
this.scheduleRefreshToken();
|
|
|
|
|
super.emit("updated", this.jwt);
|
2023-06-15 15:53:21 +00:00
|
|
|
}, refreshDelay);
|
|
|
|
|
}
|
|
|
|
|
}
|