diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index a54626b637..a62c0b02ba 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -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', }, }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index cb2a605779..ed648c6191 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -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, }; diff --git a/packages/core/src/agents/auth-provider/api-key-provider.test.ts b/packages/core/src/agents/auth-provider/api-key-provider.test.ts new file mode 100644 index 0000000000..82d8c271e5 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/api-key-provider.ts b/packages/core/src/agents/auth-provider/api-key-provider.ts new file mode 100644 index 0000000000..207c987271 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.ts @@ -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 { + 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 { + 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 { + 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(); + } +} diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts index 7fb2e61acc..2c8fd3ee2a 100644 --- a/packages/core/src/agents/auth-provider/base-provider.ts +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -23,8 +23,8 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { */ abstract headers(): Promise; - 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. diff --git a/packages/core/src/agents/auth-provider/factory.test.ts b/packages/core/src/agents/auth-provider/factory.test.ts index 6aa7069fa9..17de791de9 100644 --- a/packages/core/src/agents/auth-provider/factory.test.ts +++ b/packages/core/src/agents/auth-provider/factory.test.ts @@ -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' }); + }); }); }); diff --git a/packages/core/src/agents/auth-provider/factory.ts b/packages/core/src/agents/auth-provider/factory.ts index b79c8b4f77..9562737345 100644 --- a/packages/core/src/agents/auth-provider/factory.ts +++ b/packages/core/src/agents/auth-provider/factory.ts @@ -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 diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts index 67fce94ca8..7d41b1b4a9 100644 --- a/packages/core/src/agents/auth-provider/types.ts +++ b/packages/core/src/agents/auth-provider/types.ts @@ -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; }