mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(core): implement dynamic client registration for a2a oauth2
This commit is contained in:
Generated
+28
-2
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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