feat(a2a): Add API key authentication provider (#19548)

This commit is contained in:
Adam Weidman
2026-02-20 13:55:36 -05:00
committed by GitHub
parent fe428936d5
commit ce03156c9f
8 changed files with 311 additions and 34 deletions
+1 -5
View File
@@ -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',
}, },
}); });
}); });
+20 -20
View File
@@ -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;
} }