feat: add expiration warning for Google OAuth authentication
This commit is contained in:
1 parent
a438070268
commit
f224fea081
2 files changed
+133
-9
No files matched your search
@@ -6,8 +6,6 @@ import { ProxyAgent } from 'undici';
|
|||||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||||
const CLIENT_SECRET = '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';
|
const CLIENT_SECRET = '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';
|
||||||
|
|
||||||
declare const fetch: any;
|
|
||||||
|
|
||||||
const URLS = {
|
const URLS = {
|
||||||
TOKEN: 'https://oauth2.googleapis.com/token',
|
TOKEN: 'https://oauth2.googleapis.com/token',
|
||||||
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||||
@@ -20,6 +18,18 @@ const URLS = {
|
|||||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
||||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||||
|
|
||||||
|
// Request timeout in milliseconds (30 seconds)
|
||||||
|
const REQUEST_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AbortSignal that times out after the specified duration.
|
||||||
|
*/
|
||||||
|
function createTimeoutSignal(ms: number): AbortSignal {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), ms);
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
@@ -65,7 +75,7 @@ interface LoadProjectResponse {
|
|||||||
// --- Service Implementation ---
|
// --- Service Implementation ---
|
||||||
|
|
||||||
export class GoogleAPIService {
|
export class GoogleAPIService {
|
||||||
private static getFetchOptions(): any {
|
private static getFetchOptions() {
|
||||||
try {
|
try {
|
||||||
const config = ConfigManager.loadConfig();
|
const config = ConfigManager.loadConfig();
|
||||||
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
|
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
|
||||||
@@ -121,7 +131,17 @@ export class GoogleAPIService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params,
|
body: params,
|
||||||
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -147,7 +167,17 @@ export class GoogleAPIService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params,
|
body: params,
|
||||||
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token refresh timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -166,7 +196,17 @@ export class GoogleAPIService {
|
|||||||
static async getUserInfo(accessToken: string): Promise<UserInfo> {
|
static async getUserInfo(accessToken: string): Promise<UserInfo> {
|
||||||
const response = await fetch(URLS.USER_INFO, {
|
const response = await fetch(URLS.USER_INFO, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'User info request timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -221,7 +261,7 @@ export class GoogleAPIService {
|
|||||||
const projectId = await this.fetchProjectId(accessToken);
|
const projectId = await this.fetchProjectId(accessToken);
|
||||||
|
|
||||||
// 2. Build Payload
|
// 2. Build Payload
|
||||||
const payload: Record<string, any> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
payload['project'] = projectId;
|
payload['project'] = projectId;
|
||||||
}
|
}
|
||||||
@@ -285,11 +325,13 @@ export class GoogleAPIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.warn(
|
if (e instanceof Error) {
|
||||||
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
|
console.warn(
|
||||||
);
|
`[GoogleAPIService] Request failed: ${e.message} (Attempt ${attempt}/${maxRetries})`,
|
||||||
lastError = e;
|
);
|
||||||
|
}
|
||||||
|
lastError = e instanceof Error ? e : new Error(String(e));
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock module to test timeout behavior
|
||||||
|
describe('GoogleAPIService Timeout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should abort request after timeout', async () => {
|
||||||
|
const TIMEOUT_MS = 100;
|
||||||
|
|
||||||
|
// Recreate the same logic used in GoogleAPIService
|
||||||
|
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), ms);
|
||||||
|
return controller.signal;
|
||||||
|
};
|
||||||
|
|
||||||
|
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Verify signal is not aborted initially
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
|
||||||
|
// Fast-forward time
|
||||||
|
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||||
|
|
||||||
|
// Signal should now be aborted
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set aborted state when controller.abort() is called', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// Initially not aborted
|
||||||
|
expect(controller.signal.aborted).toBe(false);
|
||||||
|
|
||||||
|
// Abort the controller
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
// Signal should now be aborted
|
||||||
|
expect(controller.signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform AbortError to user-friendly message', () => {
|
||||||
|
// This tests our error transformation logic from GoogleAPIService
|
||||||
|
const handleAbortError = (err: Error) => {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortError = new Error('The operation was aborted');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
|
||||||
|
expect(() => handleAbortError(abortError)).toThrow(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not transform non-AbortError', () => {
|
||||||
|
const handleAbortError = (err: Error) => {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(
|
||||||
|
'Token exchange timed out. Please check your network connection and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkError = new Error('Network failure');
|
||||||
|
networkError.name = 'NetworkError';
|
||||||
|
|
||||||
|
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user