mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(cli) - Define base class for token storage (#7221)
Co-authored-by: Shi Shu <shii@google.com>
This commit is contained in:
208
packages/core/src/mcp/token-storage/base-token-storage.test.ts
Normal file
208
packages/core/src/mcp/token-storage/base-token-storage.test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { BaseTokenStorage } from './base-token-storage.js';
|
||||||
|
import type { OAuthCredentials, OAuthToken } from './types.js';
|
||||||
|
|
||||||
|
class TestTokenStorage extends BaseTokenStorage {
|
||||||
|
private storage = new Map<string, OAuthCredentials>();
|
||||||
|
|
||||||
|
async getCredentials(serverName: string): Promise<OAuthCredentials | null> {
|
||||||
|
return this.storage.get(serverName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCredentials(credentials: OAuthCredentials): Promise<void> {
|
||||||
|
this.validateCredentials(credentials);
|
||||||
|
this.storage.set(credentials.serverName, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCredentials(serverName: string): Promise<void> {
|
||||||
|
this.storage.delete(serverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listServers(): Promise<string[]> {
|
||||||
|
return Array.from(this.storage.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
|
||||||
|
return new Map(this.storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
this.storage.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
override validateCredentials(credentials: OAuthCredentials): void {
|
||||||
|
super.validateCredentials(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
override isTokenExpired(credentials: OAuthCredentials): boolean {
|
||||||
|
return super.isTokenExpired(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
override sanitizeServerName(serverName: string): string {
|
||||||
|
return super.sanitizeServerName(serverName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BaseTokenStorage', () => {
|
||||||
|
let storage: TestTokenStorage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new TestTokenStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateCredentials', () => {
|
||||||
|
it('should validate valid credentials', () => {
|
||||||
|
const credentials: OAuthCredentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => storage.validateCredentials(credentials)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing server name', () => {
|
||||||
|
const credentials = {
|
||||||
|
serverName: '',
|
||||||
|
token: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
} as OAuthCredentials;
|
||||||
|
|
||||||
|
expect(() => storage.validateCredentials(credentials)).toThrow(
|
||||||
|
'Server name is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing token', () => {
|
||||||
|
const credentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: null as unknown as OAuthToken,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
} as OAuthCredentials;
|
||||||
|
|
||||||
|
expect(() => storage.validateCredentials(credentials)).toThrow(
|
||||||
|
'Token is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing access token', () => {
|
||||||
|
const credentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: {
|
||||||
|
accessToken: '',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
} as OAuthCredentials;
|
||||||
|
|
||||||
|
expect(() => storage.validateCredentials(credentials)).toThrow(
|
||||||
|
'Access token is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing token type', () => {
|
||||||
|
const credentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
tokenType: '',
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
} as OAuthCredentials;
|
||||||
|
|
||||||
|
expect(() => storage.validateCredentials(credentials)).toThrow(
|
||||||
|
'Token type is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isTokenExpired', () => {
|
||||||
|
it('should return false for tokens without expiry', () => {
|
||||||
|
const credentials: OAuthCredentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(storage.isTokenExpired(credentials)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for valid tokens', () => {
|
||||||
|
const credentials: OAuthCredentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(storage.isTokenExpired(credentials)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for expired tokens', () => {
|
||||||
|
const credentials: OAuthCredentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: Date.now() - 3600000,
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(storage.isTokenExpired(credentials)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply 5-minute buffer for expiry check', () => {
|
||||||
|
const fourMinutesFromNow = Date.now() + 4 * 60 * 1000;
|
||||||
|
const credentials: OAuthCredentials = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
token: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: fourMinutesFromNow,
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(storage.isTokenExpired(credentials)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeServerName', () => {
|
||||||
|
it('should keep valid characters', () => {
|
||||||
|
expect(storage.sanitizeServerName('test-server.example_123')).toBe(
|
||||||
|
'test-server.example_123',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace invalid characters with underscore', () => {
|
||||||
|
expect(storage.sanitizeServerName('test@server#example')).toBe(
|
||||||
|
'test_server_example',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters', () => {
|
||||||
|
expect(storage.sanitizeServerName('test server/example:123')).toBe(
|
||||||
|
'test_server_example_123',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/core/src/mcp/token-storage/base-token-storage.ts
Normal file
49
packages/core/src/mcp/token-storage/base-token-storage.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TokenStorage, OAuthCredentials } from './types.js';
|
||||||
|
|
||||||
|
export abstract class BaseTokenStorage implements TokenStorage {
|
||||||
|
protected readonly serviceName: string;
|
||||||
|
|
||||||
|
constructor(serviceName: string = 'gemini-cli-mcp-oauth') {
|
||||||
|
this.serviceName = serviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getCredentials(serverName: string): Promise<OAuthCredentials | null>;
|
||||||
|
abstract setCredentials(credentials: OAuthCredentials): Promise<void>;
|
||||||
|
abstract deleteCredentials(serverName: string): Promise<void>;
|
||||||
|
abstract listServers(): Promise<string[]>;
|
||||||
|
abstract getAllCredentials(): Promise<Map<string, OAuthCredentials>>;
|
||||||
|
abstract clearAll(): Promise<void>;
|
||||||
|
|
||||||
|
protected validateCredentials(credentials: OAuthCredentials): void {
|
||||||
|
if (!credentials.serverName) {
|
||||||
|
throw new Error('Server name is required');
|
||||||
|
}
|
||||||
|
if (!credentials.token) {
|
||||||
|
throw new Error('Token is required');
|
||||||
|
}
|
||||||
|
if (!credentials.token.accessToken) {
|
||||||
|
throw new Error('Access token is required');
|
||||||
|
}
|
||||||
|
if (!credentials.token.tokenType) {
|
||||||
|
throw new Error('Token type is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isTokenExpired(credentials: OAuthCredentials): boolean {
|
||||||
|
if (!credentials.token.expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bufferMs = 5 * 60 * 1000;
|
||||||
|
return Date.now() > credentials.token.expiresAt - bufferMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sanitizeServerName(serverName: string): string {
|
||||||
|
return serverName.replace(/[^a-zA-Z0-9-_.]/g, '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user