mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
feat(agents): add API Key and HTTP auth providers with value resolution
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
155
packages/core/src/agents/auth-provider/api-key-provider.test.ts
Normal file
155
packages/core/src/agents/auth-provider/api-key-provider.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
125
packages/core/src/agents/auth-provider/api-key-provider.ts
Normal file
125
packages/core/src/agents/auth-provider/api-key-provider.ts
Normal file
@@ -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<void> {
|
||||
// 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<HttpHeaders> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
149
packages/core/src/agents/auth-provider/http-auth-provider.ts
Normal file
149
packages/core/src/agents/auth-provider/http-auth-provider.ts
Normal file
@@ -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<void> {
|
||||
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<HttpHeaders> {
|
||||
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<HttpHeaders | undefined> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
132
packages/core/src/agents/auth-provider/value-resolver.test.ts
Normal file
132
packages/core/src/agents/auth-provider/value-resolver.test.ts
Normal file
@@ -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('****');
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/core/src/agents/auth-provider/value-resolver.ts
Normal file
122
packages/core/src/agents/auth-provider/value-resolver.ts
Normal file
@@ -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<string> {
|
||||
// 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user