diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index 7391161542..7ae4b0411a 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -361,4 +361,171 @@ Hidden`, expect(result.errors).toHaveLength(1); }); }); + + describe('remote agent auth configuration', () => { + it('should parse remote agent with apiKey auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: api-key-agent +agent_card_url: https://example.com/card +auth: + type: apiKey + key: $MY_API_KEY + in: header + name: X-Custom-Key +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'api-key-agent', + auth: { + type: 'apiKey', + key: '$MY_API_KEY', + in: 'header', + name: 'X-Custom-Key', + }, + }); + }); + + it('should parse remote agent with http Bearer auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: bearer-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Bearer + token: $BEARER_TOKEN +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'bearer-agent', + auth: { + type: 'http', + scheme: 'Bearer', + token: '$BEARER_TOKEN', + }, + }); + }); + + it('should parse remote agent with http Basic auth', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: basic-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Basic + username: $AUTH_USER + password: $AUTH_PASS +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'basic-agent', + auth: { + type: 'http', + scheme: 'Basic', + username: '$AUTH_USER', + password: '$AUTH_PASS', + }, + }); + }); + + it('should throw error for Bearer auth without token', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-bearer +agent_card_url: https://example.com/card +auth: + type: http + scheme: Bearer +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Bearer scheme requires "token"/, + ); + }); + + it('should throw error for Basic auth without credentials', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-basic +agent_card_url: https://example.com/card +auth: + type: http + scheme: Basic + username: user +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /Basic scheme requires "username" and "password"/, + ); + }); + + it('should throw error for apiKey auth without key', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: invalid-apikey +agent_card_url: https://example.com/card +auth: + type: apiKey +--- +`); + await expect(parseAgentMarkdown(filePath)).rejects.toThrow( + /auth\.key.*Required/, + ); + }); + + it('should convert auth config in markdownToAgentDefinition', () => { + const markdown = { + kind: 'remote' as const, + name: 'auth-agent', + agent_card_url: 'https://example.com/card', + auth: { + type: 'apiKey' as const, + key: '$API_KEY', + in: 'header' as const, + }, + }; + + const result = markdownToAgentDefinition(markdown); + expect(result).toMatchObject({ + kind: 'remote', + name: 'auth-agent', + auth: { + type: 'apiKey', + key: '$API_KEY', + in: 'header', + }, + }); + }); + + it('should parse auth with agent_card_requires_auth flag', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: protected-card-agent +agent_card_url: https://example.com/card +auth: + type: apiKey + key: $MY_API_KEY + agent_card_requires_auth: true +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result[0]).toMatchObject({ + auth: { + type: 'apiKey', + agent_card_requires_auth: true, + }, + }); + }); + }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index 1679b52fb3..3748ca451f 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -11,6 +11,7 @@ import * as path from 'node:path'; import * as crypto from 'node:crypto'; import { z } from 'zod'; import type { AgentDefinition } from './types.js'; +import type { A2AAuthConfig } from './auth-provider/types.js'; import { isValidToolName } from '../tools/tool-names.js'; import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -35,11 +36,29 @@ interface FrontmatterLocalAgentDefinition timeout_mins?: number; } +/** + * Authentication configuration for remote agents in frontmatter format. + */ +interface FrontmatterAuthConfig { + type: 'apiKey' | 'http'; + agent_card_requires_auth?: boolean; + // API Key + key?: string; + in?: 'header' | 'query' | 'cookie'; + name?: string; + // HTTP + scheme?: 'Bearer' | 'Basic'; + token?: string; + username?: string; + password?: string; +} + interface FrontmatterRemoteAgentDefinition extends FrontmatterBaseAgentDefinition { kind: 'remote'; description?: string; agent_card_url: string; + auth?: FrontmatterAuthConfig; } type FrontmatterAgentDefinition = @@ -91,6 +110,74 @@ const localAgentSchema = z }) .strict(); +// ============================================================================ +// Auth configuration schemas +// ============================================================================ + +/** + * Base fields shared by all auth configs. + */ +const baseAuthFields = { + agent_card_requires_auth: z.boolean().optional(), +}; + +/** + * API Key auth schema. + * Supports sending key in header, query parameter, or cookie. + */ +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(), +}); + +/** + * HTTP auth schema (Bearer or Basic). + * Note: Validation for scheme-specific fields is applied in authConfigSchema + * since discriminatedUnion doesn't support refined schemas directly. + */ +const httpAuthSchemaBase = z.object({ + ...baseAuthFields, + type: z.literal('http'), + scheme: z.enum(['Bearer', 'Basic']), + token: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), +}); + +/** + * Combined auth schema - discriminated union of all auth types. + * Note: We use the base schema for discriminatedUnion, then apply refinements + * via a transform since discriminatedUnion doesn't support refined schemas. + */ +const authConfigSchema = z + .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase]) + .superRefine((data, ctx) => { + // Apply HTTP auth validation after union parsing + if (data.type === 'http') { + if (data.scheme === 'Bearer' && !data.token) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Bearer scheme requires "token"', + 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'], + }); + } + } + }); + +// ============================================================================ +// Agent schemas +// ============================================================================ + const remoteAgentSchema = z .object({ kind: z.literal('remote').optional().default('remote'), @@ -98,6 +185,7 @@ const remoteAgentSchema = z description: z.string().optional(), display_name: z.string().optional(), agent_card_url: z.string().url(), + auth: authConfigSchema.optional(), }) .strict(); @@ -233,6 +321,50 @@ export async function parseAgentMarkdown( return [agentDef]; } +/** + * Converts frontmatter auth config to the internal A2AAuthConfig type. + * This handles the mapping from snake_case YAML to the internal type structure. + */ +function convertFrontmatterAuthToConfig( + frontmatter: FrontmatterAuthConfig, +): A2AAuthConfig { + const base = { + agent_card_requires_auth: frontmatter.agent_card_requires_auth, + }; + + switch (frontmatter.type) { + case 'apiKey': + if (!frontmatter.key) { + throw new Error('API key auth requires "key" field'); + } + return { + ...base, + type: 'apiKey', + key: frontmatter.key, + in: frontmatter.in, + name: frontmatter.name, + }; + + case 'http': + if (!frontmatter.scheme) { + throw new Error('HTTP auth requires "scheme" field'); + } + return { + ...base, + type: 'http', + scheme: frontmatter.scheme, + token: frontmatter.token, + username: frontmatter.username, + password: frontmatter.password, + }; + + default: { + const exhaustive: never = frontmatter.type; + throw new Error(`Unknown auth type: ${exhaustive}`); + } + } +} + /** * Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure. * @@ -265,6 +397,9 @@ export function markdownToAgentDefinition( description: markdown.description || '(Loading description...)', displayName: markdown.display_name, agentCardUrl: markdown.agent_card_url, + auth: markdown.auth + ? convertFrontmatterAuthToConfig(markdown.auth) + : undefined, inputConfig, metadata, }; 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..4130ad7a42 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; + +describe('ApiKeyAuthProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + 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 () => { + process.env['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 () => { + delete process.env['MISSING_KEY']; + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$MISSING_KEY', + }); + + await expect(provider.initialize()).rejects.toThrow( + "Environment variable 'MISSING_KEY' 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 for header location', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + in: 'header', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-key' }); + }); + }); + + describe('query and cookie locations', () => { + it('should return empty headers for query location', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + in: 'query', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({}); + }); + + it('should expose key for query via getKeyForQuery', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + in: 'query', + name: 'apikey', + }); + await provider.initialize(); + + const queryKey = provider.getKeyForQuery(); + expect(queryKey).toEqual({ name: 'apikey', value: 'my-key' }); + }); + + it('should return undefined from getKeyForQuery when location is header', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + in: 'header', + }); + await provider.initialize(); + + expect(provider.getKeyForQuery()).toBeUndefined(); + }); + + it('should expose key for cookie via getKeyForCookie', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + in: 'cookie', + name: 'auth_cookie', + }); + await provider.initialize(); + + const cookieKey = provider.getKeyForCookie(); + expect(cookieKey).toEqual({ name: 'auth_cookie', value: 'my-key' }); + }); + }); + + 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..4598cf6166 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { ApiKeyAuthConfig, HttpHeaders } from './types.js'; +import { resolveAuthValue, needsResolution } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** + * Default header name for API Key authentication. + */ +const DEFAULT_HEADER_NAME = 'X-API-Key'; + +/** + * Default query/cookie parameter name for API Key authentication. + */ +const DEFAULT_PARAM_NAME = 'api_key'; + +/** + * Authentication provider for API Key authentication. + * + * Supports sending the API key in: + * - HTTP headers (default) + * - Query parameters + * - Cookies + * + * 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 keyLocation: 'header' | 'query' | 'cookie'; + private readonly keyName: string; + + constructor(private readonly config: ApiKeyAuthConfig) { + super(); + this.keyLocation = config.in ?? 'header'; + this.keyName = + config.name ?? + (this.keyLocation === 'header' + ? DEFAULT_HEADER_NAME + : DEFAULT_PARAM_NAME); + } + + /** + * Initialize the provider by resolving the API key value. + */ + override async initialize(): Promise { + // Only resolve dynamic values once during initialization + // to avoid repeated command execution + 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; + } + } + + /** + * Get the HTTP headers to include in requests. + * + * For API keys in headers, this returns the header directly. + * For query/cookie locations, this returns an empty object + * (the query/cookie handling would need to be done at a different layer). + */ + async headers(): Promise { + if (!this.resolvedKey) { + throw new Error( + 'ApiKeyAuthProvider not initialized. Call initialize() first.', + ); + } + + if (this.keyLocation === 'header') { + return { [this.keyName]: this.resolvedKey }; + } + + // For query and cookie, we can't set headers directly. + // The SDK's transport layer would need to handle these. + // For now, we log a warning and return empty headers. + if (this.keyLocation === 'query') { + debugLogger.warn( + `[ApiKeyAuthProvider] API key location 'query' is not fully supported. ` + + `Consider using 'header' instead.`, + ); + } else if (this.keyLocation === 'cookie') { + debugLogger.warn( + `[ApiKeyAuthProvider] API key location 'cookie' is not fully supported. ` + + `Consider using 'header' instead.`, + ); + } + + return {}; + } + + /** + * Get the API key value for use in query parameters. + * This is exposed for transport layers that need to add query params. + */ + getKeyForQuery(): { name: string; value: string } | undefined { + if (this.keyLocation !== 'query' || !this.resolvedKey) { + return undefined; + } + return { name: this.keyName, value: this.resolvedKey }; + } + + /** + * Get the API key value for use in cookies. + * This is exposed for transport layers that need to set cookies. + */ + getKeyForCookie(): { name: string; value: string } | undefined { + if (this.keyLocation !== 'cookie' || !this.resolvedKey) { + return undefined; + } + return { name: this.keyName, value: this.resolvedKey }; + } +} diff --git a/packages/core/src/agents/auth-provider/http-auth-provider.test.ts b/packages/core/src/agents/auth-provider/http-auth-provider.test.ts new file mode 100644 index 0000000000..9737aa21e4 --- /dev/null +++ b/packages/core/src/agents/auth-provider/http-auth-provider.test.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { HttpAuthProvider } from './http-auth-provider.js'; + +describe('HttpAuthProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('Bearer authentication', () => { + it('should generate Bearer authorization header', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: 'my-bearer-token', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer my-bearer-token' }); + }); + + it('should resolve token from environment variable', async () => { + process.env['BEARER_TOKEN'] = 'env-token'; + + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: '$BEARER_TOKEN', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer env-token' }); + }); + + it('should throw if Bearer token is not provided', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + }); + + await expect(provider.initialize()).rejects.toThrow( + 'HTTP Bearer authentication requires a token', + ); + }); + + it('should throw if not initialized', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: 'test', + }); + + await expect(provider.headers()).rejects.toThrow('not initialized'); + }); + }); + + describe('Basic authentication', () => { + it('should generate Basic authorization header', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Basic', + username: 'user', + password: 'pass', + }); + await provider.initialize(); + + const headers = await provider.headers(); + // 'user:pass' base64 encoded is 'dXNlcjpwYXNz' + expect(headers).toEqual({ Authorization: 'Basic dXNlcjpwYXNz' }); + }); + + it('should resolve credentials from environment variables', async () => { + process.env['AUTH_USER'] = 'envuser'; + process.env['AUTH_PASS'] = 'envpass'; + + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Basic', + username: '$AUTH_USER', + password: '$AUTH_PASS', + }); + await provider.initialize(); + + const headers = await provider.headers(); + // 'envuser:envpass' base64 encoded + const expected = Buffer.from('envuser:envpass').toString('base64'); + expect(headers).toEqual({ Authorization: `Basic ${expected}` }); + }); + + it('should throw if username is not provided', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Basic', + password: 'pass', + }); + + await expect(provider.initialize()).rejects.toThrow( + 'HTTP Basic authentication requires username and password', + ); + }); + + it('should throw if password is not provided', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Basic', + username: 'user', + }); + + await expect(provider.initialize()).rejects.toThrow( + 'HTTP Basic authentication requires username and password', + ); + }); + }); + + describe('shouldRetryWithHeaders', () => { + it('should return undefined for non-auth errors', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: 'test-token', + }); + await provider.initialize(); + + const response = new Response(null, { status: 500 }); + const result = await provider.shouldRetryWithHeaders({}, response); + expect(result).toBeUndefined(); + }); + + it('should return headers for 401 response', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: 'test-token', + }); + await provider.initialize(); + + const response = new Response(null, { status: 401 }); + const result = await provider.shouldRetryWithHeaders({}, response); + expect(result).toEqual({ Authorization: 'Bearer test-token' }); + }); + + it('should return headers for 403 response', async () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: 'test-token', + }); + await provider.initialize(); + + const response = new Response(null, { status: 403 }); + const result = await provider.shouldRetryWithHeaders({}, response); + expect(result).toEqual({ Authorization: 'Bearer test-token' }); + }); + + it('should re-resolve command-based tokens on retry', async () => { + // Use a command that returns different values + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: '!echo refreshed-token', + }); + await provider.initialize(); + + const response = new Response(null, { status: 401 }); + const result = await provider.shouldRetryWithHeaders({}, response); + expect(result).toEqual({ Authorization: 'Bearer refreshed-token' }); + }); + }); + + describe('type property', () => { + it('should have type http', () => { + const provider = new HttpAuthProvider({ + type: 'http', + scheme: 'Bearer', + token: 'test', + }); + expect(provider.type).toBe('http'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/http-auth-provider.ts b/packages/core/src/agents/auth-provider/http-auth-provider.ts new file mode 100644 index 0000000000..0ea04b7a8f --- /dev/null +++ b/packages/core/src/agents/auth-provider/http-auth-provider.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { HttpAuthConfig, HttpHeaders } from './types.js'; +import { resolveAuthValue, needsResolution } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** + * Authentication provider for HTTP authentication (Bearer and Basic). + * + * Supports: + * - Bearer token authentication + * - Basic authentication (username/password) + * + * Credential values can be: + * - Literal strings + * - Environment variable references ($ENV_VAR) + * - Shell commands (!command) + */ +export class HttpAuthProvider extends BaseA2AAuthProvider { + readonly type = 'http' as const; + + private resolvedCredentials: { + token?: string; + username?: string; + password?: string; + } = {}; + + constructor(private readonly config: HttpAuthConfig) { + super(); + } + + /** + * Initialize the provider by resolving credential values. + */ + override async initialize(): Promise { + if (this.config.scheme === 'Bearer') { + if (!this.config.token) { + throw new Error( + 'HTTP Bearer authentication requires a token. ' + + 'Add "token" to your auth configuration.', + ); + } + + if (needsResolution(this.config.token)) { + this.resolvedCredentials.token = await resolveAuthValue( + this.config.token, + ); + debugLogger.debug( + `[HttpAuthProvider] Resolved Bearer token from: ${this.config.token.startsWith('$') ? 'env var' : 'command'}`, + ); + } else { + this.resolvedCredentials.token = this.config.token; + } + } else if (this.config.scheme === 'Basic') { + if (!this.config.username || !this.config.password) { + throw new Error( + 'HTTP Basic authentication requires username and password. ' + + 'Add "username" and "password" to your auth configuration.', + ); + } + + // Resolve username + if (needsResolution(this.config.username)) { + this.resolvedCredentials.username = await resolveAuthValue( + this.config.username, + ); + } else { + this.resolvedCredentials.username = this.config.username; + } + + // Resolve password + if (needsResolution(this.config.password)) { + this.resolvedCredentials.password = await resolveAuthValue( + this.config.password, + ); + } else { + this.resolvedCredentials.password = this.config.password; + } + + debugLogger.debug('[HttpAuthProvider] Resolved Basic auth credentials'); + } + } + + /** + * Get the HTTP headers to include in requests. + */ + async headers(): Promise { + if (this.config.scheme === 'Bearer') { + if (!this.resolvedCredentials.token) { + throw new Error( + 'HttpAuthProvider not initialized. Call initialize() first.', + ); + } + return { Authorization: `Bearer ${this.resolvedCredentials.token}` }; + } + + if (this.config.scheme === 'Basic') { + const { username, password } = this.resolvedCredentials; + if (!username || !password) { + throw new Error( + 'HttpAuthProvider not initialized. Call initialize() first.', + ); + } + + // Base64 encode the credentials + const credentials = `${username}:${password}`; + const encoded = Buffer.from(credentials, 'utf-8').toString('base64'); + return { Authorization: `Basic ${encoded}` }; + } + + throw new Error(`Unsupported HTTP auth scheme: ${this.config.scheme}`); + } + + /** + * For Bearer tokens that may expire, re-resolve the token on retry. + * This is useful when using shell commands that fetch fresh tokens. + */ + override async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status !== 401 && res.status !== 403) { + return undefined; + } + + // For Bearer tokens from commands, re-resolve to get a fresh token + if ( + this.config.scheme === 'Bearer' && + this.config.token && + this.config.token.startsWith('!') + ) { + debugLogger.debug( + '[HttpAuthProvider] Re-resolving Bearer token after auth failure', + ); + this.resolvedCredentials.token = await resolveAuthValue( + this.config.token, + ); + return this.headers(); + } + + // For other cases, just return the same headers + return this.headers(); + } +} diff --git a/packages/core/src/agents/auth-provider/index.ts b/packages/core/src/agents/auth-provider/index.ts index c6249b65ec..d1fe8ac7b3 100644 --- a/packages/core/src/agents/auth-provider/index.ts +++ b/packages/core/src/agents/auth-provider/index.ts @@ -30,5 +30,13 @@ export { type CreateAuthProviderOptions, } from './factory.js'; -// Note: Individual providers are lazy-loaded by the factory. -// They will be exported as they are implemented in subsequent PRs. +// Providers +export { ApiKeyAuthProvider } from './api-key-provider.js'; +export { HttpAuthProvider } from './http-auth-provider.js'; + +// Utilities +export { + resolveAuthValue, + needsResolution, + maskSensitiveValue, +} from './value-resolver.js'; diff --git a/packages/core/src/agents/auth-provider/value-resolver.test.ts b/packages/core/src/agents/auth-provider/value-resolver.test.ts new file mode 100644 index 0000000000..866754dc07 --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.test.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + resolveAuthValue, + needsResolution, + maskSensitiveValue, +} from './value-resolver.js'; + +describe('value-resolver', () => { + describe('resolveAuthValue', () => { + describe('environment variables', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should resolve environment variable with $ prefix', async () => { + process.env['TEST_API_KEY'] = 'secret-key-123'; + const result = await resolveAuthValue('$TEST_API_KEY'); + expect(result).toBe('secret-key-123'); + }); + + it('should throw error for unset environment variable', async () => { + delete process.env['UNSET_VAR']; + await expect(resolveAuthValue('$UNSET_VAR')).rejects.toThrow( + "Environment variable 'UNSET_VAR' is not set or is empty", + ); + }); + + it('should throw error for empty environment variable', async () => { + process.env['EMPTY_VAR'] = ''; + await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow( + "Environment variable 'EMPTY_VAR' is not set or is empty", + ); + }); + }); + + describe('shell commands', () => { + it('should execute shell command with ! prefix', async () => { + const result = await resolveAuthValue('!echo hello'); + expect(result).toBe('hello'); + }); + + it('should trim whitespace from command output', async () => { + const result = await resolveAuthValue('!echo " hello "'); + expect(result).toBe('hello'); + }); + + it('should throw error for empty command', async () => { + await expect(resolveAuthValue('!')).rejects.toThrow( + 'Empty command in auth value', + ); + }); + + it('should throw error for command that returns empty output', async () => { + // Use printf which is more portable than echo -n + await expect(resolveAuthValue('!printf ""')).rejects.toThrow( + 'returned empty output', + ); + }); + + it('should throw error for failed command', async () => { + await expect( + resolveAuthValue('!nonexistent-command-12345'), + ).rejects.toThrow(/Command.*failed/); + }); + }); + + describe('literal values', () => { + it('should return literal value as-is', async () => { + const result = await resolveAuthValue('literal-api-key'); + expect(result).toBe('literal-api-key'); + }); + + it('should return empty string as-is', async () => { + const result = await resolveAuthValue(''); + expect(result).toBe(''); + }); + + it('should not treat values starting with other characters as special', async () => { + const result = await resolveAuthValue('api-key-123'); + expect(result).toBe('api-key-123'); + }); + }); + }); + + describe('needsResolution', () => { + it('should return true for environment variable reference', () => { + expect(needsResolution('$ENV_VAR')).toBe(true); + }); + + it('should return true for command reference', () => { + expect(needsResolution('!command')).toBe(true); + }); + + it('should return false for literal value', () => { + expect(needsResolution('literal')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(needsResolution('')).toBe(false); + }); + }); + + describe('maskSensitiveValue', () => { + it('should mask value longer than 8 characters', () => { + expect(maskSensitiveValue('1234567890')).toBe('12****90'); + }); + + it('should return **** for short values', () => { + expect(maskSensitiveValue('short')).toBe('****'); + }); + + it('should return **** for exactly 8 characters', () => { + expect(maskSensitiveValue('12345678')).toBe('****'); + }); + + it('should return **** for empty string', () => { + expect(maskSensitiveValue('')).toBe('****'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/value-resolver.ts b/packages/core/src/agents/auth-provider/value-resolver.ts new file mode 100644 index 0000000000..c10b82ab17 --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** + * Resolves a value that may be an environment variable reference, + * a shell command, or a literal value. + * + * Supported formats: + * - `$ENV_VAR`: Read from environment variable + * - `!command`: Execute shell command and use output (trimmed) + * - Any other string: Use as literal value + * + * @param value The value to resolve + * @returns The resolved value + * @throws Error if environment variable is not set or command fails + * + * @example + * // Environment variable + * await resolveAuthValue('$MY_API_KEY') // reads process.env.MY_API_KEY + * + * // Shell command + * await resolveAuthValue('!gcloud auth print-access-token') // executes command + * + * // Literal value + * await resolveAuthValue('sk-12345') // returns 'sk-12345' + */ +export async function resolveAuthValue(value: string): Promise { + // Environment variable: $MY_VAR + if (value.startsWith('$')) { + const envVar = value.slice(1); + const resolved = process.env[envVar]; + if (resolved === undefined || resolved === '') { + throw new Error( + `Environment variable '${envVar}' is not set or is empty. ` + + `Please set it before using this agent.`, + ); + } + debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`); + return resolved; + } + + // Shell command: !command arg1 arg2 + if (value.startsWith('!')) { + const command = value.slice(1).trim(); + if (!command) { + throw new Error('Empty command in auth value. Expected format: !command'); + } + + debugLogger.debug(`[AuthValueResolver] Executing command for auth value`); + + try { + const result = execSync(command, { + encoding: 'utf-8', + timeout: 30000, // 30 second timeout + stdio: ['pipe', 'pipe', 'pipe'], // Capture stdout, stderr + windowsHide: true, // Hide console window on Windows + }); + const trimmed = result.trim(); + if (!trimmed) { + throw new Error(`Command '${command}' returned empty output`); + } + return trimmed; + } catch (error) { + if (error instanceof Error) { + // Check for timeout + if ('killed' in error && error.killed) { + throw new Error(`Command '${command}' timed out after 30 seconds`); + } + // Check for non-zero exit code + if ( + 'status' in error && + typeof error.status === 'number' && + error.status !== 0 + ) { + throw new Error( + `Command '${command}' failed with exit code ${error.status}: ${error.message}`, + ); + } + throw new Error( + `Failed to execute command '${command}': ${error.message}`, + ); + } + throw new Error( + `Failed to execute command '${command}': ${String(error)}`, + ); + } + } + + // Literal value - return as-is + return value; +} + +/** + * Check if a value needs resolution (is an env var or command reference). + * Useful for validation without actually resolving. + * + * @param value The value to check + * @returns true if the value needs resolution + */ +export function needsResolution(value: string): boolean { + return value.startsWith('$') || value.startsWith('!'); +} + +/** + * Mask a sensitive value for logging purposes. + * Shows the first and last 2 characters with asterisks in between. + * + * @param value The sensitive value to mask + * @returns The masked value + */ +export function maskSensitiveValue(value: string): string { + if (value.length <= 8) { + return '****'; + } + return `${value.slice(0, 2)}****${value.slice(-2)}`; +}