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

View File

@@ -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',
},
});
});

View File

@@ -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,
};

View 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');
});
});
});

View 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();
}
}

View File

@@ -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.

View File

@@ -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' });
});
});
});

View File

@@ -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

View File

@@ -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;
}