refactor: improve client auth flow (#269)

* refactor: improve client auth flow

* fix: agent waiting for auth token should update status before resolve.
sweep/improve-logging-information
Zhiming Ma 2023-06-25 05:43:13 +08:00 committed by GitHub
parent 7abca766ca
commit af517fb15b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 491 additions and 363 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -69,10 +69,21 @@ interface AgentFunction {
getConfig(): AgentConfig; getConfig(): AgentConfig;
getStatus(): AgentStatus; getStatus(): AgentStatus;
/** /**
* @returns string auth url if AgentStatus is `unauthorized`, null otherwise * @returns the auth url for redirecting, and the code for next step `waitingForAuth`, only return value when
* `AgentStatus` is `unauthorized`, return null otherwise
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
startAuth(): CancelablePromise<string | null>; requestAuthUrl(): CancelablePromise<{
authUrl: string;
code: string;
} | null>;
/**
* Wait for auth token to be ready after redirecting user to auth url,
* returns nothing, but `AgentStatus` will change to `ready` if resolved successfully
* @param code from `requestAuthUrl`
* @throws Error if agent is not initialized
*/
waitForAuthToken(code: string): CancelablePromise<any>;
/** /**
* @param request * @param request
* @returns * @returns
@ -94,7 +105,11 @@ type ConfigUpdatedEvent = {
event: "configUpdated"; event: "configUpdated";
config: AgentConfig; config: AgentConfig;
}; };
type AgentEvent = StatusChangedEvent | ConfigUpdatedEvent; type AuthRequiredEvent = {
event: "authRequired";
server: AgentConfig["server"];
};
type AgentEvent = StatusChangedEvent | ConfigUpdatedEvent | AuthRequiredEvent;
declare const agentEventNames: AgentEvent["event"][]; declare const agentEventNames: AgentEvent["event"][];
interface AgentEventEmitter { interface AgentEventEmitter {
on<T extends AgentEvent>(eventName: T["event"], callback: (event: T) => void): this; on<T extends AgentEvent>(eventName: T["event"], callback: (event: T) => void): this;
@ -145,7 +160,11 @@ declare class TabbyAgent extends EventEmitter implements Agent {
updateConfig(config: Partial<AgentConfig>): Promise<boolean>; updateConfig(config: Partial<AgentConfig>): Promise<boolean>;
getConfig(): AgentConfig; getConfig(): AgentConfig;
getStatus(): AgentStatus; getStatus(): AgentStatus;
startAuth(): CancelablePromise<string | null>; requestAuthUrl(): CancelablePromise<{
authUrl: string;
code: string;
} | null>;
waitForAuthToken(code: string): CancelablePromise<any>;
getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>; getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>;
postEvent(request: LogEventRequest): CancelablePromise<boolean>; postEvent(request: LogEventRequest): CancelablePromise<boolean>;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -627,14 +627,13 @@ var _Auth = class extends import_events.EventEmitter {
super(); super();
this.logger = rootLogger.child({ component: "Auth" }); this.logger = rootLogger.child({ component: "Auth" });
this.dataStore = null; this.dataStore = null;
this.pollingTokenTimer = null;
this.stopPollingTokenTimer = null;
this.refreshTokenTimer = null; this.refreshTokenTimer = null;
this.authApi = null; this.authApi = null;
this.jwt = null; this.jwt = null;
this.endpoint = options.endpoint; this.endpoint = options.endpoint;
this.dataStore = options.dataStore || dataStore; this.dataStore = options.dataStore || dataStore;
this.authApi = new CloudApi(); this.authApi = new CloudApi();
this.scheduleRefreshToken();
} }
static async create(options) { static async create(options) {
const auth = new _Auth(options); const auth = new _Auth(options);
@ -665,7 +664,6 @@ var _Auth = class extends import_events.EventEmitter {
} else { } else {
this.jwt = jwt; this.jwt = jwt;
} }
this.scheduleRefreshToken();
} }
} catch (error) { } catch (error) {
this.logger.debug({ error }, "Error when loading auth"); this.logger.debug({ error }, "Error when loading auth");
@ -695,33 +693,63 @@ var _Auth = class extends import_events.EventEmitter {
this.jwt = null; this.jwt = null;
await this.save(); await this.save();
} }
if (this.refreshTokenTimer) {
clearTimeout(this.refreshTokenTimer);
this.refreshTokenTimer = null;
}
if (this.pollingTokenTimer) {
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
}
if (this.stopPollingTokenTimer) {
clearTimeout(this.stopPollingTokenTimer);
this.stopPollingTokenTimer = null;
}
} }
async requestToken() { requestAuthUrl() {
try { return new CancelablePromise(async (resolve2, reject, onCancel) => {
await this.reset(); let apiRequest;
this.logger.debug("Start to request device token"); onCancel(() => {
const deviceToken = await this.authApi.api.deviceToken({ auth_url: this.endpoint }); apiRequest?.cancel();
this.logger.debug({ deviceToken }, "Request device token response"); });
const authUrl = new URL(_Auth.authPageUrl); try {
authUrl.searchParams.append("code", deviceToken.data.code); await this.reset();
this.schedulePollingToken(deviceToken.data.code); if (onCancel.isCancelled)
return authUrl.toString(); return;
} catch (error) { this.logger.debug("Start to request device token");
this.logger.error({ error }, "Error when requesting token"); apiRequest = this.authApi.api.deviceToken({ auth_url: this.endpoint });
throw error; const deviceToken = await apiRequest;
} this.logger.debug({ deviceToken }, "Request device token response");
const authUrl = new URL(_Auth.authPageUrl);
authUrl.searchParams.append("code", deviceToken.data.code);
resolve2({ authUrl: authUrl.toString(), code: deviceToken.data.code });
} catch (error) {
this.logger.error({ error }, "Error when requesting token");
reject(error);
}
});
}
pollingToken(code) {
return new CancelablePromise((resolve2, reject, onCancel) => {
let apiRequest;
const timer = setInterval(async () => {
try {
apiRequest = this.authApi.api.deviceTokenAccept({ code });
const response = await apiRequest;
this.logger.debug({ response }, "Poll jwt response");
this.jwt = {
token: response.data.jwt,
payload: (0, import_jwt_decode.default)(response.data.jwt)
};
super.emit("updated", this.jwt);
await this.save();
clearInterval(timer);
resolve2(true);
} catch (error) {
if (error instanceof ApiError && [400, 401, 403, 405].indexOf(error.status) !== -1) {
this.logger.debug({ error }, "Expected error when polling jwt");
} else {
this.logger.error({ error }, "Error when polling jwt");
}
}
}, _Auth.tokenStrategy.polling.interval);
setTimeout(() => {
clearInterval(timer);
reject(new Error("Timeout when polling token"));
}, _Auth.tokenStrategy.polling.timeout);
onCancel(() => {
apiRequest?.cancel();
clearInterval(timer);
});
});
} }
async refreshToken(jwt, options = { maxTry: 1, retryDelay: 1e3 }, retry = 0) { async refreshToken(jwt, options = { maxTry: 1, retryDelay: 1e3 }, retry = 0) {
try { try {
@ -746,50 +774,16 @@ var _Auth = class extends import_events.EventEmitter {
throw { ...error, retry }; throw { ...error, retry };
} }
} }
async schedulePollingToken(code) {
this.pollingTokenTimer = setInterval(async () => {
try {
const response = await this.authApi.api.deviceTokenAccept({ code });
this.logger.debug({ response }, "Poll jwt response");
this.jwt = {
token: response.data.jwt,
payload: (0, import_jwt_decode.default)(response.data.jwt)
};
await this.save();
this.scheduleRefreshToken();
super.emit("updated", this.jwt);
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
} catch (error) {
if (error instanceof ApiError && [400, 401, 403, 405].indexOf(error.status) !== -1) {
this.logger.debug({ error }, "Expected error when polling jwt");
} else {
this.logger.error({ error }, "Error when polling jwt");
}
}
}, _Auth.tokenStrategy.polling.interval);
this.stopPollingTokenTimer = setTimeout(() => {
if (this.pollingTokenTimer) {
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
}
}, _Auth.tokenStrategy.polling.timeout);
}
scheduleRefreshToken() { scheduleRefreshToken() {
if (this.refreshTokenTimer) {
clearTimeout(this.refreshTokenTimer);
this.refreshTokenTimer = null;
}
if (!this.jwt) {
return null;
}
this.refreshTokenTimer = setInterval(async () => { this.refreshTokenTimer = setInterval(async () => {
if (!this.jwt) {
return null;
}
if (this.jwt.payload.exp * 1e3 - Date.now() < _Auth.tokenStrategy.refresh.beforeExpire) { if (this.jwt.payload.exp * 1e3 - Date.now() < _Auth.tokenStrategy.refresh.beforeExpire) {
try { try {
this.jwt = await this.refreshToken(this.jwt, _Auth.tokenStrategy.refresh.whenScheduled); this.jwt = await this.refreshToken(this.jwt, _Auth.tokenStrategy.refresh.whenScheduled);
await this.save();
this.scheduleRefreshToken();
super.emit("updated", this.jwt); super.emit("updated", this.jwt);
await this.save();
} catch (error) { } catch (error) {
this.logger.error({ error }, "Error when refreshing jwt"); this.logger.error({ error }, "Error when refreshing jwt");
} }
@ -1211,6 +1205,10 @@ var _TabbyAgent = class extends import_events2.EventEmitter {
this.config = (0, import_deepmerge.default)(this.config, options.config); this.config = (0, import_deepmerge.default)(this.config, options.config);
} }
await this.applyConfig(); await this.applyConfig();
if (this.status === "unauthorized") {
const event = { event: "authRequired", server: this.config.server };
super.emit("authRequired", event);
}
await this.anonymousUsageLogger.event("AgentInitialized", { await this.anonymousUsageLogger.event("AgentInitialized", {
client: options.client client: options.client
}); });
@ -1220,11 +1218,16 @@ var _TabbyAgent = class extends import_events2.EventEmitter {
async updateConfig(config) { async updateConfig(config) {
const mergedConfig = (0, import_deepmerge.default)(this.config, config); const mergedConfig = (0, import_deepmerge.default)(this.config, config);
if (!(0, import_deep_equal.default)(this.config, mergedConfig)) { if (!(0, import_deep_equal.default)(this.config, mergedConfig)) {
const serverUpdated = !(0, import_deep_equal.default)(this.config.server, mergedConfig.server);
this.config = mergedConfig; this.config = mergedConfig;
await this.applyConfig(); await this.applyConfig();
const event = { event: "configUpdated", config: this.config }; const event = { event: "configUpdated", config: this.config };
this.logger.debug({ event }, "Config updated"); this.logger.debug({ event }, "Config updated");
super.emit("configUpdated", event); super.emit("configUpdated", event);
if (serverUpdated && this.status === "unauthorized") {
const event2 = { event: "authRequired", server: this.config.server };
super.emit("authRequired", event2);
}
} }
return true; return true;
} }
@ -1234,27 +1237,46 @@ var _TabbyAgent = class extends import_events2.EventEmitter {
getStatus() { getStatus() {
return this.status; return this.status;
} }
startAuth() { requestAuthUrl() {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {
});
} }
return new CancelablePromise(async (resolve2, reject, onCancel) => {
let request2;
onCancel(() => {
request2?.cancel();
});
await this.healthCheck();
if (onCancel.isCancelled)
return;
if (this.status === "unauthorized") {
request2 = this.auth.requestAuthUrl();
resolve2(request2);
} else {
}
resolve2(null);
});
}
waitForAuthToken(code) {
if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {
});
}
const polling = this.auth.pollingToken(code);
return cancelable( return cancelable(
this.healthCheck().then(() => { polling.then(() => {
if (this.status === "unauthorized") { return this.setupApi();
return this.auth.requestToken();
}
return null;
}), }),
() => { () => {
if (this.status === "unauthorized") { polling.cancel();
this.auth.reset();
}
} }
); );
} }
getCompletions(request2) { getCompletions(request2) {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {
});
} }
if (this.completionCache.has(request2)) { if (this.completionCache.has(request2)) {
this.logger.debug({ request: request2 }, "Completion cache hit"); this.logger.debug({ request: request2 }, "Completion cache hit");
@ -1291,7 +1313,8 @@ var _TabbyAgent = class extends import_events2.EventEmitter {
} }
postEvent(request2) { postEvent(request2) {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {
});
} }
return this.callApi(this.api.v1.event, request2); return this.callApi(this.api.v1.event, request2);
} }
@ -1300,7 +1323,7 @@ var TabbyAgent = _TabbyAgent;
TabbyAgent.tryConnectInterval = 1e3 * 30; TabbyAgent.tryConnectInterval = 1e3 * 30;
// src/Agent.ts // src/Agent.ts
var agentEventNames = ["statusChanged", "configUpdated"]; var agentEventNames = ["statusChanged", "configUpdated", "authRequired"];
// Annotate the CommonJS export names for ESM import in node: // Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = { 0 && (module.exports = {
CancelablePromise, CancelablePromise,

File diff suppressed because one or more lines are too long

View File

@ -13353,14 +13353,13 @@ var _Auth = class extends EventEmitter {
super(); super();
this.logger = rootLogger.child({ component: "Auth" }); this.logger = rootLogger.child({ component: "Auth" });
this.dataStore = null; this.dataStore = null;
this.pollingTokenTimer = null;
this.stopPollingTokenTimer = null;
this.refreshTokenTimer = null; this.refreshTokenTimer = null;
this.authApi = null; this.authApi = null;
this.jwt = null; this.jwt = null;
this.endpoint = options.endpoint; this.endpoint = options.endpoint;
this.dataStore = options.dataStore || dataStore; this.dataStore = options.dataStore || dataStore;
this.authApi = new CloudApi(); this.authApi = new CloudApi();
this.scheduleRefreshToken();
} }
static async create(options) { static async create(options) {
const auth = new _Auth(options); const auth = new _Auth(options);
@ -13391,7 +13390,6 @@ var _Auth = class extends EventEmitter {
} else { } else {
this.jwt = jwt; this.jwt = jwt;
} }
this.scheduleRefreshToken();
} }
} catch (error) { } catch (error) {
this.logger.debug({ error }, "Error when loading auth"); this.logger.debug({ error }, "Error when loading auth");
@ -13421,33 +13419,63 @@ var _Auth = class extends EventEmitter {
this.jwt = null; this.jwt = null;
await this.save(); await this.save();
} }
if (this.refreshTokenTimer) {
clearTimeout(this.refreshTokenTimer);
this.refreshTokenTimer = null;
}
if (this.pollingTokenTimer) {
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
}
if (this.stopPollingTokenTimer) {
clearTimeout(this.stopPollingTokenTimer);
this.stopPollingTokenTimer = null;
}
} }
async requestToken() { requestAuthUrl() {
try { return new CancelablePromise(async (resolve4, reject, onCancel) => {
await this.reset(); let apiRequest;
this.logger.debug("Start to request device token"); onCancel(() => {
const deviceToken = await this.authApi.api.deviceToken({ auth_url: this.endpoint }); apiRequest?.cancel();
this.logger.debug({ deviceToken }, "Request device token response"); });
const authUrl = new URL(_Auth.authPageUrl); try {
authUrl.searchParams.append("code", deviceToken.data.code); await this.reset();
this.schedulePollingToken(deviceToken.data.code); if (onCancel.isCancelled)
return authUrl.toString(); return;
} catch (error) { this.logger.debug("Start to request device token");
this.logger.error({ error }, "Error when requesting token"); apiRequest = this.authApi.api.deviceToken({ auth_url: this.endpoint });
throw error; const deviceToken = await apiRequest;
} this.logger.debug({ deviceToken }, "Request device token response");
const authUrl = new URL(_Auth.authPageUrl);
authUrl.searchParams.append("code", deviceToken.data.code);
resolve4({ authUrl: authUrl.toString(), code: deviceToken.data.code });
} catch (error) {
this.logger.error({ error }, "Error when requesting token");
reject(error);
}
});
}
pollingToken(code) {
return new CancelablePromise((resolve4, reject, onCancel) => {
let apiRequest;
const timer = setInterval(async () => {
try {
apiRequest = this.authApi.api.deviceTokenAccept({ code });
const response = await apiRequest;
this.logger.debug({ response }, "Poll jwt response");
this.jwt = {
token: response.data.jwt,
payload: jwt_decode_esm_default(response.data.jwt)
};
super.emit("updated", this.jwt);
await this.save();
clearInterval(timer);
resolve4(true);
} catch (error) {
if (error instanceof ApiError && [400, 401, 403, 405].indexOf(error.status) !== -1) {
this.logger.debug({ error }, "Expected error when polling jwt");
} else {
this.logger.error({ error }, "Error when polling jwt");
}
}
}, _Auth.tokenStrategy.polling.interval);
setTimeout(() => {
clearInterval(timer);
reject(new Error("Timeout when polling token"));
}, _Auth.tokenStrategy.polling.timeout);
onCancel(() => {
apiRequest?.cancel();
clearInterval(timer);
});
});
} }
async refreshToken(jwt, options = { maxTry: 1, retryDelay: 1e3 }, retry = 0) { async refreshToken(jwt, options = { maxTry: 1, retryDelay: 1e3 }, retry = 0) {
try { try {
@ -13472,50 +13500,16 @@ var _Auth = class extends EventEmitter {
throw { ...error, retry }; throw { ...error, retry };
} }
} }
async schedulePollingToken(code) {
this.pollingTokenTimer = setInterval(async () => {
try {
const response = await this.authApi.api.deviceTokenAccept({ code });
this.logger.debug({ response }, "Poll jwt response");
this.jwt = {
token: response.data.jwt,
payload: jwt_decode_esm_default(response.data.jwt)
};
await this.save();
this.scheduleRefreshToken();
super.emit("updated", this.jwt);
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
} catch (error) {
if (error instanceof ApiError && [400, 401, 403, 405].indexOf(error.status) !== -1) {
this.logger.debug({ error }, "Expected error when polling jwt");
} else {
this.logger.error({ error }, "Error when polling jwt");
}
}
}, _Auth.tokenStrategy.polling.interval);
this.stopPollingTokenTimer = setTimeout(() => {
if (this.pollingTokenTimer) {
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
}
}, _Auth.tokenStrategy.polling.timeout);
}
scheduleRefreshToken() { scheduleRefreshToken() {
if (this.refreshTokenTimer) {
clearTimeout(this.refreshTokenTimer);
this.refreshTokenTimer = null;
}
if (!this.jwt) {
return null;
}
this.refreshTokenTimer = setInterval(async () => { this.refreshTokenTimer = setInterval(async () => {
if (!this.jwt) {
return null;
}
if (this.jwt.payload.exp * 1e3 - Date.now() < _Auth.tokenStrategy.refresh.beforeExpire) { if (this.jwt.payload.exp * 1e3 - Date.now() < _Auth.tokenStrategy.refresh.beforeExpire) {
try { try {
this.jwt = await this.refreshToken(this.jwt, _Auth.tokenStrategy.refresh.whenScheduled); this.jwt = await this.refreshToken(this.jwt, _Auth.tokenStrategy.refresh.whenScheduled);
await this.save();
this.scheduleRefreshToken();
super.emit("updated", this.jwt); super.emit("updated", this.jwt);
await this.save();
} catch (error) { } catch (error) {
this.logger.error({ error }, "Error when refreshing jwt"); this.logger.error({ error }, "Error when refreshing jwt");
} }
@ -15291,6 +15285,10 @@ var _TabbyAgent = class extends EventEmitter {
this.config = (0, import_deepmerge.default)(this.config, options.config); this.config = (0, import_deepmerge.default)(this.config, options.config);
} }
await this.applyConfig(); await this.applyConfig();
if (this.status === "unauthorized") {
const event = { event: "authRequired", server: this.config.server };
super.emit("authRequired", event);
}
await this.anonymousUsageLogger.event("AgentInitialized", { await this.anonymousUsageLogger.event("AgentInitialized", {
client: options.client client: options.client
}); });
@ -15300,11 +15298,16 @@ var _TabbyAgent = class extends EventEmitter {
async updateConfig(config2) { async updateConfig(config2) {
const mergedConfig = (0, import_deepmerge.default)(this.config, config2); const mergedConfig = (0, import_deepmerge.default)(this.config, config2);
if (!(0, import_deep_equal.default)(this.config, mergedConfig)) { if (!(0, import_deep_equal.default)(this.config, mergedConfig)) {
const serverUpdated = !(0, import_deep_equal.default)(this.config.server, mergedConfig.server);
this.config = mergedConfig; this.config = mergedConfig;
await this.applyConfig(); await this.applyConfig();
const event = { event: "configUpdated", config: this.config }; const event = { event: "configUpdated", config: this.config };
this.logger.debug({ event }, "Config updated"); this.logger.debug({ event }, "Config updated");
super.emit("configUpdated", event); super.emit("configUpdated", event);
if (serverUpdated && this.status === "unauthorized") {
const event2 = { event: "authRequired", server: this.config.server };
super.emit("authRequired", event2);
}
} }
return true; return true;
} }
@ -15314,27 +15317,45 @@ var _TabbyAgent = class extends EventEmitter {
getStatus() { getStatus() {
return this.status; return this.status;
} }
startAuth() { requestAuthUrl() {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {
});
} }
return new CancelablePromise(async (resolve4, reject, onCancel) => {
let request2;
onCancel(() => {
request2?.cancel();
});
await this.healthCheck();
if (onCancel.isCancelled)
return;
if (this.status === "unauthorized") {
request2 = this.auth.requestAuthUrl();
resolve4(request2);
}
resolve4(null);
});
}
waitForAuthToken(code) {
if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {
});
}
const polling = this.auth.pollingToken(code);
return cancelable( return cancelable(
this.healthCheck().then(() => { polling.then(() => {
if (this.status === "unauthorized") { return this.setupApi();
return this.auth.requestToken();
}
return null;
}), }),
() => { () => {
if (this.status === "unauthorized") { polling.cancel();
this.auth.reset();
}
} }
); );
} }
getCompletions(request2) { getCompletions(request2) {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {
});
} }
if (this.completionCache.has(request2)) { if (this.completionCache.has(request2)) {
this.logger.debug({ request: request2 }, "Completion cache hit"); this.logger.debug({ request: request2 }, "Completion cache hit");
@ -15371,7 +15392,8 @@ var _TabbyAgent = class extends EventEmitter {
} }
postEvent(request2) { postEvent(request2) {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {
});
} }
return this.callApi(this.api.v1.event, request2); return this.callApi(this.api.v1.event, request2);
} }
@ -15385,7 +15407,7 @@ init_dirname();
init_filename(); init_filename();
init_buffer2(); init_buffer2();
init_process2(); init_process2();
var agentEventNames = ["statusChanged", "configUpdated"]; var agentEventNames = ["statusChanged", "configUpdated", "authRequired"];
/*! Bundled license information: /*! Bundled license information:
@jspm/core/nodelibs/browser/buffer.js: @jspm/core/nodelibs/browser/buffer.js:

File diff suppressed because one or more lines are too long

View File

@ -28,25 +28,34 @@ export type AgentStatus = "notInitialized" | "ready" | "disconnected" | "unautho
export interface AgentFunction { export interface AgentFunction {
initialize(options: Partial<AgentInitOptions>): Promise<boolean>; initialize(options: Partial<AgentInitOptions>): Promise<boolean>;
updateConfig(config: Partial<AgentConfig>): Promise<boolean>; updateConfig(config: Partial<AgentConfig>): Promise<boolean>;
getConfig(): AgentConfig; getConfig(): AgentConfig;
getStatus(): AgentStatus; getStatus(): AgentStatus;
/** /**
* @returns string auth url if AgentStatus is `unauthorized`, null otherwise * @returns the auth url for redirecting, and the code for next step `waitingForAuth`, only return value when
* `AgentStatus` is `unauthorized`, return null otherwise
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
startAuth(): CancelablePromise<string | null>; requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string } | null>;
/** /**
* @param request * Wait for auth token to be ready after redirecting user to auth url,
* returns nothing, but `AgentStatus` will change to `ready` if resolved successfully
* @param code from `requestAuthUrl`
* @throws Error if agent is not initialized
*/
waitForAuthToken(code: string): CancelablePromise<any>;
/**
* @param request
* @returns * @returns
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>; getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>;
/** /**
* @param event * @param event
* @returns * @returns
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
@ -61,9 +70,13 @@ export type ConfigUpdatedEvent = {
event: "configUpdated"; event: "configUpdated";
config: AgentConfig; config: AgentConfig;
}; };
export type AuthRequiredEvent = {
event: "authRequired";
server: AgentConfig["server"]
};
export type AgentEvent = StatusChangedEvent | ConfigUpdatedEvent; export type AgentEvent = StatusChangedEvent | ConfigUpdatedEvent | AuthRequiredEvent;
export const agentEventNames: AgentEvent["event"][] = ["statusChanged", "configUpdated"]; export const agentEventNames: AgentEvent["event"][] = ["statusChanged", "configUpdated", "authRequired"];
export interface AgentEventEmitter { export interface AgentEventEmitter {
on<T extends AgentEvent>(eventName: T["event"], callback: (event: T) => void): this; on<T extends AgentEvent>(eventName: T["event"], callback: (event: T) => void): this;

View File

@ -1,7 +1,7 @@
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import decodeJwt from "jwt-decode"; import decodeJwt from "jwt-decode";
import { CloudApi } from "./cloud"; import { CloudApi, DeviceTokenResponse, DeviceTokenAcceptResponse } from "./cloud";
import { ApiError } from "./generated"; import { ApiError, CancelablePromise } from "./generated";
import { dataStore, DataStore } from "./dataStore"; import { dataStore, DataStore } from "./dataStore";
import { rootLogger } from "./logger"; import { rootLogger } from "./logger";
@ -39,8 +39,6 @@ export class Auth extends EventEmitter {
private readonly logger = rootLogger.child({ component: "Auth" }); private readonly logger = rootLogger.child({ component: "Auth" });
readonly endpoint: string; readonly endpoint: string;
readonly dataStore: DataStore | null = null; readonly dataStore: DataStore | null = null;
private pollingTokenTimer: ReturnType<typeof setInterval> | null = null;
private stopPollingTokenTimer: ReturnType<typeof setTimeout> | null = null;
private refreshTokenTimer: ReturnType<typeof setInterval> | null = null; private refreshTokenTimer: ReturnType<typeof setInterval> | null = null;
private authApi: CloudApi | null = null; private authApi: CloudApi | null = null;
private jwt: JWT | null = null; private jwt: JWT | null = null;
@ -56,6 +54,7 @@ export class Auth extends EventEmitter {
this.endpoint = options.endpoint; this.endpoint = options.endpoint;
this.dataStore = options.dataStore || dataStore; this.dataStore = options.dataStore || dataStore;
this.authApi = new CloudApi(); this.authApi = new CloudApi();
this.scheduleRefreshToken();
} }
get token(): string | null { get token(): string | null {
@ -84,7 +83,6 @@ export class Auth extends EventEmitter {
} else { } else {
this.jwt = jwt; this.jwt = jwt;
} }
this.scheduleRefreshToken();
} }
} catch (error: any) { } catch (error: any) {
this.logger.debug({ error }, "Error when loading auth"); this.logger.debug({ error }, "Error when loading auth");
@ -113,34 +111,65 @@ export class Auth extends EventEmitter {
this.jwt = null; this.jwt = null;
await this.save(); await this.save();
} }
if (this.refreshTokenTimer) {
clearTimeout(this.refreshTokenTimer);
this.refreshTokenTimer = null;
}
if (this.pollingTokenTimer) {
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
}
if (this.stopPollingTokenTimer) {
clearTimeout(this.stopPollingTokenTimer);
this.stopPollingTokenTimer = null;
}
} }
async requestToken(): Promise<string> { requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string }> {
try { return new CancelablePromise(async (resolve, reject, onCancel) => {
await this.reset(); let apiRequest: CancelablePromise<DeviceTokenResponse>;
this.logger.debug("Start to request device token"); onCancel(() => {
const deviceToken = await this.authApi.api.deviceToken({ auth_url: this.endpoint }); apiRequest?.cancel();
this.logger.debug({ deviceToken }, "Request device token response"); });
const authUrl = new URL(Auth.authPageUrl); try {
authUrl.searchParams.append("code", deviceToken.data.code); await this.reset();
this.schedulePollingToken(deviceToken.data.code); if (onCancel.isCancelled) return;
return authUrl.toString(); this.logger.debug("Start to request device token");
} catch (error) { apiRequest = this.authApi.api.deviceToken({ auth_url: this.endpoint });
this.logger.error({ error }, "Error when requesting token"); const deviceToken = await apiRequest;
throw error; this.logger.debug({ deviceToken }, "Request device token response");
} const authUrl = new URL(Auth.authPageUrl);
authUrl.searchParams.append("code", deviceToken.data.code);
resolve({ authUrl: authUrl.toString(), code: deviceToken.data.code });
} catch (error) {
this.logger.error({ error }, "Error when requesting token");
reject(error);
}
});
}
pollingToken(code: string): CancelablePromise<boolean> {
return new CancelablePromise((resolve, reject, onCancel) => {
let apiRequest: CancelablePromise<DeviceTokenAcceptResponse>;
const timer = setInterval(async () => {
try {
apiRequest = this.authApi.api.deviceTokenAccept({ code });
const response = await apiRequest;
this.logger.debug({ response }, "Poll jwt response");
this.jwt = {
token: response.data.jwt,
payload: decodeJwt(response.data.jwt),
};
super.emit("updated", this.jwt);
await this.save();
clearInterval(timer);
resolve(true);
} catch (error) {
if (error instanceof ApiError && [400, 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");
}
}
}, Auth.tokenStrategy.polling.interval);
setTimeout(() => {
clearInterval(timer);
reject(new Error("Timeout when polling token"));
}, Auth.tokenStrategy.polling.timeout);
onCancel(() => {
apiRequest?.cancel();
clearInterval(timer);
});
});
} }
private async refreshToken(jwt: JWT, options = { maxTry: 1, retryDelay: 1000 }, retry = 0): Promise<JWT> { private async refreshToken(jwt: JWT, options = { maxTry: 1, retryDelay: 1000 }, retry = 0): Promise<JWT> {
@ -168,53 +197,16 @@ export class Auth extends EventEmitter {
} }
} }
private async schedulePollingToken(code: string) {
this.pollingTokenTimer = setInterval(async () => {
try {
const response = await this.authApi.api.deviceTokenAccept({ code });
this.logger.debug({ response }, "Poll jwt response");
this.jwt = {
token: response.data.jwt,
payload: decodeJwt(response.data.jwt),
};
await this.save();
this.scheduleRefreshToken();
super.emit("updated", this.jwt);
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
} catch (error) {
if (error instanceof ApiError && [400, 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");
}
}
}, Auth.tokenStrategy.polling.interval);
this.stopPollingTokenTimer = setTimeout(() => {
if (this.pollingTokenTimer) {
clearInterval(this.pollingTokenTimer);
this.pollingTokenTimer = null;
}
}, Auth.tokenStrategy.polling.timeout);
}
private scheduleRefreshToken() { private scheduleRefreshToken() {
if (this.refreshTokenTimer) {
clearTimeout(this.refreshTokenTimer);
this.refreshTokenTimer = null;
}
if (!this.jwt) {
return null;
}
this.refreshTokenTimer = setInterval(async () => { this.refreshTokenTimer = setInterval(async () => {
if (!this.jwt) {
return null;
}
if (this.jwt.payload.exp * 1000 - Date.now() < Auth.tokenStrategy.refresh.beforeExpire) { if (this.jwt.payload.exp * 1000 - Date.now() < Auth.tokenStrategy.refresh.beforeExpire) {
try { try {
this.jwt = await this.refreshToken(this.jwt, Auth.tokenStrategy.refresh.whenScheduled); this.jwt = await this.refreshToken(this.jwt, Auth.tokenStrategy.refresh.whenScheduled);
await this.save();
this.scheduleRefreshToken();
super.emit("updated", this.jwt); super.emit("updated", this.jwt);
await this.save();
} catch (error) { } catch (error) {
this.logger.error({ error }, "Error when refreshing jwt"); this.logger.error({ error }, "Error when refreshing jwt");
} }

View File

@ -148,6 +148,10 @@ export class TabbyAgent extends EventEmitter implements Agent {
this.config = deepMerge(this.config, options.config); this.config = deepMerge(this.config, options.config);
} }
await this.applyConfig(); await this.applyConfig();
if (this.status === "unauthorized") {
const event: AgentEvent = { event: "authRequired", server: this.config.server };
super.emit("authRequired", event);
}
await this.anonymousUsageLogger.event("AgentInitialized", { await this.anonymousUsageLogger.event("AgentInitialized", {
client: options.client, client: options.client,
}); });
@ -158,11 +162,16 @@ export class TabbyAgent extends EventEmitter implements Agent {
public async updateConfig(config: Partial<AgentConfig>): Promise<boolean> { public async updateConfig(config: Partial<AgentConfig>): Promise<boolean> {
const mergedConfig = deepMerge(this.config, config); const mergedConfig = deepMerge(this.config, config);
if (!deepEqual(this.config, mergedConfig)) { if (!deepEqual(this.config, mergedConfig)) {
const serverUpdated = !deepEqual(this.config.server, mergedConfig.server);
this.config = mergedConfig; this.config = mergedConfig;
await this.applyConfig(); await this.applyConfig();
const event: AgentEvent = { event: "configUpdated", config: this.config }; const event: AgentEvent = { event: "configUpdated", config: this.config };
this.logger.debug({ event }, "Config updated"); this.logger.debug({ event }, "Config updated");
super.emit("configUpdated", event); super.emit("configUpdated", event);
if (serverUpdated && this.status === "unauthorized") {
const event: AgentEvent = { event: "authRequired", server: this.config.server };
super.emit("authRequired", event);
}
} }
return true; return true;
} }
@ -175,28 +184,44 @@ export class TabbyAgent extends EventEmitter implements Agent {
return this.status; return this.status;
} }
public startAuth(): CancelablePromise<string | null> { public requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string } | null> {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {});
} }
return new CancelablePromise(async (resolve, reject, onCancel) => {
let request: CancelablePromise<{ authUrl: string; code: string }>;
onCancel(() => {
request?.cancel();
});
await this.healthCheck();
if (onCancel.isCancelled) return;
if (this.status === "unauthorized") {
request = this.auth.requestAuthUrl();
resolve(request);
} else {
}
resolve(null);
});
}
public waitForAuthToken(code: string): CancelablePromise<any> {
if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {});
}
const polling = this.auth.pollingToken(code);
return cancelable( return cancelable(
this.healthCheck().then(() => { polling.then(() => {
if (this.status === "unauthorized") { return this.setupApi();
return this.auth.requestToken();
}
return null;
}), }),
() => { () => {
if (this.status === "unauthorized") { polling.cancel();
this.auth.reset();
}
} }
); );
} }
public getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse> { public getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse> {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {});
} }
if (this.completionCache.has(request)) { if (this.completionCache.has(request)) {
this.logger.debug({ request }, "Completion cache hit"); this.logger.debug({ request }, "Completion cache hit");
@ -236,7 +261,7 @@ export class TabbyAgent extends EventEmitter implements Agent {
public postEvent(request: LogEventRequest): CancelablePromise<boolean> { public postEvent(request: LogEventRequest): CancelablePromise<boolean> {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
throw new Error("Agent is not initialized"); return cancelable(Promise.reject("Agent is not initialized"), () => {});
} }
return this.callApi(this.api.v1.event, request); return this.callApi(this.api.v1.event, request);
} }

View File

@ -1,6 +1,7 @@
import { import {
ConfigurationTarget, ConfigurationTarget,
InputBoxValidationSeverity, InputBoxValidationSeverity,
ProgressLocation,
QuickPickItem, QuickPickItem,
QuickPickItemKind, QuickPickItemKind,
Uri, Uri,
@ -11,6 +12,7 @@ import {
} from "vscode"; } from "vscode";
import { strict as assert } from "assert"; import { strict as assert } from "assert";
import { Duration } from "@sapphire/duration"; import { Duration } from "@sapphire/duration";
import { CancelablePromise } from "tabby-agent";
import { agent } from "./agent"; import { agent } from "./agent";
import { notifications } from "./notifications"; import { notifications } from "./notifications";
@ -140,23 +142,46 @@ const emitEvent: Command = {
const openAuthPage: Command = { const openAuthPage: Command = {
command: "tabby.openAuthPage", command: "tabby.openAuthPage",
callback: (callbacks?: { onOpenAuthPage?: () => void }) => { callback: (callbacks?: { onAuthStart?: () => void; onAuthEnd?: () => void }) => {
agent() window.withProgress(
.startAuth() {
.then((authUrl) => { location: ProgressLocation.Notification,
if (authUrl) { title: "Tabby Server Authorization",
callbacks?.onOpenAuthPage?.(); cancellable: true,
env.openExternal(Uri.parse(authUrl)); },
} else if (agent().getStatus() === "ready") { async (progress, token) => {
notifications.showInformationWhenStartAuthButAlreadyAuthorized(); let requestAuthUrl: CancelablePromise<{ authUrl: string; code: string } | null>;
} else { let waitForAuthToken: CancelablePromise<any>;
notifications.showInformationWhenStartAuthFailed(); token.onCancellationRequested(() => {
requestAuthUrl?.cancel();
waitForAuthToken?.cancel();
});
try {
callbacks?.onAuthStart?.();
progress.report({ message: "Generating authorization url..." });
requestAuthUrl = agent().requestAuthUrl();
let authUrl = await requestAuthUrl;
if (authUrl) {
env.openExternal(Uri.parse(authUrl.authUrl));
progress.report({ message: "Waiting for authorization from browser..." });
waitForAuthToken = agent().waitForAuthToken(authUrl.code);
await waitForAuthToken;
assert(agent().getStatus() === "ready");
notifications.showInformationAuthSuccess();
} else if (agent().getStatus() === "ready") {
notifications.showInformationWhenStartAuthButAlreadyAuthorized();
} else {
notifications.showInformationWhenAuthFailed();
}
} catch (error: any) {
if (error.isCancelled) return;
console.debug("Error auth", { error });
notifications.showInformationWhenAuthFailed();
} finally {
callbacks?.onAuthEnd?.();
} }
}) }
.catch((error) => { );
console.debug("Error to start auth", { error });
notifications.showInformationWhenStartAuthFailed();
});
}, },
}; };

View File

@ -52,9 +52,9 @@ function showInformationWhenDisconnected() {
}); });
} }
function showInformationStartAuth(callbacks?: { onOpenAuthPage?: () => void }) { function showInformationStartAuth(callbacks?: { onAuthStart?: () => void; onAuthEnd?: () => void }) {
window window
.showInformationMessage( .showWarningMessage(
"Tabby Server requires authorization. Continue to open authorization page in your browser.", "Tabby Server requires authorization. Continue to open authorization page in your browser.",
"Continue", "Continue",
"Settings" "Settings"
@ -78,8 +78,8 @@ function showInformationWhenStartAuthButAlreadyAuthorized() {
window.showInformationMessage("You are already authorized now."); window.showInformationMessage("You are already authorized now.");
} }
function showInformationWhenStartAuthFailed() { function showInformationWhenAuthFailed() {
window.showInformationMessage("Cannot connect to server. Please check settings.", "Settings").then((selection) => { window.showWarningMessage("Cannot connect to server. Please check settings.", "Settings").then((selection) => {
switch (selection) { switch (selection) {
case "Settings": case "Settings":
commands.executeCommand("tabby.openSettings"); commands.executeCommand("tabby.openSettings");
@ -96,5 +96,5 @@ export const notifications = {
showInformationStartAuth, showInformationStartAuth,
showInformationAuthSuccess, showInformationAuthSuccess,
showInformationWhenStartAuthButAlreadyAuthorized, showInformationWhenStartAuthButAlreadyAuthorized,
showInformationWhenStartAuthFailed, showInformationWhenAuthFailed,
}; };

View File

@ -36,24 +36,14 @@ const fsm = createMachine({
ready: "ready", ready: "ready",
disconnected: "disconnected", disconnected: "disconnected",
disabled: "disabled", disabled: "disabled",
openAuthPage: "unauthorizedAndAuthPageOpen", authStart: "unauthorizedAndAuthInProgress",
},
entry: () => {
toUnauthorized();
notifications.showInformationStartAuth({
onOpenAuthPage: () => {
fsmService.send("openAuthPage");
},
});
}, },
entry: () => toUnauthorized(),
}, },
unauthorizedAndAuthPageOpen: { unauthorizedAndAuthInProgress: {
on: { ready: "ready", disconnected: "disconnected", disabled: "disabled" }, // if auth succeeds, we will get `ready` before `authEnd` event
exit: (_, event) => { on: { ready: "ready", disconnected: "disconnected", disabled: "disabled", authEnd: "unauthorized" },
if (event.type === "ready") { entry: () => toUnauthorizedAndAuthInProgress(),
notifications.showInformationAuthSuccess();
}
},
}, },
disabled: { disabled: {
on: { loading: "loading", ready: "ready", disconnected: "disconnected", unauthorized: "unauthorized" }, on: { loading: "loading", ready: "ready", disconnected: "disconnected", unauthorized: "unauthorized" },
@ -95,6 +85,14 @@ function toUnauthorized() {
item.command = { title: "", command: "tabby.statusBarItemClicked", arguments: ["unauthorized"] }; item.command = { title: "", command: "tabby.statusBarItemClicked", arguments: ["unauthorized"] };
} }
function toUnauthorizedAndAuthInProgress() {
item.color = colorWarning;
item.backgroundColor = backgroundColorWarning;
item.text = `${iconUnauthorized} ${label}`;
item.tooltip = "Waiting for authorization.";
item.command = undefined;
}
function toDisabled() { function toDisabled() {
item.color = colorWarning; item.color = colorWarning;
item.backgroundColor = backgroundColorWarning; item.backgroundColor = backgroundColorWarning;
@ -133,6 +131,17 @@ export const tabbyStatusBarItem = () => {
}); });
agent().on("statusChanged", updateStatusBarItem); agent().on("statusChanged", updateStatusBarItem);
agent().on("authRequired", () => {
notifications.showInformationStartAuth({
onAuthStart: () => {
fsmService.send("authStart");
},
onAuthEnd: () => {
fsmService.send("authEnd");
},
});
});
item.show(); item.show();
return item; return item;
}; };