feat(agents): add API Key and HTTP auth providers with value resolution

This commit is contained in:
Adam Weidman
2026-01-29 15:10:16 -05:00
parent a58eff8c91
commit 7ef86a3aad
9 changed files with 1188 additions and 2 deletions

View File

@@ -361,4 +361,171 @@ Hidden`,
expect(result.errors).toHaveLength(1);
});
});
describe('remote agent auth configuration', () => {
it('should parse remote agent with apiKey auth', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: api-key-agent
agent_card_url: https://example.com/card
auth:
type: apiKey
key: $MY_API_KEY
in: header
name: X-Custom-Key
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'api-key-agent',
auth: {
type: 'apiKey',
key: '$MY_API_KEY',
in: 'header',
name: 'X-Custom-Key',
},
});
});
it('should parse remote agent with http Bearer auth', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: bearer-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: Bearer
token: $BEARER_TOKEN
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'bearer-agent',
auth: {
type: 'http',
scheme: 'Bearer',
token: '$BEARER_TOKEN',
},
});
});
it('should parse remote agent with http Basic auth', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: basic-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: Basic
username: $AUTH_USER
password: $AUTH_PASS
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'basic-agent',
auth: {
type: 'http',
scheme: 'Basic',
username: '$AUTH_USER',
password: '$AUTH_PASS',
},
});
});
it('should throw error for Bearer auth without token', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-bearer
agent_card_url: https://example.com/card
auth:
type: http
scheme: Bearer
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/Bearer scheme requires "token"/,
);
});
it('should throw error for Basic auth without credentials', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-basic
agent_card_url: https://example.com/card
auth:
type: http
scheme: Basic
username: user
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/Basic scheme requires "username" and "password"/,
);
});
it('should throw error for apiKey auth without key', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-apikey
agent_card_url: https://example.com/card
auth:
type: apiKey
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/auth\.key.*Required/,
);
});
it('should convert auth config in markdownToAgentDefinition', () => {
const markdown = {
kind: 'remote' as const,
name: 'auth-agent',
agent_card_url: 'https://example.com/card',
auth: {
type: 'apiKey' as const,
key: '$API_KEY',
in: 'header' as const,
},
};
const result = markdownToAgentDefinition(markdown);
expect(result).toMatchObject({
kind: 'remote',
name: 'auth-agent',
auth: {
type: 'apiKey',
key: '$API_KEY',
in: 'header',
},
});
});
it('should parse auth with agent_card_requires_auth flag', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: protected-card-agent
agent_card_url: https://example.com/card
auth:
type: apiKey
key: $MY_API_KEY
agent_card_requires_auth: true
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result[0]).toMatchObject({
auth: {
type: 'apiKey',
agent_card_requires_auth: true,
},
});
});
});
});

View File

@@ -11,6 +11,7 @@ import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { z } from 'zod';
import type { AgentDefinition } from './types.js';
import type { A2AAuthConfig } from './auth-provider/types.js';
import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
@@ -35,11 +36,29 @@ interface FrontmatterLocalAgentDefinition
timeout_mins?: number;
}
/**
* Authentication configuration for remote agents in frontmatter format.
*/
interface FrontmatterAuthConfig {
type: 'apiKey' | 'http';
agent_card_requires_auth?: boolean;
// API Key
key?: string;
in?: 'header' | 'query' | 'cookie';
name?: string;
// HTTP
scheme?: 'Bearer' | 'Basic';
token?: string;
username?: string;
password?: string;
}
interface FrontmatterRemoteAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'remote';
description?: string;
agent_card_url: string;
auth?: FrontmatterAuthConfig;
}
type FrontmatterAgentDefinition =
@@ -91,6 +110,74 @@ const localAgentSchema = z
})
.strict();
// ============================================================================
// Auth configuration schemas
// ============================================================================
/**
* Base fields shared by all auth configs.
*/
const baseAuthFields = {
agent_card_requires_auth: z.boolean().optional(),
};
/**
* API Key auth schema.
* Supports sending key in header, query parameter, or cookie.
*/
const apiKeyAuthSchema = z.object({
...baseAuthFields,
type: z.literal('apiKey'),
key: z.string().min(1, 'API key is required'),
in: z.enum(['header', 'query', 'cookie']).optional(),
name: z.string().optional(),
});
/**
* HTTP auth schema (Bearer or Basic).
* Note: Validation for scheme-specific fields is applied in authConfigSchema
* since discriminatedUnion doesn't support refined schemas directly.
*/
const httpAuthSchemaBase = z.object({
...baseAuthFields,
type: z.literal('http'),
scheme: z.enum(['Bearer', 'Basic']),
token: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
});
/**
* Combined auth schema - discriminated union of all auth types.
* Note: We use the base schema for discriminatedUnion, then apply refinements
* via a transform since discriminatedUnion doesn't support refined schemas.
*/
const authConfigSchema = z
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase])
.superRefine((data, ctx) => {
// Apply HTTP auth validation after union parsing
if (data.type === 'http') {
if (data.scheme === 'Bearer' && !data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
if (data.scheme === 'Basic' && (!data.username || !data.password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Basic scheme requires "username" and "password"',
path: data.username ? ['password'] : ['username'],
});
}
}
});
// ============================================================================
// Agent schemas
// ============================================================================
const remoteAgentSchema = z
.object({
kind: z.literal('remote').optional().default('remote'),
@@ -98,6 +185,7 @@ const remoteAgentSchema = z
description: z.string().optional(),
display_name: z.string().optional(),
agent_card_url: z.string().url(),
auth: authConfigSchema.optional(),
})
.strict();
@@ -233,6 +321,50 @@ export async function parseAgentMarkdown(
return [agentDef];
}
/**
* Converts frontmatter auth config to the internal A2AAuthConfig type.
* This handles the mapping from snake_case YAML to the internal type structure.
*/
function convertFrontmatterAuthToConfig(
frontmatter: FrontmatterAuthConfig,
): A2AAuthConfig {
const base = {
agent_card_requires_auth: frontmatter.agent_card_requires_auth,
};
switch (frontmatter.type) {
case 'apiKey':
if (!frontmatter.key) {
throw new Error('API key auth requires "key" field');
}
return {
...base,
type: 'apiKey',
key: frontmatter.key,
in: frontmatter.in,
name: frontmatter.name,
};
case 'http':
if (!frontmatter.scheme) {
throw new Error('HTTP auth requires "scheme" field');
}
return {
...base,
type: 'http',
scheme: frontmatter.scheme,
token: frontmatter.token,
username: frontmatter.username,
password: frontmatter.password,
};
default: {
const exhaustive: never = frontmatter.type;
throw new Error(`Unknown auth type: ${exhaustive}`);
}
}
}
/**
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
*
@@ -265,6 +397,9 @@ export function markdownToAgentDefinition(
description: markdown.description || '(Loading description...)',
displayName: markdown.display_name,
agentCardUrl: markdown.agent_card_url,
auth: markdown.auth
? convertFrontmatterAuthToConfig(markdown.auth)
: undefined,
inputConfig,
metadata,
};

View File

@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ApiKeyAuthProvider } from './api-key-provider.js';
describe('ApiKeyAuthProvider', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('initialization', () => {
it('should initialize with literal API key', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'my-api-key',
});
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({ 'X-API-Key': 'my-api-key' });
});
it('should resolve API key from environment variable', async () => {
process.env['TEST_API_KEY'] = 'env-api-key';
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: '$TEST_API_KEY',
});
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({ 'X-API-Key': 'env-api-key' });
});
it('should throw if environment variable is not set', async () => {
delete process.env['MISSING_KEY'];
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: '$MISSING_KEY',
});
await expect(provider.initialize()).rejects.toThrow(
"Environment variable 'MISSING_KEY' is not set",
);
});
});
describe('headers', () => {
it('should throw if not initialized', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'test-key',
});
await expect(provider.headers()).rejects.toThrow('not initialized');
});
it('should use custom header name', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'my-key',
name: 'X-Custom-Auth',
});
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({ 'X-Custom-Auth': 'my-key' });
});
it('should use default header name X-API-Key for header location', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'my-key',
in: 'header',
});
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({ 'X-API-Key': 'my-key' });
});
});
describe('query and cookie locations', () => {
it('should return empty headers for query location', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'my-key',
in: 'query',
});
await provider.initialize();
const headers = await provider.headers();
expect(headers).toEqual({});
});
it('should expose key for query via getKeyForQuery', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'my-key',
in: 'query',
name: 'apikey',
});
await provider.initialize();
const queryKey = provider.getKeyForQuery();
expect(queryKey).toEqual({ name: 'apikey', value: 'my-key' });
});
it('should return undefined from getKeyForQuery when location is header', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'my-key',
in: 'header',
});
await provider.initialize();
expect(provider.getKeyForQuery()).toBeUndefined();
});
it('should expose key for cookie via getKeyForCookie', async () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'my-key',
in: 'cookie',
name: 'auth_cookie',
});
await provider.initialize();
const cookieKey = provider.getKeyForCookie();
expect(cookieKey).toEqual({ name: 'auth_cookie', value: 'my-key' });
});
});
describe('type property', () => {
it('should have type apiKey', () => {
const provider = new ApiKeyAuthProvider({
type: 'apiKey',
key: 'test',
});
expect(provider.type).toBe('apiKey');
});
});
});

View File

@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { BaseA2AAuthProvider } from './base-provider.js';
import type { ApiKeyAuthConfig, HttpHeaders } from './types.js';
import { resolveAuthValue, needsResolution } from './value-resolver.js';
import { debugLogger } from '../../utils/debugLogger.js';
/**
* Default header name for API Key authentication.
*/
const DEFAULT_HEADER_NAME = 'X-API-Key';
/**
* Default query/cookie parameter name for API Key authentication.
*/
const DEFAULT_PARAM_NAME = 'api_key';
/**
* Authentication provider for API Key authentication.
*
* Supports sending the API key in:
* - HTTP headers (default)
* - Query parameters
* - Cookies
*
* The API key value can be:
* - A literal string
* - An environment variable reference ($ENV_VAR)
* - A shell command (!command)
*/
export class ApiKeyAuthProvider extends BaseA2AAuthProvider {
readonly type = 'apiKey' as const;
private resolvedKey: string | undefined;
private readonly keyLocation: 'header' | 'query' | 'cookie';
private readonly keyName: string;
constructor(private readonly config: ApiKeyAuthConfig) {
super();
this.keyLocation = config.in ?? 'header';
this.keyName =
config.name ??
(this.keyLocation === 'header'
? DEFAULT_HEADER_NAME
: DEFAULT_PARAM_NAME);
}
/**
* Initialize the provider by resolving the API key value.
*/
override async initialize(): Promise<void> {
// Only resolve dynamic values once during initialization
// to avoid repeated command execution
if (needsResolution(this.config.key)) {
this.resolvedKey = await resolveAuthValue(this.config.key);
debugLogger.debug(
`[ApiKeyAuthProvider] Resolved API key from: ${this.config.key.startsWith('$') ? 'env var' : 'command'}`,
);
} else {
this.resolvedKey = this.config.key;
}
}
/**
* Get the HTTP headers to include in requests.
*
* For API keys in headers, this returns the header directly.
* For query/cookie locations, this returns an empty object
* (the query/cookie handling would need to be done at a different layer).
*/
async headers(): Promise<HttpHeaders> {
if (!this.resolvedKey) {
throw new Error(
'ApiKeyAuthProvider not initialized. Call initialize() first.',
);
}
if (this.keyLocation === 'header') {
return { [this.keyName]: this.resolvedKey };
}
// For query and cookie, we can't set headers directly.
// The SDK's transport layer would need to handle these.
// For now, we log a warning and return empty headers.
if (this.keyLocation === 'query') {
debugLogger.warn(
`[ApiKeyAuthProvider] API key location 'query' is not fully supported. ` +
`Consider using 'header' instead.`,
);
} else if (this.keyLocation === 'cookie') {
debugLogger.warn(
`[ApiKeyAuthProvider] API key location 'cookie' is not fully supported. ` +
`Consider using 'header' instead.`,
);
}
return {};
}
/**
* Get the API key value for use in query parameters.
* This is exposed for transport layers that need to add query params.
*/
getKeyForQuery(): { name: string; value: string } | undefined {
if (this.keyLocation !== 'query' || !this.resolvedKey) {
return undefined;
}
return { name: this.keyName, value: this.resolvedKey };
}
/**
* Get the API key value for use in cookies.
* This is exposed for transport layers that need to set cookies.
*/
getKeyForCookie(): { name: string; value: string } | undefined {
if (this.keyLocation !== 'cookie' || !this.resolvedKey) {
return undefined;
}
return { name: this.keyName, value: this.resolvedKey };
}
}

View File

@@ -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');
});
});
});

View File

@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { BaseA2AAuthProvider } from './base-provider.js';
import type { HttpAuthConfig, HttpHeaders } from './types.js';
import { resolveAuthValue, needsResolution } from './value-resolver.js';
import { debugLogger } from '../../utils/debugLogger.js';
/**
* Authentication provider for HTTP authentication (Bearer and Basic).
*
* Supports:
* - Bearer token authentication
* - Basic authentication (username/password)
*
* Credential values can be:
* - Literal strings
* - Environment variable references ($ENV_VAR)
* - Shell commands (!command)
*/
export class HttpAuthProvider extends BaseA2AAuthProvider {
readonly type = 'http' as const;
private resolvedCredentials: {
token?: string;
username?: string;
password?: string;
} = {};
constructor(private readonly config: HttpAuthConfig) {
super();
}
/**
* Initialize the provider by resolving credential values.
*/
override async initialize(): Promise<void> {
if (this.config.scheme === 'Bearer') {
if (!this.config.token) {
throw new Error(
'HTTP Bearer authentication requires a token. ' +
'Add "token" to your auth configuration.',
);
}
if (needsResolution(this.config.token)) {
this.resolvedCredentials.token = await resolveAuthValue(
this.config.token,
);
debugLogger.debug(
`[HttpAuthProvider] Resolved Bearer token from: ${this.config.token.startsWith('$') ? 'env var' : 'command'}`,
);
} else {
this.resolvedCredentials.token = this.config.token;
}
} else if (this.config.scheme === 'Basic') {
if (!this.config.username || !this.config.password) {
throw new Error(
'HTTP Basic authentication requires username and password. ' +
'Add "username" and "password" to your auth configuration.',
);
}
// Resolve username
if (needsResolution(this.config.username)) {
this.resolvedCredentials.username = await resolveAuthValue(
this.config.username,
);
} else {
this.resolvedCredentials.username = this.config.username;
}
// Resolve password
if (needsResolution(this.config.password)) {
this.resolvedCredentials.password = await resolveAuthValue(
this.config.password,
);
} else {
this.resolvedCredentials.password = this.config.password;
}
debugLogger.debug('[HttpAuthProvider] Resolved Basic auth credentials');
}
}
/**
* Get the HTTP headers to include in requests.
*/
async headers(): Promise<HttpHeaders> {
if (this.config.scheme === 'Bearer') {
if (!this.resolvedCredentials.token) {
throw new Error(
'HttpAuthProvider not initialized. Call initialize() first.',
);
}
return { Authorization: `Bearer ${this.resolvedCredentials.token}` };
}
if (this.config.scheme === 'Basic') {
const { username, password } = this.resolvedCredentials;
if (!username || !password) {
throw new Error(
'HttpAuthProvider not initialized. Call initialize() first.',
);
}
// Base64 encode the credentials
const credentials = `${username}:${password}`;
const encoded = Buffer.from(credentials, 'utf-8').toString('base64');
return { Authorization: `Basic ${encoded}` };
}
throw new Error(`Unsupported HTTP auth scheme: ${this.config.scheme}`);
}
/**
* For Bearer tokens that may expire, re-resolve the token on retry.
* This is useful when using shell commands that fetch fresh tokens.
*/
override async shouldRetryWithHeaders(
_req: RequestInit,
res: Response,
): Promise<HttpHeaders | undefined> {
if (res.status !== 401 && res.status !== 403) {
return undefined;
}
// For Bearer tokens from commands, re-resolve to get a fresh token
if (
this.config.scheme === 'Bearer' &&
this.config.token &&
this.config.token.startsWith('!')
) {
debugLogger.debug(
'[HttpAuthProvider] Re-resolving Bearer token after auth failure',
);
this.resolvedCredentials.token = await resolveAuthValue(
this.config.token,
);
return this.headers();
}
// For other cases, just return the same headers
return this.headers();
}
}

View File

@@ -30,5 +30,13 @@ export {
type CreateAuthProviderOptions,
} from './factory.js';
// Note: Individual providers are lazy-loaded by the factory.
// They will be exported as they are implemented in subsequent PRs.
// Providers
export { ApiKeyAuthProvider } from './api-key-provider.js';
export { HttpAuthProvider } from './http-auth-provider.js';
// Utilities
export {
resolveAuthValue,
needsResolution,
maskSensitiveValue,
} from './value-resolver.js';

View File

@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
resolveAuthValue,
needsResolution,
maskSensitiveValue,
} from './value-resolver.js';
describe('value-resolver', () => {
describe('resolveAuthValue', () => {
describe('environment variables', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should resolve environment variable with $ prefix', async () => {
process.env['TEST_API_KEY'] = 'secret-key-123';
const result = await resolveAuthValue('$TEST_API_KEY');
expect(result).toBe('secret-key-123');
});
it('should throw error for unset environment variable', async () => {
delete process.env['UNSET_VAR'];
await expect(resolveAuthValue('$UNSET_VAR')).rejects.toThrow(
"Environment variable 'UNSET_VAR' is not set or is empty",
);
});
it('should throw error for empty environment variable', async () => {
process.env['EMPTY_VAR'] = '';
await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow(
"Environment variable 'EMPTY_VAR' is not set or is empty",
);
});
});
describe('shell commands', () => {
it('should execute shell command with ! prefix', async () => {
const result = await resolveAuthValue('!echo hello');
expect(result).toBe('hello');
});
it('should trim whitespace from command output', async () => {
const result = await resolveAuthValue('!echo " hello "');
expect(result).toBe('hello');
});
it('should throw error for empty command', async () => {
await expect(resolveAuthValue('!')).rejects.toThrow(
'Empty command in auth value',
);
});
it('should throw error for command that returns empty output', async () => {
// Use printf which is more portable than echo -n
await expect(resolveAuthValue('!printf ""')).rejects.toThrow(
'returned empty output',
);
});
it('should throw error for failed command', async () => {
await expect(
resolveAuthValue('!nonexistent-command-12345'),
).rejects.toThrow(/Command.*failed/);
});
});
describe('literal values', () => {
it('should return literal value as-is', async () => {
const result = await resolveAuthValue('literal-api-key');
expect(result).toBe('literal-api-key');
});
it('should return empty string as-is', async () => {
const result = await resolveAuthValue('');
expect(result).toBe('');
});
it('should not treat values starting with other characters as special', async () => {
const result = await resolveAuthValue('api-key-123');
expect(result).toBe('api-key-123');
});
});
});
describe('needsResolution', () => {
it('should return true for environment variable reference', () => {
expect(needsResolution('$ENV_VAR')).toBe(true);
});
it('should return true for command reference', () => {
expect(needsResolution('!command')).toBe(true);
});
it('should return false for literal value', () => {
expect(needsResolution('literal')).toBe(false);
});
it('should return false for empty string', () => {
expect(needsResolution('')).toBe(false);
});
});
describe('maskSensitiveValue', () => {
it('should mask value longer than 8 characters', () => {
expect(maskSensitiveValue('1234567890')).toBe('12****90');
});
it('should return **** for short values', () => {
expect(maskSensitiveValue('short')).toBe('****');
});
it('should return **** for exactly 8 characters', () => {
expect(maskSensitiveValue('12345678')).toBe('****');
});
it('should return **** for empty string', () => {
expect(maskSensitiveValue('')).toBe('****');
});
});
});

View File

@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import { debugLogger } from '../../utils/debugLogger.js';
/**
* Resolves a value that may be an environment variable reference,
* a shell command, or a literal value.
*
* Supported formats:
* - `$ENV_VAR`: Read from environment variable
* - `!command`: Execute shell command and use output (trimmed)
* - Any other string: Use as literal value
*
* @param value The value to resolve
* @returns The resolved value
* @throws Error if environment variable is not set or command fails
*
* @example
* // Environment variable
* await resolveAuthValue('$MY_API_KEY') // reads process.env.MY_API_KEY
*
* // Shell command
* await resolveAuthValue('!gcloud auth print-access-token') // executes command
*
* // Literal value
* await resolveAuthValue('sk-12345') // returns 'sk-12345'
*/
export async function resolveAuthValue(value: string): Promise<string> {
// Environment variable: $MY_VAR
if (value.startsWith('$')) {
const envVar = value.slice(1);
const resolved = process.env[envVar];
if (resolved === undefined || resolved === '') {
throw new Error(
`Environment variable '${envVar}' is not set or is empty. ` +
`Please set it before using this agent.`,
);
}
debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`);
return resolved;
}
// Shell command: !command arg1 arg2
if (value.startsWith('!')) {
const command = value.slice(1).trim();
if (!command) {
throw new Error('Empty command in auth value. Expected format: !command');
}
debugLogger.debug(`[AuthValueResolver] Executing command for auth value`);
try {
const result = execSync(command, {
encoding: 'utf-8',
timeout: 30000, // 30 second timeout
stdio: ['pipe', 'pipe', 'pipe'], // Capture stdout, stderr
windowsHide: true, // Hide console window on Windows
});
const trimmed = result.trim();
if (!trimmed) {
throw new Error(`Command '${command}' returned empty output`);
}
return trimmed;
} catch (error) {
if (error instanceof Error) {
// Check for timeout
if ('killed' in error && error.killed) {
throw new Error(`Command '${command}' timed out after 30 seconds`);
}
// Check for non-zero exit code
if (
'status' in error &&
typeof error.status === 'number' &&
error.status !== 0
) {
throw new Error(
`Command '${command}' failed with exit code ${error.status}: ${error.message}`,
);
}
throw new Error(
`Failed to execute command '${command}': ${error.message}`,
);
}
throw new Error(
`Failed to execute command '${command}': ${String(error)}`,
);
}
}
// Literal value - return as-is
return value;
}
/**
* Check if a value needs resolution (is an env var or command reference).
* Useful for validation without actually resolving.
*
* @param value The value to check
* @returns true if the value needs resolution
*/
export function needsResolution(value: string): boolean {
return value.startsWith('$') || value.startsWith('!');
}
/**
* Mask a sensitive value for logging purposes.
* Shows the first and last 2 characters with asterisks in between.
*
* @param value The sensitive value to mask
* @returns The masked value
*/
export function maskSensitiveValue(value: string): string {
if (value.length <= 8) {
return '****';
}
return `${value.slice(0, 2)}****${value.slice(-2)}`;
}