mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 11:04:42 -07:00
feat(core): implement dynamic client registration for a2a oauth2
This commit is contained in:
@@ -615,8 +615,9 @@ auth:
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
authorization_url: https://auth.example.com/authorize
|
||||
token_url: https://auth.example.com/token
|
||||
endpoints:
|
||||
authorization_url: https://auth.example.com/authorize
|
||||
token_url: https://auth.example.com/token
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
@@ -629,8 +630,10 @@ auth:
|
||||
client_id: 'my-client-id',
|
||||
client_secret: 'my-client-secret',
|
||||
scopes: ['openid', 'profile'],
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -663,7 +666,8 @@ agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: oauth2
|
||||
client_id: my-client
|
||||
authorization_url: not-a-valid-url
|
||||
endpoints:
|
||||
authorization_url: not-a-valid-url
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(/Invalid url/);
|
||||
@@ -677,7 +681,8 @@ agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: oauth2
|
||||
client_id: my-client
|
||||
token_url: not-a-valid-url
|
||||
endpoints:
|
||||
token_url: not-a-valid-url
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(/Invalid url/);
|
||||
@@ -692,8 +697,10 @@ auth:
|
||||
type: 'oauth2' as const,
|
||||
client_id: '$MY_CLIENT_ID',
|
||||
scopes: ['read'],
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -705,8 +712,10 @@ auth:
|
||||
type: 'oauth2',
|
||||
client_id: '$MY_CLIENT_ID',
|
||||
scopes: ['read'],
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ interface FrontmatterLocalAgentDefinition
|
||||
* Authentication configuration for remote agents in frontmatter format.
|
||||
*/
|
||||
interface FrontmatterAuthConfig {
|
||||
type: 'apiKey' | 'http' | 'oauth2';
|
||||
type: string;
|
||||
agent_card_requires_auth?: boolean;
|
||||
// API Key
|
||||
key?: string;
|
||||
@@ -56,11 +56,23 @@ interface FrontmatterAuthConfig {
|
||||
password?: string;
|
||||
value?: string;
|
||||
// OAuth2
|
||||
grant_type?: 'authorization_code' | 'client_credentials' | 'device_code';
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scopes?: string[];
|
||||
authorization_url?: string;
|
||||
token_url?: string;
|
||||
registration_url?: string;
|
||||
device_authorization_url?: string;
|
||||
endpoints?: {
|
||||
authorization_url?: string;
|
||||
token_url?: string;
|
||||
device_authorization_url?: string;
|
||||
registration_url?: string;
|
||||
};
|
||||
client_type?: 'static' | 'dynamic';
|
||||
client_name?: string;
|
||||
registration_token?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterRemoteAgentDefinition
|
||||
@@ -153,18 +165,32 @@ const httpAuthSchema = z.object({
|
||||
value: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const oauth2EndpointsSchema = z.object({
|
||||
authorization_url: z.string().url().optional(),
|
||||
token_url: z.string().url().optional(),
|
||||
device_authorization_url: z.string().url().optional(),
|
||||
registration_url: z.string().url().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* OAuth2 auth schema.
|
||||
* authorization_url and token_url can be discovered from the agent card if omitted.
|
||||
* endpoints can be discovered from the agent card if omitted.
|
||||
*/
|
||||
const oauth2AuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('oauth2'),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
grant_type: z
|
||||
.enum(['authorization_code', 'client_credentials', 'device_code'])
|
||||
.optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
authorization_url: z.string().url().optional(),
|
||||
token_url: z.string().url().optional(),
|
||||
endpoints: oauth2EndpointsSchema.optional(),
|
||||
|
||||
// Client configuration
|
||||
client_type: z.enum(['static', 'dynamic']).optional(),
|
||||
client_id: z.string().optional(), // Technically required for 'static', validated at type-level
|
||||
client_secret: z.string().optional(),
|
||||
client_name: z.string().optional(),
|
||||
registration_token: z.string().optional(),
|
||||
});
|
||||
|
||||
const authConfigSchema = z
|
||||
@@ -419,20 +445,41 @@ function convertFrontmatterAuthToConfig(
|
||||
}
|
||||
}
|
||||
|
||||
case 'oauth2':
|
||||
case 'oauth2': {
|
||||
const endpoints = frontmatter.endpoints || {
|
||||
authorization_url: frontmatter.authorization_url,
|
||||
token_url: frontmatter.token_url,
|
||||
registration_url: frontmatter.registration_url,
|
||||
device_authorization_url: frontmatter.device_authorization_url,
|
||||
};
|
||||
|
||||
if (frontmatter.client_type === 'dynamic') {
|
||||
return {
|
||||
...base,
|
||||
type: 'oauth2',
|
||||
grant_type: frontmatter.grant_type,
|
||||
scopes: frontmatter.scopes,
|
||||
endpoints,
|
||||
client_type: 'dynamic',
|
||||
client_name: frontmatter.client_name,
|
||||
registration_token: frontmatter.registration_token,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: 'oauth2',
|
||||
client_id: frontmatter.client_id,
|
||||
client_secret: frontmatter.client_secret,
|
||||
grant_type: frontmatter.grant_type,
|
||||
scopes: frontmatter.scopes,
|
||||
authorization_url: frontmatter.authorization_url,
|
||||
token_url: frontmatter.token_url,
|
||||
endpoints,
|
||||
client_type: 'static',
|
||||
client_id: frontmatter.client_id || '',
|
||||
client_secret: frontmatter.client_secret,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.type;
|
||||
throw new Error(`Unknown auth type: ${exhaustive}`);
|
||||
throw new Error(`Unknown auth type: ${frontmatter.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,8 @@ describe('A2AAuthProviderFactory', () => {
|
||||
it('should match oauth2 config with oauth2 scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'oauth2',
|
||||
client_type: 'static',
|
||||
client_id: 'test-client',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oauth2Auth: {
|
||||
@@ -510,8 +512,10 @@ describe('A2AAuthProviderFactory', () => {
|
||||
authConfig: {
|
||||
type: 'oauth2',
|
||||
client_id: 'my-client',
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
},
|
||||
scopes: ['read'],
|
||||
},
|
||||
});
|
||||
@@ -552,8 +556,10 @@ describe('A2AAuthProviderFactory', () => {
|
||||
authConfig: {
|
||||
type: 'oauth2',
|
||||
client_id: 'my-client',
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('../../mcp/oauth-token-storage.js', () => {
|
||||
});
|
||||
|
||||
vi.mock('../../utils/oauth-flow.js', () => ({
|
||||
REDIRECT_PATH: '/oauth/callback',
|
||||
generatePKCEParams: vi.fn().mockReturnValue({
|
||||
codeVerifier: 'test-verifier',
|
||||
codeChallenge: 'test-challenge',
|
||||
@@ -102,8 +103,10 @@ function createConfig(
|
||||
return {
|
||||
type: 'oauth2',
|
||||
client_id: 'test-client-id',
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://auth.example.com/authorize',
|
||||
token_url: 'https://auth.example.com/token',
|
||||
},
|
||||
scopes: ['read', 'write'],
|
||||
...overrides,
|
||||
};
|
||||
@@ -133,8 +136,10 @@ describe('OAuth2AuthProvider', () => {
|
||||
|
||||
it('should use config values for authorization_url and token_url', () => {
|
||||
const config = createConfig({
|
||||
authorization_url: 'https://custom.example.com/authorize',
|
||||
token_url: 'https://custom.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://custom.example.com/authorize',
|
||||
token_url: 'https://custom.example.com/token',
|
||||
},
|
||||
});
|
||||
const provider = new OAuth2AuthProvider(config, 'test-agent');
|
||||
// Verify by calling headers which will trigger interactive flow with these URLs.
|
||||
@@ -143,8 +148,10 @@ describe('OAuth2AuthProvider', () => {
|
||||
|
||||
it('should merge agent card defaults when config values are missing', () => {
|
||||
const config = createConfig({
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
endpoints: {
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
scopes: undefined,
|
||||
});
|
||||
|
||||
@@ -169,8 +176,10 @@ describe('OAuth2AuthProvider', () => {
|
||||
|
||||
it('should prefer config values over agent card values', async () => {
|
||||
const config = createConfig({
|
||||
authorization_url: 'https://config.example.com/authorize',
|
||||
token_url: 'https://config.example.com/token',
|
||||
endpoints: {
|
||||
authorization_url: 'https://config.example.com/authorize',
|
||||
token_url: 'https://config.example.com/token',
|
||||
},
|
||||
scopes: ['custom-scope'],
|
||||
});
|
||||
|
||||
@@ -389,8 +398,10 @@ describe('OAuth2AuthProvider', () => {
|
||||
|
||||
it('should throw when authorization_url and token_url are missing', async () => {
|
||||
const config = createConfig({
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
endpoints: {
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
});
|
||||
const provider = new OAuth2AuthProvider(config, 'test-agent');
|
||||
await provider.initialize();
|
||||
@@ -538,8 +549,10 @@ describe('OAuth2AuthProvider', () => {
|
||||
describe('agent card integration', () => {
|
||||
it('should discover URLs from agent card when not in config', async () => {
|
||||
const config = createConfig({
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
endpoints: {
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
scopes: undefined,
|
||||
});
|
||||
|
||||
@@ -576,8 +589,10 @@ describe('OAuth2AuthProvider', () => {
|
||||
|
||||
it('should discover URLs from agentCardUrl via DefaultAgentCardResolver during initialize', async () => {
|
||||
const config = createConfig({
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
endpoints: {
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
scopes: undefined,
|
||||
});
|
||||
|
||||
@@ -625,8 +640,10 @@ describe('OAuth2AuthProvider', () => {
|
||||
|
||||
it('should ignore agent card with no authorizationCode flow', () => {
|
||||
const config = createConfig({
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
endpoints: {
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const agentCard = {
|
||||
|
||||
@@ -13,10 +13,11 @@ import type { OAuthToken } from '../../mcp/token-storage/types.js';
|
||||
import {
|
||||
generatePKCEParams,
|
||||
startCallbackServer,
|
||||
getPortFromUrl,
|
||||
buildAuthorizationUrl,
|
||||
exchangeCodeForToken,
|
||||
refreshAccessToken,
|
||||
registerDynamicClient,
|
||||
REDIRECT_PATH,
|
||||
type OAuthFlowConfig,
|
||||
} from '../../utils/oauth-flow.js';
|
||||
import { openBrowserSecurely } from '../../utils/secure-browser-launcher.js';
|
||||
@@ -38,11 +39,14 @@ export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
|
||||
private readonly tokenStorage: MCPOAuthTokenStorage;
|
||||
private cachedToken: OAuthToken | null = null;
|
||||
private dynamicClientId: string | undefined;
|
||||
private dynamicClientSecret: string | undefined;
|
||||
|
||||
/** Resolved OAuth URLs — may come from config or agent card. */
|
||||
private authorizationUrl: string | undefined;
|
||||
private tokenUrl: string | undefined;
|
||||
private scopes: string[] | undefined;
|
||||
private registrationUrl: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly config: OAuth2AuthConfig,
|
||||
@@ -57,8 +61,9 @@ export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
);
|
||||
|
||||
// Seed from user config.
|
||||
this.authorizationUrl = config.authorization_url;
|
||||
this.tokenUrl = config.token_url;
|
||||
this.authorizationUrl = config.endpoints?.authorization_url;
|
||||
this.tokenUrl = config.endpoints?.token_url;
|
||||
this.registrationUrl = config.endpoints?.registration_url;
|
||||
this.scopes = config.scopes;
|
||||
|
||||
// Fall back to agent card's OAuth2 security scheme if user config is incomplete.
|
||||
@@ -76,14 +81,32 @@ export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
}
|
||||
|
||||
const credentials = await this.tokenStorage.getCredentials(this.agentName);
|
||||
if (credentials && !this.tokenStorage.isTokenExpired(credentials.token)) {
|
||||
this.cachedToken = credentials.token;
|
||||
debugLogger.debug(
|
||||
`[OAuth2AuthProvider] Loaded valid cached token for "${this.agentName}"`,
|
||||
);
|
||||
if (credentials) {
|
||||
if (this.config.client_type === 'dynamic') {
|
||||
this.dynamicClientId = credentials.clientId;
|
||||
}
|
||||
|
||||
if (!this.tokenStorage.isTokenExpired(credentials.token)) {
|
||||
this.cachedToken = credentials.token;
|
||||
debugLogger.debug(
|
||||
`[OAuth2AuthProvider] Loaded valid cached token for "${this.agentName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private get clientId(): string | undefined {
|
||||
return this.config.client_type === 'dynamic'
|
||||
? this.dynamicClientId
|
||||
: this.config.client_id;
|
||||
}
|
||||
|
||||
private get clientSecret(): string | undefined {
|
||||
return this.config.client_type === 'dynamic'
|
||||
? this.dynamicClientSecret
|
||||
: this.config.client_secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an Authorization header with a valid Bearer token.
|
||||
* Refreshes or triggers interactive auth as needed.
|
||||
@@ -98,16 +121,12 @@ export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
}
|
||||
|
||||
// 2. Expired but has refresh token → attempt silent refresh.
|
||||
if (
|
||||
this.cachedToken?.refreshToken &&
|
||||
this.tokenUrl &&
|
||||
this.config.client_id
|
||||
) {
|
||||
if (this.cachedToken?.refreshToken && this.tokenUrl && this.clientId) {
|
||||
try {
|
||||
const refreshed = await refreshAccessToken(
|
||||
{
|
||||
clientId: this.config.client_id,
|
||||
clientSecret: this.config.client_secret,
|
||||
clientId: this.clientId,
|
||||
clientSecret: this.clientSecret,
|
||||
scopes: this.scopes,
|
||||
},
|
||||
this.cachedToken.refreshToken,
|
||||
@@ -209,12 +228,6 @@ export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
* Run a full OAuth 2.0 Authorization Code + PKCE flow through the browser.
|
||||
*/
|
||||
private async authenticateInteractively(): Promise<OAuthToken> {
|
||||
if (!this.config.client_id) {
|
||||
throw new Error(
|
||||
`OAuth2 authentication for agent "${this.agentName}" requires a client_id. ` +
|
||||
'Add client_id to the auth config in your agent definition.',
|
||||
);
|
||||
}
|
||||
if (!this.authorizationUrl || !this.tokenUrl) {
|
||||
throw new Error(
|
||||
`OAuth2 authentication for agent "${this.agentName}" requires authorization_url and token_url. ` +
|
||||
@@ -222,19 +235,66 @@ export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const pkceParams = generatePKCEParams();
|
||||
|
||||
// We don't have a redirectUri in config yet (unlike MCP). Let's use a default one for port allocation
|
||||
// If we want to allow configuring redirectUri, we can add it to OAuth2AuthConfig later.
|
||||
const preferredPort = 0; // Let OS assign port
|
||||
const callbackServer = startCallbackServer(pkceParams.state, preferredPort);
|
||||
const redirectPort = await callbackServer.port;
|
||||
|
||||
if (!this.clientId) {
|
||||
if (this.config.client_type === 'dynamic') {
|
||||
if (!this.registrationUrl) {
|
||||
throw new Error(
|
||||
`OAuth2 dynamic registration for agent "${this.agentName}" requires registration_url. ` +
|
||||
'Provide it in the auth config or ensure the agent card exposes an oauth2 security scheme with it.',
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[OAuth2AuthProvider] Attempting dynamic client registration for "${this.agentName}"...`,
|
||||
);
|
||||
const redirectUri = `http://localhost:${redirectPort}${REDIRECT_PATH}`;
|
||||
const clientName =
|
||||
this.config.client_name || `Gemini CLI A2A Agent - ${this.agentName}`;
|
||||
|
||||
const clientRegistration = await registerDynamicClient(
|
||||
this.registrationUrl,
|
||||
clientName,
|
||||
redirectUri,
|
||||
this.scopes,
|
||||
);
|
||||
|
||||
this.dynamicClientId = clientRegistration.client_id;
|
||||
if (clientRegistration.client_secret) {
|
||||
this.dynamicClientSecret = clientRegistration.client_secret;
|
||||
}
|
||||
debugLogger.debug(
|
||||
`[OAuth2AuthProvider] ✓ Dynamic client registration successful`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`OAuth2 authentication for agent "${this.agentName}" requires a client_id. ` +
|
||||
'Add client_id to the auth config in your agent definition.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After dynamic registration, clientId should be populated
|
||||
if (!this.clientId) {
|
||||
throw new Error('Failed to resolve client_id for OAuth2 authentication');
|
||||
}
|
||||
|
||||
const flowConfig: OAuthFlowConfig = {
|
||||
clientId: this.config.client_id,
|
||||
clientSecret: this.config.client_secret,
|
||||
clientId: this.clientId,
|
||||
clientSecret: this.clientSecret,
|
||||
authorizationUrl: this.authorizationUrl,
|
||||
tokenUrl: this.tokenUrl,
|
||||
scopes: this.scopes,
|
||||
redirectUri: `http://localhost:${redirectPort}${REDIRECT_PATH}`,
|
||||
};
|
||||
|
||||
const pkceParams = generatePKCEParams();
|
||||
const preferredPort = getPortFromUrl(flowConfig.redirectUri);
|
||||
const callbackServer = startCallbackServer(pkceParams.state, preferredPort);
|
||||
const redirectPort = await callbackServer.port;
|
||||
|
||||
const authUrl = buildAuthorizationUrl(
|
||||
flowConfig,
|
||||
pkceParams,
|
||||
@@ -329,11 +389,11 @@ export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
* Persist the current cached token to disk.
|
||||
*/
|
||||
private async persistToken(): Promise<void> {
|
||||
if (!this.cachedToken) return;
|
||||
if (!this.cachedToken || !this.clientId) return;
|
||||
await this.tokenStorage.saveToken(
|
||||
this.agentName,
|
||||
this.cachedToken,
|
||||
this.config.client_id,
|
||||
this.clientId,
|
||||
this.tokenUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,18 +68,32 @@ export type HttpAuthConfig = BaseAuthConfig & {
|
||||
}
|
||||
);
|
||||
|
||||
/** Client config corresponding to OAuth2SecurityScheme. */
|
||||
export interface OAuth2AuthConfig extends BaseAuthConfig {
|
||||
type: 'oauth2';
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scopes?: string[];
|
||||
/** Override or provide the authorization endpoint URL. Discovered from agent card if omitted. */
|
||||
export interface OAuth2Endpoints {
|
||||
authorization_url?: string;
|
||||
/** Override or provide the token endpoint URL. Discovered from agent card if omitted. */
|
||||
token_url?: string;
|
||||
device_authorization_url?: string;
|
||||
registration_url?: string;
|
||||
}
|
||||
|
||||
/** Client config corresponding to OAuth2SecurityScheme. */
|
||||
export type OAuth2AuthConfig = BaseAuthConfig & {
|
||||
type: 'oauth2';
|
||||
grant_type?: 'authorization_code' | 'client_credentials' | 'device_code';
|
||||
scopes?: string[];
|
||||
endpoints?: OAuth2Endpoints;
|
||||
} & (
|
||||
| {
|
||||
client_type?: 'static';
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
}
|
||||
| {
|
||||
client_type: 'dynamic';
|
||||
client_name?: string;
|
||||
registration_token?: string;
|
||||
}
|
||||
);
|
||||
|
||||
/** Client config corresponding to OpenIdConnectSecurityScheme. */
|
||||
export interface OpenIdConnectAuthConfig extends BaseAuthConfig {
|
||||
type: 'openIdConnect';
|
||||
|
||||
@@ -375,10 +375,6 @@ describe('MCPOAuthProvider', () => {
|
||||
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(
|
||||
@@ -447,10 +443,6 @@ describe('MCPOAuthProvider', () => {
|
||||
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 = {
|
||||
@@ -550,10 +542,6 @@ describe('MCPOAuthProvider', () => {
|
||||
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
|
||||
|
||||
@@ -24,12 +24,15 @@ import {
|
||||
REDIRECT_PATH,
|
||||
type OAuthFlowConfig,
|
||||
type OAuthTokenResponse,
|
||||
registerDynamicClient,
|
||||
} from '../utils/oauth-flow.js';
|
||||
|
||||
// Re-export types that were moved to oauth-flow.ts for backward compatibility.
|
||||
export type {
|
||||
OAuthAuthorizationResponse,
|
||||
OAuthTokenResponse,
|
||||
OAuthClientRegistrationRequest,
|
||||
OAuthClientRegistrationResponse,
|
||||
} from '../utils/oauth-flow.js';
|
||||
|
||||
/**
|
||||
@@ -49,33 +52,6 @@ export interface MCPOAuthConfig {
|
||||
registrationUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic client registration request (RFC 7591).
|
||||
*/
|
||||
export interface OAuthClientRegistrationRequest {
|
||||
client_name: string;
|
||||
redirect_uris: string[];
|
||||
grant_types: string[];
|
||||
response_types: string[];
|
||||
token_endpoint_auth_method: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic client registration response (RFC 7591).
|
||||
*/
|
||||
export interface OAuthClientRegistrationResponse {
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
client_id_issued_at?: number;
|
||||
client_secret_expires_at?: number;
|
||||
redirect_uris: string[];
|
||||
grant_types: string[];
|
||||
response_types: string[];
|
||||
token_endpoint_auth_method: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for handling OAuth authentication for MCP servers.
|
||||
*/
|
||||
@@ -86,51 +62,6 @@ export class MCPOAuthProvider {
|
||||
this.tokenStorage = tokenStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a client dynamically with the OAuth server.
|
||||
*
|
||||
* @param registrationUrl The client registration endpoint URL
|
||||
* @param config OAuth configuration
|
||||
* @param redirectPort The port to use for the redirect URI
|
||||
* @returns The registered client information
|
||||
*/
|
||||
private async registerClient(
|
||||
registrationUrl: string,
|
||||
config: MCPOAuthConfig,
|
||||
redirectPort: number,
|
||||
): Promise<OAuthClientRegistrationResponse> {
|
||||
const redirectUri =
|
||||
config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;
|
||||
|
||||
const registrationRequest: OAuthClientRegistrationRequest = {
|
||||
client_name: 'Gemini CLI MCP Client',
|
||||
redirect_uris: [redirectUri],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none', // Public client
|
||||
scope: config.scopes?.join(' ') || '',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(registrationUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(registrationRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Client registration failed: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as OAuthClientRegistrationResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover OAuth configuration from an MCP server URL.
|
||||
*
|
||||
@@ -401,10 +332,14 @@ export class MCPOAuthProvider {
|
||||
|
||||
// Register client if registration endpoint is available
|
||||
if (registrationUrl) {
|
||||
const clientRegistration = await this.registerClient(
|
||||
const redirectUri =
|
||||
config.redirectUri ||
|
||||
`http://localhost:${redirectPort}${REDIRECT_PATH}`;
|
||||
const clientRegistration = await registerDynamicClient(
|
||||
registrationUrl,
|
||||
config,
|
||||
redirectPort,
|
||||
'Gemini CLI MCP Client',
|
||||
redirectUri,
|
||||
config.scopes,
|
||||
);
|
||||
|
||||
config.clientId = clientRegistration.client_id;
|
||||
|
||||
@@ -67,6 +67,72 @@ export interface OAuthTokenResponse {
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic client registration request (RFC 7591).
|
||||
*/
|
||||
export interface OAuthClientRegistrationRequest {
|
||||
client_name: string;
|
||||
redirect_uris: string[];
|
||||
grant_types: string[];
|
||||
response_types: string[];
|
||||
token_endpoint_auth_method: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic client registration response (RFC 7591).
|
||||
*/
|
||||
export interface OAuthClientRegistrationResponse {
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
client_id_issued_at?: number;
|
||||
client_secret_expires_at?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a client dynamically with the OAuth server.
|
||||
*
|
||||
* @param registrationUrl The client registration endpoint URL
|
||||
* @param clientName The name of the client to register
|
||||
* @param redirectUri The callback URI
|
||||
* @param scopes Optional array of scopes
|
||||
* @returns The registered client information
|
||||
*/
|
||||
export async function registerDynamicClient(
|
||||
registrationUrl: string,
|
||||
clientName: string,
|
||||
redirectUri: string,
|
||||
scopes?: string[],
|
||||
): Promise<OAuthClientRegistrationResponse> {
|
||||
const registrationRequest: OAuthClientRegistrationRequest = {
|
||||
client_name: clientName,
|
||||
redirect_uris: [redirectUri],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none', // Public client
|
||||
scope: scopes?.join(' ') || '',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(registrationUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(registrationRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Client registration failed: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as OAuthClientRegistrationResponse;
|
||||
}
|
||||
|
||||
/** The path the local callback server listens on. */
|
||||
export const REDIRECT_PATH = '/oauth/callback';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user