Skip to content
49 changes: 44 additions & 5 deletions packages/a2a-server/src/http/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import express from 'express';
import express, { type Request } from 'express';

import type { AgentCard, Message } from '@a2a-js/sdk';
import {
Expand All @@ -13,8 +13,9 @@ import {
InMemoryTaskStore,
DefaultExecutionEventBus,
type AgentExecutionEvent,
UnauthenticatedUser,
} from '@a2a-js/sdk/server';
import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type { AgentSettings } from '../types.js';
Expand Down Expand Up @@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = {
pushNotifications: false,
stateTransitionHistory: true,
},
securitySchemes: undefined,
security: undefined,
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
basicAuth: {
type: 'http',
scheme: 'basic',
},
},
security: [{ bearerAuth: [] }, { basicAuth: [] }],
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
Expand All @@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
}

const customUserBuilder: UserBuilder = async (req: Request) => {
const auth = req.headers['authorization'];
if (auth) {
const scheme = auth.split(' ')[0];
logger.info(
`[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
);
}
if (!auth) return new UnauthenticatedUser();

// 1. Bearer Auth
if (auth.startsWith('Bearer ')) {
const token = auth.substring(7);
if (token === 'valid-token') {
return { userName: 'bearer-user', isAuthenticated: true };
}
}

// 2. Basic Auth
if (auth.startsWith('Basic ')) {
const credentials = Buffer.from(auth.substring(6), 'base64').toString();
if (credentials === 'admin:password') {
return { userName: 'basic-user', isAuthenticated: true };
}
}

return new UnauthenticatedUser();
};

async function handleExecuteCommand(
req: express.Request,
res: express.Response,
Expand Down Expand Up @@ -204,7 +243,7 @@ export async function createApp() {
requestStorage.run({ req }, next);
});

const appBuilder = new A2AExpressApp(requestHandler);
const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
expressApp = appBuilder.setupRoutes(expressApp, '');
expressApp.use(express.json());

Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/agents/agentLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,54 @@ auth:
});
});

it('should parse remote agent with Digest via raw value', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: digest-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: Digest
value: username="admin", response="abc123"
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'digest-agent',
auth: {
type: 'http',
scheme: 'Digest',
value: 'username="admin", response="abc123"',
},
});
});

it('should parse remote agent with generic raw auth value', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: raw-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: CustomScheme
value: raw-token-value
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'raw-agent',
auth: {
type: 'http',
scheme: 'CustomScheme',
value: 'raw-token-value',
},
});
});

it('should throw error for Bearer auth without token', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/agents/agentLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ interface FrontmatterAuthConfig {
key?: string;
name?: string;
// HTTP
scheme?: 'Bearer' | 'Basic';
scheme?: string;
token?: string;
username?: string;
password?: string;
value?: string;
}

interface FrontmatterRemoteAgentDefinition
Expand Down Expand Up @@ -139,16 +140,21 @@ const apiKeyAuthSchema = z.object({
const httpAuthSchema = z.object({
...baseAuthFields,
type: z.literal('http'),
scheme: z.enum(['Bearer', 'Basic']),
scheme: z.string().min(1),
token: z.string().min(1).optional(),
username: z.string().min(1).optional(),
password: z.string().min(1).optional(),
value: z.string().min(1).optional(),
});

const authConfigSchema = z
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema])
.superRefine((data, ctx) => {
if (data.type === 'http') {
if (data.value) {
// Raw mode - only scheme and value are needed
return;
}
if (data.scheme === 'Bearer' && !data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
Expand Down Expand Up @@ -348,6 +354,14 @@ function convertFrontmatterAuthToConfig(
'Internal error: HTTP scheme missing after validation.',
);
}
if (frontmatter.value) {
return {
...base,
type: 'http',
scheme: frontmatter.scheme,
value: frontmatter.value,
};
}
switch (frontmatter.scheme) {
case 'Bearer':
if (!frontmatter.token) {
Expand Down Expand Up @@ -375,8 +389,8 @@ function convertFrontmatterAuthToConfig(
password: frontmatter.password,
};
default: {
const exhaustive: never = frontmatter.scheme;
throw new Error(`Unknown HTTP scheme: ${exhaustive}`);
// Other IANA schemes without a value should not reach here after validation
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/agents/auth-provider/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
AuthValidationResult,
} from './types.js';
import { ApiKeyAuthProvider } from './api-key-provider.js';
import { HttpAuthProvider } from './http-provider.js';

export interface CreateAuthProviderOptions {
/** Required for OAuth/OIDC token storage. */
Expand Down Expand Up @@ -50,9 +51,11 @@ export class A2AAuthProviderFactory {
return provider;
}

case 'http':
// TODO: Implement
throw new Error('http auth provider not yet implemented');
case 'http': {
const provider = new HttpAuthProvider(authConfig);
await provider.initialize();
return provider;
}

case 'oauth2':
// TODO: Implement
Expand Down
133 changes: 133 additions & 0 deletions packages/core/src/agents/auth-provider/http-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpAuthProvider } from './http-provider.js';

describe('HttpAuthProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('Bearer Authentication', () => {
it('should provide Bearer token header', async () => {
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'test-token',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();

const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'Bearer test-token' });
});

it('should resolve token from environment variable', async () => {
process.env['TEST_TOKEN'] = 'env-token';
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: '$TEST_TOKEN',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();

const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'Bearer env-token' });
delete process.env['TEST_TOKEN'];
});
});

describe('Basic Authentication', () => {
it('should provide Basic auth header', async () => {
const config = {
type: 'http' as const,
scheme: 'Basic' as const,
username: 'user',
password: 'password',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();

const headers = await provider.headers();
const expected = Buffer.from('user:password').toString('base64');
expect(headers).toEqual({ Authorization: `Basic ${expected}` });
});
});

describe('Generic/Raw Authentication', () => {
it('should provide custom scheme with raw value', async () => {
const config = {
type: 'http' as const,
scheme: 'CustomScheme',
value: 'raw-value-here',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();

const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'CustomScheme raw-value-here' });
});

it('should support Digest via raw value', async () => {
const config = {
type: 'http' as const,
scheme: 'Digest',
value: 'username="foo", response="bar"',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();

const headers = await provider.headers();
expect(headers).toEqual({
Authorization: 'Digest username="foo", response="bar"',
});
});
});

describe('Retry logic', () => {
it('should re-initialize on 401 for Bearer', async () => {
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: '$DYNAMIC_TOKEN',
};
process.env['DYNAMIC_TOKEN'] = 'first';
const provider = new HttpAuthProvider(config);
await provider.initialize();

process.env['DYNAMIC_TOKEN'] = 'second';
const mockResponse = { status: 401 } as Response;
const retryHeaders = await provider.shouldRetryWithHeaders(
{},
mockResponse,
);

expect(retryHeaders).toEqual({ Authorization: 'Bearer second' });
delete process.env['DYNAMIC_TOKEN'];
});

it('should stop after max retries', async () => {
const config = {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'token',
};
const provider = new HttpAuthProvider(config);
await provider.initialize();

const mockResponse = { status: 401 } as Response;

// MAX_AUTH_RETRIES is 2
await provider.shouldRetryWithHeaders({}, mockResponse);
await provider.shouldRetryWithHeaders({}, mockResponse);
const third = await provider.shouldRetryWithHeaders({}, mockResponse);

expect(third).toBeUndefined();
});
});
});
Loading
Loading