mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(a2a): Add API key authentication provider (#19548)
This commit is contained in:
@@ -373,7 +373,6 @@ agent_card_url: https://example.com/card
|
|||||||
auth:
|
auth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
key: $MY_API_KEY
|
key: $MY_API_KEY
|
||||||
in: header
|
|
||||||
name: X-Custom-Key
|
name: X-Custom-Key
|
||||||
---
|
---
|
||||||
`);
|
`);
|
||||||
@@ -385,7 +384,6 @@ auth:
|
|||||||
auth: {
|
auth: {
|
||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
key: '$MY_API_KEY',
|
key: '$MY_API_KEY',
|
||||||
in: 'header',
|
|
||||||
name: 'X-Custom-Key',
|
name: 'X-Custom-Key',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -468,7 +466,7 @@ auth:
|
|||||||
---
|
---
|
||||||
`);
|
`);
|
||||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||||
/Basic scheme requires "username" and "password"/,
|
/Basic authentication requires "password"/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,7 +492,6 @@ auth:
|
|||||||
auth: {
|
auth: {
|
||||||
type: 'apiKey' as const,
|
type: 'apiKey' as const,
|
||||||
key: '$API_KEY',
|
key: '$API_KEY',
|
||||||
in: 'header' as const,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,7 +502,6 @@ auth:
|
|||||||
auth: {
|
auth: {
|
||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
key: '$API_KEY',
|
key: '$API_KEY',
|
||||||
location: 'header',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ interface FrontmatterAuthConfig {
|
|||||||
agent_card_requires_auth?: boolean;
|
agent_card_requires_auth?: boolean;
|
||||||
// API Key
|
// API Key
|
||||||
key?: string;
|
key?: string;
|
||||||
in?: 'header' | 'query' | 'cookie';
|
|
||||||
name?: string;
|
name?: string;
|
||||||
// HTTP
|
// HTTP
|
||||||
scheme?: 'Bearer' | 'Basic';
|
scheme?: 'Bearer' | 'Basic';
|
||||||
@@ -129,7 +128,6 @@ const apiKeyAuthSchema = z.object({
|
|||||||
...baseAuthFields,
|
...baseAuthFields,
|
||||||
type: z.literal('apiKey'),
|
type: z.literal('apiKey'),
|
||||||
key: z.string().min(1, 'API key is required'),
|
key: z.string().min(1, 'API key is required'),
|
||||||
in: z.enum(['header', 'query', 'cookie']).optional(),
|
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,24 +136,18 @@ const apiKeyAuthSchema = z.object({
|
|||||||
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
||||||
* since discriminatedUnion doesn't support refined schemas directly.
|
* since discriminatedUnion doesn't support refined schemas directly.
|
||||||
*/
|
*/
|
||||||
const httpAuthSchemaBase = z.object({
|
const httpAuthSchema = z.object({
|
||||||
...baseAuthFields,
|
...baseAuthFields,
|
||||||
type: z.literal('http'),
|
type: z.literal('http'),
|
||||||
scheme: z.enum(['Bearer', 'Basic']),
|
scheme: z.enum(['Bearer', 'Basic']),
|
||||||
token: z.string().optional(),
|
token: z.string().min(1).optional(),
|
||||||
username: z.string().optional(),
|
username: z.string().min(1).optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined auth schema - discriminated union of all auth types.
|
|
||||||
* Note: We use the base schema for discriminatedUnion, then apply refinements
|
|
||||||
* via superRefine since discriminatedUnion doesn't support refined schemas directly.
|
|
||||||
*/
|
|
||||||
const authConfigSchema = z
|
const authConfigSchema = z
|
||||||
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase])
|
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema])
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
// Apply HTTP auth validation after union parsing
|
|
||||||
if (data.type === 'http') {
|
if (data.type === 'http') {
|
||||||
if (data.scheme === 'Bearer' && !data.token) {
|
if (data.scheme === 'Bearer' && !data.token) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -164,12 +156,21 @@ const authConfigSchema = z
|
|||||||
path: ['token'],
|
path: ['token'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.scheme === 'Basic' && (!data.username || !data.password)) {
|
if (data.scheme === 'Basic') {
|
||||||
ctx.addIssue({
|
if (!data.username) {
|
||||||
code: z.ZodIssueCode.custom,
|
ctx.addIssue({
|
||||||
message: 'Basic scheme requires "username" and "password"',
|
code: z.ZodIssueCode.custom,
|
||||||
path: data.username ? ['password'] : ['username'],
|
message: 'Basic authentication requires "username"',
|
||||||
});
|
path: ['username'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.password) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Basic authentication requires "password"',
|
||||||
|
path: ['password'],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -338,7 +339,6 @@ function convertFrontmatterAuthToConfig(
|
|||||||
...base,
|
...base,
|
||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
key: frontmatter.key,
|
key: frontmatter.key,
|
||||||
location: frontmatter.in,
|
|
||||||
name: frontmatter.name,
|
name: frontmatter.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { ApiKeyAuthProvider } from './api-key-provider.js';
|
||||||
|
|
||||||
|
describe('ApiKeyAuthProvider', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize with literal API key', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'my-api-key',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const headers = await provider.headers();
|
||||||
|
expect(headers).toEqual({ 'X-API-Key': 'my-api-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve API key from environment variable', async () => {
|
||||||
|
vi.stubEnv('TEST_API_KEY', 'env-api-key');
|
||||||
|
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: '$TEST_API_KEY',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const headers = await provider.headers();
|
||||||
|
expect(headers).toEqual({ 'X-API-Key': 'env-api-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if environment variable is not set', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: '$MISSING_KEY_12345',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(provider.initialize()).rejects.toThrow(
|
||||||
|
"Environment variable 'MISSING_KEY_12345' is not set",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headers', () => {
|
||||||
|
it('should throw if not initialized', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'test-key',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(provider.headers()).rejects.toThrow('not initialized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom header name', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'my-key',
|
||||||
|
name: 'X-Custom-Auth',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const headers = await provider.headers();
|
||||||
|
expect(headers).toEqual({ 'X-Custom-Auth': 'my-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default header name X-API-Key', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'my-key',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const headers = await provider.headers();
|
||||||
|
expect(headers).toEqual({ 'X-API-Key': 'my-key' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldRetryWithHeaders', () => {
|
||||||
|
it('should return undefined for non-auth errors', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'test-key',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const result = await provider.shouldRetryWithHeaders(
|
||||||
|
{},
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for literal keys on 401 (same headers would fail again)', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'test-key',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const result = await provider.shouldRetryWithHeaders(
|
||||||
|
{},
|
||||||
|
new Response(null, { status: 401 }),
|
||||||
|
);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for env-var keys on 403', async () => {
|
||||||
|
vi.stubEnv('RETRY_TEST_KEY', 'some-key');
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: '$RETRY_TEST_KEY',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const result = await provider.shouldRetryWithHeaders(
|
||||||
|
{},
|
||||||
|
new Response(null, { status: 403 }),
|
||||||
|
);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-resolve and return headers for command keys on 401', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: '!echo refreshed-key',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const result = await provider.shouldRetryWithHeaders(
|
||||||
|
{},
|
||||||
|
new Response(null, { status: 401 }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ 'X-API-Key': 'refreshed-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop retrying after MAX_AUTH_RETRIES', async () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: '!echo rotating-key',
|
||||||
|
});
|
||||||
|
await provider.initialize();
|
||||||
|
|
||||||
|
const r1 = await provider.shouldRetryWithHeaders(
|
||||||
|
{},
|
||||||
|
new Response(null, { status: 401 }),
|
||||||
|
);
|
||||||
|
expect(r1).toBeDefined();
|
||||||
|
|
||||||
|
const r2 = await provider.shouldRetryWithHeaders(
|
||||||
|
{},
|
||||||
|
new Response(null, { status: 401 }),
|
||||||
|
);
|
||||||
|
expect(r2).toBeDefined();
|
||||||
|
|
||||||
|
const r3 = await provider.shouldRetryWithHeaders(
|
||||||
|
{},
|
||||||
|
new Response(null, { status: 401 }),
|
||||||
|
);
|
||||||
|
expect(r3).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('type property', () => {
|
||||||
|
it('should have type apiKey', () => {
|
||||||
|
const provider = new ApiKeyAuthProvider({
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'test',
|
||||||
|
});
|
||||||
|
expect(provider.type).toBe('apiKey');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HttpHeaders } from '@a2a-js/sdk/client';
|
||||||
|
import { BaseA2AAuthProvider } from './base-provider.js';
|
||||||
|
import type { ApiKeyAuthConfig } from './types.js';
|
||||||
|
import { resolveAuthValue, needsResolution } from './value-resolver.js';
|
||||||
|
import { debugLogger } from '../../utils/debugLogger.js';
|
||||||
|
|
||||||
|
const DEFAULT_HEADER_NAME = 'X-API-Key';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication provider for API Key authentication.
|
||||||
|
* Sends the API key as an HTTP header.
|
||||||
|
*
|
||||||
|
* The API key value can be:
|
||||||
|
* - A literal string
|
||||||
|
* - An environment variable reference ($ENV_VAR)
|
||||||
|
* - A shell command (!command)
|
||||||
|
*/
|
||||||
|
export class ApiKeyAuthProvider extends BaseA2AAuthProvider {
|
||||||
|
readonly type = 'apiKey' as const;
|
||||||
|
|
||||||
|
private resolvedKey: string | undefined;
|
||||||
|
private readonly headerName: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ApiKeyAuthConfig) {
|
||||||
|
super();
|
||||||
|
this.headerName = config.name ?? DEFAULT_HEADER_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async initialize(): Promise<void> {
|
||||||
|
if (needsResolution(this.config.key)) {
|
||||||
|
this.resolvedKey = await resolveAuthValue(this.config.key);
|
||||||
|
debugLogger.debug(
|
||||||
|
`[ApiKeyAuthProvider] Resolved API key from: ${this.config.key.startsWith('$') ? 'env var' : 'command'}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.resolvedKey = this.config.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async headers(): Promise<HttpHeaders> {
|
||||||
|
if (!this.resolvedKey) {
|
||||||
|
throw new Error(
|
||||||
|
'ApiKeyAuthProvider not initialized. Call initialize() first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { [this.headerName]: this.resolvedKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve command-based API keys on auth failure.
|
||||||
|
*/
|
||||||
|
override async shouldRetryWithHeaders(
|
||||||
|
_req: RequestInit,
|
||||||
|
res: Response,
|
||||||
|
): Promise<HttpHeaders | undefined> {
|
||||||
|
if (res.status !== 401 && res.status !== 403) {
|
||||||
|
this.authRetryCount = 0;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only retry for command-based keys that may resolve to a new value.
|
||||||
|
// Literal and env-var keys would just resend the same failing headers.
|
||||||
|
if (!this.config.key.startsWith('!') || this.config.key.startsWith('!!')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this.authRetryCount++;
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
'[ApiKeyAuthProvider] Re-resolving API key after auth failure',
|
||||||
|
);
|
||||||
|
this.resolvedKey = await resolveAuthValue(this.config.key);
|
||||||
|
|
||||||
|
return this.headers();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,8 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
|||||||
*/
|
*/
|
||||||
abstract headers(): Promise<HttpHeaders>;
|
abstract headers(): Promise<HttpHeaders>;
|
||||||
|
|
||||||
private static readonly MAX_AUTH_RETRIES = 2;
|
protected static readonly MAX_AUTH_RETRIES = 2;
|
||||||
private authRetryCount = 0;
|
protected authRetryCount = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a request should be retried with new headers.
|
* Check if a request should be retried with new headers.
|
||||||
|
|||||||
@@ -478,5 +478,19 @@ describe('A2AAuthProviderFactory', () => {
|
|||||||
// Returns undefined - caller should prompt user to configure auth
|
// Returns undefined - caller should prompt user to configure auth
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create an ApiKeyAuthProvider for apiKey config', async () => {
|
||||||
|
const provider = await A2AAuthProviderFactory.create({
|
||||||
|
authConfig: {
|
||||||
|
type: 'apiKey',
|
||||||
|
key: 'factory-test-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(provider!.type).toBe('apiKey');
|
||||||
|
const headers = await provider!.headers();
|
||||||
|
expect(headers).toEqual({ 'X-API-Key': 'factory-test-key' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
A2AAuthProvider,
|
A2AAuthProvider,
|
||||||
AuthValidationResult,
|
AuthValidationResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { ApiKeyAuthProvider } from './api-key-provider.js';
|
||||||
|
|
||||||
export interface CreateAuthProviderOptions {
|
export interface CreateAuthProviderOptions {
|
||||||
/** Required for OAuth/OIDC token storage. */
|
/** Required for OAuth/OIDC token storage. */
|
||||||
@@ -43,9 +44,11 @@ export class A2AAuthProviderFactory {
|
|||||||
// TODO: Implement
|
// TODO: Implement
|
||||||
throw new Error('google-credentials auth provider not yet implemented');
|
throw new Error('google-credentials auth provider not yet implemented');
|
||||||
|
|
||||||
case 'apiKey':
|
case 'apiKey': {
|
||||||
// TODO: Implement
|
const provider = new ApiKeyAuthProvider(authConfig);
|
||||||
throw new Error('apiKey auth provider not yet implemented');
|
await provider.initialize();
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
case 'http':
|
case 'http':
|
||||||
// TODO: Implement
|
// TODO: Implement
|
||||||
|
|||||||
@@ -34,14 +34,13 @@ export interface GoogleCredentialsAuthConfig extends BaseAuthConfig {
|
|||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Client config corresponding to APIKeySecurityScheme. */
|
/** Client config corresponding to APIKeySecurityScheme. Only header location is supported. */
|
||||||
|
// TODO: Add 'query' and 'cookie' location support if needed.
|
||||||
export interface ApiKeyAuthConfig extends BaseAuthConfig {
|
export interface ApiKeyAuthConfig extends BaseAuthConfig {
|
||||||
type: 'apiKey';
|
type: 'apiKey';
|
||||||
/** The secret. Supports $ENV_VAR, !command, or literal. */
|
/** The secret. Supports $ENV_VAR, !command, or literal. */
|
||||||
key: string;
|
key: string;
|
||||||
/** Defaults to server's SecurityScheme.in value. */
|
/** Header name. @default 'X-API-Key' */
|
||||||
location?: 'header' | 'query' | 'cookie';
|
|
||||||
/** Defaults to server's SecurityScheme.name value. */
|
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user