mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0
This commit is contained in:
@@ -6,8 +6,9 @@
|
||||
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { GoogleCredentialProvider } from './google-auth-provider.js';
|
||||
import { vi, describe, beforeEach, it, expect, Mock } from 'vitest';
|
||||
import { MCPServerConfig } from '../config/config.js';
|
||||
import type { Mock } from 'vitest';
|
||||
import { vi, describe, beforeEach, it, expect } from 'vitest';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
|
||||
vi.mock('google-auth-library');
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import {
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { MCPServerConfig } from '../config/config.js';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
|
||||
const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/];
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ vi.mock('./oauth-token-storage.js');
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as http from 'node:http';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
MCPOAuthProvider,
|
||||
import type {
|
||||
MCPOAuthConfig,
|
||||
OAuthTokenResponse,
|
||||
OAuthClientRegistrationResponse,
|
||||
} from './oauth-provider.js';
|
||||
import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js';
|
||||
import { MCPOAuthProvider } from './oauth-provider.js';
|
||||
import type { OAuthToken } from './token-storage/types.js';
|
||||
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
@@ -100,7 +101,7 @@ describe('MCPOAuthProvider', () => {
|
||||
audiences: ['https://api.example.com'],
|
||||
};
|
||||
|
||||
const mockToken: MCPOAuthToken = {
|
||||
const mockToken: OAuthToken = {
|
||||
accessToken: 'access_token_123',
|
||||
refreshToken: 'refresh_token_456',
|
||||
tokenType: 'Bearer',
|
||||
|
||||
@@ -8,7 +8,8 @@ import * as http from 'node:http';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { URL } from 'node:url';
|
||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||
import { MCPOAuthToken, MCPOAuthTokenStorage } from './oauth-token-storage.js';
|
||||
import type { OAuthToken } from './token-storage/types.js';
|
||||
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { OAuthUtils } from './oauth-utils.js';
|
||||
|
||||
@@ -582,7 +583,7 @@ export class MCPOAuthProvider {
|
||||
serverName: string,
|
||||
config: MCPOAuthConfig,
|
||||
mcpServerUrl?: string,
|
||||
): Promise<MCPOAuthToken> {
|
||||
): Promise<OAuthToken> {
|
||||
// If no authorization URL is provided, try to discover OAuth configuration
|
||||
if (!config.authorizationUrl && mcpServerUrl) {
|
||||
console.log(
|
||||
@@ -776,7 +777,7 @@ export class MCPOAuthProvider {
|
||||
throw new Error('No access token received from token endpoint');
|
||||
}
|
||||
|
||||
const token: MCPOAuthToken = {
|
||||
const token: OAuthToken = {
|
||||
accessToken: tokenResponse.access_token,
|
||||
tokenType: tokenResponse.token_type || 'Bearer',
|
||||
refreshToken: tokenResponse.refresh_token,
|
||||
@@ -862,7 +863,7 @@ export class MCPOAuthProvider {
|
||||
);
|
||||
|
||||
// Update stored token
|
||||
const newToken: MCPOAuthToken = {
|
||||
const newToken: OAuthToken = {
|
||||
accessToken: newTokenResponse.access_token,
|
||||
tokenType: newTokenResponse.token_type,
|
||||
refreshToken: newTokenResponse.refresh_token || token.refreshToken,
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
MCPOAuthTokenStorage,
|
||||
MCPOAuthToken,
|
||||
MCPOAuthCredentials,
|
||||
} from './oauth-token-storage.js';
|
||||
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
|
||||
import type { OAuthToken, OAuthCredentials } from './token-storage/types.js';
|
||||
|
||||
// Mock file system operations
|
||||
vi.mock('node:fs', () => ({
|
||||
@@ -21,6 +18,7 @@ vi.mock('node:fs', () => ({
|
||||
mkdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
@@ -28,7 +26,7 @@ vi.mock('node:os', () => ({
|
||||
}));
|
||||
|
||||
describe('MCPOAuthTokenStorage', () => {
|
||||
const mockToken: MCPOAuthToken = {
|
||||
const mockToken: OAuthToken = {
|
||||
accessToken: 'access_token_123',
|
||||
refreshToken: 'refresh_token_456',
|
||||
tokenType: 'Bearer',
|
||||
@@ -36,7 +34,7 @@ describe('MCPOAuthTokenStorage', () => {
|
||||
expiresAt: Date.now() + 3600000, // 1 hour from now
|
||||
};
|
||||
|
||||
const mockCredentials: MCPOAuthCredentials = {
|
||||
const mockCredentials: OAuthCredentials = {
|
||||
serverName: 'test-server',
|
||||
token: mockToken,
|
||||
clientId: 'test-client-id',
|
||||
@@ -72,7 +70,7 @@ describe('MCPOAuthTokenStorage', () => {
|
||||
expect(tokens.size).toBe(1);
|
||||
expect(tokens.get('test-server')).toEqual(mockCredentials);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.gemini', 'mcp-oauth-tokens.json'),
|
||||
path.join('/mock/home', '.qwen', 'mcp-oauth-tokens.json'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
@@ -114,12 +112,11 @@ describe('MCPOAuthTokenStorage', () => {
|
||||
'https://token.url',
|
||||
);
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.gemini'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(path.join('/mock/home', '.qwen'), {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.gemini', 'mcp-oauth-tokens.json'),
|
||||
path.join('/mock/home', '.qwen', 'mcp-oauth-tokens.json'),
|
||||
expect.stringContaining('test-server'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
@@ -219,7 +216,7 @@ describe('MCPOAuthTokenStorage', () => {
|
||||
await MCPOAuthTokenStorage.removeToken('test-server');
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.gemini', 'mcp-oauth-tokens.json'),
|
||||
path.join('/mock/home', '.qwen', 'mcp-oauth-tokens.json'),
|
||||
);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -300,7 +297,7 @@ describe('MCPOAuthTokenStorage', () => {
|
||||
await MCPOAuthTokenStorage.clearAllTokens();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.gemini', 'mcp-oauth-tokens.json'),
|
||||
path.join('/mock/home', '.qwen', 'mcp-oauth-tokens.json'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,47 +6,21 @@
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Interface for MCP OAuth tokens.
|
||||
*/
|
||||
export interface MCPOAuthToken {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for stored MCP OAuth credentials.
|
||||
*/
|
||||
export interface MCPOAuthCredentials {
|
||||
serverName: string;
|
||||
token: MCPOAuthToken;
|
||||
clientId?: string;
|
||||
tokenUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
import type { OAuthToken, OAuthCredentials } from './token-storage/types.js';
|
||||
|
||||
/**
|
||||
* Class for managing MCP OAuth token storage and retrieval.
|
||||
*/
|
||||
export class MCPOAuthTokenStorage {
|
||||
private static readonly TOKEN_FILE = 'mcp-oauth-tokens.json';
|
||||
private static readonly CONFIG_DIR = '.gemini';
|
||||
|
||||
/**
|
||||
* Get the path to the token storage file.
|
||||
*
|
||||
* @returns The full path to the token storage file
|
||||
*/
|
||||
private static getTokenFilePath(): string {
|
||||
const homeDir = os.homedir();
|
||||
return path.join(homeDir, this.CONFIG_DIR, this.TOKEN_FILE);
|
||||
return Storage.getMcpOAuthTokensPath();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,13 +36,13 @@ export class MCPOAuthTokenStorage {
|
||||
*
|
||||
* @returns A map of server names to credentials
|
||||
*/
|
||||
static async loadTokens(): Promise<Map<string, MCPOAuthCredentials>> {
|
||||
const tokenMap = new Map<string, MCPOAuthCredentials>();
|
||||
static async loadTokens(): Promise<Map<string, OAuthCredentials>> {
|
||||
const tokenMap = new Map<string, OAuthCredentials>();
|
||||
|
||||
try {
|
||||
const tokenFile = this.getTokenFilePath();
|
||||
const data = await fs.readFile(tokenFile, 'utf-8');
|
||||
const tokens = JSON.parse(data) as MCPOAuthCredentials[];
|
||||
const tokens = JSON.parse(data) as OAuthCredentials[];
|
||||
|
||||
for (const credential of tokens) {
|
||||
tokenMap.set(credential.serverName, credential);
|
||||
@@ -96,7 +70,7 @@ export class MCPOAuthTokenStorage {
|
||||
*/
|
||||
static async saveToken(
|
||||
serverName: string,
|
||||
token: MCPOAuthToken,
|
||||
token: OAuthToken,
|
||||
clientId?: string,
|
||||
tokenUrl?: string,
|
||||
mcpServerUrl?: string,
|
||||
@@ -105,7 +79,7 @@ export class MCPOAuthTokenStorage {
|
||||
|
||||
const tokens = await this.loadTokens();
|
||||
|
||||
const credential: MCPOAuthCredentials = {
|
||||
const credential: OAuthCredentials = {
|
||||
serverName,
|
||||
token,
|
||||
clientId,
|
||||
@@ -139,9 +113,7 @@ export class MCPOAuthTokenStorage {
|
||||
* @param serverName The name of the MCP server
|
||||
* @returns The stored credentials or null if not found
|
||||
*/
|
||||
static async getToken(
|
||||
serverName: string,
|
||||
): Promise<MCPOAuthCredentials | null> {
|
||||
static async getToken(serverName: string): Promise<OAuthCredentials | null> {
|
||||
const tokens = await this.loadTokens();
|
||||
return tokens.get(serverName) || null;
|
||||
}
|
||||
@@ -181,7 +153,7 @@ export class MCPOAuthTokenStorage {
|
||||
* @param token The token to check
|
||||
* @returns True if the token is expired
|
||||
*/
|
||||
static isTokenExpired(token: MCPOAuthToken): boolean {
|
||||
static isTokenExpired(token: OAuthToken): boolean {
|
||||
if (!token.expiresAt) {
|
||||
return false; // No expiry, assume valid
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
OAuthUtils,
|
||||
import type {
|
||||
OAuthAuthorizationServerMetadata,
|
||||
OAuthProtectedResourceMetadata,
|
||||
} from './oauth-utils.js';
|
||||
import { OAuthUtils } from './oauth-utils.js';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
@@ -142,6 +142,74 @@ describe('OAuthUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverAuthorizationServerMetadata', () => {
|
||||
const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
scopes_supported: ['read', 'write'],
|
||||
};
|
||||
|
||||
it('should handle URLs without path components correctly', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthServerMetadata),
|
||||
});
|
||||
|
||||
const result = await OAuthUtils.discoverAuthorizationServerMetadata(
|
||||
'https://auth.example.com/',
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockAuthServerMetadata);
|
||||
|
||||
expect(mockFetch).nthCalledWith(
|
||||
1,
|
||||
'https://auth.example.com/.well-known/oauth-authorization-server',
|
||||
);
|
||||
expect(mockFetch).nthCalledWith(
|
||||
2,
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle URLs with path components correctly', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthServerMetadata),
|
||||
});
|
||||
|
||||
const result = await OAuthUtils.discoverAuthorizationServerMetadata(
|
||||
'https://auth.example.com/mcp',
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockAuthServerMetadata);
|
||||
|
||||
expect(mockFetch).nthCalledWith(
|
||||
1,
|
||||
'https://auth.example.com/.well-known/oauth-authorization-server/mcp',
|
||||
);
|
||||
expect(mockFetch).nthCalledWith(
|
||||
2,
|
||||
'https://auth.example.com/.well-known/openid-configuration/mcp',
|
||||
);
|
||||
expect(mockFetch).nthCalledWith(
|
||||
3,
|
||||
'https://auth.example.com/mcp/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadataToOAuthConfig', () => {
|
||||
it('should convert metadata to OAuth config', () => {
|
||||
const metadata: OAuthAuthorizationServerMetadata = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { MCPOAuthConfig } from './oauth-provider.js';
|
||||
import type { MCPOAuthConfig } from './oauth-provider.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
@@ -140,6 +140,76 @@ export class OAuthUtils {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover Oauth Authorization server metadata given an Auth server URL, by
|
||||
* trying the standard well-known endpoints.
|
||||
*
|
||||
* @param authServerUrl The authorization server URL
|
||||
* @returns The authorization server metadata or null if not found
|
||||
*/
|
||||
static async discoverAuthorizationServerMetadata(
|
||||
authServerUrl: string,
|
||||
): Promise<OAuthAuthorizationServerMetadata | null> {
|
||||
const authServerUrlObj = new URL(authServerUrl);
|
||||
const base = `${authServerUrlObj.protocol}//${authServerUrlObj.host}`;
|
||||
|
||||
const endpointsToTry: string[] = [];
|
||||
|
||||
// With issuer URLs with path components, try the following well-known
|
||||
// endpoints in order:
|
||||
if (authServerUrlObj.pathname !== '/') {
|
||||
// 1. OAuth 2.0 Authorization Server Metadata with path insertion
|
||||
endpointsToTry.push(
|
||||
new URL(
|
||||
`/.well-known/oauth-authorization-server${authServerUrlObj.pathname}`,
|
||||
base,
|
||||
).toString(),
|
||||
);
|
||||
|
||||
// 2. OpenID Connect Discovery 1.0 with path insertion
|
||||
endpointsToTry.push(
|
||||
new URL(
|
||||
`/.well-known/openid-configuration${authServerUrlObj.pathname}`,
|
||||
base,
|
||||
).toString(),
|
||||
);
|
||||
|
||||
// 3. OpenID Connect Discovery 1.0 with path appending
|
||||
endpointsToTry.push(
|
||||
new URL(
|
||||
`${authServerUrlObj.pathname}/.well-known/openid-configuration`,
|
||||
base,
|
||||
).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// With issuer URLs without path components, and those that failed previous
|
||||
// discoveries, try the following well-known endpoints in order:
|
||||
|
||||
// 1. OAuth 2.0 Authorization Server Metadata
|
||||
endpointsToTry.push(
|
||||
new URL('/.well-known/oauth-authorization-server', base).toString(),
|
||||
);
|
||||
|
||||
// 2. OpenID Connect Discovery 1.0
|
||||
endpointsToTry.push(
|
||||
new URL('/.well-known/openid-configuration', base).toString(),
|
||||
);
|
||||
|
||||
for (const endpoint of endpointsToTry) {
|
||||
const authServerMetadata =
|
||||
await this.fetchAuthorizationServerMetadata(endpoint);
|
||||
if (authServerMetadata) {
|
||||
return authServerMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`Metadata discovery failed for authorization server ${authServerUrl}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover OAuth configuration using the standard well-known endpoints.
|
||||
*
|
||||
@@ -172,33 +242,8 @@ export class OAuthUtils {
|
||||
if (resourceMetadata?.authorization_servers?.length) {
|
||||
// Use the first authorization server
|
||||
const authServerUrl = resourceMetadata.authorization_servers[0];
|
||||
|
||||
// The authorization server URL may include a path (e.g., https://github.com/login/oauth)
|
||||
// We need to preserve this path when constructing the metadata URL
|
||||
const authServerUrlObj = new URL(authServerUrl);
|
||||
const authServerPath =
|
||||
authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname;
|
||||
|
||||
// Try with the authorization server's path first
|
||||
let authServerMetadataUrl = new URL(
|
||||
`/.well-known/oauth-authorization-server${authServerPath}`,
|
||||
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
|
||||
).toString();
|
||||
|
||||
let authServerMetadata = await this.fetchAuthorizationServerMetadata(
|
||||
authServerMetadataUrl,
|
||||
);
|
||||
|
||||
// If that fails, try root as fallback
|
||||
if (!authServerMetadata && authServerPath) {
|
||||
authServerMetadataUrl = new URL(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
|
||||
).toString();
|
||||
authServerMetadata = await this.fetchAuthorizationServerMetadata(
|
||||
authServerMetadataUrl,
|
||||
);
|
||||
}
|
||||
const authServerMetadata =
|
||||
await this.discoverAuthorizationServerMetadata(authServerUrl);
|
||||
|
||||
if (authServerMetadata) {
|
||||
const config = this.metadataToOAuthConfig(authServerMetadata);
|
||||
@@ -212,13 +257,10 @@ export class OAuthUtils {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try /.well-known/oauth-authorization-server at the base URL
|
||||
console.debug(
|
||||
`Trying OAuth discovery fallback at ${wellKnownUrls.authorizationServer}`,
|
||||
);
|
||||
const authServerMetadata = await this.fetchAuthorizationServerMetadata(
|
||||
wellKnownUrls.authorizationServer,
|
||||
);
|
||||
// Fallback: try well-known endpoints at the base URL
|
||||
console.debug(`Trying OAuth discovery fallback at ${serverUrl}`);
|
||||
const authServerMetadata =
|
||||
await this.discoverAuthorizationServerMetadata(serverUrl);
|
||||
|
||||
if (authServerMetadata) {
|
||||
const config = this.metadataToOAuthConfig(authServerMetadata);
|
||||
@@ -277,34 +319,8 @@ export class OAuthUtils {
|
||||
}
|
||||
|
||||
const authServerUrl = resourceMetadata.authorization_servers[0];
|
||||
|
||||
// The authorization server URL may include a path (e.g., https://github.com/login/oauth)
|
||||
// We need to preserve this path when constructing the metadata URL
|
||||
const authServerUrlObj = new URL(authServerUrl);
|
||||
const authServerPath =
|
||||
authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname;
|
||||
|
||||
// Build auth server metadata URL with the authorization server's path
|
||||
const authServerMetadataUrl = new URL(
|
||||
`/.well-known/oauth-authorization-server${authServerPath}`,
|
||||
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
|
||||
).toString();
|
||||
|
||||
let authServerMetadata = await this.fetchAuthorizationServerMetadata(
|
||||
authServerMetadataUrl,
|
||||
);
|
||||
|
||||
// If that fails and we have a path, also try the root path as a fallback
|
||||
if (!authServerMetadata && authServerPath) {
|
||||
const rootAuthServerMetadataUrl = new URL(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
|
||||
).toString();
|
||||
|
||||
authServerMetadata = await this.fetchAuthorizationServerMetadata(
|
||||
rootAuthServerMetadataUrl,
|
||||
);
|
||||
}
|
||||
const authServerMetadata =
|
||||
await this.discoverAuthorizationServerMetadata(authServerUrl);
|
||||
|
||||
if (authServerMetadata) {
|
||||
return this.metadataToOAuthConfig(authServerMetadata);
|
||||
|
||||
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, '_');
|
||||
}
|
||||
}
|
||||
37
packages/core/src/mcp/token-storage/types.ts
Normal file
37
packages/core/src/mcp/token-storage/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface for OAuth tokens.
|
||||
*/
|
||||
export interface OAuthToken {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for stored OAuth credentials.
|
||||
*/
|
||||
export interface OAuthCredentials {
|
||||
serverName: string;
|
||||
token: OAuthToken;
|
||||
clientId?: string;
|
||||
tokenUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TokenStorage {
|
||||
getCredentials(serverName: string): Promise<OAuthCredentials | null>;
|
||||
setCredentials(credentials: OAuthCredentials): Promise<void>;
|
||||
deleteCredentials(serverName: string): Promise<void>;
|
||||
listServers(): Promise<string[]>;
|
||||
getAllCredentials(): Promise<Map<string, OAuthCredentials>>;
|
||||
clearAll(): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user