mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -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 type { OAuthToken } from './token-storage/types.js';
|
||||
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
|
||||
import type {
|
||||
OAuthAuthorizationServerMetadata,
|
||||
OAuthProtectedResourceMetadata,
|
||||
} from './oauth-utils.js';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
@@ -329,8 +333,11 @@ describe('MCPOAuthProvider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should perform dynamic client registration when no client ID provided', async () => {
|
||||
const configWithoutClient = { ...mockConfig };
|
||||
it('should perform dynamic client registration when no client ID is provided but registration URL is provided', async () => {
|
||||
const configWithoutClient: MCPOAuthConfig = {
|
||||
...mockConfig,
|
||||
registrationUrl: 'https://auth.example.com/register',
|
||||
};
|
||||
delete configWithoutClient.clientId;
|
||||
|
||||
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
|
||||
@@ -342,7 +349,80 @@ describe('MCPOAuthProvider', () => {
|
||||
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',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
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 () => {
|
||||
let callbackHandler: unknown;
|
||||
vi.mocked(http.createServer).mockImplementation((handler) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface MCPOAuthConfig {
|
||||
audiences?: string[];
|
||||
redirectUri?: string;
|
||||
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,
|
||||
tokenUrl: discoveredConfig.tokenUrl,
|
||||
scopes: discoveredConfig.scopes || config.scopes || [],
|
||||
registrationUrl: discoveredConfig.registrationUrl,
|
||||
// Preserve existing client credentials
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
@@ -665,38 +667,38 @@ export class MCPOAuthProvider {
|
||||
|
||||
// If no client ID is provided, try dynamic client registration
|
||||
if (!config.clientId) {
|
||||
// Extract server URL from authorization URL
|
||||
if (!config.authorizationUrl) {
|
||||
throw new Error(
|
||||
'Cannot perform dynamic registration without authorization URL',
|
||||
);
|
||||
}
|
||||
let registrationUrl = config.registrationUrl;
|
||||
|
||||
const authUrl = new URL(config.authorizationUrl);
|
||||
const serverUrl = `${authUrl.protocol}//${authUrl.host}`;
|
||||
// If no registration URL was previously discovered, try to discover it
|
||||
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
|
||||
const authServerMetadataUrl = new URL(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
serverUrl,
|
||||
).toString();
|
||||
console.debug('→ Attempting dynamic client registration...');
|
||||
|
||||
const authServerMetadata =
|
||||
await OAuthUtils.fetchAuthorizationServerMetadata(
|
||||
authServerMetadataUrl,
|
||||
);
|
||||
if (!authServerMetadata) {
|
||||
throw new Error(
|
||||
'Failed to fetch authorization server metadata for client registration',
|
||||
);
|
||||
// Get the authorization server metadata for registration
|
||||
const authServerMetadata =
|
||||
await OAuthUtils.discoverAuthorizationServerMetadata(serverUrl);
|
||||
|
||||
if (!authServerMetadata) {
|
||||
throw new Error(
|
||||
'Failed to fetch authorization server metadata for client registration',
|
||||
);
|
||||
}
|
||||
registrationUrl = authServerMetadata.registration_endpoint;
|
||||
}
|
||||
|
||||
// Register client if registration endpoint is available
|
||||
if (authServerMetadata.registration_endpoint) {
|
||||
if (registrationUrl) {
|
||||
const clientRegistration = await this.registerClient(
|
||||
authServerMetadata.registration_endpoint,
|
||||
registrationUrl,
|
||||
config,
|
||||
);
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export class OAuthUtils {
|
||||
authorizationUrl: metadata.authorization_endpoint,
|
||||
tokenUrl: metadata.token_endpoint,
|
||||
scopes: metadata.scopes_supported || [],
|
||||
registrationUrl: metadata.registration_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user