mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 13:22:35 -07: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);
|
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 * as crypto from 'node:crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { AgentDefinition } from './types.js';
|
import type { AgentDefinition } from './types.js';
|
||||||
|
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||||
import { isValidToolName } from '../tools/tool-names.js';
|
import { isValidToolName } from '../tools/tool-names.js';
|
||||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
@@ -35,11 +36,29 @@ interface FrontmatterLocalAgentDefinition
|
|||||||
timeout_mins?: number;
|
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
|
interface FrontmatterRemoteAgentDefinition
|
||||||
extends FrontmatterBaseAgentDefinition {
|
extends FrontmatterBaseAgentDefinition {
|
||||||
kind: 'remote';
|
kind: 'remote';
|
||||||
description?: string;
|
description?: string;
|
||||||
agent_card_url: string;
|
agent_card_url: string;
|
||||||
|
auth?: FrontmatterAuthConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FrontmatterAgentDefinition =
|
type FrontmatterAgentDefinition =
|
||||||
@@ -91,6 +110,74 @@ const localAgentSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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
|
const remoteAgentSchema = z
|
||||||
.object({
|
.object({
|
||||||
kind: z.literal('remote').optional().default('remote'),
|
kind: z.literal('remote').optional().default('remote'),
|
||||||
@@ -98,6 +185,7 @@ const remoteAgentSchema = z
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
display_name: z.string().optional(),
|
display_name: z.string().optional(),
|
||||||
agent_card_url: z.string().url(),
|
agent_card_url: z.string().url(),
|
||||||
|
auth: authConfigSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -233,6 +321,50 @@ export async function parseAgentMarkdown(
|
|||||||
return [agentDef];
|
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.
|
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||||
*
|
*
|
||||||
@@ -265,6 +397,9 @@ export function markdownToAgentDefinition(
|
|||||||
description: markdown.description || '(Loading description...)',
|
description: markdown.description || '(Loading description...)',
|
||||||
displayName: markdown.display_name,
|
displayName: markdown.display_name,
|
||||||
agentCardUrl: markdown.agent_card_url,
|
agentCardUrl: markdown.agent_card_url,
|
||||||
|
auth: markdown.auth
|
||||||
|
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||||
|
: undefined,
|
||||||
inputConfig,
|
inputConfig,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about unsupported locations once during init
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve command-based API keys on auth failure.
|
||||||
|
* This handles cases where the key may have expired or been rotated.
|
||||||
|
*/
|
||||||
|
override async shouldRetryWithHeaders(
|
||||||
|
_req: RequestInit,
|
||||||
|
res: Response,
|
||||||
|
): Promise<HttpHeaders | undefined> {
|
||||||
|
if (res.status !== 401 && res.status !== 403) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For command-based keys, re-resolve to get a fresh key
|
||||||
|
if (this.config.key.startsWith('!')) {
|
||||||
|
debugLogger.debug(
|
||||||
|
'[ApiKeyAuthProvider] Re-resolving API key after auth failure',
|
||||||
|
);
|
||||||
|
this.resolvedKey = await resolveAuthValue(this.config.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.headers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
type CreateAuthProviderOptions,
|
||||||
} from './factory.js';
|
} from './factory.js';
|
||||||
|
|
||||||
// Note: Individual providers are lazy-loaded by the factory.
|
// Providers
|
||||||
// They will be exported as they are implemented in subsequent PRs.
|
export { ApiKeyAuthProvider } from './api-key-provider.js';
|
||||||
|
export { HttpAuthProvider } from './http-auth-provider.js';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
export {
|
||||||
|
resolveAuthValue,
|
||||||
|
needsResolution,
|
||||||
|
maskSensitiveValue,
|
||||||
|
} from './value-resolver.js';
|
||||||
|
|||||||
@@ -23,27 +23,11 @@ export type A2AAuthProviderType =
|
|||||||
* lifecycle management methods.
|
* lifecycle management methods.
|
||||||
*/
|
*/
|
||||||
export interface A2AAuthProvider extends AuthenticationHandler {
|
export interface A2AAuthProvider extends AuthenticationHandler {
|
||||||
/**
|
|
||||||
* The type of authentication provider.
|
|
||||||
*/
|
|
||||||
readonly type: A2AAuthProviderType;
|
readonly type: A2AAuthProviderType;
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the provider. Called before first use.
|
|
||||||
* For OAuth/OIDC, this may trigger discovery or browser-based auth.
|
|
||||||
*/
|
|
||||||
initialize?(): Promise<void>;
|
initialize?(): Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up any resources held by the provider.
|
|
||||||
*/
|
|
||||||
dispose?(): Promise<void>;
|
dispose?(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Base configuration interface
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base configuration shared by all auth types.
|
* Base configuration shared by all auth types.
|
||||||
*/
|
*/
|
||||||
@@ -55,172 +39,67 @@ export interface BaseAuthConfig {
|
|||||||
agent_card_requires_auth?: boolean;
|
agent_card_requires_auth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Google Credentials configuration
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for Google Application Default Credentials (ADC).
|
* Configuration for Google Application Default Credentials (ADC).
|
||||||
*/
|
*/
|
||||||
export interface GoogleCredentialsAuthConfig extends BaseAuthConfig {
|
export interface GoogleCredentialsAuthConfig extends BaseAuthConfig {
|
||||||
type: 'google-credentials';
|
type: 'google-credentials';
|
||||||
|
/** OAuth scopes to request. */
|
||||||
/**
|
|
||||||
* OAuth scopes to request. Required for access tokens.
|
|
||||||
* @example ['https://www.googleapis.com/auth/cloud-platform']
|
|
||||||
*/
|
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
|
/** Target audience for ID token requests (e.g., Cloud Run URL). */
|
||||||
/**
|
|
||||||
* Target audience for ID token requests.
|
|
||||||
* When specified, an ID token is requested instead of an access token.
|
|
||||||
* Typically the URL of the Cloud Run service or other GCP resource.
|
|
||||||
* @example 'https://my-agent.run.app'
|
|
||||||
*/
|
|
||||||
target_audience?: string;
|
target_audience?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// API Key configuration
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for API Key authentication.
|
* Configuration for API Key authentication.
|
||||||
* The API key can be sent in a header, query parameter, or cookie.
|
|
||||||
*/
|
*/
|
||||||
export interface ApiKeyAuthConfig extends BaseAuthConfig {
|
export interface ApiKeyAuthConfig extends BaseAuthConfig {
|
||||||
type: 'apiKey';
|
type: 'apiKey';
|
||||||
|
/** The API key. Supports $ENV_VAR, !command, or literal value. */
|
||||||
/**
|
|
||||||
* The API key value. Supports:
|
|
||||||
* - `$ENV_VAR`: Read from environment variable
|
|
||||||
* - `!command`: Execute shell command and use output
|
|
||||||
* - Literal string value
|
|
||||||
*/
|
|
||||||
key: string;
|
key: string;
|
||||||
|
/** Where to include the key. @default 'header' */
|
||||||
/**
|
|
||||||
* Where to include the API key in requests.
|
|
||||||
* @default 'header'
|
|
||||||
*/
|
|
||||||
in?: 'header' | 'query' | 'cookie';
|
in?: 'header' | 'query' | 'cookie';
|
||||||
|
/** Header/param/cookie name. @default 'X-API-Key' for header */
|
||||||
/**
|
|
||||||
* The name of the header, query parameter, or cookie.
|
|
||||||
* @default 'X-API-Key' for header, 'api_key' for query/cookie
|
|
||||||
*/
|
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HTTP Auth configuration
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for HTTP authentication (Bearer or Basic).
|
* Configuration for HTTP authentication (Bearer or Basic).
|
||||||
*/
|
*/
|
||||||
export interface HttpAuthConfig extends BaseAuthConfig {
|
export interface HttpAuthConfig extends BaseAuthConfig {
|
||||||
type: 'http';
|
type: 'http';
|
||||||
|
|
||||||
/**
|
|
||||||
* The HTTP authentication scheme.
|
|
||||||
*/
|
|
||||||
scheme: 'Bearer' | 'Basic';
|
scheme: 'Bearer' | 'Basic';
|
||||||
|
/** Bearer token. Supports $ENV_VAR, !command, or literal. */
|
||||||
/**
|
|
||||||
* The token for Bearer authentication. Supports:
|
|
||||||
* - `$ENV_VAR`: Read from environment variable
|
|
||||||
* - `!command`: Execute shell command and use output
|
|
||||||
* - Literal string value
|
|
||||||
*/
|
|
||||||
token?: string;
|
token?: string;
|
||||||
|
/** Basic auth username. Supports $ENV_VAR and !command. */
|
||||||
/**
|
|
||||||
* Username for Basic authentication. Supports $ENV_VAR and !command.
|
|
||||||
*/
|
|
||||||
username?: string;
|
username?: string;
|
||||||
|
/** Basic auth password. Supports $ENV_VAR and !command. */
|
||||||
/**
|
|
||||||
* Password for Basic authentication. Supports $ENV_VAR and !command.
|
|
||||||
*/
|
|
||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// OAuth 2.0 configuration
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for OAuth 2.0 authentication.
|
* Configuration for OAuth 2.0 authentication.
|
||||||
* Endpoints can be discovered from the AgentCard's securitySchemes.
|
|
||||||
*/
|
*/
|
||||||
export interface OAuth2AuthConfig extends BaseAuthConfig {
|
export interface OAuth2AuthConfig extends BaseAuthConfig {
|
||||||
type: 'oauth2';
|
type: 'oauth2';
|
||||||
|
|
||||||
/**
|
|
||||||
* Client ID for OAuth. Supports $ENV_VAR and !command.
|
|
||||||
*/
|
|
||||||
client_id?: string;
|
client_id?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Client secret for OAuth. Supports $ENV_VAR and !command.
|
|
||||||
* May be omitted for public clients using PKCE.
|
|
||||||
*/
|
|
||||||
client_secret?: string;
|
client_secret?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth scopes to request.
|
|
||||||
*/
|
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// OpenID Connect configuration
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for OpenID Connect authentication.
|
* Configuration for OpenID Connect authentication.
|
||||||
* This is a generic OIDC provider that works with any compliant issuer
|
|
||||||
* (Auth0, Okta, Keycloak, Google, etc.).
|
|
||||||
*/
|
*/
|
||||||
export interface OpenIdConnectAuthConfig extends BaseAuthConfig {
|
export interface OpenIdConnectAuthConfig extends BaseAuthConfig {
|
||||||
type: 'openIdConnect';
|
type: 'openIdConnect';
|
||||||
|
/** OIDC issuer URL for discovery. */
|
||||||
/**
|
|
||||||
* The OIDC issuer URL for discovery.
|
|
||||||
* Used to fetch the .well-known/openid-configuration.
|
|
||||||
* @example 'https://auth.example.com'
|
|
||||||
*/
|
|
||||||
issuer_url: string;
|
issuer_url: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Client ID for OIDC. Supports $ENV_VAR and !command.
|
|
||||||
*/
|
|
||||||
client_id: string;
|
client_id: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Client secret for OIDC. Supports $ENV_VAR and !command.
|
|
||||||
* May be omitted for public clients.
|
|
||||||
*/
|
|
||||||
client_secret?: string;
|
client_secret?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Target audience for ID token requests.
|
|
||||||
* @example 'https://protected-agent.example.com'
|
|
||||||
*/
|
|
||||||
target_audience?: string;
|
target_audience?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth scopes to request.
|
|
||||||
* @default ['openid']
|
|
||||||
*/
|
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Union type for all auth configs
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union type of all supported A2A authentication configurations.
|
* Union type of all supported A2A authentication configurations.
|
||||||
*/
|
*/
|
||||||
@@ -231,27 +110,12 @@ export type A2AAuthConfig =
|
|||||||
| OAuth2AuthConfig
|
| OAuth2AuthConfig
|
||||||
| OpenIdConnectAuthConfig;
|
| OpenIdConnectAuthConfig;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Auth validation types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes a mismatch between configured auth and AgentCard requirements.
|
* Describes a mismatch between configured auth and AgentCard requirements.
|
||||||
*/
|
*/
|
||||||
export interface AuthConfigDiff {
|
export interface AuthConfigDiff {
|
||||||
/**
|
|
||||||
* Security scheme names required by the AgentCard.
|
|
||||||
*/
|
|
||||||
requiredSchemes: string[];
|
requiredSchemes: string[];
|
||||||
|
|
||||||
/**
|
|
||||||
* The auth type configured in the agent definition, if any.
|
|
||||||
*/
|
|
||||||
configuredType?: A2AAuthProviderType;
|
configuredType?: A2AAuthProviderType;
|
||||||
|
|
||||||
/**
|
|
||||||
* Description of what's missing to satisfy the requirements.
|
|
||||||
*/
|
|
||||||
missingConfig: string[];
|
missingConfig: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,19 +123,8 @@ export interface AuthConfigDiff {
|
|||||||
* Result of validating auth configuration against AgentCard requirements.
|
* Result of validating auth configuration against AgentCard requirements.
|
||||||
*/
|
*/
|
||||||
export interface AuthValidationResult {
|
export interface AuthValidationResult {
|
||||||
/**
|
|
||||||
* Whether the configuration is valid for the AgentCard's requirements.
|
|
||||||
*/
|
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Details about the mismatch, if any.
|
|
||||||
*/
|
|
||||||
diff?: AuthConfigDiff;
|
diff?: AuthConfigDiff;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Re-export useful types from the SDK
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type { AuthenticationHandler, HttpHeaders };
|
export type { AuthenticationHandler, HttpHeaders };
|
||||||
|
|||||||
@@ -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('****');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { debugLogger } from '../../utils/debugLogger.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 { stdout } = await execAsync(command, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 30000, // 30 second timeout
|
||||||
|
windowsHide: true, // Hide console window on Windows
|
||||||
|
});
|
||||||
|
const trimmed = stdout.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 (
|
||||||
|
'code' in error &&
|
||||||
|
typeof error.code === 'number' &&
|
||||||
|
error.code !== 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Command '${command}' failed with exit code ${error.code}: ${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