diff --git a/package-lock.json b/package-lock.json index b49fff2113..4bb19c5204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -527,7 +527,8 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" + "license": "(Apache-2.0 AND BSD-3-Clause)", + "peer": true }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", @@ -1600,6 +1601,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -2324,6 +2326,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2504,6 +2507,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2553,6 +2557,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2927,6 +2932,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2960,6 +2966,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -3014,6 +3021,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4210,6 +4218,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4483,6 +4492,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5330,6 +5340,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7933,6 +7944,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8565,6 +8577,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9879,6 +9892,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -10158,6 +10172,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13840,6 +13855,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13850,6 +13866,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15938,6 +15955,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16161,7 +16179,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16169,6 +16188,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16328,6 +16348,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16551,6 +16572,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16664,6 +16686,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16676,6 +16699,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17320,6 +17344,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17722,6 +17747,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index 9c03094b3f..61b22524de 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -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', + }, }, }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index b91187204e..d3a8e0bed1 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -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}`); } } } diff --git a/packages/core/src/agents/auth-provider/factory.test.ts b/packages/core/src/agents/auth-provider/factory.test.ts index 857d68ff45..b660f5996d 100644 --- a/packages/core/src/agents/auth-provider/factory.test.ts +++ b/packages/core/src/agents/auth-provider/factory.test.ts @@ -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 = { 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', + }, }, }); diff --git a/packages/core/src/agents/auth-provider/oauth2-provider.test.ts b/packages/core/src/agents/auth-provider/oauth2-provider.test.ts index a40b242d41..61214a0772 100644 --- a/packages/core/src/agents/auth-provider/oauth2-provider.test.ts +++ b/packages/core/src/agents/auth-provider/oauth2-provider.test.ts @@ -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 = { diff --git a/packages/core/src/agents/auth-provider/oauth2-provider.ts b/packages/core/src/agents/auth-provider/oauth2-provider.ts index c362765799..d387d9d9eb 100644 --- a/packages/core/src/agents/auth-provider/oauth2-provider.ts +++ b/packages/core/src/agents/auth-provider/oauth2-provider.ts @@ -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 { - 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 { - 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, ); } diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts index f4e2e48b13..b1fd7390c5 100644 --- a/packages/core/src/agents/auth-provider/types.ts +++ b/packages/core/src/agents/auth-provider/types.ts @@ -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'; diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 5cd4460e97..e26db6c298 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -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 diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 01934d9019..0cbc24ea25 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -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 { - 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; diff --git a/packages/core/src/utils/oauth-flow.ts b/packages/core/src/utils/oauth-flow.ts index 45318efdb5..9fbf82f763 100644 --- a/packages/core/src/utils/oauth-flow.ts +++ b/packages/core/src/utils/oauth-flow.ts @@ -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 { + 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';