diff --git a/packages/core/src/agents/auth-provider/base-provider.test.ts b/packages/core/src/agents/auth-provider/base-provider.test.ts new file mode 100644 index 0000000000..cc9a20eecd --- /dev/null +++ b/packages/core/src/agents/auth-provider/base-provider.test.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { A2AAuthProviderType } from './types.js'; + +/** + * Concrete implementation of BaseA2AAuthProvider for testing. + */ +class TestAuthProvider extends BaseA2AAuthProvider { + readonly type: A2AAuthProviderType = 'apiKey'; + private testHeaders: HttpHeaders; + + constructor(headers: HttpHeaders = { Authorization: 'test-token' }) { + super(); + this.testHeaders = headers; + } + + async headers(): Promise { + return this.testHeaders; + } + + setHeaders(headers: HttpHeaders): void { + this.testHeaders = headers; + } +} + +describe('BaseA2AAuthProvider', () => { + describe('shouldRetryWithHeaders', () => { + it('should return headers for 401 response', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response = new Response(null, { status: 401 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toEqual({ Authorization: 'Bearer token' }); + }); + + it('should return headers for 403 response', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response = new Response(null, { status: 403 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toEqual({ Authorization: 'Bearer token' }); + }); + + it('should return undefined for 200 response', async () => { + const provider = new TestAuthProvider(); + const response = new Response(null, { status: 200 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for 500 response', async () => { + const provider = new TestAuthProvider(); + const response = new Response(null, { status: 500 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for 404 response', async () => { + const provider = new TestAuthProvider(); + const response = new Response(null, { status: 404 }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toBeUndefined(); + }); + + it('should call headers() to get fresh headers on retry', async () => { + const provider = new TestAuthProvider({ Authorization: 'old-token' }); + const response = new Response(null, { status: 401 }); + + // Change headers before retry + provider.setHeaders({ Authorization: 'new-token' }); + + const result = await provider.shouldRetryWithHeaders({}, response); + + expect(result).toEqual({ Authorization: 'new-token' }); + }); + + it('should retry up to 2 times on 401/403', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response401 = new Response(null, { status: 401 }); + + // First retry should succeed + const result1 = await provider.shouldRetryWithHeaders({}, response401); + expect(result1).toEqual({ Authorization: 'Bearer token' }); + + // Second retry should succeed + const result2 = await provider.shouldRetryWithHeaders({}, response401); + expect(result2).toEqual({ Authorization: 'Bearer token' }); + }); + + it('should return undefined after max retries exceeded', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response401 = new Response(null, { status: 401 }); + + // Exhaust retries + await provider.shouldRetryWithHeaders({}, response401); // retry 1 + await provider.shouldRetryWithHeaders({}, response401); // retry 2 + + // Third attempt should return undefined + const result = await provider.shouldRetryWithHeaders({}, response401); + expect(result).toBeUndefined(); + }); + + it('should reset retry count on successful response', async () => { + const provider = new TestAuthProvider({ Authorization: 'Bearer token' }); + const response401 = new Response(null, { status: 401 }); + const response200 = new Response(null, { status: 200 }); + + // Use up retries + await provider.shouldRetryWithHeaders({}, response401); // retry 1 + await provider.shouldRetryWithHeaders({}, response401); // retry 2 + + // Success resets counter + await provider.shouldRetryWithHeaders({}, response200); + + // Should be able to retry again + const result = await provider.shouldRetryWithHeaders({}, response401); + expect(result).toEqual({ Authorization: 'Bearer token' }); + }); + }); + + describe('initialize', () => { + it('should be a no-op by default', async () => { + const provider = new TestAuthProvider(); + + // Should not throw + await expect(provider.initialize()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts new file mode 100644 index 0000000000..7b21853a09 --- /dev/null +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import type { A2AAuthProvider, A2AAuthProviderType } from './types.js'; + +/** + * Abstract base class for A2A authentication providers. + */ +export abstract class BaseA2AAuthProvider implements A2AAuthProvider { + abstract readonly type: A2AAuthProviderType; + abstract headers(): Promise; + + private static readonly MAX_AUTH_RETRIES = 2; + private authRetryCount = 0; + + /** + * Default: retry on 401/403 with fresh headers. + * Subclasses with cached tokens must override to force-refresh to avoid infinite retries. + */ + async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status === 401 || res.status === 403) { + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; // Max retries exceeded + } + this.authRetryCount++; + return this.headers(); + } + // Reset on success + this.authRetryCount = 0; + return undefined; + } + + async initialize(): Promise {} +} diff --git a/packages/core/src/agents/auth-provider/factory.test.ts b/packages/core/src/agents/auth-provider/factory.test.ts new file mode 100644 index 0000000000..6aa7069fa9 --- /dev/null +++ b/packages/core/src/agents/auth-provider/factory.test.ts @@ -0,0 +1,482 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { A2AAuthProviderFactory } from './factory.js'; +import type { AgentCard, SecurityScheme } from '@a2a-js/sdk'; +import type { A2AAuthConfig } from './types.js'; + +describe('A2AAuthProviderFactory', () => { + describe('validateAuthConfig', () => { + describe('when no security schemes required', () => { + it('should return valid when securitySchemes is undefined', () => { + const result = A2AAuthProviderFactory.validateAuthConfig( + undefined, + undefined, + ); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid when securitySchemes is empty', () => { + const result = A2AAuthProviderFactory.validateAuthConfig(undefined, {}); + expect(result).toEqual({ valid: true }); + }); + + it('should return valid when auth config provided but not required', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'test-key', + }; + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + {}, + ); + expect(result).toEqual({ valid: true }); + }); + }); + + describe('when auth is required but not configured', () => { + it('should return invalid with diff', () => { + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + undefined, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff).toBeDefined(); + expect(result.diff?.requiredSchemes).toContain('apiKeyAuth'); + expect(result.diff?.configuredType).toBeUndefined(); + expect(result.diff?.missingConfig).toContain( + 'Authentication is required but not configured', + ); + }); + }); + + describe('apiKey scheme matching', () => { + it('should match apiKey config with apiKey scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'my-key', + }; + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match http config with apiKey scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Bearer', + token: 'my-token', + }; + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'apiKeyAuth' requires apiKey authentication", + ); + }); + }); + + describe('http scheme matching', () => { + it('should match http Bearer config with http Bearer scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Bearer', + token: 'my-token', + }; + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should match http Basic config with http Basic scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Basic', + username: 'user', + password: 'pass', + }; + const securitySchemes: Record = { + basicAuth: { + type: 'http', + scheme: 'Basic', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match http Basic config with http Bearer scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Basic', + username: 'user', + password: 'pass', + }; + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'bearerAuth' requires HTTP Bearer authentication, but Basic was configured", + ); + }); + + it('should match google-credentials with http Bearer scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'google-credentials', + }; + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + }); + + describe('oauth2 scheme matching', () => { + it('should match oauth2 config with oauth2 scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'oauth2', + }; + const securitySchemes: Record = { + oauth2Auth: { + type: 'oauth2', + flows: {}, + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match apiKey config with oauth2 scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'my-key', + }; + const securitySchemes: Record = { + oauth2Auth: { + type: 'oauth2', + flows: {}, + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'oauth2Auth' requires OAuth 2.0 authentication", + ); + }); + }); + + describe('openIdConnect scheme matching', () => { + it('should match openIdConnect config with openIdConnect scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'openIdConnect', + issuer_url: 'https://auth.example.com', + client_id: 'client-id', + }; + const securitySchemes: Record = { + oidcAuth: { + type: 'openIdConnect', + openIdConnectUrl: + 'https://auth.example.com/.well-known/openid-configuration', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + + it('should not match google-credentials for openIdConnect scheme', () => { + const authConfig: A2AAuthConfig = { + type: 'google-credentials', + }; + const securitySchemes: Record = { + oidcAuth: { + type: 'openIdConnect', + openIdConnectUrl: + 'https://auth.example.com/.well-known/openid-configuration', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'oidcAuth' requires OpenID Connect authentication", + ); + }); + }); + + describe('mutualTLS scheme', () => { + it('should always fail for mutualTLS (not supported)', () => { + const authConfig: A2AAuthConfig = { + type: 'apiKey', + key: 'test', + }; + const securitySchemes: Record = { + mtlsAuth: { + type: 'mutualTLS', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result.valid).toBe(false); + expect(result.diff?.missingConfig).toContain( + "Scheme 'mtlsAuth' requires mTLS authentication (not yet supported)", + ); + }); + }); + + describe('multiple security schemes', () => { + it('should match if any scheme matches', () => { + const authConfig: A2AAuthConfig = { + type: 'http', + scheme: 'Bearer', + token: 'my-token', + }; + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = A2AAuthProviderFactory.validateAuthConfig( + authConfig, + securitySchemes, + ); + + expect(result).toEqual({ valid: true }); + }); + }); + }); + + describe('describeRequiredAuth', () => { + it('should describe apiKey scheme', () => { + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('API Key (apiKeyAuth): Send X-API-Key in header'); + }); + + it('should describe http Bearer scheme', () => { + const securitySchemes: Record = { + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('HTTP Bearer (bearerAuth)'); + }); + + it('should describe http Basic scheme', () => { + const securitySchemes: Record = { + basicAuth: { + type: 'http', + scheme: 'Basic', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('HTTP Basic (basicAuth)'); + }); + + it('should describe oauth2 scheme', () => { + const securitySchemes: Record = { + oauth2Auth: { + type: 'oauth2', + flows: {}, + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('OAuth 2.0 (oauth2Auth)'); + }); + + it('should describe openIdConnect scheme', () => { + const securitySchemes: Record = { + oidcAuth: { + type: 'openIdConnect', + openIdConnectUrl: + 'https://auth.example.com/.well-known/openid-configuration', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('OpenID Connect (oidcAuth)'); + }); + + it('should describe mutualTLS scheme', () => { + const securitySchemes: Record = { + mtlsAuth: { + type: 'mutualTLS', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe('Mutual TLS (mtlsAuth)'); + }); + + it('should join multiple schemes with OR', () => { + const securitySchemes: Record = { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + bearerAuth: { + type: 'http', + scheme: 'Bearer', + }, + }; + + const result = + A2AAuthProviderFactory.describeRequiredAuth(securitySchemes); + + expect(result).toBe( + 'API Key (apiKeyAuth): Send X-API-Key in header OR HTTP Bearer (bearerAuth)', + ); + }); + }); + + describe('create', () => { + it('should return undefined when no auth config and no security schemes', async () => { + const result = await A2AAuthProviderFactory.create({ + agentName: 'test-agent', + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when no auth config but AgentCard has security schemes', async () => { + const result = await A2AAuthProviderFactory.create({ + agentName: 'test-agent', + agentCard: { + securitySchemes: { + apiKeyAuth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }, + } as unknown as AgentCard, + }); + + // Returns undefined - caller should prompt user to configure auth + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/factory.ts b/packages/core/src/agents/auth-provider/factory.ts new file mode 100644 index 0000000000..b79c8b4f77 --- /dev/null +++ b/packages/core/src/agents/auth-provider/factory.ts @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AgentCard, SecurityScheme } from '@a2a-js/sdk'; +import type { + A2AAuthConfig, + A2AAuthProvider, + AuthValidationResult, +} from './types.js'; + +export interface CreateAuthProviderOptions { + /** Required for OAuth/OIDC token storage. */ + agentName?: string; + authConfig?: A2AAuthConfig; + agentCard?: AgentCard; +} + +/** + * Factory for creating A2A authentication providers. + * @see https://a2a-protocol.org/latest/specification/#451-securityscheme + */ +export class A2AAuthProviderFactory { + static async create( + options: CreateAuthProviderOptions, + ): Promise { + const { agentName: _agentName, authConfig, agentCard } = options; + + if (!authConfig) { + if ( + agentCard?.securitySchemes && + Object.keys(agentCard.securitySchemes).length > 0 + ) { + return undefined; // Caller should prompt user to configure auth + } + return undefined; + } + + switch (authConfig.type) { + case 'google-credentials': + // 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 'http': + // TODO: Implement + throw new Error('http auth provider not yet implemented'); + + case 'oauth2': + // TODO: Implement + throw new Error('oauth2 auth provider not yet implemented'); + + case 'openIdConnect': + // TODO: Implement + throw new Error('openIdConnect auth provider not yet implemented'); + + default: { + const _exhaustive: never = authConfig; + throw new Error( + `Unknown auth type: ${(_exhaustive as A2AAuthConfig).type}`, + ); + } + } + } + + /** Create provider directly from config, bypassing AgentCard validation. */ + static async createFromConfig( + authConfig: A2AAuthConfig, + agentName?: string, + ): Promise { + const provider = await A2AAuthProviderFactory.create({ + authConfig, + agentName, + }); + + // create() returns undefined only when authConfig is missing. + // Since authConfig is required here, provider will always be defined + // (or create() throws for unimplemented types). + return provider!; + } + + /** Validate auth config against AgentCard's security requirements. */ + static validateAuthConfig( + authConfig: A2AAuthConfig | undefined, + securitySchemes: Record | undefined, + ): AuthValidationResult { + if (!securitySchemes || Object.keys(securitySchemes).length === 0) { + return { valid: true }; + } + + const requiredSchemes = Object.keys(securitySchemes); + + if (!authConfig) { + return { + valid: false, + diff: { + requiredSchemes, + configuredType: undefined, + missingConfig: ['Authentication is required but not configured'], + }, + }; + } + + const matchResult = A2AAuthProviderFactory.findMatchingScheme( + authConfig, + securitySchemes, + ); + + if (matchResult.matched) { + return { valid: true }; + } + + return { + valid: false, + diff: { + requiredSchemes, + configuredType: authConfig.type, + missingConfig: matchResult.missingConfig, + }, + }; + } + + // Security schemes have OR semantics per A2A spec - matching any single scheme is sufficient + private static findMatchingScheme( + authConfig: A2AAuthConfig, + securitySchemes: Record, + ): { matched: boolean; missingConfig: string[] } { + const missingConfig: string[] = []; + + for (const [schemeName, scheme] of Object.entries(securitySchemes)) { + switch (scheme.type) { + case 'apiKey': + if (authConfig.type === 'apiKey') { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires apiKey authentication`, + ); + break; + + case 'http': + if (authConfig.type === 'http') { + if ( + authConfig.scheme.toLowerCase() === scheme.scheme.toLowerCase() + ) { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication, but ${authConfig.scheme} was configured`, + ); + } else if ( + authConfig.type === 'google-credentials' && + scheme.scheme.toLowerCase() === 'bearer' + ) { + return { matched: true, missingConfig: [] }; + } else { + missingConfig.push( + `Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication`, + ); + } + break; + + case 'oauth2': + if (authConfig.type === 'oauth2') { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires OAuth 2.0 authentication`, + ); + break; + + case 'openIdConnect': + if (authConfig.type === 'openIdConnect') { + return { matched: true, missingConfig: [] }; + } + missingConfig.push( + `Scheme '${schemeName}' requires OpenID Connect authentication`, + ); + break; + + case 'mutualTLS': + missingConfig.push( + `Scheme '${schemeName}' requires mTLS authentication (not yet supported)`, + ); + break; + + default: { + const _exhaustive: never = scheme; + missingConfig.push( + `Unknown security scheme type: ${(_exhaustive as SecurityScheme).type}`, + ); + } + } + } + + return { matched: false, missingConfig }; + } + + /** Get human-readable description of required auth for error messages. */ + static describeRequiredAuth( + securitySchemes: Record, + ): string { + const descriptions: string[] = []; + + for (const [name, scheme] of Object.entries(securitySchemes)) { + switch (scheme.type) { + case 'apiKey': + descriptions.push( + `API Key (${name}): Send ${scheme.name} in ${scheme.in}`, + ); + break; + case 'http': + descriptions.push(`HTTP ${scheme.scheme} (${name})`); + break; + case 'oauth2': + descriptions.push(`OAuth 2.0 (${name})`); + break; + case 'openIdConnect': + descriptions.push(`OpenID Connect (${name})`); + break; + case 'mutualTLS': + descriptions.push(`Mutual TLS (${name})`); + break; + default: { + const _exhaustive: never = scheme; + // This ensures TypeScript errors if a new SecurityScheme type is added + descriptions.push( + `Unknown (${name}): ${(_exhaustive as SecurityScheme).type}`, + ); + } + } + } + + return descriptions.join(' OR '); + } +}