mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 13:41:05 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cc5119cc1 | |||
| 67ad7c4064 | |||
| 6931581efe |
@@ -363,4 +363,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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} 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';
|
||||
@@ -39,11 +40,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 =
|
||||
@@ -95,6 +114,66 @@ const localAgentSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const remoteAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
@@ -102,6 +181,7 @@ const remoteAgentSchema = z
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
agent_card_url: z.string().url(),
|
||||
auth: authConfigSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -237,6 +317,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.
|
||||
*
|
||||
@@ -269,6 +393,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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,33 @@ import type { A2AAuthProvider, A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for A2A authentication providers.
|
||||
* Provides default implementations for optional methods.
|
||||
*/
|
||||
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
/**
|
||||
* The type of authentication provider.
|
||||
*/
|
||||
abstract readonly type: A2AAuthProviderType;
|
||||
|
||||
/**
|
||||
* Get the HTTP headers to include in requests.
|
||||
* Subclasses must implement this method.
|
||||
*/
|
||||
abstract headers(): Promise<HttpHeaders>;
|
||||
|
||||
private static readonly MAX_AUTH_RETRIES = 2;
|
||||
private authRetryCount = 0;
|
||||
|
||||
/**
|
||||
* Default: retry on 401/403 with fresh headers.
|
||||
* Subclasses with cached tokens must override to force-refresh to avoid infinite retries.
|
||||
* Check if a request should be retried with new headers.
|
||||
*
|
||||
* The default implementation checks for 401/403 status codes and
|
||||
* returns fresh headers for retry. Subclasses can override for
|
||||
* custom retry logic.
|
||||
*
|
||||
* @param _req The original request init
|
||||
* @param res The response from the server
|
||||
* @returns New headers for retry, or undefined if no retry should be made
|
||||
*/
|
||||
async shouldRetryWithHeaders(
|
||||
_req: RequestInit,
|
||||
@@ -37,5 +53,17 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
/**
|
||||
* Initialize the provider. Override in subclasses that need async setup.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Default: no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources. Override in subclasses that need cleanup.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
// Default: no-op
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,23 @@ import type {
|
||||
AuthValidationResult,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Options for creating an auth provider.
|
||||
*/
|
||||
export interface CreateAuthProviderOptions {
|
||||
/** Required for OAuth/OIDC token storage. */
|
||||
agentName?: string;
|
||||
/**
|
||||
* Name of the agent (for error messages and token storage).
|
||||
*/
|
||||
agentName: string;
|
||||
|
||||
/**
|
||||
* Auth configuration from the agent definition frontmatter.
|
||||
*/
|
||||
authConfig?: A2AAuthConfig;
|
||||
|
||||
/**
|
||||
* The fetched AgentCard with securitySchemes.
|
||||
*/
|
||||
agentCard?: AgentCard;
|
||||
}
|
||||
|
||||
@@ -23,33 +36,50 @@ export interface CreateAuthProviderOptions {
|
||||
* @see https://a2a-protocol.org/latest/specification/#451-securityscheme
|
||||
*/
|
||||
export class A2AAuthProviderFactory {
|
||||
/**
|
||||
* Create an auth provider from configuration.
|
||||
*
|
||||
* @param options Creation options including agent name and config
|
||||
* @returns The created auth provider, or undefined if no auth is needed
|
||||
*/
|
||||
static async create(
|
||||
options: CreateAuthProviderOptions,
|
||||
): Promise<A2AAuthProvider | undefined> {
|
||||
const { agentName: _agentName, authConfig, agentCard } = options;
|
||||
|
||||
// If no auth config, check if the AgentCard requires auth
|
||||
if (!authConfig) {
|
||||
if (
|
||||
agentCard?.securitySchemes &&
|
||||
Object.keys(agentCard.securitySchemes).length > 0
|
||||
) {
|
||||
return undefined; // Caller should prompt user to configure auth
|
||||
// AgentCard requires auth but none configured
|
||||
// The caller should handle this case by prompting the user
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Create provider based on config type
|
||||
// Providers are lazy-loaded to support incremental implementation
|
||||
switch (authConfig.type) {
|
||||
case 'google-credentials':
|
||||
// TODO: Implement
|
||||
throw new Error('google-credentials auth provider not yet implemented');
|
||||
|
||||
case 'apiKey':
|
||||
// TODO: Implement
|
||||
throw new Error('apiKey auth provider not yet implemented');
|
||||
case 'apiKey': {
|
||||
const { ApiKeyAuthProvider } = await import('./api-key-provider.js');
|
||||
const provider = new ApiKeyAuthProvider(authConfig);
|
||||
await provider.initialize();
|
||||
return provider;
|
||||
}
|
||||
|
||||
case 'http':
|
||||
// TODO: Implement
|
||||
throw new Error('http auth provider not yet implemented');
|
||||
case 'http': {
|
||||
const { HttpAuthProvider } = await import('./http-auth-provider.js');
|
||||
const provider = new HttpAuthProvider(authConfig);
|
||||
await provider.initialize();
|
||||
return provider;
|
||||
}
|
||||
|
||||
case 'oauth2':
|
||||
// TODO: Implement
|
||||
@@ -60,6 +90,7 @@ export class A2AAuthProviderFactory {
|
||||
throw new Error('openIdConnect auth provider not yet implemented');
|
||||
|
||||
default: {
|
||||
// TypeScript exhaustiveness check
|
||||
const _exhaustive: never = authConfig;
|
||||
throw new Error(
|
||||
`Unknown auth type: ${(_exhaustive as A2AAuthConfig).type}`,
|
||||
@@ -68,33 +99,51 @@ export class A2AAuthProviderFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create provider directly from config, bypassing AgentCard validation. */
|
||||
/**
|
||||
* Create an auth provider directly from a config (for AgentCard fetching).
|
||||
* This bypasses AgentCard-based validation since we need auth to fetch the card.
|
||||
*
|
||||
* @param agentName Name of the agent
|
||||
* @param authConfig Auth configuration
|
||||
* @returns The created auth provider
|
||||
*/
|
||||
static async createFromConfig(
|
||||
agentName: string,
|
||||
authConfig: A2AAuthConfig,
|
||||
agentName?: string,
|
||||
): Promise<A2AAuthProvider> {
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig,
|
||||
agentName,
|
||||
authConfig,
|
||||
});
|
||||
|
||||
// create() returns undefined only when authConfig is missing.
|
||||
// Since authConfig is required here, provider will always be defined
|
||||
// (or create() throws for unimplemented types).
|
||||
return provider!;
|
||||
if (!provider) {
|
||||
throw new Error(
|
||||
`Failed to create auth provider for config type: ${authConfig.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** Validate auth config against AgentCard's security requirements. */
|
||||
/**
|
||||
* Validate that the auth configuration satisfies the AgentCard's security requirements.
|
||||
*
|
||||
* @param authConfig The configured auth from agent-definition
|
||||
* @param securitySchemes The security schemes declared in the AgentCard
|
||||
* @returns Validation result with diff if invalid
|
||||
*/
|
||||
static validateAuthConfig(
|
||||
authConfig: A2AAuthConfig | undefined,
|
||||
securitySchemes: Record<string, SecurityScheme> | undefined,
|
||||
): AuthValidationResult {
|
||||
// If no security schemes required, any config is valid
|
||||
if (!securitySchemes || Object.keys(securitySchemes).length === 0) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const requiredSchemes = Object.keys(securitySchemes);
|
||||
|
||||
// If auth is required but none configured
|
||||
if (!authConfig) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -106,6 +155,7 @@ export class A2AAuthProviderFactory {
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the configured type matches any of the required schemes
|
||||
const matchResult = A2AAuthProviderFactory.findMatchingScheme(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
@@ -145,6 +195,7 @@ export class A2AAuthProviderFactory {
|
||||
|
||||
case 'http':
|
||||
if (authConfig.type === 'http') {
|
||||
// Check if the scheme matches (Bearer, Basic, etc.)
|
||||
if (
|
||||
authConfig.scheme.toLowerCase() === scheme.scheme.toLowerCase()
|
||||
) {
|
||||
@@ -157,6 +208,7 @@ export class A2AAuthProviderFactory {
|
||||
authConfig.type === 'google-credentials' &&
|
||||
scheme.scheme.toLowerCase() === 'bearer'
|
||||
) {
|
||||
// Google credentials can provide Bearer tokens
|
||||
return { matched: true, missingConfig: [] };
|
||||
} else {
|
||||
missingConfig.push(
|
||||
@@ -178,6 +230,13 @@ export class A2AAuthProviderFactory {
|
||||
if (authConfig.type === 'openIdConnect') {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
// Google credentials with target_audience can work as OIDC
|
||||
if (
|
||||
authConfig.type === 'google-credentials' &&
|
||||
authConfig.target_audience
|
||||
) {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires OpenID Connect authentication`,
|
||||
);
|
||||
@@ -201,7 +260,9 @@ export class A2AAuthProviderFactory {
|
||||
return { matched: false, missingConfig };
|
||||
}
|
||||
|
||||
/** Get human-readable description of required auth for error messages. */
|
||||
/**
|
||||
* Get a human-readable description of required auth for an AgentCard.
|
||||
*/
|
||||
static describeRequiredAuth(
|
||||
securitySchemes: Record<string, SecurityScheme>,
|
||||
): string {
|
||||
@@ -228,7 +289,6 @@ export class A2AAuthProviderFactory {
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = scheme;
|
||||
// This ensures TypeScript errors if a new SecurityScheme type is added
|
||||
descriptions.push(
|
||||
`Unknown (${name}): ${(_exhaustive as SecurityScheme).type}`,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
A2AAuthProvider,
|
||||
A2AAuthProviderType,
|
||||
A2AAuthConfig,
|
||||
GoogleCredentialsAuthConfig,
|
||||
ApiKeyAuthConfig,
|
||||
HttpAuthConfig,
|
||||
OAuth2AuthConfig,
|
||||
OpenIdConnectAuthConfig,
|
||||
BaseAuthConfig,
|
||||
AuthConfigDiff,
|
||||
AuthValidationResult,
|
||||
AuthenticationHandler,
|
||||
HttpHeaders,
|
||||
} from './types.js';
|
||||
|
||||
// Base class
|
||||
export { BaseA2AAuthProvider } from './base-provider.js';
|
||||
|
||||
// Factory
|
||||
export {
|
||||
A2AAuthProviderFactory,
|
||||
type CreateAuthProviderOptions,
|
||||
} from './factory.js';
|
||||
|
||||
// Providers
|
||||
export { ApiKeyAuthProvider } from './api-key-provider.js';
|
||||
export { HttpAuthProvider } from './http-auth-provider.js';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
resolveAuthValue,
|
||||
needsResolution,
|
||||
maskSensitiveValue,
|
||||
} from './value-resolver.js';
|
||||
@@ -4,14 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AuthenticationHandler, HttpHeaders } from '@a2a-js/sdk/client';
|
||||
|
||||
/**
|
||||
* Client-side auth configuration for A2A remote agents.
|
||||
* Corresponds to server-side SecurityScheme types from @a2a-js/sdk.
|
||||
* @see https://a2a-protocol.org/latest/specification/#451-securityscheme
|
||||
* Authentication provider types supported for A2A remote agents.
|
||||
* These align with the SecurityScheme types from the A2A specification.
|
||||
*/
|
||||
|
||||
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||
|
||||
export type A2AAuthProviderType =
|
||||
| 'google-credentials'
|
||||
| 'apiKey'
|
||||
@@ -19,51 +17,69 @@ export type A2AAuthProviderType =
|
||||
| 'oauth2'
|
||||
| 'openIdConnect';
|
||||
|
||||
/**
|
||||
* Extended authentication handler interface for A2A remote agents.
|
||||
* Extends the base AuthenticationHandler from the A2A SDK with
|
||||
* lifecycle management methods.
|
||||
*/
|
||||
export interface A2AAuthProvider extends AuthenticationHandler {
|
||||
readonly type: A2AAuthProviderType;
|
||||
initialize?(): Promise<void>;
|
||||
dispose?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base configuration shared by all auth types.
|
||||
*/
|
||||
export interface BaseAuthConfig {
|
||||
/**
|
||||
* If true, use this auth configuration to fetch the AgentCard.
|
||||
* Required when the AgentCard endpoint itself requires authentication.
|
||||
*/
|
||||
agent_card_requires_auth?: boolean;
|
||||
}
|
||||
|
||||
/** Client config for google-credentials (not in A2A spec, Gemini-specific). */
|
||||
/**
|
||||
* Configuration for Google Application Default Credentials (ADC).
|
||||
*/
|
||||
export interface GoogleCredentialsAuthConfig extends BaseAuthConfig {
|
||||
type: 'google-credentials';
|
||||
/** OAuth scopes to request. */
|
||||
scopes?: string[];
|
||||
/** Target audience for ID token requests (e.g., Cloud Run URL). */
|
||||
target_audience?: string;
|
||||
}
|
||||
|
||||
/** Client config corresponding to APIKeySecurityScheme. */
|
||||
/**
|
||||
* Configuration for API Key authentication.
|
||||
*/
|
||||
export interface ApiKeyAuthConfig extends BaseAuthConfig {
|
||||
type: 'apiKey';
|
||||
/** The secret. Supports $ENV_VAR, !command, or literal. */
|
||||
/** The API key. Supports $ENV_VAR, !command, or literal value. */
|
||||
key: string;
|
||||
/** Defaults to server's SecurityScheme.in value. */
|
||||
location?: 'header' | 'query' | 'cookie';
|
||||
/** Defaults to server's SecurityScheme.name value. */
|
||||
/** Where to include the key. @default 'header' */
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/** Header/param/cookie name. @default 'X-API-Key' for header */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Client config corresponding to HTTPAuthSecurityScheme. */
|
||||
export type HttpAuthConfig = BaseAuthConfig & {
|
||||
/**
|
||||
* Configuration for HTTP authentication (Bearer or Basic).
|
||||
*/
|
||||
export interface HttpAuthConfig extends BaseAuthConfig {
|
||||
type: 'http';
|
||||
} & (
|
||||
| {
|
||||
scheme: 'Bearer';
|
||||
/** For Bearer. Supports $ENV_VAR, !command, or literal. */
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
scheme: 'Basic';
|
||||
/** For Basic. Supports $ENV_VAR, !command, or literal. */
|
||||
username: string;
|
||||
/** For Basic. Supports $ENV_VAR, !command, or literal. */
|
||||
password: string;
|
||||
}
|
||||
);
|
||||
scheme: 'Bearer' | 'Basic';
|
||||
/** Bearer token. Supports $ENV_VAR, !command, or literal. */
|
||||
token?: string;
|
||||
/** Basic auth username. Supports $ENV_VAR and !command. */
|
||||
username?: string;
|
||||
/** Basic auth password. Supports $ENV_VAR and !command. */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/** Client config corresponding to OAuth2SecurityScheme. */
|
||||
/**
|
||||
* Configuration for OAuth 2.0 authentication.
|
||||
*/
|
||||
export interface OAuth2AuthConfig extends BaseAuthConfig {
|
||||
type: 'oauth2';
|
||||
client_id?: string;
|
||||
@@ -71,9 +87,12 @@ export interface OAuth2AuthConfig extends BaseAuthConfig {
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
/** Client config corresponding to OpenIdConnectSecurityScheme. */
|
||||
/**
|
||||
* Configuration for OpenID Connect authentication.
|
||||
*/
|
||||
export interface OpenIdConnectAuthConfig extends BaseAuthConfig {
|
||||
type: 'openIdConnect';
|
||||
/** OIDC issuer URL for discovery. */
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
@@ -81,6 +100,9 @@ export interface OpenIdConnectAuthConfig extends BaseAuthConfig {
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all supported A2A authentication configurations.
|
||||
*/
|
||||
export type A2AAuthConfig =
|
||||
| GoogleCredentialsAuthConfig
|
||||
| ApiKeyAuthConfig
|
||||
@@ -88,13 +110,21 @@ export type A2AAuthConfig =
|
||||
| OAuth2AuthConfig
|
||||
| OpenIdConnectAuthConfig;
|
||||
|
||||
/**
|
||||
* Describes a mismatch between configured auth and AgentCard requirements.
|
||||
*/
|
||||
export interface AuthConfigDiff {
|
||||
requiredSchemes: string[];
|
||||
configuredType?: A2AAuthProviderType;
|
||||
missingConfig: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of validating auth configuration against AgentCard requirements.
|
||||
*/
|
||||
export interface AuthValidationResult {
|
||||
valid: boolean;
|
||||
diff?: AuthConfigDiff;
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export interface RemoteAgentDefinition<
|
||||
> extends BaseAgentDefinition<TOutput> {
|
||||
kind: 'remote';
|
||||
agentCardUrl: string;
|
||||
|
||||
/**
|
||||
* Optional authentication configuration for the remote agent.
|
||||
* If not specified, the agent will try to use defaults based on the AgentCard's
|
||||
|
||||
Reference in New Issue
Block a user