mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
For dynamic client registration - use registration endpoint in config if available instead of performing OAuth discovery again (#9231)
This commit is contained in:
@@ -39,6 +39,10 @@ import type {
|
|||||||
import { MCPOAuthProvider } from './oauth-provider.js';
|
import { MCPOAuthProvider } from './oauth-provider.js';
|
||||||
import type { OAuthToken } from './token-storage/types.js';
|
import type { OAuthToken } from './token-storage/types.js';
|
||||||
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
|
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
|
||||||
|
import type {
|
||||||
|
OAuthAuthorizationServerMetadata,
|
||||||
|
OAuthProtectedResourceMetadata,
|
||||||
|
} from './oauth-utils.js';
|
||||||
|
|
||||||
// Mock fetch globally
|
// Mock fetch globally
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
@@ -329,8 +333,11 @@ describe('MCPOAuthProvider', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should perform dynamic client registration when no client ID provided', async () => {
|
it('should perform dynamic client registration when no client ID is provided but registration URL is provided', async () => {
|
||||||
const configWithoutClient = { ...mockConfig };
|
const configWithoutClient: MCPOAuthConfig = {
|
||||||
|
...mockConfig,
|
||||||
|
registrationUrl: 'https://auth.example.com/register',
|
||||||
|
};
|
||||||
delete configWithoutClient.clientId;
|
delete configWithoutClient.clientId;
|
||||||
|
|
||||||
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
|
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
|
||||||
@@ -342,7 +349,80 @@ describe('MCPOAuthProvider', () => {
|
|||||||
token_endpoint_auth_method: 'none',
|
token_endpoint_auth_method: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAuthServerMetadata = {
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
contentType: 'application/json',
|
||||||
|
text: JSON.stringify(mockRegistrationResponse),
|
||||||
|
json: mockRegistrationResponse,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup callback handler
|
||||||
|
let callbackHandler: unknown;
|
||||||
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
||||||
|
callbackHandler = handler;
|
||||||
|
return mockHttpServer as unknown as http.Server;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
||||||
|
callback?.();
|
||||||
|
setTimeout(() => {
|
||||||
|
const mockReq = {
|
||||||
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
||||||
|
};
|
||||||
|
const mockRes = {
|
||||||
|
writeHead: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
};
|
||||||
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
||||||
|
mockReq,
|
||||||
|
mockRes,
|
||||||
|
);
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock token exchange
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
contentType: 'application/json',
|
||||||
|
text: JSON.stringify(mockTokenResponse),
|
||||||
|
json: mockTokenResponse,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authProvider = new MCPOAuthProvider();
|
||||||
|
const result = await authProvider.authenticate(
|
||||||
|
'test-server',
|
||||||
|
configWithoutClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com/register',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should perform OAuth discovery and dynamic client registration when no client ID or registration URL provided', async () => {
|
||||||
|
const configWithoutClient: MCPOAuthConfig = { ...mockConfig };
|
||||||
|
delete configWithoutClient.clientId;
|
||||||
|
|
||||||
|
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
|
||||||
|
client_id: 'dynamic_client_id',
|
||||||
|
client_secret: 'dynamic_client_secret',
|
||||||
|
redirect_uris: ['http://localhost:7777/oauth/callback'],
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = {
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||||
token_endpoint: 'https://auth.example.com/token',
|
token_endpoint: 'https://auth.example.com/token',
|
||||||
registration_endpoint: 'https://auth.example.com/register',
|
registration_endpoint: 'https://auth.example.com/register',
|
||||||
@@ -416,6 +496,117 @@ describe('MCPOAuthProvider', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should perform OAuth discovery once and dynamic client registration when no client ID, authorization URL or registration URL provided', async () => {
|
||||||
|
const configWithoutClientAndAuthorizationUrl: MCPOAuthConfig = {
|
||||||
|
...mockConfig,
|
||||||
|
};
|
||||||
|
delete configWithoutClientAndAuthorizationUrl.clientId;
|
||||||
|
delete configWithoutClientAndAuthorizationUrl.authorizationUrl;
|
||||||
|
|
||||||
|
const mockResourceMetadata: OAuthProtectedResourceMetadata = {
|
||||||
|
resource: 'https://api.example.com',
|
||||||
|
authorization_servers: ['https://auth.example.com'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = {
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||||
|
token_endpoint: 'https://auth.example.com/token',
|
||||||
|
registration_endpoint: 'https://auth.example.com/register',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
|
||||||
|
client_id: 'dynamic_client_id',
|
||||||
|
client_secret: 'dynamic_client_secret',
|
||||||
|
redirect_uris: ['http://localhost:7777/oauth/callback'],
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
contentType: 'application/json',
|
||||||
|
text: JSON.stringify(mockResourceMetadata),
|
||||||
|
json: mockResourceMetadata,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
contentType: 'application/json',
|
||||||
|
text: JSON.stringify(mockAuthServerMetadata),
|
||||||
|
json: mockAuthServerMetadata,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
contentType: 'application/json',
|
||||||
|
text: JSON.stringify(mockRegistrationResponse),
|
||||||
|
json: mockRegistrationResponse,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup callback handler
|
||||||
|
let callbackHandler: unknown;
|
||||||
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
||||||
|
callbackHandler = handler;
|
||||||
|
return mockHttpServer as unknown as http.Server;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockHttpServer.listen.mockImplementation((port, callback) => {
|
||||||
|
callback?.();
|
||||||
|
setTimeout(() => {
|
||||||
|
const mockReq = {
|
||||||
|
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
|
||||||
|
};
|
||||||
|
const mockRes = {
|
||||||
|
writeHead: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
};
|
||||||
|
(callbackHandler as (req: unknown, res: unknown) => void)(
|
||||||
|
mockReq,
|
||||||
|
mockRes,
|
||||||
|
);
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock token exchange
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
contentType: 'application/json',
|
||||||
|
text: JSON.stringify(mockTokenResponse),
|
||||||
|
json: mockTokenResponse,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authProvider = new MCPOAuthProvider();
|
||||||
|
const result = await authProvider.authenticate(
|
||||||
|
'test-server',
|
||||||
|
configWithoutClientAndAuthorizationUrl,
|
||||||
|
'https://api.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com/register',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle OAuth callback errors', async () => {
|
it('should handle OAuth callback errors', async () => {
|
||||||
let callbackHandler: unknown;
|
let callbackHandler: unknown;
|
||||||
vi.mocked(http.createServer).mockImplementation((handler) => {
|
vi.mocked(http.createServer).mockImplementation((handler) => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface MCPOAuthConfig {
|
|||||||
audiences?: string[];
|
audiences?: string[];
|
||||||
redirectUri?: string;
|
redirectUri?: string;
|
||||||
tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token
|
tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token
|
||||||
|
registrationUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -651,6 +652,7 @@ export class MCPOAuthProvider {
|
|||||||
authorizationUrl: discoveredConfig.authorizationUrl,
|
authorizationUrl: discoveredConfig.authorizationUrl,
|
||||||
tokenUrl: discoveredConfig.tokenUrl,
|
tokenUrl: discoveredConfig.tokenUrl,
|
||||||
scopes: discoveredConfig.scopes || config.scopes || [],
|
scopes: discoveredConfig.scopes || config.scopes || [],
|
||||||
|
registrationUrl: discoveredConfig.registrationUrl,
|
||||||
// Preserve existing client credentials
|
// Preserve existing client credentials
|
||||||
clientId: config.clientId,
|
clientId: config.clientId,
|
||||||
clientSecret: config.clientSecret,
|
clientSecret: config.clientSecret,
|
||||||
@@ -665,38 +667,38 @@ export class MCPOAuthProvider {
|
|||||||
|
|
||||||
// If no client ID is provided, try dynamic client registration
|
// If no client ID is provided, try dynamic client registration
|
||||||
if (!config.clientId) {
|
if (!config.clientId) {
|
||||||
// Extract server URL from authorization URL
|
let registrationUrl = config.registrationUrl;
|
||||||
if (!config.authorizationUrl) {
|
|
||||||
throw new Error(
|
|
||||||
'Cannot perform dynamic registration without authorization URL',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authUrl = new URL(config.authorizationUrl);
|
// If no registration URL was previously discovered, try to discover it
|
||||||
const serverUrl = `${authUrl.protocol}//${authUrl.host}`;
|
if (!registrationUrl) {
|
||||||
|
// Extract server URL from authorization URL
|
||||||
|
if (!config.authorizationUrl) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot perform dynamic registration without authorization URL',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('→ Attempting dynamic client registration...');
|
const authUrl = new URL(config.authorizationUrl);
|
||||||
|
const serverUrl = `${authUrl.protocol}//${authUrl.host}`;
|
||||||
|
|
||||||
// Get the authorization server metadata for registration
|
console.debug('→ Attempting dynamic client registration...');
|
||||||
const authServerMetadataUrl = new URL(
|
|
||||||
'/.well-known/oauth-authorization-server',
|
|
||||||
serverUrl,
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
const authServerMetadata =
|
// Get the authorization server metadata for registration
|
||||||
await OAuthUtils.fetchAuthorizationServerMetadata(
|
const authServerMetadata =
|
||||||
authServerMetadataUrl,
|
await OAuthUtils.discoverAuthorizationServerMetadata(serverUrl);
|
||||||
);
|
|
||||||
if (!authServerMetadata) {
|
if (!authServerMetadata) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Failed to fetch authorization server metadata for client registration',
|
'Failed to fetch authorization server metadata for client registration',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
registrationUrl = authServerMetadata.registration_endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register client if registration endpoint is available
|
// Register client if registration endpoint is available
|
||||||
if (authServerMetadata.registration_endpoint) {
|
if (registrationUrl) {
|
||||||
const clientRegistration = await this.registerClient(
|
const clientRegistration = await this.registerClient(
|
||||||
authServerMetadata.registration_endpoint,
|
registrationUrl,
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export class OAuthUtils {
|
|||||||
authorizationUrl: metadata.authorization_endpoint,
|
authorizationUrl: metadata.authorization_endpoint,
|
||||||
tokenUrl: metadata.token_endpoint,
|
tokenUrl: metadata.token_endpoint,
|
||||||
scopes: metadata.scopes_supported || [],
|
scopes: metadata.scopes_supported || [],
|
||||||
|
registrationUrl: metadata.registration_endpoint,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user