mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -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:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
in: header
|
||||
name: X-Custom-Key
|
||||
---
|
||||
`);
|
||||
@@ -385,7 +384,6 @@ auth:
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$MY_API_KEY',
|
||||
in: 'header',
|
||||
name: 'X-Custom-Key',
|
||||
},
|
||||
});
|
||||
@@ -468,7 +466,7 @@ auth:
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Basic scheme requires "username" and "password"/,
|
||||
/Basic authentication requires "password"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -494,7 +492,6 @@ auth:
|
||||
auth: {
|
||||
type: 'apiKey' as const,
|
||||
key: '$API_KEY',
|
||||
in: 'header' as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -505,7 +502,6 @@ auth:
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$API_KEY',
|
||||
location: 'header',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ interface FrontmatterAuthConfig {
|
||||
agent_card_requires_auth?: boolean;
|
||||
// API Key
|
||||
key?: string;
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
name?: string;
|
||||
// HTTP
|
||||
scheme?: 'Bearer' | 'Basic';
|
||||
@@ -129,7 +128,6 @@ const apiKeyAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('apiKey'),
|
||||
key: z.string().min(1, 'API key is required'),
|
||||
in: z.enum(['header', 'query', 'cookie']).optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -138,24 +136,18 @@ const apiKeyAuthSchema = z.object({
|
||||
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
||||
* since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const httpAuthSchemaBase = z.object({
|
||||
const httpAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('http'),
|
||||
scheme: z.enum(['Bearer', 'Basic']),
|
||||
token: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
token: z.string().min(1).optional(),
|
||||
username: z.string().min(1).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
|
||||
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase])
|
||||
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema])
|
||||
.superRefine((data, ctx) => {
|
||||
// Apply HTTP auth validation after union parsing
|
||||
if (data.type === 'http') {
|
||||
if (data.scheme === 'Bearer' && !data.token) {
|
||||
ctx.addIssue({
|
||||
@@ -164,12 +156,21 @@ const authConfigSchema = z
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
if (data.scheme === 'Basic' && (!data.username || !data.password)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Basic scheme requires "username" and "password"',
|
||||
path: data.username ? ['password'] : ['username'],
|
||||
});
|
||||
if (data.scheme === 'Basic') {
|
||||
if (!data.username) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
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,
|
||||
type: 'apiKey',
|
||||
key: frontmatter.key,
|
||||
location: frontmatter.in,
|
||||
name: frontmatter.name,
|
||||
};
|
||||
|
||||
|
||||
180
packages/core/src/agents/auth-provider/api-key-provider.test.ts
Normal file
180
packages/core/src/agents/auth-provider/api-key-provider.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
85
packages/core/src/agents/auth-provider/api-key-provider.ts
Normal file
85
packages/core/src/agents/auth-provider/api-key-provider.ts
Normal file
@@ -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>;
|
||||
|
||||
private static readonly MAX_AUTH_RETRIES = 2;
|
||||
private authRetryCount = 0;
|
||||
protected static readonly MAX_AUTH_RETRIES = 2;
|
||||
protected authRetryCount = 0;
|
||||
|
||||
/**
|
||||
* 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
|
||||
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,
|
||||
AuthValidationResult,
|
||||
} from './types.js';
|
||||
import { ApiKeyAuthProvider } from './api-key-provider.js';
|
||||
|
||||
export interface CreateAuthProviderOptions {
|
||||
/** Required for OAuth/OIDC token storage. */
|
||||
@@ -43,9 +44,11 @@ export class A2AAuthProviderFactory {
|
||||
// TODO: Implement
|
||||
throw new Error('google-credentials auth provider not yet implemented');
|
||||
|
||||
case 'apiKey':
|
||||
// TODO: Implement
|
||||
throw new Error('apiKey auth provider not yet implemented');
|
||||
case 'apiKey': {
|
||||
const provider = new ApiKeyAuthProvider(authConfig);
|
||||
await provider.initialize();
|
||||
return provider;
|
||||
}
|
||||
|
||||
case 'http':
|
||||
// TODO: Implement
|
||||
|
||||
@@ -34,14 +34,13 @@ export interface GoogleCredentialsAuthConfig extends BaseAuthConfig {
|
||||
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 {
|
||||
type: 'apiKey';
|
||||
/** The secret. Supports $ENV_VAR, !command, or literal. */
|
||||
key: string;
|
||||
/** Defaults to server's SecurityScheme.in value. */
|
||||
location?: 'header' | 'query' | 'cookie';
|
||||
/** Defaults to server's SecurityScheme.name value. */
|
||||
/** Header name. @default 'X-API-Key' */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user