feat(core): implement dynamic client registration for a2a oauth2

This commit is contained in:
Adam Weidman
2026-03-10 14:17:51 -04:00
parent 556825f81c
commit f787c0fb97
10 changed files with 338 additions and 170 deletions
+28 -2
View File
@@ -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"
},
+19 -10
View File
@@ -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',
},
},
});
});
+60 -13
View File
@@ -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
+10 -75
View File
@@ -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;
+66
View File
@@ -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';